Files
apophis-fastify/src/cli/commands/doctor/checks/routes.ts
T
John Dvorak 5921b1437f trim: remove dead code and move large spec docs to attic
- Remove unused exports: renderProgress, formatTripleBoundaryCounterexample, clearCapturedRoutes
- Remove dead BUILTIN_PLUGIN_CONTRACTS constant (auto-registration removed earlier)
- Fix app-loader error messages to mention multiple export patterns
- Move to attic: protocol-extensions-spec, OUTBOUND_CONTRACT_MOCKING_SPEC, PLUGIN_CONTRACTS_SPEC, fastify-structure
- Build: clean | Tests: 849 pass, 0 fail
2026-04-30 12:47:59 -07:00

283 lines
8.8 KiB
TypeScript

/**
* S8: Doctor thread - Route discovery checks
*
* Checks:
* - Can we discover routes from the Fastify app?
* - Are routes properly registered with swagger?
* - Is the app file loadable?
*/
import { existsSync } from 'node:fs';
import { resolve } from 'node:path';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface RouteCheckResult {
name: string;
status: 'pass' | 'fail' | 'warn';
message: string;
detail?: string;
remediation?: string;
mode: 'all' | 'verify' | 'observe' | 'qualify';
}
export interface RouteCheckOptions {
cwd: string;
configPath?: string;
}
// ---------------------------------------------------------------------------
// App file detection
// ---------------------------------------------------------------------------
const APP_CANDIDATES = [
'app.js',
'app.ts',
'server.js',
'server.ts',
'index.js',
'index.ts',
'src/app.js',
'src/app.ts',
'src/server.js',
'src/server.ts',
'src/index.js',
'src/index.ts',
];
/**
* Find the Fastify app entrypoint file.
*/
function findAppFile(cwd: string): string | null {
for (const candidate of APP_CANDIDATES) {
const fullPath = resolve(cwd, candidate);
if (existsSync(fullPath)) {
return candidate;
}
}
return null;
}
/**
* Check if app file exists and is readable.
*/
export function checkAppFile(options: RouteCheckOptions): RouteCheckResult {
const appFile = findAppFile(options.cwd);
if (!appFile) {
return {
name: 'app-file',
status: 'warn',
message: 'No Fastify app file found.',
detail: `Searched for: ${APP_CANDIDATES.join(', ')}. ` +
'APOPHIS needs an app.js or similar to discover routes.',
remediation: 'Create an app.js or server.js that exports a Fastify instance.',
mode: 'all',
};
}
return {
name: 'app-file',
status: 'pass',
message: `Found Fastify app: ${appFile}`,
mode: 'all',
};
}
// ---------------------------------------------------------------------------
// Route discovery check
// ---------------------------------------------------------------------------
/**
* Attempt to load the app and discover routes.
*/
export async function checkRouteDiscovery(options: RouteCheckOptions): Promise<RouteCheckResult> {
const appFile = findAppFile(options.cwd);
if (!appFile) {
return {
name: 'route-discovery',
status: 'warn',
message: 'Skipping route discovery (no app file found).',
mode: 'all',
};
}
try {
const appPath = resolve(options.cwd, appFile);
const appModule = await import(appPath);
const app = appModule.default || appModule;
// Check if it looks like a Fastify instance
if (!app || typeof app !== 'object') {
return {
name: 'route-discovery',
status: 'fail',
message: `App file ${appFile} does not export a valid object.`,
detail: 'Ensure the app file exports a Fastify instance or a factory function.',
remediation: 'Export your Fastify instance: export default app; or export const createApp = () => app; or module.exports = app;',
mode: 'all',
};
}
// Try to register APOPHIS plugin for route capture
// Skip if already registered to avoid "decorator already added" errors
const isAlreadyRegistered = app.hasDecorator && typeof app.hasDecorator === 'function' && app.hasDecorator('apophis');
if (!isAlreadyRegistered) {
try {
const apophisPlugin = (await import('../../../../index.js')).default;
if (typeof apophisPlugin === 'function' && typeof app.register === 'function') {
await app.register(apophisPlugin, { runtime: 'off' });
}
} catch (err) {
const errMessage = err instanceof Error ? err.message : String(err);
// If decorator already added, the plugin is pre-registered — that's fine
if (errMessage.includes("decorator 'apophis' has already been added")) {
// Plugin is already registered, proceed with discovery
}
// Otherwise, plugin registration is optional for discovery
}
}
// Try to ready the app so routes are registered
if (typeof app.ready === 'function') {
await app.ready();
}
// Check for routes
let routeCount = 0;
// Fastify 5+ routes access
if (app.routes && typeof app.routes === 'function') {
const routes = app.routes();
routeCount = Array.isArray(routes) ? routes.length : 0;
}
// Fallback: check if we can get routes via inject or other methods
if (routeCount === 0 && app.hasRoute) {
// We can't enumerate, but we can at least verify the app is functional
routeCount = -1; // Unknown but app seems functional
}
if (routeCount === 0) {
return {
name: 'route-discovery',
status: 'warn',
message: `App loaded from ${appFile} but no routes were discovered.`,
detail: 'Ensure routes are registered before exporting the app. ' +
'APOPHIS discovers routes via the onRoute hook.',
remediation: 'Register routes before exporting the app, or ensure the APOPHIS plugin is registered.',
mode: 'all',
};
}
if (routeCount < 0) {
return {
name: 'route-discovery',
status: 'pass',
message: `App loaded from ${appFile}. Route enumeration not available (app is functional).`,
detail: 'Route count could not be determined, but the app appears to be a valid Fastify instance.',
mode: 'all',
};
}
return {
name: 'route-discovery',
status: 'pass',
message: `Discovered ${routeCount} route(s) from ${appFile}.`,
mode: 'all',
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
// If the error is a module not found, treat as warn (dependencies may not be installed in test env)
if (message.includes('Cannot find module') || message.includes('Cannot resolve')) {
return {
name: 'route-discovery',
status: 'warn',
message: `Could not load app from ${appFile}: ${message}`,
detail: 'Dependencies may not be installed. Run npm install to resolve.',
remediation: 'Run npm install to install missing dependencies.',
mode: 'all',
};
}
return {
name: 'route-discovery',
status: 'fail',
message: `Failed to load app from ${appFile}: ${message}`,
detail: 'Check that the app file exports a valid Fastify instance and all imports resolve.',
remediation: 'Verify all imports in your app file are correct and the file exports a Fastify instance.',
mode: 'all',
};
}
}
// ---------------------------------------------------------------------------
// Swagger registration check
// ---------------------------------------------------------------------------
/**
* Check if @fastify/swagger is registered in the app.
*/
export async function checkSwaggerRegistration(options: RouteCheckOptions): Promise<RouteCheckResult> {
const appFile = findAppFile(options.cwd);
if (!appFile) {
return {
name: 'swagger-registration',
status: 'warn',
message: 'Skipping swagger check (no app file found).',
mode: 'all',
};
}
try {
const appPath = resolve(options.cwd, appFile);
const content = (await import('node:fs')).readFileSync(appPath, 'utf-8');
if (content.includes('@fastify/swagger') || content.includes('fastify-swagger')) {
return {
name: 'swagger-registration',
status: 'pass',
message: `@fastify/swagger appears to be imported in ${appFile}.`,
mode: 'all',
};
}
return {
name: 'swagger-registration',
status: 'warn',
message: `@fastify/swagger not found in ${appFile}.`,
detail: 'APOPHIS requires @fastify/swagger for route discovery. ' +
'Register it with: await app.register(import("@fastify/swagger"), { openapi: { info: { title: "API", version: "1.0.0" } } });',
remediation: 'npm install @fastify/swagger@^9.0.0 and register it in your app file.',
mode: 'all',
};
} catch {
return {
name: 'swagger-registration',
status: 'warn',
message: `Could not read ${appFile} to check swagger registration.`,
mode: 'all',
};
}
}
// ---------------------------------------------------------------------------
// Main route check runner
// ---------------------------------------------------------------------------
/**
* Run all route discovery checks.
*/
export async function runRouteChecks(options: RouteCheckOptions): Promise<RouteCheckResult[]> {
const results: RouteCheckResult[] = [];
results.push(checkAppFile(options));
results.push(await checkRouteDiscovery(options));
results.push(await checkSwaggerRegistration(options));
return results;
}