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
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"@mermaid-js/mermaid-cli": "^11.9.0",
"commander": "^14.0.0",
"fs-extra": "^11.3.0",
"lodash": "^4.17.21",
"mustache": "^4.2.0",
"next": "15.4.2",
"react": "19.1.0",
Expand All @@ -54,6 +55,7 @@
"@tailwindcss/postcss": "^4.1.11",
"@types/fs-extra": "^11.0.4",
"@types/jest": "^30.0.0",
"@types/lodash": "^4.17.20",
"@types/mustache": "^4.2.6",
"@types/node": "^20.19.9",
"@types/react": "^19.1.8",
Expand Down
16 changes: 16 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

69 changes: 63 additions & 6 deletions src/core/config-discovery.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as path from 'path';
import * as YAML from 'yaml';
import _ from 'lodash';
import { ConfigPaths, ResolvedConfig } from './types.js';
import { ProjectConfigSchema, type ProjectConfig } from './schemas.js';
import { SystemInterface, NodeSystemInterface } from './system-interface.js';
Expand Down Expand Up @@ -118,20 +119,76 @@ export class ConfigDiscovery {
}

/**
* Load and parse project configuration with Zod validation
* Reads config.yml from the project and validates its structure
* Load system default configuration
* Reads default-config.yml from the workflows directory
*/
async loadProjectConfig(configPath: string): Promise<ProjectConfig | null> {
private async loadSystemDefaults(systemRoot: string): Promise<ProjectConfig | null> {
try {
if (!this.systemInterface.existsSync(configPath)) {
const defaultConfigPath = path.join(systemRoot, 'workflows', 'default-config.yml');

if (!this.systemInterface.existsSync(defaultConfigPath)) {
console.warn('System default config not found, using empty defaults');
return null;
}

const configContent = this.systemInterface.readFileSync(configPath);
const configContent = this.systemInterface.readFileSync(defaultConfigPath);
const parsedYaml = YAML.parse(configContent);

const validationResult = ProjectConfigSchema.safeParse(parsedYaml);

if (!validationResult.success) {
console.warn(`Invalid system default config: ${validationResult.error.message}`);
return null;
}

return validationResult.data;
} catch (error) {
console.warn(
`Error loading system defaults: ${error instanceof Error ? error.message : String(error)}`,
);
return null;
}
}

/**
* Load and parse project configuration with Zod validation
* Reads config.yml from the project, merges with system defaults, and validates structure
*/
async loadProjectConfig(configPath: string, systemRoot?: string): Promise<ProjectConfig | null> {
try {
// Load system defaults first
let systemDefaults: ProjectConfig | null = null;
if (systemRoot) {
systemDefaults = await this.loadSystemDefaults(systemRoot);
}

// Load user config if it exists
let userConfig: unknown = null;
if (this.systemInterface.existsSync(configPath)) {
const configContent = this.systemInterface.readFileSync(configPath);
userConfig = YAML.parse(configContent);
}

// Merge user config over system defaults
let mergedConfig: unknown;
if (systemDefaults && userConfig) {
// User config takes precedence, system defaults fill in missing values
mergedConfig = _.defaultsDeep({}, userConfig, systemDefaults);
console.log('🔧 Config merged: user config + system defaults');
} else if (userConfig) {
mergedConfig = userConfig;
console.log('🔧 Config loaded: user config only (no system defaults)');
} else if (systemDefaults) {
mergedConfig = systemDefaults;
console.log('🔧 Config loaded: system defaults only (no user config)');
} else {
console.log('🔧 No configuration found');
return null;
}

// Validate the merged configuration
const validationResult = ProjectConfigSchema.safeParse(mergedConfig);

if (!validationResult.success) {
throw new Error(`Invalid configuration format: ${validationResult.error.message}`);
}
Expand Down Expand Up @@ -177,7 +234,7 @@ export class ConfigDiscovery {

let projectConfig: ProjectConfig | null = null;
if (paths.projectConfig) {
projectConfig = await this.loadProjectConfig(paths.projectConfig);
projectConfig = await this.loadProjectConfig(paths.projectConfig, paths.systemRoot);
}

return {
Expand Down
7 changes: 2 additions & 5 deletions src/core/workflow-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -374,11 +374,8 @@ export class WorkflowEngine {
console.log(`\n⚠️ NO TEMPLATE TYPE DETECTED - using default pandoc styling\n`);
}

// Get Mermaid configuration from project config
let mermaidConfig = undefined;
if (this.projectConfig?.system?.mermaid) {
mermaidConfig = this.projectConfig.system.mermaid;
}
// Get Mermaid configuration from project config (with system defaults already merged)
const mermaidConfig = this.projectConfig?.system?.mermaid;

const result = await convertDocument({
inputFile: inputPath,
Expand Down
12 changes: 10 additions & 2 deletions src/shared/mermaid-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,17 @@ export class MermaidProcessor {
*/
static async detectMermaidCLI(): Promise<boolean> {
try {
execSync('npx @mermaid-js/mermaid-cli --version', { stdio: 'ignore', timeout: 5000 });
// Try to run the Mermaid CLI version command
execSync('npx @mermaid-js/mermaid-cli --version', {
stdio: 'pipe',
timeout: 10000,
encoding: 'utf8',
});
return true;
} catch {
} catch (error) {
console.warn(
`⚠️ Mermaid CLI detection failed: ${error instanceof Error ? error.message : String(error)}`,
);
return false;
}
}
Expand Down
99 changes: 99 additions & 0 deletions tests/unit/core/config-layering.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
* Integration tests for configuration layering behavior
* Tests that system defaults are properly merged with user configuration
*/

import { ConfigDiscovery } from '../../../src/core/config-discovery.js';
import { MockSystemInterface } from '../mocks/mock-system-interface.js';
import { describe, it, expect, beforeEach } from '@jest/globals';
import _ from 'lodash';

describe('Configuration Layering Integration', () => {
let configDiscovery: ConfigDiscovery;
let mockSystemInterface: MockSystemInterface;

beforeEach(() => {
mockSystemInterface = new MockSystemInterface();
configDiscovery = new ConfigDiscovery(mockSystemInterface);
});

describe('loadProjectConfig', () => {
it('should return null when neither config exists', async () => {
// No files exist
const result = await configDiscovery.loadProjectConfig(
'/project/.markdown-workflow/config.yml',
'/system',
);

expect(result).toBeNull();
});

it('should load system defaults when available', async () => {
// This test validates that the system defaults loading mechanism works
// without requiring full schema validation
const mockLoadSystemDefaults = jest.spyOn(
configDiscovery as unknown as { loadSystemDefaults: (path: string) => Promise<unknown> },
'loadSystemDefaults',
);
mockLoadSystemDefaults.mockResolvedValue(null); // Mock return value

const result = await configDiscovery.loadProjectConfig(
'/project/.markdown-workflow/config.yml',
'/system',
);

expect(mockLoadSystemDefaults).toHaveBeenCalledWith('/system');
expect(result).toBeNull(); // Since both configs are null/missing
});

it('should handle missing system root gracefully', async () => {
const result = await configDiscovery.loadProjectConfig(
'/project/.markdown-workflow/config.yml',
// No systemRoot provided
);

expect(result).toBeNull();
});
});

describe('Configuration merging behavior', () => {
it('should demonstrate lodash defaultsDeep behavior', () => {
// This test validates our merging logic works as expected

const systemDefaults = {
system: {
mermaid: {
output_format: 'png',
theme: 'default',
timeout: 30,
},
git: {
auto_commit: true,
},
},
user: {
name: 'Default User',
},
};

const userConfig = {
system: {
mermaid: {
theme: 'dark', // User override
},
},
user: {
name: 'John Doe', // User override
},
};

const merged = _.defaultsDeep({}, userConfig, systemDefaults);

expect(merged.user.name).toBe('John Doe'); // User override
expect(merged.system.mermaid.theme).toBe('dark'); // User override
expect(merged.system.mermaid.output_format).toBe('png'); // System default
expect(merged.system.mermaid.timeout).toBe(30); // System default
expect(merged.system.git.auto_commit).toBe(true); // System default
});
});
});
5 changes: 3 additions & 2 deletions tests/unit/shared/mermaid-processor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,8 +203,9 @@ No mermaid diagrams here.
expect(mockExecSync).toHaveBeenCalledWith(
'npx @mermaid-js/mermaid-cli --version',
expect.objectContaining({
stdio: 'ignore',
timeout: 5000,
stdio: 'pipe',
timeout: 10000,
encoding: 'utf8',
}),
);
});
Expand Down
Loading