validate_component_protocol_signatures
Check that component classes have required methods with correct return-type annotations, flagging missing methods or mismatched annotations.
Instructions
AST-check that each required component class has the required method with a matching return-type annotation (if annotated at all).
- STR-003: class is missing the required method.
- VAL-006: method declares a return annotation but it doesn't match
the expected BaseModel (EntrySignalOutput / ExitSignalOutput / ...).
Missing annotations are NOT flagged (policy vs correctness — missing
annotation is a stylistic choice, Pydantic catches runtime
mismatches). Missing files are silently skipped (preflight STR-001
territory).
Returns ``{"any_errors": bool, "findings": [...]}``.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| strategy_dir | Yes |
Implementation Reference
- echolon/mcp/server.py:280-299 (handler)MCP tool handler that delegates to the internal validator. Defined as a @server.tool() decorated function validate_component_protocol_signatures(strategy_dir: str) -> dict. Imports and calls validate_component_signatures from echolon.strategy.validators.component_signatures and returns its .to_dict() result.
@server.tool() def validate_component_protocol_signatures(strategy_dir: str) -> dict: """AST-check that each required component class has the required method with a matching return-type annotation (if annotated at all). - STR-003: class is missing the required method. - VAL-006: method declares a return annotation but it doesn't match the expected BaseModel (EntrySignalOutput / ExitSignalOutput / ...). Missing annotations are NOT flagged (policy vs correctness — missing annotation is a stylistic choice, Pydantic catches runtime mismatches). Missing files are silently skipped (preflight STR-001 territory). Returns ``{"any_errors": bool, "findings": [...]}``. """ from echolon.strategy.validators.component_signatures import ( validate_component_signatures as _impl, ) return _impl(strategy_dir=strategy_dir).to_dict() - Core implementation: validate_component_signatures() performs AST-based checking of 4 required component files (entry.py, exit.py, risk.py, sizer.py). Checks STR-003 (class missing required method) and VAL-006 (return annotation doesn't match expected BaseModel). Silently skips missing files and missing annotations (by design).
def validate_component_signatures(strategy_dir: "Path | str") -> Report: """Return a Report with findings for method / return-annotation issues across the 4 required component files in ``strategy_dir``. Silently skips files that don't exist — that's preflight's STR-001 concern, not ours. """ strategy_dir = Path(strategy_dir) report = Report() for file_name, (class_name, method_name, expected_return) in _CONTRACT.items(): file_path = strategy_dir / file_name if not file_path.exists(): continue # file-presence is preflight's concern (STR-001) try: tree = ast.parse(file_path.read_text(encoding="utf-8")) except SyntaxError as e: # Syntax errors propagate upstream — surface as STR-003 so # the caller still gets a structured finding. report.add(Finding( code="STR-003", message=f"Cannot parse {file_name}: {e}", context={ "file": str(file_path), "component": class_name, "method": method_name, }, )) continue class_node = _find_class_in_module(tree, class_name) if class_node is None: # preflight STR-002 already catches wrong class names; skip. continue method_node = _find_method_in_class(class_node, method_name) if method_node is None: report.add(Finding( code="STR-003", message=( f"Class {class_name} in {file_name} is missing required method " f"{method_name}" ), context={ "file": str(file_path), "component": class_name, "class_name": class_name, "method": method_name, "missing_method": method_name, }, )) continue actual_annotation_tail = _annotation_tail(method_node.returns) if actual_annotation_tail is None: # Missing annotation → NO finding (FP-insurance principle 2). continue if actual_annotation_tail != expected_return: report.add(Finding( code="VAL-006", message=( f"{class_name}.{method_name} return annotation is " f"{actual_annotation_tail!r}, expected {expected_return!r}" ), context={ "file": str(file_path), "component": class_name, "method": method_name, "expected_return": expected_return, "actual_annotation": actual_annotation_tail, }, )) return report - Contract definition: maps each component file to (class_name, method_name, expected_return_type_tail_identifier) — the protocol requirements for entry.py/entry_rule.generate_signal -> EntrySignalOutput, exit.py/exit_rule.should_exit -> ExitSignalOutput, risk.py/risk_manager.can_trade -> RiskOutput, sizer.py/position_sizer.calculate_size -> SizerOutput.
_CONTRACT: Dict[str, Tuple[str, str, str]] = { "entry.py": ("entry_rule", "generate_signal", "EntrySignalOutput"), "exit.py": ("exit_rule", "should_exit", "ExitSignalOutput"), "risk.py": ("risk_manager", "can_trade", "RiskOutput"), "sizer.py": ("position_sizer", "calculate_size", "SizerOutput"), } - Utility _annotation_tail() extracts the trailing identifier from an AST annotation node, handling ast.Name, ast.Constant (string forward refs), ast.Attribute (dotted names). Returns None for missing annotations.
def _annotation_tail(annotation: ast.AST | None) -> str | None: """Return the trailing identifier of an annotation AST node. Handles: - ``ast.Name`` (bare: EntrySignalOutput) -> "EntrySignalOutput" - ``ast.Constant`` (string forward ref: "EntrySignalOutput") -> "EntrySignalOutput" - ``ast.Attribute`` (dotted: schemas.EntrySignalOutput) -> "EntrySignalOutput" - ``None`` (no annotation) -> None Anything else (generics, unions, subscripts) returns the raw AST dump so callers can surface the unusual shape verbatim in context. """ if annotation is None: return None if isinstance(annotation, ast.Name): return annotation.id if isinstance(annotation, ast.Constant) and isinstance(annotation.value, str): # String forward reference — take the trailing identifier. return annotation.value.strip().split(".")[-1] if isinstance(annotation, ast.Attribute): return annotation.attr return ast.dump(annotation) - echolon/mcp/server.py:280-281 (registration)Registration via @server.tool() decorator on the validate_component_protocol_signatures method. Also referenced in validate_strategy_full at line 638 which composes it with other validators.
@server.tool() def validate_component_protocol_signatures(strategy_dir: str) -> dict: