diff --git a/.gitea/workflows/lint.yml b/.gitea/workflows/lint.yml index 50229bf189..cd72e27ef3 100644 --- a/.gitea/workflows/lint.yml +++ b/.gitea/workflows/lint.yml @@ -3,11 +3,13 @@ name: lint on: - pull_request +env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + jobs: cppcheck: runs-on: ubuntu-latest - env: - BASE_SHA: ${{ github.event.pull_request.base.sha }} steps: - uses: actions/checkout@v4 @@ -42,3 +44,18 @@ jobs: - name: Check for issues run: ./source/tools/lint/cppcheck/cppcheck.sh --diff ${{ env.BASE_SHA }} + + copyright: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Fetch the base branch, so we can use `git diff` + run: git fetch origin ${{ env.BASE_SHA }} + + - name: Check for issues with copyright + run: ./source/tools/lint/copyright/copyright.sh --from ${{ env.BASE_SHA }} --to ${{ env.HEAD_SHA }} diff --git a/build/arclint/README.md b/build/arclint/README.md index 8b3802e7ff..6db47cea95 100644 --- a/build/arclint/README.md +++ b/build/arclint/README.md @@ -8,7 +8,6 @@ https://secure.phabricator.com/book/phabricator/article/arcanist_lint/ - `text` is configured to detect whitespace issues. - `json` detects JSON syntax errors. -- `licence-year` detects Copyright header years and compares against modification time. - `eslint`, if installed, will run on javascript files. ## Installation diff --git a/build/arclint/pyrolint/__phutil_library_map__.php b/build/arclint/pyrolint/__phutil_library_map__.php index 925ff282ca..0fac3ddaee 100644 --- a/build/arclint/pyrolint/__phutil_library_map__.php +++ b/build/arclint/pyrolint/__phutil_library_map__.php @@ -11,7 +11,6 @@ phutil_register_library_map(array( 'class' => array( 'ESLintLinter' => 'src/ESLintLinter.php', 'JenkinsRenderer' => 'src/JenkinsRenderer.php', - 'LicenceYearLinter' => 'src/LicenceYearLinter.php', ), 'function' => array( 'remove_null' => 'src/JenkinsRenderer.php', @@ -19,6 +18,5 @@ phutil_register_library_map(array( 'xmap' => array( 'ESLintLinter' => 'ArcanistExternalLinter', 'JenkinsRenderer' => 'ArcanistLintRenderer', - 'LicenceYearLinter' => 'ArcanistLinter', ), )); diff --git a/build/arclint/pyrolint/src/LicenceYearLinter.php b/build/arclint/pyrolint/src/LicenceYearLinter.php deleted file mode 100644 index 742ff91b96..0000000000 --- a/build/arclint/pyrolint/src/LicenceYearLinter.php +++ /dev/null @@ -1,78 +0,0 @@ - ArcanistLintSeverity::SEVERITY_ERROR, - ); - } - - public function getLintNameMap() { - return array( - self::BAD_YEAR => pht('Inaccurate Copyright Year'), - ); - } - - public function lintPath($path) { - $txt = $this->getData($path); - - $matches = null; - $preg = preg_match_all( - "/Copyright( \(C\))? (20[0-9]{2}) Wildfire Games/", - $txt, - $matches, - PREG_OFFSET_CAPTURE); - - if (!$preg) { - return; - } - - $year = date("Y", filemtime($path)); - foreach ($matches[2] as $match) { - list($string, $offset) = $match; - if ($string == $year) { - continue; - } - $this->raiseLintAtOffset( - $offset, - self::BAD_YEAR, - pht('Inaccurate Copyright Year'), - $string, - "$year"); - } - } -} diff --git a/source/tools/lint/README.md b/source/tools/lint/README.md index 43c0b931c7..0b47d7f327 100644 --- a/source/tools/lint/README.md +++ b/source/tools/lint/README.md @@ -16,3 +16,7 @@ The format for an error suppression is one of: ### libraries Adding library cfg's for other deps could improve cppchecks ability to find issues. + +## copyright + +A linter for checking copyright dates in file headers are up to date. diff --git a/source/tools/lint/copyright/check_copyright_year.py b/source/tools/lint/copyright/check_copyright_year.py new file mode 100755 index 0000000000..0ad4e74fd1 --- /dev/null +++ b/source/tools/lint/copyright/check_copyright_year.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2025 Wildfire Games. +# This file is part of 0 A.D. +# +# 0 A.D. is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# +# 0 A.D. is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with 0 A.D. If not, see . + +"""pre-commit hook to check for correct copyright year. + +This script checks whether files, which contain a copyright notice, +contain the correct copyright year. When run as a pre-commit hook, +it checks the staged files only. When run on all files +(pre-commit run --all-files), to not generate warnings for all files, +it checks that the copyright year matches the year the file was last +modified. + +This script will only work for text files encoded in UTF-8. All other +files passed to it will be silently ignored. +""" + +import difflib +import re +import subprocess +from argparse import ArgumentDefaultsHelpFormatter, ArgumentError, ArgumentParser +from collections.abc import Sequence +from datetime import UTC, datetime + + +def check_copyright_year( + filenames: list[str], + copyright_regex: re.Pattern, + lines_to_check: int = 100, + show_diff: bool = False, + fix: bool = False, +) -> int: + """Check files for correct copyright year.""" + diff_process = subprocess.run( + ["git", "diff", "--cached", "--name-only"], capture_output=True, check=True + ) + staged_files = diff_process.stdout.decode().split("\n") + current_year = datetime.now(UTC).date().year + is_error = False + + last_commit = subprocess.run( + ["git", "log", "-1", "--pretty=%cI"], capture_output=True, check=True + ) + last_commit_year = datetime.fromisoformat(last_commit.stdout.decode().strip()).year + + for filename in filenames: + with open(filename, encoding="utf8") as f: + if lines_to_check > 0: + data_list = [] + try: + for _ in range(lines_to_check): + data_list.append(next(f)) + except StopIteration: + pass + except UnicodeDecodeError: + continue + data = "".join(data_list) + else: + try: + data = f.read() + except UnicodeDecodeError: + continue + + match = copyright_regex.search(data) + if not match: + continue + + copyright_year = int(match.group(1)) + if filename in staged_files: + if copyright_year == current_year: + continue + # Avoid reporting outdated copyright years when commits were + # done at the end of a year, but the check runs in the + # following year, by not using the current year, but the year + # of the last commit as expected year in that case. + elif copyright_year == last_commit_year: + continue + + expected_year = current_year + + # file to check isn't staged, so we're likely running with + # --all-files. Use committer date of the last commit instead + # as indication what the copyright year should be. + if filename not in staged_files: + last_modified = subprocess.run( + ["git", "log", "-1", "--pretty=%cI", filename], + capture_output=True, + check=True, + ) + last_modified_year = datetime.fromisoformat(last_modified.stdout.decode().strip()).year + if last_modified_year == copyright_year: + continue + # Reporting the last modified year as expected year is + # probably misleading, if it isn't the current year as + # well, as changing the copyright year causes the last + # modification year to be the current year. However, + # reporting the current year as expected year would be + # equally confusing, as the file might not have been + # modified in the current year yet, so the expected year + # wouldn't match the year the file got last modified. + expected_year = last_modified_year + + print(f"{filename}: Copyright year {copyright_year} instead of {expected_year}") + is_error = True + + data_modified = copyright_regex.sub( + lambda x: x.group(0).replace(x.group(1), str(current_year)), data + ) + + if show_diff: + show_diff = difflib.unified_diff(data.split("\n"), data_modified.split("\n"), n=2) + print("\n".join(list(show_diff)[2:]), end="\n\n") + + if fix: + with open(filename, "r+", encoding="utf8") as f: + f.write(data_modified) + + return 1 if is_error else 0 + + +def regex_type(value: str) -> re.Pattern: + """Regex pattern argument type for argparse.""" + try: + return re.compile(value) + except re.error as exc: + raise ArgumentError from exc + + +def main(argv: Sequence[str] | None = None) -> int: + """Parse command line parameters and call checking logic.""" + parser = ArgumentParser( + description="Check files with license header for correct copyright year.", + formatter_class=lambda prog: ArgumentDefaultsHelpFormatter(prog, width=78), + ) + parser.add_argument( + "filenames", + nargs="+", + help="Files to check for the copyright year.", + ) + parser.add_argument( + "--regex", + type=regex_type, + default="(?im)^(?://|/\\*|#) Copyright \\(C?\\) (\\d+) Wildfire Games", + help="The regex to search for copyright notices and to use to fix copyright years. Must " + "contain a single capture group with the copyright year.", + ) + parser.add_argument( + "--diff", + action="store_true", + help="Show differences of actual and desired copyright years", + ) + parser.add_argument( + "--fix", + action="store_true", + help="Automatically fix outdated coypyright years.", + ) + parser.add_argument( + "--lines-to-check", + type=int, + default=100, + help="Number of lines to check to find a copyright notice. Set to 0 or a negative " + "value to read whole files.", + ) + args = parser.parse_args(argv) + return check_copyright_year( + args.filenames, args.regex, args.lines_to_check, args.diff, args.fix + ) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/source/tools/lint/copyright/copyright.sh b/source/tools/lint/copyright/copyright.sh new file mode 100755 index 0000000000..bdd89820ba --- /dev/null +++ b/source/tools/lint/copyright/copyright.sh @@ -0,0 +1,42 @@ +#!/bin/sh +set -e + +cd "$(dirname "$0")/../../../.." + +while [ "$#" -gt 0 ]; do + case "$1" in + --from) + from_commitish=$2 + shift + ;; + --to) + to_commitish=$2 + shift + ;; + -j*) ;; + *) + printf "Unknown option: %s\n\n" "$1" + exit 1 + ;; + esac + shift +done + +if [ -n "${from_commitish}" ]; then + if [ -n "${to_commitish}" ]; then + diff="${from_commitish}..${to_commitish}" + else + diff="${from_commitish}..$(git rev-parse HEAD)" + fi + printf "Running copyright linter for range\n%s\n\n" "${diff}" +fi + +if [ -n "${diff}" ]; then + git diff --name-status --no-renames "${diff}" | + awk '!/^D/{$1=""; printf "%s\0", substr($0,2)}' | + xargs -0 -L100 ./source/tools/lint/copyright/check_copyright_year.py +else + echo "WARNING: running copyright linter without base commit, likely not what you want." + find . -type f -print0 | + xargs -0 -L100 ./source/tools/lint/copyright/check_copyright_year.py +fi diff --git a/source/tools/lint/lint.sh b/source/tools/lint/lint.sh index ed2bcb4ae0..7139dbe2df 100755 --- a/source/tools/lint/lint.sh +++ b/source/tools/lint/lint.sh @@ -3,14 +3,40 @@ set -e cd "$(dirname "$0")" +while [ "$#" -gt 0 ]; do + case "$1" in + --diff) + commitish=$2 + args="${args} --diff $2" + shift + ;; + -j*) + args="${args} $1" + ;; + *) + printf "Unknown option: %s\n\n" "$1" + exit 1 + ;; + esac + shift +done + has_errors=false if command -v cppcheck >/dev/null; then - ./cppcheck/cppcheck.sh || has_errors=true + # shellcheck disable=SC2086 + ./cppcheck/cppcheck.sh ${args} || has_errors=true else echo "Cppcheck not found in path" fi +if [ -n "${commitish}" ]; then + # shellcheck disable=SC2086 + copyright/copyright.sh --from ${commitish} || has_errors=true +else + "Skipping copyright linter as no base commit was defined" +fi + if [ ${has_errors} = true ]; then exit 1 fi