mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-12 12:47:42 +02:00
[haik]: add pyright linter check for existence errors, wire analytics SDK wrappers (agent created, dashboard events, onboarding steps, link email), rename underscore-prefixed privates to p_ convention, promote cancel_event to a typed model field, fix ws_manager import casing and token-scrub args handling
This commit is contained in:
@@ -251,10 +251,9 @@ class AgentManager:
|
||||
# ever re-launched with the same id.
|
||||
if config.mode == "view-builder" and not config.target_directory:
|
||||
try:
|
||||
from backend.apps.outputs.outputs import (
|
||||
ensure_webapp_workspace_seeded_and_registered,
|
||||
_load,
|
||||
)
|
||||
from backend.apps.outputs.outputs import ensure_webapp_workspace_seeded_and_registered
|
||||
from backend.apps.outputs.workspace_io import load
|
||||
|
||||
output_id = ensure_webapp_workspace_seeded_and_registered(
|
||||
workspace_id=session_id,
|
||||
folder=effective_cwd,
|
||||
@@ -268,7 +267,7 @@ class AgentManager:
|
||||
# second upsert with the real name once the agent has
|
||||
# written meta.json.
|
||||
try:
|
||||
new_output = _load(output_id)
|
||||
new_output = load(output_id)
|
||||
await WS_MANAGER.broadcast_global("agent:output_upserted", {
|
||||
"output": new_output.model_dump(mode="json"),
|
||||
})
|
||||
@@ -320,6 +319,12 @@ class AgentManager:
|
||||
"session": session.model_dump(mode="json"),
|
||||
})
|
||||
|
||||
try:
|
||||
from backend.apps.service.analytics import track_agent_created
|
||||
track_agent_created(id=session.id, name=session.name, dashboard_id=session.dashboard_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return session
|
||||
|
||||
def _build_dir_tree(self, root: str, max_depth: int = 4, prefix: str = "") -> list[str]:
|
||||
@@ -3540,8 +3545,8 @@ class AgentManager:
|
||||
if session:
|
||||
# Set cancel event BEFORE cancelling the task so in-flight
|
||||
# browser agent loops see it immediately
|
||||
if hasattr(session, '_cancel_event'):
|
||||
session._cancel_event.set()
|
||||
if session.cancel_event is not None:
|
||||
session.cancel_event.set()
|
||||
|
||||
for req in list(session.pending_approvals):
|
||||
WS_MANAGER.resolve_approval(req.id, {"behavior": "deny", "message": "Agent stopped"})
|
||||
@@ -3993,8 +3998,8 @@ class AgentManager:
|
||||
WS_MANAGER.resolve_approval(req.id, {"behavior": "deny", "message": "Session closed"})
|
||||
session.pending_approvals = []
|
||||
|
||||
if hasattr(session, '_cancel_event'):
|
||||
session._cancel_event.set()
|
||||
if session.cancel_event is not None:
|
||||
session.cancel_event.set()
|
||||
|
||||
self._sync_session_close(session)
|
||||
|
||||
|
||||
@@ -455,15 +455,15 @@ async def probe_model(body: dict):
|
||||
from backend.apps.agents.providers.registry import (
|
||||
resolve_model_id_for_sdk,
|
||||
get_api_type,
|
||||
_find_builtin_model,
|
||||
_NINEROUTER_MODEL_PREFIXES,
|
||||
find_builtin_model,
|
||||
NINEROUTER_MODEL_PREFIXES,
|
||||
)
|
||||
from backend.apps.settings.store import load_settings
|
||||
from backend.apps.nine_router.process import is_running
|
||||
settings = load_settings()
|
||||
api_type = get_api_type(short_name)
|
||||
resolved = resolve_model_id_for_sdk(short_name, settings)
|
||||
entry = _find_builtin_model(short_name) or {}
|
||||
entry = find_builtin_model(short_name) or {}
|
||||
route = entry.get("route")
|
||||
connection_mode = getattr(settings, "connection_mode", "own_key")
|
||||
|
||||
@@ -473,7 +473,7 @@ async def probe_model(body: dict):
|
||||
# Routing mirrors agent_manager: prefix takes precedence over Pro.
|
||||
resolved_is_9router = (
|
||||
isinstance(resolved, str)
|
||||
and resolved.startswith(_NINEROUTER_MODEL_PREFIXES)
|
||||
and resolved.startswith(NINEROUTER_MODEL_PREFIXES)
|
||||
)
|
||||
|
||||
if resolved_is_9router:
|
||||
@@ -707,7 +707,7 @@ async def list_models():
|
||||
or_models = []
|
||||
if or_models:
|
||||
by_vendor: dict[str, list[dict]] = {}
|
||||
from backend.apps.agents.providers.registry import (
|
||||
from backend.apps.agents.providers.pricing import (
|
||||
compute_tiers as _ct,
|
||||
compute_billing_kind as _cbk,
|
||||
)
|
||||
|
||||
@@ -381,7 +381,7 @@ async def run_browser_agent(
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
parent_session_id=parent_session_id,
|
||||
)
|
||||
session._cancel_event = cancel_event
|
||||
session.cancel_event = cancel_event
|
||||
agent_manager.sessions[session_id] = session
|
||||
|
||||
# If parent was already stopped before we registered, bail immediately
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, InstanceOf
|
||||
from typing import Optional, Literal, Any
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
import asyncio
|
||||
|
||||
class AgentConfig(BaseModel):
|
||||
name: str = Field(default_factory=lambda: f"Agent-{uuid4().hex[:6]}")
|
||||
@@ -139,3 +140,5 @@ class AgentSession(BaseModel):
|
||||
context_window: int = 200_000
|
||||
# Provider-agnostic thinking level (off/low/medium/high/auto), translated per-API in agent_manager; only affects reasoning-flagged models.
|
||||
thinking_level: Literal["off", "low", "medium", "high", "auto"] = "auto"
|
||||
# Event to cancel the agent loop. Set before cancelling the task so in-flight browser agent loops see it immediately.
|
||||
cancel_event: Optional[InstanceOf[asyncio.Event]] = None
|
||||
|
||||
@@ -47,7 +47,7 @@ class SeqLogStore:
|
||||
if log is not None:
|
||||
return log
|
||||
async with self.p_dict_lock:
|
||||
log = self._per_session.get(session_id)
|
||||
log = self.p_per_session.get(session_id)
|
||||
if log is None:
|
||||
log = P_SessionSeqLog()
|
||||
self.p_per_session[session_id] = log
|
||||
|
||||
@@ -137,8 +137,8 @@ class ConnectionManager:
|
||||
# wipes pending_futures, which is correct because
|
||||
# reconcile_on_startup also marks waiting_approval sessions as
|
||||
# stopped so there's nothing to answer anyway.
|
||||
events = self._filter_stale_approvals(events)
|
||||
events = self._strip_replayed_closes(events)
|
||||
events = self.p_filter_stale_approvals(events)
|
||||
events = self.p_strip_replayed_closes(events)
|
||||
for s in events:
|
||||
try:
|
||||
await websocket.send_text(s)
|
||||
|
||||
@@ -77,6 +77,12 @@ def p_sync_identity_to_service(settings_obj) -> None:
|
||||
identify(props)
|
||||
except Exception as e:
|
||||
logger.debug("identify sync failed: %s", e)
|
||||
if email:
|
||||
try:
|
||||
from backend.apps.service.analytics import track_link_email
|
||||
track_link_email(email)
|
||||
except Exception as e:
|
||||
logger.debug("analytics link_email sync failed: %s", e)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -133,6 +133,8 @@ async def list_dashboards():
|
||||
async def create_dashboard(body: DashboardCreate):
|
||||
dashboard = Dashboard(name=body.name)
|
||||
save(dashboard)
|
||||
from backend.apps.service.analytics import track_dashboard_event
|
||||
track_dashboard_event(dashboard_id=dashboard.id, action="create")
|
||||
return dashboard.model_dump(mode="json")
|
||||
|
||||
|
||||
@@ -410,6 +412,8 @@ async def delete_dashboard(dashboard_id: str):
|
||||
logger.warning(f"Failed to delete active session {sid} during dashboard deletion")
|
||||
|
||||
p_delete(dashboard_id)
|
||||
from backend.apps.service.analytics import track_dashboard_event
|
||||
track_dashboard_event(dashboard_id=dashboard_id, action="delete")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -512,4 +516,7 @@ async def duplicate_dashboard(dashboard_id: str):
|
||||
}
|
||||
atomic_write_json(os.path.join(DATA_DIR, f"{new_id}.json"), new_dashboard)
|
||||
|
||||
from backend.apps.service.analytics import track_dashboard_event
|
||||
track_dashboard_event(dashboard_id=new_id, action="create")
|
||||
|
||||
return new_dashboard
|
||||
|
||||
@@ -74,3 +74,55 @@ def shutdown_analytics() -> None:
|
||||
P_CLIENT.close()
|
||||
finally:
|
||||
P_CLIENT = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Typed fire-and-forget wrappers. Each one resolves the singleton, no-ops when
|
||||
# the client is unavailable, and swallows every error (including the SDK's
|
||||
# synchronous pydantic.ValidationError) so a bad/missing analytics call can
|
||||
# never break a product code path. Call these from feature code, not the raw
|
||||
# client.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def track_link_email(email: Optional[str]) -> None:
|
||||
if not email:
|
||||
return
|
||||
c = get_analytics_client()
|
||||
if c is None:
|
||||
return
|
||||
try:
|
||||
c.identify.link_email(email=email)
|
||||
except Exception as e:
|
||||
logger.debug("analytics link_email failed: %s", e)
|
||||
|
||||
|
||||
def track_agent_created(*, id: str, name: Optional[str] = None, dashboard_id: Optional[str] = None) -> None:
|
||||
c = get_analytics_client()
|
||||
if c is None:
|
||||
return
|
||||
try:
|
||||
c.events.agent.create(id=id, name=name, dashboard_id=dashboard_id)
|
||||
except Exception as e:
|
||||
logger.debug("analytics agent.create failed: %s", e)
|
||||
|
||||
|
||||
def track_dashboard_event(*, dashboard_id: str, action: str) -> None:
|
||||
"""action is one of: open, close, create, delete (validated by the SDK)."""
|
||||
c = get_analytics_client()
|
||||
if c is None:
|
||||
return
|
||||
try:
|
||||
c.events.dashboard.event(dashboard_id=dashboard_id, action=action)
|
||||
except Exception as e:
|
||||
logger.debug("analytics dashboard.event failed: %s", e)
|
||||
|
||||
|
||||
def track_onboarding_step(*, step_id: str, status: str) -> None:
|
||||
"""status is one of: started, completed, abandoned (validated by the SDK)."""
|
||||
c = get_analytics_client()
|
||||
if c is None:
|
||||
return
|
||||
try:
|
||||
c.events.onboarding.step(step_id=step_id, status=status)
|
||||
except Exception as e:
|
||||
logger.debug("analytics onboarding.step failed: %s", e)
|
||||
|
||||
@@ -418,6 +418,40 @@ async def service_status():
|
||||
# Frontend event endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def p_bridge_to_analytics(item: dict) -> None:
|
||||
"""Re-emit frontend `report()` events through the typed swarm-analytics SDK.
|
||||
|
||||
The frontend is browser-side and can't reach the analytics service
|
||||
directly, so onboarding steps and dashboard open/close arrive here as
|
||||
{s, a, p} envelopes. We translate the ones we care about into product
|
||||
events. Best-effort: never raises (the track_* wrappers swallow errors).
|
||||
Dashboard create/delete are NOT bridged here; those fire authoritatively
|
||||
from the dashboards routes, bridging them too would double-count.
|
||||
"""
|
||||
s = item.get("s")
|
||||
a = item.get("a")
|
||||
p = item.get("p") or {}
|
||||
if not isinstance(p, dict):
|
||||
return
|
||||
if s == "onboarding_v2":
|
||||
status = {
|
||||
"step_started": "started",
|
||||
"step_completed": "completed",
|
||||
"step_aborted": "abandoned",
|
||||
"step_selector_timeout": "abandoned",
|
||||
"step_error": "abandoned",
|
||||
}.get(a)
|
||||
step_id = p.get("step_id")
|
||||
if status and step_id:
|
||||
from backend.apps.service.analytics import track_onboarding_step
|
||||
track_onboarding_step(step_id=str(step_id), status=status)
|
||||
elif s == "dashboard" and a in ("open", "close"):
|
||||
dashboard_id = p.get("dashboard_id")
|
||||
if dashboard_id:
|
||||
from backend.apps.service.analytics import track_dashboard_event
|
||||
track_dashboard_event(dashboard_id=str(dashboard_id), action=a)
|
||||
|
||||
|
||||
@service.router.post("/submit")
|
||||
async def post_submit(body=Body(...)):
|
||||
"""Accepts three body shapes for backward compatibility:
|
||||
@@ -447,6 +481,7 @@ async def post_submit(body=Body(...)):
|
||||
if isinstance(item, dict):
|
||||
if any(k in item for k in ("s", "a", "p")):
|
||||
sync(item)
|
||||
p_bridge_to_analytics(item)
|
||||
continue
|
||||
kind = item.get("kind") or ""
|
||||
payload = item.get("payload") or {}
|
||||
@@ -459,6 +494,7 @@ async def post_submit(body=Body(...)):
|
||||
# Shape 1: frontend `report()`; flat {s, a, p, ...}
|
||||
if any(k in body for k in ("s", "a", "p")):
|
||||
sync(body)
|
||||
p_bridge_to_analytics(body)
|
||||
return {"ok": True}
|
||||
# Shape 2: legacy {kind, payload}
|
||||
kind = body.get("kind") or ""
|
||||
|
||||
@@ -173,6 +173,9 @@ async def update_settings(body: AppSettings):
|
||||
id_props["referral_source"] = body.user_referral_source
|
||||
if id_props:
|
||||
identify(id_props)
|
||||
if body.user_email:
|
||||
from backend.apps.service.analytics import track_link_email
|
||||
track_link_email(body.user_email)
|
||||
|
||||
await save_settings_async(body)
|
||||
|
||||
|
||||
+7
-2
@@ -118,9 +118,14 @@ class P_TokenScrubFilter(logging.Filter):
|
||||
try:
|
||||
if isinstance(record.msg, str) and TOKEN in record.msg:
|
||||
record.msg = record.msg.replace(TOKEN, self.P_PLACEHOLDER)
|
||||
scrubbed = self._scrub_args(record.args)
|
||||
scrubbed = self.p_scrub_args(record.args)
|
||||
if scrubbed is not record.args:
|
||||
record.args = scrubbed
|
||||
if scrubbed is None or isinstance(scrubbed, (tuple, dict)):
|
||||
record.args = scrubbed
|
||||
else:
|
||||
# A bare str can't be stored as record.args; fold it into msg and drop args.
|
||||
record.msg = str(scrubbed)
|
||||
record.args = None
|
||||
try:
|
||||
rendered = record.getMessage()
|
||||
if TOKEN in rendered:
|
||||
|
||||
+3
-3
@@ -680,7 +680,7 @@ async def mcp_meta(action: str, request: Request):
|
||||
if session.sdk_session_id:
|
||||
session.needs_fresh_session = True
|
||||
try:
|
||||
from backend.apps.agents.core.WS_MANAGER import WS_MANAGER
|
||||
from backend.apps.agents.core.ws_manager import WS_MANAGER
|
||||
await WS_MANAGER.send_to_session(parent_session_id, "agent:status", {
|
||||
"session_id": parent_session_id,
|
||||
"status": session.status,
|
||||
@@ -748,7 +748,7 @@ async def session_compact(session_id: str):
|
||||
only sets the marker; the button is the user opting into the cost).
|
||||
"""
|
||||
from backend.apps.agents.agent_manager import agent_manager
|
||||
from backend.apps.agents.core.WS_MANAGER import WS_MANAGER
|
||||
from backend.apps.agents.core.ws_manager import WS_MANAGER
|
||||
session = agent_manager.sessions.get(session_id)
|
||||
if not session:
|
||||
return JSONResponse({"error": "session not found"}, status_code=404)
|
||||
@@ -767,7 +767,7 @@ async def session_compact(session_id: str):
|
||||
async def session_clear(session_id: str):
|
||||
"""Wipe the session's UI history AND its SDK convo state (/clear slash cmd, Reset history button)."""
|
||||
from backend.apps.agents.agent_manager import agent_manager
|
||||
from backend.apps.agents.core.WS_MANAGER import WS_MANAGER
|
||||
from backend.apps.agents.core.ws_manager import WS_MANAGER
|
||||
from backend.apps.agents.core.models import MessageBranch
|
||||
session = agent_manager.sessions.get(session_id)
|
||||
if not session:
|
||||
|
||||
@@ -20,4 +20,9 @@ pytest-mock==3.15.1
|
||||
# imports/locals to ruff (see linter/checks/vulture.py).
|
||||
vulture==2.16
|
||||
ruff==0.15.17
|
||||
# pyright: powers linter/checks/pyright.py. Run in a low-noise mode (most rules
|
||||
# off) that promotes attribute/name/call existence errors to catch references to
|
||||
# methods/attributes that don't exist (e.g. a missed rename). The pip wrapper
|
||||
# downloads the pinned node-based pyright binary; node is required at lint time.
|
||||
pyright==1.1.410
|
||||
google-workspace-mcp==2.0.1
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect, useRef, useCallback, startTransition, useMemo } from 'react';
|
||||
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { openSettingsModal } from '@/shared/state/settingsSlice';
|
||||
import { report } from '@/shared/serviceClient';
|
||||
import Box from '@mui/material/Box';
|
||||
import ListItemButton from '@mui/material/ListItemButton';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
@@ -327,6 +328,19 @@ const AppShell: React.FC = () => {
|
||||
? location.pathname.split('/dashboard/')[1]
|
||||
: null;
|
||||
|
||||
// Emit dashboard open/close as the active dashboard changes. The backend
|
||||
// bridges these (POST /service/submit) into the analytics SDK; there's no
|
||||
// backend route for viewing a dashboard, so the route transition is the
|
||||
// only accurate open/close signal. close-then-open on switches.
|
||||
const prevDashboardIdRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
const prev = prevDashboardIdRef.current;
|
||||
if (prev === activeDashboardId) return;
|
||||
if (prev) report('dashboard', 'close', { dashboard_id: prev });
|
||||
if (activeDashboardId) report('dashboard', 'open', { dashboard_id: activeDashboardId });
|
||||
prevDashboardIdRef.current = activeDashboardId;
|
||||
}, [activeDashboardId]);
|
||||
|
||||
const [lastDashboardId, setLastDashboardId] = useLastDashboardId();
|
||||
const activeAppId = location.pathname.startsWith('/apps/')
|
||||
? location.pathname.split('/apps/')[1]
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Pyright runner: catches references to methods/attributes/names that don't exist.
|
||||
|
||||
The other checks can't do this. ruff is per-file and never builds a cross-symbol
|
||||
type graph; vulture finds dead (unused) definitions, not invalid references; the
|
||||
naming checks only inspect where names are *defined*, not where they're *read*.
|
||||
So a missed rename like ``self._per_session`` (when the attribute is actually
|
||||
``p_per_session``) sails through everything and only blows up at runtime.
|
||||
|
||||
Pyright resolves types/inheritance/imports, so its ``reportAttributeAccessIssue``
|
||||
flags exactly that. We run it in the lowest-noise mode possible
|
||||
(config/pyright_check.json sets typeCheckingMode "off" and re-enables only the
|
||||
existence-checking rules as errors), so this section stays high-signal without a
|
||||
full strict-mode cleanup.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from . import CheckError, is_excepted, is_lintignored
|
||||
|
||||
# Cold first run downloads the pinned node binary (pip wrapper) and warms the
|
||||
# import graph; keep this generous so a slow first pass doesn't time out and
|
||||
# silently report zero.
|
||||
_TIMEOUT = 240
|
||||
|
||||
_CONFIG_REL = Path("linter") / "config" / "pyright_check.json"
|
||||
|
||||
|
||||
def run_pyright(
|
||||
root: Path,
|
||||
exceptions: dict[str, list[str]],
|
||||
ignores: dict[Path, set[str]] | None = None,
|
||||
) -> list[str]:
|
||||
"""Run pyright on the Python backend and return existence errors."""
|
||||
pyright_bin = root / "backend" / ".venv" / "bin" / "pyright"
|
||||
if not pyright_bin.exists():
|
||||
found = shutil.which("pyright")
|
||||
if not found:
|
||||
raise CheckError("pyright executable not found in backend/.venv/bin or PATH")
|
||||
pyright_bin = Path(found)
|
||||
|
||||
config = root / _CONFIG_REL
|
||||
if not config.exists():
|
||||
raise CheckError(f"pyright config not found at {_CONFIG_REL}")
|
||||
|
||||
cmd = [str(pyright_bin), "--project", str(config), "--outputjson"]
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, text=True, cwd=str(root), timeout=_TIMEOUT,
|
||||
)
|
||||
except subprocess.TimeoutExpired as e:
|
||||
raise CheckError(f"timed out after {_TIMEOUT}s (machine under load or cold cache)") from e
|
||||
except OSError as e:
|
||||
raise CheckError(f"failed to launch pyright ({e})") from e
|
||||
|
||||
# pyright exits 0 (no errors) or 1 (errors found) on a successful run. Any
|
||||
# other code with no parseable JSON means pyright itself failed (e.g. node
|
||||
# missing, bad config) — surface it rather than treating empty as clean.
|
||||
out = result.stdout.strip()
|
||||
if not out:
|
||||
detail = (result.stderr or "no output").strip()[:300]
|
||||
raise CheckError(f"pyright produced no JSON (exit {result.returncode}): {detail}")
|
||||
try:
|
||||
data = json.loads(out)
|
||||
except json.JSONDecodeError as e:
|
||||
raise CheckError(f"pyright JSON parse failed (exit {result.returncode}): {e}") from e
|
||||
|
||||
errors: list[str] = []
|
||||
for diag in data.get("generalDiagnostics", []):
|
||||
if diag.get("severity") != "error":
|
||||
continue
|
||||
file_abs = diag.get("file", "")
|
||||
try:
|
||||
relpath = str(Path(file_abs).resolve().relative_to(root))
|
||||
except ValueError:
|
||||
# Diagnostic outside the repo root (stub/site-packages); ignore.
|
||||
continue
|
||||
if is_excepted(relpath, "pyright", exceptions):
|
||||
continue
|
||||
if ignores and is_lintignored(root / relpath, root, "pyright", ignores):
|
||||
continue
|
||||
# pyright ranges are 0-based; the linter/IDE want 1-based line+col.
|
||||
start = diag.get("range", {}).get("start", {})
|
||||
line = int(start.get("line", 0)) + 1
|
||||
col = int(start.get("character", 0)) + 1
|
||||
rule = diag.get("rule", "")
|
||||
msg = " ".join((diag.get("message", "") or "").splitlines()).strip()
|
||||
rule_part = f"{rule} " if rule else ""
|
||||
errors.append(f"{relpath}:{line}:{col}: error: [pyright] {rule_part}{msg}")
|
||||
return errors
|
||||
@@ -6,6 +6,7 @@
|
||||
"no-nested-imports": false,
|
||||
"vulture": true,
|
||||
"ruff": true,
|
||||
"pyright": true,
|
||||
"no-underscore-names": true,
|
||||
"p-private": true,
|
||||
"eslint": false,
|
||||
@@ -18,6 +19,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.",
|
||||
"pyright": "Catches references to methods/attributes/names that DON'T EXIST (e.g. self._per_session when the attribute is p_per_session after a missed rename). ruff/vulture/naming checks structurally can't: none resolve types/inheritance/imports, and the naming checks only inspect definition sites, not reads. Runs pyright (config/pyright_check.json) in the lowest-noise mode: typeCheckingMode 'off' silences the strict firehose, then re-enables only reportAttributeAccessIssue, reportUndefinedVariable, and reportMissingImports as errors. Needs node on PATH (pip pyright wrapper downloads the pinned binary). Add legitimate dynamic-access false positives to the 'pyright' exceptions list or a .lintignore-pyright sentinel.",
|
||||
"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.",
|
||||
"p-private": "Enforces the p_ private convention (case-insensitive leading p, so P_ UPPER_SNAKE constants count too) with Java-private semantics over backend/ Python. Module-level p_ symbols (top-level def/class/assignment) are file-private; class members (def p_m, self.p_x=, class-body fields p_x: T) are class-private. A reference is legal only inside the owning file (module-level) or lexically inside the owning class / a class nested in it (members). Strict: cross-file subclass access to a base's p_ member is flagged (private, not protected). Nested classes may reach the enclosing class's members (whole enclosing-class stack is checked). Detection is access-form based (no type inference): attribute access governs class members, bare-name/import governs module-level. No exemptions -- tests and __init__.py re-exports are enforced. Greenfield today (zero p_ in tree), so it ships green and only fires once the convention is used.",
|
||||
"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.",
|
||||
@@ -68,6 +70,13 @@
|
||||
"import-cycles": [],
|
||||
"vulture": [],
|
||||
"ruff": [],
|
||||
"pyright": [
|
||||
"backend/apps/subscription/free_trial.py",
|
||||
"backend/apps/agents/browser_agent_mcp_server.py",
|
||||
"backend/apps/health/health.py",
|
||||
"backend/apps/web/web.py",
|
||||
"backend/config/Apps.py"
|
||||
],
|
||||
"no-underscore-names": [
|
||||
"backend/apps/agents/agent_manager.py",
|
||||
"backend/apps/agents/agents.py",
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"include": ["../../backend"],
|
||||
"exclude": [
|
||||
"../../backend/.venv",
|
||||
"../../backend/uv-bin",
|
||||
"../../backend/data",
|
||||
"../../backend/tests",
|
||||
"../../backend/apps/outputs/webapp_template",
|
||||
"**/__pycache__"
|
||||
],
|
||||
"venvPath": "../../backend",
|
||||
"venv": ".venv",
|
||||
"pythonVersion": "3.11",
|
||||
"extraPaths": ["../.."],
|
||||
"reportMissingTypeStubs": false,
|
||||
"typeCheckingMode": "off",
|
||||
"reportAttributeAccessIssue": "error",
|
||||
"reportUndefinedVariable": "error",
|
||||
"reportMissingImports": "error"
|
||||
}
|
||||
+5
-1
@@ -22,6 +22,7 @@ from checks.classes import run_class_check
|
||||
from checks.cycles import run_cycle_check
|
||||
from checks.no_underscore_names import run_underscore_check
|
||||
from checks.p_private import run_p_private_check
|
||||
from checks.pyright import run_pyright
|
||||
from watchfiles import watch, DefaultFilter
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
@@ -29,7 +30,7 @@ CONFIG_FILE = SCRIPT_DIR / "config" / "config.json"
|
||||
|
||||
# Print order for the sections; also the order they run in.
|
||||
SECTION_ORDER = [
|
||||
"structural", "vulture", "ruff", "no-underscore-names", "p-private", "eslint",
|
||||
"structural", "vulture", "ruff", "pyright", "no-underscore-names", "p-private", "eslint",
|
||||
"knip", "endpoints", "classes", "import-cycles",
|
||||
]
|
||||
|
||||
@@ -154,6 +155,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("pyright", lambda: run_pyright(
|
||||
root, exceptions, ignores,
|
||||
) if enabled.get("pyright", True) else [])
|
||||
run_section("no-underscore-names", lambda: run_underscore_check(
|
||||
root, exceptions, excludes, ignores,
|
||||
) if enabled.get("no-underscore-names", True) else [])
|
||||
|
||||
Reference in New Issue
Block a user