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(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',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user