205 lines
7.7 KiB
Python
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
|