18 KiB
APOPHIS DX Improvement Plan
Getting Started, Error Context, Cache/CI Docs, and Human-Readable Output
1. GETTING STARTED GUIDE
Goal
A complete "Hello World" to "Production Ready" guide that a developer can follow in 15 minutes.
Structure
1.1 Installation (30 seconds)
npm install apophis-fastify
# peer deps: fastify, @fastify/swagger
1.2 Minimal Setup (2 minutes)
import Fastify from 'fastify'
import apophisPlugin from 'apophis-fastify'
const fastify = Fastify()
// APOPHIS needs @fastify/swagger for spec generation
await fastify.register(import('@fastify/swagger'), {})
await fastify.register(apophisPlugin, {
validateRuntime: true, // optional: validates contracts on every request
})
fastify.get('/health', {
schema: {
response: {
200: {
type: 'object',
properties: { status: { type: 'string' } }
}
}
}
}, async () => ({ status: 'ok' }))
await fastify.ready()
// Run contract tests
const result = await fastify.apophis.test({ mode: 'all', depth: 'quick' })
console.log(result.summary)
1.3 Your First Contract (5 minutes)
Explain the mental model:
- Requires (preconditions): What must be true BEFORE the request
- Ensures (postconditions): What must be true AFTER the response
- Invariants: What must ALWAYS be true across requests
fastify.post('/users', {
schema: {
'x-category': 'constructor', // creates a resource
'x-requires': [], // no preconditions
'x-ensures': [
'status:201',
'response_body(this).id != null',
'response_body(this).email == request_body(this).email',
],
body: {
type: 'object',
properties: {
email: { type: 'string', format: 'email' },
name: { type: 'string', minLength: 1 }
},
required: ['email', 'name']
},
response: {
201: {
type: 'object',
properties: {
id: { type: 'string' },
email: { type: 'string' },
name: { type: 'string' }
}
}
}
}
}, async (req, reply) => {
reply.status(201)
return { id: 'user-123', email: req.body.email, name: req.body.name }
})
1.4 Complete CRUD Example (7 minutes)
Show a full resource lifecycle:
- POST /users (constructor)
- GET /users/:id (observer — reads the resource)
- PUT /users/:id (mutator — updates the resource)
- DELETE /users/:id (destructor — deletes the resource)
Demonstrate:
- How constructors populate the state
- How observers verify state
- How mutators maintain invariants
- How cleanup works
1.5 Running in CI (1 minute)
# .github/workflows/contracts.yml
- run: npm test
env:
APOPHIS_CHANGED_ROUTES: "${{ steps.changes.outputs.routes }}"
Files to Create
docs/getting-started.md— Full guidedocs/examples/crud-api.ts— Complete working exampledocs/examples/minimal.ts— Single route example
2. RICH ERROR CONTEXT SYSTEM
Current State (Bad)
Contract violation: response_body(this).id != null
No context. No request body. No response body. No status code. No suggestion.
Target State (Good)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CONTRACT VIOLATION: POST /users
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Formula:
response_body(this).id != null
Expected:
id to be non-null
Actual:
id = undefined
Request:
POST /users
Content-Type: application/json
{
"email": "alice@example.com",
"name": "Alice"
}
Response:
HTTP/1.1 201 Created
content-type: application/json
{
"email": "alice@example.com",
"name": "Alice"
// id is MISSING
}
Suggestion:
Your handler returned a 201 but forgot to include 'id' in the
response body. Ensure your constructor routes return the created
resource with its generated identifier.
Stack:
at validatePostconditions (src/domain/contract-validation.ts:39)
at runSequence (src/test/stateful-runner.ts:167)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Implementation Plan
Phase 1: Structured Error Objects
Replace string errors with rich error types:
// src/types.ts
export interface ContractViolation {
readonly type: 'contract-violation'
readonly route: { method: string; path: string }
readonly formula: string
readonly formulaType: 'status' | 'apostl'
readonly request: {
body: unknown
headers: Record<string, string>
query: Record<string, unknown>
params: Record<string, unknown>
}
readonly response: {
statusCode: number
headers: Record<string, string>
body: unknown
}
readonly context: {
expected: string
actual: string
diff?: string
}
readonly suggestion?: string
readonly stack?: string
}
Phase 2: Smart Suggestions Engine
Add a suggestions module that maps common failures to actionable fixes:
// src/domain/error-suggestions.ts
export const getSuggestion = (violation: ContractViolation): string | undefined => {
// Status code mismatch
if (violation.formulaType === 'status') {
return `Expected status ${violation.context.expected}, got ${violation.context.actual}. Check your route handler's reply.status() call.`
}
// Null field
if (violation.formula.includes('!= null') && violation.context.actual === 'undefined') {
const field = extractField(violation.formula)
return `Field '${field}' is missing from the response. Ensure your handler returns all required fields.`
}
// Equality mismatch
if (violation.formula.includes('==')) {
return `Expected values to match. Check for typos, case sensitivity, or missing transformations.`
}
// Authorization
if (violation.formula.includes('authorization') || violation.formula.includes('tenant')) {
return `This route may require authentication headers. Check your scope configuration.`
}
return undefined
}
Phase 3: Diff Generation
For equality comparisons, show a visual diff:
// src/domain/error-formatter.ts
export const formatDiff = (expected: unknown, actual: unknown): string => {
if (typeof expected === 'string' && typeof actual === 'string') {
// String diff
return `Expected: "${expected}"\nActual: "${actual}"\nDiff: ${generateCharDiff(expected, actual)}`
}
if (typeof expected === 'number' && typeof actual === 'number') {
return `Expected: ${expected}\nActual: ${actual}\nDelta: ${actual - expected}`
}
// Object diff (shallow)
return `Expected: ${JSON.stringify(expected, null, 2)}\nActual: ${JSON.stringify(actual, null, 2)}`
}
Phase 4: Stack Traces
Capture the call stack at the point of failure:
// In validatePostconditions
const stack = new Error().stack
return {
success: false,
error: new ContractViolation({
// ... fields
stack: cleanStack(stack),
})
}
Files to Create/Modify
src/types.ts— AddContractViolationinterfacesrc/domain/error-suggestions.ts— Suggestion enginesrc/domain/error-formatter.ts— Human-readable formattersrc/domain/contract-validation.ts— Return structured errorssrc/test/tap-formatter.ts— Format violations in TAP output
3. CACHE/CI DOCUMENTATION
Goal
Clear documentation for CI/CD integration with practical examples.
Content
3.1 Cache Overview
Explain:
- What gets cached (schema → arbitrary mappings, generated commands)
- Where it lives (
.apophis-cache.jsonin project root) - When it invalidates (schema hash mismatch, explicit hints)
- Performance impact (12x speedup on warm cache)
3.2 CI/CD Integration Patterns
Pattern A: Git-based Route Detection
# Detect changed routes from git diff
CHANGED=$(git diff --name-only HEAD~1 | grep 'routes/' | sed 's|routes/||' | paste -sd ',' -)
APOPHIS_CHANGED_ROUTES="$CHANGED" npm test
Pattern B: Manual Hints File
// .apophis-hints.json
{
"changed": ["/users", "POST /orders"],
"reason": "PR #123: Updated user and order endpoints"
}
Pattern C: Full Cache Reset
# Nuclear option: rebuild everything
rm .apophis-cache.json
npm test
Pattern D: Monorepo Support
# Per-package cache
APOPHIS_CACHE_FILE="./packages/api/.apophis-cache.json" npm test
3.3 GitHub Actions Example
name: Contract Tests
on: [push, pull_request]
jobs:
contracts:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Detect changed routes
id: changes
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
CHANGED=$(git diff --name-only ${{ github.base_ref }} | grep -E 'routes/|schema/' || true)
echo "routes=$CHANGED" >> $GITHUB_OUTPUT
fi
- name: Run contract tests
run: npm test
env:
APOPHIS_CHANGED_ROUTES: ${{ steps.changes.outputs.routes }}
- name: Upload cache artifact
uses: actions/upload-artifact@v4
with:
name: apophis-cache
path: .apophis-cache.json
3.4 Cache Configuration API
// Programmatic control
import { invalidateRoutes, invalidateCache } from 'apophis-fastify/incremental/cache'
// Before test run
invalidateRoutes(['/users']) // Invalidate specific routes
invalidateCache() // Clear everything
Files to Create
docs/cache-and-ci.md— Complete guidedocs/examples/github-actions.yml— Working workflowdocs/examples/gitlab-ci.yml— GitLab example
4. HUMAN-READABLE FAST-CHECK OUTPUT
Current State (Bad)
Property failed after 42 tests
Counterexample: [{"name":"","email":"a@b.c"}]
Target State (Good)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
PROPERTY TEST FAILURE: POST /users
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Fast-check found a counterexample after 42 generated test cases:
Generated Input:
{
"name": "", ← empty string (violates minLength: 1)
"email": "a@b.c" ← valid email format
}
Request:
POST /users
Content-Type: application/json
{ "name": "", "email": "a@b.c" }
Response:
HTTP/1.1 400 Bad Request
{ "error": "Name is required" }
Contract Violation:
Postcondition: status:201
Expected: 201 Created
Actual: 400 Bad Request
Analysis:
Your schema requires name to have minLength: 1, but the
generated test case produced an empty string. Your handler
correctly rejected it with 400, but the contract expects 201.
Fix: Either:
1. Remove minLength constraint from schema if empty names are valid
2. Update contract to expect 400 for invalid input
3. Add x-category: 'utility' if this is a validation endpoint
Shrunk: 3 times (from 128-character string to empty string)
Seed: 12345 (re-run with APOPHIS_SEED=12345 to reproduce)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Implementation Plan
Phase 1: Counterexample Formatter
// src/test/counterexample-formatter.ts
export interface FormattedCounterexample {
readonly route: { method: string; path: string }
readonly generatedInput: Record<string, unknown>
readonly request: { body: unknown; headers: Record<string, string> }
readonly response: { statusCode: number; body: unknown }
readonly contractViolation: ContractViolation
readonly shrinkCount: number
readonly seed: number
}
export const formatCounterexample = (example: FormattedCounterexample): string => {
// Build human-readable output
}
Phase 2: Route Context in Errors
When fast-check finds a failure, include the route context:
// In stateful-runner.ts, catch fast-check errors
try {
await fc.assert(prop, { numRuns, seed })
} catch (err) {
if (err instanceof fc.Error) {
const formatted = formatFastCheckError(err, results)
console.error(formatted)
}
}
Phase 3: Analysis Engine
Auto-analyze failures and suggest fixes:
// src/test/failure-analyzer.ts
export const analyzeFailure = (
cmd: ApiOperation,
ctx: EvalContext,
violation: ContractViolation
): string => {
// 400 status with 201 expectation
if (ctx.response.statusCode === 400 && violation.formula === 'status:201') {
return `Your handler rejected valid input. Check schema constraints match contract expectations.`
}
// Missing field
if (violation.formula.includes('!= null') && violation.context.actual === 'undefined') {
const field = extractField(violation.formula)
return `Response missing '${field}'. Check your handler returns all required fields.`
}
// Schema mismatch
return `Schema and contract may be out of sync. Review both for consistency.`
}
Files to Create
src/test/counterexample-formatter.ts— Format fast-check failuressrc/test/failure-analyzer.ts— Auto-analyze and suggest fixessrc/test/error-renderer.ts— Terminal-friendly rendering with box drawing
5. ERROR SYSTEM ARCHITECTURE
Design Principles
- Structured over String: All errors are objects, not strings
- Context-Rich: Every error includes request, response, and contract context
- Actionable: Every error includes a suggestion for how to fix it
- Traceable: Every error includes a stack trace and route identifier
- Diff-Friendly: Equality failures show visual diffs
- Reproducible: Every error includes the seed needed to reproduce
Error Flow
Test Execution
↓
Contract Validation (contract-validation.ts)
↓
Structured Error Object (ContractViolation)
↓
Suggestion Engine (error-suggestions.ts)
↓
Diff Generation (error-formatter.ts)
↓
TAP Output (tap-formatter.ts)
↓
Console/CI Reporter
Error Types
export type ApophisError =
| ContractViolation
| FormulaParseError
| FormulaEvalError
| PreconditionError
| InvariantError
| TestGenerationError
6. IMPLEMENTATION ORDER
Week 1: Foundation ✅ COMPLETE
- Create
ContractViolationtype insrc/types.ts - Update
contract-validation.tsto return structured errors - Create
error-suggestions.tswith basic suggestion engine - Update
tap-formatter.tsto render rich diagnostics - Add tests for new error system
- Fix
extractContractnull schema crash (contract.ts:21) - Fix
hashSchemacircular reference stack overflow (hash.ts:24) - Fix cleanup manager signal listener leak (
cleanup-manager.ts:48) - Block dangerous accessors (
__proto__,constructor,prototype) in formula evaluator - Normalize empty arrays to singletons in
extractContract - Fix build output path (
tsconfig.jsonrootDir) - Document route registration order requirement in README
- Add violation deduplication in test output (PETIT + stateful runners)
- Fix HEAD route noise in test generation
- Add clean stack traces filtered to user code
Status: Error type chain tightened. EvalResult uses error: string with optional violation?: ContractViolation. Runners check post.violation. All 246 tests passing. Hardened against null schemas, circular references, prototype pollution, signal leaks, and duplicate failures.
Week 2: Getting Started ✅ COMPLETE
- Write
docs/getting-started.md - Create
docs/examples/minimal.ts - Create
docs/examples/crud-api.ts - Add screenshots/GIFs of test output
- Update README with quick-start section
Week 3: Cache/CI Docs
- Write
docs/cache-and-ci.md - Create GitHub Actions example
- Create GitLab CI example
- Document
APOPHIS_CHANGED_ROUTES - Document
.apophis-hints.json
Week 4: Fast-Check Formatter ✅ COMPLETE
- Create
counterexample-formatter.ts - Create
failure-analyzer.ts - Create
error-renderer.tswith box drawing - Integrate with stateful runner
- Add tests for formatting
Week 5: Production Hardening ✅ COMPLETE
- Regex DoS protection with
safe-regex - Standard logging with
pino(APOPHIS_LOG_LEVEL) - Environment-aware cache (disabled in production/test)
- Lazy cache loading (no sync file I/O at module load)
- Fastify prefix support in route discovery
- Signal handler deduplication (global Map)
- Add
dispose()method to CleanupManager - Remove all
console.logfrom production code - Stryker mutation testing (contract-validation: 70%, error-suggestions: 68.7%)
- Fix flaky property test (schema-to-arbitrary)
- 345 tests passing
Week 6: Scope Isolation ✅ COMPLETE
- Implement scope filtering in
petit-runner.ts - Implement scope filtering in
stateful-runner.ts - Add scope headers to test requests via
buildRequest - Tests for multi-scope scenarios
Status: Scope isolation fully implemented. Routes with x-scope annotation are filtered by the scope test parameter. Scope headers from ScopeRegistry are passed to test requests. 249 tests passing.
7. SUCCESS METRICS
- New user can go from
npm installto passing contract tests in < 15 minutes - Error messages include request/response context 100% of the time
- 80% of contract violations include an actionable suggestion
- CI integration documented for GitHub Actions, GitLab CI, and CircleCI
- Fast-check failures formatted with route context and analysis
- All examples in documentation are tested and working
- README has a "Getting Started" section above the fold