chore: crush git history - reborn from consolidation on 2026-03-10
This commit is contained in:
@@ -0,0 +1,382 @@
|
||||
/**
|
||||
* 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<ContractViolation, 'type' | 'kind' | 'suggestion'> & { 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<string, unknown>)[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<boolean> => {
|
||||
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<string, unknown>)[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<EvalResult | null> => {
|
||||
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<EvalResult> => {
|
||||
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<EvalResult> => {
|
||||
return runValidationAsync('postcondition', ensures, ctx, route, extensionRegistry)
|
||||
}
|
||||
export const validatePreconditionsAsync = (
|
||||
requires: string[],
|
||||
ctx: EvalContext,
|
||||
route?: RouteContract | { method: string; path: string },
|
||||
extensionRegistry?: ExtensionRegistry
|
||||
): Promise<EvalResult> => {
|
||||
return runValidationAsync('precondition', requires, ctx, route, extensionRegistry)
|
||||
}
|
||||
Reference in New Issue
Block a user