Skip to content

Commit b107bb2

Browse files
committed
Improve component and config merging
1 parent 7fd9f89 commit b107bb2

2 files changed

Lines changed: 283 additions & 37 deletions

File tree

claude-config-composer/src/merger/component-merger.ts

Lines changed: 61 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import yaml from 'js-yaml';
22
import type { Agent, Command, Hook, Settings } from '../parser/config-parser';
3-
import type { HooksConfig, StatusLine } from '../types/config.js';
3+
import type { HooksConfig, StatusLine, HookEntry, HookCommand } from '../types/config.js';
44

55
/**
66
* Handles the intelligent merging of configuration components from multiple sources
@@ -232,33 +232,74 @@ export class ComponentMerger {
232232
private mergeHooksConfig(hooks1: HooksConfig, hooks2: HooksConfig): HooksConfig {
233233
const merged: HooksConfig = {};
234234

235-
// Handle both camelCase and PascalCase hook types
236-
const hookTypes = ['PreToolUse', 'PostToolUse', 'Stop', 'preToolUse', 'postToolUse', 'stop'];
235+
// All hook types that might appear in configurations
236+
const hookTypes = [
237+
'PreToolUse', 'PostToolUse', 'Stop', 'UserPromptSubmit',
238+
'Notification', 'SubagentStop', 'SessionEnd', 'SessionStart', 'PreCompact'
239+
];
237240

238241
for (const type of hookTypes) {
239-
if (hooks1[type] || hooks2[type]) {
240-
const items1 = Array.isArray(hooks1[type])
241-
? hooks1[type]
242-
: hooks1[type]
243-
? [hooks1[type]]
244-
: [];
245-
const items2 = Array.isArray(hooks2[type])
246-
? hooks2[type]
247-
: hooks2[type]
248-
? [hooks2[type]]
249-
: [];
250-
251-
const combined = [...items1, ...items2];
252-
if (combined.length === 1) {
253-
merged[type] = combined[0];
254-
} else if (combined.length > 1) {
255-
merged[type] = combined;
242+
const entries1 = hooks1[type] || [];
243+
const entries2 = hooks2[type] || [];
244+
245+
if (entries1.length > 0 || entries2.length > 0) {
246+
const mergedEntries: HookEntry[] = [];
247+
248+
// Process entries from hooks1
249+
for (const entry of entries1) {
250+
if (this.isValidHookEntry(entry)) {
251+
mergedEntries.push(this.normalizeHookEntry(entry));
252+
}
253+
}
254+
255+
// Process entries from hooks2
256+
for (const entry of entries2) {
257+
if (this.isValidHookEntry(entry)) {
258+
mergedEntries.push(this.normalizeHookEntry(entry));
259+
}
260+
}
261+
262+
if (mergedEntries.length > 0) {
263+
merged[type] = mergedEntries;
256264
}
257265
}
258266
}
259267

260268
return merged;
261269
}
270+
271+
private isValidHookEntry(entry: any): boolean {
272+
return entry && typeof entry === 'object' && 'hooks' in entry && Array.isArray(entry.hooks);
273+
}
274+
275+
private normalizeHookEntry(entry: any): HookEntry {
276+
const normalized: HookEntry = {
277+
hooks: []
278+
};
279+
280+
// Only include matcher if it exists and is not empty
281+
if (entry.matcher && entry.matcher !== '') {
282+
normalized.matcher = entry.matcher;
283+
}
284+
285+
// Process hooks array
286+
if (Array.isArray(entry.hooks)) {
287+
for (const hook of entry.hooks) {
288+
if (hook && typeof hook === 'object' && 'command' in hook) {
289+
const hookCommand: HookCommand = {
290+
type: hook.type || 'command',
291+
command: hook.command
292+
};
293+
if (hook.timeout) {
294+
hookCommand.timeout = hook.timeout;
295+
}
296+
normalized.hooks.push(hookCommand);
297+
}
298+
}
299+
}
300+
301+
return normalized;
302+
}
262303

263304
private mergeStatusLine(status1: StatusLine | undefined, status2: StatusLine | undefined): StatusLine | undefined {
264305
if (!status1) return status2;

claude-config-composer/src/merger/config-merger.ts

Lines changed: 222 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,31 @@ export class ConfigMerger {
132132
}
133133

134134
private normalizeTitle(title: string): string {
135+
// Special normalization for "Development Assistant" titles to group them together
136+
if (title.toLowerCase().includes('development assistant')) {
137+
return 'development assistant';
138+
}
139+
140+
// Special normalization for similar section types
141+
const normalizations: Record<string, string> = {
142+
'breaking changes': 'breaking changes',
143+
'file conventions': 'file conventions',
144+
'project structure': 'project structure',
145+
'common commands': 'common commands',
146+
'available commands': 'available commands',
147+
'security best practices': 'security best practices',
148+
'performance optimization': 'performance optimization',
149+
'testing': 'testing',
150+
'deployment': 'deployment',
151+
};
152+
153+
const titleLower = title.toLowerCase();
154+
for (const [pattern, normalized] of Object.entries(normalizations)) {
155+
if (titleLower.includes(pattern)) {
156+
return normalized;
157+
}
158+
}
159+
135160
return title
136161
.toLowerCase()
137162
.replace(/[^a-z0-9\s]/g, '')
@@ -219,17 +244,30 @@ export class ConfigMerger {
219244
try {
220245
const bestSection = this.selectBestSection(sections);
221246

247+
// Skip sections with no content
248+
const hasContent = sections.some(s => s.content && s.content.trim() !== '');
249+
if (!hasContent) {
250+
processedSections.add(key);
251+
continue;
252+
}
253+
222254
if (this.shouldMergeSections(sections)) {
223255
const merged = this.mergeSimilarSections(sections);
224-
output.push(`${'#'.repeat(Math.min(bestSection.level, 2))} ${bestSection.title}`);
225-
output.push('');
226-
output.push(merged);
227-
output.push('');
256+
// Only add if merged content is not empty
257+
if (merged && merged.trim()) {
258+
output.push(`${'#'.repeat(Math.min(bestSection.level, 2))} ${bestSection.title}`);
259+
output.push('');
260+
output.push(merged);
261+
output.push('');
262+
}
228263
} else {
229-
output.push(`${'#'.repeat(Math.min(bestSection.level, 2))} ${bestSection.title}`);
230-
output.push('');
231-
output.push(bestSection.content);
232-
output.push('');
264+
// Only add if content is not empty
265+
if (bestSection.content && bestSection.content.trim()) {
266+
output.push(`${'#'.repeat(Math.min(bestSection.level, 2))} ${bestSection.title}`);
267+
output.push('');
268+
output.push(bestSection.content);
269+
output.push('');
270+
}
233271
}
234272

235273
processedSections.add(key);
@@ -355,39 +393,206 @@ export class ConfigMerger {
355393
const mergedContent: string[] = [];
356394
const sources = [...new Set(sections.map(s => s.source))];
357395

358-
mergedContent.push(`*Combined from: ${sources.join(', ')}*`);
359-
mergedContent.push('');
396+
// Only add "Combined from" if there are multiple unique sources
397+
if (sources.length > 1) {
398+
mergedContent.push(`*Combined from: ${sources.join(', ')}*`);
399+
mergedContent.push('');
400+
}
401+
402+
// Special handling for sections with numbered lists (like Security Best Practices)
403+
const isNumberedListSection = sections.some(s =>
404+
s.content.match(/^\d+\.\s+/m) || s.content.includes('1. ')
405+
);
406+
407+
if (isNumberedListSection) {
408+
return this.mergeNumberedLists(sections, sources);
409+
}
410+
411+
// Special handling for project context sections
412+
if (sections[0].title.toLowerCase().includes('project context')) {
413+
return this.mergeProjectContexts(sections, sources);
414+
}
360415

361-
const contentMap = new Map<string, Set<string>>();
416+
// For other sections, use improved content merging
417+
const contentMap = new Map<string, string[]>();
418+
const processedContent = new Set<string>();
362419

363420
for (const section of sections) {
421+
if (!section.content || section.content.trim() === '') continue;
422+
364423
const lines = section.content.split('\n');
365424
let currentSubsection = 'main';
366425

367426
for (const line of lines) {
368-
if (line.startsWith('###')) {
427+
// Track subsections
428+
if (line.match(/^#{3,}\s+/)) {
369429
currentSubsection = line;
370430
}
371431

372432
if (!contentMap.has(currentSubsection)) {
373-
contentMap.set(currentSubsection, new Set());
433+
contentMap.set(currentSubsection, []);
374434
}
375435

376436
const normalizedLine = line.trim();
377-
if (normalizedLine && !normalizedLine.startsWith('*Combined from:')) {
378-
contentMap.get(currentSubsection)!.add(line);
437+
const contentKey = normalizedLine.toLowerCase().replace(/[^a-z0-9]/g, '');
438+
439+
// Skip empty lines and duplicates
440+
if (normalizedLine &&
441+
!normalizedLine.startsWith('*Combined from:') &&
442+
!processedContent.has(contentKey)) {
443+
contentMap.get(currentSubsection)!.push(line);
444+
if (contentKey) processedContent.add(contentKey);
379445
}
380446
}
381447
}
382448

449+
// Rebuild content with proper structure
383450
for (const [subsection, lines] of contentMap) {
451+
if (lines.length === 0) continue; // Skip empty subsections
452+
384453
if (subsection !== 'main') {
385454
mergedContent.push(subsection);
386455
}
387-
mergedContent.push(...Array.from(lines));
456+
mergedContent.push(...lines);
457+
}
458+
459+
return mergedContent.join('\n').trim();
460+
}
461+
462+
private mergeNumberedLists(sections: Section[], sources: string[]): string {
463+
const mergedContent: string[] = [];
464+
465+
if (sources.length > 1) {
466+
mergedContent.push(`*Combined from: ${sources.join(', ')}*`);
467+
mergedContent.push('');
468+
}
469+
470+
const allItems = new Map<string, { content: string; source: string }>();
471+
let itemNumber = 1;
472+
473+
for (const section of sections) {
474+
if (!section.content || section.content.trim() === '') continue;
475+
476+
const lines = section.content.split('\n');
477+
let currentItem: string[] = [];
478+
let isInItem = false;
479+
480+
for (const line of lines) {
481+
// Check if this is a numbered item (at any level)
482+
if (line.match(/^\d+\.\s+/)) {
483+
// Save previous item if exists
484+
if (currentItem.length > 0) {
485+
const itemText = currentItem.join('\n');
486+
const itemKey = itemText.toLowerCase().replace(/^\d+\.\s+/, '').trim();
487+
488+
if (!allItems.has(itemKey)) {
489+
allItems.set(itemKey, { content: itemText, source: section.source });
490+
}
491+
}
492+
493+
currentItem = [line];
494+
isInItem = true;
495+
} else if (isInItem && line.match(/^\s+/)) {
496+
// Continuation of current item (indented)
497+
currentItem.push(line);
498+
} else if (line.trim() === '') {
499+
// Empty line might end an item
500+
if (currentItem.length > 0) {
501+
const itemText = currentItem.join('\n');
502+
const itemKey = itemText.toLowerCase().replace(/^\d+\.\s+/, '').trim();
503+
504+
if (!allItems.has(itemKey)) {
505+
allItems.set(itemKey, { content: itemText, source: section.source });
506+
}
507+
currentItem = [];
508+
isInItem = false;
509+
}
510+
} else if (!line.startsWith('*Combined from:')) {
511+
// Other content
512+
if (currentItem.length > 0) {
513+
const itemText = currentItem.join('\n');
514+
const itemKey = itemText.toLowerCase().replace(/^\d+\.\s+/, '').trim();
515+
516+
if (!allItems.has(itemKey)) {
517+
allItems.set(itemKey, { content: itemText, source: section.source });
518+
}
519+
currentItem = [];
520+
isInItem = false;
521+
}
522+
mergedContent.push(line);
523+
}
524+
}
525+
526+
// Don't forget the last item
527+
if (currentItem.length > 0) {
528+
const itemText = currentItem.join('\n');
529+
const itemKey = itemText.toLowerCase().replace(/^\d+\.\s+/, '').trim();
530+
531+
if (!allItems.has(itemKey)) {
532+
allItems.set(itemKey, { content: itemText, source: section.source });
533+
}
534+
}
535+
}
536+
537+
// Renumber and add all unique items
538+
for (const { content } of allItems.values()) {
539+
const renumbered = content.replace(/^\d+\.\s+/, `${itemNumber}. `);
540+
mergedContent.push(renumbered);
541+
itemNumber++;
542+
}
543+
544+
return mergedContent.join('\n').trim();
545+
}
546+
547+
private mergeProjectContexts(sections: Section[], sources: string[]): string {
548+
const mergedContent: string[] = [];
549+
550+
if (sources.length > 1) {
551+
mergedContent.push(`*Combined from: ${sources.join(', ')}*`);
552+
mergedContent.push('');
553+
}
554+
555+
// Collect unique project descriptions
556+
const projectDescriptions = new Map<string, string>();
557+
558+
for (const section of sections) {
559+
if (!section.content || section.content.trim() === '') continue;
560+
561+
// Extract project description paragraphs
562+
const lines = section.content.split('\n');
563+
const descriptionLines: string[] = [];
564+
565+
for (const line of lines) {
566+
if (!line.startsWith('*Combined from:')) {
567+
descriptionLines.push(line);
568+
}
569+
}
570+
571+
if (descriptionLines.length > 0) {
572+
const description = descriptionLines.join('\n').trim();
573+
if (description && !projectDescriptions.has(section.source)) {
574+
projectDescriptions.set(section.source, description);
575+
}
576+
}
577+
}
578+
579+
// Combine project descriptions intelligently
580+
const descriptions = Array.from(projectDescriptions.values());
581+
if (descriptions.length === 1) {
582+
mergedContent.push(descriptions[0]);
583+
} else {
584+
// For multiple descriptions, present them as a unified context
585+
mergedContent.push('This is a comprehensive project that combines multiple technologies:');
586+
mergedContent.push('');
587+
588+
for (const desc of descriptions) {
589+
// Add each description as a paragraph
590+
mergedContent.push(desc);
591+
mergedContent.push('');
592+
}
388593
}
389594

390-
return mergedContent.join('\n');
595+
return mergedContent.join('\n').trim();
391596
}
392597

393598
private createEmptyConfiguration(): string {

0 commit comments

Comments
 (0)