467 lines
13 KiB
TypeScript
467 lines
13 KiB
TypeScript
/**
|
|
* Port Validation
|
|
*
|
|
* Handles type compatibility checking between ports and validates
|
|
* workflow connections to prevent invalid data flow.
|
|
*/
|
|
|
|
import {
|
|
PortDataType,
|
|
InputPort,
|
|
OutputPort,
|
|
BlockDefinition,
|
|
PortBinding,
|
|
isTypeCompatible,
|
|
} from './types'
|
|
import { getBlockDefinition, hasBlockDefinition } from './blockRegistry'
|
|
|
|
// =============================================================================
|
|
// Validation Result Types
|
|
// =============================================================================
|
|
|
|
export interface ValidationResult {
|
|
valid: boolean
|
|
errors: ValidationError[]
|
|
warnings: ValidationWarning[]
|
|
}
|
|
|
|
export interface ValidationError {
|
|
type: 'type_mismatch' | 'missing_required' | 'unknown_block' | 'unknown_port' | 'cycle_detected'
|
|
message: string
|
|
blockId?: string
|
|
portId?: string
|
|
details?: Record<string, unknown>
|
|
}
|
|
|
|
export interface ValidationWarning {
|
|
type: 'implicit_conversion' | 'unused_output' | 'unconnected_input'
|
|
message: string
|
|
blockId?: string
|
|
portId?: string
|
|
}
|
|
|
|
// =============================================================================
|
|
// Port Compatibility
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Check if an output port can connect to an input port
|
|
*/
|
|
export function canConnect(
|
|
outputPort: OutputPort,
|
|
inputPort: InputPort
|
|
): boolean {
|
|
return isTypeCompatible(outputPort.produces, inputPort.accepts)
|
|
}
|
|
|
|
/**
|
|
* Check if a specific type can connect to an input port
|
|
*/
|
|
export function canConnectType(
|
|
outputType: PortDataType,
|
|
inputPort: InputPort
|
|
): boolean {
|
|
return isTypeCompatible(outputType, inputPort.accepts)
|
|
}
|
|
|
|
/**
|
|
* Get all compatible ports on a target block for a given output port
|
|
*/
|
|
export function getCompatibleInputPorts(
|
|
sourceBlockType: string,
|
|
sourcePortId: string,
|
|
targetBlockType: string
|
|
): InputPort[] {
|
|
if (!hasBlockDefinition(sourceBlockType) || !hasBlockDefinition(targetBlockType)) {
|
|
return []
|
|
}
|
|
|
|
const sourceBlock = getBlockDefinition(sourceBlockType)
|
|
const targetBlock = getBlockDefinition(targetBlockType)
|
|
|
|
const sourcePort = sourceBlock.outputs.find(p => p.id === sourcePortId)
|
|
if (!sourcePort) return []
|
|
|
|
return targetBlock.inputs.filter(inputPort =>
|
|
canConnect(sourcePort, inputPort)
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Get all compatible output ports on a source block for a given input port
|
|
*/
|
|
export function getCompatibleOutputPorts(
|
|
sourceBlockType: string,
|
|
targetBlockType: string,
|
|
targetPortId: string
|
|
): OutputPort[] {
|
|
if (!hasBlockDefinition(sourceBlockType) || !hasBlockDefinition(targetBlockType)) {
|
|
return []
|
|
}
|
|
|
|
const sourceBlock = getBlockDefinition(sourceBlockType)
|
|
const targetBlock = getBlockDefinition(targetBlockType)
|
|
|
|
const targetPort = targetBlock.inputs.find(p => p.id === targetPortId)
|
|
if (!targetPort) return []
|
|
|
|
return sourceBlock.outputs.filter(outputPort =>
|
|
canConnect(outputPort, targetPort)
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// Connection Validation
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Validate a single connection between two blocks
|
|
*/
|
|
export function validateConnection(
|
|
sourceBlockType: string,
|
|
sourcePortId: string,
|
|
targetBlockType: string,
|
|
targetPortId: string
|
|
): ValidationResult {
|
|
const errors: ValidationError[] = []
|
|
const warnings: ValidationWarning[] = []
|
|
|
|
// Check source block exists
|
|
if (!hasBlockDefinition(sourceBlockType)) {
|
|
errors.push({
|
|
type: 'unknown_block',
|
|
message: `Unknown source block type: ${sourceBlockType}`,
|
|
details: { blockType: sourceBlockType },
|
|
})
|
|
return { valid: false, errors, warnings }
|
|
}
|
|
|
|
// Check target block exists
|
|
if (!hasBlockDefinition(targetBlockType)) {
|
|
errors.push({
|
|
type: 'unknown_block',
|
|
message: `Unknown target block type: ${targetBlockType}`,
|
|
details: { blockType: targetBlockType },
|
|
})
|
|
return { valid: false, errors, warnings }
|
|
}
|
|
|
|
const sourceBlock = getBlockDefinition(sourceBlockType)
|
|
const targetBlock = getBlockDefinition(targetBlockType)
|
|
|
|
// Check source port exists
|
|
const sourcePort = sourceBlock.outputs.find(p => p.id === sourcePortId)
|
|
if (!sourcePort) {
|
|
errors.push({
|
|
type: 'unknown_port',
|
|
message: `Unknown output port "${sourcePortId}" on block "${sourceBlockType}"`,
|
|
portId: sourcePortId,
|
|
details: { blockType: sourceBlockType, availablePorts: sourceBlock.outputs.map(p => p.id) },
|
|
})
|
|
return { valid: false, errors, warnings }
|
|
}
|
|
|
|
// Check target port exists
|
|
const targetPort = targetBlock.inputs.find(p => p.id === targetPortId)
|
|
if (!targetPort) {
|
|
errors.push({
|
|
type: 'unknown_port',
|
|
message: `Unknown input port "${targetPortId}" on block "${targetBlockType}"`,
|
|
portId: targetPortId,
|
|
details: { blockType: targetBlockType, availablePorts: targetBlock.inputs.map(p => p.id) },
|
|
})
|
|
return { valid: false, errors, warnings }
|
|
}
|
|
|
|
// Check type compatibility
|
|
if (!canConnect(sourcePort, targetPort)) {
|
|
errors.push({
|
|
type: 'type_mismatch',
|
|
message: `Type mismatch: "${sourcePort.produces}" cannot connect to "${targetPort.accepts.join(' | ')}"`,
|
|
details: {
|
|
sourceType: sourcePort.produces,
|
|
targetAccepts: targetPort.accepts,
|
|
sourcePort: sourcePortId,
|
|
targetPort: targetPortId,
|
|
},
|
|
})
|
|
return { valid: false, errors, warnings }
|
|
}
|
|
|
|
// Check for implicit conversions (warning, not error)
|
|
if (sourcePort.produces !== targetPort.type && targetPort.accepts.includes('any')) {
|
|
warnings.push({
|
|
type: 'implicit_conversion',
|
|
message: `Implicit conversion from "${sourcePort.produces}" to "${targetPort.type}"`,
|
|
})
|
|
}
|
|
|
|
return { valid: true, errors, warnings }
|
|
}
|
|
|
|
/**
|
|
* Validate a port binding
|
|
*/
|
|
export function validatePortBinding(
|
|
binding: PortBinding,
|
|
getBlockType: (shapeId: string) => string | undefined
|
|
): ValidationResult {
|
|
const sourceType = getBlockType(binding.fromShapeId as string)
|
|
const targetType = getBlockType(binding.toShapeId as string)
|
|
|
|
if (!sourceType || !targetType) {
|
|
return {
|
|
valid: false,
|
|
errors: [{
|
|
type: 'unknown_block',
|
|
message: 'Could not determine block types for binding',
|
|
blockId: !sourceType ? binding.fromShapeId as string : binding.toShapeId as string,
|
|
}],
|
|
warnings: [],
|
|
}
|
|
}
|
|
|
|
return validateConnection(
|
|
sourceType,
|
|
binding.fromPortId,
|
|
targetType,
|
|
binding.toPortId
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// Block Validation
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Validate a block's configuration
|
|
*/
|
|
export function validateBlockConfig(
|
|
blockType: string,
|
|
config: Record<string, unknown>
|
|
): ValidationResult {
|
|
const errors: ValidationError[] = []
|
|
const warnings: ValidationWarning[] = []
|
|
|
|
if (!hasBlockDefinition(blockType)) {
|
|
errors.push({
|
|
type: 'unknown_block',
|
|
message: `Unknown block type: ${blockType}`,
|
|
})
|
|
return { valid: false, errors, warnings }
|
|
}
|
|
|
|
const definition = getBlockDefinition(blockType)
|
|
|
|
// If no config schema, any config is valid
|
|
if (!definition.configSchema) {
|
|
return { valid: true, errors, warnings }
|
|
}
|
|
|
|
// Basic schema validation (could use ajv for full JSON Schema validation)
|
|
const schema = definition.configSchema as { properties?: Record<string, unknown> }
|
|
if (schema.properties) {
|
|
for (const [key, propSchema] of Object.entries(schema.properties)) {
|
|
const prop = propSchema as { type?: string; required?: boolean; enum?: unknown[] }
|
|
|
|
// Check required properties
|
|
if (prop.required && !(key in config)) {
|
|
errors.push({
|
|
type: 'missing_required',
|
|
message: `Missing required configuration: ${key}`,
|
|
details: { key },
|
|
})
|
|
}
|
|
|
|
// Check enum values
|
|
if (prop.enum && key in config && !prop.enum.includes(config[key])) {
|
|
errors.push({
|
|
type: 'type_mismatch',
|
|
message: `Invalid value for "${key}": must be one of ${prop.enum.join(', ')}`,
|
|
details: { key, value: config[key], allowed: prop.enum },
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
return { valid: errors.length === 0, errors, warnings }
|
|
}
|
|
|
|
/**
|
|
* Check if a block has all required inputs satisfied
|
|
*/
|
|
export function validateRequiredInputs(
|
|
blockType: string,
|
|
inputValues: Record<string, unknown>,
|
|
connectedInputs: string[]
|
|
): ValidationResult {
|
|
const errors: ValidationError[] = []
|
|
const warnings: ValidationWarning[] = []
|
|
|
|
if (!hasBlockDefinition(blockType)) {
|
|
errors.push({
|
|
type: 'unknown_block',
|
|
message: `Unknown block type: ${blockType}`,
|
|
})
|
|
return { valid: false, errors, warnings }
|
|
}
|
|
|
|
const definition = getBlockDefinition(blockType)
|
|
|
|
for (const input of definition.inputs) {
|
|
if (input.required) {
|
|
const hasValue = input.id in inputValues && inputValues[input.id] !== undefined
|
|
const hasConnection = connectedInputs.includes(input.id)
|
|
|
|
if (!hasValue && !hasConnection) {
|
|
errors.push({
|
|
type: 'missing_required',
|
|
message: `Required input "${input.name}" is not connected or provided`,
|
|
portId: input.id,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// Warn about unconnected optional inputs
|
|
for (const input of definition.inputs) {
|
|
if (!input.required) {
|
|
const hasValue = input.id in inputValues && inputValues[input.id] !== undefined
|
|
const hasConnection = connectedInputs.includes(input.id)
|
|
|
|
if (!hasValue && !hasConnection && input.defaultValue === undefined) {
|
|
warnings.push({
|
|
type: 'unconnected_input',
|
|
message: `Optional input "${input.name}" has no value or connection`,
|
|
portId: input.id,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
return { valid: errors.length === 0, errors, warnings }
|
|
}
|
|
|
|
// =============================================================================
|
|
// Workflow Validation
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Detect cycles in a workflow graph
|
|
*/
|
|
export function detectCycles(
|
|
connections: PortBinding[]
|
|
): { hasCycle: boolean; cycleNodes?: string[] } {
|
|
// Build adjacency list
|
|
const graph = new Map<string, Set<string>>()
|
|
|
|
for (const conn of connections) {
|
|
const from = conn.fromShapeId as string
|
|
const to = conn.toShapeId as string
|
|
|
|
if (!graph.has(from)) graph.set(from, new Set())
|
|
graph.get(from)!.add(to)
|
|
}
|
|
|
|
// DFS to detect cycles
|
|
const visited = new Set<string>()
|
|
const recursionStack = new Set<string>()
|
|
const cyclePath: string[] = []
|
|
|
|
function dfs(node: string): boolean {
|
|
visited.add(node)
|
|
recursionStack.add(node)
|
|
cyclePath.push(node)
|
|
|
|
const neighbors = graph.get(node) || new Set()
|
|
for (const neighbor of neighbors) {
|
|
if (!visited.has(neighbor)) {
|
|
if (dfs(neighbor)) return true
|
|
} else if (recursionStack.has(neighbor)) {
|
|
cyclePath.push(neighbor)
|
|
return true
|
|
}
|
|
}
|
|
|
|
cyclePath.pop()
|
|
recursionStack.delete(node)
|
|
return false
|
|
}
|
|
|
|
for (const node of graph.keys()) {
|
|
if (!visited.has(node)) {
|
|
if (dfs(node)) {
|
|
// Extract just the cycle portion
|
|
const cycleStart = cyclePath.indexOf(cyclePath[cyclePath.length - 1])
|
|
return {
|
|
hasCycle: true,
|
|
cycleNodes: cyclePath.slice(cycleStart),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return { hasCycle: false }
|
|
}
|
|
|
|
/**
|
|
* Validate an entire workflow
|
|
*/
|
|
export function validateWorkflow(
|
|
blocks: Array<{ id: string; blockType: string; config: Record<string, unknown> }>,
|
|
connections: PortBinding[]
|
|
): ValidationResult {
|
|
const errors: ValidationError[] = []
|
|
const warnings: ValidationWarning[] = []
|
|
|
|
// Validate each connection
|
|
for (const conn of connections) {
|
|
const sourceBlock = blocks.find(b => b.id === conn.fromShapeId)
|
|
const targetBlock = blocks.find(b => b.id === conn.toShapeId)
|
|
|
|
if (sourceBlock && targetBlock) {
|
|
const result = validateConnection(
|
|
sourceBlock.blockType,
|
|
conn.fromPortId,
|
|
targetBlock.blockType,
|
|
conn.toPortId
|
|
)
|
|
errors.push(...result.errors)
|
|
warnings.push(...result.warnings)
|
|
}
|
|
}
|
|
|
|
// Check for cycles
|
|
const cycleResult = detectCycles(connections)
|
|
if (cycleResult.hasCycle) {
|
|
errors.push({
|
|
type: 'cycle_detected',
|
|
message: `Cycle detected in workflow: ${cycleResult.cycleNodes?.join(' -> ')}`,
|
|
details: { cycleNodes: cycleResult.cycleNodes },
|
|
})
|
|
}
|
|
|
|
// Check for unused outputs (optional warning)
|
|
const connectedOutputs = new Set(connections.map(c => `${c.fromShapeId}:${c.fromPortId}`))
|
|
for (const block of blocks) {
|
|
if (!hasBlockDefinition(block.blockType)) continue
|
|
const def = getBlockDefinition(block.blockType)
|
|
|
|
for (const output of def.outputs) {
|
|
if (!connectedOutputs.has(`${block.id}:${output.id}`)) {
|
|
// Only warn for non-terminal blocks
|
|
if (def.category !== 'output') {
|
|
warnings.push({
|
|
type: 'unused_output',
|
|
message: `Output "${output.name}" on block "${def.name}" is not connected`,
|
|
blockId: block.id,
|
|
portId: output.id,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return { valid: errors.length === 0, errors, warnings }
|
|
}
|