From 2fa05cf6fd4315e9dcdc59d1dc8de6d8f722cb57 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Wed, 18 Mar 2026 17:57:50 -0600 Subject: [PATCH 1/7] feat: centralize hardcoded config into DEFAULTS with recursive deep merge Route ~50 behavioral constants through the existing .codegraphrc.json config system so users can tune thresholds, depths, weights, and limits without editing source code. - Add analysis, community, structure, risk, display, mcp sections to DEFAULTS - Fix mergeConfig to recurse so partial overrides of nested objects (e.g. risk.weights.complexity) preserve unspecified sibling keys - Wire analysis depths: impact, fn-impact, audit, sequence, brief, module-map - Wire risk scoring: scoreRisk accepts roleWeights/defaultRoleWeight via opts; triage passes config-based weights through - Wire search: hybrid/semantic read topK, rrfK, minScore, similarityWarnThreshold - Wire display: result-formatter maxColWidth, file-utils excerpt/scan/gather params - Wire MCP: initMcpDefaults() at server startup for config-based page sizes - Document config guidance in CLAUDE.md - Add 14 new unit tests for DEFAULTS shape, recursive merge, and config loading Impact: 24 functions changed, 18 affected --- CLAUDE.md | 2 + src/domain/analysis/brief.js | 18 ++-- src/domain/analysis/impact.js | 6 +- src/domain/analysis/module-map.js | 9 +- src/domain/search/search/hybrid.js | 9 +- src/domain/search/search/semantic.js | 17 ++-- src/features/audit.js | 3 +- src/features/sequence.js | 4 +- src/features/structure.js | 4 +- src/features/triage.js | 11 ++- src/graph/classifiers/risk.js | 7 +- src/infrastructure/config.js | 83 +++++++++++++++++- src/mcp/middleware.js | 15 +++- src/mcp/server.js | 6 ++ src/presentation/result-formatter.js | 12 +-- src/shared/file-utils.js | 31 ++++--- src/shared/paginate.js | 12 ++- tests/unit/config.test.js | 124 ++++++++++++++++++++++++++- 18 files changed, 321 insertions(+), 52 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 94efe84dc..c51271927 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -150,6 +150,8 @@ JS source is plain JavaScript (ES modules) in `src/`. No transpilation step. The - **MCP single-repo isolation:** `startMCPServer` defaults to single-repo mode — tools have no `repo` property and `list_repos` is not exposed. Passing `--multi-repo` or `--repos` to the CLI (or `options.multiRepo` / `options.allowedRepos` programmatically) enables multi-repo access. `buildToolList(multiRepo)` builds the tool list dynamically; the backward-compatible `TOOLS` export equals `buildToolList(true)` - **Credential resolution:** `loadConfig` pipeline is `mergeConfig → applyEnvOverrides → resolveSecrets`. The `apiKeyCommand` config field shells out to an external secret manager via `execFileSync` (no shell). Priority: command output > env var > file config > defaults. On failure, warns and falls back gracefully +**Configuration:** All tunable behavioral constants live in `DEFAULTS` in `src/infrastructure/config.js`, grouped by concern (`analysis`, `risk`, `search`, `display`, `community`, `structure`, `mcp`, `check`, `coChange`, `manifesto`). Users override via `.codegraphrc.json` — `mergeConfig` deep-merges recursively so partial overrides preserve sibling keys. Env vars override LLM settings (`CODEGRAPH_LLM_*`). When adding new behavioral constants, **always add them to `DEFAULTS`** and wire them through config — never introduce new hardcoded magic numbers in individual modules. Category F values (safety boundaries, standard formulas, platform concerns) are the only exception. + **Database:** SQLite at `.codegraph/graph.db` with tables: `nodes`, `edges`, `metadata`, `embeddings`, `function_complexity` ## Test Structure diff --git a/src/domain/analysis/brief.js b/src/domain/analysis/brief.js index c472afcbf..78c3d3420 100644 --- a/src/domain/analysis/brief.js +++ b/src/domain/analysis/brief.js @@ -7,6 +7,7 @@ import { findNodesByFile, openReadonlyOrFail, } from '../../db/index.js'; +import { loadConfig } from '../../infrastructure/config.js'; import { isTestFile } from '../../infrastructure/test-filter.js'; /** Symbol kinds meaningful for a file brief — excludes parameters, properties, constants. */ @@ -28,15 +29,15 @@ const BRIEF_KINDS = new Set([ * @param {{ role: string|null, callerCount: number }[]} symbols * @returns {'high'|'medium'|'low'} */ -function computeRiskTier(symbols) { +function computeRiskTier(symbols, highThreshold = 10, mediumThreshold = 3) { let maxCallers = 0; let hasCoreRole = false; for (const s of symbols) { if (s.callerCount > maxCallers) maxCallers = s.callerCount; if (s.role === 'core') hasCoreRole = true; } - if (maxCallers >= 10 || hasCoreRole) return 'high'; - if (maxCallers >= 3) return 'medium'; + if (maxCallers >= highThreshold || hasCoreRole) return 'high'; + if (maxCallers >= mediumThreshold) return 'medium'; return 'low'; } @@ -105,6 +106,11 @@ export function briefData(file, customDbPath, opts = {}) { const db = openReadonlyOrFail(customDbPath); try { const noTests = opts.noTests || false; + const config = opts.config || loadConfig(); + const callerDepth = config.analysis?.briefCallerDepth ?? 5; + const importerDepth = config.analysis?.briefImporterDepth ?? 5; + const highRiskCallers = config.analysis?.briefHighRiskCallers ?? 10; + const mediumRiskCallers = config.analysis?.briefMediumRiskCallers ?? 3; const fileNodes = findFileNodes(db, `%${file}%`); if (fileNodes.length === 0) { return { file, results: [] }; @@ -117,7 +123,7 @@ export function briefData(file, customDbPath, opts = {}) { const directImporters = [...new Set(importedBy.map((i) => i.file))]; // Transitive importer count - const totalImporterCount = countTransitiveImporters(db, [fn.id], noTests); + const totalImporterCount = countTransitiveImporters(db, [fn.id], noTests, importerDepth); // Direct imports let importsTo = findImportTargets(db, fn.id); @@ -126,7 +132,7 @@ export function briefData(file, customDbPath, opts = {}) { // Symbol definitions with roles and caller counts const defs = findNodesByFile(db, fn.file).filter((d) => BRIEF_KINDS.has(d.kind)); const symbols = defs.map((d) => { - const callerCount = countTransitiveCallers(db, d.id, noTests); + const callerCount = countTransitiveCallers(db, d.id, noTests, callerDepth); return { name: d.name, kind: d.kind, @@ -136,7 +142,7 @@ export function briefData(file, customDbPath, opts = {}) { }; }); - const riskTier = computeRiskTier(symbols); + const riskTier = computeRiskTier(symbols, highRiskCallers, mediumRiskCallers); return { file: fn.file, diff --git a/src/domain/analysis/impact.js b/src/domain/analysis/impact.js index 6bdd5464e..413ce3330 100644 --- a/src/domain/analysis/impact.js +++ b/src/domain/analysis/impact.js @@ -109,7 +109,8 @@ export function impactAnalysisData(file, customDbPath, opts = {}) { export function fnImpactData(name, customDbPath, opts = {}) { const db = openReadonlyOrFail(customDbPath); try { - const maxDepth = opts.depth || 5; + const config = opts.config || loadConfig(); + const maxDepth = opts.depth || config.analysis?.fnImpactDepth || 5; const noTests = opts.noTests || false; const hc = new Map(); @@ -387,7 +388,8 @@ export function diffImpactData(customDbPath, opts = {}) { const db = openReadonlyOrFail(customDbPath); try { const noTests = opts.noTests || false; - const maxDepth = opts.depth || 3; + const config = opts.config || loadConfig(); + const maxDepth = opts.depth || config.analysis?.impactDepth || 3; const dbPath = findDbPath(customDbPath); const repoRoot = path.resolve(path.dirname(dbPath), '..'); diff --git a/src/domain/analysis/module-map.js b/src/domain/analysis/module-map.js index daf09b33a..5d15de017 100644 --- a/src/domain/analysis/module-map.js +++ b/src/domain/analysis/module-map.js @@ -1,5 +1,6 @@ import path from 'node:path'; import { openReadonlyOrFail, testFilterSQL } from '../../db/index.js'; +import { loadConfig } from '../../infrastructure/config.js'; import { debug } from '../../infrastructure/logger.js'; import { isTestFile } from '../../infrastructure/test-filter.js'; import { findCycles } from '../graph/cycles.js'; @@ -159,7 +160,7 @@ function getEmbeddingsInfo(db) { return null; } -function computeQualityMetrics(db, testFilter) { +function computeQualityMetrics(db, testFilter, fpThreshold = FALSE_POSITIVE_CALLER_THRESHOLD) { const qualityTestFilter = testFilter.replace(/n\.file/g, 'file'); const totalCallable = db @@ -192,7 +193,7 @@ function computeQualityMetrics(db, testFilter) { HAVING caller_count > ? ORDER BY caller_count DESC `) - .all(FALSE_POSITIVE_CALLER_THRESHOLD); + .all(fpThreshold); const falsePositiveWarnings = fpRows .filter((r) => FALSE_POSITIVE_NAMES.has(r.name.includes('.') ? r.name.split('.').pop() : r.name), @@ -314,6 +315,7 @@ export function statsData(customDbPath, opts = {}) { const db = openReadonlyOrFail(customDbPath); try { const noTests = opts.noTests || false; + const config = opts.config || loadConfig(); const testFilter = testFilterSQL('n.file', noTests); const testFileIds = noTests ? buildTestFileIds(db) : null; @@ -327,7 +329,8 @@ export function statsData(customDbPath, opts = {}) { const hotspots = findHotspots(db, noTests, 5); const embeddings = getEmbeddingsInfo(db); - const quality = computeQualityMetrics(db, testFilter); + const fpThreshold = config.analysis?.falsePositiveCallers ?? FALSE_POSITIVE_CALLER_THRESHOLD; + const quality = computeQualityMetrics(db, testFilter, fpThreshold); const roles = countRoles(db, noTests); const complexity = getComplexitySummary(db, testFilter); diff --git a/src/domain/search/search/hybrid.js b/src/domain/search/search/hybrid.js index 6d2365683..aa5a964de 100644 --- a/src/domain/search/search/hybrid.js +++ b/src/domain/search/search/hybrid.js @@ -1,4 +1,5 @@ import { openReadonlyOrFail } from '../../../db/index.js'; +import { loadConfig } from '../../../infrastructure/config.js'; import { hasFtsIndex } from '../stores/fts5.js'; import { ftsSearchData } from './keyword.js'; import { searchData } from './semantic.js'; @@ -9,9 +10,11 @@ import { searchData } from './semantic.js'; * or null if no FTS5 index (caller should fall back to semantic-only). */ export async function hybridSearchData(query, customDbPath, opts = {}) { - const limit = opts.limit || 15; - const k = opts.rrfK || 60; - const topK = (opts.limit || 15) * 5; + const config = opts.config || loadConfig(); + const searchCfg = config.search || {}; + const limit = opts.limit || searchCfg.topK || 15; + const k = opts.rrfK || searchCfg.rrfK || 60; + const topK = (opts.limit || searchCfg.topK || 15) * 5; // Split semicolons for multi-query support const queries = diff --git a/src/domain/search/search/semantic.js b/src/domain/search/search/semantic.js index dc7b301ab..61bcf0cd6 100644 --- a/src/domain/search/search/semantic.js +++ b/src/domain/search/search/semantic.js @@ -1,3 +1,4 @@ +import { loadConfig } from '../../../infrastructure/config.js'; import { warn } from '../../../infrastructure/logger.js'; import { normalizeSymbol } from '../../queries.js'; import { embed } from '../models.js'; @@ -9,8 +10,10 @@ import { prepareSearch } from './prepare.js'; * Returns { results: [{ name, kind, file, line, similarity }] } or null on failure. */ export async function searchData(query, customDbPath, opts = {}) { - const limit = opts.limit || 15; - const minScore = opts.minScore || 0.2; + const config = opts.config || loadConfig(); + const searchCfg = config.search || {}; + const limit = opts.limit || searchCfg.topK || 15; + const minScore = opts.minScore || searchCfg.defaultMinScore || 0.2; const prepared = prepareSearch(customDbPath, opts); if (!prepared) return null; @@ -56,9 +59,11 @@ export async function searchData(query, customDbPath, opts = {}) { * Returns { results: [{ name, kind, file, line, rrf, queryScores }] } or null on failure. */ export async function multiSearchData(queries, customDbPath, opts = {}) { - const limit = opts.limit || 15; - const minScore = opts.minScore || 0.2; - const k = opts.rrfK || 60; + const config = opts.config || loadConfig(); + const searchCfg = config.search || {}; + const limit = opts.limit || searchCfg.topK || 15; + const minScore = opts.minScore || searchCfg.defaultMinScore || 0.2; + const k = opts.rrfK || searchCfg.rrfK || 60; const prepared = prepareSearch(customDbPath, opts); if (!prepared) return null; @@ -68,7 +73,7 @@ export async function multiSearchData(queries, customDbPath, opts = {}) { const { vectors: queryVecs, dim } = await embed(queries, modelKey); // Warn about similar queries that may bias RRF results - const SIMILARITY_WARN_THRESHOLD = 0.85; + const SIMILARITY_WARN_THRESHOLD = searchCfg.similarityWarnThreshold ?? 0.85; for (let i = 0; i < queryVecs.length; i++) { for (let j = i + 1; j < queryVecs.length; j++) { const sim = cosineSim(queryVecs[i], queryVecs[j]); diff --git a/src/features/audit.js b/src/features/audit.js index 5cc74a6c9..b9bddb2bd 100644 --- a/src/features/audit.js +++ b/src/features/audit.js @@ -100,7 +100,8 @@ function readPhase44(db, nodeId) { export function auditData(target, customDbPath, opts = {}) { const noTests = opts.noTests || false; - const maxDepth = opts.depth || 3; + const config = opts.config || loadConfig(); + const maxDepth = opts.depth || config.analysis?.auditDepth || 3; const fileFilters = normalizeFileFilter(opts.file); const kind = opts.kind; diff --git a/src/features/sequence.js b/src/features/sequence.js index cf59ddc33..8fd5a74f1 100644 --- a/src/features/sequence.js +++ b/src/features/sequence.js @@ -9,6 +9,7 @@ import { openRepo } from '../db/index.js'; import { SqliteRepository } from '../db/repository/sqlite-repository.js'; import { findMatchingNodes } from '../domain/queries.js'; +import { loadConfig } from '../infrastructure/config.js'; import { isTestFile } from '../infrastructure/test-filter.js'; import { paginateResult } from '../shared/paginate.js'; import { FRAMEWORK_ENTRY_PREFIXES } from './structure.js'; @@ -230,7 +231,8 @@ function buildParticipants(fileSet, entryFile) { export function sequenceData(name, dbPath, opts = {}) { const { repo, close } = openRepo(dbPath, opts); try { - const maxDepth = opts.depth || 10; + const config = opts.config || loadConfig(); + const maxDepth = opts.depth || config.analysis?.sequenceDepth || 10; const noTests = opts.noTests || false; const matchNode = findEntryNode(repo, name, opts); diff --git a/src/features/structure.js b/src/features/structure.js index 5899e62f7..30394d96d 100644 --- a/src/features/structure.js +++ b/src/features/structure.js @@ -1,5 +1,6 @@ import path from 'node:path'; import { getNodeId, openReadonlyOrFail, testFilterSQL } from '../db/index.js'; +import { loadConfig } from '../infrastructure/config.js'; import { debug } from '../infrastructure/logger.js'; import { isTestFile } from '../infrastructure/test-filter.js'; import { normalizePath } from '../shared/constants.js'; @@ -609,7 +610,8 @@ export function hotspotsData(customDbPath, opts = {}) { export function moduleBoundariesData(customDbPath, opts = {}) { const db = openReadonlyOrFail(customDbPath); try { - const threshold = opts.threshold || 0.3; + const config = opts.config || loadConfig(); + const threshold = opts.threshold || config.structure?.cohesionThreshold || 0.3; const dirs = db .prepare(` diff --git a/src/features/triage.js b/src/features/triage.js index 8c23875a7..7b7d36046 100644 --- a/src/features/triage.js +++ b/src/features/triage.js @@ -1,5 +1,6 @@ import { openRepo } from '../db/index.js'; import { DEFAULT_WEIGHTS, scoreRisk } from '../graph/classifiers/risk.js'; +import { loadConfig } from '../infrastructure/config.js'; import { warn } from '../infrastructure/logger.js'; import { isTestFile } from '../infrastructure/test-filter.js'; import { paginateResult } from '../shared/paginate.js'; @@ -94,7 +95,13 @@ export function triageData(customDbPath, opts = {}) { const noTests = opts.noTests || false; const minScore = opts.minScore != null ? Number(opts.minScore) : null; const sort = opts.sort || 'risk'; - const weights = { ...DEFAULT_WEIGHTS, ...(opts.weights || {}) }; + const config = opts.config || loadConfig(); + const riskConfig = config.risk || {}; + const weights = { ...DEFAULT_WEIGHTS, ...(riskConfig.weights || {}), ...(opts.weights || {}) }; + const riskOpts = { + roleWeights: riskConfig.roleWeights, + defaultRoleWeight: riskConfig.defaultRoleWeight, + }; let rows; try { @@ -114,7 +121,7 @@ export function triageData(customDbPath, opts = {}) { return { items: [], summary: EMPTY_SUMMARY(weights) }; } - const riskMetrics = scoreRisk(filtered, weights); + const riskMetrics = scoreRisk(filtered, weights, riskOpts); const items = buildTriageItems(filtered, riskMetrics); const scored = minScore != null ? items.filter((it) => it.riskScore >= minScore) : items; diff --git a/src/graph/classifiers/risk.js b/src/graph/classifiers/risk.js index cf7366bd2..0459ff42c 100644 --- a/src/graph/classifiers/risk.js +++ b/src/graph/classifiers/risk.js @@ -48,11 +48,14 @@ function round4(n) { * * @param {{ fan_in: number, cognitive: number, churn: number, mi: number, role: string|null }[]} items * @param {object} [weights] - Override DEFAULT_WEIGHTS + * @param {{ roleWeights?: object, defaultRoleWeight?: number }} [opts] - Optional role weight overrides * @returns {{ normFanIn: number, normComplexity: number, normChurn: number, normMI: number, roleWeight: number, riskScore: number }[]} * Parallel array with risk metrics for each input item. */ -export function scoreRisk(items, weights = {}) { +export function scoreRisk(items, weights = {}, opts = {}) { const w = { ...DEFAULT_WEIGHTS, ...weights }; + const rw = opts.roleWeights || ROLE_WEIGHTS; + const drw = opts.defaultRoleWeight ?? DEFAULT_ROLE_WEIGHT; const fanIns = items.map((r) => r.fan_in); const cognitives = items.map((r) => r.cognitive); @@ -66,7 +69,7 @@ export function scoreRisk(items, weights = {}) { const normMIs = normMIsRaw.map((v) => round4(1 - v)); return items.map((r, i) => { - const roleWeight = ROLE_WEIGHTS[r.role] ?? DEFAULT_ROLE_WEIGHT; + const roleWeight = rw[r.role] ?? drw; const riskScore = w.fanIn * normFanIns[i] + w.complexity * normCognitives[i] + diff --git a/src/infrastructure/config.js b/src/infrastructure/config.js index 1ab75e47f..1e3eb41ec 100644 --- a/src/infrastructure/config.js +++ b/src/infrastructure/config.js @@ -23,7 +23,7 @@ export const DEFAULTS = { }, embeddings: { model: 'nomic-v1.5', llmProvider: null }, llm: { provider: null, model: null, baseUrl: null, apiKey: null, apiKeyCommand: null }, - search: { defaultMinScore: 0.2, rrfK: 60, topK: 15 }, + search: { defaultMinScore: 0.2, rrfK: 60, topK: 15, similarityWarnThreshold: 0.85 }, ci: { failOnCycles: false, impactThreshold: null }, manifesto: { rules: { @@ -54,6 +54,80 @@ export const DEFAULTS = { minJaccard: 0.3, maxFilesPerCommit: 50, }, + analysis: { + impactDepth: 3, + fnImpactDepth: 5, + auditDepth: 3, + sequenceDepth: 10, + falsePositiveCallers: 20, + briefCallerDepth: 5, + briefImporterDepth: 5, + briefHighRiskCallers: 10, + briefMediumRiskCallers: 3, + }, + community: { + resolution: 1.0, + }, + structure: { + cohesionThreshold: 0.3, + }, + risk: { + weights: { + fanIn: 0.25, + complexity: 0.3, + churn: 0.2, + role: 0.15, + mi: 0.1, + }, + roleWeights: { + core: 1.0, + utility: 0.9, + entry: 0.8, + adapter: 0.5, + leaf: 0.2, + 'test-only': 0.1, + dead: 0.1, + 'dead-leaf': 0.0, + 'dead-entry': 0.3, + 'dead-ffi': 0.05, + 'dead-unresolved': 0.15, + }, + defaultRoleWeight: 0.5, + }, + display: { + maxColWidth: 40, + excerptLines: 50, + summaryMaxChars: 100, + jsdocEndScanLines: 10, + jsdocOpenScanLines: 20, + signatureGatherLines: 5, + }, + mcp: { + defaults: { + list_functions: 100, + query: 10, + where: 50, + node_roles: 100, + export_graph: 500, + fn_impact: 5, + context: 5, + explain: 10, + file_deps: 20, + file_exports: 20, + diff_impact: 30, + impact_analysis: 20, + semantic_search: 20, + execution_flow: 50, + hotspots: 20, + co_changes: 20, + complexity: 30, + manifesto: 50, + communities: 20, + structure: 30, + triage: 20, + ast_query: 50, + }, + }, }; /** @@ -120,7 +194,7 @@ export function resolveSecrets(config) { return config; } -function mergeConfig(defaults, overrides) { +export function mergeConfig(defaults, overrides) { const result = { ...defaults }; for (const [key, value] of Object.entries(overrides)) { if ( @@ -128,9 +202,10 @@ function mergeConfig(defaults, overrides) { typeof value === 'object' && !Array.isArray(value) && defaults[key] && - typeof defaults[key] === 'object' + typeof defaults[key] === 'object' && + !Array.isArray(defaults[key]) ) { - result[key] = { ...defaults[key], ...value }; + result[key] = mergeConfig(defaults[key], value); } else { result[key] = value; } diff --git a/src/mcp/middleware.js b/src/mcp/middleware.js index 96dc26aff..062f8dc77 100644 --- a/src/mcp/middleware.js +++ b/src/mcp/middleware.js @@ -2,10 +2,21 @@ * MCP middleware helpers — pagination defaults and limits. */ -import { MCP_DEFAULTS, MCP_MAX_LIMIT } from '../shared/paginate.js'; +import { getMcpDefaults, MCP_DEFAULTS, MCP_MAX_LIMIT } from '../shared/paginate.js'; export { MCP_DEFAULTS, MCP_MAX_LIMIT }; +/** Resolved MCP defaults (may include config overrides). Set via initMcpDefaults(). */ +let resolvedDefaults = MCP_DEFAULTS; + +/** + * Initialize MCP defaults from config. Call once at server startup. + * @param {object} [configMcpDefaults] - config.mcp.defaults overrides + */ +export function initMcpDefaults(configMcpDefaults) { + resolvedDefaults = getMcpDefaults(configMcpDefaults); +} + /** * Resolve effective limit for a tool call. * @param {object} args - Tool arguments @@ -13,7 +24,7 @@ export { MCP_DEFAULTS, MCP_MAX_LIMIT }; * @returns {number} */ export function effectiveLimit(args, toolName) { - return Math.min(args.limit ?? MCP_DEFAULTS[toolName] ?? 100, MCP_MAX_LIMIT); + return Math.min(args.limit ?? resolvedDefaults[toolName] ?? 100, MCP_MAX_LIMIT); } /** diff --git a/src/mcp/server.js b/src/mcp/server.js index bae0bbbf4..db5d3eb8d 100644 --- a/src/mcp/server.js +++ b/src/mcp/server.js @@ -7,8 +7,10 @@ import { createRequire } from 'node:module'; import { findDbPath } from '../db/index.js'; +import { loadConfig } from '../infrastructure/config.js'; import { CodegraphError, ConfigError } from '../shared/errors.js'; import { MCP_MAX_LIMIT } from '../shared/paginate.js'; +import { initMcpDefaults } from './middleware.js'; import { buildToolList } from './tool-registry.js'; import { TOOL_HANDLERS } from './tools/index.js'; @@ -91,6 +93,10 @@ export async function startMCPServer(customDbPath, options = {}) { const { allowedRepos } = options; const multiRepo = options.multiRepo || !!allowedRepos; + // Apply config-based MCP page-size overrides + const config = options.config || loadConfig(); + initMcpDefaults(config.mcp?.defaults); + const { Server, StdioServerTransport, ListToolsRequestSchema, CallToolRequestSchema } = await loadMCPSdk(); diff --git a/src/presentation/result-formatter.js b/src/presentation/result-formatter.js index 7565115b7..f26b7be16 100644 --- a/src/presentation/result-formatter.js +++ b/src/presentation/result-formatter.js @@ -85,11 +85,13 @@ const MAX_COL_WIDTH = 40; * Print data as an aligned table to stdout. * @param {object} data - Result object from a *Data() function * @param {string} field - Array field name (e.g. 'results') + * @param {{ maxColWidth?: number }} [displayOpts] */ -function printAutoTable(data, field) { +function printAutoTable(data, field, displayOpts = {}) { const prepared = prepareFlatItems(data, field); if (!prepared) return false; const { flatItems, columns } = prepared; + const colWidth = displayOpts.maxColWidth ?? MAX_COL_WIDTH; const colDefs = columns.map((col) => { const maxLen = flatItems.reduce( @@ -104,13 +106,13 @@ function printAutoTable(data, field) { }); return { header: col, - width: Math.min(maxLen, MAX_COL_WIDTH), + width: Math.min(maxLen, colWidth), align: isNumeric ? 'right' : 'left', }; }); const rows = flatItems.map((item) => - columns.map((col) => truncEnd(String(item[col] ?? ''), MAX_COL_WIDTH)), + columns.map((col) => truncEnd(String(item[col] ?? ''), colWidth)), ); console.log(formatTable({ columns: colDefs, rows })); @@ -122,7 +124,7 @@ function printAutoTable(data, field) { * * @param {object} data - Result object from a *Data() function * @param {string} field - Array field name for NDJSON streaming (e.g. 'results') - * @param {object} opts - CLI options ({ json?, ndjson?, table?, csv? }) + * @param {object} opts - CLI options ({ json?, ndjson?, table?, csv?, display?: { maxColWidth? } }) * @returns {boolean} true if output was handled (caller should return early) */ export function outputResult(data, field, opts) { @@ -138,7 +140,7 @@ export function outputResult(data, field, opts) { return printCsv(data, field) !== false; } if (opts.table) { - return printAutoTable(data, field) !== false; + return printAutoTable(data, field, opts.display) !== false; } return false; } diff --git a/src/shared/file-utils.js b/src/shared/file-utils.js index 814f54de0..c48ae9cbe 100644 --- a/src/shared/file-utils.js +++ b/src/shared/file-utils.js @@ -13,14 +13,15 @@ export function safePath(repoRoot, file) { return resolved; } -export function readSourceRange(repoRoot, file, startLine, endLine) { +export function readSourceRange(repoRoot, file, startLine, endLine, opts = {}) { try { const absPath = safePath(repoRoot, file); if (!absPath) return null; const content = fs.readFileSync(absPath, 'utf-8'); const lines = content.split('\n'); + const excerptLines = opts.excerptLines ?? 50; const start = Math.max(0, (startLine || 1) - 1); - const end = Math.min(lines.length, endLine || startLine + 50); + const end = Math.min(lines.length, endLine || startLine + excerptLines); return lines.slice(start, end).join('\n'); } catch (e) { debug(`readSourceRange failed for ${file}: ${e.message}`); @@ -28,12 +29,15 @@ export function readSourceRange(repoRoot, file, startLine, endLine) { } } -export function extractSummary(fileLines, line) { +export function extractSummary(fileLines, line, opts = {}) { if (!fileLines || !line || line <= 1) return null; const idx = line - 2; // line above the definition (0-indexed) - // Scan up to 10 lines above for JSDoc or comment + const jsdocEndScanLines = opts.jsdocEndScanLines ?? 10; + const jsdocOpenScanLines = opts.jsdocOpenScanLines ?? 20; + const summaryMaxChars = opts.summaryMaxChars ?? 100; + // Scan up for JSDoc or comment let jsdocEnd = -1; - for (let i = idx; i >= Math.max(0, idx - 10); i--) { + for (let i = idx; i >= Math.max(0, idx - jsdocEndScanLines); i--) { const trimmed = fileLines[i].trim(); if (trimmed.endsWith('*/')) { jsdocEnd = i; @@ -45,13 +49,13 @@ export function extractSummary(fileLines, line) { .replace(/^\/\/\s*/, '') .replace(/^#\s*/, '') .trim(); - return text.length > 100 ? `${text.slice(0, 100)}...` : text; + return text.length > summaryMaxChars ? `${text.slice(0, summaryMaxChars)}...` : text; } if (trimmed !== '' && !trimmed.startsWith('*') && !trimmed.startsWith('/*')) break; } if (jsdocEnd >= 0) { // Find opening /** - for (let i = jsdocEnd; i >= Math.max(0, jsdocEnd - 20); i--) { + for (let i = jsdocEnd; i >= Math.max(0, jsdocEnd - jsdocOpenScanLines); i--) { if (fileLines[i].trim().startsWith('/**')) { // Extract first non-tag, non-empty line for (let j = i + 1; j <= jsdocEnd; j++) { @@ -60,7 +64,9 @@ export function extractSummary(fileLines, line) { .replace(/^\*\s?/, '') .trim(); if (docLine && !docLine.startsWith('@') && docLine !== '/' && docLine !== '*/') { - return docLine.length > 100 ? `${docLine.slice(0, 100)}...` : docLine; + return docLine.length > summaryMaxChars + ? `${docLine.slice(0, summaryMaxChars)}...` + : docLine; } } break; @@ -70,11 +76,14 @@ export function extractSummary(fileLines, line) { return null; } -export function extractSignature(fileLines, line) { +export function extractSignature(fileLines, line, opts = {}) { if (!fileLines || !line) return null; const idx = line - 1; - // Gather up to 5 lines to handle multi-line params - const chunk = fileLines.slice(idx, Math.min(fileLines.length, idx + 5)).join('\n'); + const signatureGatherLines = opts.signatureGatherLines ?? 5; + // Gather lines to handle multi-line params + const chunk = fileLines + .slice(idx, Math.min(fileLines.length, idx + signatureGatherLines)) + .join('\n'); // JS/TS: function name(params) or (params) => or async function let m = chunk.match( diff --git a/src/shared/paginate.js b/src/shared/paginate.js index 09cc03b71..e5392a486 100644 --- a/src/shared/paginate.js +++ b/src/shared/paginate.js @@ -7,13 +7,11 @@ /** Default limits applied by MCP tool handlers (not by the programmatic API). */ export const MCP_DEFAULTS = { - // Existing list_functions: 100, query: 10, where: 50, node_roles: 100, export_graph: 500, - // Smaller defaults for rich/nested results fn_impact: 5, context: 5, explain: 10, @@ -33,6 +31,16 @@ export const MCP_DEFAULTS = { ast_query: 50, }; +/** + * Get MCP page-size defaults, optionally merged with config overrides. + * @param {object} [configDefaults] - Override map from config.mcp.defaults + * @returns {object} + */ +export function getMcpDefaults(configDefaults) { + if (!configDefaults) return MCP_DEFAULTS; + return { ...MCP_DEFAULTS, ...configDefaults }; +} + /** Hard cap to prevent abuse via MCP. */ export const MCP_MAX_LIMIT = 1000; diff --git a/tests/unit/config.test.js b/tests/unit/config.test.js index 56685830f..d402a4336 100644 --- a/tests/unit/config.test.js +++ b/tests/unit/config.test.js @@ -11,6 +11,7 @@ import { CONFIG_FILES, DEFAULTS, loadConfig, + mergeConfig, resolveSecrets, } from '../../src/infrastructure/config.js'; @@ -69,12 +70,67 @@ describe('DEFAULTS', () => { }); it('has search defaults', () => { - expect(DEFAULTS.search).toEqual({ defaultMinScore: 0.2, rrfK: 60, topK: 15 }); + expect(DEFAULTS.search).toEqual({ + defaultMinScore: 0.2, + rrfK: 60, + topK: 15, + similarityWarnThreshold: 0.85, + }); }); it('has ci defaults', () => { expect(DEFAULTS.ci).toEqual({ failOnCycles: false, impactThreshold: null }); }); + + it('has analysis defaults', () => { + expect(DEFAULTS.analysis).toEqual({ + impactDepth: 3, + fnImpactDepth: 5, + auditDepth: 3, + sequenceDepth: 10, + falsePositiveCallers: 20, + briefCallerDepth: 5, + briefImporterDepth: 5, + briefHighRiskCallers: 10, + briefMediumRiskCallers: 3, + }); + }); + + it('has risk defaults', () => { + expect(DEFAULTS.risk.weights).toEqual({ + fanIn: 0.25, + complexity: 0.3, + churn: 0.2, + role: 0.15, + mi: 0.1, + }); + expect(DEFAULTS.risk.defaultRoleWeight).toBe(0.5); + expect(DEFAULTS.risk.roleWeights.core).toBe(1.0); + }); + + it('has display defaults', () => { + expect(DEFAULTS.display).toEqual({ + maxColWidth: 40, + excerptLines: 50, + summaryMaxChars: 100, + jsdocEndScanLines: 10, + jsdocOpenScanLines: 20, + signatureGatherLines: 5, + }); + }); + + it('has community defaults', () => { + expect(DEFAULTS.community).toEqual({ resolution: 1.0 }); + }); + + it('has structure defaults', () => { + expect(DEFAULTS.structure).toEqual({ cohesionThreshold: 0.3 }); + }); + + it('has mcp defaults', () => { + expect(DEFAULTS.mcp.defaults.list_functions).toBe(100); + expect(DEFAULTS.mcp.defaults.fn_impact).toBe(5); + }); }); describe('loadConfig', () => { @@ -160,6 +216,72 @@ describe('loadConfig', () => { expect(config.search.defaultMinScore).toBe(0.2); expect(config.search.rrfK).toBe(60); }); + + it('deep-merges nested objects recursively (2+ levels)', () => { + const dir = fs.mkdtempSync(path.join(tmpDir, 'deep-merge-')); + fs.writeFileSync( + path.join(dir, '.codegraphrc.json'), + JSON.stringify({ risk: { weights: { complexity: 0.4, churn: 0.1 } } }), + ); + const config = loadConfig(dir); + // User overrides applied + expect(config.risk.weights.complexity).toBe(0.4); + expect(config.risk.weights.churn).toBe(0.1); + // Sibling keys preserved (not dropped) + expect(config.risk.weights.fanIn).toBe(0.25); + expect(config.risk.weights.role).toBe(0.15); + expect(config.risk.weights.mi).toBe(0.1); + // Sibling sections preserved + expect(config.risk.defaultRoleWeight).toBe(0.5); + expect(config.risk.roleWeights.core).toBe(1.0); + }); + + it('loads analysis overrides from config', () => { + const dir = fs.mkdtempSync(path.join(tmpDir, 'analysis-')); + fs.writeFileSync( + path.join(dir, '.codegraphrc.json'), + JSON.stringify({ analysis: { fnImpactDepth: 8, falsePositiveCallers: 30 } }), + ); + const config = loadConfig(dir); + expect(config.analysis.fnImpactDepth).toBe(8); + expect(config.analysis.falsePositiveCallers).toBe(30); + // Defaults preserved + expect(config.analysis.impactDepth).toBe(3); + expect(config.analysis.auditDepth).toBe(3); + }); +}); + +describe('mergeConfig', () => { + it('recursively merges nested objects', () => { + const defaults = { a: { b: { c: 1, d: 2 }, e: 3 } }; + const overrides = { a: { b: { c: 10 } } }; + const result = mergeConfig(defaults, overrides); + expect(result.a.b.c).toBe(10); + expect(result.a.b.d).toBe(2); + expect(result.a.e).toBe(3); + }); + + it('replaces arrays instead of merging', () => { + const defaults = { a: [1, 2, 3] }; + const overrides = { a: [4] }; + const result = mergeConfig(defaults, overrides); + expect(result.a).toEqual([4]); + }); + + it('handles overrides with keys not in defaults', () => { + const defaults = { a: 1 }; + const overrides = { b: 2 }; + const result = mergeConfig(defaults, overrides); + expect(result.a).toBe(1); + expect(result.b).toBe(2); + }); + + it('does not mutate defaults', () => { + const defaults = { a: { b: 1 } }; + const overrides = { a: { b: 2 } }; + mergeConfig(defaults, overrides); + expect(defaults.a.b).toBe(1); + }); }); describe('excludeTests hoisting', () => { From 267bfb7ab27aceb86bf1235b965d6fb3a68cebec Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Wed, 18 Mar 2026 18:22:21 -0600 Subject: [PATCH 2/7] fix: thread config.display opts through context and exports callers The file-utils functions readSourceRange, extractSummary, and extractSignature accept display opts but callers in context.js and exports.js never passed them, making the centralized display config dead code. Load config.display in contextData, explainData, and exportsData, then thread it through to all internal call sites. Impact: 9 functions changed, 5 affected --- src/domain/analysis/context.js | 65 +++++++++++++++++++++++----------- src/domain/analysis/exports.js | 12 ++++--- 2 files changed, 53 insertions(+), 24 deletions(-) diff --git a/src/domain/analysis/context.js b/src/domain/analysis/context.js index e8b5a8691..516d08422 100644 --- a/src/domain/analysis/context.js +++ b/src/domain/analysis/context.js @@ -13,6 +13,7 @@ import { getComplexityForNode, openReadonlyOrFail, } from '../../db/index.js'; +import { loadConfig } from '../../infrastructure/config.js'; import { debug } from '../../infrastructure/logger.js'; import { isTestFile } from '../../infrastructure/test-filter.js'; import { @@ -28,16 +29,16 @@ import { paginateResult } from '../../shared/paginate.js'; import { findMatchingNodes } from './symbol-lookup.js'; function buildCallees(db, node, repoRoot, getFileLines, opts) { - const { noTests, depth } = opts; + const { noTests, depth, displayOpts } = opts; const calleeRows = findCallees(db, node.id); const filteredCallees = noTests ? calleeRows.filter((c) => !isTestFile(c.file)) : calleeRows; const callees = filteredCallees.map((c) => { const cLines = getFileLines(c.file); - const summary = cLines ? extractSummary(cLines, c.line) : null; + const summary = cLines ? extractSummary(cLines, c.line, displayOpts) : null; let calleeSource = null; if (depth >= 1) { - calleeSource = readSourceRange(repoRoot, c.file, c.line, c.end_line); + calleeSource = readSourceRange(repoRoot, c.file, c.line, c.end_line, displayOpts); } return { name: c.name, @@ -70,8 +71,8 @@ function buildCallees(db, node, repoRoot, getFileLines, opts) { file: c.file, line: c.line, endLine: c.end_line || null, - summary: cLines ? extractSummary(cLines, c.line) : null, - source: readSourceRange(repoRoot, c.file, c.line, c.end_line), + summary: cLines ? extractSummary(cLines, c.line, displayOpts) : null, + source: readSourceRange(repoRoot, c.file, c.line, c.end_line, displayOpts), }); } } @@ -170,7 +171,7 @@ function getNodeChildrenSafe(db, nodeId) { } } -function explainFileImpl(db, target, getFileLines) { +function explainFileImpl(db, target, getFileLines, displayOpts) { const fileNodes = findFileNodes(db, `%${target}%`); if (fileNodes.length === 0) return []; @@ -186,8 +187,8 @@ function explainFileImpl(db, target, getFileLines) { kind: s.kind, line: s.line, role: s.role || null, - summary: fileLines ? extractSummary(fileLines, s.line) : null, - signature: fileLines ? extractSignature(fileLines, s.line) : null, + summary: fileLines ? extractSummary(fileLines, s.line, displayOpts) : null, + signature: fileLines ? extractSignature(fileLines, s.line, displayOpts) : null, }); const publicApi = symbols.filter((s) => publicIds.has(s.id)).map(mapSymbol); @@ -231,7 +232,7 @@ function explainFileImpl(db, target, getFileLines) { }); } -function explainFunctionImpl(db, target, noTests, getFileLines) { +function explainFunctionImpl(db, target, noTests, getFileLines, displayOpts) { let nodes = db .prepare( `SELECT * FROM nodes WHERE name LIKE ? AND kind IN ('function','method','class','interface','type','struct','enum','trait','record','module','constant') ORDER BY file, line`, @@ -244,8 +245,8 @@ function explainFunctionImpl(db, target, noTests, getFileLines) { return nodes.slice(0, 10).map((node) => { const fileLines = getFileLines(node.file); const lineCount = node.end_line ? node.end_line - node.line + 1 : null; - const summary = fileLines ? extractSummary(fileLines, node.line) : null; - const signature = fileLines ? extractSignature(fileLines, node.line) : null; + const summary = fileLines ? extractSummary(fileLines, node.line, displayOpts) : null; + const signature = fileLines ? extractSignature(fileLines, node.line, displayOpts) : null; const callees = findCallees(db, node.id).map((c) => ({ name: c.name, @@ -281,7 +282,15 @@ function explainFunctionImpl(db, target, noTests, getFileLines) { }); } -function explainCallees(parentResults, currentDepth, visited, db, noTests, getFileLines) { +function explainCallees( + parentResults, + currentDepth, + visited, + db, + noTests, + getFileLines, + displayOpts, +) { if (currentDepth <= 0) return; for (const r of parentResults) { const newCallees = []; @@ -289,7 +298,13 @@ function explainCallees(parentResults, currentDepth, visited, db, noTests, getFi const key = `${callee.name}:${callee.file}:${callee.line}`; if (visited.has(key)) continue; visited.add(key); - const calleeResults = explainFunctionImpl(db, callee.name, noTests, getFileLines); + const calleeResults = explainFunctionImpl( + db, + callee.name, + noTests, + getFileLines, + displayOpts, + ); const exact = calleeResults.find((cr) => cr.file === callee.file && cr.line === callee.line); if (exact) { exact._depth = (r._depth || 0) + 1; @@ -298,7 +313,7 @@ function explainCallees(parentResults, currentDepth, visited, db, noTests, getFi } if (newCallees.length > 0) { r.depDetails = newCallees; - explainCallees(newCallees, currentDepth - 1, visited, db, noTests, getFileLines); + explainCallees(newCallees, currentDepth - 1, visited, db, noTests, getFileLines, displayOpts); } } } @@ -313,6 +328,9 @@ export function contextData(name, customDbPath, opts = {}) { const noTests = opts.noTests || false; const includeTests = opts.includeTests || false; + const config = opts.config || loadConfig(); + const displayOpts = config.display || {}; + const dbPath = findDbPath(customDbPath); const repoRoot = path.resolve(path.dirname(dbPath), '..'); @@ -328,11 +346,15 @@ export function contextData(name, customDbPath, opts = {}) { const source = noSource ? null - : readSourceRange(repoRoot, node.file, node.line, node.end_line); + : readSourceRange(repoRoot, node.file, node.line, node.end_line, displayOpts); - const signature = fileLines ? extractSignature(fileLines, node.line) : null; + const signature = fileLines ? extractSignature(fileLines, node.line, displayOpts) : null; - const callees = buildCallees(db, node, repoRoot, getFileLines, { noTests, depth }); + const callees = buildCallees(db, node, repoRoot, getFileLines, { + noTests, + depth, + displayOpts, + }); const callers = buildCallers(db, node, noTests); const relatedTests = buildRelatedTests(db, node, getFileLines, includeTests); const complexityMetrics = getComplexityMetrics(db, node.id); @@ -369,6 +391,9 @@ export function explainData(target, customDbPath, opts = {}) { const depth = opts.depth || 0; const kind = isFileLikeTarget(target) ? 'file' : 'function'; + const config = opts.config || loadConfig(); + const displayOpts = config.display || {}; + const dbPath = findDbPath(customDbPath); const repoRoot = path.resolve(path.dirname(dbPath), '..'); @@ -376,12 +401,12 @@ export function explainData(target, customDbPath, opts = {}) { const results = kind === 'file' - ? explainFileImpl(db, target, getFileLines) - : explainFunctionImpl(db, target, noTests, getFileLines); + ? explainFileImpl(db, target, getFileLines, displayOpts) + : explainFunctionImpl(db, target, noTests, getFileLines, displayOpts); if (kind === 'function' && depth > 0 && results.length > 0) { const visited = new Set(results.map((r) => `${r.name}:${r.file}:${r.line}`)); - explainCallees(results, depth, visited, db, noTests, getFileLines); + explainCallees(results, depth, visited, db, noTests, getFileLines, displayOpts); } const base = { target, kind, results }; diff --git a/src/domain/analysis/exports.js b/src/domain/analysis/exports.js index 7bebac406..7c086d6e6 100644 --- a/src/domain/analysis/exports.js +++ b/src/domain/analysis/exports.js @@ -6,6 +6,7 @@ import { findNodesByFile, openReadonlyOrFail, } from '../../db/index.js'; +import { loadConfig } from '../../infrastructure/config.js'; import { debug } from '../../infrastructure/logger.js'; import { isTestFile } from '../../infrastructure/test-filter.js'; import { @@ -20,13 +21,16 @@ export function exportsData(file, customDbPath, opts = {}) { try { const noTests = opts.noTests || false; + const config = opts.config || loadConfig(); + const displayOpts = config.display || {}; + const dbFilePath = findDbPath(customDbPath); const repoRoot = path.resolve(path.dirname(dbFilePath), '..'); const getFileLines = createFileLinesReader(repoRoot); const unused = opts.unused || false; - const fileResults = exportsFileImpl(db, file, noTests, getFileLines, unused); + const fileResults = exportsFileImpl(db, file, noTests, getFileLines, unused, displayOpts); if (fileResults.length === 0) { return paginateResult( @@ -52,7 +56,7 @@ export function exportsData(file, customDbPath, opts = {}) { } } -function exportsFileImpl(db, target, noTests, getFileLines, unused) { +function exportsFileImpl(db, target, noTests, getFileLines, unused, displayOpts) { const fileNodes = findFileNodes(db, `%${target}%`); if (fileNodes.length === 0) return []; @@ -100,8 +104,8 @@ function exportsFileImpl(db, target, noTests, getFileLines, unused) { line: s.line, endLine: s.end_line ?? null, role: s.role || null, - signature: fileLines ? extractSignature(fileLines, s.line) : null, - summary: fileLines ? extractSummary(fileLines, s.line) : null, + signature: fileLines ? extractSignature(fileLines, s.line, displayOpts) : null, + summary: fileLines ? extractSummary(fileLines, s.line, displayOpts) : null, consumers: consumers.map((c) => ({ name: c.name, file: c.file, line: c.line })), consumerCount: consumers.length, }; From 6aba162ed85a18c3f61c2c4c9f78cc435db907cf Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Wed, 18 Mar 2026 18:22:34 -0600 Subject: [PATCH 3/7] fix: wire config.community.resolution into communitiesData The community.resolution default was defined in DEFAULTS but never consumed. communitiesData read opts.resolution ?? 1.0 directly, ignoring the config. Now falls back through opts.resolution ?? config.community?.resolution ?? 1.0. Impact: 1 functions changed, 1 affected --- src/features/communities.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/features/communities.js b/src/features/communities.js index f850dc8d5..1f3a54d71 100644 --- a/src/features/communities.js +++ b/src/features/communities.js @@ -2,6 +2,7 @@ import path from 'node:path'; import { openRepo } from '../db/index.js'; import { louvainCommunities } from '../graph/algorithms/louvain.js'; import { buildDependencyGraph } from '../graph/builders/dependency.js'; +import { loadConfig } from '../infrastructure/config.js'; import { paginateResult } from '../shared/paginate.js'; // ─── Directory Helpers ──────────────────────────────────────────────── @@ -144,7 +145,8 @@ export function communitiesData(customDbPath, opts = {}) { }; } - const resolution = opts.resolution ?? 1.0; + const config = opts.config || loadConfig(); + const resolution = opts.resolution ?? config.community?.resolution ?? 1.0; const { assignments, modularity } = louvainCommunities(graph, { resolution }); const { communities, communityDirs } = buildCommunityObjects(graph, assignments, opts); From 741f3dccddc4583a0749147ccce7691e36ee29c7 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Wed, 18 Mar 2026 18:22:44 -0600 Subject: [PATCH 4/7] fix: add resetMcpDefaults() for test isolation The module-level resolvedDefaults in middleware.js persists across tests, causing test isolation issues. Add a resetMcpDefaults() export that restores resolvedDefaults back to MCP_DEFAULTS so tests can clean up after calling initMcpDefaults(). Impact: 1 functions changed, 0 affected --- src/mcp/middleware.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/mcp/middleware.js b/src/mcp/middleware.js index 062f8dc77..01bb8ed2f 100644 --- a/src/mcp/middleware.js +++ b/src/mcp/middleware.js @@ -17,6 +17,13 @@ export function initMcpDefaults(configMcpDefaults) { resolvedDefaults = getMcpDefaults(configMcpDefaults); } +/** + * Reset MCP defaults back to the base defaults. Useful for test isolation. + */ +export function resetMcpDefaults() { + resolvedDefaults = MCP_DEFAULTS; +} + /** * Resolve effective limit for a tool call. * @param {object} args - Tool arguments From 0cc9e719ed564b268776e5cce47a8e6532f69a72 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Wed, 18 Mar 2026 18:22:59 -0600 Subject: [PATCH 5/7] fix: use nullish coalescing for minScore and limit in search Replace || with ?? for minScore, limit, rrfK, and topK in semantic.js and hybrid.js so that explicit 0 values are respected instead of being treated as falsy and falling through to defaults. Impact: 3 functions changed, 1 affected --- src/domain/search/search/hybrid.js | 8 ++++---- src/domain/search/search/semantic.js | 10 +++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/domain/search/search/hybrid.js b/src/domain/search/search/hybrid.js index aa5a964de..2c6cd00a2 100644 --- a/src/domain/search/search/hybrid.js +++ b/src/domain/search/search/hybrid.js @@ -12,9 +12,9 @@ import { searchData } from './semantic.js'; export async function hybridSearchData(query, customDbPath, opts = {}) { const config = opts.config || loadConfig(); const searchCfg = config.search || {}; - const limit = opts.limit || searchCfg.topK || 15; - const k = opts.rrfK || searchCfg.rrfK || 60; - const topK = (opts.limit || searchCfg.topK || 15) * 5; + const limit = opts.limit ?? searchCfg.topK ?? 15; + const k = opts.rrfK ?? searchCfg.rrfK ?? 60; + const topK = (opts.limit ?? searchCfg.topK ?? 15) * 5; // Split semicolons for multi-query support const queries = @@ -52,7 +52,7 @@ export async function hybridSearchData(query, customDbPath, opts = {}) { const semData = await searchData(q, customDbPath, { ...opts, limit: topK, - minScore: opts.minScore || 0.2, + minScore: opts.minScore ?? 0.2, }); if (semData?.results) { rankedLists.push( diff --git a/src/domain/search/search/semantic.js b/src/domain/search/search/semantic.js index 61bcf0cd6..262d59461 100644 --- a/src/domain/search/search/semantic.js +++ b/src/domain/search/search/semantic.js @@ -12,8 +12,8 @@ import { prepareSearch } from './prepare.js'; export async function searchData(query, customDbPath, opts = {}) { const config = opts.config || loadConfig(); const searchCfg = config.search || {}; - const limit = opts.limit || searchCfg.topK || 15; - const minScore = opts.minScore || searchCfg.defaultMinScore || 0.2; + const limit = opts.limit ?? searchCfg.topK ?? 15; + const minScore = opts.minScore ?? searchCfg.defaultMinScore ?? 0.2; const prepared = prepareSearch(customDbPath, opts); if (!prepared) return null; @@ -61,9 +61,9 @@ export async function searchData(query, customDbPath, opts = {}) { export async function multiSearchData(queries, customDbPath, opts = {}) { const config = opts.config || loadConfig(); const searchCfg = config.search || {}; - const limit = opts.limit || searchCfg.topK || 15; - const minScore = opts.minScore || searchCfg.defaultMinScore || 0.2; - const k = opts.rrfK || searchCfg.rrfK || 60; + const limit = opts.limit ?? searchCfg.topK ?? 15; + const minScore = opts.minScore ?? searchCfg.defaultMinScore ?? 0.2; + const k = opts.rrfK ?? searchCfg.rrfK ?? 60; const prepared = prepareSearch(customDbPath, opts); if (!prepared) return null; From 169885fd02ac1debef46e24e5f717c3f7dbcd5bd Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Wed, 18 Mar 2026 18:40:34 -0600 Subject: [PATCH 6/7] fix: use nullish coalescing for cohesionThreshold in structure.js Replace || with ?? so an explicit threshold of 0.0 (meaning include all directories) is not silently discarded in favor of the config or default value. Impact: 1 functions changed, 0 affected --- src/features/structure.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/structure.js b/src/features/structure.js index 30394d96d..b9ec5d32d 100644 --- a/src/features/structure.js +++ b/src/features/structure.js @@ -611,7 +611,7 @@ export function moduleBoundariesData(customDbPath, opts = {}) { const db = openReadonlyOrFail(customDbPath); try { const config = opts.config || loadConfig(); - const threshold = opts.threshold || config.structure?.cohesionThreshold || 0.3; + const threshold = opts.threshold ?? config.structure?.cohesionThreshold ?? 0.3; const dirs = db .prepare(` From ec2ad78cf5b9d74e0a72f748c4de2fa0f447207b Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Wed, 18 Mar 2026 18:40:44 -0600 Subject: [PATCH 7/7] fix: thread config.display through outputResult for table output CLI commands pass opts to outputResult but never populate opts.display from config, making display.maxColWidth unreachable via .codegraphrc.json. Now outputResult falls back to loadConfig().display when opts.display is not provided by the caller. Impact: 1 functions changed, 2 affected --- src/presentation/result-formatter.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/presentation/result-formatter.js b/src/presentation/result-formatter.js index f26b7be16..b3fc843d0 100644 --- a/src/presentation/result-formatter.js +++ b/src/presentation/result-formatter.js @@ -1,3 +1,4 @@ +import { loadConfig } from '../infrastructure/config.js'; import { printNdjson } from '../shared/paginate.js'; import { formatTable, truncEnd } from './table.js'; @@ -140,7 +141,8 @@ export function outputResult(data, field, opts) { return printCsv(data, field) !== false; } if (opts.table) { - return printAutoTable(data, field, opts.display) !== false; + const displayOpts = opts.display ?? loadConfig().display; + return printAutoTable(data, field, displayOpts) !== false; } return false; }