804 lines
26 KiB
TypeScript
804 lines
26 KiB
TypeScript
|
|
/**
|
||
|
|
* 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 { resolveGenerationProfileOverride, GenerationProfileResolutionError } from '../../core/generation-profile.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
|
||
|
|
generationProfile?: 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.contract,
|
||
|
|
expected: f.expected,
|
||
|
|
observed: f.observed,
|
||
|
|
seed: options.seed,
|
||
|
|
replayCommand: `apophis replay --artifact ${f.artifactPath || '<artifact-path-unavailable>'}`,
|
||
|
|
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.noRoutesMatched) {
|
||
|
|
warnings.push(`No routes matched the filter. Available routes: ${runResult.availableRoutes?.join(', ') || 'none'}`)
|
||
|
|
}
|
||
|
|
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.0.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<string | undefined> {
|
||
|
|
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<string>()
|
||
|
|
|
||
|
|
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')
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Format human-readable output for verify results.
|
||
|
|
*/
|
||
|
|
function formatHumanOutput(
|
||
|
|
runResult: VerifyRunResult,
|
||
|
|
options: { profile?: string; seed: number; env: string; routeFilters?: string[] },
|
||
|
|
): string {
|
||
|
|
const lines: string[] = []
|
||
|
|
|
||
|
|
if (runResult.notGitRepo) {
|
||
|
|
lines.push(`--changed requires a git repository.`)
|
||
|
|
lines.push(`Current directory is not inside a git repo.`)
|
||
|
|
lines.push('')
|
||
|
|
lines.push('Next:')
|
||
|
|
lines.push(` Initialize git with \`git init\`, or run verify without --changed.`)
|
||
|
|
lines.push('')
|
||
|
|
return lines.join('\n')
|
||
|
|
}
|
||
|
|
|
||
|
|
if (runResult.noRelevantChanges) {
|
||
|
|
lines.push(`No relevant changes detected.`)
|
||
|
|
lines.push(`Git shows no modified files that match any route.`)
|
||
|
|
lines.push('')
|
||
|
|
return lines.join('\n')
|
||
|
|
}
|
||
|
|
|
||
|
|
if (runResult.noRoutesMatched) {
|
||
|
|
lines.push(`No routes matched the filter.`)
|
||
|
|
lines.push(`Filters applied: ${options.routeFilters?.join(', ') || 'none'}`)
|
||
|
|
lines.push(`Available routes:`)
|
||
|
|
for (const r of runResult.availableRoutes || []) {
|
||
|
|
lines.push(` ${r}`)
|
||
|
|
}
|
||
|
|
lines.push('')
|
||
|
|
lines.push('Next:')
|
||
|
|
lines.push(` Adjust --routes filter or add routes to your app.`)
|
||
|
|
lines.push('')
|
||
|
|
return lines.join('\n')
|
||
|
|
}
|
||
|
|
|
||
|
|
if (runResult.noContractsFound) {
|
||
|
|
lines.push('No behavioral contracts found.')
|
||
|
|
lines.push('')
|
||
|
|
lines.push('APOPHIS discovered routes, but none have behavioral contracts.')
|
||
|
|
lines.push('Schema-only routes (with response schemas) are not enough.')
|
||
|
|
lines.push('You must add x-ensures or x-requires clauses that check behavior.')
|
||
|
|
lines.push('')
|
||
|
|
lines.push('Example — add this to your route schema:')
|
||
|
|
lines.push(' "x-ensures": [')
|
||
|
|
lines.push(' "response_code(GET /users/{response_body(this).id}) == 200"')
|
||
|
|
lines.push(' ]')
|
||
|
|
lines.push('')
|
||
|
|
lines.push('Next steps:')
|
||
|
|
lines.push(' 1. Open your route file (e.g., app.js or src/routes/users.js)')
|
||
|
|
lines.push(' 2. Find the route you want to test')
|
||
|
|
lines.push(' 3. Add an "x-ensures" array inside the schema object')
|
||
|
|
lines.push(' 4. Run: apophis verify --profile quick --routes "POST /users"')
|
||
|
|
lines.push('')
|
||
|
|
lines.push('For more examples, see docs/getting-started.md')
|
||
|
|
lines.push('')
|
||
|
|
return lines.join('\n')
|
||
|
|
}
|
||
|
|
|
||
|
|
// Print failures using canonical format
|
||
|
|
for (const failure of runResult.failures) {
|
||
|
|
const failureRecord: FailureRecord = {
|
||
|
|
route: failure.route,
|
||
|
|
contract: failure.contract,
|
||
|
|
expected: failure.expected,
|
||
|
|
observed: failure.observed,
|
||
|
|
seed: options.seed,
|
||
|
|
replayCommand: `apophis replay --artifact ${failure.artifactPath || 'reports/apophis/failure-*.json'}`,
|
||
|
|
}
|
||
|
|
lines.push(formatHumanFailure(failureRecord, options.profile))
|
||
|
|
lines.push('')
|
||
|
|
}
|
||
|
|
|
||
|
|
// Summary
|
||
|
|
if (runResult.passed) {
|
||
|
|
lines.push(`All ${runResult.total} contract(s) passed.`)
|
||
|
|
} else {
|
||
|
|
lines.push(`Failed: ${runResult.failed} of ${runResult.total} contract(s) failed.`)
|
||
|
|
}
|
||
|
|
lines.push(`Seed: ${options.seed}`)
|
||
|
|
|
||
|
|
// Replay command on failure
|
||
|
|
if (!runResult.passed && runResult.failures.length > 0) {
|
||
|
|
lines.push('')
|
||
|
|
lines.push('Replay')
|
||
|
|
lines.push(` apophis replay --artifact <path-to-artifact>`)
|
||
|
|
lines.push('')
|
||
|
|
lines.push('Determinism')
|
||
|
|
lines.push(` This run used seed ${options.seed}.`)
|
||
|
|
lines.push(` Same seed + same app state = same results.`)
|
||
|
|
lines.push(` If results differ on re-run, the app has nondeterministic behavior.`)
|
||
|
|
lines.push(` Stabilize: reset app state, mock external services, avoid time-dependent logic.`)
|
||
|
|
}
|
||
|
|
|
||
|
|
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<CommandResult> {
|
||
|
|
const {
|
||
|
|
profile,
|
||
|
|
generationProfile,
|
||
|
|
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
|
||
|
|
const resolvedGenerationProfile = resolveGenerationProfileOverride(generationProfile, 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
|
||
|
|
try {
|
||
|
|
const { loadApp } = await import('../../core/app-loader.js')
|
||
|
|
const loaded = await loadApp(workingDir)
|
||
|
|
fastify = loaded.fastify
|
||
|
|
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: `No Fastify app found. Ensure app.js exports a Fastify instance.\n\nError: ${errorMessage}\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,
|
||
|
|
generationProfile: resolvedGenerationProfile,
|
||
|
|
timeout: typeof config.presets?.[loadResult.presetName || '']?.timeout === 'number'
|
||
|
|
? (config.presets[loadResult.presetName || ''] as { timeout?: number }).timeout
|
||
|
|
: undefined,
|
||
|
|
routeFilters,
|
||
|
|
changed,
|
||
|
|
profileRoutes: config.profiles?.[profile || '']?.routes,
|
||
|
|
})
|
||
|
|
|
||
|
|
// 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}`,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (error instanceof GenerationProfileResolutionError) {
|
||
|
|
return {
|
||
|
|
exitCode: USAGE_ERROR,
|
||
|
|
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<number> {
|
||
|
|
const options: VerifyOptions = {
|
||
|
|
profile: ctx.options.profile || undefined,
|
||
|
|
generationProfile: ctx.options.generationProfile,
|
||
|
|
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 generationProfileIdx = args.indexOf('--generation-profile')
|
||
|
|
if (generationProfileIdx !== -1 && args[generationProfileIdx + 1]) {
|
||
|
|
options.generationProfile = args[generationProfileIdx + 1]
|
||
|
|
}
|
||
|
|
|
||
|
|
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
|
||
|
|
}
|