gateway/modules/features/graphicalEditor/adapterValidator.py
2026-04-25 01:13:01 +02:00

205 lines
7.7 KiB
Python

# 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