diff --git a/src/cli/commands/doctor/checks/plugin-contracts.ts b/src/cli/commands/doctor/checks/plugin-contracts.ts new file mode 100644 index 0000000..7304be2 --- /dev/null +++ b/src/cli/commands/doctor/checks/plugin-contracts.ts @@ -0,0 +1,155 @@ +/** + * S8: Doctor thread - Plugin contract health checks + * + * Checks: + * - Plugin contract registration + * - Extension availability for plugin contracts + * - Configuration warnings from plugin contract registry + */ + +import type { PluginContractRegistry } from '../../../../domain/plugin-contracts.js' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface PluginContractCheckResult { + name: string + status: 'pass' | 'fail' | 'warn' + message: string + detail?: string + remediation?: string + mode: 'all' | 'verify' | 'observe' | 'qualify' +} + +export interface PluginContractCheckOptions { + pluginContractRegistry?: PluginContractRegistry +} + +// --------------------------------------------------------------------------- +// Plugin contract presence +// --------------------------------------------------------------------------- + +export function checkPluginContractPresence(options: PluginContractCheckOptions): PluginContractCheckResult { + const registry = options.pluginContractRegistry + + if (!registry || registry.getPluginNames().length === 0) { + return { + name: 'plugin-contracts-presence', + status: 'pass', + message: 'No plugin contracts configured (optional)', + mode: 'all', + } + } + + const names = registry.getPluginNames() + return { + name: 'plugin-contracts-presence', + status: 'pass', + message: `${names.length} plugin contract(s) registered: ${names.join(', ')}`, + mode: 'all', + } +} + +// --------------------------------------------------------------------------- +// Plugin contract extension availability +// --------------------------------------------------------------------------- + +export function checkPluginContractExtensions(options: PluginContractCheckOptions): PluginContractCheckResult { + const registry = options.pluginContractRegistry + + if (!registry || registry.getPluginNames().length === 0) { + return { + name: 'plugin-contracts-extensions', + status: 'pass', + message: 'No plugin contracts to check for extension dependencies', + mode: 'all', + } + } + + const available = new Set(registry.getAvailableExtensions()) + const missingByPlugin: Record = {} + + for (const name of registry.getPluginNames()) { + const spec = registry.getPluginSpec(name) + if (spec?.extensions) { + const missing = spec.extensions + .filter(e => e.required !== false) + .map(e => e.name) + .filter(n => !available.has(n)) + if (missing.length > 0) { + missingByPlugin[name] = missing + } + } + } + + if (Object.keys(missingByPlugin).length === 0) { + return { + name: 'plugin-contracts-extensions', + status: 'pass', + message: 'All plugin contract extension dependencies are available', + mode: 'all', + } + } + + const details = Object.entries(missingByPlugin) + .map(([plugin, exts]) => ` ${plugin}: missing [${exts.join(', ')}]`) + .join('\n') + + return { + name: 'plugin-contracts-extensions', + status: 'warn', + message: 'Some plugin contracts have missing extension dependencies', + detail: `Missing extensions:\n${details}`, + remediation: 'Register the required extensions via app.register(apophisPlugin, { extensions: [...] })', + mode: 'all', + } +} + +// --------------------------------------------------------------------------- +// Plugin contract warnings +// --------------------------------------------------------------------------- + +export function checkPluginContractWarnings(options: PluginContractCheckOptions): PluginContractCheckResult { + const registry = options.pluginContractRegistry + + if (!registry) { + return { + name: 'plugin-contracts-warnings', + status: 'pass', + message: 'No plugin contract registry available', + mode: 'all', + } + } + + const warnings = registry.drainWarnings() + + if (warnings.length === 0) { + return { + name: 'plugin-contracts-warnings', + status: 'pass', + message: 'No plugin contract configuration warnings', + mode: 'all', + } + } + + return { + name: 'plugin-contracts-warnings', + status: 'warn', + message: `${warnings.length} plugin contract warning(s)`, + detail: warnings.join('\n'), + mode: 'all', + } +} + +// --------------------------------------------------------------------------- +// Aggregation +// --------------------------------------------------------------------------- + +export function runPluginContractChecks(options: PluginContractCheckOptions): PluginContractCheckResult[] { + return [ + checkPluginContractPresence(options), + checkPluginContractExtensions(options), + checkPluginContractWarnings(options), + ] +} diff --git a/src/cli/commands/doctor/checks/safety.ts b/src/cli/commands/doctor/checks/safety.ts index b867be4..63b23ad 100644 --- a/src/cli/commands/doctor/checks/safety.ts +++ b/src/cli/commands/doctor/checks/safety.ts @@ -226,5 +226,85 @@ export function runSafetyChecks(options: SafetyCheckOptions): SafetyCheckResult[ results.push(checkEnvironmentPolicies(options)); results.push(checkProductionSafety(options)); + // Observe readiness: only when filtering for observe (or no filter) + if (!modeFilter || modeFilter === 'observe') { + results.push(checkObserveReadiness(options)); + } + return results; } + +// --------------------------------------------------------------------------- +// Observe readiness +// --------------------------------------------------------------------------- + +/** + * Check that observe mode is properly configured. + * Runs when mode filter is 'observe' or unset. + */ +export function checkObserveReadiness(options: SafetyCheckOptions): SafetyCheckResult { + const { config } = options + + const profileName = config.profile + const profile = profileName ? config.profiles?.[profileName] : undefined + const presetName = profile?.preset || config.preset + const preset = presetName ? config.presets?.[presetName] : undefined + + const hasObserve = + preset?.observe === true || + profile?.observe === true + + if (!hasObserve) { + return { + name: 'observe-readiness', + status: 'pass', + message: 'Observe not enabled in this preset/profile', + mode: 'observe', + } + } + + const warnings: string[] = [] + + // Check sampling rate + const sampling = profile?.sampling ?? preset?.sampling + if (sampling !== undefined && (sampling < 0 || sampling > 1)) { + warnings.push(`Sampling rate ${sampling} is outside valid range [0.0, 1.0]`) + } + + // Check sinks + const sinks = profile?.sinks ?? preset?.sinks + if (!sinks || Object.keys(sinks).length === 0) { + warnings.push('No sinks configured — observe will run but events will not be collected') + } + + // Check blocking + const blocking = profile?.blocking ?? preset?.blocking + if (blocking === true) { + const env = options.env || detectEnvironment() + const isProd = env === 'production' || env === 'prod' + if (isProd) { + const envPolicy = config.environments?.[env] + if (!envPolicy?.allowBlocking) { + warnings.push('Blocking observe enabled but allowBlocking is not set in production environment policy') + } + } + } + + if (warnings.length > 0) { + return { + name: 'observe-readiness', + status: 'warn', + message: `Observe readiness: ${warnings.length} concern(s)`, + detail: warnings.join('\n'), + remediation: 'Review observe configuration in presets and profiles', + mode: 'observe', + } + } + + return { + name: 'observe-readiness', + status: 'pass', + message: 'Observe configuration looks ready', + mode: 'observe', + } +} diff --git a/src/cli/commands/doctor/index.ts b/src/cli/commands/doctor/index.ts index 68d7023..8b0986f 100644 --- a/src/cli/commands/doctor/index.ts +++ b/src/cli/commands/doctor/index.ts @@ -23,6 +23,7 @@ import { runConfigChecks } from './checks/config.js'; 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 { renderJson } from '../../renderers/json.js'; @@ -196,6 +197,24 @@ async function runPackageChecks( checks.push({ ...result, package: packageName }); } + // 6. Plugin contract checks (all modes) + try { + const { loadApp } = await import('../../core/app-loader.js') + const loaded = await loadApp(cwd) + const fastify = loaded.fastify + if (fastify) { + const pcr = (fastify as any)?._apophisPluginContractRegistry + if (pcr) { + const pcrResults = runPluginContractChecks({ pluginContractRegistry: pcr }) + for (const result of pcrResults) { + checks.push({ ...result, package: packageName }) + } + } + } + } catch { + // App can't be loaded — skip plugin contract checks + } + return checks; } diff --git a/src/cli/commands/observe/validator.ts b/src/cli/commands/observe/validator.ts index b449cff..910889c 100644 --- a/src/cli/commands/observe/validator.ts +++ b/src/cli/commands/observe/validator.ts @@ -195,9 +195,9 @@ function presetToObserveConfig(preset: PresetDefinition): ObserveProfileConfig { function profileToObserveConfig(profile: ProfileDefinition): ObserveProfileConfig { return { features: profile.features, - sampling: (profile as Record).sampling as number | undefined, - blocking: (profile as Record).blocking as boolean | undefined, - sinks: (profile as Record).sinks as SinkConfig | undefined, + sampling: profile.sampling, + blocking: profile.blocking, + sinks: profile.sinks as SinkConfig | undefined, }; } diff --git a/src/cli/core/config-loader.ts b/src/cli/core/config-loader.ts index ef7ef62..a1be1b2 100644 --- a/src/cli/core/config-loader.ts +++ b/src/cli/core/config-loader.ts @@ -42,11 +42,38 @@ export interface EnvironmentPolicy { } export interface ProfileDefinition { + name: string; + mode?: 'verify' | 'observe' | 'qualify'; preset?: string; routes?: string[]; seed?: number; + artifactDir?: string; + environment?: string; + observe?: boolean; + sampling?: number; + blocking?: boolean; + sinks?: Record; features?: string[]; - [key: string]: unknown; + chaos?: boolean; + chaosStrategy?: 'one' | 'all' | 'sample' | 'routes'; + chaosSampleSize?: number; + chaosSampleRoutes?: string[]; +} + +export interface PresetDefinition { + name: string; + timeout?: number; + parallel?: boolean; + chaos?: boolean; + observe?: boolean; + runs?: number; + sampling?: number; + blocking?: boolean; + sinks?: Record; + features?: string[]; + chaosStrategy?: 'one' | 'all' | 'sample' | 'routes'; + chaosSampleSize?: number; + chaosSampleRoutes?: string[]; } export interface PresetDefinition { diff --git a/src/cli/core/index.ts b/src/cli/core/index.ts index 693adcb..9f7a4c5 100644 --- a/src/cli/core/index.ts +++ b/src/cli/core/index.ts @@ -73,11 +73,13 @@ function getCommandHelp(command: string): string { --routes Route filter pattern --seed Deterministic seed --changed Filter to git-modified routes + --workspace Run across all monorepo packages ${pc.dim('Examples:')} apophis verify --profile quick apophis verify --routes "POST /users" --seed 42 apophis verify --changed + apophis verify --workspace `, observe: ` ${pc.bold('apophis observe')} — Validate runtime observe configuration and reporting setup @@ -131,11 +133,12 @@ function getCommandHelp(command: string): string { ${pc.dim('Options:')} --mode Focus checks on a mode: verify | observe | qualify --strict Treat warnings as failures + --workspace Run across all monorepo packages ${pc.dim('Examples:')} apophis doctor - apophis doctor --mode verify - apophis doctor --strict + apophis doctor --mode observe + apophis doctor --strict --workspace `, migrate: ` ${pc.bold('apophis migrate')} — Check and rewrite deprecated config or API usage diff --git a/src/cli/core/types.ts b/src/cli/core/types.ts index 2fc76a1..49d685f 100644 --- a/src/cli/core/types.ts +++ b/src/cli/core/types.ts @@ -123,6 +123,15 @@ export interface ProfileDefinition { seed?: number; artifactDir?: string; environment?: string; + observe?: boolean; + sampling?: number; + blocking?: boolean; + sinks?: Record; + features?: string[]; + chaos?: boolean; + chaosStrategy?: 'one' | 'all' | 'sample' | 'routes'; + chaosSampleSize?: number; + chaosSampleRoutes?: string[]; } /**