/** * S4: Verify thread - Deterministic contract verification command * * Responsibilities: * - Load config and resolve profile * - Discover routes from Fastify app * - Filter routes by --routes flag (supports wildcards/patterns) * - Run deterministic contract verification * - Generate seed if omitted, always print it * - Produce canonical failure output matching golden snapshot * - Emit artifact JSON * - Print replay command * - Support --changed for git-based filtering * - Exit 0 on pass, 1 on behavioral failure, 2 on config error * * Architecture: * - Dependency injection: all dependencies passed explicitly * - No optional imports — everything is required or injected * - Inline comments for documentation */ import type { CliContext } from '../../core/context.js' import { loadConfig, findWorkspacePackages } from '../../core/config-loader.js' import { PolicyEngine, detectEnvironment } from '../../core/policy-engine.js' import { SUCCESS, BEHAVIORAL_FAILURE, USAGE_ERROR, INTERNAL_ERROR } from '../../core/exit-codes.js' import type { CommandResult, Artifact, FailureRecord, RouteResult, WorkspaceRun, WorkspaceResult } from '../../core/types.js' import { classifyError, ErrorTaxonomy } from '../../core/error-taxonomy.js' import { runVerify, type VerifyRunResult } from './runner.js' import { renderCanonicalFailure, renderHumanArtifact } from '../../renderers/human.js' import { renderJson, renderJsonArtifact, renderJsonSummaryArtifact } from '../../renderers/json.js' import { renderNdjsonArtifact, renderNdjsonSummaryArtifact } from '../../renderers/ndjson.js' import type { OutputContext } from '../../renderers/shared.js' import { resolve, basename } from 'node:path' const ROUTE_IDENTITY_PATTERN = /^[A-Z]+\s+\/\S*$/ function normalizeRouteIdentity(route: string): string { const normalized = route.trim().replace(/\s+/g, ' ') const [method, ...pathParts] = normalized.split(' ') if (!method || pathParts.length === 0) { return normalized } return `${method.toUpperCase()} ${pathParts.join(' ')}` } function isReplayCompatibleRoute(route: string): boolean { return ROUTE_IDENTITY_PATTERN.test(route) } // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface VerifyOptions { profile?: string routes?: string seed?: number changed?: boolean config?: string cwd?: string format?: 'human' | 'json' | 'ndjson' quiet?: boolean verbose?: boolean artifactDir?: string } // --------------------------------------------------------------------------- // Seed generation // --------------------------------------------------------------------------- /** * Generate a deterministic seed if none provided. * Uses current time + process pid for uniqueness. */ export function generateSeed(): number { return Date.now() + (process.pid || 0) } // --------------------------------------------------------------------------- // Route filter parsing // --------------------------------------------------------------------------- /** * Parse --routes flag into filter patterns. * Supports comma-separated patterns with wildcards. */ function parseRouteFilters(routesFlag: string | undefined): string[] | undefined { if (!routesFlag) return undefined return routesFlag.split(',').map(r => r.trim()).filter(Boolean) } // --------------------------------------------------------------------------- // Artifact builder // --------------------------------------------------------------------------- /** * Build artifact document from verify results. */ function buildArtifact( runResult: VerifyRunResult, options: { cwd: string configPath?: string profile?: string preset?: string env: string seed: number routeFilters?: string[] }, ): Artifact { const warnings: string[] = [] const failures: FailureRecord[] = runResult.failures.map(f => { const route = normalizeRouteIdentity(f.route) if (!isReplayCompatibleRoute(route)) { warnings.push(`Failure route "${f.route}" is not in METHOD /path format; replay matching may be less precise.`) } return { route, contract: f.formula || f.contract, expected: f.expected, observed: f.observed, seed: options.seed, replayCommand: `apophis replay --artifact ${f.artifactPath || ''}`, category: f.category ?? (f.observed ? classifyError(f.observed) : ErrorTaxonomy.RUNTIME), } }) if (runResult.noContractsFound) { warnings.push('No behavioral contracts found. Schema-only routes are not enough for verify. Add x-ensures or x-requires to route schemas. See docs/getting-started.md for examples.') if (runResult.discoveryWarnings && runResult.discoveryWarnings.length > 0) { warnings.push(...runResult.discoveryWarnings) } } if (runResult.noRoutesMatched) { warnings.push(`No routes matched the filter. Available routes: ${runResult.availableRoutes?.join(', ') || 'none'}`) if (runResult.discoveryWarnings && runResult.discoveryWarnings.length > 0) { warnings.push(...runResult.discoveryWarnings) } } if (runResult.notGitRepo) { warnings.push('--changed requires a git repository. Current directory is not inside a git repo.') } if (runResult.noRelevantChanges) { warnings.push('No relevant changes detected. Git shows no modified files that match any route.') } if (runResult.failures.length > 0) { const profileFlag = options.profile ? ` --profile ${options.profile}` : '' const routesFlag = options.routeFilters && options.routeFilters.length > 0 ? ` --routes "${options.routeFilters.join(',')}"` : '' warnings.push(`Deterministic rerun: apophis verify --seed ${options.seed}${profileFlag}${routesFlag}`) warnings.push('If rerun output differs with same seed, stabilize app state/data and isolate time/external dependencies.') } return { version: 'apophis-artifact/1', cliVersion: '2.7.0', command: 'verify', mode: 'verify', cwd: options.cwd, configPath: options.configPath, profile: options.profile, preset: options.preset, env: options.env, seed: options.seed, startedAt: new Date(Date.now() - runResult.durationMs).toISOString(), durationMs: runResult.durationMs, summary: { total: runResult.total, passed: runResult.passedCount, failed: runResult.failed, }, deterministicParams: { seed: options.seed, routeFilters: options.routeFilters ?? [], }, failures, artifacts: runResult.artifactPaths, warnings, exitReason: runResult.passed ? 'success' : 'behavioral_failure', } } function attachReplayCommands(artifact: Artifact, artifactPath: string): void { for (const failure of artifact.failures) { failure.replayCommand = `apophis replay --artifact ${artifactPath}` } } async function emitArtifact( artifact: Artifact, options: { command: 'verify' cwd: string preferredDir?: string force: boolean }, ): Promise { if (!options.force && !options.preferredDir) { return undefined } const defaultDir = resolve(options.cwd, 'reports', 'apophis') const candidateDirs = [options.preferredDir, defaultDir].filter(Boolean) as string[] const attempted = new Set() for (const dir of candidateDirs) { if (attempted.has(dir)) continue attempted.add(dir) try { const { mkdirSync, writeFileSync } = await import('node:fs') const artifactPath = resolve(dir, `${options.command}-${new Date().toISOString().replace(/[:.]/g, '-')}.json`) mkdirSync(dir, { recursive: true }) attachReplayCommands(artifact, artifactPath) writeFileSync(artifactPath, JSON.stringify(artifact, null, 2)) if (!artifact.artifacts.includes(artifactPath)) { artifact.artifacts.push(artifactPath) } return artifactPath } catch { // Try fallback directory if available. } } artifact.warnings.push('Failed to write artifact to disk') return undefined } // --------------------------------------------------------------------------- // Human output formatting // --------------------------------------------------------------------------- /** * Format canonical failure output matching golden snapshot. */ function formatHumanFailure(failure: FailureRecord, profile?: string): string { const lines: string[] = [] lines.push('Contract violation') lines.push(failure.route) lines.push(`Profile: ${profile || 'default'}`) lines.push(`Seed: ${failure.seed}`) lines.push('') lines.push('Expected') lines.push(` ${failure.contract}`) lines.push('') lines.push('Observed') lines.push(` ${failure.observed}`) lines.push('') lines.push('Why this matters') lines.push(` The resource created by ${failure.route.split(' ')[1]} is not retrievable.`) lines.push('') lines.push('Replay') lines.push(` ${failure.replayCommand}`) lines.push('') lines.push('Next') lines.push(` Check the create/read consistency for ${failure.route} and GET ${failure.route.split(' ')[1]}/{id}.`) return lines.join('\n') } /** // --------------------------------------------------------------------------- // Main command handler // --------------------------------------------------------------------------- /** * Main verify command handler. * * Flow: * 1. Load and resolve config * 2. Run policy engine checks * 3. Generate seed if omitted, always print it * 4. Parse route filters * 5. Load Fastify app and discover routes * 6. Run deterministic contract verification * 7. Build artifact * 8. Format output * 9. Write artifact if artifactDir specified * 10. Return appropriate exit code */ export async function verifyCommand( options: VerifyOptions, ctx: CliContext, ): Promise { const { profile, routes: routesFlag, seed: explicitSeed, changed, config: configPath, cwd, artifactDir, } = options const workingDir = cwd || ctx.cwd const format = options.format || ctx.options.format || 'human' // Detect environment const env = detectEnvironment() try { // 1. Load config const loadResult = await loadConfig({ cwd: workingDir, configPath, profileName: profile, env, }) if (!loadResult.configPath) { return { exitCode: USAGE_ERROR, message: 'No config found. Run "apophis init" to create one.', } } const config = loadResult.config // 2a. Resolve profile — if explicitly requested but missing, list available ones if (profile && !config.profiles?.[profile]) { const available = Object.keys(config.profiles ?? {}).join(', ') || 'none' return { exitCode: USAGE_ERROR, message: `Unknown profile "${profile}". Available profiles: ${available}.\n\nNext:\n Run \`apophis init\` to scaffold a new profile, or use one of the profiles listed above.`, } } // 2. Run policy engine checks const policyEngine = new PolicyEngine({ config, env, mode: 'verify', profileName: profile || undefined, presetName: loadResult.presetName || undefined, }) const policyResult = policyEngine.check() if (!policyResult.allowed) { const message = [ 'Policy check failed:', ...policyResult.errors.map(e => ` ✗ ${e}`), ].join('\n') return { exitCode: USAGE_ERROR, message, } } // 3. Generate seed if omitted const seed = explicitSeed ?? generateSeed() if (!ctx.options.quiet && format === 'human') { console.log(`Seed: ${seed}`) } // 4. Parse route filters const routeFilters = parseRouteFilters(routesFlag) // 5. Load the Fastify app let fastify: unknown let appEntrypoint: string | undefined try { const { loadApp } = await import('../../core/app-loader.js') const loaded = await loadApp(workingDir) fastify = loaded.fastify appEntrypoint = loaded.entrypoint if (fastify && typeof (fastify as any).ready === 'function') { await (fastify as any).ready() } } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err) return { exitCode: USAGE_ERROR, message: `Failed to load Fastify app.\n\nError: ${errorMessage}\n\nApp file candidates (searched in order):\n app.js, app.ts, server.js, server.ts, index.js, index.ts\n src/app.js, src/app.ts, src/server.js, src/server.ts, src/index.js, src/index.ts\n\nNext:\n Run \`apophis init\` to scaffold a working app.js and config.`, } } // 6. Run verify execution const runResult = await runVerify({ fastify: fastify as any, seed, timeout: typeof config.presets?.[loadResult.presetName || '']?.timeout === 'number' ? (config.presets[loadResult.presetName || ''] as { timeout?: number }).timeout : undefined, routeFilters, changed, profileRoutes: config.profiles?.[profile || '']?.routes, runs: typeof config.presets?.[loadResult.presetName || '']?.runs === 'number' ? (config.presets[loadResult.presetName || ''] as { runs?: number }).runs : undefined, }) // 7. Build artifact const artifact = buildArtifact(runResult, { cwd: workingDir, configPath: loadResult.configPath, profile: profile || undefined, preset: loadResult.presetName || undefined, env, seed, routeFilters, }) // 8. Write artifact if configured or on failure const shouldEmitArtifact = Boolean(artifactDir || config.artifactDir || !runResult.passed) await emitArtifact(artifact, { command: 'verify', cwd: workingDir, preferredDir: artifactDir || config.artifactDir, force: shouldEmitArtifact, }) // 9. Format output based on format option const outputCtx: OutputContext = { isTTY: ctx.isTTY, isCI: ctx.isCI, colorMode: ctx.options.color, } let message: string if (format === 'json') { message = renderJsonArtifact(artifact) } else if (format === 'json-summary') { message = renderJsonSummaryArtifact(artifact) } else if (format === 'ndjson') { // For ndjson, we don't return a message string; events are streamed message = '' } else if (format === 'ndjson-summary') { // Concise ndjson: only summary events message = '' } else { // human format message = renderHumanArtifact(artifact, outputCtx) } // Determine exit code let exitCode: number = SUCCESS if (runResult.noRoutesMatched || runResult.noContractsFound || runResult.notGitRepo) { exitCode = USAGE_ERROR } else if (!runResult.passed) { exitCode = BEHAVIORAL_FAILURE } return { exitCode: exitCode as import('../../core/types.js').ExitCode, artifact, message, warnings: artifact.warnings, } } catch (error) { const message = error instanceof Error ? error.message : String(error) // Config validation errors are usage errors, not internal errors if (error instanceof Error && error.name === 'ConfigValidationError') { return { exitCode: USAGE_ERROR, message: `Config validation failed: ${message}`, } } return { exitCode: INTERNAL_ERROR, message: `Internal error in verify command: ${message}`, } } } // --------------------------------------------------------------------------- // CLI adapter // --------------------------------------------------------------------------- /** * Adapter that bridges the CLI framework (cac) to the verify command handler. * This function signature matches what the CLI core expects. */ export async function handleVerify( args: string[], ctx: CliContext, ): Promise { const options: VerifyOptions = { profile: ctx.options.profile || undefined, routes: undefined, seed: undefined, changed: false, config: ctx.options.config || undefined, cwd: ctx.cwd, format: ctx.options.format as VerifyOptions['format'], quiet: ctx.options.quiet, verbose: ctx.options.verbose, artifactDir: ctx.options.artifactDir || undefined, } // Parse command-specific flags from args (passed by CLI dispatcher) const routesIdx = args.indexOf('--routes') if (routesIdx !== -1 && args[routesIdx + 1]) { options.routes = args[routesIdx + 1] } const seedIdx = args.indexOf('--seed') if (seedIdx !== -1 && args[seedIdx + 1]) { const parsed = parseInt(args[seedIdx + 1]!, 10) if (!isNaN(parsed)) { options.seed = parsed } } options.seed = options.seed as number | undefined if (args.includes('--changed')) { options.changed = true } const workspaceMode = args.includes('--workspace') if (workspaceMode) { const packages = findWorkspacePackages(ctx.cwd) if (packages.length === 0) { if (!ctx.options.quiet) { console.error('No workspace packages found. Ensure workspaces are defined in root package.json or pnpm-workspace.yaml.') } return USAGE_ERROR } const runs: WorkspaceRun[] = [] let overallExitCode = SUCCESS const allWarnings: string[] = [] for (const pkgPath of packages) { const pkgName = basename(pkgPath) const pkgOptions = { ...options, cwd: pkgPath } const pkgCtx: CliContext = { ...ctx, cwd: pkgPath } const pkgResult = await verifyCommand(pkgOptions, pkgCtx) if (pkgResult.artifact) { pkgResult.artifact.package = pkgName runs.push({ package: pkgName, cwd: pkgPath, artifact: pkgResult.artifact }) } if (pkgResult.exitCode !== SUCCESS) { overallExitCode = pkgResult.exitCode } if (pkgResult.warnings) { allWarnings.push(...pkgResult.warnings.map(w => `[${pkgName}] ${w}`)) } } const workspaceResult: WorkspaceResult = { exitCode: overallExitCode as import('../../core/types.js').ExitCode, runs, warnings: allWarnings, } const format = options.format || ctx.options.format || 'human' if (!ctx.options.quiet) { if (format === 'json') { console.log(renderJson({ exitCode: workspaceResult.exitCode, runs: workspaceResult.runs.map(r => ({ package: r.package, cwd: r.cwd, artifact: r.artifact, })), warnings: workspaceResult.warnings, })) } else if (format === 'json-summary') { console.log(renderJson({ exitCode: workspaceResult.exitCode, runs: workspaceResult.runs.map(r => ({ package: r.package, cwd: r.cwd, summary: r.artifact.summary, exitReason: r.artifact.exitReason, })), warnings: workspaceResult.warnings, })) } else if (format === 'ndjson') { for (const run of workspaceResult.runs) { process.stdout.write(JSON.stringify({ type: 'workspace.run.completed', package: run.package, cwd: run.cwd, summary: run.artifact.summary, exitReason: run.artifact.exitReason, }) + '\n') } process.stdout.write(JSON.stringify({ type: 'workspace.completed', exitCode: workspaceResult.exitCode, packages: workspaceResult.runs.length, }) + '\n') } else if (format === 'ndjson-summary') { for (const run of workspaceResult.runs) { process.stdout.write(JSON.stringify({ type: 'workspace.run.completed', package: run.package, cwd: run.cwd, summary: run.artifact.summary, exitReason: run.artifact.exitReason, }) + '\n') } process.stdout.write(JSON.stringify({ type: 'workspace.completed', exitCode: workspaceResult.exitCode, packages: workspaceResult.runs.length, }) + '\n') } else { // Human format const lines: string[] = [] lines.push('Workspace verify results') lines.push('') for (const run of workspaceResult.runs) { const a = run.artifact const status = a.exitReason === 'success' ? '✓' : '✗' lines.push(` ${status} ${run.package}: ${a.summary.passed}/${a.summary.total} passed`) if (a.summary.failed > 0) { lines.push(` ${a.summary.failed} failed`) } } lines.push('') lines.push(`Overall: ${workspaceResult.exitCode === SUCCESS ? 'passed' : 'failed'}`) console.log(lines.join('\n')) } } if (format !== 'json' && format !== 'ndjson' && format !== 'json-summary' && format !== 'ndjson-summary' && allWarnings.length > 0 && !ctx.options.quiet) { for (const warning of allWarnings) { console.warn(`Warning: ${warning}`) } } return workspaceResult.exitCode } const result = await verifyCommand(options, ctx) const format = options.format || ctx.options.format || 'human' const machineMode = format === 'json' || format === 'ndjson' || format === 'json-summary' || format === 'ndjson-summary' if (!ctx.options.quiet) { if (format === 'json') { if (result.artifact) { console.log(renderJsonArtifact(result.artifact)) } else { console.log(renderJson({ exitCode: result.exitCode, message: result.message, warnings: result.warnings, })) } } else if (format === 'json-summary') { if (result.artifact) { console.log(renderJsonSummaryArtifact(result.artifact)) } else { console.log(renderJson({ exitCode: result.exitCode, message: result.message, warnings: result.warnings, })) } } else if (format === 'ndjson') { if (result.artifact) { renderNdjsonArtifact(result.artifact) } else { process.stdout.write(JSON.stringify({ type: 'run.completed', command: 'verify', exitCode: result.exitCode, message: result.message, warnings: result.warnings, }) + '\n') } } else if (format === 'ndjson-summary') { if (result.artifact) { renderNdjsonSummaryArtifact(result.artifact) } else { process.stdout.write(JSON.stringify({ type: 'run.completed', command: 'verify', exitCode: result.exitCode, message: result.message, warnings: result.warnings, }) + '\n') } } else if (result.message) { console.log(result.message) } } // Print warnings in human mode only if (!machineMode && result.warnings && result.warnings.length > 0 && !ctx.options.quiet) { for (const warning of result.warnings) { console.warn(`Warning: ${warning}`) } } return result.exitCode }