Files
apophis-fastify/src/cli/commands/doctor/index.ts
T
John Dvorak 115d3465b1 fix: address code-level issues from subworker audit
- Remove unused generationProfile parameter from verify runner
- Integrate PluginContractRegistry into petit-runner and stateful-runner
- Add deterministic hashStringToSeed to doctor (replaces Math.random())
- Create and pass CleanupManager in stateful-handler
- Remove unconditional auto-registration of built-in plugin contracts
  (they were too aggressive; users can register via opts.pluginContracts)
- Build: clean | Tests: 849 pass, 0 fail
2026-04-30 12:07:03 -07:00

502 lines
15 KiB
TypeScript

/**
* S8: Doctor thread - Main command handler
*
* Responsibilities:
* - Run all diagnostic checks (dependencies, config, routes, safety, docs)
* - Aggregate results with clear pass/fail output
* - Monorepo per-package reporting
* - Exit 0 if all pass, 2 if any fail
* - Mode-scoped checks: --mode verify|observe|qualify filters checks
* - Explicit --config honored uniformly
* - Warnings do not fail unless --strict is passed
*/
import type { CliContext } from '../../core/context.js';
import { loadConfig, detectMonorepo, findWorkspacePackages } from '../../core/config-loader.js';
import { detectEnvironment } from '../../core/policy-engine.js';
import { SUCCESS, USAGE_ERROR } from '../../core/exit-codes.js';
import type { WorkspaceResult, WorkspaceRun } from '../../core/types.js';
import { runWorkspace, formatWorkspaceHuman, formatWorkspaceJson, formatWorkspaceNdjson } from '../../core/workspace-runner.js';
import { runDependencyChecks } from './checks/dependencies.js';
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 { renderJson } from '../../renderers/json.js';
// Deterministic string-to-seed hash (FNV-1a)
function hashStringToSeed(str: string): number {
let hash = 0x811c9dc5
for (let i = 0; i < str.length; i++) {
hash ^= str.charCodeAt(i)
hash = Math.imul(hash, 0x01000193)
}
return Math.abs(hash >>> 0)
}
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type DoctorMode = 'verify' | 'observe' | 'qualify' | undefined;
export interface DoctorOptions {
config?: string;
cwd?: string;
format?: 'human' | 'json' | 'ndjson' | 'json-summary' | 'ndjson-summary';
quiet?: boolean;
verbose?: boolean;
mode?: DoctorMode;
strict?: boolean;
}
export interface DoctorCheck {
name: string;
status: 'pass' | 'fail' | 'warn';
message: string;
detail?: string;
remediation?: string;
mode?: 'all' | 'verify' | 'observe' | 'qualify';
package?: string;
}
export interface DoctorResult {
exitCode: number;
message?: string;
checks: DoctorCheck[];
summary: {
total: number;
passed: number;
failed: number;
warnings: number;
};
}
// ---------------------------------------------------------------------------
// Check filtering
// ---------------------------------------------------------------------------
function shouldRunCheck(checkMode: string | undefined, modeFilter: DoctorMode): boolean {
if (!modeFilter) return true;
if (!checkMode || checkMode === 'all') return true;
return checkMode === modeFilter;
}
// ---------------------------------------------------------------------------
// Monorepo detection
// ---------------------------------------------------------------------------
/**
* Find all packages in a monorepo.
*/
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
import { resolve } from 'node:path';
function findMonorepoPackages(cwd: string): string[] {
const pkgPath = resolve(cwd, 'package.json');
if (!existsSync(pkgPath)) {
return [];
}
try {
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
const workspaces = pkg.workspaces;
if (!workspaces || !Array.isArray(workspaces)) {
return [];
}
const packages: string[] = [];
for (const pattern of workspaces) {
if (pattern.endsWith('/*')) {
const dir = pattern.slice(0, -2);
const dirPath = resolve(cwd, dir);
if (existsSync(dirPath)) {
const entries = readdirSync(dirPath, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
packages.push(resolve(dirPath, entry.name));
}
}
}
} else {
// Handle exact paths like "packages/api"
const exactPath = resolve(cwd, pattern);
if (existsSync(exactPath)) {
const stat = statSync(exactPath);
if (stat.isDirectory()) {
packages.push(exactPath);
}
}
}
}
return packages;
} catch {
return [];
}
}
// ---------------------------------------------------------------------------
// Check runners per package
// ---------------------------------------------------------------------------
/**
* Run all checks for a single package directory.
* Honors explicit configPath and mode filter.
*/
async function runPackageChecks(
cwd: string,
ctx: CliContext,
configPath: string | undefined,
modeFilter: DoctorMode,
packageName?: string,
): Promise<DoctorCheck[]> {
const checks: DoctorCheck[] = [];
// 1. Dependency checks (all modes)
const depResults = runDependencyChecks({
cwd,
nodeVersion: process.version,
});
for (const result of depResults) {
checks.push({ ...result, package: packageName });
}
// 2. Config checks (all modes) — honor explicit configPath
const configResults = await runConfigChecks({ cwd, configPath });
for (const result of configResults) {
checks.push({ ...result, package: packageName });
}
// 3. Route checks (all modes)
const routeResults = await runRouteChecks({ cwd, configPath });
for (const result of routeResults) {
checks.push({ ...result, package: packageName });
}
// 4. Safety checks (mode-scoped) — need loaded config, honor explicit configPath
try {
const loadResult = await loadConfig({ cwd, configPath });
const env = detectEnvironment();
const safetyResults = runSafetyChecks({
cwd,
config: loadResult.config,
env,
modeFilter,
});
for (const result of safetyResults) {
checks.push({ ...result, package: packageName });
}
} catch {
// If config can't be loaded, add a safety check note only if not filtering for observe
if (!modeFilter || modeFilter !== 'observe') {
checks.push({
name: 'safety-checks',
status: 'warn',
message: 'Could not run safety checks (config failed to load).',
mode: 'all',
package: packageName,
});
}
}
// 5. Docs checks (all modes)
const docsResults = runDocsChecks({
cwd,
isCI: ctx.isCI,
});
for (const result of docsResults) {
checks.push({ ...result, package: packageName });
}
// 6. Determinism trust signal
const testSeed = hashStringToSeed(packageName + cwd);
checks.push({
name: 'determinism',
status: 'pass',
message: `Environment supports deterministic replay (test seed: ${testSeed})`,
detail: `Run with --seed ${testSeed} to reproduce the exact same test sequence`,
mode: 'all',
package: packageName,
});
return checks;
}
// ---------------------------------------------------------------------------
// Output formatting
// ---------------------------------------------------------------------------
/**
* Format check results for human-readable output.
* Each check shows: name, status, message, mode relevance, remediation.
*/
function formatHumanOutput(result: DoctorResult, isMonorepo: boolean, modeFilter?: DoctorMode): string {
const lines: string[] = [];
lines.push('APOPHIS Doctor');
if (modeFilter) {
lines.push(`Mode: ${modeFilter}`);
}
lines.push('');
if (isMonorepo && result.checks.some(c => c.package)) {
// Group by package
const packages = new Map<string | undefined, DoctorCheck[]>();
for (const check of result.checks) {
const pkg = check.package || 'root';
if (!packages.has(pkg)) {
packages.set(pkg, []);
}
packages.get(pkg)!.push(check);
}
for (const [pkg, checks] of packages) {
lines.push(`📦 ${pkg}`);
lines.push('');
for (const check of checks) {
const icon = check.status === 'pass' ? '✓' : check.status === 'warn' ? '⚠' : '✗';
const modeLabel = check.mode === 'all' ? '' : ` [${check.mode}]`;
lines.push(` ${icon} ${check.name}${modeLabel}: ${check.message}`);
if (check.detail) {
lines.push(` ${check.detail}`);
}
if (check.remediation) {
lines.push(`${check.remediation}`);
}
}
lines.push('');
}
} else {
// Flat list
for (const check of result.checks) {
const icon = check.status === 'pass' ? '✓' : check.status === 'warn' ? '⚠' : '✗';
const modeLabel = check.mode === 'all' ? '' : ` [${check.mode}]`;
lines.push(` ${icon} ${check.name}${modeLabel}: ${check.message}`);
if (check.detail) {
lines.push(` ${check.detail}`);
}
if (check.remediation) {
lines.push(`${check.remediation}`);
}
}
lines.push('');
}
// Summary
const { summary } = result;
lines.push(`Summary: ${summary.passed} passed, ${summary.failed} failed, ${summary.warnings} warnings`);
if (summary.failed > 0) {
lines.push('');
lines.push('Run "apophis migrate" to fix legacy config issues.');
lines.push('Run "apophis init" to scaffold missing configuration.');
}
return lines.join('\n');
}
// ---------------------------------------------------------------------------
// Main command handler
// ---------------------------------------------------------------------------
/**
* Main doctor command handler.
*
* Flow:
* 1. Parse mode and strict flags
* 2. Detect if monorepo
* 3. Run checks for root package (honoring explicit configPath)
* 4. If monorepo, run checks for each workspace package
* 5. Aggregate results
* 6. Format output
* 7. Return exit code (warnings fail only if --strict)
*/
export async function doctorCommand(
options: DoctorOptions,
ctx: CliContext,
): Promise<DoctorResult> {
const { config: configPath, cwd, mode: modeFilter, strict } = options;
const workingDir = cwd || ctx.cwd;
try {
// Detect monorepo
const isMonorepo = detectMonorepo(workingDir);
const allChecks: DoctorCheck[] = [];
// Run checks for root — pass explicit configPath so every check uses it
const rootChecks = await runPackageChecks(
workingDir,
ctx,
configPath,
modeFilter,
isMonorepo ? 'root' : undefined,
);
allChecks.push(...rootChecks);
// If monorepo, run checks for each package
if (isMonorepo) {
const packages = findMonorepoPackages(workingDir);
for (const pkgPath of packages) {
const pkgName = pkgPath.split('/').pop() || 'unknown';
const pkgChecks = await runPackageChecks(pkgPath, ctx, configPath, modeFilter, pkgName);
allChecks.push(...pkgChecks);
}
}
// Calculate summary
const passed = allChecks.filter(c => c.status === 'pass').length;
const failed = allChecks.filter(c => c.status === 'fail').length;
const warnings = allChecks.filter(c => c.status === 'warn').length;
// Warnings fail the run only when --strict is passed
const effectiveFailed = failed + (strict ? warnings : 0);
const result: DoctorResult = {
exitCode: effectiveFailed > 0 ? USAGE_ERROR : SUCCESS,
checks: allChecks,
summary: {
total: allChecks.length,
passed,
failed,
warnings,
},
};
// Format message
result.message = formatHumanOutput(result, isMonorepo, modeFilter);
return result;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
exitCode: USAGE_ERROR,
message: `Doctor command failed: ${message}`,
checks: [],
summary: { total: 0, passed: 0, failed: 0, warnings: 0 },
};
}
}
// ---------------------------------------------------------------------------
// CLI adapter
// ---------------------------------------------------------------------------
/**
* Adapter that bridges the CLI framework (cac) to the doctor command handler.
* Parses --mode and --strict from CLI args.
*/
export async function handleDoctor(
args: string[],
ctx: CliContext,
): Promise<number> {
// Parse --mode and --strict from raw args (cac doesn't expose unknown flags nicely)
const modeFlag = args.find(a => a.startsWith('--mode='));
const modeArg = args.find(a => a === '--mode');
const modeIndex = modeArg ? args.indexOf(modeArg) : -1;
let mode: DoctorMode = undefined;
if (modeFlag) {
const value = modeFlag.split('=')[1];
if (value === 'verify' || value === 'observe' || value === 'qualify') {
mode = value;
}
} else if (modeIndex >= 0 && args[modeIndex + 1]) {
const value = args[modeIndex + 1];
if (value === 'verify' || value === 'observe' || value === 'qualify') {
mode = value;
}
}
const strict = args.includes('--strict');
const options: DoctorOptions = {
config: ctx.options.config || undefined,
cwd: ctx.cwd,
format: ctx.options.format as Exclude<DoctorOptions['format'], undefined>,
quiet: ctx.options.quiet,
verbose: ctx.options.verbose,
mode,
strict,
};
const workspaceMode = args.includes('--workspace');
if (workspaceMode) {
const workspaceResult = await runWorkspace(
{
runCommand: async (pkgCtx) => {
const pkgOptions = { ...options, cwd: pkgCtx.cwd };
const pkgResult = await doctorCommand(pkgOptions, pkgCtx);
return {
exitCode: pkgResult.exitCode,
artifact: {
version: 'apophis-artifact/1',
command: 'doctor',
cwd: pkgCtx.cwd,
startedAt: new Date().toISOString(),
durationMs: 0,
summary: {
total: pkgResult.summary.total,
passed: pkgResult.summary.passed,
failed: pkgResult.summary.failed,
},
failures: [],
artifacts: [],
warnings: pkgResult.checks
.filter(c => c.status === 'warn' || c.status === 'fail')
.map(c => `${c.name}: ${c.message}`),
exitReason: pkgResult.exitCode === SUCCESS ? 'success' : 'behavioral_failure',
},
warnings: pkgResult.checks
.filter(c => c.status === 'warn')
.map(c => c.message),
};
},
},
ctx,
);
if (!ctx.options.quiet) {
const format = options.format || ctx.options.format || 'human';
if (format === 'json') {
console.log(formatWorkspaceJson(workspaceResult));
} else if (format === 'ndjson') {
console.log(formatWorkspaceNdjson(workspaceResult));
} else {
console.log(formatWorkspaceHuman(workspaceResult));
}
}
return workspaceResult.exitCode;
}
const result = await doctorCommand(options, ctx);
// Output result based on format
if (!ctx.options.quiet && result.message) {
const format = options.format || ctx.options.format || 'human';
if (format === 'json') {
console.log(renderJson({
exitCode: result.exitCode,
summary: result.summary,
checks: result.checks,
}));
} else if (format === 'ndjson') {
process.stdout.write(JSON.stringify({
type: 'run.completed',
command: 'doctor',
exitCode: result.exitCode,
summary: result.summary,
checks: result.checks,
}) + '\n');
} else {
console.log(result.message);
}
}
return result.exitCode;
}