115d3465b1
- 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
502 lines
15 KiB
TypeScript
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;
|
|
}
|