#!/usr/bin/env python3 """Unified linter: orchestrates structural checks, dead-code detection, and lint tools.""" from __future__ import annotations import argparse import json import os import sys from pathlib import Path from typing import Any from checks import CheckError, is_excluded, is_excepted, is_lintignored, collect_lintignores from checks.structural import check_file_lines, check_folder_items, check_nested_imports from checks.vulture import run_vulture from checks.eslint import run_eslint from checks.knip import run_knip from checks.endpoints import run_endpoint_check from checks.classes import run_class_check from checks.cycles import run_cycle_check from checks.no_underscore_names import run_underscore_check from checks.p_private import run_p_private_check from checks.ruff import run_ruff from checks.pyright import run_pyright from watchfiles import watch, DefaultFilter SCRIPT_DIR = Path(__file__).resolve().parent CONFIG_FILE = SCRIPT_DIR / "config" / "config.json" def load_config() -> dict[str, Any]: with open(CONFIG_FILE) as f: return json.load(f) def run_checks(root: Path) -> tuple[list[str], list[str], list[str], list[str], list[str], list[str], list[str], list[str], list[str], list[str], list[str]]: config = load_config() enabled: dict[str, bool] = config.get("enabled", {}) rules: dict[str, int] = config["rules"] excludes: list[str] = config["exclude"] exceptions: dict[str, list[str]] = config["exceptions"] extensions: list[str] = config["include_extensions"] ignores = collect_lintignores(root, excludes) max_lines: int = rules["max-file-lines"] max_items: int = rules["max-folder-items"] check_imports: bool = rules.get("no-nested-imports", False) structural_errors: list[str] = [] file_lines_on = enabled.get("max-file-lines", True) folder_items_on = enabled.get("max-folder-items", True) nested_imports_on = enabled.get("no-nested-imports", True) for dirpath_str, dirnames, filenames in os.walk(root): dp = Path(dirpath_str) if is_excluded(dp, root, excludes): dirnames.clear() continue rel_dir = str(dp.relative_to(root)) if ( folder_items_on and rel_dir != "." and not is_excepted(rel_dir, "max-folder-items", exceptions) and not is_lintignored(dp, root, "max-folder-items", ignores) ): result = check_folder_items(dp, root, max_items, excludes) if result: structural_errors.append(result[0]) for fname in filenames: fp = dp / fname if fp.suffix not in extensions: continue if is_excluded(fp, root, excludes): continue rel_file = str(fp.relative_to(root)) if ( file_lines_on and not is_excepted(rel_file, "max-file-lines", exceptions) and not is_lintignored(fp, root, "max-file-lines", ignores) ): result = check_file_lines(fp, root, max_lines) if result: structural_errors.append(result[0]) if ( nested_imports_on and check_imports and not is_excepted(rel_file, "no-nested-imports", exceptions) and not is_lintignored(fp, root, "no-nested-imports", ignores) ): structural_errors.extend(check_nested_imports(fp, root)) vulture_errors: list[str] = [] if enabled.get("vulture", True): vulture_confidence = rules.get("vulture-min-confidence") if vulture_confidence is not None: vulture_error_threshold = rules.get("vulture-error-threshold", 100) vulture_errors = run_vulture( root, vulture_confidence, vulture_error_threshold, exceptions, ignores, ) eslint_errors = run_eslint(root, ignores) if enabled.get("eslint", True) else [] knip_errors = run_knip(root, ignores) if enabled.get("knip", True) else [] endpoint_ignore_routes: list[str] = rules.get("endpoint-ignore-routes", []) endpoint_errors = run_endpoint_check(root, exceptions, endpoint_ignore_routes, ignores) if enabled.get("endpoints", True) else [] class_errors = run_class_check(root, exceptions, excludes, ignores) if enabled.get("classes", True) else [] aliases: dict[str, str] = rules.get("import-cycle-aliases", {}) cycle_errors = run_cycle_check(root, excludes, aliases, exceptions, ignores) if enabled.get("import-cycles", True) else [] # Convention checks (ported from Haik's linter): ban leading-underscore names # (dead-code tooling blind spot) and enforce p_-private access boundaries. underscore_errors = run_underscore_check(root, exceptions, excludes, ignores) if enabled.get("no-underscore-names", False) else [] p_private_errors = run_p_private_check(root, exceptions, excludes, ignores) if enabled.get("p-private", False) else [] # ruff (scoped dead-code codes) + pyright (existence errors), also from Haik's # linter. Both shell out to a tool, so a missing tool / timeout raises CheckError # and is surfaced as a loud error rather than a silently-clean empty result. ruff_errors: list[str] = [] if enabled.get("ruff", False): try: ruff_errors = run_ruff(root, rules.get("ruff-select", "F401,F811,F841,ARG001,ARG002"), exceptions, ignores) except CheckError as e: ruff_errors = [f"ruff: check could not run: {e.reason}"] pyright_errors: list[str] = [] if enabled.get("pyright", False): try: pyright_errors = run_pyright(root, exceptions, ignores) except CheckError as e: pyright_errors = [f"pyright: check could not run: {e.reason}"] return sorted(structural_errors), sorted(vulture_errors), sorted(eslint_errors), sorted(knip_errors), sorted(endpoint_errors), sorted(class_errors), sorted(cycle_errors), sorted(underscore_errors), sorted(p_private_errors), sorted(ruff_errors), sorted(pyright_errors) def _print_section(name: str, errors: list[str]) -> None: print(f"{name}: checking...", flush=True) for e in errors: print(e, flush=True) print(f"{name}: done. {len(errors)} error(s) found.", flush=True) def print_results( structural_errors: list[str], vulture_errors: list[str], eslint_errors: list[str], knip_errors: list[str], endpoint_errors: list[str], class_errors: list[str], cycle_errors: list[str], underscore_errors: list[str], p_private_errors: list[str], ruff_errors: list[str], pyright_errors: list[str], ) -> None: _print_section("structural", structural_errors) _print_section("vulture", vulture_errors) _print_section("eslint", eslint_errors) _print_section("knip", knip_errors) _print_section("endpoints", endpoint_errors) _print_section("classes", class_errors) _print_section("import-cycles", cycle_errors) _print_section("no-underscore-names", underscore_errors) _print_section("p-private", p_private_errors) _print_section("ruff", ruff_errors) _print_section("pyright", pyright_errors) def watch_loop(root: Path) -> None: config_dir = SCRIPT_DIR / "config" print_results(*run_checks(root)) class SourceFilter(DefaultFilter): allowed_extensions = (".py", ".ts", ".tsx", ".js", ".jsx") def __call__(self, change: Any, path: str) -> bool: if not super().__call__(change, path): return False if Path(path).suffix in self.allowed_extensions: return True p = Path(path) if p.suffix == ".json" and (p.parent == SCRIPT_DIR or p.parent == config_dir): return True if p.name.startswith(".lintignore"): return True return Path(path).is_dir() for _changes in watch(root, watch_filter=SourceFilter()): print_results(*run_checks(root)) def main() -> None: parser = argparse.ArgumentParser(description="Unified linter") parser.add_argument("--watch", action="store_true", help="Watch for changes") parser.add_argument("--root", type=str, default=".", help="Root directory") args = parser.parse_args() root = Path(args.root).resolve() if args.watch: watch_loop(root) else: results = run_checks(root) print_results(*results) sys.exit(1 if any(results) else 0) if __name__ == "__main__": main()