chore: crush git history - reborn from consolidation on 2026-03-10
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* 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 as default.',
|
||||
remediation: 'Export your Fastify instance as default: export default 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;
|
||||
}
|
||||
Reference in New Issue
Block a user