Merge remote-tracking branch 'origin/main' into feat/kotlin-language-support

# Conflicts:
#	gitnexus/src/core/ingestion/parsing-processor.ts
#	gitnexus/src/core/ingestion/workers/parse-worker.ts
This commit is contained in:
Gary Magyar 2026-02-28 08:57:40 +00:00
commit da63281a5a
17 changed files with 235 additions and 73 deletions

View file

@ -14,7 +14,7 @@ export const EmbeddingStatus = () => {
startEmbeddings,
graph,
viewMode,
isBackendMode,
serverBaseUrl,
testArrayParams,
} = useAppState();
@ -22,7 +22,7 @@ export const EmbeddingStatus = () => {
const [showFallbackDialog, setShowFallbackDialog] = useState(false);
// Only show when exploring a loaded graph; hide in backend mode (no WASM DB)
if (viewMode !== 'exploring' || !graph || isBackendMode) return null;
if (viewMode !== 'exploring' || !graph || serverBaseUrl) return null;
const nodeCount = graph.nodes.length;

View file

@ -69,7 +69,9 @@ export async function fetchRepoInfo(baseUrl: string, repoName?: string): Promise
if (!response.ok) {
throw new Error(`Server returned ${response.status}: ${response.statusText}`);
}
return response.json();
const data = await response.json();
// npm gitnexus@1.3.3 returns "path"; git HEAD returns "repoPath"
return { ...data, repoPath: data.repoPath ?? data.path };
}
export async function fetchGraph(

View file

@ -1,12 +1,12 @@
{
"name": "gitnexus",
"version": "1.3.3",
"version": "1.3.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gitnexus",
"version": "1.3.3",
"version": "1.3.4",
"license": "PolyForm-Noncommercial-1.0.0",
"dependencies": {
"@huggingface/transformers": "^3.0.0",

View file

@ -1,6 +1,6 @@
{
"name": "gitnexus",
"version": "1.3.3",
"version": "1.3.4",
"description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
"author": "Abhigyan Patwari",
"license": "PolyForm-Noncommercial-1.0.0",

View file

@ -10,7 +10,9 @@ import v8 from 'v8';
import cliProgress from 'cli-progress';
import { runPipelineFromRepo } from '../core/ingestion/pipeline.js';
import { initKuzu, loadGraphToKuzu, getKuzuStats, executeQuery, executeWithReusedStatement, closeKuzu, createFTSIndex, loadCachedEmbeddings } from '../core/kuzu/kuzu-adapter.js';
import { runEmbeddingPipeline } from '../core/embeddings/embedding-pipeline.js';
// Embedding imports are lazy (dynamic import) so onnxruntime-node is never
// loaded when embeddings are not requested. This avoids crashes on Node
// versions whose ABI is not yet supported by the native binary (#89).
// disposeEmbedder intentionally not called — ONNX Runtime segfaults on cleanup (see #38)
import { getStoragePaths, saveMeta, loadMeta, addToGitignore, registerRepo, getGlobalRegistryPath } from '../storage/repo-manager.js';
import { getCurrentCommit, isGitRepo, getGitRoot } from '../storage/git.js';
@ -256,6 +258,7 @@ export const analyzeCommand = async (
if (!embeddingSkipped) {
updateBar(90, 'Loading embedding model...');
const t0Emb = Date.now();
const { runEmbeddingPipeline } = await import('../core/embeddings/embedding-pipeline.js');
await runEmbeddingPipeline(
executeQuery,
executeWithReusedStatement,

View file

@ -8,7 +8,6 @@
import { startMCPServer } from '../mcp/server.js';
import { LocalBackend } from '../mcp/local/local-backend.js';
import { listRegisteredRepos } from '../storage/repo-manager.js';
export const mcpCommand = async () => {
// Prevent unhandled errors from crashing the MCP server process.
@ -21,33 +20,19 @@ export const mcpCommand = async () => {
console.error(`GitNexus MCP: unhandled rejection — ${msg}`);
});
// Load all registered repos
const entries = await listRegisteredRepos({ validate: true });
if (entries.length === 0) {
console.error('');
console.error(' GitNexus: No indexed repositories found.');
console.error('');
console.error(' To get started:');
console.error(' 1. cd into a git repository');
console.error(' 2. Run: gitnexus analyze');
console.error(' 3. Restart your editor');
console.error('');
process.exit(1);
}
// Initialize multi-repo backend from registry
// Initialize multi-repo backend from registry.
// The server starts even with 0 repos — tools call refreshRepos() lazily,
// so repos indexed after the server starts are discovered automatically.
const backend = new LocalBackend();
const ok = await backend.init();
await backend.init();
if (!ok) {
console.error('GitNexus: Failed to initialize backend from registry.');
process.exit(1);
const repos = await backend.listRepos();
if (repos.length === 0) {
console.error('GitNexus: No indexed repos yet. Run `gitnexus analyze` in a git repo — the server will pick it up automatically.');
} else {
console.error(`GitNexus: MCP server starting with ${repos.length} repo(s): ${repos.map(r => r.name).join(', ')}`);
}
const repoNames = (await backend.listRepos()).map(r => r.name);
console.error(`GitNexus: MCP server starting with ${repoNames.length} repo(s): ${repoNames.join(', ')}`);
// Start MCP server (serves all repos)
// Start MCP server (serves all repos, discovers new ones lazily)
await startMCPServer(backend);
};

View file

@ -7,7 +7,7 @@
import path from 'path';
import readline from 'readline';
import { execSync } from 'child_process';
import { execSync, execFileSync } from 'child_process';
import cliProgress from 'cli-progress';
import { getGitRoot, isGitRepo } from '../storage/git.js';
import { getStoragePaths, loadMeta, loadCLIConfig, saveCLIConfig } from '../storage/repo-manager.js';
@ -343,10 +343,11 @@ function hasGhCLI(): boolean {
function publishGist(htmlPath: string): { url: string; rawUrl: string } | null {
try {
const output = execSync(
`gh gist create "${htmlPath}" --desc "Repository Wiki — generated by GitNexus" --public`,
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] },
).trim();
const output = execFileSync('gh', [
'gist', 'create', htmlPath,
'--desc', 'Repository Wiki — generated by GitNexus',
'--public',
], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
// gh gist create prints the gist URL as the last line
const lines = output.split('\n');

View file

@ -42,6 +42,9 @@ export type NodeProperties = {
endLine?: number,
language?: string,
isExported?: boolean,
// Optional AST-derived framework hint (e.g. @Controller, @GetMapping)
astFrameworkMultiplier?: number,
astFrameworkReason?: string,
// Community-specific properties
heuristicLabel?: string,
cohesion?: number,
@ -113,4 +116,4 @@ export interface KnowledgeGraph {
addRelationship: (relationship: GraphRelationship) => void,
removeNode: (nodeId: string) => boolean,
removeNodesByFile: (filePath: string) => number,
}
}

View file

@ -1,8 +1,10 @@
/**
* Framework Detection
*
* Detects frameworks from file path patterns and provides entry point multipliers.
* This enables framework-aware entry point scoring.
* Detects frameworks from:
* 1) file path patterns
* 2) AST definition text (decorators/annotations/attributes)
* and provides entry point multipliers for process scoring.
*
* DESIGN: Returns null for unknown frameworks, which causes a 1.0 multiplier
* (no bonus, no penalty) - same behavior as before this feature.
@ -272,12 +274,12 @@ export function detectFrameworkFromPath(filePath: string): FrameworkHint | null
}
// ============================================================================
// FUTURE: AST-BASED PATTERNS (for Phase 3)
// AST-BASED FRAMEWORK DETECTION
// ============================================================================
/**
* Patterns that indicate entry points within code (for future AST-based detection)
* These would require parsing decorators/annotations in the code itself.
* Patterns that indicate framework entry points within code definitions.
* These are matched against AST node text (class/method/function declaration text).
*/
export const FRAMEWORK_AST_PATTERNS = {
// JavaScript/TypeScript decorators
@ -307,3 +309,63 @@ export const FRAMEWORK_AST_PATTERNS = {
'axum': ['Router::new'],
'rocket': ['#[get', '#[post'],
};
interface AstFrameworkPatternConfig {
framework: string;
entryPointMultiplier: number;
reason: string;
patterns: string[];
}
const AST_FRAMEWORK_PATTERNS_BY_LANGUAGE: Record<string, AstFrameworkPatternConfig[]> = {
javascript: [
{ framework: 'nestjs', entryPointMultiplier: 3.2, reason: 'nestjs-decorator', patterns: FRAMEWORK_AST_PATTERNS.nestjs },
],
typescript: [
{ framework: 'nestjs', entryPointMultiplier: 3.2, reason: 'nestjs-decorator', patterns: FRAMEWORK_AST_PATTERNS.nestjs },
],
python: [
{ framework: 'fastapi', entryPointMultiplier: 3.0, reason: 'fastapi-decorator', patterns: FRAMEWORK_AST_PATTERNS.fastapi },
{ framework: 'flask', entryPointMultiplier: 2.8, reason: 'flask-decorator', patterns: FRAMEWORK_AST_PATTERNS.flask },
],
java: [
{ framework: 'spring', entryPointMultiplier: 3.2, reason: 'spring-annotation', patterns: FRAMEWORK_AST_PATTERNS.spring },
{ framework: 'jaxrs', entryPointMultiplier: 3.0, reason: 'jaxrs-annotation', patterns: FRAMEWORK_AST_PATTERNS.jaxrs },
],
csharp: [
{ framework: 'aspnet', entryPointMultiplier: 3.2, reason: 'aspnet-attribute', patterns: FRAMEWORK_AST_PATTERNS.aspnet },
],
php: [
{ framework: 'laravel', entryPointMultiplier: 3.0, reason: 'php-route-attribute', patterns: FRAMEWORK_AST_PATTERNS.laravel },
],
};
/**
* Detect framework entry points from AST definition text (decorators/annotations/attributes).
* Returns null if no known pattern is found.
*/
export function detectFrameworkFromAST(
language: string,
definitionText: string
): FrameworkHint | null {
if (!language || !definitionText) return null;
const configs = AST_FRAMEWORK_PATTERNS_BY_LANGUAGE[language.toLowerCase()];
if (!configs || configs.length === 0) return null;
const normalized = definitionText.toLowerCase();
for (const cfg of configs) {
for (const pattern of cfg.patterns) {
if (normalized.includes(pattern.toLowerCase())) {
return {
framework: cfg.framework,
entryPointMultiplier: cfg.entryPointMultiplier,
reason: cfg.reason,
};
}
}
}
return null;
}

View file

@ -6,6 +6,7 @@ import { generateId } from '../../lib/utils.js';
import { SymbolTable } from './symbol-table.js';
import { ASTCache } from './ast-cache.js';
import { findSiblingChild, getLanguageFromFilename, yieldToEventLoop } from './utils.js';
import { detectFrameworkFromAST } from './framework-detection.js';
import { WorkerPool } from './workers/worker-pool.js';
import type { ParseWorkerResult, ParseWorkerInput, ExtractedImport, ExtractedCall, ExtractedHeritage } from './workers/parse-worker.js';
@ -17,6 +18,38 @@ export interface WorkerExtractedData {
heritage: ExtractedHeritage[];
}
const getDefinitionNodeFromCaptures = (captureMap: Record<string, any>): any | null => {
const definitionKeys = [
'definition.function',
'definition.class',
'definition.interface',
'definition.method',
'definition.struct',
'definition.enum',
'definition.namespace',
'definition.module',
'definition.trait',
'definition.impl',
'definition.type',
'definition.const',
'definition.static',
'definition.typedef',
'definition.macro',
'definition.union',
'definition.property',
'definition.record',
'definition.delegate',
'definition.annotation',
'definition.constructor',
'definition.template',
];
for (const key of definitionKeys) {
if (captureMap[key]) return captureMap[key];
}
return null;
};
// ============================================================================
// EXPORT DETECTION - Language-specific visibility detection
// ============================================================================
@ -304,14 +337,25 @@ const processParsingSequential = async (
const node: GraphNode = {
id: nodeId,
label: nodeLabel as any,
properties: {
properties: (() => {
const definitionNode = getDefinitionNodeFromCaptures(captureMap);
const frameworkHint = definitionNode
? detectFrameworkFromAST(language, definitionNode.text || '')
: null;
return {
name: nodeName,
filePath: file.path,
startLine: nameNode.startPosition.row,
endLine: nameNode.endPosition.row,
language: language,
isExported: isNodeExported(nameNode, nodeName, language),
}
...(frameworkHint ? {
astFrameworkMultiplier: frameworkHint.entryPointMultiplier,
astFrameworkReason: frameworkHint.reason,
} : {}),
};
})()
};
graph.addNode(node);

View file

@ -285,7 +285,7 @@ const findEntryPoints = (
if (callees.length === 0) continue;
// Calculate entry point score using new scoring system
const { score, reasons } = calculateEntryPointScore(
const { score: baseScore, reasons } = calculateEntryPointScore(
node.properties.name,
node.properties.language || 'javascript',
node.properties.isExported ?? false,
@ -294,6 +294,13 @@ const findEntryPoints = (
filePath // Pass filePath for framework detection
);
let score = baseScore;
const astFrameworkMultiplier = node.properties.astFrameworkMultiplier ?? 1.0;
if (astFrameworkMultiplier > 1.0) {
score *= astFrameworkMultiplier;
reasons.push(`framework-ast:${node.properties.astFrameworkReason || 'decorator'}`);
}
if (score > 0) {
entryPointCandidates.push({ id: node.id, score, reasons });
}

View file

@ -14,6 +14,7 @@ import Kotlin from 'tree-sitter-kotlin';
import { SupportedLanguages } from '../../../config/supported-languages.js';
import { LANGUAGE_QUERIES } from '../tree-sitter-queries.js';
import { findSiblingChild, getLanguageFromFilename } from '../utils.js';
import { detectFrameworkFromAST } from '../framework-detection.js';
import { generateId } from '../../../lib/utils.js';
// ============================================================================
@ -30,6 +31,8 @@ interface ParsedNode {
endLine: number;
language: string;
isExported: boolean;
astFrameworkMultiplier?: number;
astFrameworkReason?: string;
description?: string;
};
}
@ -399,6 +402,38 @@ const getLabelFromCaptures = (captureMap: Record<string, any>): string | null =>
return 'CodeElement';
};
const getDefinitionNodeFromCaptures = (captureMap: Record<string, any>): any | null => {
const definitionKeys = [
'definition.function',
'definition.class',
'definition.interface',
'definition.method',
'definition.struct',
'definition.enum',
'definition.namespace',
'definition.module',
'definition.trait',
'definition.impl',
'definition.type',
'definition.const',
'definition.static',
'definition.typedef',
'definition.macro',
'definition.union',
'definition.property',
'definition.record',
'definition.delegate',
'definition.annotation',
'definition.constructor',
'definition.template',
];
for (const key of definitionKeys) {
if (captureMap[key]) return captureMap[key];
}
return null;
};
// ============================================================================
// Process a batch of files
// ============================================================================
@ -706,6 +741,11 @@ const processFileGroup = (
}
}
const definitionNode = getDefinitionNodeFromCaptures(captureMap);
const frameworkHint = definitionNode
? detectFrameworkFromAST(language, definitionNode.text || '')
: null;
result.nodes.push({
id: nodeId,
label: nodeLabel,
@ -716,6 +756,10 @@ const processFileGroup = (
endLine: nameNode.endPosition.row,
language: language,
isExported: isNodeExported(nameNode, nodeName, language),
...(frameworkHint ? {
astFrameworkMultiplier: frameworkHint.entryPointMultiplier,
astFrameworkReason: frameworkHint.reason,
} : {}),
...(description !== undefined ? { description } : {}),
},
});

View file

@ -674,21 +674,18 @@ export const getEmbeddingTableName = (): string => EMBEDDING_TABLE_NAME;
/**
* Load the FTS extension (required before using FTS functions).
* Safe to call multiple times tracks loaded state.
* Safe to call multiple times tracks loaded state via module-level ftsLoaded.
*/
export const loadFTSExtension = async (): Promise<void> => {
if (ftsLoaded) return;
if (!conn) {
throw new Error('KuzuDB not initialized. Call initKuzu first.');
}
if (ftsLoaded) return;
try {
await conn.query('INSTALL fts');
await conn.query('LOAD EXTENSION fts');
ftsLoaded = true;
} catch {
// Extension may already be loaded
ftsLoaded = true;
}
ftsLoaded = true;
};

View file

@ -12,7 +12,7 @@
import fs from 'fs/promises';
import path from 'path';
import { execSync } from 'child_process';
import { execSync, execFileSync } from 'child_process';
import {
initWikiDb,
@ -712,8 +712,8 @@ export class WikiGenerator {
private getChangedFiles(fromCommit: string, toCommit: string): string[] {
try {
const output = execSync(
`git diff ${fromCommit}..${toCommit} --name-only`,
const output = execFileSync(
'git', ['diff', `${fromCommit}..${toCommit}`, '--name-only'],
{ cwd: this.repoPath },
).toString().trim();
return output ? output.split('\n').filter(Boolean) : [];

View file

@ -9,7 +9,8 @@
import fs from 'fs/promises';
import path from 'path';
import { initKuzu, executeQuery, closeKuzu, isKuzuReady } from '../core/kuzu-adapter.js';
import { embedQuery, getEmbeddingDims, disposeEmbedder } from '../core/embedder.js';
// Embedding imports are lazy (dynamic import) to avoid loading onnxruntime-node
// at MCP server startup — crashes on unsupported Node ABI versions (#89)
// git utilities available if needed
// import { isGitRepo, getCurrentCommit, getGitRoot } from '../../storage/git.js';
import {
@ -586,6 +587,7 @@ export class LocalBackend {
const tableCheck = await executeQuery(repo.id, `MATCH (e:CodeEmbedding) RETURN COUNT(*) AS cnt LIMIT 1`);
if (!tableCheck.length || (tableCheck[0].cnt ?? tableCheck[0][0]) === 0) return [];
const { embedQuery, getEmbeddingDims } = await import('../core/embedder.js');
const queryVec = await embedQuery(query);
const dims = getEmbeddingDims();
const queryVecStr = `[${queryVec.join(',')}]`;
@ -1027,30 +1029,30 @@ export class LocalBackend {
await this.ensureInitialized(repo.id);
const scope = params.scope || 'unstaged';
const { execSync } = await import('child_process');
// Build git diff command based on scope
let diffCmd: string;
const { execFileSync } = await import('child_process');
// Build git diff args based on scope (using execFileSync to avoid shell injection)
let diffArgs: string[];
switch (scope) {
case 'staged':
diffCmd = 'git diff --staged --name-only';
diffArgs = ['diff', '--staged', '--name-only'];
break;
case 'all':
diffCmd = 'git diff HEAD --name-only';
diffArgs = ['diff', 'HEAD', '--name-only'];
break;
case 'compare':
if (!params.base_ref) return { error: 'base_ref is required for "compare" scope' };
diffCmd = `git diff ${params.base_ref} --name-only`;
diffArgs = ['diff', params.base_ref, '--name-only'];
break;
case 'unstaged':
default:
diffCmd = 'git diff --name-only';
diffArgs = ['diff', '--name-only'];
break;
}
let changedFiles: string[];
try {
const output = execSync(diffCmd, { cwd: repo.repoPath, encoding: 'utf-8' });
const output = execFileSync('git', diffArgs, { cwd: repo.repoPath, encoding: 'utf-8' });
changedFiles = output.trim().split('\n').filter(f => f.length > 0);
} catch (err: any) {
return { error: `Git diff failed: ${err.message}` };
@ -1224,9 +1226,15 @@ export class LocalBackend {
// Simple text search across the repo for the old name (in files not already covered by graph)
try {
const { execSync } = await import('child_process');
const rgCmd = `rg -l --type-add "code:*.{ts,tsx,js,jsx,py,go,rs,java}" -t code "\\b${oldName}\\b" .`;
const output = execSync(rgCmd, { cwd: repo.repoPath, encoding: 'utf-8', timeout: 5000 });
const { execFileSync } = await import('child_process');
const rgArgs = [
'-l',
'--type-add', 'code:*.{ts,tsx,js,jsx,py,go,rs,java}',
'-t', 'code',
`\\b${oldName}\\b`,
'.',
];
const output = execFileSync('rg', rgArgs, { cwd: repo.repoPath, encoding: 'utf-8', timeout: 5000 });
const files = output.trim().split('\n').filter(f => f.length > 0);
for (const file of files) {
@ -1590,7 +1598,11 @@ export class LocalBackend {
async disconnect(): Promise<void> {
await closeKuzu(); // close all connections
await disposeEmbedder();
// Note: we intentionally do NOT call disposeEmbedder() here.
// ONNX Runtime's native cleanup segfaults on macOS and some Linux configs,
// and importing the embedder module on Node v24+ crashes if onnxruntime
// was never loaded during the session. Since process.exit(0) follows
// immediately after disconnect(), the OS reclaims everything. See #38, #89.
this.repos.clear();
this.contextCache.clear();
this.initializedRepos.clear();

View file

@ -5,7 +5,7 @@
* Returns a hint for the LLM to call analyze if stale.
*/
import { execSync } from 'child_process';
import { execFileSync } from 'child_process';
import path from 'path';
export interface StalenessInfo {
@ -20,8 +20,8 @@ export interface StalenessInfo {
export function checkStaleness(repoPath: string, lastCommit: string): StalenessInfo {
try {
// Get count of commits between lastCommit and HEAD
const result = execSync(
`git rev-list --count ${lastCommit}..HEAD`,
const result = execFileSync(
'git', ['rev-list', '--count', `${lastCommit}..HEAD`],
{ cwd: repoPath, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
).trim();

View file

@ -18,8 +18,8 @@ import { NODE_TABLES } from '../core/kuzu/schema.js';
import { GraphNode, GraphRelationship } from '../core/graph/types.js';
import { searchFTSFromKuzu } from '../core/search/bm25-index.js';
import { hybridSearch } from '../core/search/hybrid-search.js';
import { semanticSearch } from '../core/embeddings/embedding-pipeline.js';
import { isEmbedderReady } from '../core/embeddings/embedder.js';
// Embedding imports are lazy (dynamic import) to avoid loading onnxruntime-node
// at server startup — crashes on unsupported Node ABI versions (#89)
import { LocalBackend } from '../mcp/local/local-backend.js';
import { mountMCPEndpoints } from './mcp-http.js';
@ -230,7 +230,9 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
: 10;
const results = await withKuzuDb(kuzuPath, async () => {
const { isEmbedderReady } = await import('../core/embeddings/embedder.js');
if (isEmbedderReady()) {
const { semanticSearch } = await import('../core/embeddings/embedding-pipeline.js');
return hybridSearch(query, limit, executeQuery, semanticSearch);
}
// FTS-only fallback when embeddings aren't loaded