From dab14ef77de4906b28caa0abd8937c81d9037356 Mon Sep 17 00:00:00 2001 From: John Dvorak Date: Fri, 22 May 2026 14:11:34 -0700 Subject: [PATCH] feat: add extension registry health checks to doctor, expose registry on Fastify --- src/cli/commands/doctor/checks/extensions.ts | 163 +++++++++++++++++++ src/cli/commands/doctor/index.ts | 12 +- src/plugin/index.ts | 3 +- 3 files changed, 175 insertions(+), 3 deletions(-) create mode 100644 src/cli/commands/doctor/checks/extensions.ts diff --git a/src/cli/commands/doctor/checks/extensions.ts b/src/cli/commands/doctor/checks/extensions.ts new file mode 100644 index 0000000..6a76e21 --- /dev/null +++ b/src/cli/commands/doctor/checks/extensions.ts @@ -0,0 +1,163 @@ +/** + * S8: Doctor thread - Extension registry health checks + * + * Checks: + * - Extension registration status + * - Extension health check results + * - Production safety of registered extensions + */ + +import type { ExtensionRegistry, ApophisExtension } from '../../../../extension/types.js' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface ExtensionCheckResult { + name: string + status: 'pass' | 'fail' | 'warn' + message: string + detail?: string + remediation?: string + mode: 'all' | 'verify' | 'observe' | 'qualify' +} + +export interface ExtensionCheckOptions { + extensionRegistry?: ExtensionRegistry + env?: string +} + +// --------------------------------------------------------------------------- +// Extension presence +// --------------------------------------------------------------------------- + +export function checkExtensionPresence(options: ExtensionCheckOptions): ExtensionCheckResult { + const registry = options.extensionRegistry + + if (!registry || registry.extensions.length === 0) { + return { + name: 'extensions-presence', + status: 'pass', + message: 'No extensions registered (optional)', + mode: 'all', + } + } + + const names = registry.extensions.map((e: ApophisExtension) => e.name).join(', ') + return { + name: 'extensions-presence', + status: 'pass', + message: `${registry.extensions.length} extension(s) registered: ${names}`, + mode: 'all', + } +} + +// --------------------------------------------------------------------------- +// Extension health +// --------------------------------------------------------------------------- + +export async function checkExtensionHealth(options: ExtensionCheckOptions): Promise { + const registry = options.extensionRegistry + + if (!registry || registry.extensions.length === 0) { + return { + name: 'extensions-health', + status: 'pass', + message: 'No extensions to health-check', + mode: 'all', + } + } + + try { + const results = await registry.runHealthChecks() + const unhealthy = results.filter((r: { name: string; error?: string }) => r.error) + + if (unhealthy.length === 0) { + return { + name: 'extensions-health', + status: 'pass', + message: 'All extensions passed health checks', + mode: 'all', + } + } + + const details = unhealthy.map((r: { name: string; error?: string }) => ` ${r.name}: ${r.error}`).join('\n') + return { + name: 'extensions-health', + status: 'warn', + message: `${unhealthy.length} extension(s) failed health checks`, + detail: details, + remediation: 'Review extension configuration and dependencies', + mode: 'all', + } + } catch (error) { + return { + name: 'extensions-health', + status: 'warn', + message: 'Could not run extension health checks', + detail: error instanceof Error ? error.message : String(error), + mode: 'all', + } + } +} + +// --------------------------------------------------------------------------- +// Production safety +// --------------------------------------------------------------------------- + +export function checkExtensionProductionSafety(options: ExtensionCheckOptions): ExtensionCheckResult { + const registry = options.extensionRegistry + const env = options.env || process.env.NODE_ENV || 'local' + + if (!registry || registry.extensions.length === 0) { + return { + name: 'extensions-production-safety', + status: 'pass', + message: 'No extensions to check', + mode: 'all', + } + } + + const isProd = env === 'production' || env === 'prod' + if (!isProd) { + return { + name: 'extensions-production-safety', + status: 'pass', + message: 'Not in production — extension safety check skipped', + mode: 'all', + } + } + + const unsafe = registry.extensions.filter((e: ApophisExtension) => e.productionSafe === false) + + if (unsafe.length === 0) { + return { + name: 'extensions-production-safety', + status: 'pass', + message: 'All extensions are production-safe', + mode: 'all', + } + } + + const names = unsafe.map((e: ApophisExtension) => e.name).join(', ') + return { + name: 'extensions-production-safety', + status: 'warn', + message: `${unsafe.length} extension(s) not marked production-safe`, + detail: `Unsafe extensions: ${names}`, + remediation: 'Remove these extensions from production config or mark them productionSafe', + mode: 'all', + } +} + +// --------------------------------------------------------------------------- +// Aggregation +// --------------------------------------------------------------------------- + +export async function runExtensionChecks(options: ExtensionCheckOptions): Promise { + return [ + checkExtensionPresence(options), + await checkExtensionHealth(options), + checkExtensionProductionSafety(options), + ] +} diff --git a/src/cli/commands/doctor/index.ts b/src/cli/commands/doctor/index.ts index 8b0986f..b661b40 100644 --- a/src/cli/commands/doctor/index.ts +++ b/src/cli/commands/doctor/index.ts @@ -24,6 +24,7 @@ import { runRouteChecks } from './checks/routes.js'; import { runSafetyChecks } from './checks/safety.js'; import { runDocsChecks } from './checks/docs.js'; import { runPluginContractChecks } from './checks/plugin-contracts.js'; +import { runExtensionChecks } from './checks/extensions.js'; import { renderJson } from '../../renderers/json.js'; @@ -197,7 +198,7 @@ async function runPackageChecks( checks.push({ ...result, package: packageName }); } - // 6. Plugin contract checks (all modes) + // 6. Plugin contract and extension checks (all modes) try { const { loadApp } = await import('../../core/app-loader.js') const loaded = await loadApp(cwd) @@ -210,9 +211,16 @@ async function runPackageChecks( checks.push({ ...result, package: packageName }) } } + const extReg = (fastify as any)?._apophisExtensionRegistry + if (extReg) { + const extResults = await runExtensionChecks({ extensionRegistry: extReg, env: detectEnvironment() }) + for (const result of extResults) { + checks.push({ ...result, package: packageName }) + } + } } } catch { - // App can't be loaded — skip plugin contract checks + // App can't be loaded — skip plugin/extension checks } return checks; diff --git a/src/plugin/index.ts b/src/plugin/index.ts index 77671bf..918dab9 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -147,8 +147,9 @@ export const apophisPlugin = async (fastify: FastifyInstance, opts: ApophisOptio }, } fastify.decorate('apophis', decorations) - // Expose plugin contract registry for CLI qualify access + // Expose registries for CLI qualify and doctor access ;(fastify as unknown as Record)._apophisPluginContractRegistry = pluginContractRegistry + ;(fastify as unknown as Record)._apophisExtensionRegistry = extensionRegistry // Runtime validation may block requests, so it is disabled in production. // Observe sinks are explicitly non-blocking and are allowed in production. const isProd = process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'prod'