From 27e1ef2f06c3fc0f792d7e874f004f80da72fb84 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Fri, 31 Jul 2026 17:10:56 -0700 Subject: [PATCH] [eric] linter: wire dangling-refs in, grandfathering the 32 pre-existing fields per field not per file --- linter/config/config.json | 39 +++++++++++++++++++++++++++++- linter/config/vulture_whitelist.py | 6 +++++ linter/lint.py | 19 ++++++++++++--- 3 files changed, 59 insertions(+), 5 deletions(-) diff --git a/linter/config/config.json b/linter/config/config.json index 2d5c9613..d8f89753 100644 --- a/linter/config/config.json +++ b/linter/config/config.json @@ -11,6 +11,7 @@ "classes": false, "no-underscore-names": true, "p-private": true, + "dangling-refs": true, "ruff": true, "pyright": true }, @@ -22,7 +23,8 @@ "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. 2026-07 desktop-shell additions: Dashboard canvas/cards/desktop + hooks/interaction + hooks/lifecycle, AgentChat bubbles/tool-ui, and shared/styles are flat peer collections (one component or hook per concern) that crossed 7 as the redesign surface grew. frontend/src/toolui carries a whole-subtree .lintignore: vendored tool-ui component library (pierre), same treatment as mcp-bundles. openswarm-edge/app is the edge's flat one-module-per-concern set (routing, bundles, inject, ratelimit, sandbox, and the vendored code_safety gate); it crossed 7 when the sandbox's static gate was split out to mirror the desktop file byte for byte.", "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.", "ruff + pyright": "Ported from Haik's linter (haik/feat/ingest). ruff is narrowed to F401/F811/F841 (unused imports/redefs/locals) and intentionally DROPS Haik's ARG001/ARG002 (unused args): our SDK-callback signatures require unused params (can_use_tool/pre_tool_hook take a `context` they don't use) and we ban the `_unused` prefix, so ARG is noise here. pyright runs Haik's existence-only config (typeCheckingMode off) with reportAttributeAccessIssue ENABLED: the AgentManager behavior classes now inherit a typing-only AgentManagerProtocol base (manager/AgentManagerProtocol.py) that declares the composed __init__ state + cross-class methods, so the checker sees self.sessions etc. from inside a mixin. pyright caught real bugs: a dangling `_conns` ref + TWO broken lazy imports (`_load_all`/`_load` from outputs.py, renamed to load_all/load in workspace_io but the import sites weren't updated \u2014 App Builder workspace seeding/name-sync was silently failing in a try/except). The one grandfathered SURFACE file (handle_assistant_message) is the SDK-optional try/except-import boundary (TextBlock=object fallback defeats isinstance narrowing). Both grandfather pre-existing debt by file; the refactor surface is clean. Requires `ruff` + `pyright` on PATH (added to requirements-dev.txt); pyright's config expects the venv at backend/.venv.", - "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. browser_cookies.py is excepted for `_fields_` only: a ctypes.Structure protocol name required by the ctypes metaclass, not our naming." + "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. browser_cookies.py is excepted for `_fields_` only: a ctypes.Structure protocol name required by the ctypes metaclass, not our naming.", + "dangling-refs": "Every *_id / *_ids field on a backend pydantic model must name the entity it points at, in backend/config/entity_references.py. A model's own primary key is spelled `id`, which never matches the suffix, and neither do words that merely END in id (uuid, grid, valid) since the underscore is required. 42 of the 74 existing fields are declared in the registry (sessions, dashboards, workflows, workflow runs, apps/outputs, workspaces); the 32 listed here are grandfathered debt, and the entry is keyed ::. rather than by file ON PURPOSE, so a NEW id field added to an already-listed model is still caught (a file glob would exempt workflows/models.py forever, which is exactly where the next dangling pointer lands). The grandfathered set is what does not resolve against a store: renderer-owned live objects (browser_id, selected_browser_ids, selected_setting_ids), ids internal to a single record (active_branch_id, msg_id, parent_id, fork_point_message_id, compacted_through_msg_id), external protocol ids we do not own (sdk_session_id, client_message_id, connection_id, installation_id, user_id), telemetry echoes (analytics bridges), and the skill-registry / .swarm-bundle entities that have no backend store module yet. Move an entry out of this list and into the registry when its entity gets one. backend/tests/*::* is blanket-exempt: a test-local model is not a persisted entity. The registry is checked back both ways, so an entry for a deleted field, or a store whose lookup function was renamed, is an error too." }, "rules": { "max-file-lines": 300, @@ -231,6 +233,41 @@ "backend/tests/test_workflows_semantics.py", "backend/tests/test_workflows_storage.py" ], + "dangling-refs": [ + "backend/apps/agents/core/models.py::AgentSession.active_branch_id", + "backend/apps/agents/core/models.py::AgentSession.browser_id", + "backend/apps/agents/core/models.py::AgentSession.compacted_through_msg_id", + "backend/apps/agents/core/models.py::AgentSession.sdk_session_id", + "backend/apps/agents/core/models.py::ApprovalResponse.request_id", + "backend/apps/agents/core/models.py::Message.branch_id", + "backend/apps/agents/core/models.py::Message.client_message_id", + "backend/apps/agents/core/models.py::Message.parent_id", + "backend/apps/agents/core/models.py::MessageBranch.fork_point_message_id", + "backend/apps/agents/core/models.py::MessageBranch.parent_branch_id", + "backend/apps/agents/manager/Messaging.py::QueuedMessage.client_message_id", + "backend/apps/agents/manager/Messaging.py::QueuedMessage.selected_browser_ids", + "backend/apps/agents/manager/Messaging.py::QueuedMessage.selected_setting_ids", + "backend/apps/agents/manager/permissions/workflow_approval.py::WorkflowApprovalMemory.current_step_id", + "backend/apps/agents/manager/streaming/PartialReply.py::PartialReply.branch_id", + "backend/apps/agents/manager/streaming/PartialReply.py::PartialReply.msg_id", + "backend/apps/agents/manager/streaming/state.py::ThinkingState.msg_id", + "backend/apps/agents/manager/streaming/state.py::TurnState.stream_text_msg_id", + "backend/apps/dashboards/models.py::BrowserCardPosition.browser_id", + "backend/apps/nine_router/credential_store.py::ProviderCredential.connection_id", + "backend/apps/outputs/models.py::OutputVersion.parent_id", + "backend/apps/service/analytics/agent_bridge.py::BroadcastMessage.branch_id", + "backend/apps/service/analytics/agent_bridge.py::BroadcastMessage.parent_id", + "backend/apps/service/analytics/frontend_bridge.py::FrontendEventProps.dashboard_id", + "backend/apps/service/analytics/frontend_bridge.py::FrontendEventProps.step_id", + "backend/apps/settings/models.py::AppSettings.installation_id", + "backend/apps/settings/models.py::AppSettings.user_id", + "backend/apps/skill_registry/skill_registry.py::p_InstallRequest.skill_id", + "backend/apps/skill_registry/skill_registry.py::p_UpdateRequest.skill_id", + "backend/apps/swarm/models.py::EntityRef.bundle_id", + "backend/apps/swarm/models.py::ImportCommitResponse.root_id", + "backend/apps/swarm/models.py::Manifest.bundle_id", + "backend/tests/*::*" + ], "ruff": [ "backend/apps/agents/agents.py", "backend/apps/agents/browser/browser_agent.py", diff --git a/linter/config/vulture_whitelist.py b/linter/config/vulture_whitelist.py index 91ddb509..5de8dfcc 100644 --- a/linter/config/vulture_whitelist.py +++ b/linter/config/vulture_whitelist.py @@ -72,3 +72,9 @@ resolve_forced_tools used_llm usage_summary last_run_at + +# config/entity_references.py: the cross-entity reference registry. Its consumer is +# the dangling-refs linter check, which reads the file as data rather than importing +# it, so vulture sees two module-level tables nobody touches. +ENTITY_STORES +CROSS_ENTITY_REFERENCES diff --git a/linter/lint.py b/linter/lint.py index ccd605f2..a259ae3e 100644 --- a/linter/lint.py +++ b/linter/lint.py @@ -18,6 +18,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.dangling_refs import run_dangling_refs_check from checks.no_underscore_names import run_underscore_check from checks.p_private import run_p_private_check from checks.ruff import run_ruff @@ -33,7 +34,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], 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], list[str], list[str], list[str]]: config = load_config() enabled: dict[str, bool] = config.get("enabled", {}) rules: dict[str, int] = config["rules"] @@ -114,6 +115,15 @@ def run_checks(root: Path) -> tuple[list[str], list[str], list[str], list[str], 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 [] + # Cross-entity id fields must name the entity they point at, so a dangling reference is a + # linter error at declaration time instead of a blank card six months later. + dangling_ref_errors: list[str] = [] + if enabled.get("dangling-refs", False): + try: + dangling_ref_errors = run_dangling_refs_check(root, exceptions, excludes, ignores) + except CheckError as e: + dangling_ref_errors = [f"dangling-refs: check could not run: {e.reason}"] + # ruff (scoped dead-code codes) + pyright (existence errors), also from Haik's # linter. Both shell out to a tool, so a missing tool / timeout raises CheckError # and is surfaced as a loud error rather than a silently-clean empty result. @@ -130,7 +140,7 @@ def run_checks(root: Path) -> tuple[list[str], list[str], list[str], list[str], except CheckError as e: pyright_errors = [f"pyright: check could not run: {e.reason}"] - 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), sorted(ruff_errors), sorted(pyright_errors) + 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), sorted(dangling_ref_errors), sorted(ruff_errors), sorted(pyright_errors) def _print_section(name: str, errors: list[str]) -> None: @@ -145,8 +155,8 @@ def print_results( eslint_errors: list[str], knip_errors: list[str], endpoint_errors: list[str], class_errors: list[str], cycle_errors: list[str], underscore_errors: list[str], - p_private_errors: list[str], ruff_errors: list[str], - pyright_errors: list[str], + p_private_errors: list[str], dangling_ref_errors: list[str], + ruff_errors: list[str], pyright_errors: list[str], ) -> None: _print_section("structural", structural_errors) _print_section("vulture", vulture_errors) @@ -157,6 +167,7 @@ def print_results( _print_section("import-cycles", cycle_errors) _print_section("no-underscore-names", underscore_errors) _print_section("p-private", p_private_errors) + _print_section("dangling-refs", dangling_ref_errors) _print_section("ruff", ruff_errors) _print_section("pyright", pyright_errors)