Files

1652 lines
58 KiB
Markdown

# NEXT_STEPS_425.md — Post-v1.1 Integration Feedback & Priorities
## Status: v1.3 Complete (2026-04-25)
**Test count**: 551 passing, 0 failures
**New in v1.3**: All 8 protocol extensions (JWT, Time, Stateful, X.509, SPIFFE, Token Hash, HTTP Signature, Request Context), Plugin Contract System with lazy extension resolution, built-in plugin contracts for `@fastify/auth`, `@fastify/cors`, `@fastify/compress`, `@fastify/rate-limit`
## Completed
### v1.2 (2026-04-25)
- [x] **F1**: APOSTL `else` is optional — defaults to `else T`
- [x] **F2**: Value proposition comparison table in README and skills.md
- [x] **F3**: Auth extension factory — `createAuthExtension()`
- [x] **F4**: ContractViolation includes full request/response context
- [x] **F5**: Fastify App Structure Guide
- [x] **Chaos Mode**: Content-type aware corruption with extension strategies (21 tests)
### v1.3 (2026-04-25)
- [x] **P0.1**: JWT Extension — claims, headers, format detection, Base64URL decode, `seen_jtis` tracking
- [x] **P0.2**: Time Control — `now()` predicate + `createTimeControl().advance()` API
- [x] **P1.1**: Stateful predicates — `already_seen()`, `is_consumed()`, `previous(category)`
- [x] **P1.2**: X.509 Extension — URI SANs, CA check, expiration, self-signed, issuer, subject
- [x] **P2.1**: SPIFFE Extension — trust domain, path, validation
- [x] **P2.2**: Token Hash Extension — `ath_valid()`, `tth_valid()`, `token_hash()`
- [x] **P2.3**: HTTP Signature Extension — `signature_input()`, `signature_covers()`
- [x] **P2.4**: Request Context — `request_url()`, `request_tls()`, `request_body_hash()`
- [x] **Plugin Contract System** — Registry, pattern matching, composition, lazy extension resolution
- [x] **Built-in Plugin Contracts**`@fastify/auth`, `@fastify/cors`, `@fastify/compress`, `@fastify/rate-limit`
- [x] **Extension Registry Link**`setPluginContractRegistry()` notifies on extension registration
- [x] **Runner Integration** — Plugin contracts composed with route contracts in `petit-runner.ts`
## Expert Assessment Remediation Plan
### Chaos Engineering (Critical — Grade: F)
#### Issue C1: Two-Level Probability Bug
**Location**: `src/quality/chaos.ts:55, 82`
**Problem**: Global gate at line 55 applies `config.probability`, then `pickEventType()` at line 82 applies per-event probability. Actual injection rate = `config.probability * eventProbability`, not `eventProbability`.
**Pseudocode Fix**:
```
FUNCTION executeWithChaos(executeHttp, route, request, extensionRegistry):
assertTestEnv('Chaos mode')
this.events = []
// Pick event type using weighted probabilities (no global gate)
eventType = this.pickEventType()
IF eventType IS NULL:
ctx = await executeHttp()
RETURN { ctx, events: [] }
// Apply the selected event directly
SWITCH eventType:
CASE 'delay': RETURN this.injectDelay(executeHttp)
CASE 'dropout': RETURN this.injectDropout(route, request)
CASE 'error': RETURN this.injectError(executeHttp, route, request)
CASE 'corruption': RETURN this.injectCorruption(executeHttp, route, request, extensionRegistry)
```
**Invariants**:
- MUST: The probability of event type X being injected MUST equal `config[X].probability / sum(all configured probabilities)`
- MUST: The `pickEventType()` function MUST be the sole probability gate; no secondary filtering
- MAY NEVER: A global probability gate AND per-event probability multiply
#### Issue C2: `Math.random()` in Corruption Breaks Determinism
**Location**: `src/quality/corruption.ts:165`
**Problem**: `rng ?? new SeededRng(Date.now())` uses `Date.now()` when no RNG provided, making corruption non-deterministic.
**Pseudocode Fix**:
```
FUNCTION corruptResponse(ctx, contentType, extensionRegistry, rng):
// MUST receive RNG from caller; no fallback to Math.random() or Date.now()
ASSERT rng IS NOT NULL, "corruptResponse requires injected SeededRng"
// Check extension-provided strategies first
IF extensionRegistry HAS strategy FOR contentType:
RETURN applyExtensionStrategy(ctx, strategy, rng)
// Fall back to built-in strategies using injected RNG
baseType = contentType.split(';')[0].trim()
builtin = BUILTIN_STRATEGIES[baseType]
IF builtin EXISTS:
RETURN {
ctx: applyCorruption(ctx, (data) => builtin.strategy(data, rng), contentType),
strategy: builtin.name,
description: builtin.description
}
// Generic fallback with injected RNG
RETURN {
ctx: applyCorruption(ctx, (data) => truncateText(data, rng), contentType),
strategy: 'generic-truncate',
description: 'Generic truncation'
}
```
**Invariants**:
- MUST: `corruptResponse` MUST require `rng` parameter (non-optional)
- MUST: All corruption strategies MUST use the injected `rng`, never `Math.random()` or `Date.now()`
- MAY NEVER: Corruption produce different results for the same seed across runs
#### Issue C3: Seed Collision Risk in ChaosEngine
**Location**: `src/quality/chaos.ts:39`
**Problem**: `seed + 0xCA05` can collide for nearby seeds.
**Pseudocode Fix**:
```
CONSTRUCTOR(config, seed):
this.config = config
// Use hash-based seed derivation to avoid collisions
IF seed IS DEFINED:
chaosSeed = hashCombine(seed, 0xCA05)
ELSE:
chaosSeed = Date.now() // Only for undefined seed
this.rng = new SeededRng(chaosSeed)
FUNCTION hashCombine(a, b):
// FNV-1a inspired combination
hash = 0x811c9dc5
hash = ((hash ^ (a & 0xFF)) * 0x01000193) >>> 0
hash = ((hash ^ ((a >>> 8) & 0xFF)) * 0x01000193) >>> 0
hash = ((hash ^ ((a >>> 16) & 0xFF)) * 0x01000193) >>> 0
hash = ((hash ^ ((a >>> 24) & 0xFF)) * 0x01000193) >>> 0
hash = ((hash ^ (b & 0xFF)) * 0x01000193) >>> 0
hash = ((hash ^ ((b >>> 8) & 0xFF)) * 0x01000193) >>> 0
hash = ((hash ^ ((b >>> 16) & 0xFF)) * 0x01000193) >>> 0
hash = ((hash ^ ((b >>> 24) & 0xFF)) * 0x01000193) >>> 0
RETURN hash
```
**Invariants**:
- MUST: Chaos seed derivation MUST use a hash function, not simple addition
- MUST: Different test seeds MUST produce different chaos sequences with high probability
- MAY NEVER: Two test seeds within 100,000 of each other produce identical chaos behavior
### Runtime Hook Safety (Critical)
#### Issue H1: Hook Validator Throws 500s for Formula Parse Errors
**Location**: `src/infrastructure/hook-validator.ts:89-93, 101`
**Problem**: `preParseFormulas` throws on parse error, which becomes a 500 in Fastify hooks instead of failing at plugin registration time.
**Pseudocode Fix**:
```
// Split into two phases:
// Phase 1: Registration-time validation (in plugin/index.ts)
FUNCTION validateAllContracts(routes):
FOR EACH route IN routes:
FOR EACH formula IN route.requires + route.ensures:
TRY:
parse(formula)
CATCH err:
THROW Error(`Invalid formula in ${route.method} ${route.path}: ${formula}\n${err.message}`)
// Pre-parse and cache ASTs at registration time
routeAsts = new Map()
FOR EACH route IN routes:
routeAsts.set(route, {
requires: route.requires.map(f => parse(f).ast),
ensures: route.ensures.map(f => parse(f).ast)
})
RETURN routeAsts
// Phase 2: Hook uses pre-parsed ASTs (O(1), never throws)
FUNCTION createPreHandler(opts, routeAsts):
RETURN (request, reply, done) =>
contract = getRouteContract(request)
IF shouldSkipRoute(contract, opts):
done()
RETURN
asts = routeAsts.get(contract)?.requires
IF NOT asts:
done() // No requires to check
RETURN
context = buildPreContext(request)
evaluateFormulas(context, asts, contract.requires)
done()
```
**Invariants**:
- MUST: All formulas MUST be parsed at plugin registration time, not at request time
- MUST: Parse errors MUST fail plugin registration with a clear error message
- MAY NEVER: A request-time hook throw a 500 due to formula syntax error
- MAY NEVER: Formula parsing happen on the request hot path
#### Issue H2: env-guard Throws at Runtime Instead of Registration
**Location**: `src/quality/env-guard.ts:8-14`
**Problem**: `assertTestEnv` throws when chaos/flake/mutation are first used, not when configured.
**Pseudocode Fix**:
```
// At plugin registration time (src/plugin/index.ts):
FUNCTION registerApophis(fastify, opts):
IF opts.chaos IS DEFINED AND process.env.NODE_ENV !== 'test':
THROW Error('Chaos mode requires NODE_ENV=test')
IF opts.flake IS DEFINED AND process.env.NODE_ENV !== 'test':
THROW Error('Flake detection requires NODE_ENV=test')
IF opts.mutation IS DEFINED AND process.env.NODE_ENV !== 'test':
THROW Error('Mutation testing requires NODE_ENV=test')
// ... rest of registration
// Remove assertTestEnv from runtime paths entirely
// Quality features are only constructed in test environment
```
**Invariants**:
- MUST: Environment validation MUST happen at plugin registration time
- MUST: Quality feature configuration in non-test env MUST prevent plugin startup
- MAY NEVER: A runtime quality feature throw an environment error during test execution
### Architecture & Design (Martin Fowler / Uncle Bob)
#### Issue A1: petit-runner.ts Violates SRP (583 lines)
**Location**: `src/test/petit-runner.ts`
**Problem**: Single file handles command generation, precondition checking, HTTP execution, chaos injection, flake detection, postcondition validation, deduplication, and result formatting.
**Pseudocode Fix**:
```
// Extract into focused modules:
// src/test/command-generator.ts (lines 92-149)
FUNCTION generateCommands(routes, depth, seed):
// Pure: cache lookup, schema conversion, fast-check sampling
RETURN { commands, cacheHits, cacheMisses }
// src/test/precondition-checker.ts (lines 155-165)
FUNCTION checkPreconditions(command, state):
// Pure: check resource existence
RETURN boolean
// src/test/chaos-wrapper.ts (lines 288-298)
FUNCTION executeWithChaos(chaosEngine, executeFn, route, request, extensionRegistry):
// Effect: inject chaos if enabled
RETURN { ctx, chaosEvents }
// src/test/flake-detector.ts (lines 341-412)
FUNCTION detectFlake(failingResult, rerunFn, config, extensionRegistry, pluginContractRegistry):
// Effect: rerun with varied seeds
RETURN flakeReport
// src/test/postcondition-validator.ts (lines 324-339, 400-408)
FUNCTION validatePostconditionsWithPlugins(route, ctx, pluginContractRegistry, extensionRegistry):
// Pure: compose plugin + route contracts, validate
RETURN validationResult
// src/test/result-deduplicator.ts (lines 489-528)
FUNCTION deduplicateFailures(results):
// Pure: group by route+formula, keep first
RETURN dedupedResults
// src/test/petit-runner.ts (reduced to ~150 lines)
FUNCTION runPetitTests(fastify, config, scopeRegistry, extensionRegistry, pluginContractRegistry):
// Orchestrator: delegates to extracted modules
routes = discoverAndFilterRoutes(fastify, config)
{ commands, cacheHits, cacheMisses } = generateCommands(routes, depth, config.seed)
FOR EACH command IN commands:
IF NOT checkPreconditions(command, state):
results.push(SKIP)
CONTINUE
{ ctx, chaosEvents } = await executeWithChaos(...)
validation = validatePostconditionsWithPlugins(...)
IF NOT validation.success:
flakeReport = await detectFlake(...)
results.push(FAILURE with diagnostics)
ELSE:
results.push(SUCCESS)
state = updateModelState(command.route, ctx, state)
RETURN formatSuite(results, deduplicateFailures)
```
**Invariants**:
- MUST: No test runner module exceed 200 lines of code
- MUST: Each module have a single, clearly stated responsibility
- MUST: Orchestrator modules contain only delegation logic, no implementation
- MAY NEVER: A module mix pure computation with side effects
#### Issue A2: stateful-runner.ts Duplicates petit-runner Logic
**Location**: `src/test/stateful-runner.ts:54-64, 66-72`
**Problem**: Precondition checking (`ApiOperation.check`) and HTTP execution (`ApiOperation.run`) duplicate petit-runner logic.
**Pseudocode Fix**:
```
// Extract shared operations:
// src/test/operations.ts
CLASS ApiOperation:
CONSTRUCTOR(route, params):
this.route = route
this.params = params
check(model):
RETURN checkPreconditions(this.route, model) // Shared with petit-runner
async run(real):
request = buildRequest(this.route, this.params, real.scopeHeaders, real.state, real.rng)
ctx = await executeHttp(real.fastify, this.route, request, real.previousCtx)
real.history.push(ctx)
real.previousCtx = ctx
// src/test/stateful-runner.ts
IMPORT { ApiOperation } from './operations.js'
IMPORT { checkPreconditions } from './precondition-checker.js'
// Stateful runner focuses on fast-check command() integration only
```
**Invariants**:
- MUST: Precondition checking logic exist in exactly one module
- MUST: HTTP execution logic exist in exactly one module
- MAY NEVER: Two runners implement the same logic differently
#### Issue A3: Plugin Entry Point is God Object Factory
**Location**: `src/plugin/index.ts`
**Problem**: Lines 24-48 do 7 things: swagger registration, spec building, contract testing, stateful testing, health checking, route capture, and cleanup.
**Pseudocode Fix**:
```
// src/plugin/swagger.ts
FUNCTION registerSwagger(fastify, opts):
IF fastify HAS swagger: RETURN
swagger = await import('@fastify/swagger')
await fastify.register(swagger.default, opts.swagger ?? {})
// src/plugin/spec-builder.ts
FUNCTION buildSpec(fastify):
routes = discoverRoutes(fastify)
spec = fastify.swagger()
RETURN { ...spec, 'x-apophis-contracts': routes.map(...) }
// src/plugin/contract-runner.ts
FUNCTION buildContract(fastify, scope, extensionRegistry, pluginContractRegistry):
RETURN async (opts) =>
config = normalizeConfig(opts)
suite = await runPetitTests(fastify, config, scope, extensionRegistry, pluginContractRegistry)
validateNonEmptyDiscovery(suite, fastify)
RETURN suite
// src/plugin/index.ts (reduced to ~80 lines)
FUNCTION apophisPlugin(fastify, opts):
await registerSwagger(fastify, opts)
scopeRegistry = new ScopeRegistry()
cleanupManager = new CleanupManager(fastify, scopeRegistry)
extensionRegistry = createExtensionRegistry()
pluginContractRegistry = createPluginContractRegistry()
fastify.decorate('apophis', {
scope: scopeRegistry,
contract: buildContract(fastify, scopeRegistry, extensionRegistry, pluginContractRegistry),
stateful: buildStateful(fastify, scopeRegistry, cleanupManager, extensionRegistry, pluginContractRegistry),
check: buildCheck(fastify, scopeRegistry, extensionRegistry, pluginContractRegistry),
spec: buildSpec(fastify),
capture: captureRoute(fastify),
cleanup: cleanupManager,
extend: extensionRegistry.register.bind(extensionRegistry),
use: pluginContractRegistry.use.bind(pluginContractRegistry),
})
```
**Invariants**:
- MUST: Plugin entry point only orchestrate, never implement
- MUST: Each decoration factory live in its own module
- MAY NEVER: A single function perform more than 3 distinct responsibilities
### Type Safety (Uncle Bob)
#### Issue T1: `OperationHeader` Union with `string` Defeats Exhaustiveness
**Location**: `src/types.ts:79-83`
**Problem**: `| string` makes the union non-exhaustive; TypeScript can't verify all cases handled.
**Pseudocode Fix**:
```
// src/types.ts
// Remove the | string catch-all; use branded type for extensions
export type CoreOperationHeader =
| 'request_body' | 'response_body' | 'response_code'
| 'request_headers' | 'response_headers' | 'query_params'
| 'cookies' | 'response_time' | 'redirect_count'
| 'redirect_url' | 'redirect_status'
| 'timeout_occurred' | 'timeout_value'
// Extension headers use branded type
export type ExtensionHeader = string & { readonly __brand: 'ExtensionHeader' }
export type OperationHeader = CoreOperationHeader | ExtensionHeader
// Extension registration validates and brands headers:
FUNCTION registerExtensionHeader(name: string): ExtensionHeader {
IF NOT /^[a-z_][a-z0-9_]*$/.test(name):
THROW Error(`Invalid extension header name: ${name}`)
RETURN name as ExtensionHeader
}
```
**Invariants**:
- MUST: Core operation headers be exhaustively listable
- MUST: Extension headers require explicit registration and validation
- MAY NEVER: An unvalidated string be accepted as an operation header
#### Issue T2: `RequestStructure.body?: unknown` is Lazy Typing
**Location**: `src/domain/request-builder.ts:14`
**Problem**: `unknown` provides no type safety for body construction.
**Pseudocode Fix**:
```
// src/domain/request-builder.ts
export type RequestBody =
| { type: 'json'; data: Record<string, unknown> }
| { type: 'multipart'; fields: Record<string, unknown>; files: MultipartFiles }
| { type: 'text'; data: string }
| undefined
export interface RequestStructure {
method: string
url: string
headers: Record<string, string>
query?: Record<string, string>
body?: RequestBody
contentType?: string
}
// Build functions return discriminated union
FUNCTION buildJsonRequest(route, data, scopeHeaders, state):
RETURN {
method: route.method,
url: substitutePathParams(route.path, data, state),
headers: { ...scopeHeaders, 'content-type': 'application/json' },
body: { type: 'json', data: extractBodyParams(data, route.schema.body) }
}
```
**Invariants**:
- MUST: Request body type be a discriminated union, not `unknown`
- MUST: Body type determine serialization strategy
- MAY NEVER: A body be accepted without knowing its content type
### Performance & Implementation (John Carmack)
#### Issue P1: Hand-Rolled charCodeAt Parser (915 lines)
**Location**: `src/formula/parser.ts`
**Problem**: 915 lines of manual charCodeAt parsing is unmaintainable. Should use a parser generator or at least regex-based tokenizer.
**Pseudocode Fix**:
```
// Option A: Use a parser generator (PEG.js / nearley)
// grammar.ne:
// main -> expression
// expression -> comparison | boolean | conditional | quantified
// comparison -> operation _ comparator _ operation
// boolean -> expression _ ("&&" | "||" | "=>") _ expression
// ...
// Option B: Regex tokenizer + recursive descent (maintainable)
// src/formula/tokenizer.ts (~100 lines)
TOKEN_PATTERNS = [
{ type: 'IF', pattern: /^if\b/ },
{ type: 'THEN', pattern: /^then\b/ },
{ type: 'ELSE', pattern: /^else\b/ },
{ type: 'FOR', pattern: /^for\b/ },
{ type: 'EXISTS', pattern: /^exists\b/ },
{ type: 'IN', pattern: /^in\b/ },
{ type: 'IDENTIFIER', pattern: /^[a-zA-Z_][a-zA-Z0-9_-]*/ },
{ type: 'NUMBER', pattern: /^-?\d+(\.\d+)?/ },
{ type: 'STRING', pattern: /^"([^"\\]|\\.)*"/ },
{ type: 'COMPARATOR', pattern: /^(==|!=|<=|>=|<|>|matches)/ },
{ type: 'BOOLEAN_OP', pattern: /^(&&|\|\||=>)/ },
{ type: 'LPAREN', pattern: /^\(/ },
{ type: 'RPAREN', pattern: /^\)/ },
{ type: 'LBRACKET', pattern: /^\[/ },
{ type: 'RBRACKET', pattern: /^\]/ },
{ type: 'DOT', pattern: /^\./ },
{ type: 'COMMA', pattern: /^,/ },
{ type: 'WS', pattern: /^\s+/, skip: true }
]
FUNCTION tokenize(input):
tokens = []
pos = 0
WHILE pos < input.length:
matched = FALSE
FOR EACH pattern IN TOKEN_PATTERNS:
match = input.slice(pos).match(pattern.pattern)
IF match:
IF NOT pattern.skip:
tokens.push({ type: pattern.type, value: match[0], pos })
pos += match[0].length
matched = TRUE
BREAK
IF NOT matched:
THROW parseError(input, pos, `Unexpected character: ${input[pos]}`)
RETURN tokens
// src/formula/parser.ts (~200 lines, recursive descent on tokens)
FUNCTION parse(tokens):
pos = 0
FUNCTION peek():
RETURN tokens[pos]
FUNCTION consume(expectedType):
IF peek().type !== expectedType:
THROW parseError(...)
RETURN tokens[pos++]
FUNCTION parseExpression():
RETURN parseConditional()
FUNCTION parseConditional():
IF peek().type === 'IF':
consume('IF')
condition = parseBoolean()
consume('THEN')
thenBranch = parseExpression()
consume('ELSE')
elseBranch = parseExpression()
RETURN { type: 'conditional', condition, then: thenBranch, else: elseBranch }
RETURN parseBoolean()
// ... etc
RETURN parseExpression()
```
**Invariants**:
- MUST: Parser be maintainable by developers without parser expertise
- MUST: Tokenizer use regex patterns, not manual charCodeAt
- MUST: Parser structure follow standard recursive descent pattern
- MAY NEVER: A single parser file exceed 300 lines
#### Issue P2: `hashSchema` Only Keeps 16 Chars of SHA-256
**Location**: `src/incremental/hash.ts:88`
**Problem**: Truncating SHA-256 to 16 hex chars (64 bits) creates collision risk with large schemas.
**Pseudocode Fix**:
```
// Use full hash or at least 32 chars (128 bits)
FUNCTION hashSchema(schema):
IF schema IS undefined:
RETURN 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' // Empty SHA-256
cached = hashMemo.get(schema)
IF cached IS NOT undefined:
RETURN cached
hash = createHash('sha256')
FOR EACH key IN Object.keys(schema):
IF NOT RELEVANT_KEYS.has(key): CONTINUE
hash.update(key)
hash.update('=')
hashValue(hash, schema[key], new WeakSet())
hash.update(';')
result = hash.digest('hex') // Full 64 chars
hashMemo.set(schema, result)
RETURN result
```
**Invariants**:
- MUST: Schema hash use full SHA-256 output (64 hex chars)
- MUST: Hash memoization use WeakMap to avoid memory leaks
- MAY NEVER: Hash output be truncated below 256 bits
#### Issue P3: `PARSE_CACHE` Map Has No TTL
**Location**: `src/formula/parser.ts` (implied by cache pattern)
**Problem**: Parsed formula ASTs accumulate indefinitely in memory.
**Pseudocode Fix**:
```
// src/formula/parser.ts
class LruCache<K, V>:
private cache = new Map<K, V>()
private maxSize: number
CONSTRUCTOR(maxSize = 1000):
this.maxSize = maxSize
get(key):
IF NOT this.cache.has(key): RETURN undefined
value = this.cache.get(key)
// Move to end (most recently used)
this.cache.delete(key)
this.cache.set(key, value)
RETURN value
set(key, value):
IF this.cache.has(key):
this.cache.delete(key)
ELSE IF this.cache.size >= this.maxSize:
// Evict least recently used (first item)
firstKey = this.cache.keys().next().value
this.cache.delete(firstKey)
this.cache.set(key, value)
const PARSE_CACHE = new LruCache<string, ParseResult>(1000)
FUNCTION parse(formula):
cached = PARSE_CACHE.get(formula)
IF cached IS NOT undefined:
RETURN cached
result = parseInternal(formula)
PARSE_CACHE.set(formula, result)
RETURN result
```
**Invariants**:
- MUST: Parse cache use LRU eviction with configurable max size
- MUST: Max cache size default to 1000 entries
- MAY NEVER: Cache grow unbounded in long-running processes
#### Issue P4: `Promise.race` in `executeHttp` Doesn't Cancel Inject
**Location**: `src/infrastructure/http-executor.ts:104-113`
**Problem**: When timeout wins the race, the `fastify.inject()` promise continues running, potentially causing memory leaks or side effects.
**Pseudocode Fix**:
```
// Use AbortController for cancellation
FUNCTION executeHttp(fastify, route, request, previous, timeoutMs):
// ... setup ...
const controller = new AbortController()
let timeoutId: NodeJS.Timeout | undefined
try:
const injectPromise = fastify.inject({
method: request.method,
url: fullUrl,
payload: request.multipart ? buildMultipartPayload(request.multipart) : request.body,
headers: request.headers,
})
IF timeoutMs AND timeoutMs > 0:
timeoutId = setTimeout(() => {
timedOut = true
controller.abort()
}, timeoutMs)
response = await injectPromise
IF timeoutId:
clearTimeout(timeoutId)
CATCH err:
IF timeoutId:
clearTimeout(timeoutId)
IF timedOut:
RETURN buildTimeoutContext(...)
THROW err
// Note: Fastify inject() may not support AbortController.
// Alternative: track active requests and clean up on suite end.
```
**Invariants**:
- MUST: Timeout mechanism prevent resource leaks from abandoned requests
- MUST: Active request tracking enable cleanup on test suite completion
- MAY NEVER: A timed-out request continue consuming resources indefinitely
#### Issue P5: Streaming NDJSON Loads Entire Response Into Memory
**Location**: `src/infrastructure/http-executor.ts:170-186`
**Problem**: `responseBody` is fully loaded as string, then split and parsed. No backpressure for large streams.
**Pseudocode Fix**:
```
// For NDJSON streaming, use chunked processing
IF isStreaming AND streamFormat === 'ndjson':
const chunks = []
const maxChunks = streamConfig.maxChunks ?? 100
const maxChunkSize = streamConfig.maxChunkSize ?? 65536 // 64KB
const maxTotalSize = streamConfig.maxTotalSize ?? 1048576 // 1MB
let totalSize = 0
const lines = responseBody.split('\n')
FOR EACH line IN lines:
IF line.trim().length === 0: CONTINUE
const lineSize = line.length
IF lineSize > maxChunkSize:
log.warn(`NDJSON chunk exceeds max size: ${lineSize} > ${maxChunkSize}`)
CONTINUE
totalSize += lineSize
IF totalSize > maxTotalSize:
log.warn(`NDJSON total size exceeds max: ${totalSize} > ${maxTotalSize}`)
BREAK
IF chunks.length >= maxChunks:
log.warn(`NDJSON chunk count exceeds max: ${chunks.length} >= ${maxChunks}`)
BREAK
TRY:
chunks.push(JSON.parse(line))
CATCH:
chunks.push(line) // Keep raw line if not valid JSON
RETURN {
...ctx,
response: {
...ctx.response,
chunks: chunks as readonly unknown[],
streamDurationMs,
truncated: lines.length > maxChunks || totalSize > maxTotalSize
}
}
```
**Invariants**:
- MUST: NDJSON processing enforce max chunk count, chunk size, and total size limits
- MUST: Exceeded limits truncate gracefully with warning, not error
- MAY NEVER: Streaming response processing consume unbounded memory
#### Issue P6: `request-builder.ts` Uses `Math.random()` as Fallback
**Location**: `src/domain/request-builder.ts:112`
**Problem**: When no RNG provided, falls back to `Math.random()` for path param selection.
**Pseudocode Fix**:
```
// src/domain/request-builder.ts
FUNCTION substitutePathParams(path, data, state, rng):
url = path
pathParams = parseRouteParams(path)
FOR EACH param IN pathParams:
value = data[param]
IF value IS undefined AND param.endsWith('Id'):
resourceType = param.replace(/Id$/, '').toLowerCase()
resources = state.resources.get(resourceType)
IF resources AND resources.size > 0:
ids = Array.from(resources.keys())
IF rng IS DEFINED:
value = rng.pick(ids)
ELSE:
// Deterministic fallback: use first ID (consistent across runs)
value = ids[0]
log.warn(`No RNG provided for path param selection; using deterministic fallback`)
IF value IS NOT undefined:
url = url.replace(`:${param}`, String(value))
RETURN url
```
**Invariants**:
- MUST: Path param selection use injected RNG when available
- MUST: Missing RNG produce deterministic, logged fallback
- MAY NEVER: `Math.random()` be used in test generation paths
#### Issue P7: Duplicate Sync/Async Evaluation Paths in `evaluator.ts`
**Location**: `src/formula/evaluator.ts`
**Problem**: Two parallel code paths for sync and async evaluation; easy to drift.
**Pseudocode Fix**:
```
// Unify on async; sync is just async with no await
FUNCTION evaluate(node, ctx, extensionRegistry):
SWITCH node.type:
CASE 'literal':
RETURN node.value
CASE 'variable':
RETURN resolveVariable(node.name, ctx)
CASE 'operation':
resolver = extensionRegistry?.resolvePredicate(node.header)
IF resolver:
// Always async; await if needed
result = resolver(ctx, node.parameter, node.accessor)
IF result IS Promise:
RETURN await result
RETURN result
RETURN resolveBuiltinOperation(node.header, ctx, node.accessor)
CASE 'comparison':
left = await evaluate(node.left, ctx, extensionRegistry)
right = await evaluate(node.right, ctx, extensionRegistry)
RETURN applyComparator(node.op, left, right)
CASE 'boolean':
left = await evaluate(node.left, ctx, extensionRegistry)
// Short-circuit
IF node.op === '&&' AND NOT left:
RETURN false
IF node.op === '||' AND left:
RETURN true
IF node.op === '=>'':
IF NOT left:
RETURN true // False antecedent = true
RETURN await evaluate(node.right, ctx, extensionRegistry)
right = await evaluate(node.right, ctx, extensionRegistry)
RETURN applyBooleanOp(node.op, left, right)
CASE 'conditional':
condition = await evaluate(node.condition, ctx, extensionRegistry)
IF condition:
RETURN await evaluate(node.then, ctx, extensionRegistry)
RETURN await evaluate(node.else, ctx, extensionRegistry)
CASE 'quantified':
collection = resolveCollection(node.collection, ctx)
IF node.quantifier === 'for':
FOR EACH item IN collection:
result = await evaluate(node.body, ctx.withBinding(node.variable, item), extensionRegistry)
IF NOT result:
RETURN false
RETURN true
ELSE: // exists
FOR EACH item IN collection:
result = await evaluate(node.body, ctx.withBinding(node.variable, item), extensionRegistry)
IF result:
RETURN true
RETURN false
CASE 'previous':
RETURN evaluate(node.inner, ctx.previous, extensionRegistry)
CASE 'status':
RETURN ctx.response.statusCode === node.code
// Public API: always async
export async function evaluateFormula(node, ctx, extensionRegistry):
RETURN evaluate(node, ctx, extensionRegistry)
// Backward compat: sync wrapper for simple cases
export function evaluateFormulaSync(node, ctx, extensionRegistry):
result = evaluate(node, ctx, extensionRegistry)
IF result IS Promise:
THROW Error('Sync evaluation encountered async predicate; use evaluateFormula()')
RETURN result
```
**Invariants**:
- MUST: Single evaluation implementation, not parallel sync/async paths
- MUST: All evaluation be async by default; sync wrapper fail on async encounter
- MAY NEVER: Sync and async paths diverge in behavior
#### Issue P8: `topologicalSort` Re-sorts Entire Array on Every `register()`
**Location**: `src/extension/registry.ts:159`
**Problem**: O(n²) complexity when registering extensions one at a time.
**Pseudocode Fix**:
```
// src/extension/registry.ts
class ExtensionRegistryImpl:
private _extensions: ApophisExtension[] = []
private _sorted = false
register(extension):
// Validate uniqueness
IF this._extensions.some(e => e.name === extension.name):
THROW Error(`Extension '${extension.name}' already registered`)
// Add without sorting
this._extensions.push(extension)
this._sorted = false
// Notify plugin contract registry
IF this._pluginContractRegistry:
this._pluginContractRegistry.registerAvailableExtension(extension.name)
// Cache predicates and corruption strategies immediately
this._cacheExtensionData(extension)
// Lazy sort: only when hook arrays are needed
private ensureSorted():
IF this._sorted: RETURN
this._extensions = topologicalSort(this._extensions)
this._rebuildHookArrays()
this._sorted = true
get extensions():
this.ensureSorted()
RETURN this._extensions
runBuildRequestHooks(ctx):
this.ensureSorted()
FOR EACH ext IN this._buildRequestExts:
// ... run hook
// ... other hook runners call ensureSorted() first
```
**Invariants**:
- MUST: Extension registration be O(1) amortized
- MUST: Sorting happen lazily, only when hooks are first accessed
- MAY NEVER: Registration trigger full re-sort of all extensions
#### Issue P9: `safe-regex` Has False Positives/Negatives, No Timeout Enforcement
**Location**: `src/infrastructure/regex-guard.ts`
**Problem**: `safe-regex` is a heuristic with known false positives and negatives. No actual ReDoS protection via timeout.
**Pseudocode Fix**:
```
// src/infrastructure/regex-guard.ts
import { Worker } from 'node:worker_threads'
const SAFE_REGEX_TIMEOUT_MS = 1000
FUNCTION validateRegexPattern(pattern):
TRY:
// Fast heuristic check
IF NOT safeRegex(pattern):
RETURN { safe: false, reason: 'Pattern flagged by safe-regex heuristic', severity: 'exponential' }
// Compile and test with timeout
regex = new RegExp(pattern)
// Test with a pathological input in a worker with timeout
testResult = await testRegexWithTimeout(regex, SAFE_REGEX_TIMEOUT_MS)
IF testResult.timedOut:
RETURN { safe: false, reason: 'Pattern timed out during test (potential ReDoS)', severity: 'exponential' }
RETURN { safe: true, severity: 'safe' }
CATCH err:
RETURN { safe: false, reason: `Validation error: ${err.message}`, severity: 'exponential' }
FUNCTION testRegexWithTimeout(regex, timeoutMs):
RETURN new Promise((resolve) => {
const worker = new Worker(`
const { parentPort } = require('worker_threads');
parentPort.once('message', ({ pattern, input }) => {
const regex = new RegExp(pattern);
const start = Date.now();
try {
regex.test(input);
parentPort.postMessage({ elapsed: Date.now() - start });
} catch (err) {
parentPort.postMessage({ error: err.message });
}
});
`, { eval: true });
const timer = setTimeout(() => {
worker.terminate();
resolve({ timedOut: true });
}, timeoutMs);
worker.once('message', (result) => {
clearTimeout(timer);
worker.terminate();
resolve(result);
});
// Pathological input: repeated 'a' followed by 'b'
worker.postMessage({ pattern: regex.source, input: 'a'.repeat(100) + 'b' });
});
```
**Invariants**:
- MUST: Regex validation include actual execution timeout test
- MUST: Timeout test run in isolated worker thread
- MUST: Patterns timing out be rejected regardless of heuristic result
- MAY NEVER: A regex be accepted solely based on heuristic analysis
#### Issue P10: Redaction Logic is Overly Broad
**Location**: `src/extension/redaction.ts:48, 77`
**Problem**: `lowerKey.includes(sensitive)` matches partial substrings (e.g., "authorization" matches "auth" but also false-positives on "author_name").
**Pseudocode Fix**:
```
// src/extension/redaction.ts
const SENSITIVE_FIELDS = new Set([
'authorization',
'x-api-key',
'x-auth-token',
'api-key',
'token',
'access_token',
'refresh_token',
'id_token',
'client_secret',
'cookie',
'session',
'sessionid',
'phpsessid',
'password',
'secret',
'private_key',
'api_secret',
'ssn',
'social_security',
'credit_card',
'creditcard',
'cvv',
])
FUNCTION isSensitiveField(key):
lowerKey = key.toLowerCase()
// Exact match
IF SENSITIVE_FIELDS.has(lowerKey):
RETURN true
// Prefix match for known patterns (e.g., x-auth-token-xxx)
FOR EACH sensitive IN SENSITIVE_FIELDS:
IF lowerKey === sensitive OR lowerKey.startsWith(sensitive + '-') OR lowerKey.startsWith(sensitive + '_'):
RETURN true
RETURN false
FUNCTION redactHeaders(headers):
result = {}
FOR EACH [key, value] IN Object.entries(headers):
IF isSensitiveField(key):
result[key] = '[REDACTED]'
ELSE:
result[key] = value
RETURN result
```
**Invariants**:
- MUST: Redaction use exact or prefix matching, not substring matching
- MUST: Redaction set be explicitly listed, not dynamically generated
- MAY NEVER: A non-sensitive field be redacted due to partial string match
#### Issue P11: `substitutor.ts` PARAM_PATTERN Could Inject Arbitrary APOSTL
**Location**: Implied by parameter substitution pattern
**Problem**: If user input reaches parameter substitution without validation, arbitrary APOSTL formulas could be injected.
**Pseudocode Fix**:
```
// src/domain/substitutor.ts (or request-builder.ts)
const PARAM_PATTERN = /:([a-zA-Z_][a-zA-Z0-9_]*)/g
FUNCTION substitutePathParams(path, data, state, rng):
url = path
FOR EACH match OF path.matchAll(PARAM_PATTERN):
paramName = match[1]
// Validate param name is alphanumeric + underscore only
IF NOT /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(paramName):
THROW Error(`Invalid path parameter name: ${paramName}`)
value = data[paramName]
// Sanitize value before substitution
IF value IS NOT undefined:
sanitized = String(value).replace(/[^a-zA-Z0-9_.~-]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0')}`)
url = url.replace(match[0], sanitized)
RETURN url
```
**Invariants**:
- MUST: Path parameter names be validated against whitelist
- MUST: Parameter values be URL-encoded before substitution
- MAY NEVER: Unsanitized user input be substituted into URLs
### Observability (Charity Majors)
#### Issue O1: Zero OpenTelemetry Integration
**Location**: Entire codebase
**Problem**: No distributed tracing, no metrics, no correlation between CI test failures and production incidents.
**Pseudocode Fix**:
```
// src/infrastructure/telemetry.ts
import { trace, metrics, context } from '@opentelemetry/api'
class ApophisTelemetry:
private tracer = trace.getTracer('apophis', '1.3.0')
private meter = metrics.getMeter('apophis', '1.3.0')
// Counters
private testsRun = this.meter.createCounter('apophis.tests.run')
private testsPassed = this.meter.createCounter('apophis.tests.passed')
private testsFailed = this.meter.createCounter('apophis.tests.failed')
private testsFlaky = this.meter.createCounter('apophis.tests.flaky')
private chaosEvents = this.meter.createCounter('apophis.chaos.events')
private contractViolations = this.meter.createCounter('apophis.contract.violations')
// Histograms
private testDuration = this.meter.createHistogram('apophis.test.duration_ms')
private requestDuration = this.meter.createHistogram('apophis.request.duration_ms')
startTestSpan(testName, attributes):
RETURN this.tracer.startSpan('apophis.test', {
attributes: {
'apophis.test.name': testName,
'apophis.test.runner': 'petit',
...attributes
}
})
recordTestResult(span, result):
this.testsRun.add(1)
IF result.ok:
this.testsPassed.add(1)
ELSE:
this.testsFailed.add(1)
IF result.diagnostics?.flake?.isFlaky:
this.testsFlaky.add(1)
span.setAttribute('apophis.test.result', result.ok ? 'pass' : 'fail')
span.end()
recordChaosEvent(eventType):
this.chaosEvents.add(1, { 'apophis.chaos.type': eventType })
recordContractViolation(formula, route):
this.contractViolations.add(1, {
'apophis.contract.formula': formula,
'apophis.contract.route': route
})
// Integration in petit-runner.ts
FUNCTION runPetitTests(fastify, config, ...):
telemetry = new ApophisTelemetry()
FOR EACH command IN allCommands:
span = telemetry.startTestSpan(command.route.path, {
'http.method': command.route.method,
'http.route': command.route.path
})
TRY:
ctx = await executeHttp(...)
validation = validatePostconditions(...)
IF NOT validation.success:
telemetry.recordContractViolation(validation.formula, command.route.path)
telemetry.recordTestResult(span, { ok: false })
ELSE:
telemetry.recordTestResult(span, { ok: true })
CATCH err:
span.recordException(err)
telemetry.recordTestResult(span, { ok: false })
FINALLY:
span.end()
```
**Invariants**:
- MUST: Every test execution produce a trace span
- MUST: Every contract violation produce a metric
- MUST: Chaos events be counted and tagged by type
- MUST: Flaky tests be distinguishable from consistent failures in metrics
- MAY NEVER: A test run produce zero telemetry output
#### Issue O2: No Per-Route Chaos Granularity
**Location**: `src/quality/chaos.ts`
**Problem**: Chaos config is global; cannot disable chaos for specific routes or apply different strategies per route.
**Pseudocode Fix**:
```
// src/types.ts
export interface ChaosConfig:
probability: number
delay?: { probability: number; minMs: number; maxMs: number }
error?: { probability: number; statusCode: number; body?: unknown }
dropout?: { probability: number }
corruption?: { probability: number }
// Per-route overrides
routeOverrides?: Map<string, Partial<ChaosConfig>> // key: "METHOD path"
// Route matchers (regex patterns)
excludeRoutes?: string[] // Routes to never chaos
includeOnlyRoutes?: string[] // If set, only chaos these routes
// src/quality/chaos.ts
FUNCTION shouldApplyChaos(route, config):
routeKey = `${route.method} ${route.path}`
// Check exclusions
IF config.excludeRoutes:
FOR EACH pattern IN config.excludeRoutes:
IF new RegExp(pattern).test(routeKey):
RETURN false
// Check inclusions
IF config.includeOnlyRoutes:
matched = false
FOR EACH pattern IN config.includeOnlyRoutes:
IF new RegExp(pattern).test(routeKey):
matched = true
BREAK
IF NOT matched:
RETURN false
RETURN true
FUNCTION executeWithChaos(executeHttp, route, request, extensionRegistry):
IF NOT shouldApplyChaos(route, this.config):
ctx = await executeHttp()
RETURN { ctx, events: [] }
// Merge route-specific config
routeKey = `${route.method} ${route.path}`
routeConfig = this.config.routeOverrides?.get(routeKey) ?? {}
effectiveConfig = { ...this.config, ...routeConfig }
// ... rest of chaos logic using effectiveConfig
```
**Invariants**:
- MUST: Chaos support per-route enable/disable
- MUST: Route matching use regex patterns for flexibility
- MUST: Per-route config override global config for that route
- MAY NEVER: Chaos be applied to excluded routes
#### Issue O3: No Resilience Verification After Chaos
**Location**: `src/quality/chaos.ts`
**Problem**: Chaos injects failures but doesn't verify the system recovers (resilience testing).
**Pseudocode Fix**:
```
// src/quality/chaos.ts
FUNCTION executeWithChaos(executeHttp, route, request, extensionRegistry):
// ... inject chaos ...
// After chaos injection, verify system health
IF this.config.resilienceCheck:
healthResult = await this.checkResilience(route, request, extensionRegistry)
IF NOT healthResult.healthy:
this.events.push({
type: 'resilience_failure',
injected: false,
details: {
reason: `System did not recover after ${eventType}: ${healthResult.reason}`,
recoveryTimeMs: healthResult.recoveryTimeMs
}
})
RETURN { ctx, events: this.events }
FUNCTION checkResilience(route, request, extensionRegistry):
startTime = Date.now()
// Retry the same request without chaos
retryCtx = await executeHttp()
// Check if response is valid
validation = validatePostconditions(route.ensures, retryCtx, route, extensionRegistry)
recoveryTimeMs = Date.now() - startTime
IF validation.success AND retryCtx.response.statusCode < 500:
RETURN { healthy: true, recoveryTimeMs }
ELSE:
RETURN {
healthy: false,
recoveryTimeMs,
reason: validation.error ?? `HTTP ${retryCtx.response.statusCode}`
}
```
**Invariants**:
- MUST: Chaos mode optionally verify system recovery after injection
- MUST: Resilience check measure recovery time
- MUST: Resilience failures be reported as distinct event type
- MAY NEVER: Chaos injection silently leave system in degraded state
#### Issue O4: Runtime Hooks Evaluate on EVERY Request
**Location**: `src/infrastructure/hook-validator.ts:110-128, 135-153`
**Problem**: Hooks run on every request in production, adding overhead even for routes with no contracts.
**Pseudocode Fix**:
```
// src/infrastructure/hook-validator.ts
FUNCTION registerValidationHooks(fastify, opts, routes):
// Pre-filter: only routes with contracts need hooks
contractRoutes = routes.filter(r => hasContractAnnotations(r) AND r.validateRuntime)
IF contractRoutes.length === 0:
log.info('No runtime validation hooks registered (no contracts with validateRuntime)')
RETURN
// Pre-parse all formulas at registration time
routeAsts = new Map()
FOR EACH route IN contractRoutes:
routeAsts.set(`${route.method} ${route.path}`, {
requires: route.requires.map(f => parse(f).ast),
ensures: route.ensures.map(f => parse(f).ast)
})
// Register hooks only for routes with contracts
fastify.addHook('preHandler', createPreHandler(opts, routeAsts))
fastify.addHook('preSerialization', createPreSerializer())
fastify.addHook('onSend', createOnSend(opts, routeAsts))
log.info(`Registered runtime validation for ${contractRoutes.length} routes`)
```
**Invariants**:
- MUST: Hooks only register for routes with runtime validation enabled
- MUST: Routes without contracts incur zero hook overhead
- MUST: Registration log count of hooked routes
- MAY NEVER: A route without contracts trigger hook execution
### Category Inference (Martin Fowler)
#### Issue Cat1: Hardcoded Exact Paths Miss Prefixed Variants
**Location**: `src/domain/category.ts:12-47`
**Problem**: `/api/health`, `/v1/health`, `/internal/health` are not recognized as utility paths.
**Pseudocode Fix**:
```
// src/domain/category.ts
const UTILITY_PATTERNS = [
/^\/?(api\/)?(v\d+\/)?(reset|health|ping|login|logout|auth|callback|purge|clear|initialize|setup|webhook)\/?$/i,
]
const isUtilityPath = (path):
FOR EACH pattern IN UTILITY_PATTERNS:
IF pattern.test(path):
RETURN true
RETURN false
// Or use suffix matching
const UTILITY_SUFFIXES = new Set([
'reset', 'health', 'ping', 'login', 'logout', 'auth',
'callback', 'purge', 'clear', 'initialize', 'setup', 'webhook'
])
const isUtilityPath = (path):
// Remove leading/trailing slashes and version prefixes
normalized = path.replace(/^\//, '').replace(/\/$/, '')
segments = normalized.split('/')
lastSegment = segments[segments.length - 1]
// Check if last segment is a known utility suffix
IF UTILITY_SUFFIXES.has(lastSegment.toLowerCase()):
RETURN true
// Check exact matches for root-level paths
RETURN UTILITY_SUFFIXES.has(normalized.toLowerCase())
```
**Invariants**:
- MUST: Category inference recognize utility paths regardless of prefix
- MUST: Path normalization handle leading/trailing slashes and version segments
- MAY NEVER: A `/api/health` route be categorized as non-utility
### APOSTL Formula Language (Martin Fowler)
#### Issue F1: No Arithmetic Operators
**Location**: `src/formula/parser.ts`, `src/formula/evaluator.ts`
**Problem**: APOSTL lacks `+`, `-`, `*`, `/` operators, limiting expressiveness.
**Pseudocode Fix**:
```
// src/types.ts
export type FormulaNode =
| ...existing nodes...
| { type: 'arithmetic'; op: '+' | '-' | '*' | '/'; left: FormulaNode; right: FormulaNode }
// src/formula/parser.ts
// Add to grammar:
// expression -> additive
// additive -> multiplicative (('+' | '-') multiplicative)*
// multiplicative -> unary (('*' | '/') unary)*
// unary -> ('-' unary) | primary
FUNCTION parseExpression():
RETURN parseAdditive()
FUNCTION parseAdditive():
left = parseMultiplicative()
WHILE peek().type IN ['+', '-']:
op = consume(peek().type).type
right = parseMultiplicative()
left = { type: 'arithmetic', op, left, right }
RETURN left
FUNCTION parseMultiplicative():
left = parseUnary()
WHILE peek().type IN ['*', '/']:
op = consume(peek().type).type
right = parseUnary()
left = { type: 'arithmetic', op, left, right }
RETURN left
FUNCTION parseUnary():
IF peek().type === '-':
consume('-')
operand = parseUnary()
RETURN { type: 'arithmetic', op: '*', left: { type: 'literal', value: -1 }, right: operand }
RETURN parsePrimary()
// src/formula/evaluator.ts
CASE 'arithmetic':
left = await evaluate(node.left, ctx, extensionRegistry)
right = await evaluate(node.right, ctx, extensionRegistry)
IF typeof left !== 'number' OR typeof right !== 'number':
THROW Error(`Arithmetic operator ${node.op} requires numeric operands`)
SWITCH node.op:
CASE '+': RETURN left + right
CASE '-': RETURN left - right
CASE '*': RETURN left * right
CASE '/':
IF right === 0:
THROW Error('Division by zero')
RETURN left / right
```
**Invariants**:
- MUST: Arithmetic operators support `+`, `-`, `*`, `/`
- MUST: Arithmetic require numeric operands
- MUST: Division by zero produce clear error
- MAY NEVER: Arithmetic operators accept non-numeric operands silently
## Remaining
### Medium Priority
- [ ] **F6**: CI/CD examples (`docs/ci-cd.md`) — GitHub Actions, GitLab CI, CircleCI workflows
### Quality Features (Phase 2-3)
- [x] **Flake Detection** (`src/quality/flake.ts`) — Auto-rerun failing tests with varied seeds
- [ ] **Mutation Testing** (`src/quality/mutation.ts`) — Synthetic bug injection, contract strength scoring
## Metrics
| Metric | v1.1 | v1.2 | v1.3 | Target |
|--------|------|------|------|--------|
| Tests passing | 482 | 502 | 551 | 551+ |
| Protocol extensions | 0 | 0 | 8 | 8 |
| Plugin contracts | 0 | 0 | 4 built-in | 4+ |
| Chaos mode | 0 | 1 engine | 1 engine | 1 engine |
| Flake detection | 0 | 0 | 0 | Auto-rerun |
| Mutation testing | 0 | 0 | 0 | Score reporting |
| CI/CD examples | 0 | 0 | 0 | 3 workflows |
## v2.0: APOSTL → Justin (Subscript) Migration
### Decision: Migrate to Justin Expression Language
**Rationale**: Pre-release, minimal internal adoption. Perfect time for clean break. Justin provides:
- Arithmetic, null coalescing, optional chaining (free)
- "Just JS" syntax — no new DSL to learn
- ~3KB bundle (smaller than custom parser)
- Sandboxed execution (no `__proto__`, `constructor`, `eval`)
- IDE support out of the box (ESLint, Prettier, syntax highlighting)
### Design Decisions
1. **Property naming**: HTTP standard names (`statusCode`, `request.body`, `response.headers`)
2. **Previous context**: First-class via `previous` object (`previous.response.statusCode`)
3. **Extensions**: Register as context methods and variables (not operation headers)
4. **Quantifiers**: Built-in array methods (`every`, `some`, `find`, `filter`)
5. **Bundle size**: Acceptable (net reduction after deleting custom parser)
6. **Rigor**: Despite JS syntax, still extract invariants and implications for model testing
### Schema Annotation Changes
```javascript
// BEFORE: APOSTL
x-ensures: 'response_body(this).name == "John"'
x-requires: 'response_code(this) == 200'
x-requires: 'for item in response_body: item.price > 0'
// AFTER: Justin
x-ensures: 'response.body.name == "John"'
x-requires: 'statusCode == 200'
x-requires: 'response.body.every(item => item.price > 0)'
```
### Context Mapping
Justin receives a flat context built from `EvalContext`:
```javascript
{
// Request properties
request: {
body: ctx.request.body,
headers: ctx.request.headers,
query: ctx.request.query,
params: ctx.request.params,
cookies: ctx.request.cookies,
multipart: ctx.request.multipart
},
// Response properties
response: {
body: ctx.response.body,
headers: ctx.response.headers,
statusCode: ctx.response.statusCode,
status: ctx.response.statusCode, // alias
responseTime: ctx.response.responseTime,
chunks: ctx.response.chunks,
streamDurationMs: ctx.response.streamDurationMs
},
// Redirects
redirects: ctx.redirects,
redirectCount: ctx.redirects?.length,
// Timeout
timedOut: ctx.timedOut,
timeoutMs: ctx.timeoutMs,
// Previous context (for temporal assertions)
previous: ctx.previous ? buildContext(ctx.previous) : null
}
```
### Extension Integration
Extensions register context variables and methods:
```javascript
// Extension registers itself
extensionRegistry.register({
name: 'jwt',
context: {
// Variables
jwtClaims: (ctx) => parseJwt(ctx.request.headers.authorization),
jwtExpired: (ctx) => isJwtExpired(ctx.request.headers.authorization),
// Methods
jwtHasClaim: (ctx, claim) => {
const claims = parseJwt(ctx.request.headers.authorization)
return claims?.[claim] !== undefined
}
}
})
// Used in formulas:
// x-ensures: 'jwtClaims.role == "admin"'
// x-requires: 'jwtHasClaim("sub")'
```
### Manual Migration Approach
No automated migration scripts. All formula conversions done by hand to ensure correctness and take advantage of Justin's richer syntax.
**Conversion examples:**
```javascript
// APOSTL: Justin:
response_body(this).name response.body.name
response_code(this) == 200 statusCode == 200
status:200 statusCode == 200
T true
F false
a => b !a || b
x matches /regex/ /regex/.test(x)
for item in arr: item.price > 0 arr.every(item => item.price > 0)
exists item in arr: item.ok arr.some(item => item.ok)
previous(response_body(this)) previous.response.body
```
### Files to Delete
- `src/formula/parser.ts` (316 lines)
- `src/formula/tokenizer.ts` (170 lines)
- `src/formula/evaluator.ts` (421 lines)
- `src/formula/substitutor.ts` (75 lines)
- `src/types.ts`: `FormulaNode`, `Comparator`, `BooleanOperator`, `OperationHeader`, `OperationParameter`, `OperationCall`, `ParseResult` types
### Files to Create
- `src/formula/justin.ts` (~80 lines)
- Wraps `subscript/justin.js`
- Builds context from `EvalContext`
- Adds `matches` operator via subscript extension API
- `src/formula/context-builder.ts` (~50 lines)
- Maps `EvalContext` → flat context object
- Handles `previous` nesting
- `src/formula/justin-context.ts` (~30 lines)
- Type definitions for Justin evaluation context
### Files to Modify
- `package.json`: Add `subscript` dependency
- `src/types.ts`:
- Replace `ValidatedFormula` with `string`
- Remove APOSTL-specific types
- Add `JustinContext` interface
- `src/infrastructure/hook-validator.ts`:
- Replace `evaluateBooleanResult` with Justin evaluation
- Pre-compile formulas at registration time using `subscript`
- `src/domain/contract-validation.ts`:
- Replace APOSTL evaluation with Justin
- `src/extension/types.ts`:
- Change extension predicate API to context variables/methods
- `src/extension/registry.ts`:
- Build combined context from all extensions
- All test files with APOSTL formulas (~40 test files)
### Invariant Extraction
Despite JS syntax, we still extract logical invariants for property-based testing:
```javascript
// Formula: 'statusCode == 200 && response.body.id != null'
// Extracted invariants:
// - statusCode ∈ {200}
// - response.body.id ≠ null
// - response.body has property 'id'
// Formula: 'request.body.price * request.body.quantity <= 10000'
// Extracted invariants:
// - request.body.price is number
// - request.body.quantity is number
// - request.body.price * request.body.quantity ≤ 10000
```
### Implementation Order
1. Add `subscript` dependency
2. Create `justin.ts` and `context-builder.ts`
3. Modify `types.ts` (remove APOSTL types, add Justin types)
4. Modify `hook-validator.ts` and `contract-validation.ts`
5. Update extension API (context variables/methods)
6. Hand-convert all schema annotations in test files
7. Update all test assertions
8. Delete old parser/evaluator/tokenizer/substitutor files
9. Verify all tests pass
## Reference
- **Protocol Extensions Spec**: `docs/protocol-extensions-spec.md`
- **Plugin Contracts Spec**: `docs/PLUGIN_CONTRACTS_SPEC.md`
- **Quality Features Plan**: `docs/QUALITY_FEATURES_PLAN.md`
- **CHANGELOG**: `CHANGELOG.md`
- **Subscript/Justin**: https://github.com/dy/subscript