From f2ec278c91b961975b95d8b43f419522d7631ec0 Mon Sep 17 00:00:00 2001 From: haikdc Date: Sat, 20 Jun 2026 11:37:55 -0700 Subject: [PATCH] [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 --- backend/apps/agents/agent_manager.py | 23 +++-- backend/apps/agents/agents.py | 10 +- backend/apps/agents/browser/browser_agent.py | 2 +- backend/apps/agents/core/models.py | 5 +- backend/apps/agents/core/seq_log.py | 2 +- backend/apps/agents/core/ws_manager.py | 4 +- backend/apps/auth/router.py | 6 ++ backend/apps/dashboards/dashboards.py | 7 ++ backend/apps/service/analytics.py | 52 ++++++++++ backend/apps/service/service.py | 36 +++++++ backend/apps/settings/settings.py | 3 + backend/auth.py | 9 +- backend/main.py | 6 +- backend/requirements-dev.txt | 5 + .../src/app/components/Layout/AppShell.tsx | 14 +++ linter/checks/pyright.py | 95 +++++++++++++++++++ linter/config/config.json | 9 ++ linter/config/pyright_check.json | 20 ++++ linter/lint.py | 6 +- 19 files changed, 289 insertions(+), 25 deletions(-) create mode 100644 linter/checks/pyright.py create mode 100644 linter/config/pyright_check.json diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index 3c06f3f2..9598cee9 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -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) diff --git a/backend/apps/agents/agents.py b/backend/apps/agents/agents.py index b705d4dc..385d87f0 100644 --- a/backend/apps/agents/agents.py +++ b/backend/apps/agents/agents.py @@ -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, ) diff --git a/backend/apps/agents/browser/browser_agent.py b/backend/apps/agents/browser/browser_agent.py index 800f3312..66e647f4 100644 --- a/backend/apps/agents/browser/browser_agent.py +++ b/backend/apps/agents/browser/browser_agent.py @@ -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 diff --git a/backend/apps/agents/core/models.py b/backend/apps/agents/core/models.py index f2b9f59d..2f88405e 100644 --- a/backend/apps/agents/core/models.py +++ b/backend/apps/agents/core/models.py @@ -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 diff --git a/backend/apps/agents/core/seq_log.py b/backend/apps/agents/core/seq_log.py index be55b64c..b75ec220 100644 --- a/backend/apps/agents/core/seq_log.py +++ b/backend/apps/agents/core/seq_log.py @@ -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 diff --git a/backend/apps/agents/core/ws_manager.py b/backend/apps/agents/core/ws_manager.py index a0891854..91245fc3 100644 --- a/backend/apps/agents/core/ws_manager.py +++ b/backend/apps/agents/core/ws_manager.py @@ -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) diff --git a/backend/apps/auth/router.py b/backend/apps/auth/router.py index c29cc882..7d6d6c48 100644 --- a/backend/apps/auth/router.py +++ b/backend/apps/auth/router.py @@ -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) # --------------------------------------------------------------------------- diff --git a/backend/apps/dashboards/dashboards.py b/backend/apps/dashboards/dashboards.py index dde1fe0e..9c9a6c07 100644 --- a/backend/apps/dashboards/dashboards.py +++ b/backend/apps/dashboards/dashboards.py @@ -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 diff --git a/backend/apps/service/analytics.py b/backend/apps/service/analytics.py index 9c0e78c4..531df874 100644 --- a/backend/apps/service/analytics.py +++ b/backend/apps/service/analytics.py @@ -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) diff --git a/backend/apps/service/service.py b/backend/apps/service/service.py index db8f48ae..81c9840f 100644 --- a/backend/apps/service/service.py +++ b/backend/apps/service/service.py @@ -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 "" diff --git a/backend/apps/settings/settings.py b/backend/apps/settings/settings.py index 9793323e..e1deff84 100644 --- a/backend/apps/settings/settings.py +++ b/backend/apps/settings/settings.py @@ -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) diff --git a/backend/auth.py b/backend/auth.py index 7c23b4dd..72e6bf3a 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -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: diff --git a/backend/main.py b/backend/main.py index 2840f755..6c46c356 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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: diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt index 0d9c7901..7e1e3efd 100644 --- a/backend/requirements-dev.txt +++ b/backend/requirements-dev.txt @@ -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 \ No newline at end of file diff --git a/frontend/src/app/components/Layout/AppShell.tsx b/frontend/src/app/components/Layout/AppShell.tsx index ce252e22..299a653d 100644 --- a/frontend/src/app/components/Layout/AppShell.tsx +++ b/frontend/src/app/components/Layout/AppShell.tsx @@ -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(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] diff --git a/linter/checks/pyright.py b/linter/checks/pyright.py new file mode 100644 index 00000000..daf19d05 --- /dev/null +++ b/linter/checks/pyright.py @@ -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 diff --git a/linter/config/config.json b/linter/config/config.json index 5a4bdf0e..8153c312 100644 --- a/linter/config/config.json +++ b/linter/config/config.json @@ -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", diff --git a/linter/config/pyright_check.json b/linter/config/pyright_check.json new file mode 100644 index 00000000..b1d196e0 --- /dev/null +++ b/linter/config/pyright_check.json @@ -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" +} diff --git a/linter/lint.py b/linter/lint.py index d3faf2c2..520568f4 100644 --- a/linter/lint.py +++ b/linter/lint.py @@ -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 [])