/** * Contract Validation with Rich Error Context * Validates postconditions and returns structured errors. * Backward compatible: error is always a string, violation is optional. */ import { parse } from '../formula/parser.js' import { evaluateAsync, evaluateBooleanResult, evaluateWithExtensions } from '../formula/evaluator.js' import { getSuggestion, formatDiff } from './error-suggestions.js' import { getErrorMessage } from '../infrastructure/http-executor.js' import type { ExtensionRegistry } from '../extension/types.js' import type { FormulaNode } from './formula.js' import type { ContractViolation, EvalContext, EvalResult, RouteContract } from '../types.js' // --------------------------------------------------------------------------- // Core helpers // --------------------------------------------------------------------------- const makeViolation = ( partial: Omit & { kind?: ContractViolation['kind']; suggestion?: string } ): ContractViolation => ({ type: 'contract-violation', kind: partial.kind ?? 'postcondition', suggestion: partial.suggestion ?? 'Contract violation detected. Review the formula and response.', ...partial, }) const getFieldValue = (obj: unknown, path: string): unknown => { const parts = path.split('.') let current: unknown = obj for (const part of parts) { if (current === null || current === undefined || typeof current !== 'object') { return undefined } current = (current as Record)[part] } return current } const extractExpectedFromEquality = (formula: string): string | undefined => { const match = formula.match(/==\s*["']?([^"']+)["']?/) return match?.[1] } const extractFieldPath = (formula: string): string | undefined => { const match = formula.match(/(?:response_body\(this\)|response_payload\(this\)|request_body\(this\)|request_headers\(this\)|response_headers\(this\)|query_params\(this\)|request_params\(this\)|cookies\(this\)|response_time\(this\))(?:\.?([\w.\[\]]+))?/) return match?.[1] } const isLegacyPreconditionSyntax = (formula: string): boolean => /^[a-zA-Z][a-zA-Z0-9_-]*:[^\s()]+$/.test(formula) && !formula.startsWith('status:') const clauseLabelForKind = (kind: ContractViolation['kind']): 'x-requires' | 'x-ensures' => kind === 'precondition' ? 'x-requires' : 'x-ensures' const formatRouteClauseContext = ( kind: ContractViolation['kind'], route: RouteContract | { method: string; path: string } | undefined, index: number, formula: string ): string => { const routeCtx = route ? `${route.method} ${route.path}` : 'unknown route' return `${routeCtx} ${clauseLabelForKind(kind)}[${index}] "${formula}"` } const buildLegacyPreconditionMessage = (formula: string): string => `Legacy precondition syntax is no longer supported: "${formula}". ` + 'Use APOSTL formulas in x-requires, for example request_params(this).id != null or response_code(GET /users/{request_params(this).id}) == 200.' const parseFormula = (formula: string, extensionRegistry?: ExtensionRegistry): FormulaNode => { const extensionHeaders = extensionRegistry?.getExtensionHeaders() ?? [] return parse(formula, extensionHeaders).ast } const evaluateParsedFormula = ( ast: FormulaNode, ctx: EvalContext, route?: RouteContract | { method: string; path: string }, extensionRegistry?: ExtensionRegistry ): boolean => { if (extensionRegistry && route && 'category' in route) { const evalResult = evaluateWithExtensions(ast, ctx, route as RouteContract, extensionRegistry) if (!evalResult.success) { throw new Error(evalResult.error) } return Boolean(evalResult.value) } return evaluateBooleanResult(ast, ctx) } const evaluateParsedFormulaAsync = async ( ast: FormulaNode, ctx: EvalContext, route?: RouteContract | { method: string; path: string }, extensionRegistry?: ExtensionRegistry ): Promise => { const evalResult = await evaluateAsync( ast, ctx, route && 'category' in route ? route as RouteContract : undefined, extensionRegistry ) if (!evalResult.success) { throw new Error(evalResult.error) } return Boolean(evalResult.value) } // --------------------------------------------------------------------------- // Violation builders — extracted from makeConditionFailure to reduce nesting // --------------------------------------------------------------------------- const resolveStatusExpectation = ( formula: string, ast: FormulaNode | undefined, statusCode: number ): { expected: string; actual: string } => { if (ast?.type === 'status') { return { expected: String(ast.code), actual: String(statusCode) } } const statusMatch = formula.match(/status:(\d+)/) if (statusMatch) { return { expected: statusMatch[1] ?? 'true', actual: String(statusCode) } } return { expected: 'true', actual: 'false' } } const resolveFieldNullExpectation = ( formula: string, body: unknown ): { expected: string; actual: string } | null => { const fieldMatch = formula.match(/(?:response_body\(this\)|response_payload\(this\)|request_body\(this\)|request_headers\(this\)|response_headers\(this\)|query_params\(this\)|request_params\(this\))\.(\w[\w.\[\]]*)/) if (!fieldMatch || !formula.includes('!= null')) return null const fieldPath = fieldMatch[1] if (!fieldPath) return null const parts = fieldPath.split('.') let current: unknown = body let exists = true for (const part of parts) { if (current === null || current === undefined || typeof current !== 'object') { exists = false break } current = (current as Record)[part] } if (!exists || current === undefined) { return { expected: 'non-null value', actual: 'undefined (field missing)' } } if (current === null) { return { expected: 'non-null value', actual: 'null' } } return null } const buildDiff = (formula: string, body: unknown): string | null => { if (!formula.includes('==') || formula.includes('!=')) return null const fieldPath = extractFieldPath(formula) const expectedValue = extractExpectedFromEquality(formula) if (!fieldPath || !expectedValue) return null const actualValue = getFieldValue(body, fieldPath) return formatDiff(expectedValue, String(actualValue ?? 'undefined')) } const makeConditionFailure = ( kind: ContractViolation['kind'], formula: string, ctx: EvalContext, route: RouteContract | { method: string; path: string } | undefined, ast?: FormulaNode ): ContractViolation => { const statusExpectation = resolveStatusExpectation(formula, ast, ctx.response.statusCode) const fieldExpectation = resolveFieldNullExpectation(formula, ctx.response.body) const expected = fieldExpectation?.expected ?? statusExpectation.expected const actual = fieldExpectation?.actual ?? statusExpectation.actual const diff = buildDiff(formula, ctx.response.body) return makeViolation({ route: route ?? { method: '', path: '' }, formula, kind, request: { body: ctx.request.body, headers: ctx.request.headers, query: ctx.request.query, params: ctx.request.params, }, response: { statusCode: ctx.response.statusCode, headers: ctx.response.headers, body: ctx.response.body, }, context: { expected, actual, diff }, }) } const makeFormulaError = ( kind: ContractViolation['kind'], formula: string, ctx: EvalContext, route: RouteContract | { method: string; path: string } | undefined, message: string ): ContractViolation => { return makeViolation({ route: route ?? { method: '', path: '' }, formula, kind, request: { body: ctx.request.body, headers: ctx.request.headers, query: ctx.request.query, params: ctx.request.params, }, response: { statusCode: ctx.response.statusCode, headers: ctx.response.headers, body: ctx.response.body, }, context: { expected: 'valid formula', actual: `parse error: ${message}`, diff: null }, suggestion: `Formula evaluation failed: ${message}. Check your contract syntax.`, }) } // --------------------------------------------------------------------------- // Shared validation body — extracted to avoid duplication between sync/async // --------------------------------------------------------------------------- const runValidationBody = ( kind: ContractViolation['kind'], formula: string, ctx: EvalContext, route: RouteContract | { method: string; path: string } | undefined, extensionRegistry: ExtensionRegistry | undefined ): EvalResult | null => { if (kind === 'precondition' && isLegacyPreconditionSyntax(formula)) { throw new Error(buildLegacyPreconditionMessage(formula)) } const ast = parseFormula(formula, extensionRegistry) const result = evaluateParsedFormula(ast, ctx, route, extensionRegistry) if (!result) { const violation = makeConditionFailure(kind, formula, ctx, route, ast) if (extensionRegistry) { extensionRegistry.runViolationHooks(violation).catch((err: unknown) => { console.warn(`Extension violation hook failed: ${getErrorMessage(err)}`) }) } return { success: false, error: `Contract violation: ${formula}`, violation: { ...violation, suggestion: getSuggestion(violation) }, } } return null } const runValidationBodyAsync = async ( kind: ContractViolation['kind'], formula: string, ctx: EvalContext, route: RouteContract | { method: string; path: string } | undefined, extensionRegistry: ExtensionRegistry | undefined ): Promise => { if (kind === 'precondition' && isLegacyPreconditionSyntax(formula)) { throw new Error(buildLegacyPreconditionMessage(formula)) } const ast = parseFormula(formula, extensionRegistry) const result = await evaluateParsedFormulaAsync(ast, ctx, route, extensionRegistry) if (!result) { const violation = makeConditionFailure(kind, formula, ctx, route, ast) if (extensionRegistry) { extensionRegistry.runViolationHooks(violation).catch((err: unknown) => { console.warn(`Extension violation hook failed: ${getErrorMessage(err)}`) }) } return { success: false, error: `Contract violation: ${formula}`, violation: { ...violation, suggestion: getSuggestion(violation) }, } } return null } // --------------------------------------------------------------------------- // Sync / Async wrappers — thin loops over the shared body // --------------------------------------------------------------------------- const runValidationSync = ( kind: ContractViolation['kind'], formulas: string[], ctx: EvalContext, route?: RouteContract | { method: string; path: string }, extensionRegistry?: ExtensionRegistry ): EvalResult => { for (let index = 0; index < formulas.length; index++) { const formula = formulas[index] ?? '' try { const stepResult = runValidationBody(kind, formula, ctx, route, extensionRegistry) if (stepResult) { return stepResult } } catch (err) { const msg = getErrorMessage(err) const violation = makeFormulaError(kind, formula, ctx, route, msg) const routeCtx = formatRouteClauseContext(kind, route, index, formula) return { success: false, error: `Formula error in ${routeCtx}: ${msg}`, violation, } } } return { success: true, value: ctx.response.statusCode } } const runValidationAsync = async ( kind: ContractViolation['kind'], formulas: string[], ctx: EvalContext, route?: RouteContract | { method: string; path: string }, extensionRegistry?: ExtensionRegistry ): Promise => { for (let index = 0; index < formulas.length; index++) { const formula = formulas[index] ?? '' try { const stepResult = await runValidationBodyAsync(kind, formula, ctx, route, extensionRegistry) if (stepResult) { return stepResult } } catch (err) { const msg = getErrorMessage(err) const violation = makeFormulaError(kind, formula, ctx, route, msg) const routeCtx = formatRouteClauseContext(kind, route, index, formula) return { success: false, error: `Formula error in ${routeCtx}: ${msg}`, violation, } } } return { success: true, value: ctx.response.statusCode } } /** * Validate a set of postcondition formulas against an evaluation context. * Returns string error for backward compatibility, with optional rich violation. */ export const validatePostconditions = ( ensures: string[], ctx: EvalContext, route?: RouteContract | { method: string; path: string }, extensionRegistry?: ExtensionRegistry ): EvalResult => { return runValidationSync('postcondition', ensures, ctx, route, extensionRegistry) } export const validatePostconditionsAsync = ( ensures: string[], ctx: EvalContext, route?: RouteContract | { method: string; path: string }, extensionRegistry?: ExtensionRegistry ): Promise => { return runValidationAsync('postcondition', ensures, ctx, route, extensionRegistry) } export const validatePreconditionsAsync = ( requires: string[], ctx: EvalContext, route?: RouteContract | { method: string; path: string }, extensionRegistry?: ExtensionRegistry ): Promise => { return runValidationAsync('precondition', requires, ctx, route, extensionRegistry) }