diff --git a/.cursor/rules/file_length.mdc b/.cursor/rules/file_length.mdc new file mode 100644 index 0000000..d01369c --- /dev/null +++ b/.cursor/rules/file_length.mdc @@ -0,0 +1,22 @@ +--- +description: File length limits for Python files +globs: *.py +alwaysApply: false +--- +## File Length Limit + +Python files must not exceed 500 lines. This is enforced by `make file_len_check` (part of `make ci`) and in the linter GitHub Actions workflow. + +This limit exists to prevent AI-generated code from becoming monolithic and forces refactoring into smaller, more modular components. + +If a file genuinely needs to exceed 500 lines, add it to the `exclude` list in `pyproject.toml` under `[tool.file_length]`: + +```toml +[tool.file_length] +max_lines = 500 +exclude = [ + "onboard.py", +] +``` + +Do not add files to the exclude list without a clear justification. diff --git a/.github/workflows/linter_require_ruff.yaml b/.github/workflows/linter_require_ruff.yaml index c9c7f89..e21aa94 100644 --- a/.github/workflows/linter_require_ruff.yaml +++ b/.github/workflows/linter_require_ruff.yaml @@ -38,3 +38,5 @@ jobs: run: uv run python scripts/check_ai_writing.py - name: Run import-linter run: make import_lint + - name: Run file length check + run: uv run python scripts/check_file_length.py diff --git a/Makefile b/Makefile index 6435c37..7fbdeb9 100644 --- a/Makefile +++ b/Makefile @@ -248,7 +248,12 @@ check_deps: install_tools ## Check for unused dependencies @uv run deptry . @echo "$(GREEN)✅Dependency check completed.$(RESET)" -ci: ruff vulture import_lint ty docs_lint lint_links check_deps ## Run all CI checks (ruff, vulture, import_lint, ty, docs_lint, lint_links) +file_len_check: check_uv ## Check Python files don't exceed max line count + @echo "$(YELLOW)🔍Checking file lengths...$(RESET)" + @uv run python scripts/check_file_length.py + @echo "$(GREEN)✅File length check completed.$(RESET)" + +ci: ruff vulture import_lint ty docs_lint lint_links check_deps file_len_check ## Run all CI checks (ruff, vulture, import_lint, ty, docs_lint, lint_links, file_len_check) @echo "$(GREEN)✅CI checks completed.$(RESET)" ######################################################## diff --git a/pyproject.toml b/pyproject.toml index d4a1f37..97525f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -95,6 +95,12 @@ exclude = [ "onboard.py" ] +[tool.file_length] +max_lines = 500 +exclude = [ + "onboard.py", +] + [tool.coverage.run] branch = true source = ["src", "common", "utils"] diff --git a/scripts/check_file_length.py b/scripts/check_file_length.py new file mode 100644 index 0000000..29f024a --- /dev/null +++ b/scripts/check_file_length.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import pathlib +import tomllib + +REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent +ROOT_SKIP_DIRS = { + ".git", + ".venv", + ".uv_cache", + ".uv-cache", + ".uv_tools", + ".uv-tools", + ".cache", + "node_modules", + ".next", +} +RECURSIVE_SKIP_DIRS = {"__pycache__", ".pytest_cache"} + + +def load_config() -> tuple[int, set[str]]: + pyproject = REPO_ROOT / "pyproject.toml" + with open(pyproject, "rb") as f: + data = tomllib.load(f) + cfg = data.get("tool", {}).get("file_length", {}) + max_lines = cfg.get("max_lines", 500) + exclude = set(cfg.get("exclude", [])) + return max_lines, exclude + + +def main() -> int: + max_lines, exclude = load_config() + violations: list[tuple[pathlib.Path, int]] = [] + + for path in REPO_ROOT.rglob("*.py"): + rel = path.relative_to(REPO_ROOT) + parts = rel.parts + if parts[0] in ROOT_SKIP_DIRS: + continue + if any(part in RECURSIVE_SKIP_DIRS for part in parts[:-1]): + continue + if rel.as_posix() in exclude: + continue + line_count = len(path.read_text(encoding="utf-8", errors="ignore").splitlines()) + if line_count > max_lines: + violations.append((rel, line_count)) + + if violations: + print(f"File length check failed: {len(violations)} file(s) exceed {max_lines} lines") + for rel_path, count in sorted(violations): + print(f" {rel_path}: {count} lines") + print( + "Refactor large files into smaller modules, " + "or add to [tool.file_length] exclude in pyproject.toml." + ) + return 1 + + print(f"File length check passed (all files <= {max_lines} lines).") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())