chore: crush git history - reborn from consolidation on 2026-03-10
This commit is contained in:
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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')));
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
Reference in New Issue
Block a user