lynkr
Version:
Self-hosted LLM gateway and tier-routing proxy for Claude Code, Cursor, and Codex. Routes across Ollama, AWS Bedrock, OpenRouter, Databricks, Azure OpenAI, llama.cpp, and LM Studio with prompt caching, MCP tools, and 60-80% cost savings.
539 lines (469 loc) • 17.6 kB
JavaScript
/**
* Graphify Integration — Knowledge Graph for Code Intelligence
*
* Communicates with Graphify's CLI to provide blast radius analysis,
* god node detection, community cohesion, surprise scoring, and
* structural complexity signals for intelligent routing decisions.
*
* Workspace resolution order (per-request):
* 1. Explicit workspace passed by caller (e.g. from X-Lynkr-Workspace header)
* 2. Auto-detected from absolute file paths in the conversation messages
* 3. CODE_GRAPH_WORKSPACE env var
* 4. process.cwd() (last resort)
*
* Graphify: https://github.com/safishamsi/graphify
*
* @module tools/code-graph
*/
const path = require("path");
const { execFile } = require("child_process");
const config = require("../config");
const logger = require("../logger");
// ============================================================================
// CACHE
// ============================================================================
/** @type {Map<string, { data: any, ts: number }>} */
const resultCache = new Map();
const CACHE_TTL_MS = 30_000; // 30 seconds
/**
* Retrieve a cached value or null if expired / missing.
* @param {string} key
* @returns {any|null}
*/
function cacheGet(key) {
const entry = resultCache.get(key);
if (!entry) return null;
if (Date.now() - entry.ts > CACHE_TTL_MS) {
resultCache.delete(key);
return null;
}
return entry.data;
}
/**
* Store a value in the cache.
* @param {string} key
* @param {any} data
*/
function cacheSet(key, data) {
resultCache.set(key, { data, ts: Date.now() });
// Prevent unbounded growth — evict oldest entries beyond 200
if (resultCache.size > 200) {
const oldest = resultCache.keys().next().value;
resultCache.delete(oldest);
}
}
// ============================================================================
// FAILURE SUPPRESSION
// ============================================================================
/** Timestamp of the last logged warning (0 = never) */
let lastWarningTs = 0;
const WARNING_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes
/**
* Log a warning at most once per cooldown period.
* @param {string} msg
* @param {Object} [meta]
*/
function warnOnce(msg, meta = {}) {
const now = Date.now();
if (now - lastWarningTs < WARNING_COOLDOWN_MS) return;
lastWarningTs = now;
logger.warn(meta, `[graphify] ${msg}`);
}
// ============================================================================
// WORKSPACE DETECTION
// ============================================================================
/**
* Detect the workspace root from a list of absolute file paths by finding
* their longest common directory prefix.
*
* Example:
* ["/Users/bob/app/src/a.js", "/Users/bob/app/src/b.js", "/Users/bob/app/test/c.js"]
* → "/Users/bob/app"
*
* Returns null if no absolute paths are provided or they share no common root.
*
* @param {string[]} filePaths
* @returns {string|null}
*/
function detectWorkspaceFromPaths(filePaths) {
// Only consider absolute paths
const absolute = filePaths.filter((p) => path.isAbsolute(p));
if (absolute.length === 0) return null;
// Split each path into segments
const segmented = absolute.map((p) => p.split(path.sep).filter(Boolean));
// Find common prefix segments
const first = segmented[0];
let commonLength = first.length;
for (let i = 1; i < segmented.length; i++) {
const other = segmented[i];
let j = 0;
while (j < commonLength && j < other.length && first[j] === other[j]) {
j++;
}
commonLength = j;
}
if (commonLength === 0) return null;
// Reconstruct the common path — must be a directory, not a file
let common = path.sep + first.slice(0, commonLength).join(path.sep);
// If the common path looks like a file (has extension), go up one level
if (path.extname(common)) {
common = path.dirname(common);
}
// Don't return root or home-level paths — too broad to be useful
const depth = common.split(path.sep).filter(Boolean).length;
if (depth < 2) return null;
return common;
}
// ============================================================================
// CONFIGURATION HELPERS
// ============================================================================
/**
* Return resolved code-graph configuration from config module.
* @returns {{ enabled: boolean, command: string, defaultWorkspace: string, timeout: number }}
*/
function getConfig() {
const cfg = config.codeGraph || {};
return {
enabled: cfg.enabled === true,
command: cfg.command || "graphify",
defaultWorkspace: cfg.workspace || process.cwd(),
timeout: cfg.timeout || 5000,
};
}
/**
* Resolve the workspace for a given request.
*
* Priority:
* 1. Explicit workspace (from header or caller)
* 2. Auto-detected from file paths
* 3. CODE_GRAPH_WORKSPACE env var
* 4. process.cwd()
*
* @param {Object} [options]
* @param {string} [options.workspace] - Explicit workspace from caller/header
* @param {string[]} [options.filePaths] - File paths from the conversation
* @returns {string}
*/
function resolveWorkspace(options = {}) {
// 1. Explicit workspace
if (options.workspace && typeof options.workspace === "string") {
return options.workspace;
}
// 2. Auto-detect from file paths
if (Array.isArray(options.filePaths) && options.filePaths.length > 0) {
const detected = detectWorkspaceFromPaths(options.filePaths);
if (detected) {
logger.debug({ workspace: detected }, "[graphify] auto-detected workspace from file paths");
return detected;
}
}
// 3/4. Static config or cwd
return getConfig().defaultWorkspace;
}
// ============================================================================
// COMMAND EXECUTION
// ============================================================================
/**
* Execute a Graphify CLI command and parse JSON output.
*
* Graphify CLI: `graphify query --workspace <path> <query>`
* or: `graphify --workspace <path>` (builds graph + reports)
*
* @param {string} subcommand — e.g. "query", "benchmark", or null for build
* @param {string[]} [args] — additional CLI arguments
* @param {string} [workspace] — resolved workspace path
* @returns {Promise<Object|null>} Parsed JSON or null on failure
*/
function execGraph(subcommand, args = [], workspace = null) {
const cfg = getConfig();
if (!cfg.enabled) return Promise.resolve(null);
const ws = workspace || cfg.defaultWorkspace;
const parts = cfg.command.split(/\s+/);
const bin = parts[0];
const baseArgs = parts.slice(1);
const fullArgs = [
...baseArgs,
...(subcommand ? [subcommand] : []),
"--workspace",
ws,
...args,
];
return new Promise((resolve) => {
execFile(
bin,
fullArgs,
{ timeout: cfg.timeout, maxBuffer: 1024 * 1024 },
(err, stdout, stderr) => {
if (err) {
warnOnce(`command failed: ${subcommand || 'build'}`, {
err: err.message,
stderr: (stderr || "").slice(0, 200),
});
return resolve(null);
}
try {
const data = JSON.parse(stdout);
return resolve(data);
} catch (parseErr) {
// Graphify may output non-JSON for build commands — try reading report
warnOnce(`failed to parse JSON for: ${subcommand || 'build'}`, {
err: parseErr.message,
});
return resolve(null);
}
}
);
});
}
// ============================================================================
// AVAILABILITY CHECK
// ============================================================================
/** Cached availability result per workspace */
const availabilityCache = new Map(); // workspace → { value, ts }
const AVAILABILITY_TTL_MS = 60_000; // 1 minute
/**
* Check whether Graphify is configured and responsive.
* Result is cached per workspace for 60 seconds.
*
* @param {Object} [options]
* @param {string} [options.workspace] - Explicit workspace
* @param {string[]} [options.filePaths] - File paths for auto-detection
* @returns {Promise<boolean>}
*/
async function isAvailable(options = {}) {
const cfg = getConfig();
if (!cfg.enabled) return false;
const ws = resolveWorkspace(options);
const now = Date.now();
const cached = availabilityCache.get(ws);
if (cached && cached.value !== null && now - cached.ts < AVAILABILITY_TTL_MS) {
return cached.value;
}
const result = await execGraph("query", ["graph_stats"], ws);
const available = result !== null;
availabilityCache.set(ws, { value: available, ts: now });
if (available) {
logger.debug({ workspace: ws }, "[graphify] available");
}
return available;
}
// ============================================================================
// PUBLIC API
// ============================================================================
/**
* @typedef {Object} CodeGraphOptions
* @property {string} [workspace] - Explicit workspace path (e.g. from X-Lynkr-Workspace header)
* @property {string[]} [filePaths] - File paths from conversation (used for auto-detection)
*/
/**
* Get blast radius for a set of file paths.
*
* Uses Graphify's `query get_neighbors` on each file to find affected nodes,
* then aggregates into blast radius metrics.
*
* @param {string[]} filePaths — list of file paths to analyze
* @param {CodeGraphOptions} [options]
* @returns {Promise<{ affected_files: number, affected_functions: number, affected_tests: number, dependency_depth: number, risk_score: number }|null>}
*/
async function getBlastRadius(filePaths, options = {}) {
if (!Array.isArray(filePaths) || filePaths.length === 0) return null;
const ws = resolveWorkspace({ ...options, filePaths });
const cacheKey = `blast:${ws}:${filePaths.sort().join(",")}`;
const cached = cacheGet(cacheKey);
if (cached) return cached;
// Query neighbors for each file to estimate blast radius
const result = await execGraph(
"query",
["get_neighbors", "--files", ...filePaths, "--depth", "2", "--json"],
ws
);
if (!result) return null;
// Normalize Graphify output into our standard blast radius format
const nodes = result.nodes || result.neighbors || [];
const affectedFiles = new Set();
const affectedFunctions = [];
const affectedTests = [];
let maxDepth = 0;
for (const node of nodes) {
const src = node.source_file || node.source || "";
if (src) affectedFiles.add(src);
const label = (node.label || node.id || "").toLowerCase();
if (label.includes("test") || src.includes("test")) {
affectedTests.push(node);
} else {
affectedFunctions.push(node);
}
if (node.depth && node.depth > maxDepth) maxDepth = node.depth;
}
// Risk score: based on affected count and depth
const riskScore = Math.min(100,
affectedFiles.size * 3 +
affectedFunctions.length * 2 +
maxDepth * 5
);
const normalized = {
affected_files: affectedFiles.size,
affected_functions: affectedFunctions.length,
affected_tests: affectedTests.length,
dependency_depth: maxDepth,
risk_score: riskScore,
};
cacheSet(cacheKey, normalized);
return normalized;
}
/**
* Get relevant file paths that should be included as context.
*
* Uses Graphify's BFS-based query to find related nodes.
*
* @param {string[]} filePaths — seed file paths
* @param {number} [maxFiles=20] — maximum files to return
* @param {CodeGraphOptions} [options]
* @returns {Promise<string[]|null>}
*/
async function getRelevantContext(filePaths, maxFiles = 20, options = {}) {
if (!Array.isArray(filePaths) || filePaths.length === 0) return null;
const ws = resolveWorkspace({ ...options, filePaths });
const cacheKey = `ctx:${ws}:${filePaths.sort().join(",")}:${maxFiles}`;
const cached = cacheGet(cacheKey);
if (cached) return cached;
// Use query_graph with BFS to find related files
const searchTerms = filePaths.map(f => path.basename(f, path.extname(f))).join(" ");
const result = await execGraph(
"query",
["query_graph", searchTerms, "--max-tokens", String(maxFiles * 100), "--json"],
ws
);
if (!result) return null;
// Extract unique source files from result nodes
const nodes = result.nodes || result.results || [];
const fileSet = new Set();
for (const node of nodes) {
const src = node.source_file || node.source || "";
if (src) fileSet.add(src);
}
const files = [...fileSet].slice(0, maxFiles);
if (files.length === 0) return null;
cacheSet(cacheKey, files);
return files;
}
/**
* Get complexity signals for routing decisions.
*
* Queries Graphify for god nodes, community cohesion, and structural signals
* that indicate how complex a code change is.
*
* @param {string[]} filePaths — list of file paths to analyze
* @param {CodeGraphOptions} [options]
* @returns {Promise<{ blast_radius: number, dependency_depth: number, test_coverage_pct: number, is_infrastructure: boolean, god_node_touched: boolean, community_count: number, cohesion: number }|null>}
*/
async function getComplexitySignals(filePaths, options = {}) {
if (!Array.isArray(filePaths) || filePaths.length === 0) return null;
const ws = resolveWorkspace({ ...options, filePaths });
const cacheKey = `complexity:${ws}:${filePaths.sort().join(",")}`;
const cached = cacheGet(cacheKey);
if (cached) return cached;
// Run parallel queries: neighbors (blast radius) + god_nodes + graph_stats
const [neighborsResult, godNodesResult, statsResult] = await Promise.all([
execGraph("query", ["get_neighbors", "--files", ...filePaths, "--depth", "2", "--json"], ws),
execGraph("query", ["god_nodes", "--json"], ws),
execGraph("query", ["graph_stats", "--json"], ws),
]);
// If all queries failed (tool not available), return null
if (!neighborsResult && !godNodesResult && !statsResult) return null;
// Compute blast radius from neighbors
let blastRadius = 0;
let depthMax = 0;
const affectedFiles = new Set();
if (neighborsResult) {
const nodes = neighborsResult.nodes || neighborsResult.neighbors || [];
for (const node of nodes) {
if (node.source_file) affectedFiles.add(node.source_file);
if (node.depth && node.depth > depthMax) depthMax = node.depth;
}
blastRadius = affectedFiles.size;
}
// Check if any touched file contains a god node
let godNodeTouched = false;
if (godNodesResult) {
const godNodes = godNodesResult.god_nodes || godNodesResult.nodes || godNodesResult || [];
const godFiles = new Set(
(Array.isArray(godNodes) ? godNodes : []).map(n => n.source_file || n.source || "")
);
godNodeTouched = filePaths.some(fp => {
const base = path.basename(fp);
for (const gf of godFiles) {
if (gf.includes(base) || base.includes(path.basename(gf))) return true;
}
return false;
});
}
// Extract community/cohesion from stats
let communityCount = 0;
let cohesion = 1;
if (statsResult) {
communityCount = statsResult.communities || statsResult.community_count || 0;
cohesion = statsResult.avg_cohesion ?? statsResult.cohesion ?? 1;
}
// Detect infrastructure files
const infraPatterns = [
/docker/i, /compose/i, /makefile/i, /webpack/i, /babel/i, /eslint/i,
/tsconfig/i, /package\.json/i, /\.github/i, /ci/i, /cd/i, /deploy/i,
/terraform/i, /ansible/i, /k8s/i, /kubernetes/i, /helm/i,
];
const isInfrastructure = filePaths.some(fp =>
infraPatterns.some(pattern => pattern.test(fp))
);
// Estimate test coverage from graph — ratio of test files to affected files
const testFiles = [...affectedFiles].filter(f => /test|spec|__test/i.test(f));
const testCoveragePct = affectedFiles.size > 0
? Math.round((testFiles.length / affectedFiles.size) * 100)
: 100; // Assume covered if we can't tell
const normalized = {
blast_radius: blastRadius,
dependency_depth: depthMax,
test_coverage_pct: testCoveragePct,
is_infrastructure: isInfrastructure,
god_node_touched: godNodeTouched,
community_count: communityCount,
cohesion,
};
cacheSet(cacheKey, normalized);
return normalized;
}
/**
* Get overall graph statistics.
*
* @param {CodeGraphOptions} [options]
* @returns {Promise<{ total_files: number, total_functions: number, total_edges: number, languages: string[], communities: number, god_nodes: string[] }|null>}
*/
async function getGraphStats(options = {}) {
const ws = resolveWorkspace(options);
const cacheKey = `stats:${ws}`;
const cached = cacheGet(cacheKey);
if (cached) return cached;
const result = await execGraph("query", ["graph_stats", "--json"], ws);
if (!result) return null;
const normalized = {
total_files: result.total_files ?? result.files ?? 0,
total_functions: result.total_functions ?? result.nodes ?? 0,
total_edges: result.total_edges ?? result.edges ?? 0,
languages: Array.isArray(result.languages) ? result.languages : [],
communities: result.communities ?? result.community_count ?? 0,
god_nodes: Array.isArray(result.god_nodes) ? result.god_nodes.map(n => n.label || n.id || n) : [],
};
cacheSet(cacheKey, normalized);
return normalized;
}
// ============================================================================
// EXPORTS
// ============================================================================
module.exports = {
isAvailable,
getBlastRadius,
getRelevantContext,
getComplexitySignals,
getGraphStats,
resolveWorkspace,
detectWorkspaceFromPaths,
};