chore: crush git history - reborn from consolidation on 2026-03-10

This commit is contained in:
John Dvorak
2026-03-10 00:00:00 -07:00
commit d278c4b105
313 changed files with 87549 additions and 0 deletions
+101
View File
@@ -0,0 +1,101 @@
/**
* App loader utility for CLI commands.
* Handles various app export patterns and module systems.
*/
import { resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
export interface LoadedApp {
fastify: unknown
source: 'default' | 'named' | 'commonjs'
}
/**
* Load a Fastify app from app.js in the given directory.
* Supports:
* - ESM default export: export default fastifyInstance
* - ESM named export: export const createApp = () => fastifyInstance
* - CommonJS: module.exports = fastifyInstance
* - CommonJS named: exports.createApp = () => fastifyInstance
*/
export async function loadApp(cwd: string): Promise<LoadedApp> {
const appPath = resolve(cwd, 'app.js')
const appUrl = pathToFileURL(appPath).href + '?t=' + Date.now()
let appModule: Record<string, unknown>
try {
appModule = await import(appUrl) as Record<string, unknown>
} catch (err) {
throw new AppLoadError(
`Cannot load app.js: ${err instanceof Error ? err.message : String(err)}`,
'import_failed',
)
}
// Try default export first
if (appModule.default && isFastifyInstance(appModule.default)) {
return { fastify: appModule.default, source: 'default' }
}
// Try named exports that look like Fastify instances or factory functions
for (const [key, value] of Object.entries(appModule)) {
if (key === 'default') continue
if (isFastifyInstance(value)) {
return { fastify: value, source: 'named' }
}
// Try calling factory functions
if (typeof value === 'function' && !isClass(value)) {
try {
const result = await value()
if (isFastifyInstance(result)) {
return { fastify: result, source: 'named' }
}
} catch {
// Factory function failed, try next
}
}
}
// If module itself is a Fastify instance (CommonJS)
if (isFastifyInstance(appModule)) {
return { fastify: appModule, source: 'commonjs' }
}
throw new AppLoadError(
'No Fastify instance found in app.js. Ensure app.js exports a Fastify instance or a factory function.',
'no_fastify',
)
}
/**
* Check if a value looks like a Fastify instance.
*/
function isFastifyInstance(value: unknown): boolean {
return value !== null &&
typeof value === 'object' &&
typeof (value as Record<string, unknown>).ready === 'function'
}
/**
* Check if a function is a class constructor.
*/
function isClass(fn: unknown): boolean {
return typeof fn === 'function' &&
fn.toString().startsWith('class ')
}
/**
* Error type for app loading failures.
*/
export class AppLoadError extends Error {
constructor(
message: string,
public readonly code: 'import_failed' | 'no_fastify',
) {
super(message)
this.name = 'AppLoadError'
}
}
+330
View File
@@ -0,0 +1,330 @@
/**
* Tests for config-loader.ts
*/
import { test } from 'node:test';
import assert from 'node:assert';
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
discoverConfig,
loadPackageJsonConfig,
loadConfigFile,
validateConfigAgainstSchema,
resolveProfile,
applyEnvironmentOverrides,
detectMonorepo,
loadConfig,
ConfigValidationError,
CONFIG_SCHEMA,
type Config,
} from './config-loader.js';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function createTempDir(): string {
const dir = join(tmpdir(), `apophis-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(dir, { recursive: true });
return dir;
}
function cleanup(dir: string): void {
rmSync(dir, { recursive: true, force: true });
}
// ---------------------------------------------------------------------------
// discoverConfig
// ---------------------------------------------------------------------------
test('discoverConfig finds apophis.config.js', () => {
const dir = createTempDir();
writeFileSync(join(dir, 'apophis.config.js'), 'module.exports = {}');
const result = discoverConfig(dir);
assert.strictEqual(result, join(dir, 'apophis.config.js'));
cleanup(dir);
});
test('discoverConfig finds apophis.config.ts', () => {
const dir = createTempDir();
writeFileSync(join(dir, 'apophis.config.ts'), 'export default {}');
const result = discoverConfig(dir);
assert.strictEqual(result, join(dir, 'apophis.config.ts'));
cleanup(dir);
});
test('discoverConfig finds apophis.config.json', () => {
const dir = createTempDir();
writeFileSync(join(dir, 'apophis.config.json'), '{}');
const result = discoverConfig(dir);
assert.strictEqual(result, join(dir, 'apophis.config.json'));
cleanup(dir);
});
test('discoverConfig returns null when no config found', () => {
const dir = createTempDir();
const result = discoverConfig(dir);
assert.strictEqual(result, null);
cleanup(dir);
});
// ---------------------------------------------------------------------------
// loadPackageJsonConfig
// ---------------------------------------------------------------------------
test('loadPackageJsonConfig finds apophis field', () => {
const dir = createTempDir();
writeFileSync(
join(dir, 'package.json'),
JSON.stringify({ name: 'test', apophis: { mode: 'verify' } }),
);
const result = loadPackageJsonConfig(dir);
assert.deepStrictEqual(result.config, { mode: 'verify' });
assert.strictEqual(result.path, join(dir, 'package.json'));
cleanup(dir);
});
test('loadPackageJsonConfig returns null when no apophis field', () => {
const dir = createTempDir();
writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'test' }));
const result = loadPackageJsonConfig(dir);
assert.strictEqual(result.config, null);
cleanup(dir);
});
// ---------------------------------------------------------------------------
// loadConfigFile
// ---------------------------------------------------------------------------
test('loadConfigFile loads JSON config', async () => {
const dir = createTempDir();
const path = join(dir, 'config.json');
writeFileSync(path, JSON.stringify({ mode: 'verify', seed: 42 }));
const config = await loadConfigFile(path);
assert.deepStrictEqual(config, { mode: 'verify', seed: 42 });
cleanup(dir);
});
// ---------------------------------------------------------------------------
// validateConfigAgainstSchema
// ---------------------------------------------------------------------------
test('validateConfigAgainstSchema passes for valid keys', () => {
const config = {
mode: 'verify',
seed: 42,
routes: ['GET /users'],
};
assert.doesNotThrow(() => validateConfigAgainstSchema(config, CONFIG_SCHEMA));
});
test('validateConfigAgainstSchema fails for unknown top-level key', () => {
const config = {
mode: 'verify',
unknownKey: true,
};
assert.throws(
() => validateConfigAgainstSchema(config, CONFIG_SCHEMA),
(err: unknown) => {
assert.ok(err instanceof ConfigValidationError);
assert.strictEqual((err as ConfigValidationError).path, 'unknownKey');
assert.strictEqual((err as ConfigValidationError).key, 'unknownKey');
return true;
},
);
});
test('validateConfigAgainstSchema fails for unknown nested key', () => {
const config = {
environments: {
local: {
allowedModes: ['verify'],
badKey: true,
},
},
};
assert.throws(
() => validateConfigAgainstSchema(config, CONFIG_SCHEMA),
(err: unknown) => {
assert.ok(err instanceof ConfigValidationError);
assert.ok((err as ConfigValidationError).path.startsWith('environments'));
return true;
},
);
});
// ---------------------------------------------------------------------------
// resolveProfile
// ---------------------------------------------------------------------------
test('resolveProfile returns original config when no profile specified', () => {
const config: Config = { mode: 'verify', seed: 42 };
const result = resolveProfile(config, undefined);
assert.deepStrictEqual(result.config, config);
assert.strictEqual(result.profileName, null);
assert.strictEqual(result.presetName, null);
});
test('resolveProfile applies preset defaults then profile overrides', () => {
const config: Config = {
presets: {
safe: { mode: 'verify', seed: 1 },
},
profiles: {
quick: {
preset: 'safe',
seed: 99,
},
},
};
const result = resolveProfile(config, 'quick');
assert.strictEqual(result.config.mode, 'verify');
assert.strictEqual(result.config.seed, 99);
assert.strictEqual(result.profileName, 'quick');
assert.strictEqual(result.presetName, 'safe');
});
test('resolveProfile throws for unknown profile', () => {
const config = { profiles: {} };
assert.throws(() => resolveProfile(config, 'missing'), /Unknown profile/);
});
// ---------------------------------------------------------------------------
// applyEnvironmentOverrides
// ---------------------------------------------------------------------------
test('applyEnvironmentOverrides returns original config when no env', () => {
const config: Config = { mode: 'verify' };
const result = applyEnvironmentOverrides(config, undefined);
assert.deepStrictEqual(result, config);
});
test('applyEnvironmentOverrides applies env policy', () => {
const config: Config = {
mode: 'verify',
environments: {
staging: { blockQualify: true },
},
};
const result = applyEnvironmentOverrides(config, 'staging');
assert.deepStrictEqual(result.environments?.staging, { blockQualify: true });
});
// ---------------------------------------------------------------------------
// detectMonorepo
// ---------------------------------------------------------------------------
test('detectMonorepo returns true for workspaces', () => {
const dir = createTempDir();
writeFileSync(
join(dir, 'package.json'),
JSON.stringify({ name: 'root', workspaces: ['packages/*'] }),
);
const result = detectMonorepo(dir);
assert.strictEqual(result, true);
cleanup(dir);
});
test('detectMonorepo returns true for pnpm-workspace.yaml', () => {
const dir = createTempDir();
writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'root' }));
writeFileSync(join(dir, 'pnpm-workspace.yaml'), 'packages:\n - packages/*');
const result = detectMonorepo(dir);
assert.strictEqual(result, true);
cleanup(dir);
});
test('detectMonorepo returns false for single package', () => {
const dir = createTempDir();
writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'single' }));
const result = detectMonorepo(dir);
assert.strictEqual(result, false);
cleanup(dir);
});
// ---------------------------------------------------------------------------
// loadConfig (integration)
// ---------------------------------------------------------------------------
test('loadConfig loads JS config file', async () => {
const dir = createTempDir();
writeFileSync(
join(dir, 'apophis.config.js'),
'export default { mode: "verify", seed: 42 }',
);
const result = await loadConfig({ cwd: dir });
assert.strictEqual(result.config.mode, 'verify');
assert.strictEqual(result.config.seed, 42);
assert.strictEqual(result.configPath, join(dir, 'apophis.config.js'));
cleanup(dir);
});
test('loadConfig loads from package.json field', async () => {
const dir = createTempDir();
writeFileSync(
join(dir, 'package.json'),
JSON.stringify({ name: 'test', apophis: { mode: 'observe' } }),
);
const result = await loadConfig({ cwd: dir });
assert.strictEqual(result.config.mode, 'observe');
assert.strictEqual(result.configPath, join(dir, 'package.json'));
cleanup(dir);
});
test('loadConfig rejects unknown keys', async () => {
const dir = createTempDir();
writeFileSync(
join(dir, 'apophis.config.json'),
JSON.stringify({ mode: 'verify', badKey: true }),
);
await assert.rejects(
loadConfig({ cwd: dir }),
(err: unknown) => {
assert.ok(err instanceof ConfigValidationError);
assert.strictEqual((err as ConfigValidationError).path, 'badKey');
return true;
},
);
cleanup(dir);
});
test('loadConfig resolves profile', async () => {
const dir = createTempDir();
writeFileSync(
join(dir, 'apophis.config.json'),
JSON.stringify({
presets: { safe: { mode: 'verify' } },
profiles: { quick: { preset: 'safe', seed: 99 } },
}),
);
const result = await loadConfig({ cwd: dir, profileName: 'quick' });
assert.strictEqual(result.config.mode, 'verify');
assert.strictEqual(result.config.seed, 99);
assert.strictEqual(result.profileName, 'quick');
assert.strictEqual(result.presetName, 'safe');
cleanup(dir);
});
test('loadConfig returns empty config when nothing found', async () => {
const dir = createTempDir();
const result = await loadConfig({ cwd: dir });
assert.deepStrictEqual(result.config, {});
assert.strictEqual(result.configPath, null);
assert.strictEqual(result.isMonorepo, false);
cleanup(dir);
});
test('loadConfig uses explicit --config path', async () => {
const dir = createTempDir();
const subdir = join(dir, 'sub');
mkdirSync(subdir);
writeFileSync(
join(subdir, 'custom.config.js'),
'export default { mode: "qualify" }',
);
const result = await loadConfig({ cwd: dir, configPath: 'sub/custom.config.js' });
assert.strictEqual(result.config.mode, 'qualify');
cleanup(dir);
});
+901
View File
@@ -0,0 +1,901 @@
/**
* Config loader for APOPHIS CLI.
*
* Responsibilities:
* - Config file discovery (.js, .ts, .json, or "apophis" field in package.json)
* - Config loading with tsx for .ts files
* - Profile resolution from config.profiles
* - Preset resolution and application
* - Environment-specific overrides
* - Unknown-key hard failure with exact path
* - Monorepo boundary detection
*/
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { pathToFileURL } from 'node:url';
import { resolvePacks } from '../../protocol-packs/index.js';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface Config {
mode?: 'verify' | 'observe' | 'qualify';
profile?: string;
preset?: string;
routes?: string[];
seed?: number;
artifactDir?: string;
environments?: Record<string, EnvironmentPolicy>;
profiles?: Record<string, ProfileDefinition>;
presets?: Record<string, PresetDefinition>;
generationProfiles?: Record<string, 'quick' | 'standard' | 'thorough' | { base: 'quick' | 'standard' | 'thorough' }>;
[key: string]: unknown;
}
export interface EnvironmentPolicy {
allowedModes?: ('verify' | 'observe' | 'qualify')[];
blockQualify?: boolean;
allowChaosOnProtected?: boolean;
[key: string]: unknown;
}
export interface ProfileDefinition {
preset?: string;
routes?: string[];
seed?: number;
features?: string[];
[key: string]: unknown;
}
export interface PresetDefinition {
mode?: 'verify' | 'observe' | 'qualify';
routes?: string[];
seed?: number;
features?: string[];
[key: string]: unknown;
}
export interface LoadConfigOptions {
cwd: string;
configPath?: string;
profileName?: string;
env?: string;
}
export interface LoadConfigResult {
config: Config;
configPath: string | null;
isMonorepo: boolean;
profileName: string | null;
presetName: string | null;
}
// ---------------------------------------------------------------------------
// Schema definition (TypeBox-style, plain TS for now)
// ---------------------------------------------------------------------------
interface SchemaField {
type: 'string' | 'number' | 'boolean' | 'array' | 'object';
optional?: boolean;
items?: SchemaField;
properties?: Record<string, SchemaField>;
enumValues?: string[];
min?: number;
}
// Schema for top-level config keys
const CONFIG_SCHEMA: Record<string, SchemaField> = {
mode: { type: 'string', optional: true, enumValues: ['verify', 'observe', 'qualify'] },
profile: { type: 'string', optional: true },
preset: { type: 'string', optional: true },
routes: { type: 'array', optional: true, items: { type: 'string' } },
seed: { type: 'number', optional: true },
artifactDir: { type: 'string', optional: true },
environments: {
type: 'object',
optional: true,
},
profiles: {
type: 'object',
optional: true,
properties: {},
},
presets: {
type: 'object',
optional: true,
properties: {},
},
generationProfiles: {
type: 'object',
optional: true,
properties: {},
},
packs: {
type: 'array',
optional: true,
items: { type: 'string' },
},
};
// Schema for EnvironmentPolicy values (inside environments.<name>)
const ENVIRONMENT_POLICY_SCHEMA: Record<string, SchemaField> = {
name: { type: 'string', optional: false },
allowVerify: { type: 'boolean', optional: true },
allowObserve: { type: 'boolean', optional: true },
allowQualify: { type: 'boolean', optional: true },
allowChaos: { type: 'boolean', optional: true },
allowBlocking: { type: 'boolean', optional: true },
requireSink: { type: 'boolean', optional: true },
allowedModes: { type: 'array', optional: true, items: { type: 'string' } },
blockQualify: { type: 'boolean', optional: true },
allowChaosOnProtected: { type: 'boolean', optional: true },
};
// Schema for ProfileDefinition values (inside profiles.<name>)
const PROFILE_SCHEMA: Record<string, SchemaField> = {
name: { type: 'string', optional: false },
mode: { type: 'string', optional: true, enumValues: ['verify', 'observe', 'qualify'] },
preset: { type: 'string', optional: true },
routes: { type: 'array', optional: true, items: { type: 'string' } },
seed: { type: 'number', optional: true },
artifactDir: { type: 'string', optional: true },
environment: { type: 'string', optional: true },
features: { type: 'array', optional: true, items: { type: 'string' } },
sampling: { type: 'number', optional: true },
blocking: { type: 'boolean', optional: true },
sinks: { type: 'object', optional: true },
};
// Schema for PresetDefinition values (inside presets.<name>)
const PRESET_SCHEMA: Record<string, SchemaField> = {
name: { type: 'string', optional: false },
depth: { type: 'string', optional: true, enumValues: ['quick', 'standard', 'deep'] },
timeout: { type: 'number', optional: true, min: 0 },
parallel: { type: 'boolean', optional: true },
chaos: { type: 'boolean', optional: true },
observe: { type: 'boolean', optional: true },
features: { type: 'array', optional: true, items: { type: 'string' } },
sampling: { type: 'number', optional: true },
blocking: { type: 'boolean', optional: true },
sinks: { type: 'object', optional: true },
};
const GENERATION_PROFILE_ALIAS_SCHEMA: Record<string, SchemaField> = {
base: { type: 'string', optional: false, enumValues: ['quick', 'standard', 'thorough'] },
}
// ---------------------------------------------------------------------------
// Config discovery
// ---------------------------------------------------------------------------
const CONFIG_FILES = [
'apophis.config.js',
'apophis.config.ts',
'apophis.config.json',
];
/**
* Discover config file in cwd or return null.
*/
export function discoverConfig(cwd: string): string | null {
for (const file of CONFIG_FILES) {
const fullPath = resolve(cwd, file);
if (existsSync(fullPath)) {
return fullPath;
}
}
return null;
}
/**
* Load package.json and check for "apophis" field.
*/
export function loadPackageJsonConfig(cwd: string): { config: Config | null; path: string | null } {
const pkgPath = resolve(cwd, 'package.json');
if (!existsSync(pkgPath)) {
return { config: null, path: null };
}
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
if (pkg.apophis && typeof pkg.apophis === 'object') {
return { config: pkg.apophis as Config, path: pkgPath };
}
return { config: null, path: pkgPath };
}
// ---------------------------------------------------------------------------
// Config loading
// ---------------------------------------------------------------------------
/**
* Load a config file by path.
* Supports .js, .ts (via dynamic import, assumes tsx available), and .json.
*/
export async function loadConfigFile(configPath: string): Promise<Config> {
if (configPath.endsWith('.json')) {
const content = readFileSync(configPath, 'utf-8');
return JSON.parse(content) as Config;
}
// For .js and .ts, use dynamic import.
// tsx handles .ts files in dev environments.
const fileUrl = pathToFileURL(configPath).href;
const mod = await import(fileUrl);
// Support both default export and direct export
const config = mod.default ?? mod;
if (!config || typeof config !== 'object') {
throw new Error(`Config file at ${configPath} must export an object`);
}
return config as Config;
}
// ---------------------------------------------------------------------------
// Schema validation (unknown-key rejection)
// ---------------------------------------------------------------------------
export class ConfigValidationError extends Error {
constructor(
message: string,
public readonly path: string,
public readonly key: string,
public readonly value?: unknown,
public readonly guidance?: string,
) {
super(message);
this.name = 'ConfigValidationError';
}
}
/**
* Get the appropriate schema for a dynamic container's child objects.
* Returns the schema to validate values inside profiles.<name>, presets.<name>, or environments.<name>.
*/
function getDynamicContainerSchema(path: string): Record<string, SchemaField> | null {
if (path === 'profiles') return PROFILE_SCHEMA;
if (path === 'presets') return PRESET_SCHEMA;
if (path === 'environments') return ENVIRONMENT_POLICY_SCHEMA;
if (path === 'generationProfiles') return GENERATION_PROFILE_ALIAS_SCHEMA;
return null;
}
/**
* Check if a path is inside a dynamic container (e.g., profiles.foo, presets.bar).
*/
function isInsideDynamicContainer(path: string): boolean {
return path.startsWith('profiles.') || path.startsWith('presets.') || path.startsWith('environments.') || path.startsWith('generationProfiles.');
}
/**
* Validate that a value matches the expected type for a schema field.
* Throws ConfigValidationError on type mismatch.
*/
function validateType(
fieldValue: unknown,
fieldSchema: SchemaField,
currentPath: string,
key: string,
): void {
// Null/undefined is only valid if optional
if (fieldValue === null || fieldValue === undefined) {
if (!fieldSchema.optional) {
throw new ConfigValidationError(
`Missing required config key at ${currentPath}`,
currentPath,
key,
fieldValue,
`This field is required. Provide a ${fieldSchema.type} value.`,
);
}
return;
}
const actualType = Array.isArray(fieldValue) ? 'array' : typeof fieldValue;
if (actualType !== fieldSchema.type) {
throw new ConfigValidationError(
`Invalid type at ${currentPath}: expected ${fieldSchema.type}, got ${actualType}`,
currentPath,
key,
fieldValue,
`Expected ${fieldSchema.type}. Received ${actualType === 'object' ? JSON.stringify(fieldValue) : String(fieldValue)}.`,
);
}
// Validate enum values
if (fieldSchema.enumValues && fieldSchema.type === 'string' && typeof fieldValue === 'string') {
if (!fieldSchema.enumValues.includes(fieldValue)) {
throw new ConfigValidationError(
`Invalid value at ${currentPath}: "${fieldValue}" is not a valid ${key}. Allowed: ${fieldSchema.enumValues.join(', ')}`,
currentPath,
key,
fieldValue,
`Must be one of: ${fieldSchema.enumValues.join(', ')}.`,
);
}
}
// Validate numeric constraints
if (fieldSchema.type === 'number' && typeof fieldValue === 'number') {
if (fieldSchema.min !== undefined && fieldValue < fieldSchema.min) {
throw new ConfigValidationError(
`Invalid value at ${currentPath}: ${fieldValue} is less than minimum ${fieldSchema.min}`,
currentPath,
key,
fieldValue,
`Must be >= ${fieldSchema.min}.`,
);
}
}
// Validate array item types
if (fieldSchema.type === 'array' && Array.isArray(fieldValue) && fieldSchema.items) {
for (let i = 0; i < fieldValue.length; i++) {
const item = fieldValue[i];
const itemPath = `${currentPath}[${i}]`;
const itemType = Array.isArray(item) ? 'array' : typeof item;
if (itemType !== fieldSchema.items.type) {
throw new ConfigValidationError(
`Invalid type at ${itemPath}: expected ${fieldSchema.items.type}, got ${itemType}`,
itemPath,
`${key}[${i}]`,
item,
`Array items must be ${fieldSchema.items.type}. Received ${itemType === 'object' ? JSON.stringify(item) : String(item)}.`,
);
}
}
}
}
/**
* Recursively validate an object against a schema.
* Checks:
* - Unknown keys (hard failure)
* - Type mismatches (hard failure)
* - Enum value violations (hard failure)
* - Array item type mismatches (hard failure)
* - Numeric constraints (hard failure)
*
* Throws ConfigValidationError on any validation failure.
*/
export function validateConfigAgainstSchema(
value: unknown,
schema: Record<string, SchemaField>,
path: string = '',
): void {
if (value === null || typeof value !== 'object') {
return;
}
const obj = value as Record<string, unknown>;
for (const key of Object.keys(obj)) {
const currentPath = path ? `${path}.${key}` : key;
const fieldSchema = schema[key];
// Handle dynamic containers: profiles, presets, environments
// The keys are user-defined names; their values have specific schemas
const isDynamicContainer = path === 'profiles' || path === 'presets' || path === 'environments' || path === 'generationProfiles';
if (!fieldSchema && isDynamicContainer) {
const childSchema = getDynamicContainerSchema(path);
const fieldValue = obj[key];
if (path === 'generationProfiles' && typeof fieldValue === 'string') {
validateType(
fieldValue,
{ type: 'string', optional: false, enumValues: ['quick', 'standard', 'thorough'] },
currentPath,
key,
);
} else if (childSchema && fieldValue !== null && typeof fieldValue === 'object') {
// Validate the dynamic container value against its specific schema
validateConfigAgainstSchema(fieldValue, childSchema, currentPath);
} else if (childSchema) {
// Value is a primitive inside a dynamic container — type check it
validateType(fieldValue, { type: 'object', optional: false }, currentPath, key);
}
continue;
}
// Handle nested keys inside profile/preset/environment objects
if (!fieldSchema && isInsideDynamicContainer(path)) {
const parentContainer = path.split('.')[0] || '';
const childSchema = getDynamicContainerSchema(parentContainer);
if (childSchema) {
const nestedSchema = childSchema[key];
if (nestedSchema) {
const fieldValue = obj[key];
validateType(fieldValue, nestedSchema, currentPath, key);
// Recurse into nested objects
if (nestedSchema.type === 'object' && fieldValue !== null && typeof fieldValue === 'object') {
if (nestedSchema.properties) {
validateConfigAgainstSchema(fieldValue, nestedSchema.properties, currentPath);
}
}
if (nestedSchema.type === 'array' && Array.isArray(fieldValue) && nestedSchema.items?.properties) {
for (let i = 0; i < fieldValue.length; i++) {
const item = fieldValue[i];
if (item !== null && typeof item === 'object') {
validateConfigAgainstSchema(item, nestedSchema.items.properties, `${currentPath}[${i}]`);
}
}
}
} else {
// Unknown key inside a profile/preset/environment object
throw new ConfigValidationError(
`Unknown config key at ${currentPath}`,
currentPath,
key,
obj[key],
`Valid keys for ${parentContainer} entries: ${Object.keys(childSchema || {}).join(', ')}.`,
);
}
}
continue;
}
if (!fieldSchema) {
throw new ConfigValidationError(
`Unknown config key at ${currentPath}`,
currentPath,
key,
obj[key],
`Valid top-level keys: ${Object.keys(CONFIG_SCHEMA).join(', ')}.`,
);
}
const fieldValue = obj[key];
// Validate type for known fields
validateType(fieldValue, fieldSchema, currentPath, key);
// Recurse into objects with known properties
if (fieldSchema.type === 'object') {
if (fieldValue !== null && typeof fieldValue === 'object') {
if (fieldSchema.properties) {
validateConfigAgainstSchema(fieldValue, fieldSchema.properties, currentPath);
} else {
// For objects without explicit properties (like profiles/presets/environments),
// we still recurse to validate nested objects, but we pass the same schema
// and the skip logic above will handle dynamic container keys
validateConfigAgainstSchema(fieldValue, schema, currentPath);
}
}
}
// Recurse into array items if they are objects
if (fieldSchema.type === 'array' && fieldSchema.items && Array.isArray(fieldValue)) {
for (let i = 0; i < fieldValue.length; i++) {
const item = fieldValue[i];
if (item !== null && typeof item === 'object' && fieldSchema.items.properties) {
validateConfigAgainstSchema(item, fieldSchema.items.properties, `${currentPath}[${i}]`);
}
}
}
}
}
// ---------------------------------------------------------------------------
// Monorepo detection
// ---------------------------------------------------------------------------
/**
* Check if cwd is inside a monorepo (has workspaces in root package.json).
*/
export function detectMonorepo(cwd: string): boolean {
let current = cwd;
while (current !== dirname(current)) {
const pkgPath = resolve(current, 'package.json');
if (existsSync(pkgPath)) {
try {
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
if (pkg.workspaces && Array.isArray(pkg.workspaces)) {
return true;
}
// Also check for pnpm workspaces
const pnpmWorkspacePath = resolve(current, 'pnpm-workspace.yaml');
if (existsSync(pnpmWorkspacePath)) {
return true;
}
} catch {
// Ignore parse errors
}
// Stop at first package.json found
return false;
}
current = dirname(current);
}
return false;
}
/**
* Find all workspace package directories under cwd.
* Supports npm workspaces (package.json workspaces field) and pnpm-workspace.yaml.
* Returns absolute paths to each package directory.
*/
export function findWorkspacePackages(cwd: string): string[] {
let root = cwd;
while (root !== dirname(root)) {
const pkgPath = resolve(root, 'package.json');
if (existsSync(pkgPath)) {
try {
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
if (pkg.workspaces && Array.isArray(pkg.workspaces)) {
return expandWorkspacePatterns(root, pkg.workspaces);
}
} catch {
// Ignore parse errors
}
// Check for pnpm-workspace.yaml
const pnpmWorkspacePath = resolve(root, 'pnpm-workspace.yaml');
if (existsSync(pnpmWorkspacePath)) {
const patterns = parsePnpmWorkspaceYaml(pnpmWorkspacePath);
return expandWorkspacePatterns(root, patterns);
}
// Stop at first package.json found
return [];
}
root = dirname(root);
}
return [];
}
function expandWorkspacePatterns(root: string, patterns: string[]): string[] {
const packages: string[] = [];
for (const pattern of patterns) {
if (pattern.endsWith('/*')) {
const dir = pattern.slice(0, -2);
const dirPath = resolve(root, 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 {
const exactPath = resolve(root, pattern);
if (existsSync(exactPath)) {
const stat = statSync(exactPath);
if (stat.isDirectory()) {
packages.push(exactPath);
}
}
}
}
return packages;
}
function parsePnpmWorkspaceYaml(yamlPath: string): string[] {
try {
const content = readFileSync(yamlPath, 'utf-8');
const patterns: string[] = [];
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (trimmed.startsWith('- ')) {
patterns.push(trimmed.slice(2).trim());
}
}
return patterns;
} catch {
return [];
}
}
// ---------------------------------------------------------------------------
// Semantic validation (cross-references, value constraints)
// ---------------------------------------------------------------------------
/**
* Validate semantic constraints that go beyond schema types:
* - Profile references nonexistent preset
* - Environment policy references unknown mode
* - Timeout is a positive number
* - Routes array elements are non-empty strings
* - Seed is an integer
*
* Throws ConfigValidationError on any semantic violation.
*/
export function validateConfigSemantics(config: Config): void {
// Validate profile references
if (config.profiles) {
for (const [profileName, profile] of Object.entries(config.profiles)) {
if (profile.preset) {
const availablePresets = config.presets ? Object.keys(config.presets) : [];
if (!config.presets || !(profile.preset in config.presets)) {
throw new ConfigValidationError(
`Profile "${profileName}" references unknown preset "${profile.preset}"`,
`profiles.${profileName}.preset`,
'preset',
profile.preset,
`Available presets: ${availablePresets.join(', ') || 'none'}. Define preset "${profile.preset}" in config.presets.`,
);
}
}
}
}
// Validate preset values
if (config.presets) {
for (const [presetName, preset] of Object.entries(config.presets)) {
if (preset.timeout !== undefined) {
if (typeof preset.timeout !== 'number' || preset.timeout < 0) {
throw new ConfigValidationError(
`Preset "${presetName}" has invalid timeout: ${preset.timeout}`,
`presets.${presetName}.timeout`,
'timeout',
preset.timeout,
`Timeout must be a non-negative number (milliseconds).`,
);
}
}
if (preset.depth !== undefined) {
const validDepths = ['quick', 'standard', 'deep'];
const depthValue = preset.depth;
if (typeof depthValue === 'string' && !validDepths.includes(depthValue as string)) {
throw new ConfigValidationError(
`Preset "${presetName}" has invalid depth: "${depthValue}"`,
`presets.${presetName}.depth`,
'depth',
depthValue,
`Must be one of: ${validDepths.join(', ')}.`,
);
}
}
}
}
// Validate environment policy allowedModes
if (config.environments) {
for (const [envName, envPolicy] of Object.entries(config.environments)) {
if (envPolicy.allowedModes) {
const validModes = ['verify', 'observe', 'qualify'];
for (const mode of envPolicy.allowedModes) {
if (!validModes.includes(mode)) {
throw new ConfigValidationError(
`Environment "${envName}" has invalid allowedMode: "${mode}"`,
`environments.${envName}.allowedModes`,
'allowedModes',
mode,
`Allowed modes must be one of: ${validModes.join(', ')}.`,
);
}
}
}
}
}
// Validate routes are non-empty strings
if (config.routes) {
for (let i = 0; i < config.routes.length; i++) {
const route = config.routes[i];
if (typeof route !== 'string' || route.trim().length === 0) {
throw new ConfigValidationError(
`Invalid route at routes[${i}]: ${JSON.stringify(route)}`,
`routes[${i}]`,
'routes',
route,
`Routes must be non-empty strings like "GET /users" or "POST /api/items".`,
);
}
}
}
// Validate seed is an integer
if (config.seed !== undefined) {
if (typeof config.seed !== 'number' || !Number.isInteger(config.seed)) {
throw new ConfigValidationError(
`Invalid seed: ${config.seed}`,
'seed',
'seed',
config.seed,
`Seed must be an integer number.`,
);
}
}
}
// ---------------------------------------------------------------------------
// Profile and preset resolution
// ---------------------------------------------------------------------------
/**
* Resolve profile from config.profiles.
* Returns merged config: preset defaults + profile overrides.
*/
export function resolveProfile(
config: Config,
profileName: string | undefined,
): { config: Config; profileName: string | null; presetName: string | null } {
if (!profileName) {
return { config, profileName: null, presetName: config.preset ?? null };
}
const profiles = config.profiles ?? {};
const profile = profiles[profileName];
if (!profile) {
const available = Object.keys(profiles).join(', ');
throw new Error(
`Unknown profile "${profileName}". Available profiles: ${available || 'none'}.`,
);
}
// Start with preset if profile references one
let merged: Config = { ...config };
let presetName: string | null = null;
if (profile.preset && config.presets) {
const preset = config.presets[profile.preset];
if (preset) {
merged = { ...merged, ...preset };
presetName = profile.preset;
}
}
// Apply profile overrides
merged = {
...merged,
...profile,
// Don't overwrite the top-level preset with the profile's preset string
preset: profile.preset ? undefined : merged.preset,
};
// Clean up undefined values
if (merged.preset === undefined) {
delete merged.preset;
}
return { config: merged, profileName, presetName };
}
// ---------------------------------------------------------------------------
// Environment-specific overrides
// ---------------------------------------------------------------------------
/**
* Apply environment-specific policy overrides.
*/
export function applyEnvironmentOverrides(
config: Config,
env: string | undefined,
): Config {
if (!env || !config.environments) {
return config;
}
const envPolicy = config.environments[env];
if (!envPolicy) {
return config;
}
// Environment policy doesn't override config values directly,
// but we merge it for policy engine consumption
return {
...config,
environments: {
...config.environments,
[env]: envPolicy,
},
};
}
// ---------------------------------------------------------------------------
// Main entry point
// ---------------------------------------------------------------------------
/**
* Load and resolve config for the CLI.
*
* Discovery order:
* 1. --config override
* 2. apophis.config.js
* 3. apophis.config.ts
* 4. apophis.config.json
* 5. "apophis" field in package.json
*
* Resolution order:
* 1. Load raw config
* 2. Validate against schema (unknown keys = hard failure)
* 3. Resolve profile (preset defaults + profile overrides)
* 4. Apply environment-specific overrides
* 5. Detect monorepo
*/
export async function loadConfig(options: LoadConfigOptions): Promise<LoadConfigResult> {
const { cwd, configPath: explicitPath, profileName, env } = options;
let configPath: string | null = null;
let rawConfig: Config;
// 1. Explicit --config override
if (explicitPath) {
const resolvedPath = resolve(cwd, explicitPath);
if (!existsSync(resolvedPath)) {
throw new Error(`Config file not found: ${resolvedPath}`);
}
configPath = resolvedPath;
rawConfig = await loadConfigFile(resolvedPath);
} else {
// 2. Discover config file
const discoveredPath = discoverConfig(cwd);
if (discoveredPath) {
configPath = discoveredPath;
rawConfig = await loadConfigFile(discoveredPath);
} else {
// 3. Check package.json "apophis" field
const pkgConfig = loadPackageJsonConfig(cwd);
if (pkgConfig.config) {
configPath = pkgConfig.path;
rawConfig = pkgConfig.config;
} else {
// No config found
return {
config: {},
configPath: null,
isMonorepo: detectMonorepo(cwd),
profileName: null,
presetName: null,
};
}
}
}
// 4. Resolve protocol packs if specified
if (rawConfig.packs && Array.isArray(rawConfig.packs) && rawConfig.packs.length > 0) {
const packFragment = resolvePacks(rawConfig.packs as string[], {
seed: rawConfig.seed,
});
rawConfig = {
...packFragment,
...rawConfig,
profiles: {
...packFragment.profiles,
...rawConfig.profiles,
},
presets: {
...packFragment.presets,
...rawConfig.presets,
},
environments: {
...packFragment.environments,
...rawConfig.environments,
},
};
}
// 5. Validate against schema (unknown keys = hard failure with exact path)
validateConfigAgainstSchema(rawConfig, CONFIG_SCHEMA);
// 5b. Validate semantic constraints (cross-references, value constraints)
validateConfigSemantics(rawConfig);
// 5. Resolve profile and preset
const { config: profiledConfig, profileName: resolvedProfile, presetName } = resolveProfile(
rawConfig,
profileName,
);
// 6. Apply environment overrides
const envConfig = applyEnvironmentOverrides(profiledConfig, env);
// 7. Detect monorepo
const isMonorepo = detectMonorepo(cwd);
return {
config: envConfig,
configPath,
isMonorepo,
profileName: resolvedProfile,
presetName,
};
}
// ---------------------------------------------------------------------------
// Re-export for convenience
// ---------------------------------------------------------------------------
export { CONFIG_SCHEMA };
+130
View File
@@ -0,0 +1,130 @@
import { resolve } from 'node:path';
import type { CliContext } from './types.js';
export type { CliContext } from './types.js';
import { existsSync, readFileSync } from 'node:fs';
function detectPackageManager(cwd: string): CliContext['packageManager'] {
// Check for lock files in cwd
if (existsSync(resolve(cwd, 'bun.lockb')) || existsSync(resolve(cwd, 'bun.lock'))) {
return 'bun';
}
if (existsSync(resolve(cwd, 'pnpm-lock.yaml'))) {
return 'pnpm';
}
if (existsSync(resolve(cwd, 'yarn.lock'))) {
return 'yarn';
}
if (existsSync(resolve(cwd, 'package-lock.json'))) {
return 'npm';
}
// Check package.json packageManager field
const packageJsonPath = resolve(cwd, 'package.json');
if (existsSync(packageJsonPath)) {
try {
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')) as { packageManager?: string };
const packageManager = packageJson.packageManager || '';
if (packageManager.startsWith('bun@')) return 'bun';
if (packageManager.startsWith('pnpm@')) return 'pnpm';
if (packageManager.startsWith('yarn@')) return 'yarn';
if (packageManager.startsWith('npm@')) return 'npm';
} catch {
// Ignore parse errors
}
}
// Check environment variables
if (process.env.npm_config_user_agent) {
const ua = process.env.npm_config_user_agent;
if (ua.includes('bun')) return 'bun';
if (ua.includes('pnpm')) return 'pnpm';
if (ua.includes('yarn')) return 'yarn';
if (ua.includes('npm')) return 'npm';
}
return 'unknown';
}
function detectCI(): boolean {
const ciEnvVars = [
'CI',
'GITHUB_ACTIONS',
'GITLAB_CI',
'CIRCLECI',
'TRAVIS',
'APPVEYOR',
'BUILDKITE',
'DRONE',
'JENKINS_URL',
'TF_BUILD',
'CODEBUILD_BUILD_ID',
'TEAMCITY_VERSION',
'SEMAPHORE',
'WERCKER',
'MAGNUM',
'SNAP_CI',
'BUDDY',
'BUILDBOX',
'AGOLA',
'WOODPECKER',
];
return ciEnvVars.some(varName => process.env[varName] !== undefined);
}
export function createContext(options: Record<string, unknown> = {}): CliContext {
// Detect cwd (respect --cwd override)
const cwd = typeof options.cwd === 'string'
? resolve(options.cwd)
: process.cwd();
// Detect environment
const nodeEnv = process.env.NODE_ENV;
const apophisEnv = process.env.APOPHIS_ENV;
// Detect TTY
const isTTY = process.stdout.isTTY === true;
// Detect CI
const isCI = detectCI();
// Package manager detection
const packageManager = detectPackageManager(cwd);
// Normalize options
const format = options.format === 'json' || options.format === 'ndjson'
? options.format
: 'human';
const color = options.color === 'always' || options.color === 'never'
? options.color
: 'auto';
const generationProfile = typeof options.generationProfile === 'string'
? options.generationProfile
: undefined;
return {
cwd,
env: {
nodeEnv,
apophisEnv,
},
isTTY,
isCI,
nodeVersion: process.version,
packageManager,
selfPath: process.argv[1],
options: {
config: typeof options.config === 'string' ? options.config : undefined,
profile: typeof options.profile === 'string' ? options.profile : undefined,
generationProfile,
format,
color,
quiet: options.quiet === true,
verbose: options.verbose === true,
artifactDir: typeof options.artifactDir === 'string' ? options.artifactDir : undefined,
},
};
}
+74
View File
@@ -0,0 +1,74 @@
/**
* E0-3 / E6-1 Error Taxonomy and Precedence
*
* Taxonomic classes for failures encountered during CLI execution.
* Precedence is lowest-numbered wins (parse before discovery before runtime).
*/
export const ErrorTaxonomy = {
PARSE: 'parse',
IMPORT: 'import',
LOAD: 'load',
DISCOVERY: 'discovery',
RUNTIME: 'runtime',
USAGE: 'usage',
} as const;
export type ErrorCategory = (typeof ErrorTaxonomy)[keyof typeof ErrorTaxonomy];
/** Precedence order: lower index = higher priority. */
export const PRECEDENCE: readonly ErrorCategory[] = [
ErrorTaxonomy.PARSE,
ErrorTaxonomy.IMPORT,
ErrorTaxonomy.LOAD,
ErrorTaxonomy.DISCOVERY,
ErrorTaxonomy.USAGE,
ErrorTaxonomy.RUNTIME,
] as const;
/** Map a raw Error or string to its taxonomic category. */
export function classifyError(err: unknown): ErrorCategory {
const msg = err instanceof Error ? err.message : String(err);
const lower = msg.toLowerCase();
if (lower.includes('parse') || lower.includes('syntax') || lower.includes('unexpected token')) {
return ErrorTaxonomy.PARSE;
}
if (lower.includes('import') || lower.includes('cannot find module') || lower.includes('module not found')) {
return ErrorTaxonomy.IMPORT;
}
if (lower.includes('load') || lower.includes('config') || lower.includes('profile') || lower.includes('cannot read')) {
return ErrorTaxonomy.LOAD;
}
if (lower.includes('discovery') || lower.includes('duplicate') || lower.includes('already added') || lower.includes('decorator')) {
return ErrorTaxonomy.DISCOVERY;
}
if (lower.includes('usage') || lower.includes('argument') || lower.includes('flag') || lower.includes('unknown option') || lower.includes('required')) {
return ErrorTaxonomy.USAGE;
}
return ErrorTaxonomy.RUNTIME;
}
/** Return the highest-precedence (most important) category from a set. */
export function highestPrecedence(categories: ErrorCategory[]): ErrorCategory | undefined {
if (categories.length === 0) return undefined;
return categories.reduce((best, cat) => {
const bestIdx = PRECEDENCE.indexOf(best);
const catIdx = PRECEDENCE.indexOf(cat);
return catIdx < bestIdx ? cat : best;
});
}
/** Attach taxonomy to any diagnostic shape. */
export interface TaxonomicDiagnostic {
category: ErrorCategory;
message: string;
details?: string;
remediation?: string;
}
export function makeDiagnostic(err: unknown, overrideCategory?: ErrorCategory): TaxonomicDiagnostic {
const category = overrideCategory ?? classifyError(err);
const message = err instanceof Error ? err.message : String(err);
return { category, message };
}
+10
View File
@@ -0,0 +1,10 @@
/**
* S0: Spec Authority - Exit code constants
* Frozen contract. All implementation streams must use these constants.
*/
export const SUCCESS = 0;
export const BEHAVIORAL_FAILURE = 1;
export const USAGE_ERROR = 2;
export const INTERNAL_ERROR = 3;
export const INTERRUPTED = 130;
+51
View File
@@ -0,0 +1,51 @@
import type { Config } from './config-loader.js'
export type ResolvedGenerationProfile = 'quick' | 'standard' | 'thorough'
export class GenerationProfileResolutionError extends Error {
constructor(message: string) {
super(message)
this.name = 'GenerationProfileResolutionError'
}
}
function isBuiltInProfile(value: string): value is ResolvedGenerationProfile {
return value === 'quick' || value === 'standard' || value === 'thorough'
}
export function resolveGenerationProfileOverride(
rawProfile: string | undefined,
config: Config,
): ResolvedGenerationProfile | undefined {
if (!rawProfile) {
return undefined
}
if (isBuiltInProfile(rawProfile)) {
return rawProfile
}
const aliases = config.generationProfiles
if (!aliases) {
throw new GenerationProfileResolutionError(
`Unknown generation profile "${rawProfile}". Use one of: quick, standard, thorough, or define an alias in config.generationProfiles.`,
)
}
const alias = aliases[rawProfile]
if (!alias) {
const available = Object.keys(aliases).join(', ') || 'none'
throw new GenerationProfileResolutionError(
`Unknown generation profile "${rawProfile}". Built-ins: quick, standard, thorough. Config aliases: ${available}.`,
)
}
const target = typeof alias === 'string' ? alias : alias.base
if (!isBuiltInProfile(target)) {
throw new GenerationProfileResolutionError(
`Invalid generation profile alias "${rawProfile}". Alias must resolve to quick, standard, or thorough.`,
)
}
return target
}
+458
View File
@@ -0,0 +1,458 @@
import { cac } from 'cac';
import pc from 'picocolors';
import { createContext, type CliContext } from './context.js';
const CLI_VERSION = '2.0.0';
const HELP_HEADER = `
${pc.bold('apophis')} — Contract-driven API testing for Fastify
${pc.dim('Usage:')}
apophis <command> [options]
${pc.dim('Commands:')}
init Scaffold config, scripts, and example usage
verify Run deterministic contract verification
observe Validate runtime observe configuration and reporting setup
qualify Run scenario, stateful, protocol, or chaos-driven qualification
replay Replay a failure using seed and stored trace
doctor Validate config, environment safety, docs/example correctness
migrate Check and rewrite deprecated config or API usage
${pc.dim('Global Options:')}
--config <path> Config file path
--profile <name> Profile name from config
--generation-profile <name> Generation budget profile (built-in or config alias)
--cwd <path> Working directory override
--format <mode> Output format: human | json | ndjson (default: human)
--color <mode> Color mode: auto | always | never (default: auto)
--quiet Suppress non-error output
--verbose Enable verbose logging
--artifact-dir <path> Directory for artifact output
--workspace Run command across all workspace packages
${pc.dim('Other:')}
-v, --version Show version number
-h, --help Show help
${pc.dim('Examples:')}
apophis init --preset safe-ci
apophis verify --profile quick --routes "POST /users"
apophis observe --profile staging-observe --check-config
apophis qualify --profile oauth-nightly --seed 42
apophis replay --artifact reports/apophis/failure-*.json
apophis doctor
apophis doctor --workspace
apophis migrate --dry-run
`;
function getCommandHelp(command: string): string {
const helps: Record<string, string> = {
init: `
${pc.bold('apophis init')} — Scaffold config, scripts, and example usage
${pc.dim('Usage:')}
apophis init [options]
${pc.dim('Options:')}
--preset <name> Preset name (e.g. safe-ci, full)
--force Overwrite existing files
--noninteractive Skip all prompts, require explicit flags
${pc.dim('Examples:')}
apophis init --preset safe-ci
apophis init --force --noninteractive
`,
verify: `
${pc.bold('apophis verify')} — Run deterministic contract verification
${pc.dim('Usage:')}
apophis verify [options]
${pc.dim('Options:')}
--profile <name> Profile name from config
--generation-profile <name> Generation budget profile (built-in or config alias)
--routes <filter> Route filter pattern
--seed <number> Deterministic seed
--changed Filter to git-modified routes
${pc.dim('Examples:')}
apophis verify --profile quick
apophis verify --routes "POST /users" --seed 42
apophis verify --changed
`,
observe: `
${pc.bold('apophis observe')} — Validate runtime observe configuration and reporting setup
${pc.dim('Usage:')}
apophis observe [options]
${pc.dim('Options:')}
--profile <name> Profile name from config
--check-config Only validate, do not activate
${pc.dim('Examples:')}
apophis observe --profile staging-observe
apophis observe --check-config
`,
qualify: `
${pc.bold('apophis qualify')} — Run scenario, stateful, protocol, or chaos-driven qualification
${pc.dim('Usage:')}
apophis qualify [options]
${pc.dim('Options:')}
--profile <name> Profile name from config
--generation-profile <name> Generation budget profile (built-in or config alias)
--seed <number> Deterministic seed
${pc.dim('Examples:')}
apophis qualify --profile oauth-nightly --seed 42
`,
replay: `
${pc.bold('apophis replay')} — Replay a failure using seed and stored trace
${pc.dim('Usage:')}
apophis replay --artifact <path>
${pc.dim('Options:')}
--artifact <path> Path to failure artifact
${pc.dim('Examples:')}
apophis replay --artifact reports/apophis/failure-*.json
`,
doctor: `
${pc.bold('apophis doctor')} — Validate config, environment safety, docs/example correctness
${pc.dim('Usage:')}
apophis doctor [options]
${pc.dim('Options:')}
--mode <mode> Focus checks on a mode: verify | observe | qualify
--strict Treat warnings as failures
${pc.dim('Examples:')}
apophis doctor
apophis doctor --mode verify
apophis doctor --strict
`,
migrate: `
${pc.bold('apophis migrate')} — Check and rewrite deprecated config or API usage
${pc.dim('Usage:')}
apophis migrate [options]
${pc.dim('Options:')}
--check Detect legacy config without rewriting
--dry-run Show exact rewrites without writing
--write Perform rewrites
${pc.dim('Examples:')}
apophis migrate --check
apophis migrate --dry-run
apophis migrate --write
`,
};
return helps[command] || '';
}
function printInternalError(error: unknown): void {
console.error();
console.error(pc.red(' ╔══════════════════════════════════════════════════════════════╗'));
console.error(pc.red(' ║ INTERNAL APOPHIS ERROR ║'));
console.error(pc.red(' ╠══════════════════════════════════════════════════════════════╣'));
console.error(pc.red(`${String(error).slice(0, 56).padEnd(56)}`));
console.error(pc.red(' ╚══════════════════════════════════════════════════════════════╝'));
console.error();
console.error(pc.dim(' This is a bug in APOPHIS. Please report it with the full error'));
console.error(pc.dim(' message and the command you ran.'));
console.error();
}
function resolveRequestedFormat(argv: string[]): 'human' | 'json' | 'ndjson' {
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (!arg) continue;
if (arg === '--format' && argv[i + 1]) {
const value = argv[i + 1];
if (value === 'json' || value === 'ndjson') return value;
return 'human';
}
if (arg.startsWith('--format=')) {
const value = arg.slice('--format='.length);
if (value === 'json' || value === 'ndjson') return value;
return 'human';
}
}
return 'human';
}
function writeMachineRecord(
format: 'json' | 'ndjson',
payload: Record<string, unknown>,
): void {
if (format === 'json') {
process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
return;
}
process.stdout.write(JSON.stringify(payload) + '\n');
}
type CommandName = 'init' | 'verify' | 'observe' | 'qualify' | 'replay' | 'doctor' | 'migrate';
type CommandHandler = (args: string[], ctx: CliContext) => Promise<number>;
const commandLoaders: Record<CommandName, () => Promise<CommandHandler>> = {
init: async () => (await import('../commands/init/index.js')).handleInit,
verify: async () => (await import('../commands/verify/index.js')).handleVerify,
observe: async () => (await import('../commands/observe/index.js')).handleObserve,
qualify: async () => (await import('../commands/qualify/index.js')).handleQualify,
replay: async () => (await import('../commands/replay/index.js')).handleReplay,
doctor: async () => (await import('../commands/doctor/index.js')).handleDoctor,
migrate: async () => (await import('../commands/migrate/index.js')).handleMigrate,
};
async function loadHandler(command: string): Promise<CommandHandler | undefined> {
const loader = commandLoaders[command as CommandName];
return loader ? loader() : undefined;
}
export async function main(argv: string[] = process.argv.slice(2)): Promise<number> {
const cli = cac('apophis');
const requestedFormat = resolveRequestedFormat(argv);
const machineMode = requestedFormat === 'json' || requestedFormat === 'ndjson';
// Global flags
cli.option('--config <path>', 'Config file path');
cli.option('--profile <name>', 'Profile name from config');
cli.option('--generation-profile <name>', 'Generation budget profile (built-in or config alias)');
cli.option('--cwd <path>', 'Working directory override');
cli.option('--format <mode>', 'Output format: human | json | ndjson', { default: 'human' });
cli.option('--color <mode>', 'Color mode: auto | always | never', { default: 'auto' });
cli.option('--quiet', 'Suppress non-error output');
cli.option('--verbose', 'Enable verbose logging');
cli.option('--artifact-dir <path>', 'Directory for artifact output');
cli.option('--workspace', 'Run command across all workspace packages');
// Version
cli.version(CLI_VERSION);
// Override help to use our custom format
// Note: cac's help() returns the CAC instance for chaining, but we just want to print
cli.help = () => {
console.log(HELP_HEADER);
return cli;
};
// Prevent cac from handling --version (we handle it manually)
// cac.version() registers --version but we intercept it before cac processes it
// Commands
const commands = [
'init',
'verify',
'observe',
'qualify',
'replay',
'doctor',
'migrate',
];
for (const command of commands) {
const cmd = cli.command(command, getCommandHelp(command).split('\n')[1]?.trim() || `${command} command`);
// Add command-specific options
switch (command) {
case 'init':
cmd.option('--preset <name>', 'Preset name (e.g. safe-ci, full)');
cmd.option('--force', 'Overwrite existing files');
cmd.option('--noninteractive', 'Skip all prompts, require explicit flags');
break;
case 'verify':
cmd.option('--profile <name>', 'Profile name from config');
cmd.option('--generation-profile <name>', 'Generation budget profile (built-in or config alias)');
cmd.option('--routes <filter>', 'Route filter pattern');
cmd.option('--seed <number>', 'Deterministic seed');
cmd.option('--changed', 'Filter to git-modified routes');
break;
case 'observe':
cmd.option('--profile <name>', 'Profile name from config');
cmd.option('--check-config', 'Only validate, do not activate');
break;
case 'qualify':
cmd.option('--profile <name>', 'Profile name from config');
cmd.option('--generation-profile <name>', 'Generation budget profile (built-in or config alias)');
cmd.option('--seed <number>', 'Deterministic seed');
break;
case 'replay':
cmd.option('--artifact <path>', 'Path to failure artifact');
break;
case 'doctor':
cmd.option('--mode <mode>', 'Focus checks on a specific mode: verify | observe | qualify');
cmd.option('--strict', 'Treat warnings as failures');
break;
case 'migrate':
cmd.option('--check', 'Detect legacy config without rewriting');
cmd.option('--dry-run', 'Show exact rewrites without writing');
cmd.option('--write', 'Perform rewrites');
break;
}
cmd.action(async (options) => {
const ctx = createContext(options);
const handler = await loadHandler(command);
if (!handler) {
console.error(pc.red(`Unknown command: ${command}`));
return 2;
}
// Pass raw argv so doctor/migrate can parse extra flags
const result = await handler(argv, ctx);
// Ensure we always return a number (cac may swallow undefined)
return typeof result === 'number' ? result : 0;
});
}
try {
// Handle --help globally (before parsing)
if (argv.includes('-h') || argv.includes('--help')) {
const commandArg = argv.find(arg => commands.includes(arg));
if (commandArg) {
const helpText = getCommandHelp(commandArg);
if (helpText) {
if (machineMode) {
writeMachineRecord(requestedFormat, {
command: commandArg,
help: helpText,
});
} else {
console.log(helpText);
}
return 0;
}
}
if (machineMode) {
writeMachineRecord(requestedFormat, { help: HELP_HEADER });
} else {
cli.help();
}
return 0;
}
// Handle --version (before parsing)
if (argv.includes('-v') || argv.includes('--version')) {
if (machineMode) {
writeMachineRecord(requestedFormat, { version: CLI_VERSION });
} else {
console.log(CLI_VERSION);
}
return 0;
}
// Check for unknown commands
const firstArg = argv[0];
if (firstArg && !firstArg.startsWith('-') && !commands.includes(firstArg)) {
if (machineMode) {
writeMachineRecord(requestedFormat, {
error: `Unknown command: ${firstArg}`,
availableCommands: commands,
next: 'Run "apophis --help" for usage information.',
});
} else {
console.error(pc.red(`Unknown command: ${firstArg}`));
console.error();
console.error(pc.dim('Available commands:'));
for (const cmd of commands) {
console.error(pc.dim(` ${cmd}`));
}
console.error();
console.error(pc.dim('Run "apophis --help" for usage information.'));
}
return 2;
}
// Handle unknown flags
const knownGlobalFlags = new Set([
'--config', '--profile', '--cwd', '--format', '--color',
'--generation-profile',
'--quiet', '--verbose', '--artifact-dir', '--workspace',
'-v', '--version', '-h', '--help',
]);
const commandSpecificFlags: Record<string, Set<string>> = {
init: new Set(['--preset', '--force', '--noninteractive']),
verify: new Set(['--profile', '--generation-profile', '--routes', '--seed', '--changed', '--workspace']),
observe: new Set(['--profile', '--check-config', '--workspace']),
qualify: new Set(['--profile', '--generation-profile', '--seed', '--workspace']),
replay: new Set(['--artifact']),
doctor: new Set(['--mode', '--strict', '--workspace']),
migrate: new Set(['--check', '--dry-run', '--write']),
};
const activeCommand = firstArg && commands.includes(firstArg) ? firstArg : undefined;
const activeCmdFlags = activeCommand ? commandSpecificFlags[activeCommand] : undefined;
const allowedFlags = activeCmdFlags
? new Set([...knownGlobalFlags, ...activeCmdFlags])
: knownGlobalFlags;
const unknownFlags: string[] = [];
for (const arg of argv) {
if (arg.startsWith('--') || (arg.startsWith('-') && arg.length > 1)) {
const flagName = arg.split('=')[0]!;
if (!allowedFlags.has(flagName)) {
unknownFlags.push(flagName);
}
}
}
if (unknownFlags.length > 0) {
if (machineMode) {
writeMachineRecord(requestedFormat, {
error: `Unknown flag: ${unknownFlags[0]}`,
next: 'Run "apophis --help" for available options.',
});
} else {
console.error(pc.red(`Unknown flag: ${unknownFlags[0]}`));
console.error();
console.error(pc.dim('Run "apophis --help" for available options.'));
}
return 2;
}
// If no command provided, show help
if (!firstArg || firstArg.startsWith('-')) {
if (machineMode) {
writeMachineRecord(requestedFormat, { help: HELP_HEADER });
} else {
cli.help();
}
return 0;
}
// Parse options for the command
const parsed = cli.parse(['node', 'apophis', ...argv], { run: false });
// Directly dispatch to handler (bypass cac's runMatchedCommand which has issues)
const handler = await loadHandler(firstArg);
if (!handler) {
console.error(pc.red(`Unknown command: ${firstArg}`));
return 2;
}
const ctx = createContext(parsed.options);
const result = await handler(argv, ctx);
return typeof result === 'number' ? result : 0;
} catch (error) {
if (machineMode) {
writeMachineRecord(requestedFormat, {
error: 'Internal APOPHIS error',
detail: String(error),
});
} else {
printInternalError(error);
}
return 3;
}
}
// src/cli/core/index.ts is the CLI logic module. The direct entrypoint is src/cli/index.ts.
// Do NOT add a direct main() call here — that belongs in the entrypoint file only.
+296
View File
@@ -0,0 +1,296 @@
/**
* Tests for policy-engine.ts
*/
import { test } from 'node:test';
import assert from 'node:assert';
import {
PolicyEngine,
isModeAllowed,
checkProfile,
detectEnvironment,
} from './policy-engine.js';
import type { Config } from './config-loader.js';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function createConfig(overrides: Partial<Config> = {}): Config {
return {
mode: 'verify',
profiles: {},
presets: {},
environments: {},
...overrides,
};
}
// ---------------------------------------------------------------------------
// PolicyEngine.check
// ---------------------------------------------------------------------------
test('verify allowed in local', () => {
const engine = new PolicyEngine({
config: createConfig(),
env: 'local',
mode: 'verify',
});
const result = engine.check();
assert.strictEqual(result.allowed, true);
assert.strictEqual(result.errors.length, 0);
});
test('qualify blocked in production', () => {
const engine = new PolicyEngine({
config: createConfig(),
env: 'production',
mode: 'qualify',
});
const result = engine.check();
assert.strictEqual(result.allowed, false);
assert.ok(result.errors.some((e) => e.includes('Qualify mode is blocked')));
});
test('observe allowed in production with warning', () => {
const engine = new PolicyEngine({
config: createConfig(),
env: 'production',
mode: 'observe',
});
const result = engine.check();
assert.strictEqual(result.allowed, true);
assert.ok(result.warnings.some((w) => w.includes('production')));
});
test('qualify allowed in local', () => {
const engine = new PolicyEngine({
config: createConfig(),
env: 'local',
mode: 'qualify',
});
const result = engine.check();
assert.strictEqual(result.allowed, true);
});
test('qualify allowed in staging', () => {
const engine = new PolicyEngine({
config: createConfig(),
env: 'staging',
mode: 'qualify',
});
const result = engine.check();
assert.strictEqual(result.allowed, true);
});
// ---------------------------------------------------------------------------
// Profile feature checks
// ---------------------------------------------------------------------------
test('profile with chaos blocked in production', () => {
const config = createConfig({
profiles: {
chaos: {
features: ['chaos'],
},
},
});
const engine = new PolicyEngine({
config,
env: 'production',
mode: 'qualify',
profileName: 'chaos',
});
const result = engine.check();
assert.strictEqual(result.allowed, false);
assert.ok(result.errors.some((e) => e.includes('Chaos on protected routes')));
});
test('profile with chaos allowed in local', () => {
const config = createConfig({
profiles: {
chaos: {
features: ['chaos'],
},
},
});
const engine = new PolicyEngine({
config,
env: 'local',
mode: 'qualify',
profileName: 'chaos',
});
const result = engine.check();
assert.strictEqual(result.allowed, true);
});
test('qualify-only feature in verify mode is blocked', () => {
const config = createConfig({
profiles: {
bad: {
features: ['stateful'],
},
},
});
const engine = new PolicyEngine({
config,
env: 'local',
mode: 'verify',
profileName: 'bad',
});
const result = engine.check();
assert.strictEqual(result.allowed, false);
assert.ok(result.errors.some((e) => e.includes('qualify-only')));
});
// ---------------------------------------------------------------------------
// Preset/profile combination
// ---------------------------------------------------------------------------
test('unknown preset referenced by profile is blocked', () => {
const config = createConfig({
profiles: {
quick: {
preset: 'missing',
},
},
});
const engine = new PolicyEngine({
config,
env: 'local',
mode: 'verify',
profileName: 'quick',
presetName: 'missing',
});
const result = engine.check();
assert.strictEqual(result.allowed, false);
assert.ok(result.errors.some((e) => e.includes('Unknown preset')));
});
test('preset mode mismatch produces warning', () => {
const config = createConfig({
presets: {
safe: { mode: 'observe' },
},
profiles: {
quick: {
preset: 'safe',
},
},
});
const engine = new PolicyEngine({
config,
env: 'local',
mode: 'verify',
profileName: 'quick',
presetName: 'safe',
});
const result = engine.check();
assert.strictEqual(result.allowed, true);
assert.ok(result.warnings.some((w) => w.includes('mode')));
});
// ---------------------------------------------------------------------------
// isModeAllowed
// ---------------------------------------------------------------------------
test('isModeAllowed for verify in local', () => {
assert.strictEqual(isModeAllowed('verify', 'local'), true);
});
test('isModeAllowed for qualify in production', () => {
assert.strictEqual(isModeAllowed('qualify', 'production'), false);
});
test('isModeAllowed for observe in production', () => {
assert.strictEqual(isModeAllowed('observe', 'production'), true);
});
// ---------------------------------------------------------------------------
// checkProfile
// ---------------------------------------------------------------------------
test('checkProfile with chaos in production', () => {
const config = createConfig({
profiles: {
nightly: {
features: ['chaos', 'scenario'],
},
},
});
const result = checkProfile('nightly', config, 'production', 'qualify');
assert.strictEqual(result.allowed, false);
assert.ok(result.errors.length > 0);
});
// ---------------------------------------------------------------------------
// detectEnvironment
// ---------------------------------------------------------------------------
test('detectEnvironment reads NODE_ENV', () => {
const original = process.env.NODE_ENV;
process.env.NODE_ENV = 'test';
assert.strictEqual(detectEnvironment(), 'test');
process.env.NODE_ENV = original;
});
test('detectEnvironment defaults to local', () => {
const original = process.env.NODE_ENV;
delete process.env.NODE_ENV;
assert.strictEqual(detectEnvironment(), 'local');
process.env.NODE_ENV = original;
});
test('detectEnvironment maps production', () => {
const original = process.env.NODE_ENV;
process.env.NODE_ENV = 'production';
assert.strictEqual(detectEnvironment(), 'production');
process.env.NODE_ENV = original;
});
test('detectEnvironment maps prod alias', () => {
const original = process.env.NODE_ENV;
process.env.NODE_ENV = 'prod';
assert.strictEqual(detectEnvironment(), 'production');
process.env.NODE_ENV = original;
});
// ---------------------------------------------------------------------------
// Custom environment policies
// ---------------------------------------------------------------------------
test('custom environment policy overrides default', () => {
const config = createConfig({
environments: {
production: {
allowedModes: ['verify', 'observe', 'qualify'],
blockQualify: false,
},
},
});
const engine = new PolicyEngine({
config,
env: 'production',
mode: 'qualify',
});
const result = engine.check();
assert.strictEqual(result.allowed, true);
});
test('custom environment can block verify', () => {
const config = createConfig({
environments: {
readonly: {
allowedModes: ['observe'],
},
},
});
const engine = new PolicyEngine({
config,
env: 'readonly',
mode: 'verify',
});
const result = engine.check();
assert.strictEqual(result.allowed, false);
assert.ok(result.errors.some((e) => e.includes('not allowed')));
});
+446
View File
@@ -0,0 +1,446 @@
/**
* Policy engine for APOPHIS CLI.
*
* Responsibilities:
* - Environment safety matrix enforcement
* - Mode gating (verify/observe/qualify per environment)
* - Profile feature validation against environment
* - Preset/profile combination validation
* - Clear error messages on policy violations
*/
import type { Config, EnvironmentPolicy, ProfileDefinition, PresetDefinition } from './config-loader.js';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface PolicyCheckResult {
allowed: boolean;
errors: string[];
warnings: string[];
}
export interface PolicyEngineOptions {
config: Config;
env: string;
mode: 'verify' | 'observe' | 'qualify';
profileName?: string;
presetName?: string;
}
// ---------------------------------------------------------------------------
// Default environment policies
// ---------------------------------------------------------------------------
/**
* Default safety matrix for environments.
*
* verify: allowed in local, test, CI, optional in staging/prod
* observe: allowed everywhere, blocking in prod requires explicit policy
* qualify: blocked in prod by default, allowed in local/test/staging with restrictions
*/
const DEFAULT_ENV_POLICIES: Record<string, EnvironmentPolicy> = {
local: {
allowedModes: ['verify', 'observe', 'qualify'],
blockQualify: false,
allowChaosOnProtected: true,
},
test: {
allowedModes: ['verify', 'observe', 'qualify'],
blockQualify: false,
allowChaosOnProtected: true,
},
ci: {
allowedModes: ['verify', 'observe', 'qualify'],
blockQualify: false,
allowChaosOnProtected: false,
},
staging: {
allowedModes: ['verify', 'observe', 'qualify'],
blockQualify: false,
allowChaosOnProtected: false,
},
production: {
allowedModes: ['verify', 'observe'],
blockQualify: true,
allowChaosOnProtected: false,
},
prod: {
allowedModes: ['verify', 'observe'],
blockQualify: true,
allowChaosOnProtected: false,
},
};
// ---------------------------------------------------------------------------
// Feature sets per mode
// ---------------------------------------------------------------------------
/**
* Features that are only available in qualify mode.
*/
const QUALIFY_ONLY_FEATURES = new Set<string>([
'chaos',
'stateful',
'scenario',
'outbound-mocks',
'protocol-flow',
]);
/**
* Features that require explicit allowlist in production.
*/
const PROD_RESTRICTED_FEATURES = new Set<string>([
'chaos',
'outbound-mocks',
'protocol-flow',
]);
// ---------------------------------------------------------------------------
// Policy engine
// ---------------------------------------------------------------------------
export class PolicyEngine {
private config: Config;
private env: string;
private mode: 'verify' | 'observe' | 'qualify';
private profileName?: string;
private presetName?: string;
constructor(options: PolicyEngineOptions) {
this.config = options.config;
this.env = options.env;
this.mode = options.mode;
this.profileName = options.profileName;
this.presetName = options.presetName;
}
/**
* Run all policy checks.
* Returns result with errors and warnings.
*/
check(): PolicyCheckResult {
const errors: string[] = [];
const warnings: string[] = [];
// 1. Check if mode is allowed in current environment
const modeCheck = this.checkModeAllowed();
if (!modeCheck.allowed) {
errors.push(...modeCheck.errors);
}
warnings.push(...modeCheck.warnings);
// 2. Check if profile references features not allowed in current env
const profileCheck = this.checkProfileFeatures();
if (!profileCheck.allowed) {
errors.push(...profileCheck.errors);
}
warnings.push(...profileCheck.warnings);
// 3. Check preset/profile combination validity
const comboCheck = this.checkPresetProfileCombination();
if (!comboCheck.allowed) {
errors.push(...comboCheck.errors);
}
warnings.push(...comboCheck.warnings);
// 4. Check observe-specific safety
if (this.mode === 'observe') {
const observeCheck = this.checkObserveSafety();
if (!observeCheck.allowed) {
errors.push(...observeCheck.errors);
}
warnings.push(...observeCheck.warnings);
}
// 5. Check qualify-specific safety
if (this.mode === 'qualify') {
const qualifyCheck = this.checkQualifySafety();
if (!qualifyCheck.allowed) {
errors.push(...qualifyCheck.errors);
}
warnings.push(...qualifyCheck.warnings);
}
return {
allowed: errors.length === 0,
errors,
warnings,
};
}
// -------------------------------------------------------------------------
// Individual checks
// -------------------------------------------------------------------------
/**
* Check if current mode is allowed in current environment.
*/
private checkModeAllowed(): PolicyCheckResult {
const errors: string[] = [];
const warnings: string[] = [];
const envPolicy = this.getEnvironmentPolicy();
const allowedModes = envPolicy.allowedModes ?? [];
if (!allowedModes.includes(this.mode)) {
errors.push(
`Mode "${this.mode}" is not allowed in environment "${this.env}". ` +
`Allowed modes: ${allowedModes.join(', ') || 'none'}.`,
);
}
// Warn about observe in prod
if (this.mode === 'observe' && (this.env === 'production' || this.env === 'prod')) {
warnings.push(
`Observe mode in production requires explicit policy configuration. ` +
`Ensure blocking behavior is disabled and sampling rate is configured.`,
);
}
return { allowed: errors.length === 0, errors, warnings };
}
/**
* Check if profile references features not allowed in current environment.
*/
private checkProfileFeatures(): PolicyCheckResult {
const errors: string[] = [];
const warnings: string[] = [];
if (!this.profileName || !this.config.profiles) {
return { allowed: true, errors, warnings };
}
const profile = this.config.profiles[this.profileName];
if (!profile) {
// This should be caught by config loader, but be defensive
return { allowed: true, errors, warnings };
}
// Resolve preset features if profile references a preset
let features = profile.features ?? [];
if (profile.preset && this.config.presets) {
const preset = this.config.presets[profile.preset];
if (preset && preset.features) {
// Merge preset features with profile features (profile takes precedence)
const presetFeatures = preset.features.filter(f => !features.includes(f));
features = [...presetFeatures, ...features];
}
}
const envPolicy = this.getEnvironmentPolicy();
for (const feature of features) {
// Check qualify-only features in non-qualify mode
if (QUALIFY_ONLY_FEATURES.has(feature) && this.mode !== 'qualify') {
errors.push(
`Profile "${this.profileName}" references qualify-only feature "${feature}" ` +
`but current mode is "${this.mode}".`,
);
}
// Check prod-restricted features
if (PROD_RESTRICTED_FEATURES.has(feature) && (this.env === 'production' || this.env === 'prod')) {
if (feature === 'chaos' && !envPolicy.allowChaosOnProtected) {
errors.push(
`Feature "${feature}" from profile "${this.profileName}" is blocked in production. ` +
`Chaos on protected routes requires explicit allowlist configuration.`,
);
} else if (feature !== 'chaos') {
errors.push(
`Feature "${feature}" from profile "${this.profileName}" is restricted in production. ` +
`Requires explicit break-glass policy.`,
);
}
}
}
return { allowed: errors.length === 0, errors, warnings };
}
/**
* Check preset/profile combination validity.
*/
private checkPresetProfileCombination(): PolicyCheckResult {
const errors: string[] = [];
const warnings: string[] = [];
if (!this.presetName || !this.profileName) {
return { allowed: true, errors, warnings };
}
if (!this.config.presets) {
errors.push(`Preset "${this.presetName}" referenced but no presets defined in config.`);
return { allowed: false, errors, warnings };
}
const preset = this.config.presets[this.presetName];
if (!preset) {
errors.push(`Unknown preset "${this.presetName}".`);
return { allowed: false, errors, warnings };
}
// Check mode compatibility between preset and current mode
if (preset.mode && preset.mode !== this.mode) {
warnings.push(
`Preset "${this.presetName}" is configured for mode "${preset.mode}" ` +
`but current mode is "${this.mode}".`,
);
}
// Check profile features against preset features
const profile = this.config.profiles?.[this.profileName];
if (profile && preset.features && profile.features) {
const presetFeatures = new Set(preset.features);
const profileFeatures = new Set(profile.features);
for (const feature of profileFeatures) {
if (!presetFeatures.has(feature)) {
warnings.push(
`Profile "${this.profileName}" includes feature "${feature}" ` +
`not present in preset "${this.presetName}".`,
);
}
}
}
return { allowed: errors.length === 0, errors, warnings };
}
/**
* Check observe-specific safety constraints.
*/
private checkObserveSafety(): PolicyCheckResult {
const errors: string[] = [];
const warnings: string[] = [];
const envPolicy = this.getEnvironmentPolicy();
// In prod, observe must be non-blocking
if ((this.env === 'production' || this.env === 'prod') && envPolicy.blockQualify) {
// blockQualify being true in prod is expected, but we should ensure
// observe doesn't have blocking behavior
warnings.push(
`Observe mode in production: ensure non-blocking semantics and proper sampling rate.`,
);
}
return { allowed: errors.length === 0, errors, warnings };
}
/**
* Check qualify-specific safety constraints.
*/
private checkQualifySafety(): PolicyCheckResult {
const errors: string[] = [];
const warnings: string[] = [];
const envPolicy = this.getEnvironmentPolicy();
// Check if qualify is blocked in this environment
if (envPolicy.blockQualify) {
errors.push(
`Qualify mode is blocked in environment "${this.env}". ` +
`This environment does not support scenario, stateful, or chaos execution.`,
);
}
// Check for chaos on protected routes
const profile = this.profileName ? this.config.profiles?.[this.profileName] : undefined;
if (profile?.features?.includes('chaos') && !envPolicy.allowChaosOnProtected) {
errors.push(
`Chaos on protected routes is not allowed in environment "${this.env}". ` +
`Add routes to allowlist or use a different environment.`,
);
}
return { allowed: errors.length === 0, errors, warnings };
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
/**
* Get environment policy, falling back to defaults.
*/
private getEnvironmentPolicy(): EnvironmentPolicy {
const userPolicy = this.config.environments?.[this.env];
const defaultPolicy = DEFAULT_ENV_POLICIES[this.env];
return {
...defaultPolicy,
...userPolicy,
};
}
}
// ---------------------------------------------------------------------------
// Convenience functions
// ---------------------------------------------------------------------------
/**
* Check if a mode is allowed in an environment.
* Standalone function for simple checks.
*/
export function isModeAllowed(
mode: 'verify' | 'observe' | 'qualify',
env: string,
config?: Config,
): boolean {
const engine = new PolicyEngine({
config: config ?? {},
env,
mode,
});
const result = engine.check();
return result.allowed;
}
/**
* Check a profile against environment policy.
*/
export function checkProfile(
profileName: string,
config: Config,
env: string,
mode: 'verify' | 'observe' | 'qualify',
): PolicyCheckResult {
const engine = new PolicyEngine({
config,
env,
mode,
profileName,
});
return engine.check();
}
/**
* Get the default environment name from NODE_ENV.
*/
export function detectEnvironment(): string {
const nodeEnv = process.env.NODE_ENV ?? 'local';
switch (nodeEnv) {
case 'test':
return 'test';
case 'ci':
case 'CI':
return 'ci';
case 'staging':
return 'staging';
case 'production':
case 'prod':
return 'production';
default:
return 'local';
}
}
// ---------------------------------------------------------------------------
// Re-export types
// ---------------------------------------------------------------------------
export type { Config, EnvironmentPolicy, ProfileDefinition, PresetDefinition };
+403
View File
@@ -0,0 +1,403 @@
/**
* S0: Spec Authority - Core types for APOPHIS CLI
* Frozen contract. All implementation streams code against these types.
*/
// ─────────────────────────────────────────────────────────────────────────────
// CLI Context (injected, never optional imports)
// ─────────────────────────────────────────────────────────────────────────────
export interface CliContext {
/** Absolute path to current working directory */
cwd: string;
/** Normalized environment detection */
env: {
nodeEnv: string | undefined;
apophisEnv: string | undefined;
};
/** Is stdout a TTY? */
isTTY: boolean;
/** Is running in CI? (CI=true, GITHUB_ACTIONS, etc.) */
isCI: boolean;
/** Node.js version string */
nodeVersion?: string;
/** Package manager detected (npm, yarn, pnpm, bun) */
packageManager: "npm" | "yarn" | "pnpm" | "bun" | "unknown";
/** Absolute path to the CLI binary (for self-reference) */
selfPath?: string;
/** Parsed global CLI options */
options: {
config: string | undefined;
profile: string | undefined;
generationProfile?: string;
format: OutputFormat;
color: ColorMode;
quiet: boolean;
verbose: boolean;
artifactDir: string | undefined;
};
}
// ─────────────────────────────────────────────────────────────────────────────
// Exit Codes
// ─────────────────────────────────────────────────────────────────────────────
export const ExitCodes = {
SUCCESS: 0,
BEHAVIORAL_FAILURE: 1,
USAGE_ERROR: 2,
INTERNAL_ERROR: 3,
INTERRUPTED: 130,
} as const;
export type ExitCode = (typeof ExitCodes)[keyof typeof ExitCodes];
// ─────────────────────────────────────────────────────────────────────────────
// Config Schema (TypeBox-style: plain TS interfaces with JSON Schema metadata)
// ─────────────────────────────────────────────────────────────────────────────
/**
* @jsonSchema { type: "string", enum: ["verify", "observe", "qualify"] }
*/
export type ApophisMode = "verify" | "observe" | "qualify";
/**
* @jsonSchema { type: "string", enum: ["human", "json", "ndjson", "json-summary", "ndjson-summary"] }
*/
export type OutputFormat = "human" | "json" | "ndjson" | "json-summary" | "ndjson-summary";
/**
* @jsonSchema { type: "string", enum: ["auto", "always", "never"] }
*/
export type ColorMode = "auto" | "always" | "never";
/**
* Environment policy: safety gates for running commands in specific environments.
* @jsonSchema {
* type: "object",
* required: ["name"],
* properties: {
* name: { type: "string" },
* allowVerify: { type: "boolean", default: true },
* allowObserve: { type: "boolean", default: true },
* allowQualify: { type: "boolean", default: false },
* allowChaos: { type: "boolean", default: false },
* allowBlocking: { type: "boolean", default: false },
* requireSink: { type: "boolean", default: false }
* },
* additionalProperties: false
* }
*/
export interface EnvironmentPolicy {
name: string;
allowVerify?: boolean;
allowObserve?: boolean;
allowQualify?: boolean;
allowChaos?: boolean;
allowBlocking?: boolean;
requireSink?: boolean;
}
/**
* Profile: a named configuration for a specific run mode.
* @jsonSchema {
* type: "object",
* required: ["name"],
* properties: {
* name: { type: "string" },
* mode: { type: "string", enum: ["verify", "observe", "qualify"] },
* preset: { type: "string" },
* routes: { type: "array", items: { type: "string" } },
* seed: { type: "number" },
* artifactDir: { type: "string" },
* environment: { type: "string" }
* },
* additionalProperties: false
* }
*/
export interface ProfileDefinition {
name: string;
mode?: ApophisMode;
preset?: string;
routes?: string[];
seed?: number;
artifactDir?: string;
environment?: string;
}
/**
* Preset: a reusable base configuration that profiles can extend.
* @jsonSchema {
* type: "object",
* required: ["name"],
* properties: {
* name: { type: "string" },
* depth: { type: "string", enum: ["quick", "standard", "deep"] },
* timeout: { type: "number" },
* parallel: { type: "boolean" },
* chaos: { type: "boolean" },
* observe: { type: "boolean" }
* },
* additionalProperties: false
* }
*/
export interface PresetDefinition {
name: string;
depth?: "quick" | "standard" | "deep";
timeout?: number;
parallel?: boolean;
chaos?: boolean;
observe?: boolean;
}
/**
* Root configuration object for apophis.config.js|ts|json
* @jsonSchema {
* type: "object",
* required: [],
* properties: {
* mode: { type: "string", enum: ["verify", "observe", "qualify"] },
* profile: { type: "string" },
* preset: { type: "string" },
* routes: { type: "array", items: { type: "string" } },
* seed: { type: "number" },
* artifactDir: { type: "string" },
* environments: { type: "object", additionalProperties: { $ref: "#/definitions/EnvironmentPolicy" } },
* profiles: { type: "object", additionalProperties: { $ref: "#/definitions/ProfileDefinition" } },
* presets: { type: "object", additionalProperties: { $ref: "#/definitions/PresetDefinition" } }
* },
* additionalProperties: false
* }
*/
export interface ApophisConfig {
mode?: ApophisMode;
profile?: string;
preset?: string;
routes?: string[];
seed?: number;
artifactDir?: string;
environments?: Record<string, EnvironmentPolicy>;
profiles?: Record<string, ProfileDefinition>;
presets?: Record<string, PresetDefinition>;
}
// ─────────────────────────────────────────────────────────────────────────────
// Artifact Schema
// ─────────────────────────────────────────────────────────────────────────────
/**
* Single contract failure record.
*/
export interface FailureRecord {
route: string;
contract: string;
expected: string;
observed: string;
seed: number;
replayCommand: string;
category?: string;
diff?: string;
actual?: string;
}
/**
* Artifact document emitted by verify, observe, and qualify runs.
* @jsonSchema {
* type: "object",
* required: ["version", "command", "cwd", "startedAt", "durationMs", "summary"],
* properties: {
* version: { type: "string", const: "apophis-artifact/1" },
* command: { type: "string" },
* mode: { type: "string" },
* cwd: { type: "string" },
* configPath: { type: "string" },
* profile: { type: "string" },
* preset: { type: "string" },
* env: { type: "string" },
* seed: { type: "number" },
* startedAt: { type: "string", format: "date-time" },
* durationMs: { type: "number" },
* summary: {
* type: "object",
* properties: {
* total: { type: "number" },
* passed: { type: "number" },
* failed: { type: "number" }
* }
* },
* failures: { type: "array", items: { type: "object" } },
* artifacts: { type: "array", items: { type: "string" } },
* warnings: { type: "array", items: { type: "string" } },
* exitReason: { type: "string" }
* }
* }
*/
export interface StepTrace {
step: number;
name: string;
route: string;
durationMs: number;
status: "passed" | "failed" | "skipped";
error?: string;
}
export interface CleanupOutcome {
resource: string;
cleaned: boolean;
error?: string;
}
export interface ExecutionSummary {
totalPlanned: number;
totalExecuted: number;
totalPassed: number;
totalFailed: number;
scenariosRun: number;
statefulTestsRun: number;
chaosRunsRun: number;
totalSteps: number;
}
export interface RouteExecutionInfo {
route: string;
executed: boolean;
reason?: string;
}
export interface ProfileGates {
scenario: boolean;
stateful: boolean;
chaos: boolean;
}
export interface WorkspaceRun {
package: string;
cwd: string;
artifact: Artifact;
}
export interface WorkspaceResult {
exitCode: ExitCode;
runs: WorkspaceRun[];
message?: string;
warnings?: string[];
}
export interface Artifact {
version: "apophis-artifact/1";
cliVersion?: string;
command: string;
mode?: string;
cwd: string;
configPath?: string;
profile?: string;
preset?: string;
env?: string;
seed?: number;
startedAt: string;
durationMs: number;
summary: {
total: number;
passed: number;
failed: number;
};
executionSummary?: ExecutionSummary;
executedRoutes?: string[];
skippedRoutes?: RouteExecutionInfo[];
stepTraces?: StepTrace[];
cleanupOutcomes?: CleanupOutcome[];
profileGates?: ProfileGates;
deterministicParams?: Record<string, unknown>;
failures: FailureRecord[];
artifacts: string[];
warnings: string[];
exitReason: string;
package?: string;
}
// ─────────────────────────────────────────────────────────────────────────────
// Command Result
// ─────────────────────────────────────────────────────────────────────────────
/**
* Result returned by every command handler.
* Renderers consume this to produce human/json/ndjson output.
*/
export interface CommandResult {
exitCode: ExitCode;
artifact?: Artifact;
message?: string;
warnings?: string[];
}
// ─────────────────────────────────────────────────────────────────────────────
// Shared types for commands
// ─────────────────────────────────────────────────────────────────────────────
/**
* Parsed CLI arguments (from cac or similar).
*/
export interface ParsedArgs {
command: string;
args: string[];
flags: Record<string, unknown>;
}
/**
* Global flags every command must accept.
*/
export interface GlobalFlags {
config?: string;
profile?: string;
cwd?: string;
format?: OutputFormat;
color?: ColorMode;
quiet?: boolean;
verbose?: boolean;
artifactDir?: string;
}
/**
* Route descriptor used for filtering and discovery.
*/
export interface RouteDescriptor {
method: string;
path: string;
schema?: unknown;
contracts?: string[];
}
/**
* Contract evaluation result for a single route.
*/
export interface RouteResult {
route: string;
passed: boolean;
durationMs: number;
failures?: FailureRecord[];
warnings?: string[];
}
/**
* NDJSON event types for streaming output.
*/
export type NdjsonEvent =
| { type: "run.started"; command: string; seed?: number; timestamp: string }
| { type: "route.started"; route: string; timestamp: string }
| { type: "route.passed"; route: string; durationMs: number; timestamp: string }
| { type: "route.failed"; route: string; failure: FailureRecord; timestamp: string }
| { type: "run.completed"; summary: Artifact["summary"]; timestamp: string };
/**
* Human output section for canonical failure rendering.
*/
export interface HumanFailureSection {
route: string;
profile?: string;
seed: number;
expected: string;
observed: string;
whyItMatters: string;
replayCommand: string;
nextSteps: string;
}
+201
View File
@@ -0,0 +1,201 @@
/**
* Workspace runner for APOPHIS CLI commands.
*
* Responsibilities:
* - Fan out a command across all workspace packages
* - Collect per-package artifacts with package attribution
* - Aggregate results into a single workspace result
* - Support json, ndjson, and human output formats
* - Preserve exit codes: fail if any package fails
*
* Architecture:
* - Dependency injection: all dependencies passed explicitly
* - No optional imports — everything is required or injected
* - Inline comments for documentation
*/
import type { CliContext } from './context.js';
import { findWorkspacePackages } from './config-loader.js';
import type { Artifact, WorkspaceRun, WorkspaceResult, ExitCode } from './types.js';
import { SUCCESS } from './exit-codes.js';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type RunCommandFn = (ctx: CliContext) => Promise<{ exitCode: number; artifact?: Artifact; warnings?: string[] }>;
export interface WorkspaceRunnerDeps {
runCommand: RunCommandFn;
findPackages?: (cwd: string) => string[];
}
// ---------------------------------------------------------------------------
// Workspace package discovery
// ---------------------------------------------------------------------------
/**
* Discover workspace packages using config-loader.
* Falls back to empty array if no workspaces found.
*/
function discoverPackages(cwd: string, findPackages?: (cwd: string) => string[]): string[] {
if (findPackages) {
return findPackages(cwd);
}
return findWorkspacePackages(cwd);
}
// ---------------------------------------------------------------------------
// Package name extraction
// ---------------------------------------------------------------------------
/**
* Extract package name from absolute path.
* Uses basename of the directory.
*/
function getPackageName(pkgPath: string): string {
const parts = pkgPath.split('/');
return parts[parts.length - 1] || 'unknown';
}
// ---------------------------------------------------------------------------
// Workspace execution
// ---------------------------------------------------------------------------
/**
* Run a command across all workspace packages.
*
* Flow:
* 1. Discover workspace packages
* 2. Run command for each package with package-attributed context
* 3. Collect artifacts and warnings
* 4. Determine overall exit code (fail if any package fails)
* 5. Return workspace result with all runs
*/
export async function runWorkspace(
deps: WorkspaceRunnerDeps,
ctx: CliContext,
): Promise<WorkspaceResult> {
const packages = discoverPackages(ctx.cwd, deps.findPackages);
if (packages.length === 0) {
return {
exitCode: SUCCESS as ExitCode,
runs: [],
message: 'No workspace packages found.',
};
}
const runs: WorkspaceRun[] = [];
let overallExitCode = SUCCESS;
const allWarnings: string[] = [];
for (const pkgPath of packages) {
const pkgName = getPackageName(pkgPath);
// Create a context scoped to this package's directory
const pkgCtx: CliContext = {
...ctx,
cwd: pkgPath,
};
const pkgResult = await deps.runCommand(pkgCtx);
if (pkgResult.artifact) {
// Attach package name to artifact for attribution
const attributedArtifact: Artifact = {
...pkgResult.artifact,
package: pkgName,
};
runs.push({
package: pkgName,
cwd: pkgPath,
artifact: attributedArtifact,
});
}
if (pkgResult.exitCode !== SUCCESS) {
overallExitCode = pkgResult.exitCode as ExitCode;
}
if (pkgResult.warnings) {
allWarnings.push(...pkgResult.warnings.map(w => `[${pkgName}] ${w}`));
}
}
return {
exitCode: overallExitCode as ExitCode,
runs,
warnings: allWarnings.length > 0 ? allWarnings : undefined,
};
}
// ---------------------------------------------------------------------------
// Output formatting
// ---------------------------------------------------------------------------
/**
* Format workspace results for human-readable output.
* Shows per-package summary with pass/fail status.
*/
export function formatWorkspaceHuman(result: WorkspaceResult): string {
const lines: string[] = [];
lines.push('Workspace results');
lines.push('');
for (const run of result.runs) {
const a = run.artifact;
const status = a.exitReason === 'success' ? '✓' : '✗';
lines.push(` ${status} ${run.package}: ${a.summary.passed}/${a.summary.total} passed`);
if (a.summary.failed > 0) {
lines.push(` ${a.summary.failed} failed`);
}
}
lines.push('');
lines.push(`Overall: ${result.exitCode === SUCCESS ? 'passed' : 'failed'}`);
return lines.join('\n');
}
/**
* Format workspace results as JSON.
* Includes all runs with full artifacts.
*/
export function formatWorkspaceJson(result: WorkspaceResult): string {
return JSON.stringify({
exitCode: result.exitCode,
runs: result.runs.map(r => ({
package: r.package,
cwd: r.cwd,
artifact: r.artifact,
})),
warnings: result.warnings,
}, null, 2);
}
/**
* Format workspace results as NDJSON.
* Emits one event per package plus a completion event.
*/
export function formatWorkspaceNdjson(result: WorkspaceResult): string {
const lines: string[] = [];
for (const run of result.runs) {
lines.push(JSON.stringify({
type: 'workspace.run.completed',
package: run.package,
cwd: run.cwd,
summary: run.artifact.summary,
exitReason: run.artifact.exitReason,
}));
}
lines.push(JSON.stringify({
type: 'workspace.completed',
exitCode: result.exitCode,
packages: result.runs.length,
}));
return lines.join('\n');
}