From 54a96cea68fdf0ad351c928ba070c3e544bbbc46 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 24 Jun 2026 22:41:59 -0700 Subject: [PATCH] [eric] linter: add no-underscore + p-private checks from Haik (enforce p_ conventions; grandfather pre-existing debt) --- linter/checks/_models.py | 81 +++++++++ linter/checks/no_underscore_names.py | 148 ++++++++++++++++ linter/checks/p_private.py | 242 +++++++++++++++++++++++++++ linter/config/config.json | 40 ++++- linter/lint.py | 16 +- 5 files changed, 521 insertions(+), 6 deletions(-) create mode 100644 linter/checks/_models.py create mode 100644 linter/checks/no_underscore_names.py create mode 100644 linter/checks/p_private.py diff --git a/linter/checks/_models.py b/linter/checks/_models.py new file mode 100644 index 00000000..fb23da27 --- /dev/null +++ b/linter/checks/_models.py @@ -0,0 +1,81 @@ +"""Shared model metadata for the linter. + +Pydantic ``BaseModel`` field names are collected here so independent checks can +agree on which attribute names belong to a serialization schema, and are +therefore "used" even when the only *read* happens across a language boundary +(serialized to JSON over the wire and consumed by the frontend) which vulture's +Python-only analysis structurally cannot see. + +Framework detection lives in ONE place so checks/classes.py and +checks/vulture.py never drift on what counts as a model. This module owns no +section of its own and is not gated by config "enabled" flags, so any check that +imports it works regardless of which sections are turned on. +""" + +from __future__ import annotations + +import ast +from functools import lru_cache +from pathlib import Path + +FRAMEWORK_BASES = {"BaseModel"} + + +def is_framework_model(cls: ast.ClassDef) -> bool: + """True when *cls* subclasses a known framework base (e.g. pydantic BaseModel).""" + return any( + (isinstance(b, ast.Name) and b.id in FRAMEWORK_BASES) + or (isinstance(b, ast.Attribute) and b.attr in FRAMEWORK_BASES) + for b in cls.bases + ) + + +@lru_cache(maxsize=512) +def _fields_in_file_cached(filepath: str, _mtime: float) -> frozenset[str]: + """Annotated field names on framework models in *filepath*. + + Keyed on *(filepath, mtime)* so the long-lived watch process re-parses a file + after it is edited instead of returning a stale set from an earlier version. + """ + try: + tree = ast.parse(Path(filepath).read_text()) + except (OSError, SyntaxError): + return frozenset() + names: set[str] = set() + for node in ast.walk(tree): + if not isinstance(node, ast.ClassDef) or not is_framework_model(node): + continue + # Direct body only: pydantic fields are annotated assignments + # (``name: type`` / ``name: type = default``). Nested models are picked + # up on their own ClassDef pass by ast.walk. + for stmt in node.body: + if isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name): + names.add(stmt.target.id) + return frozenset(names) + + +def _fields_in_file(filepath: Path) -> frozenset[str]: + try: + mtime = filepath.stat().st_mtime + except OSError: + return frozenset() + return _fields_in_file_cached(str(filepath), mtime) + + +def collect_pydantic_field_names(root: Path) -> set[str]: + """Every annotated field name on a ``BaseModel`` subclass under ``backend/``. + + Consumed by checks/vulture.py to treat ``obj. = ...`` writes as live + even when the only read is across the Python/TS boundary, which vulture would + otherwise report as an unused attribute. + """ + backend = root / "backend" + if not backend.is_dir(): + return set() + names: set[str] = set() + for pyfile in backend.rglob("*.py"): + parts = pyfile.parts + if ".venv" in parts or "__pycache__" in parts: + continue + names |= _fields_in_file(pyfile) + return names diff --git a/linter/checks/no_underscore_names.py b/linter/checks/no_underscore_names.py new file mode 100644 index 00000000..f4097068 --- /dev/null +++ b/linter/checks/no_underscore_names.py @@ -0,0 +1,148 @@ +"""Ban leading-underscore names that dead-code tooling silently skips. + +Pylance's reportUnusedVariable, ruff's dummy-variable-rgx (F841/ARG0xx), and +vulture all treat a leading underscore as "intentionally private/unused" and +stop reporting it -- which makes ``_name`` a blind spot for dead-code detection. +This check bans the prefix so nothing can hide behind it. + +Exempt: dunders (``__init__`` and friends, i.e. ``__x__``) and the bare ``_`` +throwaway (``for _ in ...`` / ``a, _ = unpack()``). Everything else starting +with ``_`` is flagged, including name-mangled ``__x``. + +Covers function/method names, arguments (incl. lambda), class names, variable +bindings (assignments, annotations, walrus, loop/with/except targets, tuple +unpacking), instance/class attribute writes (``self._x = ...``), and import +aliases. Scoped to ``backend/`` Python, like checks/classes.py. +""" + +from __future__ import annotations + +import ast +from collections.abc import Callable +from pathlib import Path + +from . import is_excepted, is_excluded, is_lintignored + +RULE = "no-underscore-names" + +Report = Callable[[str, int, int, str], None] + + +def _is_dunder(name: str) -> bool: + """True for ``__x__`` style names that Python requires (``__init__`` etc.).""" + return len(name) > 4 and name.startswith("__") and name.endswith("__") + + +def _flagged(name: str) -> bool: + if name == "_" or not name.startswith("_"): + return False + return not _is_dunder(name) + + +def _report_targets(target: ast.AST, report: Report) -> None: + """Walk an assignment/loop target down to the names it binds.""" + if isinstance(target, ast.Name): + report(target.id, target.lineno, target.col_offset, "variable") + elif isinstance(target, ast.Attribute): + # self._x = ... / cls._x = ...: the bound name is the attribute itself. + report(target.attr, target.end_lineno or target.lineno, _attr_col(target), "attribute") + elif isinstance(target, ast.Starred): + _report_targets(target.value, report) + elif isinstance(target, (ast.Tuple, ast.List)): + for elt in target.elts: + _report_targets(elt, report) + # ast.Subscript (``d["_x"] = ...``) is not a name binding and is skipped. + + +def _attr_col(node: ast.Attribute) -> int: + """Best-effort column for the attribute name (after the dot).""" + # value.end_col_offset points just past ``value``; +1 skips the dot. + end = getattr(node.value, "end_col_offset", None) + return (end + 1) if end is not None else node.col_offset + + +def _args(a: ast.arguments) -> list[ast.arg | None]: + return [*a.posonlyargs, *a.args, *a.kwonlyargs, a.vararg, a.kwarg] + + +def _check_tree(tree: ast.AST, rel: str) -> list[str]: + errors: list[str] = [] + seen: set[tuple[int, int, str]] = set() + + def report(name: str, lineno: int, col: int, kind: str) -> None: + if not _flagged(name): + return + key = (lineno, col, name) + if key in seen: + return + seen.add(key) + errors.append( + f"{rel}:{lineno}:{col + 1}: error: " + f"[{RULE}] {kind} '{name}' has a leading underscore" + ) + + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + report(node.name, node.lineno, node.col_offset, "function") + for arg in _args(node.args): + if arg is not None: + report(arg.arg, arg.lineno, arg.col_offset, "argument") + elif isinstance(node, ast.Lambda): + for arg in _args(node.args): + if arg is not None: + report(arg.arg, arg.lineno, arg.col_offset, "argument") + elif isinstance(node, ast.ClassDef): + report(node.name, node.lineno, node.col_offset, "class") + elif isinstance(node, (ast.Assign, ast.AnnAssign, ast.NamedExpr)): + targets = node.targets if isinstance(node, ast.Assign) else [node.target] + for t in targets: + _report_targets(t, report) + elif isinstance(node, (ast.For, ast.AsyncFor, ast.comprehension)): + _report_targets(node.target, report) + elif isinstance(node, (ast.With, ast.AsyncWith)): + for item in node.items: + if item.optional_vars is not None: + _report_targets(item.optional_vars, report) + elif isinstance(node, ast.ExceptHandler) and node.name: + report(node.name, node.lineno, node.col_offset, "exception") + elif isinstance(node, ast.Import): + for alias in node.names: + report( + alias.asname or alias.name.split(".")[0], + node.lineno, node.col_offset, "import", + ) + elif isinstance(node, ast.ImportFrom): + for alias in node.names: + if alias.name != "*": + report(alias.asname or alias.name, node.lineno, node.col_offset, "import") + + return errors + + +def run_underscore_check( + root: Path, + exceptions: dict[str, list[str]], + excludes: list[str], + ignores: dict[Path, set[str]] | None = None, +) -> list[str]: + """Flag leading-underscore names in backend Python files.""" + errors: list[str] = [] + backend = root / "backend" + if not backend.is_dir(): + return errors + + for pyfile in sorted(backend.rglob("*.py")): + if is_excluded(pyfile, root, excludes): + continue + rel = str(pyfile.relative_to(root)) + if is_excepted(rel, RULE, exceptions): + continue + if ignores and is_lintignored(pyfile, root, RULE, ignores): + continue + try: + tree = ast.parse(pyfile.read_text(), filename=rel) + except (OSError, SyntaxError): + continue + errors.extend(_check_tree(tree, rel)) + + return errors diff --git a/linter/checks/p_private.py b/linter/checks/p_private.py new file mode 100644 index 00000000..6f60be78 --- /dev/null +++ b/linter/checks/p_private.py @@ -0,0 +1,242 @@ +"""Cross-file/class privacy for the ``p_`` naming convention (Java ``private``). + +A name prefixed with ``p_`` (or ``P_`` -- the leading p is case-insensitive, so +UPPER_SNAKE constants like ``P_SECRET`` count too) is private to the scope that +owns it, enforced the way Java enforces ``private``: + + * **Module-level** ``p_`` symbols (top-level ``def`` / ``class`` / assignment) + are **file-private** -- usable anywhere in their own file, nowhere else. + * **Class members** (``def p_m``, ``self.p_x = ...``, and class-body fields + ``p_x: T``) are **class-private** -- any ``recv.p_x`` is legal only when it + appears lexically inside the owning class (or a class nested within it), + matching Java's type-scoped ``private`` while side-stepping type inference. + +Strict: a subclass in another file reaching a base's ``p_`` member is a +violation (Java ``private``, not ``protected``). Nested/inner classes may reach +the enclosing class's members and vice versa (the whole enclosing-class stack is +checked). No exemptions -- tests and ``__init__.py`` re-exports are enforced. + +How access is detected (no type inference needed): Python reaches class members +only through attribute access (``self.p_x`` / ``obj.p_x``) and module-level +symbols only by bare name (legal in-file) or ``module.p_x`` / ``from m import +p_x`` across files. So the access *form* selects the scoping rule: + + * ``recv.p_x`` (attribute load) -> class rule, else module rule + * ``from m import p_x`` -> module rule (cross-file import = leak) + +Two passes: phase 1 records ownership, phase 2 checks references against it. +Scoped to ``backend/`` Python, like checks/classes.py. +""" + +from __future__ import annotations + +import ast +import re +from pathlib import Path + +from . import is_excepted, is_excluded, is_lintignored + +RULE = "p-private" + +# Inline suppression, mirroring vulture's: `# p-private-ignore` (bare) silences +# any p-private finding on the line; `# p-private-ignore: p_x, p_y` only silences +# when the finding names one of the listed symbols (the noqa-with-codes form). +# The comment goes on the *reference* line (where the error is reported), e.g. +# `legacy.p_state # p-private-ignore: p_state`. +_INLINE_IGNORE_RE = re.compile(r"#\s*p-private-ignore\b(?::\s*(?P.*))?") + +# name -> set of relative file paths that define it at module level +ModuleOwners = dict[str, set[str]] +# name -> set of (relative file path, qualified class name) that define it as a member +ClassOwners = dict[str, set[tuple[str, str]]] + + +def _is_p(name: str) -> bool: + # Case-insensitive on the leading p so UPPER_SNAKE constants (P_SECRET) count + # as private too, not just lowercase p_ functions/vars. + return len(name) > 2 and name[:2] in ("p_", "P_") + + +# --------------------------------------------------------------------------- # +# Phase 1: ownership collection # +# --------------------------------------------------------------------------- # + +def _own(name: str, rel: str, stack: list[str], container: str, mp: ModuleOwners, cp: ClassOwners) -> None: + if container == "class": + cp.setdefault(name, set()).add((rel, stack[-1])) + elif container == "module": + mp.setdefault(name, set()).add(rel) + # container == "function" -> local definition, not externally reachable + + +def _own_target(t: ast.AST, rel: str, stack: list[str], container: str, mp: ModuleOwners, cp: ClassOwners) -> None: + if isinstance(t, ast.Name) and _is_p(t.id): + _own(t.id, rel, stack, container, mp, cp) + elif isinstance(t, ast.Attribute) and _is_p(t.attr): + # self._x = ... / cls._x = ...: owned by the lexically enclosing class. + if stack: + cp.setdefault(t.attr, set()).add((rel, stack[-1])) + elif isinstance(t, ast.Starred): + _own_target(t.value, rel, stack, container, mp, cp) + elif isinstance(t, (ast.Tuple, ast.List)): + for elt in t.elts: + _own_target(elt, rel, stack, container, mp, cp) + + +def _collect(node: ast.AST, rel: str, stack: list[str], container: str, mp: ModuleOwners, cp: ClassOwners) -> None: + if isinstance(node, ast.ClassDef): + if _is_p(node.name): + _own(node.name, rel, stack, container, mp, cp) + qual = f"{stack[-1]}.{node.name}" if stack else node.name + for child in node.body: + _collect(child, rel, [*stack, qual], "class", mp, cp) + return + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + if _is_p(node.name): + _own(node.name, rel, stack, container, mp, cp) + for child in node.body: + _collect(child, rel, stack, "function", mp, cp) + return + if isinstance(node, (ast.Assign, ast.AnnAssign)): + targets = node.targets if isinstance(node, ast.Assign) else [node.target] + for t in targets: + _own_target(t, rel, stack, container, mp, cp) + return + # Plain compound statements (if/for/while/with/try/...) keep the container so + # a conditionally-defined member is still attributed to its real scope. + for child in ast.iter_child_nodes(node): + _collect(child, rel, stack, container, mp, cp) + + +# --------------------------------------------------------------------------- # +# Phase 2: reference checking # +# --------------------------------------------------------------------------- # + +def _inline_ignored(lines: tuple[str, ...], lineno: int, name: str) -> bool: + """True when the reported line carries a matching ``# p-private-ignore``.""" + if not (1 <= lineno <= len(lines)): + return False + m = _INLINE_IGNORE_RE.search(lines[lineno - 1]) + if not m: + return False + names = m.group("names") + if not names: + return True # bare form silences any p-private finding on the line + wanted = {n.strip() for n in names.replace(",", " ").split() if n.strip()} + return name in wanted + + +def _emit( + out: list[str], seen: set[tuple[int, int, str]], rel: str, node: ast.AST, + name: str, msg: str, lines: tuple[str, ...], +) -> None: + if _inline_ignored(lines, node.lineno, name): + return + key = (node.lineno, node.col_offset, msg) + if key in seen: + return + seen.add(key) + out.append(f"{rel}:{node.lineno}:{node.col_offset + 1}: error: [{RULE}] {msg}") + + +def _check_attr( + node: ast.Attribute, rel: str, stack: list[str], + mp: ModuleOwners, cp: ClassOwners, out: list[str], seen: set[tuple[int, int, str]], + lines: tuple[str, ...], +) -> None: + name = node.attr + if name in cp: + if cp[name] & {(rel, q) for q in stack}: + return # lexically inside an owning class -> legal + owners = ", ".join(sorted(f"{q} ({r})" for r, q in cp[name])) + _emit(out, seen, rel, node, name, f"class-private '{name}' accessed outside its class (owner: {owners})", lines) + return + if name in mp: + if rel in mp[name]: + return + owners = ", ".join(sorted(mp[name])) + _emit(out, seen, rel, node, name, f"module-private '{name}' accessed outside its file (defined in {owners})", lines) + + +def _check_import( + name: str, node: ast.AST, rel: str, + mp: ModuleOwners, out: list[str], seen: set[tuple[int, int, str]], + lines: tuple[str, ...], +) -> None: + if name in mp and rel not in mp[name]: + owners = ", ".join(sorted(mp[name])) + _emit(out, seen, rel, node, name, f"module-private '{name}' imported outside its file (defined in {owners})", lines) + + +def _check_refs( + node: ast.AST, rel: str, stack: list[str], + mp: ModuleOwners, cp: ClassOwners, out: list[str], seen: set[tuple[int, int, str]], + lines: tuple[str, ...], +) -> None: + if isinstance(node, ast.ClassDef): + qual = f"{stack[-1]}.{node.name}" if stack else node.name + # Decorators/bases are evaluated in the enclosing scope, not inside the class. + for outer in (*node.decorator_list, *node.bases, *node.keywords): + _check_refs(outer, rel, stack, mp, cp, out, seen, lines) + for child in node.body: + _check_refs(child, rel, [*stack, qual], mp, cp, out, seen, lines) + return + if isinstance(node, ast.Attribute) and isinstance(node.ctx, ast.Load) and _is_p(node.attr): + _check_attr(node, rel, stack, mp, cp, out, seen, lines) + elif isinstance(node, ast.ImportFrom): + for alias in node.names: + if alias.name != "*" and _is_p(alias.name): + _check_import(alias.name, node, rel, mp, out, seen, lines) + # A function does not open a new *class* scope, so the class stack is carried + # through unchanged; that's why generic descent (not an early return) is right. + for child in ast.iter_child_nodes(node): + _check_refs(child, rel, stack, mp, cp, out, seen, lines) + + +# --------------------------------------------------------------------------- # +# Runner # +# --------------------------------------------------------------------------- # + +def run_p_private_check( + root: Path, + exceptions: dict[str, list[str]], + excludes: list[str], + ignores: dict[Path, set[str]] | None = None, +) -> list[str]: + """Flag ``p_`` symbols accessed outside their owning file/class.""" + backend = root / "backend" + if not backend.is_dir(): + return [] + + # Parse once; phase 1 must see *every* file (even exempted ones) so ownership + # is known when a non-exempt file reaches into them. The source lines are kept + # so phase 2 can honor inline `# p-private-ignore` comments. + trees: list[tuple[Path, str, ast.AST, tuple[str, ...]]] = [] + mp: ModuleOwners = {} + cp: ClassOwners = {} + for pyfile in sorted(backend.rglob("*.py")): + if is_excluded(pyfile, root, excludes): + continue + rel = str(pyfile.relative_to(root)) + try: + source = pyfile.read_text() + tree = ast.parse(source, filename=rel) + except (OSError, SyntaxError): + continue + trees.append((pyfile, rel, tree, tuple(source.splitlines()))) + for child in ast.iter_child_nodes(tree): + _collect(child, rel, [], "module", mp, cp) + + # Phase 2 reports only for files that aren't exempted/lintignored. + errors: list[str] = [] + for pyfile, rel, tree, lines in trees: + if is_excepted(rel, RULE, exceptions): + continue + if ignores and is_lintignored(pyfile, root, RULE, ignores): + continue + seen: set[tuple[int, int, str]] = set() + out: list[str] = [] + _check_refs(tree, rel, [], mp, cp, out, seen, lines) + errors.extend(out) + + return errors diff --git a/linter/config/config.json b/linter/config/config.json index 6c014103..da356ce7 100644 --- a/linter/config/config.json +++ b/linter/config/config.json @@ -8,7 +8,9 @@ "eslint": false, "knip": false, "endpoints": false, - "classes": false + "classes": false, + "no-underscore-names": true, + "p-private": true }, "_notes": { "no-nested-imports": "Off on purpose: this codebase uses function-level/lazy imports to break import cycles (400+ sites). Flagging them all is wrong for us.", @@ -16,7 +18,8 @@ "classes": "Placeholder check, not wired up. endpoints: orphaned-endpoint triage deferred.", "max-file-lines-exceptions": "Grandfather list of pre-existing >300-line files (existing debt, not new). Paths updated after the folder-tree restructure moved several of them. The two manager/prompt/* entries are from the agent_manager decomposition: prompt_context.py aggregates the system-prompt context builders and attachments.py is one cohesive 230-line attachment resolver; both are single-responsibility and a few lines over, not splittable without an artificial seam.", "max-folder-items-exceptions": "Exact-path allow for folders intentionally over the cap. The rule trips at >7 (7 items is fine, the 8th tips it), so only genuinely 8+ folders are listed. backend/ and backend/apps are FastAPI feature-package registries (each child is an app mounted in main.py); agents/ aggregates agent subsystems; agents/manager/ is the agent_manager god-object decomposition (cohesive AgentManager mixins + standalone run helpers + the streaming/permissions/prompt/session subtrees), conventionally flat like agents/ and core/ since its standalone helpers are heterogeneous and don't group cleanly; agents/manager/streaming and agents/manager/session are flat peer collections of one-module-per-concern handlers; core/, tools_lib/, tests/ are conventionally flat. Frontend: app/pages is the page registry, AgentChat/ChatInput/Settings-sections/Onboarding are organizational parents, and shared/state (Redux slices) plus hooks/steps/mcp-cards/Views are flat peer collections. scripts/, electron/, linter/checks/ are flat tool dirs. These replaced blanket .lintignore-max-folder-items sentinels (backend, frontend, scripts, electron, linter/checks) so the rule still catches NEW unplanned bloat everywhere else. Kept as whole-subtree sentinels on purpose: debugger/ (self-contained injected sub-tool with its own Vite GUI), webapp_template (Vite scaffold payload), and vendored mcp-bundles.", - "import-cycles": "Flags RUNTIME circular imports only (SCC>1). Skips type-only imports (import type / export type) and dynamic import() since neither runs at module init, which is why the idiomatic Redux store<->hooks type cycle is not flagged. Frontend alias resolution comes from import-cycle-aliases. Zero cycles today; the check keeps it that way." + "import-cycles": "Flags RUNTIME circular imports only (SCC>1). Skips type-only imports (import type / export type) and dynamic import() since neither runs at module init, which is why the idiomatic Redux store<->hooks type cycle is not flagged. Frontend alias resolution comes from import-cycle-aliases. Zero cycles today; the check keeps it that way.", + "no-underscore-names + p-private": "Convention checks ported verbatim from Haik's linter (haik/feat/ingest): no-underscore-names bans leading-underscore names (a dead-code-tooling blind spot; use p_ for private), p-private enforces that p_-prefixed names are accessed only inside their owning file/class (cross-file/class use means the name should be public). Backend Python only. The exception lists grandfather pre-existing debt that landed with the workflows/analytics forward-ports (eric's 'don't mass-migrate untouched files' rule); the agent_manager refactor surface is clean. NOTE: Haik's full linter (his branch also adds pyright + ruff and runs a different enabled set) should eventually supersede this; these two were lifted to enforce the p_ conventions on eric/dev now." }, "rules": { "max-file-lines": 300, @@ -86,6 +89,8 @@ "backend/tests/test_disconnect_resilience.py", "backend/tests/test_outputs_runtime_cleanup.py", "backend/tests/test_service.py", + "backend/tests/test_streaming_harness.py", + "backend/tests/test_swarm_bundle.py", "backend/tests/test_v2_invariants.py", "backend/tests/test_v2_label_logic.py", "electron/affiliateTracking.test.js", @@ -162,6 +167,35 @@ "import-cycles": [], "vulture": ["backend/tests/*"], "endpoints": [], - "classes": [] + "classes": [], + "no-underscore-names": [ + "backend/apps/agents/schedule_mcp_server.py", + "backend/apps/workflows/audit.py", + "backend/apps/workflows/escalation.py", + "backend/apps/workflows/executor.py", + "backend/apps/workflows/models.py", + "backend/apps/workflows/notifier.py", + "backend/apps/workflows/scheduler.py", + "backend/apps/workflows/storage.py", + "backend/apps/workflows/workflows.py", + "backend/tests/conftest.py", + "backend/tests/test_app_edit_bind.py", + "backend/tests/test_executor_pipeline.py", + "backend/tests/test_free_trial.py", + "backend/tests/test_schedule_e2e.py", + "backend/tests/test_schedule_recovery.py", + "backend/tests/test_schedule_recurrence.py", + "backend/tests/test_seed_no_clobber.py", + "backend/tests/test_workflows_api.py", + "backend/tests/test_workflows_semantics.py", + "backend/tests/test_workflows_storage.py" + ], + "p-private": [ + "backend/apps/workflows/workflows.py", + "backend/tests/test_schedule_recurrence.py", + "backend/tests/test_ssrf_guard.py", + "backend/tests/test_workflows_semantics.py", + "backend/tests/test_workflows_storage.py" + ] } } diff --git a/linter/lint.py b/linter/lint.py index da1df3ea..4ff84f41 100644 --- a/linter/lint.py +++ b/linter/lint.py @@ -18,6 +18,8 @@ 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 watchfiles import watch, DefaultFilter SCRIPT_DIR = Path(__file__).resolve().parent @@ -29,7 +31,7 @@ def load_config() -> dict[str, Any]: return json.load(f) -def run_checks(root: Path) -> tuple[list[str], list[str], list[str], list[str], list[str], list[str], list[str]]: +def run_checks(root: Path) -> tuple[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"] @@ -105,7 +107,12 @@ def run_checks(root: Path) -> tuple[list[str], list[str], list[str], list[str], 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 [] - return sorted(structural_errors), sorted(vulture_errors), sorted(eslint_errors), sorted(knip_errors), sorted(endpoint_errors), sorted(class_errors), sorted(cycle_errors) + # 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 [] + + 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) def _print_section(name: str, errors: list[str]) -> None: @@ -119,7 +126,8 @@ 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], + cycle_errors: list[str], underscore_errors: list[str], + p_private_errors: list[str], ) -> None: _print_section("structural", structural_errors) _print_section("vulture", vulture_errors) @@ -128,6 +136,8 @@ def print_results( _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) def watch_loop(root: Path) -> None: