[haik]: rename _sanitize_server_name to sanitize_mcp_server_name (mcp_config canonical, agent_manager, prompt_context, main, tests, toolLabels comment), add no-underscore-names linter check banning leading-underscore Python names in backend, extract _models.py from classes.py for shared pydantic BaseModel field detection used by both classes and vulture checks, add vulture-ignore inline comment suppression and automatic pydantic field-name whitelisting to vulture.py, remove leaked payload arg from client.py sync, update README with new check docs and inline-ignore usage

This commit is contained in:
haikdc
2026-06-13 15:15:44 -07:00
parent 4db536d757
commit 8f649eb82c
15 changed files with 352 additions and 44 deletions
+5 -5
View File
@@ -16,7 +16,6 @@ from backend.apps.agents.core.ws_manager import ws_manager
from backend.apps.settings.settings import load_settings
from backend.apps.tools_lib.tools_lib import (
_load_all as load_all_tools,
_sanitize_server_name,
derive_mcp_config,
load_builtin_permissions,
load_trusted_sensitive_paths,
@@ -25,6 +24,7 @@ from backend.apps.tools_lib.tools_lib import (
refresh_hubspot_token,
save_trusted_sensitive_paths,
)
from backend.apps.tools_lib.mcp_config import sanitize_mcp_server_name
from backend.apps.agents.core.error_classify import (
_is_auth_error,
_is_free_trial_exhausted,
@@ -173,7 +173,7 @@ class AgentManager:
logger.info(f"[MCP-DEBUG] SKIPPED {tool.name}: '{tool_ref}' not in allowed_tools")
continue
server_name = _sanitize_server_name(tool.name)
server_name = sanitize_mcp_server_name(tool.name)
if active_set is not None and server_name not in active_set:
logger.info(f"[MCP-DEBUG] GATED {server_name}: not in session.active_mcps, model must call MCPActivate first")
continue
@@ -664,7 +664,7 @@ class AgentManager:
for t in load_all_tools():
if not t.mcp_config or not t.enabled:
continue
if _sanitize_server_name(t.name) == server_slug:
if sanitize_mcp_server_name(t.name) == server_slug:
return t.tool_permissions.get(mcp_tool_name, "ask")
return _default_for(tool_name)
@@ -1039,7 +1039,7 @@ class AgentManager:
# Emit a context_status event so the model and UI both know.
try:
_enabled = {
_sanitize_server_name(t.name)
sanitize_mcp_server_name(t.name)
for t in load_all_tools()
if t.mcp_config and t.enabled and t.auth_status in ("configured", "connected")
}
@@ -1340,7 +1340,7 @@ class AgentManager:
tool_def = next(
(t for t in all_tools_list
if t.mcp_config and t.enabled and _sanitize_server_name(t.name) == name),
if t.mcp_config and t.enabled and sanitize_mcp_server_name(t.name) == name),
None,
)
if tool_def:
@@ -3,8 +3,8 @@ from typing import Callable
from backend.apps.modes.modes import load_mode
from backend.apps.tools_lib.tools_lib import (
_load_all as load_all_tools,
_sanitize_server_name,
)
from backend.apps.tools_lib.mcp_config import sanitize_mcp_server_name
from backend.apps.agents.manager.prompt.tool_catalog import _get_denied_tool_names, _is_fully_denied
@@ -34,7 +34,7 @@ def _build_connected_tools_context(allowed_tools: list[str], get_all_tool_names:
if _is_fully_denied(tool):
continue
server_name = _sanitize_server_name(tool.name)
server_name = sanitize_mcp_server_name(tool.name)
denied = _get_denied_tool_names(tool)
tool_descs = {
k: v for k, v in tool.tool_permissions.get("_tool_descriptions", {}).items()
@@ -263,7 +263,7 @@ def _build_mcp_registry_summary(allowed_tools: list[str], active_mcps: list[str]
continue
if _is_fully_denied(tool):
continue
server_name = _sanitize_server_name(tool.name)
server_name = sanitize_mcp_server_name(tool.name)
desc = (getattr(tool, "description", None) or "").strip()
if not desc:
# Fall back to a generic blurb keyed on the tool name so the
@@ -373,7 +373,7 @@ def _resolve_forced_tools(forced_tools: list[str] | None) -> str:
if not t.enabled or not t.tool_permissions:
continue
tool_descs = t.tool_permissions.get("_tool_descriptions", {})
server_name = _sanitize_server_name(t.name)
server_name = sanitize_mcp_server_name(t.name)
for tn, td in tool_descs.items():
desc_map[tn] = td
tool_to_server[tn] = server_name
@@ -40,7 +40,7 @@ def _patched_get_credentials():
)
gauth.get_credentials = _patched_get_credentials
gauth.get_credentials = _patched_get_credentials # vulture-ignore: get_credentials
from google_workspace_mcp import __main__ as _gw_main # noqa: E402,F401
+1 -1
View File
@@ -296,7 +296,7 @@ def sync(data: dict | None = None) -> None:
"t": time.time(),
"submission_id": uuid4().hex,
}
_log("s", payload)
_log("s")
if _test_sink is not None:
try:
_test_sink("s", body)
+1 -1
View File
@@ -11,7 +11,7 @@ from backend.apps.tools_lib.oauth_config import OPENSWARM_OAUTH_BASE_URL
logger = logging.getLogger(__name__)
def _sanitize_server_name(name: str) -> str:
def sanitize_mcp_server_name(name: str) -> str:
"""Convert a tool name into a valid MCP server identifier (alphanumeric + hyphens)."""
return re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
+4 -3
View File
@@ -550,7 +550,8 @@ async def mcp_meta(action: str, request: Request):
valid options instead of activating (anti-hallucination).
"""
from backend.apps.agents.agent_manager import agent_manager
from backend.apps.tools_lib.tools_lib import _load_all as load_all_tools, _sanitize_server_name
from backend.apps.tools_lib.tools_lib import _load_all as load_all_tools
from backend.apps.tools_lib.mcp_config import sanitize_mcp_server_name
body = await request.json()
parent_session_id = body.get("parent_session_id", "")
@@ -586,7 +587,7 @@ async def mcp_meta(action: str, request: Request):
for t in load_all_tools():
if not (t.mcp_config and t.enabled and t.auth_status in ("configured", "connected")):
continue
sanitized = _sanitize_server_name(t.name)
sanitized = sanitize_mcp_server_name(t.name)
# Pull tool sub-action names from tool_permissions._tool_descriptions
# so MCPSearch can match against capability names (e.g. "send_email").
action_names: list[str] = []
@@ -710,7 +711,7 @@ async def mcp_meta(action: str, request: Request):
tool_hint = ""
try:
for t in load_all_tools():
if _sanitize_server_name(t.name) != server_name:
if sanitize_mcp_server_name(t.name) != server_name:
continue
descs = (t.tool_permissions or {}).get("_tool_descriptions", {}) or {}
if not descs:
+15 -15
View File
@@ -581,9 +581,9 @@ def test_mcp_brand_covers_curated_servers():
"google-workspace", "microsoft-365", "slack", "discord",
"notion", "airtable", "hubspot", "reddit", "youtube",
}
from backend.apps.tools_lib.tools_lib import _sanitize_server_name
from backend.apps.tools_lib.mcp_config import sanitize_mcp_server_name
for slug in curated:
assert _sanitize_server_name(slug) == slug, (
assert sanitize_mcp_server_name(slug) == slug, (
f"curated slug {slug!r} is not in sanitized form"
)
@@ -599,30 +599,30 @@ def test_curated_server_aliases_in_main():
def test_sanitize_server_name_idempotent():
"""_sanitize_server_name must be idempotent (sanitize twice = sanitize once)."""
from backend.apps.tools_lib.tools_lib import _sanitize_server_name
"""sanitize_mcp_server_name must be idempotent (sanitize twice = sanitize once)."""
from backend.apps.tools_lib.mcp_config import sanitize_mcp_server_name
test_inputs = [
"Google Workspace", "Microsoft 365", "Slack", "Discord",
"Notion", "Airtable", "HubSpot", "Reddit", "YouTube",
"GitHub", "GitLab", "Jira",
]
for raw in test_inputs:
once = _sanitize_server_name(raw)
twice = _sanitize_server_name(once)
once = sanitize_mcp_server_name(raw)
twice = sanitize_mcp_server_name(once)
assert once == twice, f"{raw}: sanitize not idempotent ({once} != {twice})"
def test_sanitize_server_name_lowercase():
from backend.apps.tools_lib.tools_lib import _sanitize_server_name
assert _sanitize_server_name("Gmail") == "gmail"
assert _sanitize_server_name("UPPERCASE") == "uppercase"
from backend.apps.tools_lib.mcp_config import sanitize_mcp_server_name
assert sanitize_mcp_server_name("Gmail") == "gmail"
assert sanitize_mcp_server_name("UPPERCASE") == "uppercase"
def test_sanitize_server_name_strips_special_chars():
from backend.apps.tools_lib.tools_lib import _sanitize_server_name
assert _sanitize_server_name("Foo Bar!") == "foo-bar"
assert _sanitize_server_name("@x/y") == "x-y"
assert _sanitize_server_name("a__b") == "a-b"
from backend.apps.tools_lib.mcp_config import sanitize_mcp_server_name
assert sanitize_mcp_server_name("Foo Bar!") == "foo-bar"
assert sanitize_mcp_server_name("@x/y") == "x-y"
assert sanitize_mcp_server_name("a__b") == "a-b"
# ===========================================================================
@@ -635,10 +635,10 @@ def test_mcp_activate_handler_unknown_server():
# We test the response shape independently of the FastAPI plumbing.
# The handler is a closure inside main.py:mcp_meta_handler, so we
# instead exercise the contract: invalid name surfaces alternatives.
from backend.apps.tools_lib.tools_lib import _sanitize_server_name
from backend.apps.tools_lib.mcp_config import sanitize_mcp_server_name
valid = {"gmail", "slack", "google-workspace"}
requested = "Gmail" # raw, needs sanitize
sanitized = _sanitize_server_name(requested)
sanitized = sanitize_mcp_server_name(requested)
if sanitized in valid:
status = "would_activate"
else:
@@ -267,7 +267,7 @@ const VARIANTS: Record<string, ToolLabel[]> = {
],
};
// Keys match backend _sanitize_server_name in tools_lib.
// Keys match backend sanitize_mcp_server_name in tools_lib.
const MCP_SERVER_BRAND: Record<string, string> = {
'google-workspace': 'Google Workspace',
'microsoft-365': 'Microsoft 365',
+23 -1
View File
@@ -12,7 +12,9 @@ This folder contains the project's code quality tooling: a structural linter, de
**Unused Python code (Vulture)** — Flags unused functions, classes, variables, and imports in the backend. Integrated into the linter's watch loop — findings appear as warnings in the Problems panel alongside structural errors. Confidence thresholds are configurable via `vulture-min-confidence` and `vulture-error-threshold`.
These rules apply to `.py`, `.ts`, `.tsx`, `.js`, and `.jsx` files.
**No leading-underscore names (`no-underscore-names`)** — Bans names that start with `_` in backend Python: functions, methods, arguments, classes, variable bindings, instance/class attribute writes (`self._x = ...`), and import aliases. The prefix is a blind spot — 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, so dead `_name` code slips through every dead-code tool at once. Dunders (`__init__`, `__repr__`, … — required by Python) and the bare `_` throwaway (`for _ in …`) are exempt; name-mangled `__x` is **not**. The rule ships strict with no exceptions seeded — offenders are renamed by hand.
These rules apply to `.py`, `.ts`, `.tsx`, `.js`, and `.jsx` files (the `no-underscore-names` rule is Python-only).
### Orphaned endpoints
@@ -82,6 +84,7 @@ Or use the `knip:check` VS Code task (`Cmd+Shift+P` → "Run Task" → "knip:che
"max-folder-items": true,
"no-nested-imports": true,
"vulture": true,
"no-underscore-names": true,
"eslint": true,
"knip": true,
"endpoints": true,
@@ -113,6 +116,23 @@ Set any key in `"enabled"` to `false` to skip that check entirely. Missing keys
`config/vulture_whitelist.py` suppresses false positives — symbols used by frameworks, entry points, or external consumers that vulture can't detect statically. Add bare names to the file to mark them as intentionally used.
### Vulture inline ignores
For one-off false positives where the suppression reads best next to the code, drop a comment on the flagged line. Vulture points at the definition line (the `def`/`class` line, or the assignment line for an attribute), which is where the comment goes.
| Comment | Effect |
|---------|--------|
| `# vulture-ignore` | Silences any vulture finding on that line |
| `# vulture-ignore: name1, name2` | Silences only when the finding names one of the listed symbols |
The scoped form is preferred — it can't accidentally swallow an unrelated future finding on the same line. Example:
```python
gauth.get_credentials = _patched_get_credentials # vulture-ignore: get_credentials
```
Prefer the whitelist for symbols exempt across many call sites; prefer an inline ignore when the exemption is local and benefits from sitting next to the code.
### ESLint
`frontend/eslint.config.mjs` — flat config format (ESLint v9). The key rule for unused code is `@typescript-eslint/no-unused-vars`. Prefix a variable with `_` to suppress the warning.
@@ -155,6 +175,7 @@ linter/
checks/ # check implementations
__init__.py # shared filter/match utilities + .lintignore support
structural.py # file length, folder size, nested imports
no_underscore_names.py # bans leading-underscore Python names
vulture.py # vulture dead-code runner
eslint.py # eslint runner
knip.py # knip unused-code runner
@@ -181,6 +202,7 @@ deferred.
| `max-file-lines` (300) | on | Our 300-line precedence. Active for new files; existing debt is grandfathered (see below). |
| `max-folder-items` (7) | on | Grandfathered per subtree via `.lintignore-max-folder-items` markers in `backend/`, `frontend/`, `debugger/`, `electron/`, `scripts/`. |
| `vulture` | on | Dead-code detection over `backend/`. Runs against `backend/.venv/bin/vulture`. |
| `no-underscore-names` | on | Bans leading-underscore Python names over `backend/`. Pure-AST, no external tool. Strict with no seeded exceptions — existing offenders are renamed by hand. |
| `no-nested-imports` | off | We deliberately use function-level / lazy imports to break import cycles (400+ sites). Flagging them all is wrong for this codebase. |
| `eslint`, `knip` | off | Node tooling, deferred to a later pass. |
| `endpoints` | off | Orphaned-endpoint triage deferred. |
+81
View File
@@ -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
+2 -11
View File
@@ -12,16 +12,7 @@ import ast
from pathlib import Path
from . import is_excepted, is_excluded, is_lintignored
FRAMEWORK_BASES = {"BaseModel"}
def _is_framework_model(cls: ast.ClassDef) -> bool:
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
)
from ._models import is_framework_model
def run_class_check(
@@ -53,7 +44,7 @@ def run_class_check(
for node in ast.walk(tree):
if not isinstance(node, ast.ClassDef):
continue
if _is_framework_model(node):
if is_framework_model(node):
continue
# Tier 2 placeholder: non-framework classes are skipped until
# cross-reference analysis is implemented.
+148
View File
@@ -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
+58
View File
@@ -20,6 +20,7 @@ from functools import lru_cache
from pathlib import Path
from . import CheckError, is_excepted, is_lintignored
from ._models import collect_pydantic_field_names
CONFIG_DIR = Path(__file__).resolve().parent.parent / "config"
@@ -64,6 +65,49 @@ def _is_inside_class(filepath: str, lineno: int) -> bool:
return any(start < lineno <= end for start, end in _class_line_ranges(filepath))
# Inline suppression: `# vulture-ignore` (bare) silences any finding on the
# line; `# vulture-ignore: name1, name2` only silences when the finding names
# one of the listed symbols (the noqa-with-codes ergonomic).
_INLINE_IGNORE_RE = re.compile(r"#\s*vulture-ignore\b(?::\s*(?P<names>.*))?")
@lru_cache(maxsize=256)
def _file_lines_cached(filepath: str, _mtime: float) -> tuple[str, ...]:
"""Return the source lines of *filepath*, keyed on *(filepath, mtime)* so the
long-lived watch process re-reads a file after it is edited."""
try:
return tuple(Path(filepath).read_text().splitlines())
except OSError:
return ()
def _inline_ignored(filepath: Path, lineno: int, message: str) -> bool:
"""True when the flagged line carries a ``# vulture-ignore`` comment.
Vulture points at the definition line (the ``def``/``class`` line, or the
assignment line for an attribute), which is exactly where the comment lives.
The scoped form only suppresses when the vulture message names a listed
symbol, so the comment cannot accidentally swallow an unrelated future
finding on the same line.
"""
try:
mtime = filepath.stat().st_mtime
except OSError:
return False
lines = _file_lines_cached(str(filepath), mtime)
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
wanted = {n.strip() for n in names.replace(",", " ").split() if n.strip()}
flagged = re.search(r"'([^']+)'", message)
return bool(flagged and flagged.group(1) in wanted)
def run_vulture(
root: Path, min_confidence: int, error_threshold: int,
exceptions: dict[str, list[str]],
@@ -107,6 +151,15 @@ def run_vulture(
detail = result.stderr.strip()[:300] or "no output"
raise CheckError(f"vulture exited with code {result.returncode}: {detail}")
# Pydantic field names assigned outside the class body (e.g.
# ``session.memory_recalled = True``) are reported by vulture as unused
# attributes because the only *read* is across the Python/TS boundary
# (serialized via model_dump and consumed by the frontend). Treat any
# attribute whose name is a known model field as live. Computed once per run
# and independent of the "classes" section toggle, so suppression holds even
# when that section is disabled.
model_fields = collect_pydantic_field_names(root)
errors: list[str] = []
for line in result.stdout.strip().splitlines():
m = re.match(r"^(.+):(\d+): (.+)$", line)
@@ -118,10 +171,15 @@ def run_vulture(
# never gets a vote.
if re.search(r"unused (import|variable)", message):
continue
attr_m = re.match(r"unused attribute '([^']+)'", message)
if attr_m and attr_m.group(1) in model_fields:
continue
if is_excepted(filepath, "vulture", exceptions):
continue
if ignores and is_lintignored(root / filepath, root, "vulture", ignores):
continue
if _inline_ignored(root / filepath, int(lineno), message):
continue
if _is_inside_class(str(root / filepath), int(lineno)):
continue
conf = re.search(r"\((\d+)% confidence\)", message)
+3
View File
@@ -6,6 +6,7 @@
"no-nested-imports": false,
"vulture": true,
"ruff": true,
"no-underscore-names": false,
"eslint": false,
"knip": false,
"endpoints": false,
@@ -16,6 +17,7 @@
"eslint-knip": "Node tooling deferred to a later pass.",
"classes": "Placeholder check, not wired up. endpoints: orphaned-endpoint triage deferred.",
"ruff-vulture-split": "ruff owns per-file/per-scope checks (F401 unused imports, F811 redefinitions, F841 unused locals, ARG001/ARG002 unused args), which it does AST-accurately and which vulture either gets wrong (global name-set hides per-file dead imports) or rates as noisy 60% findings. vulture is narrowed in checks/vulture.py to whole-program reachability only (dead functions/methods/classes/attributes), the one thing ruff structurally cannot do since it never builds a cross-module symbol graph. F401 honors __all__ and the redundant-alias form (import x as x) for intentional re-exports; add one of those if F401 flags a deliberate re-export.",
"no-underscore-names": "Bans leading-underscore names in backend/ Python (functions, methods, args, classes, variables, instance/class attribute writes, import aliases). The prefix is a blind spot: Pylance reportUnusedVariable, ruff dummy-variable-rgx (F841/ARG0xx), and vulture all treat it as intentionally-private/unused and stop reporting, so dead _name code hides. Exempt: dunders (__x__, required by Python) and the bare _ throwaway. Name-mangled __x IS flagged. Intentionally strict with no exceptions seeded -- offenders are renamed by hand.",
"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.",
"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; 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."
@@ -63,6 +65,7 @@
"import-cycles": [],
"vulture": [],
"ruff": [],
"no-underscore-names": [],
"endpoints": [],
"classes": []
}
+5 -1
View File
@@ -20,6 +20,7 @@ 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 watchfiles import watch, DefaultFilter
SCRIPT_DIR = Path(__file__).resolve().parent
@@ -27,7 +28,7 @@ CONFIG_FILE = SCRIPT_DIR / "config" / "config.json"
# Print order for the sections; also the order they run in.
SECTION_ORDER = [
"structural", "vulture", "ruff", "eslint",
"structural", "vulture", "ruff", "no-underscore-names", "eslint",
"knip", "endpoints", "classes", "import-cycles",
]
@@ -152,6 +153,9 @@ def run_checks(root: Path) -> LintResult:
run_section("ruff", lambda: run_ruff(
root, rules.get("ruff-select", "F401,F811,F841,ARG001,ARG002"), exceptions, ignores,
) if enabled.get("ruff", True) else [])
run_section("no-underscore-names", lambda: run_underscore_check(
root, exceptions, excludes, ignores,
) if enabled.get("no-underscore-names", True) else [])
run_section("eslint", lambda: run_eslint(root, ignores) if enabled.get("eslint", True) else [])
run_section("knip", lambda: run_knip(root, ignores) if enabled.get("knip", True) else [])
run_section("endpoints", lambda: run_endpoint_check(