Files
apophis-fastify/src/cli/core/config-loader.ts
T

902 lines
29 KiB
TypeScript
Raw Normal View History

/**
* 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 };