mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-28 10:49:46 +02:00
[eric] merge eric/dangling-refs-lint: a cross-entity id field must declare what it points at
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
"""Every cross-entity id field on a backend model: what entity it points at, and where that
|
||||
entity is resolved from an id.
|
||||
|
||||
OpenSwarm stores entities as JSON records that point at each other with bare strings. A workflow
|
||||
holds `edit_agent_session_id`, an app record holds `workspace_id`, a dashboard card holds
|
||||
`session_id`. Nothing at the type level says the referent exists, so a reader that forgets the miss
|
||||
renders a blank instead of a designed empty state. This module is the one place that says what each
|
||||
pointer means, and the `dangling-refs` linter check (linter/checks/dangling_refs.py) fails any new
|
||||
`*_id` / `*_ids` field on a backend model that is not declared here.
|
||||
|
||||
Data only, no logic: nothing resolves a pointer for you. The `EntityStore` rows name the function
|
||||
that does, and the linter checks those names still exist so a rename can't quietly rot this file.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import List
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class EntityKind(str, Enum):
|
||||
"""A record type other records point at by id."""
|
||||
|
||||
SESSION = "session"
|
||||
DASHBOARD = "dashboard"
|
||||
WORKFLOW = "workflow"
|
||||
WORKFLOW_RUN = "workflow_run"
|
||||
OUTPUT = "output"
|
||||
WORKSPACE = "workspace"
|
||||
|
||||
|
||||
class EntityStore(BaseModel):
|
||||
"""Where one entity kind is looked up by id, so a reader knows what a pointer resolves against."""
|
||||
|
||||
model_config = ConfigDict(validate_assignment=True, frozen=True)
|
||||
|
||||
kind: EntityKind
|
||||
module: str
|
||||
lookup: str
|
||||
|
||||
|
||||
class EntityReference(BaseModel):
|
||||
"""One id field on one backend model, and the entity kind it points at."""
|
||||
|
||||
model_config = ConfigDict(validate_assignment=True, frozen=True)
|
||||
|
||||
module: str
|
||||
model: str
|
||||
field: str
|
||||
target: EntityKind
|
||||
|
||||
|
||||
ENTITY_STORES: List[EntityStore] = [
|
||||
EntityStore(kind=EntityKind.SESSION, module="backend.apps.agents.manager.session.session_store", lookup="load_session_data"),
|
||||
EntityStore(kind=EntityKind.DASHBOARD, module="backend.apps.dashboards.dashboards", lookup="load"),
|
||||
EntityStore(kind=EntityKind.WORKFLOW, module="backend.apps.workflows.storage", lookup="get_workflow"),
|
||||
# No get_run(run_id) exists, so a run is found by scanning the listing; a stale last_run_id just reads as "no run".
|
||||
EntityStore(kind=EntityKind.WORKFLOW_RUN, module="backend.apps.workflows.storage", lookup="list_all_runs"),
|
||||
EntityStore(kind=EntityKind.OUTPUT, module="backend.apps.outputs.workspace_io", lookup="load_output"),
|
||||
# A workspace is a folder on disk, not a record, so its only by-id lookup is the read route.
|
||||
EntityStore(kind=EntityKind.WORKSPACE, module="backend.apps.outputs.outputs", lookup="read_workspace"),
|
||||
]
|
||||
|
||||
CROSS_ENTITY_REFERENCES: List[EntityReference] = [
|
||||
EntityReference(module="backend.apps.agents.core.models", model="AgentConfig", field="dashboard_id", target=EntityKind.DASHBOARD),
|
||||
EntityReference(module="backend.apps.agents.core.models", model="AgentConfig", field="selected_app_output_ids", target=EntityKind.OUTPUT),
|
||||
EntityReference(module="backend.apps.agents.core.models", model="AgentConfig", field="workflow_edit_id", target=EntityKind.WORKFLOW),
|
||||
EntityReference(module="backend.apps.agents.core.models", model="AgentConfig", field="workflow_run_id", target=EntityKind.WORKFLOW_RUN),
|
||||
EntityReference(module="backend.apps.agents.core.models", model="AgentSession", field="dashboard_id", target=EntityKind.DASHBOARD),
|
||||
EntityReference(module="backend.apps.agents.core.models", model="AgentSession", field="parent_session_id", target=EntityKind.SESSION),
|
||||
EntityReference(module="backend.apps.agents.core.models", model="AgentSession", field="workflow_edit_id", target=EntityKind.WORKFLOW),
|
||||
EntityReference(module="backend.apps.agents.core.models", model="AgentSession", field="workflow_run_id", target=EntityKind.WORKFLOW_RUN),
|
||||
EntityReference(module="backend.apps.agents.core.models", model="ApprovalRequest", field="session_id", target=EntityKind.SESSION),
|
||||
EntityReference(module="backend.apps.agents.manager.Messaging", model="QueuedMessage", field="selected_app_output_ids", target=EntityKind.OUTPUT),
|
||||
EntityReference(module="backend.apps.agents.manager.streaming.HookContext", model="HookContext", field="session_id", target=EntityKind.SESSION),
|
||||
EntityReference(module="backend.apps.dashboard_layout.models", model="CardPosition", field="session_id", target=EntityKind.SESSION),
|
||||
EntityReference(module="backend.apps.dashboard_layout.models", model="ViewCardPosition", field="output_id", target=EntityKind.OUTPUT),
|
||||
EntityReference(module="backend.apps.dashboards.models", model="BrowserCardPosition", field="dashboard_id", target=EntityKind.DASHBOARD),
|
||||
EntityReference(module="backend.apps.dashboards.models", model="CardPosition", field="session_id", target=EntityKind.SESSION),
|
||||
EntityReference(module="backend.apps.dashboards.models", model="DashboardLayout", field="expanded_session_ids", target=EntityKind.SESSION),
|
||||
EntityReference(module="backend.apps.dashboards.models", model="ViewCardPosition", field="output_id", target=EntityKind.OUTPUT),
|
||||
EntityReference(module="backend.apps.outputs.models", model="AgentCreateAppRequest", field="parent_session_id", target=EntityKind.SESSION),
|
||||
EntityReference(module="backend.apps.outputs.models", model="Output", field="session_id", target=EntityKind.SESSION),
|
||||
EntityReference(module="backend.apps.outputs.models", model="Output", field="workspace_id", target=EntityKind.WORKSPACE),
|
||||
EntityReference(module="backend.apps.outputs.models", model="OutputCreate", field="session_id", target=EntityKind.SESSION),
|
||||
EntityReference(module="backend.apps.outputs.models", model="OutputCreate", field="workspace_id", target=EntityKind.WORKSPACE),
|
||||
EntityReference(module="backend.apps.outputs.models", model="OutputExecute", field="output_id", target=EntityKind.OUTPUT),
|
||||
EntityReference(module="backend.apps.outputs.models", model="OutputExecuteResult", field="output_id", target=EntityKind.OUTPUT),
|
||||
EntityReference(module="backend.apps.outputs.models", model="OutputUpdate", field="session_id", target=EntityKind.SESSION),
|
||||
EntityReference(module="backend.apps.outputs.models", model="OutputUpdate", field="workspace_id", target=EntityKind.WORKSPACE),
|
||||
EntityReference(module="backend.apps.outputs.models", model="PublishPreflightRequest", field="output_id", target=EntityKind.OUTPUT),
|
||||
EntityReference(module="backend.apps.outputs.models", model="PublishRequest", field="output_id", target=EntityKind.OUTPUT),
|
||||
EntityReference(module="backend.apps.outputs.models", model="WorkspaceSeedRequest", field="workspace_id", target=EntityKind.WORKSPACE),
|
||||
EntityReference(module="backend.apps.skills.models", model="SkillWorkspaceSeedRequest", field="workspace_id", target=EntityKind.WORKSPACE),
|
||||
EntityReference(module="backend.apps.workflows.models", model="AskRunBody", field="run_id", target=EntityKind.WORKFLOW_RUN),
|
||||
EntityReference(module="backend.apps.workflows.models", model="MissedRun", field="workflow_id", target=EntityKind.WORKFLOW),
|
||||
EntityReference(module="backend.apps.workflows.models", model="Workflow", field="dashboard_id", target=EntityKind.DASHBOARD),
|
||||
EntityReference(module="backend.apps.workflows.models", model="Workflow", field="edit_agent_session_id", target=EntityKind.SESSION),
|
||||
EntityReference(module="backend.apps.workflows.models", model="Workflow", field="last_run_id", target=EntityKind.WORKFLOW_RUN),
|
||||
EntityReference(module="backend.apps.workflows.models", model="Workflow", field="last_test_session_id", target=EntityKind.SESSION),
|
||||
EntityReference(module="backend.apps.workflows.models", model="Workflow", field="schedule_agent_session_id", target=EntityKind.SESSION),
|
||||
EntityReference(module="backend.apps.workflows.models", model="Workflow", field="source_session_id", target=EntityKind.SESSION),
|
||||
EntityReference(module="backend.apps.workflows.models", model="WorkflowCreate", field="dashboard_id", target=EntityKind.DASHBOARD),
|
||||
EntityReference(module="backend.apps.workflows.models", model="WorkflowCreate", field="source_session_id", target=EntityKind.SESSION),
|
||||
EntityReference(module="backend.apps.workflows.models", model="WorkflowRun", field="session_id", target=EntityKind.SESSION),
|
||||
EntityReference(module="backend.apps.workflows.models", model="WorkflowRun", field="workflow_id", target=EntityKind.WORKFLOW),
|
||||
]
|
||||
@@ -42,6 +42,16 @@ These rules apply to `.py`, `.ts`, `.tsx`, `.js`, and `.jsx` files.
|
||||
|
||||
Both are backend-only and grandfather pre-existing debt via the `no-underscore-names` / `p-private` exception lists; new code must be clean. (Ported from Haik's linter, which also adds Pyright + Ruff and should eventually supersede this subset.)
|
||||
|
||||
### Cross-entity references
|
||||
|
||||
**Declared reference targets (`dangling-refs`)** — Backend entities are JSON records that point at each other with bare strings (`Workflow.edit_agent_session_id`, `Output.workspace_id`, `CardPosition.session_id`). The type says `str`, so nothing warns a reader that the referent may be gone, and the miss renders as a blank card instead of a designed empty state.
|
||||
|
||||
Every field named `*_id` / `*_ids` on a pydantic `BaseModel` under `backend/` must therefore be declared in `backend/config/entity_references.py`, which names the entity it points at and the store that resolves that entity by id. A model's own primary key is spelled `id`, so it never matches; neither do words that merely end in "id" (`uuid`, `grid`, `valid`), since the underscore is required.
|
||||
|
||||
The registry is verified in both directions: an entry for a field that no longer exists is an error, and so is an `EntityStore` whose lookup function has been renamed away. A registry nobody checks is a registry that rots.
|
||||
|
||||
Pre-existing fields are grandfathered per FIELD, not per file — the exception entries are keyed `<path>::<Model>.<field>`, 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.
|
||||
|
||||
## How it runs
|
||||
|
||||
### Linter watch (automatic)
|
||||
@@ -154,6 +164,7 @@ The project's code conventions live here (a tracked file) rather than in `CLAUDE
|
||||
### Enforced by the linter
|
||||
- **No leading `_`** — use `p_` for private. (`no-underscore-names`, backend)
|
||||
- **`p_` is a private access boundary** — a `p_` name used across files/classes must be public. (`p-private`, backend)
|
||||
- **Cross-entity id fields declare their target.** A new `*_id` on a backend model must be registered in `backend/config/entity_references.py`. (`dangling-refs`, backend)
|
||||
- **No runtime import cycles.** (`import-cycles`)
|
||||
- **File and folder size caps.** (`max-file-lines`, `max-folder-items`)
|
||||
|
||||
@@ -190,6 +201,7 @@ linter/
|
||||
knip.py # knip unused-code runner
|
||||
endpoints.py # orphaned endpoint detection
|
||||
classes.py # class-level dead code detection
|
||||
dangling_refs.py # cross-entity id fields must declare a target entity
|
||||
config/ # all configuration files
|
||||
config.json # enabled checks, rules, exclusions, exceptions
|
||||
pyrightconfig.json # python type checking config
|
||||
@@ -211,6 +223,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`. |
|
||||
| `dangling-refs` | on | Cross-entity id fields on backend models must declare a target entity. 42 of the 74 existing fields are in the registry; the other 32 are grandfathered per field. |
|
||||
| `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. |
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
"""Every cross-entity id field on a backend pydantic model must say what it points at.
|
||||
|
||||
OpenSwarm stores entities as JSON records that reference each other with bare strings
|
||||
(``Workflow.edit_agent_session_id``, ``Output.workspace_id``, ``CardPosition.session_id``). The
|
||||
type says ``str``, so nothing tells a reader the referent may be gone, and a reader that forgets
|
||||
renders a blank instead of a designed empty state. Measured on real data: 0 of 5 workflow chat
|
||||
pointers resolved, and 8 of 10 app workspaces had no record at all.
|
||||
|
||||
The rule: a field named ``*_id`` / ``*_ids`` on a class that inherits from pydantic ``BaseModel``
|
||||
must be declared in ``backend/config/entity_references.py``, which names the entity it points at
|
||||
and the store that resolves it. A model's own primary key is spelled ``id`` and so never matches.
|
||||
Pre-existing fields are grandfathered per FIELD (not per file) in the ``dangling-refs`` exception
|
||||
list, keyed ``<path>::<Model>.<field>``, so a new field in an old model is still caught.
|
||||
|
||||
The registry is checked back: an entry for a field that no longer exists is an error, and so is a
|
||||
store whose lookup function has been renamed away. A registry nobody verifies is a registry that
|
||||
rots.
|
||||
|
||||
Scoped to ``backend/`` Python, like checks/classes.py. One AST pass, no imports of backend code
|
||||
(CI lints with a bare interpreter that has no pydantic).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Set, Tuple
|
||||
|
||||
from . import CheckError, is_excepted, is_excluded, is_lintignored
|
||||
|
||||
RULE = "dangling-refs"
|
||||
REGISTRY_REL = "backend/config/entity_references.py"
|
||||
REFERENCES_NAME = "CROSS_ENTITY_REFERENCES"
|
||||
STORES_NAME = "ENTITY_STORES"
|
||||
KIND_ENUM_NAME = "EntityKind"
|
||||
|
||||
# (dotted module, model name, field name)
|
||||
FieldKey = Tuple[str, str, str]
|
||||
|
||||
|
||||
def p_dotted(rel: str) -> str:
|
||||
"""``backend/apps/foo/models.py`` -> ``backend.apps.foo.models``."""
|
||||
parts = list(Path(rel).with_suffix("").parts)
|
||||
if parts and parts[-1] == "__init__":
|
||||
parts.pop()
|
||||
return ".".join(parts)
|
||||
|
||||
|
||||
def p_const_str(node: Optional[ast.AST]) -> Optional[str]:
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
||||
return node.value
|
||||
return None
|
||||
|
||||
|
||||
def p_attr_name(node: Optional[ast.AST]) -> Optional[str]:
|
||||
"""``EntityKind.SESSION`` -> ``SESSION``."""
|
||||
return node.attr if isinstance(node, ast.Attribute) else None
|
||||
|
||||
|
||||
def p_base_names(node: ast.ClassDef) -> List[str]:
|
||||
names: List[str] = []
|
||||
for base in node.bases:
|
||||
target: ast.AST = base.value if isinstance(base, ast.Subscript) else base
|
||||
if isinstance(target, ast.Name):
|
||||
names.append(target.id)
|
||||
elif isinstance(target, ast.Attribute):
|
||||
names.append(target.attr)
|
||||
return names
|
||||
|
||||
|
||||
def p_is_class_var(node: ast.AnnAssign) -> bool:
|
||||
annotation: ast.AST = node.annotation
|
||||
if isinstance(annotation, ast.Subscript):
|
||||
annotation = annotation.value
|
||||
if isinstance(annotation, ast.Name):
|
||||
return annotation.id == "ClassVar"
|
||||
return isinstance(annotation, ast.Attribute) and annotation.attr == "ClassVar"
|
||||
|
||||
|
||||
class BackendIndex:
|
||||
"""Everything one AST pass over ``backend/`` needs to hand the rest of the check."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.bases: Dict[str, List[str]] = {}
|
||||
self.top_level: Dict[str, Set[str]] = {}
|
||||
# (rel path, dotted module, model, field, lineno, col)
|
||||
self.id_fields: List[Tuple[str, str, str, str, int, int]] = []
|
||||
self.p_model_cache: Dict[str, bool] = {}
|
||||
|
||||
def is_model(self, name: str, seen: Optional[Set[str]] = None) -> bool:
|
||||
if name == "BaseModel":
|
||||
return True
|
||||
cached = self.p_model_cache.get(name)
|
||||
if cached is not None:
|
||||
return cached
|
||||
seen = seen if seen is not None else set()
|
||||
if name in seen:
|
||||
return False
|
||||
seen.add(name)
|
||||
result = any(self.is_model(b, seen) for b in self.bases.get(name, []))
|
||||
self.p_model_cache[name] = result
|
||||
return result
|
||||
|
||||
|
||||
def p_index_file(index: BackendIndex, tree: ast.Module, rel: str) -> None:
|
||||
module = p_dotted(rel)
|
||||
names: Set[str] = set()
|
||||
for node in tree.body:
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
||||
names.add(node.name)
|
||||
elif isinstance(node, ast.Assign):
|
||||
names.update(t.id for t in node.targets if isinstance(t, ast.Name))
|
||||
elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
|
||||
names.add(node.target.id)
|
||||
index.top_level[module] = names
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.ClassDef):
|
||||
continue
|
||||
index.bases.setdefault(node.name, []).extend(p_base_names(node))
|
||||
for stmt in node.body:
|
||||
if not isinstance(stmt, ast.AnnAssign) or not isinstance(stmt.target, ast.Name):
|
||||
continue
|
||||
field = stmt.target.id
|
||||
if not (field.endswith("_id") or field.endswith("_ids")) or p_is_class_var(stmt):
|
||||
continue
|
||||
index.id_fields.append((rel, module, node.name, field, stmt.lineno, stmt.col_offset))
|
||||
|
||||
|
||||
class Registry:
|
||||
"""The parsed contents of entity_references.py, as data."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.kinds: Set[str] = set()
|
||||
self.stores: Dict[str, Tuple[str, str, int]] = {}
|
||||
self.references: Dict[FieldKey, Tuple[str, int]] = {}
|
||||
|
||||
|
||||
def p_list_literal(tree: ast.Module, name: str) -> Optional[List[ast.expr]]:
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.Assign):
|
||||
targets: List[ast.expr] = list(node.targets)
|
||||
elif isinstance(node, ast.AnnAssign):
|
||||
targets = [node.target]
|
||||
else:
|
||||
continue
|
||||
if any(isinstance(t, ast.Name) and t.id == name for t in targets):
|
||||
return list(node.value.elts) if isinstance(node.value, ast.List) else None
|
||||
return None
|
||||
|
||||
|
||||
def p_parse_registry(path: Path) -> Registry:
|
||||
try:
|
||||
tree = ast.parse(path.read_text(), filename=REGISTRY_REL)
|
||||
except (OSError, SyntaxError) as exc:
|
||||
raise CheckError(f"cannot read the reference registry at {REGISTRY_REL}: {exc}") from exc
|
||||
|
||||
registry = Registry()
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.ClassDef) and node.name == KIND_ENUM_NAME:
|
||||
for stmt in node.body:
|
||||
if isinstance(stmt, ast.Assign):
|
||||
registry.kinds.update(t.id for t in stmt.targets if isinstance(t, ast.Name))
|
||||
|
||||
stores = p_list_literal(tree, STORES_NAME)
|
||||
references = p_list_literal(tree, REFERENCES_NAME)
|
||||
if stores is None or references is None:
|
||||
raise CheckError(f"{REGISTRY_REL} must assign {STORES_NAME} and {REFERENCES_NAME} to list literals")
|
||||
|
||||
for element in stores:
|
||||
if not isinstance(element, ast.Call):
|
||||
continue
|
||||
kw = {k.arg: k.value for k in element.keywords if k.arg}
|
||||
kind = p_attr_name(kw.get("kind"))
|
||||
module = p_const_str(kw.get("module"))
|
||||
lookup = p_const_str(kw.get("lookup"))
|
||||
if kind and module and lookup:
|
||||
registry.stores[kind] = (module, lookup, element.lineno)
|
||||
|
||||
for element in references:
|
||||
if not isinstance(element, ast.Call):
|
||||
continue
|
||||
kw = {k.arg: k.value for k in element.keywords if k.arg}
|
||||
module = p_const_str(kw.get("module"))
|
||||
model = p_const_str(kw.get("model"))
|
||||
field = p_const_str(kw.get("field"))
|
||||
target = p_attr_name(kw.get("target"))
|
||||
if module and model and field and target:
|
||||
registry.references[(module, model, field)] = (target, element.lineno)
|
||||
return registry
|
||||
|
||||
|
||||
def p_registry_error(lineno: int, message: str) -> str:
|
||||
return f"{REGISTRY_REL}:{lineno}:1: error: [{RULE}] {message}"
|
||||
|
||||
|
||||
def p_check_registry(registry: Registry, index: BackendIndex) -> List[str]:
|
||||
"""Fail loudly when the registry has drifted from the code it describes."""
|
||||
errors: List[str] = []
|
||||
for kind, (module, lookup, lineno) in sorted(registry.stores.items()):
|
||||
if kind not in registry.kinds:
|
||||
errors.append(p_registry_error(lineno, f"store kind '{kind}' is not a member of {KIND_ENUM_NAME}"))
|
||||
elif lookup not in index.top_level.get(module, set()):
|
||||
errors.append(p_registry_error(lineno, f"store for '{kind}' points at {module}.{lookup}, which no longer exists"))
|
||||
|
||||
declared = {(module, model, field) for _, module, model, field, _, _ in index.id_fields}
|
||||
for (module, model, field), (target, lineno) in sorted(registry.references.items()):
|
||||
if target not in registry.stores:
|
||||
errors.append(p_registry_error(lineno, f"'{model}.{field}' targets '{target}', which has no {STORES_NAME} row"))
|
||||
if (module, model, field) not in declared:
|
||||
errors.append(p_registry_error(lineno, f"'{model}.{field}' in {module} matches no field on a backend model; it was renamed or removed"))
|
||||
return errors
|
||||
|
||||
|
||||
def run_dangling_refs_check(
|
||||
root: Path,
|
||||
exceptions: Dict[str, List[str]],
|
||||
excludes: List[str],
|
||||
ignores: Optional[Dict[Path, Set[str]]] = None,
|
||||
) -> List[str]:
|
||||
"""Flag ``*_id`` / ``*_ids`` fields on backend models that declare no target entity."""
|
||||
backend = root / "backend"
|
||||
if not backend.is_dir():
|
||||
return []
|
||||
|
||||
index = BackendIndex()
|
||||
for pyfile in sorted(backend.rglob("*.py")):
|
||||
if is_excluded(pyfile, root, excludes):
|
||||
continue
|
||||
# Forward slashes even on Windows, so the config's exception globs match there too.
|
||||
rel = pyfile.relative_to(root).as_posix()
|
||||
try:
|
||||
tree = ast.parse(pyfile.read_text(), filename=rel)
|
||||
except (OSError, SyntaxError):
|
||||
continue
|
||||
p_index_file(index, tree, rel)
|
||||
|
||||
registry = p_parse_registry(root / REGISTRY_REL)
|
||||
errors = p_check_registry(registry, index)
|
||||
|
||||
for rel, module, model, field, lineno, col in index.id_fields:
|
||||
if not index.is_model(model):
|
||||
continue
|
||||
if (module, model, field) in registry.references:
|
||||
continue
|
||||
if is_excepted(f"{rel}::{model}.{field}", RULE, exceptions):
|
||||
continue
|
||||
if ignores and is_lintignored(root / rel, root, RULE, ignores):
|
||||
continue
|
||||
errors.append(
|
||||
f"{rel}:{lineno}:{col + 1}: error: [{RULE}] cross-entity id field "
|
||||
f"'{model}.{field}' declares no target entity; add an EntityReference for it in "
|
||||
f"{REGISTRY_REL}, or grandfather '{rel}::{model}.{field}' in the dangling-refs exceptions"
|
||||
)
|
||||
return errors
|
||||
@@ -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 <path>::<Model>.<field> 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",
|
||||
|
||||
@@ -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
|
||||
|
||||
+14
-4
@@ -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,14 @@ 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 []
|
||||
|
||||
# A dangling reference becomes a linter error at declaration time, not 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 +139,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 +154,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 +166,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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user