aios-core
Version:
Synkra AIOS: AI-Orchestrated System for Full Stack Development - Core Framework
608 lines (535 loc) • 18.9 kB
JavaScript
/**
* Configuration Resolver — Layered Config Hierarchy
*
* Implements the 5-level configuration hierarchy defined in ADR-PRO-002:
* L1 Framework → L2 Project → Pro Extension → L3 App → L4 Local → L5 User
*
* Provides:
* - resolveConfig(projectRoot, options) — main entry point
* - isLegacyMode(projectRoot) — detects monolithic core-config.yaml
* - loadLayeredConfig(projectRoot, options) — new layered loading
* - loadLegacyConfig(projectRoot) — backward-compatible monolithic loading
* - setUserConfigValue(key, value) — write to L5 user config
* - toggleUserProfile() — toggle user_profile bob↔advanced
*
* Integrates with:
* - ConfigCache (config-cache.js) — TTL-based caching
* - deepMerge (merge-utils.js) — merge strategy
* - interpolateEnvVars (env-interpolator.js) — ${VAR} resolution
*
* @module core/config/config-resolver
* @version 1.0.0
* @created 2026-02-05 (Story PRO-4)
* @see docs/architecture/adr/adr-pro-002-configuration-hierarchy.md
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const yaml = require('js-yaml');
const { deepMerge } = require('./merge-utils');
const { interpolateEnvVars, lintEnvPatterns } = require('./env-interpolator');
const { globalConfigCache } = require('./config-cache');
// ---------------------------------------------------------------------------
// JSON Schema validation (Story 12.2)
// ---------------------------------------------------------------------------
let _ajvInstance = null;
let _schemaCache = {};
/**
* Schema file mapping for each config level.
*/
const SCHEMA_FILES = {
framework: 'framework-config.schema.json',
project: 'project-config.schema.json',
local: 'local-config.schema.json',
user: 'user-config.schema.json',
};
/**
* Get or create the shared Ajv instance (lazy-loaded).
*
* @returns {Object} Ajv instance
*/
function getAjvInstance() {
if (!_ajvInstance) {
const Ajv = require('ajv');
const addFormats = require('ajv-formats');
_ajvInstance = new Ajv({ allErrors: true, strict: false });
addFormats(_ajvInstance);
}
return _ajvInstance;
}
/**
* Load a JSON Schema from the schemas/ directory.
*
* @param {string} schemaFileName - Schema file name
* @returns {Object|null} Parsed schema or null if not found
*/
function loadSchema(schemaFileName) {
if (_schemaCache[schemaFileName]) {
return _schemaCache[schemaFileName];
}
const schemaPath = path.join(__dirname, 'schemas', schemaFileName);
try {
if (!fs.existsSync(schemaPath)) {
return null;
}
const content = fs.readFileSync(schemaPath, 'utf8');
const schema = JSON.parse(content);
_schemaCache[schemaFileName] = schema;
return schema;
} catch {
return null;
}
}
/**
* Validate config data against a JSON Schema for the given level.
*
* Returns warnings (does not throw) for graceful degradation.
*
* @param {string} level - Config level: 'framework' | 'project' | 'local' | 'user'
* @param {Object} data - Config data to validate
* @param {string} filePath - Source file path (for error messages)
* @returns {string[]} Validation warnings (empty if valid)
*/
function validateConfig(level, data, filePath) {
const warnings = [];
const schemaFile = SCHEMA_FILES[level];
if (!schemaFile) {
return warnings;
}
const schema = loadSchema(schemaFile);
if (!schema) {
return warnings;
}
try {
const ajv = getAjvInstance();
const validate = ajv.compile(schema);
const isValid = validate(data);
if (!isValid && validate.errors) {
for (const err of validate.errors) {
const field = err.instancePath ? err.instancePath.replace(/^\//, '') : err.params?.missingProperty || 'unknown';
warnings.push(`${filePath} inválido: campo '${field}' ${err.message}`);
}
}
} catch {
// Graceful: if ajv fails, skip validation
}
return warnings;
}
/**
* Clear the schema cache (useful for testing).
*/
function clearSchemaCache() {
_schemaCache = {};
_ajvInstance = null;
}
/**
* Standard config file paths relative to project root.
*/
const CONFIG_FILES = {
framework: '.aios-core/framework-config.yaml',
project: '.aios-core/project-config.yaml',
pro: 'pro/pro-config.yaml',
local: '.aios-core/local-config.yaml',
legacy: '.aios-core/core-config.yaml',
user: path.join(os.homedir(), '.aios', 'user-config.yaml'),
};
/**
* Level identifiers for debug/tracing.
*/
const LEVELS = {
framework: 'L1',
project: 'L2',
pro: 'Pro',
app: 'L3',
local: 'L4',
user: 'L5',
legacy: 'Legacy',
};
// ---------------------------------------------------------------------------
// YAML loading helpers
// ---------------------------------------------------------------------------
/**
* Load and parse a YAML file. Returns null if file doesn't exist.
*
* @param {string} projectRoot - Project root directory
* @param {string} relativePath - Path relative to projectRoot
* @returns {{ data: Object|null, path: string }} Parsed YAML or null
*/
function loadYaml(projectRoot, relativePath) {
const fullPath = path.join(projectRoot, relativePath);
try {
if (!fs.existsSync(fullPath)) {
return { data: null, path: fullPath };
}
const content = fs.readFileSync(fullPath, 'utf8');
const data = yaml.load(content) || {};
return { data, path: fullPath };
} catch (error) {
throw new Error(`Failed to parse YAML at ${fullPath}: ${error.message}`);
}
}
/**
* Load and parse a YAML file from an absolute path.
* Returns null if file doesn't exist. Graceful on parse errors.
*
* @param {string} absolutePath - Absolute file path
* @returns {{ data: Object|null, path: string }} Parsed YAML or null
*/
function loadYamlAbsolute(absolutePath) {
try {
if (!fs.existsSync(absolutePath)) {
return { data: null, path: absolutePath };
}
const content = fs.readFileSync(absolutePath, 'utf8');
const data = yaml.load(content) || {};
return { data, path: absolutePath };
} catch (_error) {
// Graceful: user config may be malformed — treat as missing
return { data: null, path: absolutePath };
}
}
// ---------------------------------------------------------------------------
// Legacy detection
// ---------------------------------------------------------------------------
/**
* Detect if the project uses the monolithic core-config.yaml format.
*
* Legacy mode: core-config.yaml exists but framework-config.yaml does NOT.
* This means the project hasn't been migrated to layered config yet.
*
* @param {string} projectRoot - Project root directory
* @returns {boolean} True if legacy mode
*/
function isLegacyMode(projectRoot) {
const hasLegacy = fs.existsSync(path.join(projectRoot, CONFIG_FILES.legacy));
const hasFramework = fs.existsSync(path.join(projectRoot, CONFIG_FILES.framework));
return hasLegacy && !hasFramework;
}
// ---------------------------------------------------------------------------
// Layered config loading
// ---------------------------------------------------------------------------
/**
* Load configuration using the layered hierarchy.
*
* Order: L1 → L2 → Pro → L3 → L4 → L5
* Each level deep-merges onto the previous result.
*
* @param {string} projectRoot - Project root directory
* @param {Object} options - Load options
* @param {string} [options.appDir] - App directory for L3 config
* @param {boolean} [options.debug] - Collect source-tracking metadata
* @returns {Object} result
* @returns {Object} result.config - Merged configuration
* @returns {Object} [result.sources] - Per-key source tracking (when debug=true)
* @returns {string[]} result.warnings - Lint/interpolation warnings
*/
function loadLayeredConfig(projectRoot, options = {}) {
const warnings = [];
const sources = options.debug ? {} : null;
// L1: Framework (required — ships with npm package)
const l1 = loadYaml(projectRoot, CONFIG_FILES.framework);
let config = l1.data || {};
if (options.debug && l1.data) {
trackSources(sources, l1.data, LEVELS.framework, CONFIG_FILES.framework);
}
// Lint L1 for env patterns (should not contain ${...})
if (l1.data) {
const l1Lint = lintEnvPatterns(l1.data, CONFIG_FILES.framework);
if (l1Lint.length > 0) {
warnings.push(...l1Lint.map(w => `[LINT] ${w}`));
}
// Validate L1 against schema
const l1Validation = validateConfig('framework', l1.data, CONFIG_FILES.framework);
if (l1Validation.length > 0) {
warnings.push(...l1Validation.map(w => `[SCHEMA] ${w}`));
}
}
// L2: Project (optional)
const l2 = loadYaml(projectRoot, CONFIG_FILES.project);
if (l2.data) {
config = deepMerge(config, l2.data);
if (options.debug) {
trackSources(sources, l2.data, LEVELS.project, CONFIG_FILES.project);
}
// Lint L2 for env patterns
const l2Lint = lintEnvPatterns(l2.data, CONFIG_FILES.project);
if (l2Lint.length > 0) {
warnings.push(...l2Lint.map(w => `[LINT] ${w}`));
}
// Validate L2 against schema
const l2Validation = validateConfig('project', l2.data, CONFIG_FILES.project);
if (l2Validation.length > 0) {
warnings.push(...l2Validation.map(w => `[SCHEMA] ${w}`));
}
}
// Pro Extension (optional — only when pro/ submodule is present)
const pro = loadYaml(projectRoot, CONFIG_FILES.pro);
if (pro.data) {
config = deepMerge(config, pro.data);
if (options.debug) {
trackSources(sources, pro.data, LEVELS.pro, CONFIG_FILES.pro);
}
}
// L3: App (optional — only when appDir is specified)
if (options.appDir) {
const appConfigPath = path.join(options.appDir, 'aios-app.config.yaml');
const l3 = loadYaml(projectRoot, appConfigPath);
if (l3.data) {
config = deepMerge(config, l3.data);
if (options.debug) {
trackSources(sources, l3.data, LEVELS.app, appConfigPath);
}
}
}
// L4: Local (optional — machine-specific, gitignored)
const l4 = loadYaml(projectRoot, CONFIG_FILES.local);
if (l4.data) {
config = deepMerge(config, l4.data);
if (options.debug) {
trackSources(sources, l4.data, LEVELS.local, CONFIG_FILES.local);
}
// Validate L4 against schema
const l4Validation = validateConfig('local', l4.data, CONFIG_FILES.local);
if (l4Validation.length > 0) {
warnings.push(...l4Validation.map(w => `[SCHEMA] ${w}`));
}
}
// L5: User (optional — global user preferences, cross-project, ~/.aios/user-config.yaml)
const l5 = loadYamlAbsolute(CONFIG_FILES.user);
if (l5.data) {
config = deepMerge(config, l5.data);
if (options.debug) {
trackSources(sources, l5.data, LEVELS.user, CONFIG_FILES.user);
}
// Validate L5 against schema
const l5Validation = validateConfig('user', l5.data, CONFIG_FILES.user);
if (l5Validation.length > 0) {
warnings.push(...l5Validation.map(w => `[SCHEMA] ${w}`));
}
}
return { config, sources, warnings };
}
/**
* Load configuration in legacy mode (monolithic core-config.yaml).
*
* @param {string} projectRoot - Project root directory
* @returns {Object} result
* @returns {Object} result.config - Parsed configuration
* @returns {string[]} result.warnings - Deprecation warnings
*/
function loadLegacyConfig(projectRoot) {
const warnings = [];
const legacy = loadYaml(projectRoot, CONFIG_FILES.legacy);
if (!legacy.data) {
throw new Error(`Legacy config file not found: ${CONFIG_FILES.legacy}`);
}
const suppressDeprecation = process.env.AIOS_SUPPRESS_DEPRECATION === 'true'
|| process.env.AIOS_SUPPRESS_DEPRECATION === '1';
if (!suppressDeprecation) {
warnings.push(
'[DEPRECATION] Monolithic core-config.yaml detected. '
+ 'Run `aios config migrate` to split into layered config files. '
+ 'Monolithic format will be removed in v4.0.0. '
+ 'Set AIOS_SUPPRESS_DEPRECATION=true to silence this warning.',
);
}
return { config: legacy.data, sources: null, warnings };
}
// ---------------------------------------------------------------------------
// Source tracking (debug mode)
// ---------------------------------------------------------------------------
/**
* Track which level each config key came from (for --debug output).
*
* @param {Object} sources - Sources accumulator { 'key.path': { level, file } }
* @param {Object} data - Config data from a level
* @param {string} level - Level label (L1, L2, Pro, L3, L4)
* @param {string} file - Source file path
* @param {string} [prefix] - Key prefix for nested tracking
*/
function trackSources(sources, data, level, file, prefix = '') {
if (!sources || !data) return;
for (const [key, value] of Object.entries(data)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
// Track the object key itself
sources[fullKey] = { level, file };
// Recurse into nested objects
trackSources(sources, value, level, file, fullKey);
} else {
sources[fullKey] = { level, file };
}
}
}
// ---------------------------------------------------------------------------
// Main entry point
// ---------------------------------------------------------------------------
/**
* Resolve the final configuration for the project.
*
* Detects legacy vs layered mode automatically. Caches the resolved config
* via ConfigCache (TTL-based). Interpolates env vars after merge.
*
* @param {string} projectRoot - Project root directory
* @param {Object} [options] - Options
* @param {string} [options.appDir] - App directory for L3 (monorepo)
* @param {boolean} [options.debug] - Enable source tracking
* @param {boolean} [options.skipCache] - Bypass cache
* @returns {Object} result
* @returns {Object} result.config - Final resolved config
* @returns {Object} [result.sources] - Source tracking (debug only)
* @returns {string[]} result.warnings - All warnings
* @returns {boolean} result.legacy - Whether legacy mode was used
*/
function resolveConfig(projectRoot, options = {}) {
const cacheKey = `resolved:${projectRoot}:${options.appDir || 'root'}:${options.debug ? 'debug' : 'std'}`;
// Check cache (unless explicitly skipped)
if (!options.skipCache) {
const cached = globalConfigCache.get(cacheKey);
if (cached) return cached;
}
let result;
const isLegacy = isLegacyMode(projectRoot);
if (isLegacy) {
result = loadLegacyConfig(projectRoot);
result.legacy = true;
} else {
result = loadLayeredConfig(projectRoot, options);
result.legacy = false;
}
// Interpolate environment variables
const envWarnings = [];
result.config = interpolateEnvVars(result.config, { warnings: envWarnings });
if (envWarnings.length > 0) {
result.warnings.push(...envWarnings.map(w => `[ENV] ${w}`));
}
// Cache the result
globalConfigCache.set(cacheKey, result);
return result;
}
/**
* Get the raw config from a specific level (no merge, no interpolation).
*
* @param {string} projectRoot - Project root directory
* @param {string} level - Level: 'framework' | 'project' | 'pro' | 'local' | 'legacy'
* @param {Object} [options] - Options
* @param {string} [options.appDir] - App directory for level 'app'
* @returns {Object|null} Raw config or null if file doesn't exist
*/
function getConfigAtLevel(projectRoot, level, options = {}) {
let relativePath;
switch (level) {
case 'framework': case '1': case 'L1':
relativePath = CONFIG_FILES.framework;
break;
case 'project': case '2': case 'L2':
relativePath = CONFIG_FILES.project;
break;
case 'pro': case 'Pro':
relativePath = CONFIG_FILES.pro;
break;
case 'app': case '3': case 'L3':
if (!options.appDir) return null;
relativePath = path.join(options.appDir, 'aios-app.config.yaml');
break;
case 'local': case '4': case 'L4':
relativePath = CONFIG_FILES.local;
break;
case 'user': case '5': case 'L5': {
const { data } = loadYamlAbsolute(CONFIG_FILES.user);
return data;
}
case 'legacy':
relativePath = CONFIG_FILES.legacy;
break;
default:
throw new Error(`Unknown config level: ${level}`);
}
const { data } = loadYaml(projectRoot, relativePath);
return data;
}
// ---------------------------------------------------------------------------
// User config write operations (Story 12.1 — L5 User layer)
// ---------------------------------------------------------------------------
/**
* Valid user profile values.
*/
const VALID_USER_PROFILES = ['bob', 'advanced'];
/**
* Ensure the ~/.aios/ directory exists with secure permissions.
*
* @returns {string} Path to ~/.aios/ directory
*/
function ensureUserConfigDir() {
const dir = path.dirname(CONFIG_FILES.user);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
}
return dir;
}
/**
* Set a value in the user config file (~/.aios/user-config.yaml).
* Creates the file and directory if they don't exist.
* Invalidates the config cache after writing.
*
* @param {string} key - Config key to set
* @param {*} value - Value to set
* @returns {Object} Updated user config
*/
function setUserConfigValue(key, value) {
ensureUserConfigDir();
let config = {};
try {
if (fs.existsSync(CONFIG_FILES.user)) {
const content = fs.readFileSync(CONFIG_FILES.user, 'utf8');
config = yaml.load(content) || {};
}
} catch {
config = {};
}
config[key] = value;
const yamlContent = yaml.dump(config, { lineWidth: -1 });
fs.writeFileSync(CONFIG_FILES.user, yamlContent, 'utf8');
globalConfigCache.clear();
return config;
}
/**
* Toggle user_profile between 'bob' and 'advanced'.
* Reads current value, flips it, writes back, and invalidates cache.
*
* @returns {{ previous: string, current: string }} Previous and new profile values
*/
function toggleUserProfile() {
let config = {};
try {
if (fs.existsSync(CONFIG_FILES.user)) {
const content = fs.readFileSync(CONFIG_FILES.user, 'utf8');
config = yaml.load(content) || {};
}
} catch {
config = {};
}
const previous = config.user_profile || 'advanced';
const current = previous === 'bob' ? 'advanced' : 'bob';
setUserConfigValue('user_profile', current);
return { previous, current };
}
// ---------------------------------------------------------------------------
// Exports
// ---------------------------------------------------------------------------
module.exports = {
resolveConfig,
isLegacyMode,
loadLayeredConfig,
loadLegacyConfig,
getConfigAtLevel,
setUserConfigValue,
toggleUserProfile,
ensureUserConfigDir,
validateConfig,
clearSchemaCache,
CONFIG_FILES,
LEVELS,
SCHEMA_FILES,
VALID_USER_PROFILES,
};