# Copyright (c) 2025 Patrick Motsch # All rights reserved. """ Adapter Validator — enforces 5 drift rules between Schicht-3 NodeAdapters and the Schicht-2 Actions they bind to. This is the CI-safety net described in the typed-action-architecture plan: any drift between an Editor-Node Adapter and the underlying Action signature must be caught at build time, never silently in production. Rules ----- 1. Every `userParams[].actionArg` exists as a parameter in the bound Action. 2. Every required Action parameter is covered by either `userParams` or `contextParams` (i.e. no required arg is silently unset). 3. Every Action parameter type exists in PORT_TYPE_CATALOG (or is a primitive). 4. The Action `outputType` exists in PORT_TYPE_CATALOG (or is a primitive). 5. Every method-bound STATIC node has an Adapter (no orphan node ids). Rules 3+4 are already enforced by `_actionSignatureValidator` in Phase 2 — this module composes with it so the report covers both layers. """ from __future__ import annotations from dataclasses import dataclass, field from typing import Any, Dict, List, Mapping from modules.features.graphicalEditor.nodeAdapter import ( NodeAdapter, _adapterFromLegacyNode, _isMethodBoundNode, ) from modules.workflows.methods._actionSignatureValidator import _validateTypeRef @dataclass class AdapterValidationReport: """Aggregated drift report across all adapters.""" errors: List[str] = field(default_factory=list) warnings: List[str] = field(default_factory=list) @property def isHealthy(self) -> bool: return not self.errors def merge(self, other: "AdapterValidationReport") -> None: self.errors.extend(other.errors) self.warnings.extend(other.warnings) def _validateAdapterAgainstAction( adapter: NodeAdapter, actionDef: Any, ) -> AdapterValidationReport: """Apply rules 1-4 to a single Adapter / Action pair. `actionDef` is duck-typed so tests can pass dataclasses; production passes a `WorkflowActionDefinition` Pydantic model. """ report = AdapterValidationReport() actionParams: Mapping[str, Any] = getattr(actionDef, "parameters", {}) or {} outputType: str = getattr(actionDef, "outputType", "ActionResult") or "ActionResult" # Rule 1: every userParam.actionArg exists in the Action declaredArgs = {up.actionArg for up in adapter.userParams} for arg in declaredArgs: if arg not in actionParams: report.errors.append( f"adapter '{adapter.nodeId}' bindsAction '{adapter.bindsAction}': " f"userParams.actionArg '{arg}' does not exist in action parameters " f"(known: {sorted(actionParams.keys())})" ) # Rule 2: every required Action arg is covered (userParams OR contextParams) coveredArgs = declaredArgs | set(adapter.contextParams.keys()) for paramName, paramDef in actionParams.items(): isRequired = bool(getattr(paramDef, "required", False)) if isRequired and paramName not in coveredArgs: report.errors.append( f"adapter '{adapter.nodeId}' bindsAction '{adapter.bindsAction}': " f"required action arg '{paramName}' is neither in userParams nor contextParams" ) # Rule 3: every Action parameter type exists in catalog (re-runs Phase-2 rule) for paramName, paramDef in actionParams.items(): typeRef = getattr(paramDef, "type", None) if not typeRef: report.errors.append( f"action '{adapter.bindsAction}.{paramName}': missing 'type' on parameter" ) continue for err in _validateTypeRef(typeRef): report.errors.append( f"action '{adapter.bindsAction}.{paramName}': {err}" ) # Rule 4: Action outputType exists in catalog (or is a generic fire-and-forget type) if outputType not in {"ActionResult", "Transit"}: for err in _validateTypeRef(outputType): report.errors.append( f"action '{adapter.bindsAction}'.outputType: {err}" ) return report def _validateAllAdapters( staticNodes: List[Mapping[str, Any]], actionsRegistry: Mapping[str, Mapping[str, Any]], ) -> AdapterValidationReport: """Run rules 1-5 across all method-bound static node definitions. Args: staticNodes: list of legacy node-dicts (`STATIC_NODE_TYPES`). actionsRegistry: mapping of method-shortname -> {actionName: WorkflowActionDefinition}. Built from live `methods` registry or test-stubbed methods. Returns: Aggregated drift report. `isHealthy` is True only if every method-bound node has a matching Action and all 5 rules pass. """ report = AdapterValidationReport() seenAdapterIds: set[str] = set() for node in staticNodes: if not _isMethodBoundNode(node): continue adapter = _adapterFromLegacyNode(node) if adapter is None: report.errors.append( f"node '{node.get('id')}' is method-bound but adapter projection failed" ) continue seenAdapterIds.add(adapter.nodeId) methodName = str(node.get("_method") or "") actionName = str(node.get("_action") or "") methodActions = actionsRegistry.get(methodName) or {} actionDef = methodActions.get(actionName) if actionDef is None: report.errors.append( f"adapter '{adapter.nodeId}' bindsAction '{adapter.bindsAction}': " f"action not found in registry (method '{methodName}' has actions: " f"{sorted(methodActions.keys())})" ) continue report.merge(_validateAdapterAgainstAction(adapter, actionDef)) # Rule 5: every Action with dynamicMode=False MUST have an Editor Adapter. # dynamicMode=True actions are agent-only and may legitimately lack one. boundActions: set[str] = set() for node in staticNodes: if not _isMethodBoundNode(node): continue boundActions.add(f"{node.get('_method')}.{node.get('_action')}") for methodName, actions in actionsRegistry.items(): for actionName, actionDef in actions.items(): if bool(getattr(actionDef, "dynamicMode", False)): continue fqn = f"{methodName}.{actionName}" if fqn not in boundActions: report.warnings.append( f"action '{fqn}' has no Editor adapter " f"(set dynamicMode=True if intended as agent-only)" ) return report def _formatAdapterReport(report: AdapterValidationReport) -> str: """Format a report for human-readable logging.""" lines: List[str] = [] if report.isHealthy and not report.warnings: lines.append("Adapter validator: all healthy.") return "\n".join(lines) if report.errors: lines.append(f"Adapter validator: {len(report.errors)} ERROR(s)") for e in report.errors: lines.append(f" ERROR: {e}") if report.warnings: lines.append(f"Adapter validator: {len(report.warnings)} WARNING(s)") for w in report.warnings: lines.append(f" WARN: {w}") return "\n".join(lines) def _buildActionsRegistryFromMethods( methodInstances: Mapping[str, Any], ) -> Dict[str, Dict[str, Any]]: """Convenience: turn `{shortName: methodInstance}` into the registry shape. `methodInstance._actions` is a dict of action-name -> WorkflowActionDefinition. """ registry: Dict[str, Dict[str, Any]] = {} for shortName, instance in methodInstances.items(): actions = getattr(instance, "_actions", None) if isinstance(actions, dict): registry[shortName] = dict(actions) return registry