mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-21 20:22:22 +02:00
256 lines
10 KiB
Python
256 lines
10 KiB
Python
"""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
|