Files
openswarm/linter/structlint.py
T

188 lines
5.5 KiB
Python

#!/usr/bin/env python3
"""Structural linter: enforces file line limits and folder item limits."""
from __future__ import annotations
import argparse
import fnmatch
import json
import os
import sys
from pathlib import Path
from typing import Any
SCRIPT_DIR = Path(__file__).resolve().parent
CONFIG_FILE = SCRIPT_DIR / "structlint.json"
def load_config() -> dict[str, Any]:
with open(CONFIG_FILE) as f:
return json.load(f)
def _matches_any(text: str, patterns: list[str]) -> bool:
return any(fnmatch.fnmatch(text, p) for p in patterns)
def is_excluded(path: Path, root: Path, excludes: list[str]) -> bool:
rel = path.relative_to(root)
for part in rel.parts:
if _matches_any(part, excludes):
return True
return _matches_any(str(rel), excludes)
def is_excepted(rel_path: str, rule: str, exceptions: dict[str, list[str]]) -> bool:
return _matches_any(rel_path, exceptions.get(rule, []))
def check_file_lines(
filepath: Path, root: Path, max_lines: int,
) -> tuple[str, int] | None:
try:
count = len(filepath.read_text(errors="ignore").splitlines())
except OSError:
return None
if count >= max_lines:
rel = filepath.relative_to(root)
msg = (
f"{rel}:1:1: error: File has {count} lines "
f"(limit {max_lines}) [max-file-lines]"
)
return (msg, count)
return None
ANCHOR_FILES = ("__init__.py", "index.ts", "index.tsx", "index.js")
def _find_anchor_file(dirpath: Path, root: Path) -> str:
"""Find a real file inside the folder to attach the diagnostic to.
Prefers common entry-point files (__init__.py, index.ts, etc.) so the
error shows up inline when you open that file. Falls back to the first
file alphabetically, then the directory path itself.
"""
for name in ANCHOR_FILES:
candidate = dirpath / name
if candidate.exists():
return str(candidate.relative_to(root))
try:
first = sorted(
f for f in dirpath.iterdir()
if f.is_file() and not f.name.startswith(".")
)
if first:
return str(first[0].relative_to(root))
except OSError:
pass
return str(dirpath.relative_to(root))
def check_folder_items(
dirpath: Path, root: Path, max_items: int, excludes: list[str],
) -> tuple[str, int] | None:
try:
items = [
i for i in dirpath.iterdir()
if not i.name.startswith(".") and not _matches_any(i.name, excludes)
]
except OSError:
return None
count = len(items)
if count >= max_items:
anchor = _find_anchor_file(dirpath, root)
rel = dirpath.relative_to(root)
msg = (
f"{anchor}:1:1: error: Folder '{rel}' has {count} items "
f"(limit {max_items}) [max-folder-items]"
)
return (msg, count)
return None
def run_checks(root: Path) -> list[str]:
config = load_config()
rules: dict[str, int] = config["rules"]
excludes: list[str] = config["exclude"]
exceptions: dict[str, list[str]] = config["exceptions"]
extensions: list[str] = config["include_extensions"]
max_lines: int = rules["max-file-lines"]
max_items: int = rules["max-folder-items"]
errors: list[str] = []
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 rel_dir != "." and not is_excepted(rel_dir, "max-folder-items", exceptions):
result = check_folder_items(dp, root, max_items, excludes)
if result:
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 not is_excepted(rel_file, "max-file-lines", exceptions):
result = check_file_lines(fp, root, max_lines)
if result:
errors.append(result[0])
return sorted(errors)
def print_results(errors: list[str]) -> None:
print("structlint: checking...", flush=True)
for err in errors:
print(err, flush=True)
count = len(errors)
print(f"structlint: done. {count} error(s) found.", flush=True)
def watch_loop(root: Path) -> None:
from watchfiles import watch, DefaultFilter
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
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="Structural 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:
errors = run_checks(root)
print_results(errors)
sys.exit(1 if errors else 0)
if __name__ == "__main__":
main()