Files

610 lines
18 KiB
Markdown

# 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)
```bash
npm install apophis-fastify
# peer deps: fastify, @fastify/swagger
```
#### 1.2 Minimal Setup (2 minutes)
```typescript
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
```typescript
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)
```yaml
# .github/workflows/contracts.yml
- run: npm test
env:
APOPHIS_CHANGED_ROUTES: "${{ steps.changes.outputs.routes }}"
```
### Files to Create
- `docs/getting-started.md` — Full guide
- `docs/examples/crud-api.ts` — Complete working example
- `docs/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:
```typescript
// 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:
```typescript
// 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:
```typescript
// 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:
```typescript
// In validatePostconditions
const stack = new Error().stack
return {
success: false,
error: new ContractViolation({
// ... fields
stack: cleanStack(stack),
})
}
```
### Files to Create/Modify
- `src/types.ts` — Add `ContractViolation` interface
- `src/domain/error-suggestions.ts` — Suggestion engine
- `src/domain/error-formatter.ts` — Human-readable formatter
- `src/domain/contract-validation.ts` — Return structured errors
- `src/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.json` in 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**
```bash
# 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**
```json
// .apophis-hints.json
{
"changed": ["/users", "POST /orders"],
"reason": "PR #123: Updated user and order endpoints"
}
```
**Pattern C: Full Cache Reset**
```bash
# Nuclear option: rebuild everything
rm .apophis-cache.json
npm test
```
**Pattern D: Monorepo Support**
```bash
# Per-package cache
APOPHIS_CACHE_FILE="./packages/api/.apophis-cache.json" npm test
```
#### 3.3 GitHub Actions Example
```yaml
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
```typescript
// 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 guide
- `docs/examples/github-actions.yml` — Working workflow
- `docs/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
```typescript
// 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:
```typescript
// 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:
```typescript
// 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 failures
- `src/test/failure-analyzer.ts` — Auto-analyze and suggest fixes
- `src/test/error-renderer.ts` — Terminal-friendly rendering with box drawing
---
## 5. ERROR SYSTEM ARCHITECTURE
### Design Principles
1. **Structured over String**: All errors are objects, not strings
2. **Context-Rich**: Every error includes request, response, and contract context
3. **Actionable**: Every error includes a suggestion for how to fix it
4. **Traceable**: Every error includes a stack trace and route identifier
5. **Diff-Friendly**: Equality failures show visual diffs
6. **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
```typescript
export type ApophisError =
| ContractViolation
| FormulaParseError
| FormulaEvalError
| PreconditionError
| InvariantError
| TestGenerationError
```
---
## 6. IMPLEMENTATION ORDER
### Week 1: Foundation ✅ COMPLETE
- [x] Create `ContractViolation` type in `src/types.ts`
- [x] Update `contract-validation.ts` to return structured errors
- [x] Create `error-suggestions.ts` with basic suggestion engine
- [x] Update `tap-formatter.ts` to render rich diagnostics
- [x] Add tests for new error system
- [x] Fix `extractContract` null schema crash (`contract.ts:21`)
- [x] Fix `hashSchema` circular reference stack overflow (`hash.ts:24`)
- [x] Fix cleanup manager signal listener leak (`cleanup-manager.ts:48`)
- [x] Block dangerous accessors (`__proto__`, `constructor`, `prototype`) in formula evaluator
- [x] Normalize empty arrays to singletons in `extractContract`
- [x] Fix build output path (`tsconfig.json` rootDir)
- [x] Document route registration order requirement in README
- [x] Add violation deduplication in test output (PETIT + stateful runners)
- [x] Fix HEAD route noise in test generation
- [x] 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
- [x] Write `docs/getting-started.md`
- [x] Create `docs/examples/minimal.ts`
- [x] Create `docs/examples/crud-api.ts`
- [ ] Add screenshots/GIFs of test output
- [x] 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
- [x] Create `counterexample-formatter.ts`
- [x] Create `failure-analyzer.ts`
- [x] Create `error-renderer.ts` with box drawing
- [x] Integrate with stateful runner
- [x] Add tests for formatting
### Week 5: Production Hardening ✅ COMPLETE
- [x] Regex DoS protection with `safe-regex`
- [x] Standard logging with `pino` (APOPHIS_LOG_LEVEL)
- [x] Environment-aware cache (disabled in production/test)
- [x] Lazy cache loading (no sync file I/O at module load)
- [x] Fastify prefix support in route discovery
- [x] Signal handler deduplication (global Map)
- [x] Add `dispose()` method to CleanupManager
- [x] Remove all `console.log` from production code
- [x] Stryker mutation testing (contract-validation: 70%, error-suggestions: 68.7%)
- [x] Fix flaky property test (schema-to-arbitrary)
- [x] 345 tests passing
### Week 6: Scope Isolation ✅ COMPLETE
- [x] Implement scope filtering in `petit-runner.ts`
- [x] Implement scope filtering in `stateful-runner.ts`
- [x] Add scope headers to test requests via `buildRequest`
- [x] 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 install` to 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