Files
codegraph/src/config.ts
T
Colby McHenry 98034cc76e Resolve merge conflicts with main
- extraction/index.ts: use picomatch with static import (replacing
  dynamic require) and keep normalizePath for other call sites
- utils.ts: keep normalizePath from main, take PR's PID-based FileLock
2026-02-10 16:21:23 -06:00

300 lines
8.6 KiB
TypeScript

/**
* Configuration Management
*
* Load, save, and validate CodeGraph configuration.
*/
import * as fs from 'fs';
import * as path from 'path';
import picomatch from 'picomatch';
import { CodeGraphConfig, DEFAULT_CONFIG, Language, NodeKind } from './types';
import { normalizePath } from './utils';
/**
* Configuration filename
*/
export const CONFIG_FILENAME = 'config.json';
/**
* Get the config file path for a project
*/
export function getConfigPath(projectRoot: string): string {
return path.join(projectRoot, '.codegraph', CONFIG_FILENAME);
}
/**
* Check if a regex pattern is safe from ReDoS attacks.
*
* Rejects patterns with nested quantifiers (e.g., (a+)+, (a*)*) which
* are the primary source of catastrophic backtracking. Also rejects
* excessively long patterns and validates compilability.
*/
function isSafeRegex(pattern: string): boolean {
// Reject excessively long patterns
if (pattern.length > 500) return false;
// Reject nested quantifiers: (...)+ followed by +, *, or {
// These are the primary cause of catastrophic backtracking
if (/([+*}])\s*[+*{]/.test(pattern)) return false;
if (/\([^)]*[+*][^)]*\)[+*{]/.test(pattern)) return false;
// Verify the pattern is a valid regex
try {
new RegExp(pattern);
return true;
} catch {
return false;
}
}
/**
* Validate a configuration object
*/
export function validateConfig(config: unknown): config is CodeGraphConfig {
if (typeof config !== 'object' || config === null) {
return false;
}
const c = config as Record<string, unknown>;
// Required fields
if (typeof c.version !== 'number') return false;
if (typeof c.rootDir !== 'string') return false;
if (!Array.isArray(c.include)) return false;
if (!Array.isArray(c.exclude)) return false;
if (!Array.isArray(c.languages)) return false;
if (!Array.isArray(c.frameworks)) return false;
if (typeof c.maxFileSize !== 'number') return false;
if (typeof c.extractDocstrings !== 'boolean') return false;
if (typeof c.trackCallSites !== 'boolean') return false;
if (typeof c.enableEmbeddings !== 'boolean') return false;
// Validate include/exclude are string arrays
if (!c.include.every((p) => typeof p === 'string')) return false;
if (!c.exclude.every((p) => typeof p === 'string')) return false;
// Validate languages
const validLanguages: Language[] = [
'typescript',
'javascript',
'python',
'go',
'rust',
'java',
'svelte',
'unknown',
];
if (!c.languages.every((l) => validLanguages.includes(l as Language))) return false;
// Validate frameworks
for (const fw of c.frameworks) {
if (typeof fw !== 'object' || fw === null) return false;
const framework = fw as Record<string, unknown>;
if (typeof framework.name !== 'string') return false;
}
// Validate custom patterns if present
if (c.customPatterns !== undefined) {
if (!Array.isArray(c.customPatterns)) return false;
for (const pattern of c.customPatterns) {
if (typeof pattern !== 'object' || pattern === null) return false;
const p = pattern as Record<string, unknown>;
if (typeof p.name !== 'string') return false;
if (typeof p.pattern !== 'string') return false;
if (typeof p.kind !== 'string') return false;
// Validate regex is compilable and reject patterns with known ReDoS risks
if (!isSafeRegex(p.pattern)) return false;
}
}
return true;
}
/**
* Merge configuration with defaults
*/
function mergeConfig(
defaults: CodeGraphConfig,
overrides: Partial<CodeGraphConfig>
): CodeGraphConfig {
return {
version: overrides.version ?? defaults.version,
rootDir: overrides.rootDir ?? defaults.rootDir,
include: overrides.include ?? defaults.include,
exclude: overrides.exclude ?? defaults.exclude,
languages: overrides.languages ?? defaults.languages,
frameworks: overrides.frameworks ?? defaults.frameworks,
maxFileSize: overrides.maxFileSize ?? defaults.maxFileSize,
extractDocstrings: overrides.extractDocstrings ?? defaults.extractDocstrings,
trackCallSites: overrides.trackCallSites ?? defaults.trackCallSites,
enableEmbeddings: overrides.enableEmbeddings ?? defaults.enableEmbeddings,
customPatterns: overrides.customPatterns ?? defaults.customPatterns,
};
}
/**
* Load configuration from a project
*/
export function loadConfig(projectRoot: string): CodeGraphConfig {
const configPath = getConfigPath(projectRoot);
if (!fs.existsSync(configPath)) {
// Return default config with adjusted rootDir
return {
...DEFAULT_CONFIG,
rootDir: projectRoot,
};
}
try {
const content = fs.readFileSync(configPath, 'utf-8');
const parsed = JSON.parse(content) as unknown;
// Merge with defaults to ensure all fields are present
const merged = mergeConfig(DEFAULT_CONFIG, parsed as Partial<CodeGraphConfig>);
merged.rootDir = projectRoot; // Always use actual project root
if (!validateConfig(merged)) {
throw new Error('Invalid configuration format');
}
return merged;
} catch (error) {
if (error instanceof SyntaxError) {
throw new Error(`Invalid JSON in config file: ${configPath}`);
}
throw error;
}
}
/**
* Save configuration to a project
*/
export function saveConfig(projectRoot: string, config: CodeGraphConfig): void {
const configPath = getConfigPath(projectRoot);
const dir = path.dirname(configPath);
// Ensure directory exists
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
// Create a copy without rootDir (it's always derived from project path)
const toSave = { ...config };
delete (toSave as Partial<CodeGraphConfig>).rootDir;
const content = JSON.stringify(toSave, null, 2);
// Atomic write: write to temp file then rename to prevent partial/corrupt configs
const tmpPath = configPath + '.tmp';
fs.writeFileSync(tmpPath, content, 'utf-8');
fs.renameSync(tmpPath, configPath);
}
/**
* Create default configuration for a new project
*/
export function createDefaultConfig(projectRoot: string): CodeGraphConfig {
return {
...DEFAULT_CONFIG,
rootDir: projectRoot,
};
}
/**
* Update specific configuration values
*/
export function updateConfig(
projectRoot: string,
updates: Partial<CodeGraphConfig>
): CodeGraphConfig {
const current = loadConfig(projectRoot);
const updated = mergeConfig(current, updates);
updated.rootDir = projectRoot;
saveConfig(projectRoot, updated);
return updated;
}
/**
* Add patterns to include list
*/
export function addIncludePatterns(projectRoot: string, patterns: string[]): CodeGraphConfig {
const config = loadConfig(projectRoot);
const newPatterns = patterns.filter((p) => !config.include.includes(p));
config.include = [...config.include, ...newPatterns];
saveConfig(projectRoot, config);
return config;
}
/**
* Add patterns to exclude list
*/
export function addExcludePatterns(projectRoot: string, patterns: string[]): CodeGraphConfig {
const config = loadConfig(projectRoot);
const newPatterns = patterns.filter((p) => !config.exclude.includes(p));
config.exclude = [...config.exclude, ...newPatterns];
saveConfig(projectRoot, config);
return config;
}
/**
* Add a custom pattern
*/
export function addCustomPattern(
projectRoot: string,
name: string,
pattern: string,
kind: NodeKind
): CodeGraphConfig {
const config = loadConfig(projectRoot);
if (!config.customPatterns) {
config.customPatterns = [];
}
// Check for duplicate name
const existing = config.customPatterns.find((p) => p.name === name);
if (existing) {
existing.pattern = pattern;
existing.kind = kind;
} else {
config.customPatterns.push({ name, pattern, kind });
}
saveConfig(projectRoot, config);
return config;
}
/**
* Check if a file path matches the include/exclude patterns
*/
export function shouldIncludeFile(filePath: string, config: CodeGraphConfig): boolean {
// Normalize to forward slashes so Windows backslash paths match glob patterns
filePath = normalizePath(filePath);
// Simple glob matching (for now, just check if any pattern matches)
// A full implementation would use a proper glob library
const matchesPattern = (pattern: string, filePath: string): boolean => {
return picomatch.isMatch(filePath, pattern, { dot: true });
};
// Check exclude patterns first
for (const pattern of config.exclude) {
if (matchesPattern(pattern, filePath)) {
return false;
}
}
// Check include patterns
for (const pattern of config.include) {
if (matchesPattern(pattern, filePath)) {
return true;
}
}
// Default to not including if no pattern matches
return false;
}