Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 56 additions & 40 deletions src/config/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export const CONFIG_DEFAULTS = {
batchSize: 10,
timeout: 30000,
},
comparison: { threshold: 2.0 },
comparison: { threshold: 2.0, minClusterSize: 2 },
tdd: { openReport: false },
plugins: [],
};
Expand Down Expand Up @@ -81,32 +81,44 @@ export function validateWriteScope(scope) {
// Deep Merge
// ============================================================================

function isPlainConfigObject(value) {
return value && typeof value === 'object' && !Array.isArray(value);
}

function cloneConfigValue(value) {
if (Array.isArray(value)) {
return value.map(cloneConfigValue);
}

if (isPlainConfigObject(value)) {
let cloned = {};
for (let key of Object.keys(value)) {
cloned[key] = cloneConfigValue(value[key]);
}
return cloned;
}

return value;
}

/**
* Deep merge two objects
* @param {Object} target - Target object
* @param {Object} source - Source object
* @returns {Object} Merged object (new object, inputs not mutated)
*/
export function deepMerge(target, source) {
let output = { ...target };
let output = cloneConfigValue(target);

for (let key of Object.keys(source)) {
if (
source[key] &&
typeof source[key] === 'object' &&
!Array.isArray(source[key])
) {
if (
target[key] &&
typeof target[key] === 'object' &&
!Array.isArray(target[key])
) {
if (isPlainConfigObject(source[key])) {
if (isPlainConfigObject(target[key])) {
output[key] = deepMerge(target[key], source[key]);
} else {
output[key] = source[key];
output[key] = cloneConfigValue(source[key]);
}
} else {
output[key] = source[key];
output[key] = cloneConfigValue(source[key]);
}
}

Expand All @@ -123,12 +135,22 @@ export function deepMerge(target, source) {
* @returns {Object} The value if it's an object, empty object otherwise
*/
function ensureObject(value) {
if (value && typeof value === 'object' && !Array.isArray(value)) {
if (isPlainConfigObject(value)) {
return value;
}
return {};
}

function applyConfigLayer(config, sources, layer, sourceName) {
let merged = deepMerge(config, layer);

for (let key of Object.keys(layer)) {
sources[key] = sourceName;
}

return merged;
}

/**
* Build merged config from layers with source tracking
* @param {Object} options - Config layers
Expand All @@ -147,37 +169,31 @@ export function buildMergedConfig({
let safeGlobalConfig = ensureObject(globalConfig);
let safeEnvOverrides = ensureObject(envOverrides);

let mergedConfig = {};
let mergedConfig = cloneConfigValue(CONFIG_DEFAULTS);
let sources = {};

// Layer 1: Defaults
for (let key of Object.keys(CONFIG_DEFAULTS)) {
mergedConfig[key] = CONFIG_DEFAULTS[key];
sources[key] = 'default';
}

// Layer 2: Global config (auth, project mappings, user preferences)
if (safeGlobalConfig.auth) {
mergedConfig.auth = safeGlobalConfig.auth;
sources.auth = 'global';
}

if (safeGlobalConfig.projects) {
mergedConfig.projects = safeGlobalConfig.projects;
sources.projects = 'global';
}

// Layer 3: Project config file
for (let key of Object.keys(safeProjectConfig)) {
mergedConfig[key] = safeProjectConfig[key];
sources[key] = 'project';
}

// Layer 4: Environment variables
for (let key of Object.keys(safeEnvOverrides)) {
mergedConfig[key] = safeEnvOverrides[key];
sources[key] = 'env';
}
mergedConfig = applyConfigLayer(
mergedConfig,
sources,
safeGlobalConfig,
'global'
);
mergedConfig = applyConfigLayer(
mergedConfig,
sources,
safeProjectConfig,
'project'
);
mergedConfig = applyConfigLayer(
mergedConfig,
sources,
safeEnvOverrides,
'env'
);

return { config: mergedConfig, sources };
}
Expand Down
73 changes: 41 additions & 32 deletions src/services/config-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,30 +12,10 @@ import { existsSync } from 'node:fs';
import { writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { cosmiconfigSync } from 'cosmiconfig';
import { CONFIG_DEFAULTS } from '../config/core.js';
import { loadGlobalConfig, saveGlobalConfig } from '../utils/global-config.js';
import * as output from '../utils/output.js';

/**
* Default configuration values
*/
let DEFAULT_CONFIG = {
comparison: {
threshold: 2.0,
minClusterSize: 2,
},
server: {
port: 47392,
timeout: 30000,
},
build: {
name: 'Build {timestamp}',
environment: 'test',
},
tdd: {
openReport: false,
},
};

/**
* Create a config service instance
* @param {Object} options
Expand Down Expand Up @@ -77,7 +57,7 @@ export function createConfigService({ workingDir }) {
* Get merged configuration with source tracking
*/
async function getMergedConfig() {
let config = { ...DEFAULT_CONFIG };
let config = cloneConfigValue(CONFIG_DEFAULTS);
let sources = {};

// Layer 1: Global config
Expand All @@ -102,18 +82,17 @@ export function createConfigService({ workingDir }) {

// Layer 3: Environment variables
if (process.env.VIZZLY_THRESHOLD) {
config.comparison.threshold = parseFloat(process.env.VIZZLY_THRESHOLD);
config.comparison.threshold = Number(process.env.VIZZLY_THRESHOLD);
sources.comparison = 'env';
}
if (process.env.VIZZLY_MIN_CLUSTER_SIZE) {
config.comparison.minClusterSize = parseInt(
process.env.VIZZLY_MIN_CLUSTER_SIZE,
10
config.comparison.minClusterSize = Number(
process.env.VIZZLY_MIN_CLUSTER_SIZE
);
sources.comparison = 'env';
}
if (process.env.VIZZLY_PORT) {
config.server.port = parseInt(process.env.VIZZLY_PORT, 10);
config.server.port = Number(process.env.VIZZLY_PORT);
sources.server = 'env';
}

Expand Down Expand Up @@ -240,7 +219,11 @@ export default defineConfig(${JSON.stringify(newConfig, null, 2)});
// Validate threshold
if (config.comparison?.threshold !== undefined) {
let threshold = config.comparison.threshold;
if (typeof threshold !== 'number' || threshold < 0) {
if (
typeof threshold !== 'number' ||
!Number.isFinite(threshold) ||
threshold < 0
) {
errors.push('comparison.threshold must be a non-negative number');
} else if (threshold > 100) {
warnings.push(
Expand Down Expand Up @@ -299,7 +282,7 @@ export default defineConfig(${JSON.stringify(newConfig, null, 2)});
* Deep merge two objects
*/
function mergeDeep(target, source) {
let result = { ...target };
let result = cloneConfigValue(target);

for (let key in source) {
if (
Expand All @@ -308,6 +291,8 @@ function mergeDeep(target, source) {
!Array.isArray(source[key])
) {
result[key] = mergeDeep(result[key] || {}, source[key]);
} else if (Array.isArray(source[key])) {
result[key] = [...source[key]];
} else {
result[key] = source[key];
}
Expand All @@ -316,21 +301,45 @@ function mergeDeep(target, source) {
return result;
}

function cloneConfigValue(value) {
if (Array.isArray(value)) {
return value.map(cloneConfigValue);
}

if (value && typeof value === 'object') {
let clone = {};
for (let key in value) {
clone[key] = cloneConfigValue(value[key]);
}
return clone;
}

return value;
}

/**
* Merge config with source tracking
*/
function mergeWithTracking(target, source, sources, sourceName) {
function mergeWithTracking(target, source, sources, sourceName, sectionName) {
for (let key in source) {
let sourceSection = sectionName || key;

if (
source[key] &&
typeof source[key] === 'object' &&
!Array.isArray(source[key])
) {
if (!target[key]) target[key] = {};
mergeWithTracking(target[key], source[key], sources, sourceName);
mergeWithTracking(
target[key],
source[key],
sources,
sourceName,
sourceSection
);
} else {
target[key] = source[key];
sources[key] = sourceName;
sources[sourceSection] = sourceName;
}
}
}
Loading
Loading