feat: add plugin contract, observe readiness checks to doctor; sync types
This commit is contained in:
@@ -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),
|
||||
]
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -195,9 +195,9 @@ function presetToObserveConfig(preset: PresetDefinition): ObserveProfileConfig {
|
||||
function profileToObserveConfig(profile: ProfileDefinition): ObserveProfileConfig {
|
||||
return {
|
||||
features: profile.features,
|
||||
sampling: (profile as Record<string, unknown>).sampling as number | undefined,
|
||||
blocking: (profile as Record<string, unknown>).blocking as boolean | undefined,
|
||||
sinks: (profile as Record<string, unknown>).sinks as SinkConfig | undefined,
|
||||
sampling: profile.sampling,
|
||||
blocking: profile.blocking,
|
||||
sinks: profile.sinks as SinkConfig | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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 {
|
||||
|
||||
@@ -73,11 +73,13 @@ function getCommandHelp(command: string): string {
|
||||
--routes <filter> Route filter pattern
|
||||
--seed <number> 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 <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
|
||||
|
||||
@@ -123,6 +123,15 @@ export interface ProfileDefinition {
|
||||
seed?: number;
|
||||
artifactDir?: 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[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user