mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[eric] linter: add no-underscore + p-private checks from Haik (enforce p_ conventions; grandfather pre-existing debt)
This commit is contained in:
@@ -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.<field> = ...`` 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
|
||||
@@ -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
|
||||
@@ -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<names>.*))?")
|
||||
|
||||
# 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
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+13
-3
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user