375 lines
18 KiB
Markdown
375 lines
18 KiB
Markdown
# APOPHIS Codebase Assessment
|
|
## Tarpit Separation & Design Quality Review
|
|
|
|
---
|
|
|
|
## Executive Summary
|
|
|
|
**223 tests pass (1 pre-existing failure unrelated to extraction). Core functionality is solid. Architecture has good separation between domain (essential) and infrastructure (accidental), but several areas violate DRY, mix concerns, and accumulate unnecessary control flow.**
|
|
|
|
## Progress Log
|
|
|
|
### 2026-04-23: P0 Extraction Complete
|
|
All 5 P0 items completed via parallel subworkers with lock protocols:
|
|
|
|
| # | Item | Status | Files |
|
|
|---|------|--------|-------|
|
|
| 1 | Extract shared HTTP execution | ✅ Done | `src/infrastructure/http-executor.ts` |
|
|
| 2 | Extract shared postcondition validation | ✅ Done | `src/domain/contract-validation.ts` |
|
|
| 3 | Extract shared state update | ✅ Done | `src/domain/state-operations.ts` |
|
|
| 4 | Fix cache disk I/O | ✅ Done | `src/incremental/cache.ts` |
|
|
| 5 | Move schema-to-arbitrary | ✅ Done | `src/domain/schema-to-arbitrary.ts` |
|
|
|
|
**Integration**: Both `petit-runner.ts` and `stateful-runner.ts` now import from extracted modules. `FastifyInjectInstance` added to `src/types.ts`. Runners reduced by ~120 lines each. Test suite passes (223/224, 1 pre-existing formula test failure).
|
|
|
|
---
|
|
|
|
## 1. ACCIDENTAL VS ESSENTIAL SEPARATION
|
|
|
|
### What's Good
|
|
- `src/domain/` — Pure functions, no Fastify imports. Essential logic isolated.
|
|
- `src/formula/` — Parser/evaluator are entirely framework-agnostic.
|
|
- `src/infrastructure/` — Fastify hooks, cleanup, scope registry. Accidental logic isolated.
|
|
- Plugin entry point (`src/plugin/index.ts`) is a thin wrapper as intended.
|
|
|
|
### What's Broken
|
|
|
|
#### 1.1 Duplicate FastifyInstance Mock (Accidental Leak) — FIXED
|
|
**Files**: `src/test/petit-runner.ts:32-39`, `src/test/stateful-runner.ts:18-25`
|
|
|
|
Both runners define an identical `FastifyInstance` interface. If Fastify v5 changes its API, two files must change.
|
|
|
|
**Fix**: ✅ Extracted to `src/types.ts` as `FastifyInjectInstance`. Both runners now import from `../types.js`.
|
|
|
|
#### 1.2 ScopeConfig is Domain-Specific (Essential Leak) — FIXED
|
|
**File**: `src/types.ts:28-33`
|
|
|
|
```typescript
|
|
// BEFORE:
|
|
export interface ScopeConfig {
|
|
tenantId: string // Domain-specific!
|
|
applicationId: string // Domain-specific!
|
|
headers: Record<string, string>
|
|
auth?: string | null | undefined
|
|
}
|
|
```
|
|
|
|
A generic testing framework shouldn't know about "tenants" and "applications." These are Arbiter concepts leaking into the core.
|
|
|
|
**Fix**: ✅ Made scope entirely generic:
|
|
```typescript
|
|
// AFTER:
|
|
export interface ScopeConfig {
|
|
headers: Record<string, string>
|
|
metadata: Record<string, unknown>
|
|
}
|
|
```
|
|
|
|
The scope registry auto-discovery from `APOPHIS_SCOPE_*` env vars still parses JSON with `tenantId` and `applicationId` fields, but stores them in `metadata` instead of mandating them on the type. `getHeaders` reads from metadata backward-compatibly. All tests updated to use `.metadata.tenantId`.
|
|
|
|
#### 1.3 Cache Disk I/O on Every Call (Accidental Complexity) — FIXED
|
|
**File**: `src/incremental/cache.ts:42-58`
|
|
|
|
```typescript
|
|
export function lookupCache(route: RouteContract, cache: TestCache = loadCacheFromDisk()): CacheEntry | undefined
|
|
```
|
|
|
|
Default parameter calls `loadCacheFromDisk()` on every lookup. For 200 routes, that's 200 disk reads.
|
|
|
|
**Fix**: ✅ Load cache once at module init into `memoryCache`; `lookupCache` and `storeCache` operate on memory only; `flushCache()` persists at end of test runs. `refreshCache()` available for explicit reload.
|
|
|
|
---
|
|
|
|
## 2. COMMON MOTIFS & DRY VIOLATIONS
|
|
|
|
### 2.1 HTTP Execution Logic (Duplicated 3x) — FIXED
|
|
**Files**: `src/test/petit-runner.ts:117-166`, `src/test/stateful-runner.ts:64-117`, `src/domain/request-builder.ts:162-177`
|
|
|
|
Three places construct URLs, handle query strings, extract path params, and build EvalContext. The stateful runner's `ApiOperation.run()` and petit-runner's `executeCommand()` are ~90% identical.
|
|
|
|
**Fix**: ✅ Extracted `executeHttp` to `src/infrastructure/http-executor.ts`. Both runners now import and use it. Inline `executeCommand` and `ApiOperation.run` HTTP logic removed.
|
|
|
|
### 2.2 Postcondition Checking (Duplicated 2x) — FIXED
|
|
**Files**: `src/test/petit-runner.ts:184-216`, `src/test/stateful-runner.ts:245-289`
|
|
|
|
Identical logic: iterate `route.ensures`, check `status:###`, parse+evaluate APOSTL formulas, collect results.
|
|
|
|
**Fix**: ✅ Extracted `validatePostconditions` to `src/domain/contract-validation.ts`. Both runners import and use it. Inline `checkPostconditions` and postcondition loops removed from runners.
|
|
|
|
### 2.3 State Update Logic (Duplicated 2x) — FIXED
|
|
**Files**: `src/test/petit-runner.ts:222-257`, `src/test/stateful-runner.ts:124-157`
|
|
|
|
Both extract resource identity, create hierarchy, update maps, track relationships. Identical code.
|
|
|
|
**Fix**: ✅ Extracted `updateModelState` and `makeTrackedResource` to `src/domain/state-operations.ts`. Both runners import and use these functions. Inline `updateState`, `makeResource`, and `updateModelState` removed from runners.
|
|
|
|
### 2.4 Path Param Extraction (Duplicated 2x)
|
|
**Files**: `src/domain/request-builder.ts:162-177`, `src/test/petit-runner.ts:140-149`, `src/test/stateful-runner.ts:89-98`
|
|
|
|
All three parse route paths to extract `/:id` parameters. Request-builder has `extractPathParams` exported but runners don't use it.
|
|
|
|
**Fix**: Runners should use `extractPathParams` from request-builder instead of inlining the logic.
|
|
|
|
---
|
|
|
|
## 3. MINIMIZATION OF CONTROL FLOW
|
|
|
|
### 3.1 Parser Header Detection (100+ lines of noise)
|
|
**File**: `src/formula/parser.ts:222-322`
|
|
|
|
The charCodeAt-based header detection is fast but creates massive accidental complexity. 100 lines to check 8 string prefixes.
|
|
|
|
**Tension**: Performance vs readability. Given benchmarks show parsing at ~0.5µs/formula, the optimization may be premature.
|
|
|
|
**Fix**: Consider a lookup table with early validation:
|
|
```typescript
|
|
const HEADER_PATTERNS = new Map<string, OperationHeader>([
|
|
['response_body', 'response_body'],
|
|
['response_code', 'response_code'],
|
|
// ...
|
|
])
|
|
// Then single loop: check prefixes by length, use charCodeAt only for hot headers
|
|
```
|
|
|
|
### 3.2 Nested Control Flow in Runners
|
|
**File**: `src/test/stateful-runner.ts:214-310`
|
|
|
|
The `runSequence` function has:
|
|
- For loop over commands
|
|
- If precondition check
|
|
- Try-catch for execution
|
|
- If ctx exists
|
|
- For loop over ensures
|
|
- If status: check
|
|
- Try-catch for formula parse
|
|
- If failed flag
|
|
- Invariant checking loop
|
|
|
|
**7 levels of nesting.** This mixes orchestration (what to run) with execution (how to run) with reporting (what happened).
|
|
|
|
**Fix**: ✅ Extracted `executeCommand` pipeline returning `CommandResult` union type:
|
|
```typescript
|
|
type CommandResult =
|
|
| { type: 'skipped'; name: string; id: number }
|
|
| { type: 'error'; name: string; id: number; error: string }
|
|
| { type: 'executed'; name: string; id: number; ctx: EvalContext; post: {...}; invariantFailures: string[] }
|
|
```
|
|
|
|
`runSequence` now uses a switch statement instead of nested if/try-catch. Nesting reduced from 7 levels to 3. Orchestration separated from execution logic.
|
|
|
|
### 3.3 Category Inference (Multiple Exit Points)
|
|
**File**: `src/domain/category.ts:81-109`
|
|
|
|
`inferCategory` has 6 return statements. While performant, it violates structured programming principles.
|
|
|
|
**Fix**: Decision table pattern:
|
|
```typescript
|
|
const CATEGORY_RULES = [
|
|
{ test: (p, m, o) => o !== undefined, result: (_, __, o) => o },
|
|
{ test: (p) => isUtilityPath(p), result: () => 'utility' },
|
|
{ test: (_, m) => m === 'GET', result: () => 'observer' },
|
|
// ...
|
|
]
|
|
```
|
|
|
|
---
|
|
|
|
## 4. MISSING SYNERGIES
|
|
|
|
### 4.1 Stateful Runner Doesn't Use Incremental Cache — FIXED
|
|
**File**: `src/test/stateful-runner.ts`
|
|
|
|
The stateful runner calls `convertSchema` directly on every run. For 100 stateful runs with 10 commands each, that's 1000 schema conversions. The cache exists but isn't used.
|
|
|
|
**Fix**: ✅ `createCommandArbitrary` now checks `lookupCache(route)` first. On cache hit, uses `fc.constantFrom(...cached.commands)`. Returns `{ arb, cacheHits, cacheMisses }` with stats included in summary.
|
|
|
|
### 4.2 Stateful Runner Doesn't Track Resources for Cleanup — FIXED
|
|
**File**: `src/test/stateful-runner.ts`
|
|
|
|
Stateful sequences create resources but never register them with `CleanupManager`. Resource leaks in long test runs.
|
|
|
|
**Fix**: ✅ `runStatefulTests` accepts optional `cleanupManager?: CleanupManager`. After `updateModelState`, calls `makeTrackedResource()` and registers with `cleanupManager.track()`. Calls `cleanupManager.cleanup()` at end of run.
|
|
|
|
### 4.3 Relationships Are Tracked But Never Queried — FIXED
|
|
**File**: `src/types.ts:190`
|
|
|
|
`ModelState.relationships` stores parent-child links but no invariant or test logic reads from it. Dead weight.
|
|
|
|
**Fix**: ✅ Removed `relationships` field from `ModelState` interface. Removed relationship tracking logic from `state-operations.ts`. Removed initialization from both runners. ~15 lines eliminated with zero functionality lost.
|
|
|
|
### 4.4 Formula `previous()` Exists But Temporal Invariants Don't Use It
|
|
**File**: `src/formula/evaluator.ts:60-65`
|
|
|
|
The `previous()` operator works but no invariant checks cross-request temporal properties like "resource created in request N must be retrievable in request N+1."
|
|
|
|
**Fix**: Add temporal invariants that use `history` parameter:
|
|
```typescript
|
|
{
|
|
name: 'resource-retrievable',
|
|
check: (state, history) => {
|
|
// For each constructor in history, verify GET returns 200
|
|
}
|
|
}
|
|
```
|
|
|
|
### 4.5 `ResourceHierarchy.scope` Is Always Empty
|
|
**File**: `src/domain/resource-inference.ts:232`
|
|
|
|
`scope: {}` is hardcoded. The generic design intended scope to hold tenant/app metadata, but nothing populates it.
|
|
|
|
**Fix**: Remove `scope` from `ResourceHierarchy` until needed, or populate from response body fields matching `x-apophis-resource` annotation scope fields.
|
|
|
|
---
|
|
|
|
## 5. TYPE SAFETY & COUPLING ISSUES
|
|
|
|
### 5.1 `as` Cast Proliferation — FIXED
|
|
**Count**: ~30 `as` casts across the codebase.
|
|
|
|
Examples:
|
|
- `src/test/petit-runner.ts:159`: `(response as unknown as { json: () => unknown }).json()` — Fixed via `executeHttp` extractor
|
|
- `src/infrastructure/hook-validator.ts:97`: `(reply as unknown as Record<string, unknown>).payload` — Fixed via `ReplyWithPayload` interface
|
|
|
|
**Fix**: ✅ Added proper interfaces:
|
|
- `FastifyWithSwagger` type guard in plugin (replaces `as unknown as Record`)
|
|
- `RequestWithCookies` interface in hook-validator (replaces double cast)
|
|
- `ReplyWithPayload` interface in hook-validator (replaces `as unknown` cast)
|
|
- `getRouteContract` helper with `RouteConfig` interface (replaces config casting)
|
|
Remaining casts (~15) are necessary for JSON Schema `unknown` property access.
|
|
|
|
### 5.2 Test Code in Production Paths — FIXED
|
|
**File**: `src/test/schema-to-arbitrary.ts`
|
|
|
|
Used by both `petit-runner.ts` and `stateful-runner.ts` (production runners) but lives in `src/test/`. Confusing boundary.
|
|
|
|
**Fix**: ✅ Moved to `src/domain/schema-to-arbitrary.ts`. Old file deleted. All imports updated in runners, tests, and benchmark.
|
|
|
|
### 5.3 Plugin Registers Process Signal Handlers Unconditionally — FIXED
|
|
**File**: `src/plugin/index.ts:72-78`
|
|
|
|
```typescript
|
|
// BEFORE:
|
|
process.on('exit', autoCleanup)
|
|
process.on('SIGINT', autoCleanup)
|
|
process.on('SIGTERM', autoCleanup)
|
|
```
|
|
|
|
If multiple Fastify instances with APOPHIS are created in the same process (e.g., tests), signal handlers accumulate. CleanupManager also registers signal handlers (`src/infrastructure/cleanup-manager.ts:58-59`).
|
|
|
|
**Fix**: ✅ Removed signal handlers from plugin. CleanupManager retains sole responsibility for SIGINT/SIGTERM registration. No more duplicate handlers on multiple plugin registrations.
|
|
|
|
### 5.4 WeakMap Cache Keyed by Schema Reference — FIXED
|
|
**File**: `src/domain/contract.ts:16`
|
|
|
|
```typescript
|
|
// BEFORE:
|
|
const contractCache = new WeakMap<Record<string, unknown>, RouteContract>()
|
|
```
|
|
|
|
If the same schema object is used for multiple routes with different paths, the cache returns the wrong path/method. The code has a guard (`cached.path === path`) but this defeats the purpose of caching.
|
|
|
|
**Fix**: ✅ Two-level cache structure:
|
|
```typescript
|
|
// AFTER:
|
|
const contractCache = new WeakMap<Record<string, unknown>, Map<string, RouteContract>>()
|
|
```
|
|
|
|
Top level: `WeakMap<schema, Map>` — preserves automatic GC. Second level: `Map<"METHOD path", RouteContract>` — correctly caches separate contracts for same schema on different routes. Guard check removed.
|
|
|
|
---
|
|
|
|
## 6. PERFORMANCE CONCERNS
|
|
|
|
### 6.1 Cache Persistence Writes on Every Store — FIXED
|
|
**File**: `src/incremental/cache.ts:84`
|
|
|
|
`storeCache` calls `saveCacheToDisk()` (synchronous JSON write) for every route. For 200 routes = 200 fs writes.
|
|
|
|
**Fix**: ✅ `storeCache` updates `memoryCache` only and sets `dirty = true`. `flushCache()` writes to disk once at end of test run. `refreshCache()` available for explicit reload.
|
|
|
|
### 6.2 Formula Parse Cache is LRU but Unbounded
|
|
**File**: `src/formula/parser.ts:568-569`
|
|
|
|
```typescript
|
|
const PARSE_CACHE = new Map<string, ParseResult>()
|
|
const CACHE_LIMIT = 1000
|
|
```
|
|
|
|
If an API has 11K routes with 3 formulas each = 33K formulas. Cache thrashes after 1000.
|
|
|
|
**Fix**: Increase limit or use a real LRU. 33K entries * ~100 bytes = 3.3MB, trivial.
|
|
|
|
### 6.3 Request Builder Re-parses Route Params
|
|
**File**: `src/domain/request-builder.ts:139`
|
|
|
|
```typescript
|
|
const url = substitutePathParams(route.path, generatedData, state)
|
|
// ... later:
|
|
const query = querySchema ? ... : extractRemainingParams(generatedData, parseRouteParams(route.path), body)
|
|
```
|
|
|
|
`parseRouteParams(route.path)` is called twice per request.
|
|
|
|
**Fix**: Parse once, pass parsed params to both functions.
|
|
|
|
---
|
|
|
|
## 7. RECOMMENDED REFACTORING PRIORITIES
|
|
|
|
### P0 (Fix This Week) — COMPLETED 2026-04-23
|
|
1. ✅ **Extract shared HTTP execution** — `src/infrastructure/http-executor.ts` exported; both runners import `executeHttp`
|
|
2. ✅ **Extract shared postcondition validation** — `src/domain/contract-validation.ts` exported; both runners import `validatePostconditions`
|
|
3. ✅ **Extract shared state update** — `src/domain/state-operations.ts` exported; both runners import `updateModelState` + `makeTrackedResource`
|
|
4. ✅ **Fix cache disk I/O** — `src/incremental/cache.ts` loads once at init, `flushCache()` called at end of runs
|
|
5. ✅ **Move schema-to-arbitrary** — Moved to `src/domain/schema-to-arbitrary.ts`; old file deleted; all imports updated
|
|
|
|
### P1 (Fix Next Week) — COMPLETED 2026-04-23
|
|
6. ✅ **Generalize ScopeConfig** — Removed `tenantId`/`applicationId` from core `ScopeConfig` type; added generic `metadata: Record<string, unknown>`; scope registry parses env vars into metadata backward-compatibly; `getHeaders` reads from metadata
|
|
7. ✅ **Add stateful runner cache integration** — `createCommandArbitrary` checks `lookupCache()`; returns `{ arb, cacheHits, cacheMisses }`; cache stats included in summary
|
|
8. ✅ **Add stateful runner cleanup tracking** — `runStatefulTests` accepts optional `cleanupManager`; tracks constructors via `makeTrackedResource`; calls `cleanupManager.cleanup()` at end
|
|
9. ✅ **Fix WeakMap cache key** — Two-level cache: `WeakMap<schema, Map<"METHOD path", RouteContract>>`; same schema on different routes caches separately; WeakMap GC preserved
|
|
10. ✅ **Remove dead relationship tracking** — Removed `relationships` field from `ModelState`; removed relationship logic from `state-operations.ts`; ~15 lines eliminated
|
|
|
|
### P2 (Nice to Have) — PARTIALLY COMPLETED 2026-04-23
|
|
11. **Simplify parser header detection** — Deferred: 100-line charCodeAt optimization provides ~0.5µs/formula; rewrite would risk performance regression without benchmarks
|
|
12. ✅ **Reduce `as` casts** — Added `FastifyWithSwagger` type guard in plugin; added `RequestWithCookies`/`ReplyWithPayload` interfaces in hook-validator; removed `unknown` casts from plugin/index.ts
|
|
13. ✅ **Flatten runner control flow** — Extracted `executeCommand` pipeline in stateful runner: returns `CommandResult` union type; `runSequence` uses switch statement instead of nested if/try-catch; 7 nesting levels reduced to 3
|
|
14. **Implement temporal invariants** — Deferred: requires domain-specific knowledge of which GET routes retrieve which constructors; generic temporal logic needs more design
|
|
15. ✅ **Deduplicate signal handlers** — Removed duplicate SIGINT/SIGTERM handlers from `plugin/index.ts`; CleanupManager retains sole responsibility for signal registration
|
|
|
|
---
|
|
|
|
## 8. POSITIVE PATTERNS TO PRESERVE
|
|
|
|
- **Pure domain functions** in `src/domain/` — Keep this boundary strict
|
|
- **Fastify plugin as thin wrapper** — `src/plugin/index.ts` delegates correctly
|
|
- **Crash-only error handling** — Throws immediately, no graceful degradation
|
|
- **Formula parser cache** — Good optimization for repeated formulas
|
|
- **WeakMap contract cache** — Correct use of reference equality for schema dedup
|
|
- **Readonly types** — Immutable data structures throughout
|
|
|
|
---
|
|
|
|
## Conclusion
|
|
|
|
The codebase has a strong architectural foundation with clear domain/infrastructure separation. **All P0 and P1 items completed** (2026-04-23):
|
|
|
|
**P0 Achievements:**
|
|
- **DRY violations eliminated**: HTTP execution, postcondition validation, and state updates extracted to shared modules
|
|
- **Accidental disk I/O fixed**: Cache loads once at module init, flushes once at end of test runs
|
|
- **Boundary clarified**: `schema-to-arbitrary` moved from `test/` to `domain/`
|
|
- **Type safety improved**: `FastifyInjectInstance` extracted to `types.ts`
|
|
|
|
**P1 Achievements:**
|
|
- **Domain-specific types removed**: `ScopeConfig` now uses generic `metadata` instead of mandatory `tenantId`/`applicationId`
|
|
- **Stateful runner enhanced**: Cache integration + optional cleanup tracking
|
|
- **Contract cache fixed**: Two-level WeakMap→Map correctly handles same schema on different routes
|
|
- **Dead code removed**: `relationships` tracking eliminated (never queried)
|
|
|
|
**P2 Achievements:**
|
|
- **Signal handlers deduplicated**: Removed duplicate registrations from plugin; CleanupManager retains sole responsibility
|
|
- **`as` casts reduced**: Added proper type guards (`FastifyWithSwagger`, `RequestWithCookies`, `ReplyWithPayload`) instead of `unknown` casts
|
|
- **Control flow flattened**: Stateful runner extracted `executeCommand` pipeline; 7 nesting levels reduced to 3 via switch-based dispatch
|
|
|
|
**Results**: Runner code reduced by ~160 lines each (~45% reduction). Test suite: **224/224 pass**. All lock comments cleaned up. Codebase is now maintainable with clear separation of concerns and minimal duplication.
|