name: Code Quality Checks on: push: branches: [ main ] pull_request: branches: [ main ] jobs: linting: name: Code Linting and Quality runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v6 with: python-version: "3.11" - name: Cache pip dependencies uses: actions/cache@v5 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-${{ hashFiles('requirements-dev.txt', 'setup.py') }} restore-keys: | ${{ runner.os }}-pip- - name: Cache npm dependencies uses: actions/cache@v5 with: path: ~/.npm key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }} restore-keys: | ${{ runner.os }}-npm- - name: Install dependencies run: | python -m pip install --upgrade pip pip install pytest>=7.0.0 pytest-cov>=4.0.0 pip install pylint>=2.15.0 black>=22.0.0 isort>=5.10.0 mypy>=0.990 pip install coverage>=6.0.0 codecov>=2.1.0 pip install -e . - name: Cache linting results uses: actions/cache@v5 with: path: | .pylint.d .mypy_cache key: ${{ runner.os }}-lint-${{ hashFiles('nodupe/', 'tests/') }} restore-keys: | ${{ runner.os }}-lint- - name: Run pylint (strict mode) run: | pylint nodupe/ --rcfile=.pylintrc --fail-under=10.0 --enable=all --disable=fixme,line-too-long - name: Run black (strict mode) run: | black --check --diff nodupe/ tests/ - name: Run isort (strict mode) run: | isort --check --diff nodupe/ tests/ - name: Run mypy (strict mode) run: | mypy nodupe/ --ignore-missing-imports --strict --disallow-untyped-defs --disallow-incomplete-defs - name: Run markdownlint run: | npx markdownlint-cli "**/*.md" --config .markdownlint.json - name: Check docstring coverage (100% required) run: | python -c " import os import ast import sys from pathlib import Path def check_docstrings(): missing_docstrings = [] total_classes = 0 total_functions = 0 missing_classes = 0 missing_functions = 0 for root, dirs, files in os.walk('nodupe'): for file in files: if file.endswith('.py') and not file.startswith('__'): filepath = os.path.join(root, file) with open(filepath, 'r') as f: try: tree = ast.parse(f.read()) for node in ast.walk(tree): if isinstance(node, ast.ClassDef): total_classes += 1 if not ast.get_docstring(node): missing_classes += 1 missing_docstrings.append(f'Class {node.name} in {filepath}') elif isinstance(node, ast.FunctionDef) and not node.name.startswith('_'): total_functions += 1 if not ast.get_docstring(node): missing_functions += 1 missing_docstrings.append(f'Function {node.name} in {filepath}') except Exception as e: print(f'Error parsing {filepath}: {e}') pass return missing_docstrings, total_classes, total_functions, missing_classes, missing_functions missing, total_classes, total_functions, missing_classes, missing_functions = check_docstrings() print(f'Docstring Coverage Report:') print(f' Total Classes: {total_classes}') print(f' Total Functions: {total_functions}') print(f' Classes with docstrings: {total_classes - missing_classes}/{total_classes}') print(f' Functions with docstrings: {total_functions - missing_functions}/{total_functions}') if total_classes > 0: class_coverage = ((total_classes - missing_classes) / total_classes) * 100 print(f' Class docstring coverage: {class_coverage:.1f}%') else: class_coverage = 100.0 if total_functions > 0: func_coverage = ((total_functions - missing_functions) / total_functions) * 100 print(f' Function docstring coverage: {func_coverage:.1f}%') else: func_coverage = 100.0 if missing: print(f'\\nāŒ DOCSTRING REQUIREMENT FAILURE: {len(missing)} items missing docstrings') print('Missing docstrings found:') for item in missing: print(f' - {item}') # Calculate overall coverage total_items = total_classes + total_functions missing_items = missing_classes + missing_functions overall_coverage = ((total_items - missing_items) / total_items) * 100 if total_items > 0 else 100.0 print(f'\\nOverall docstring coverage: {overall_coverage:.1f}%') print('āŒ 100% docstring coverage required. CI failed due to missing docstrings.') sys.exit(1) else: print('āœ… All public classes and functions have docstrings!') print('āœ… 100% docstring coverage achieved!') " auto-fix: name: Auto-fix Formatting Issues runs-on: ubuntu-latest needs: linting if: github.event_name == 'pull_request' && github.actor != 'dependabot[bot]' steps: - name: Checkout repository uses: actions/checkout@v4 with: ref: ${{ github.head_ref }} TOKEN_REMOVED: ${{ SECRET_REMOVEDs.GITHUB_TOKEN }} - name: Set up Python uses: actions/setup-python@v6 with: python-version: "3.11" - name: Cache pip dependencies uses: actions/cache@v5 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-${{ hashFiles('requirements-dev.txt') }} restore-keys: | ${{ runner.os }}-pip- - name: Install dependencies run: | python -m pip install --upgrade pip pip install black>=22.0.0 isort>=5.10.0 pep8>=1.7.0 - name: Run black auto-formatting run: | black nodupe/ tests/ echo "BLACK_CHANGES=$(git diff --name-only)" >> $GITHUB_ENV - name: Run isort auto-formatting run: | isort nodupe/ tests/ echo "ISORT_CHANGES=$(git diff --name-only)" >> $GITHUB_ENV - name: Check for formatting changes id: check_changes run: | if [ -n "$BLACK_CHANGES" ] || [ -n "$ISORT_CHANGES" ]; then echo "changes_detected=true" >> $GITHUB_OUTPUT echo "::notice::Auto-formatting applied changes to files" else echo "changes_detected=false" >> $GITHUB_OUTPUT echo "::notice::No formatting changes needed" fi - name: Commit and push auto-formatting changes if: steps.check_changes.outputs.changes_detected == 'true' run: | git config --global user.name "github-actions[bot]" git config --global user.email "github-actions[bot]@users.noreply.github.com" git add . git commit -m "chore: auto-fix formatting issues [skip ci]" git push origin ${{ github.head_ref }} echo "::notice::Auto-formatting changes committed and pushed"