feat: add plugin contract, observe readiness checks to doctor; sync types

This commit is contained in:
John Dvorak
2026-05-22 13:08:51 -07:00
parent 4891b0883e
commit 60e710370a
7 changed files with 299 additions and 6 deletions
@@ -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<string, string[]> = {}
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),
]
}
+80
View File
@@ -226,5 +226,85 @@ export function runSafetyChecks(options: SafetyCheckOptions): SafetyCheckResult[
results.push(checkEnvironmentPolicies(options)); results.push(checkEnvironmentPolicies(options));
results.push(checkProductionSafety(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; 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',
}
}
+19
View File
@@ -23,6 +23,7 @@ import { runConfigChecks } from './checks/config.js';
import { runRouteChecks } from './checks/routes.js'; import { runRouteChecks } from './checks/routes.js';
import { runSafetyChecks } from './checks/safety.js'; import { runSafetyChecks } from './checks/safety.js';
import { runDocsChecks } from './checks/docs.js'; import { runDocsChecks } from './checks/docs.js';
import { runPluginContractChecks } from './checks/plugin-contracts.js';
import { renderJson } from '../../renderers/json.js'; import { renderJson } from '../../renderers/json.js';
@@ -196,6 +197,24 @@ async function runPackageChecks(
checks.push({ ...result, package: packageName }); 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; return checks;
} }
+3 -3
View File
@@ -195,9 +195,9 @@ function presetToObserveConfig(preset: PresetDefinition): ObserveProfileConfig {
function profileToObserveConfig(profile: ProfileDefinition): ObserveProfileConfig { function profileToObserveConfig(profile: ProfileDefinition): ObserveProfileConfig {
return { return {
features: profile.features, features: profile.features,
sampling: (profile as Record<string, unknown>).sampling as number | undefined, sampling: profile.sampling,
blocking: (profile as Record<string, unknown>).blocking as boolean | undefined, blocking: profile.blocking,
sinks: (profile as Record<string, unknown>).sinks as SinkConfig | undefined, sinks: profile.sinks as SinkConfig | undefined,
}; };
} }
+28 -1
View File
@@ -42,11 +42,38 @@ export interface EnvironmentPolicy {
} }
export interface ProfileDefinition { export interface ProfileDefinition {
name: string;
mode?: 'verify' | 'observe' | 'qualify';
preset?: string; preset?: string;
routes?: string[]; routes?: string[];
seed?: number; seed?: number;
artifactDir?: string;
environment?: string;
observe?: boolean;
sampling?: number;
blocking?: boolean;
sinks?: Record<string, unknown>;
features?: string[]; 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<string, unknown>;
features?: string[];
chaosStrategy?: 'one' | 'all' | 'sample' | 'routes';
chaosSampleSize?: number;
chaosSampleRoutes?: string[];
} }
export interface PresetDefinition { export interface PresetDefinition {
+5 -2
View File
@@ -73,11 +73,13 @@ function getCommandHelp(command: string): string {
--routes <filter> Route filter pattern --routes <filter> Route filter pattern
--seed <number> Deterministic seed --seed <number> Deterministic seed
--changed Filter to git-modified routes --changed Filter to git-modified routes
--workspace Run across all monorepo packages
${pc.dim('Examples:')} ${pc.dim('Examples:')}
apophis verify --profile quick apophis verify --profile quick
apophis verify --routes "POST /users" --seed 42 apophis verify --routes "POST /users" --seed 42
apophis verify --changed apophis verify --changed
apophis verify --workspace
`, `,
observe: ` observe: `
${pc.bold('apophis observe')} Validate runtime observe configuration and reporting setup ${pc.bold('apophis observe')} Validate runtime observe configuration and reporting setup
@@ -131,11 +133,12 @@ function getCommandHelp(command: string): string {
${pc.dim('Options:')} ${pc.dim('Options:')}
--mode <mode> Focus checks on a mode: verify | observe | qualify --mode <mode> Focus checks on a mode: verify | observe | qualify
--strict Treat warnings as failures --strict Treat warnings as failures
--workspace Run across all monorepo packages
${pc.dim('Examples:')} ${pc.dim('Examples:')}
apophis doctor apophis doctor
apophis doctor --mode verify apophis doctor --mode observe
apophis doctor --strict apophis doctor --strict --workspace
`, `,
migrate: ` migrate: `
${pc.bold('apophis migrate')} Check and rewrite deprecated config or API usage ${pc.bold('apophis migrate')} Check and rewrite deprecated config or API usage
+9
View File
@@ -123,6 +123,15 @@ export interface ProfileDefinition {
seed?: number; seed?: number;
artifactDir?: string; artifactDir?: string;
environment?: string; environment?: string;
observe?: boolean;
sampling?: number;
blocking?: boolean;
sinks?: Record<string, unknown>;
features?: string[];
chaos?: boolean;
chaosStrategy?: 'one' | 'all' | 'sample' | 'routes';
chaosSampleSize?: number;
chaosSampleRoutes?: string[];
} }
/** /**