diff --git a/CLAUDE.md b/CLAUDE.md index d54ad954b..6d441c782 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/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, }; 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 5cf4c47f5..d3566c8c6 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 { DEAD_ROLE_PREFIX } from '../../shared/kinds.js'; @@ -160,7 +161,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 @@ -193,7 +194,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), @@ -320,6 +321,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; @@ -333,7 +335,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..2c6cd00a2 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 = @@ -49,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 dc7b301ab..262d59461 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/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); 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 87dfc6a23..cfbf0e4c4 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'; @@ -636,7 +637,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 930776faa..0ef67c08a 100644 --- a/src/graph/classifiers/risk.js +++ b/src/graph/classifiers/risk.js @@ -52,11 +52,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); @@ -70,7 +73,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..01bb8ed2f 100644 --- a/src/mcp/middleware.js +++ b/src/mcp/middleware.js @@ -2,10 +2,28 @@ * 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); +} + +/** + * 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 @@ -13,7 +31,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..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'; @@ -85,11 +86,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 +107,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 +125,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 +141,8 @@ export function outputResult(data, field, opts) { return printCsv(data, field) !== false; } if (opts.table) { - return printAutoTable(data, field) !== false; + const displayOpts = opts.display ?? loadConfig().display; + return printAutoTable(data, field, displayOpts) !== 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', () => {