gitnexus 1.6.6-rc.6 → 1.6.6-rc.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/lbug/lbug-adapter.d.ts +3 -1
- package/dist/core/lbug/lbug-adapter.js +16 -18
- package/dist/core/lbug/pool-adapter.d.ts +0 -7
- package/dist/core/lbug/pool-adapter.js +8 -32
- package/dist/core/lbug/query-params.d.ts +1 -0
- package/dist/core/lbug/query-params.js +21 -0
- package/dist/core/search/bm25-index.js +4 -6
- package/dist/mcp/local/local-backend.d.ts +18 -3
- package/dist/mcp/local/local-backend.js +105 -11
- package/dist/mcp/tools.js +10 -0
- package/dist/server/api.d.ts +3 -0
- package/dist/server/api.js +36 -24
- package/package.json +1 -1
|
@@ -66,7 +66,9 @@ export declare const initLbug: (dbPath: string) => Promise<{
|
|
|
66
66
|
* database is busy (e.g. `gitnexus analyze` holds the write lock).
|
|
67
67
|
* Each retry waits DB_LOCK_RETRY_DELAY_MS * attempt milliseconds.
|
|
68
68
|
*/
|
|
69
|
-
export declare const withLbugDb: <T>(dbPath: string, operation: () => Promise<T
|
|
69
|
+
export declare const withLbugDb: <T>(dbPath: string, operation: () => Promise<T>, options?: {
|
|
70
|
+
readOnly?: boolean;
|
|
71
|
+
}) => Promise<T>;
|
|
70
72
|
export type LbugProgressCallback = (message: string) => void;
|
|
71
73
|
export declare const loadGraphToLbug: (graph: KnowledgeGraph, repoPath: string, storagePath: string, onProgress?: LbugProgressCallback) => Promise<{
|
|
72
74
|
success: boolean;
|
|
@@ -112,6 +112,7 @@ export const splitRelCsvByLabelPair = async (csvPath, csvDir, validTables, getNo
|
|
|
112
112
|
let db = null;
|
|
113
113
|
let conn = null;
|
|
114
114
|
let currentDbPath = null;
|
|
115
|
+
let currentDbReadOnly = false;
|
|
115
116
|
let ftsLoaded = false;
|
|
116
117
|
let vectorExtensionLoaded = false;
|
|
117
118
|
/**
|
|
@@ -367,12 +368,13 @@ export const initLbug = async (dbPath) => {
|
|
|
367
368
|
* database is busy (e.g. `gitnexus analyze` holds the write lock).
|
|
368
369
|
* Each retry waits DB_LOCK_RETRY_DELAY_MS * attempt milliseconds.
|
|
369
370
|
*/
|
|
370
|
-
export const withLbugDb = async (dbPath, operation) => {
|
|
371
|
+
export const withLbugDb = async (dbPath, operation, options = {}) => {
|
|
371
372
|
let lastError;
|
|
373
|
+
const readOnly = options.readOnly === true;
|
|
372
374
|
for (let attempt = 1; attempt <= DB_LOCK_RETRY_ATTEMPTS; attempt++) {
|
|
373
375
|
try {
|
|
374
376
|
return await runWithSessionLock(async () => {
|
|
375
|
-
await ensureLbugInitialized(dbPath);
|
|
377
|
+
await ensureLbugInitialized(dbPath, readOnly);
|
|
376
378
|
return operation();
|
|
377
379
|
});
|
|
378
380
|
}
|
|
@@ -402,14 +404,14 @@ export const withLbugDb = async (dbPath, operation) => {
|
|
|
402
404
|
// but TypeScript needs an explicit throw to satisfy the return type.
|
|
403
405
|
throw lastError;
|
|
404
406
|
};
|
|
405
|
-
const ensureLbugInitialized = async (dbPath) => {
|
|
406
|
-
if (conn && currentDbPath === dbPath) {
|
|
407
|
+
const ensureLbugInitialized = async (dbPath, readOnly = false) => {
|
|
408
|
+
if (conn && currentDbPath === dbPath && currentDbReadOnly === readOnly) {
|
|
407
409
|
return { db, conn };
|
|
408
410
|
}
|
|
409
|
-
await doInitLbug(dbPath);
|
|
411
|
+
await doInitLbug(dbPath, readOnly);
|
|
410
412
|
return { db, conn };
|
|
411
413
|
};
|
|
412
|
-
const doInitLbug = async (dbPath) => {
|
|
414
|
+
const doInitLbug = async (dbPath, readOnly = false) => {
|
|
413
415
|
// Different database requested — close the old one first
|
|
414
416
|
if (conn || db) {
|
|
415
417
|
await safeClose();
|
|
@@ -486,9 +488,12 @@ const doInitLbug = async (dbPath) => {
|
|
|
486
488
|
// Ensure parent directory exists
|
|
487
489
|
const parentDir = path.dirname(dbPath);
|
|
488
490
|
await fs.mkdir(parentDir, { recursive: true });
|
|
489
|
-
const opened =
|
|
491
|
+
const opened = readOnly
|
|
492
|
+
? await openLbugConnection(lbug, dbPath, { readOnly: true })
|
|
493
|
+
: await openLbugConnection(lbug, dbPath);
|
|
490
494
|
db = opened.db;
|
|
491
495
|
conn = opened.conn;
|
|
496
|
+
currentDbReadOnly = readOnly;
|
|
492
497
|
}
|
|
493
498
|
finally {
|
|
494
499
|
await releaseInitLock();
|
|
@@ -524,7 +529,7 @@ const doInitLbug = async (dbPath) => {
|
|
|
524
529
|
throw new Error(`LadybugDB WAL corruption detected at ${dbPath}. ${WAL_RECOVERY_SUGGESTION}\n` +
|
|
525
530
|
` Original error: ${msg.slice(0, 200)}`);
|
|
526
531
|
}
|
|
527
|
-
if (!msg.includes('already exists') && !isDbBusyError(err)) {
|
|
532
|
+
if (!msg.includes('already exists') && !isDbBusyError(err) && !isReadOnlyDbError(err)) {
|
|
528
533
|
logger.warn(`⚠️ Schema creation warning: ${msg.slice(0, 120)}`);
|
|
529
534
|
}
|
|
530
535
|
}
|
|
@@ -931,11 +936,7 @@ export const batchInsertNodesToLbug = async (nodes, dbPath) => {
|
|
|
931
936
|
return { inserted, failed };
|
|
932
937
|
};
|
|
933
938
|
export const executeQuery = async (cypher) => {
|
|
934
|
-
|
|
935
|
-
throw new Error('LadybugDB not initialized. Call initLbug first.');
|
|
936
|
-
}
|
|
937
|
-
const queryResult = await conn.query(cypher);
|
|
938
|
-
return await readQueryRows(queryResult);
|
|
939
|
+
return await executePrepared(cypher, {});
|
|
939
940
|
};
|
|
940
941
|
export const streamQuery = async (cypher, onRow) => {
|
|
941
942
|
if (!conn) {
|
|
@@ -1517,17 +1518,14 @@ export const queryFTS = async (tableName, indexName, query, limit = 20, conjunct
|
|
|
1517
1518
|
if (!conn) {
|
|
1518
1519
|
throw new Error('LadybugDB not initialized. Call initLbug first.');
|
|
1519
1520
|
}
|
|
1520
|
-
// Escape backslashes and single quotes to prevent Cypher injection
|
|
1521
|
-
const escapedQuery = query.replace(/\\/g, '\\\\').replace(/'/g, "''");
|
|
1522
1521
|
const cypher = `
|
|
1523
|
-
CALL QUERY_FTS_INDEX('${tableName}', '${indexName}',
|
|
1522
|
+
CALL QUERY_FTS_INDEX('${tableName}', '${indexName}', $query, conjunctive := ${conjunctive})
|
|
1524
1523
|
RETURN node, score
|
|
1525
1524
|
ORDER BY score DESC
|
|
1526
1525
|
LIMIT ${limit}
|
|
1527
1526
|
`;
|
|
1528
1527
|
try {
|
|
1529
|
-
const
|
|
1530
|
-
const rows = await readQueryRows(queryResult);
|
|
1528
|
+
const rows = await executePrepared(cypher, { query });
|
|
1531
1529
|
return rows.map((row) => {
|
|
1532
1530
|
const node = row.node || row[0] || {};
|
|
1533
1531
|
const score = row.score ?? row[1] ?? 0;
|
|
@@ -81,10 +81,3 @@ export declare const closeLbug: (repoId?: string) => Promise<void>;
|
|
|
81
81
|
* Check if a specific repo's pool is active
|
|
82
82
|
*/
|
|
83
83
|
export declare const isLbugReady: (repoId: string) => boolean;
|
|
84
|
-
/** Regex to detect write operations in user-supplied Cypher queries.
|
|
85
|
-
* Note: CALL is NOT blocked — it's used for read-only FTS (CALL QUERY_FTS_INDEX)
|
|
86
|
-
* and vector search (CALL QUERY_VECTOR_INDEX). The database is opened in
|
|
87
|
-
* read-only mode as defense-in-depth against write procedures. */
|
|
88
|
-
export declare const CYPHER_WRITE_RE: RegExp;
|
|
89
|
-
/** Check if a Cypher query contains write operations */
|
|
90
|
-
export declare function isWriteQuery(query: string): boolean;
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import fs from 'fs/promises';
|
|
18
18
|
import lbug from '@ladybugdb/core';
|
|
19
|
-
import { loadFTSExtension } from './lbug-adapter.js';
|
|
19
|
+
import { isReadOnlyDbError, loadFTSExtension } from './lbug-adapter.js';
|
|
20
20
|
import { createLbugDatabase, isWalCorruptionError, WAL_RECOVERY_SUGGESTION, } from './lbug-config.js';
|
|
21
21
|
const pool = new Map();
|
|
22
22
|
const poolCloseListeners = new Set();
|
|
@@ -505,28 +505,7 @@ function withTimeout(promise, ms, label) {
|
|
|
505
505
|
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
506
506
|
}
|
|
507
507
|
export const executeQuery = async (repoId, cypher) => {
|
|
508
|
-
|
|
509
|
-
if (!entry) {
|
|
510
|
-
throw new Error(`LadybugDB not initialized for repo "${repoId}". Call initLbug first.`);
|
|
511
|
-
}
|
|
512
|
-
if (isWriteQuery(cypher)) {
|
|
513
|
-
throw new Error('Write operations are not allowed. The pool adapter is read-only.');
|
|
514
|
-
}
|
|
515
|
-
entry.lastUsed = Date.now();
|
|
516
|
-
const conn = await checkout(entry);
|
|
517
|
-
silenceStdout();
|
|
518
|
-
activeQueryCount++;
|
|
519
|
-
try {
|
|
520
|
-
const queryResult = await withTimeout(conn.query(cypher), QUERY_TIMEOUT_MS, 'Query');
|
|
521
|
-
const result = Array.isArray(queryResult) ? queryResult[0] : queryResult;
|
|
522
|
-
const rows = await result.getAll();
|
|
523
|
-
return rows;
|
|
524
|
-
}
|
|
525
|
-
finally {
|
|
526
|
-
activeQueryCount--;
|
|
527
|
-
restoreStdout();
|
|
528
|
-
checkin(entry, conn);
|
|
529
|
-
}
|
|
508
|
+
return await executeParameterized(repoId, cypher, {});
|
|
530
509
|
};
|
|
531
510
|
/**
|
|
532
511
|
* Execute a parameterized query on a specific repo's connection pool.
|
|
@@ -552,6 +531,12 @@ export const executeParameterized = async (repoId, cypher, params) => {
|
|
|
552
531
|
const rows = await result.getAll();
|
|
553
532
|
return rows;
|
|
554
533
|
}
|
|
534
|
+
catch (err) {
|
|
535
|
+
if (isReadOnlyDbError(err)) {
|
|
536
|
+
throw new Error('Write operations are not allowed. The pool adapter is read-only.');
|
|
537
|
+
}
|
|
538
|
+
throw err;
|
|
539
|
+
}
|
|
555
540
|
finally {
|
|
556
541
|
activeQueryCount--;
|
|
557
542
|
restoreStdout();
|
|
@@ -580,12 +565,3 @@ export const closeLbug = async (repoId) => {
|
|
|
580
565
|
* Check if a specific repo's pool is active
|
|
581
566
|
*/
|
|
582
567
|
export const isLbugReady = (repoId) => pool.has(repoId);
|
|
583
|
-
/** Regex to detect write operations in user-supplied Cypher queries.
|
|
584
|
-
* Note: CALL is NOT blocked — it's used for read-only FTS (CALL QUERY_FTS_INDEX)
|
|
585
|
-
* and vector search (CALL QUERY_VECTOR_INDEX). The database is opened in
|
|
586
|
-
* read-only mode as defense-in-depth against write procedures. */
|
|
587
|
-
export const CYPHER_WRITE_RE = /(?<!:)\b(CREATE|DELETE|SET|MERGE|REMOVE|DROP|ALTER|COPY|DETACH|FOREACH|INSTALL|LOAD)\b/i;
|
|
588
|
-
/** Check if a Cypher query contains write operations */
|
|
589
|
-
export function isWriteQuery(query) {
|
|
590
|
-
return CYPHER_WRITE_RE.test(query);
|
|
591
|
-
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const isValidQueryParams: (value: unknown) => value is Record<string, unknown>;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Return true only for plain-object payloads that can be safely used as
|
|
3
|
+
* named parameter maps in prepared Cypher execution.
|
|
4
|
+
*
|
|
5
|
+
* Validation criteria:
|
|
6
|
+
* - must be a JavaScript object (`typeof value === 'object'`)
|
|
7
|
+
* - must not be `null`
|
|
8
|
+
* - must not be an array
|
|
9
|
+
* - must have a plain-object prototype
|
|
10
|
+
* - values must be scalar bindable values (string | number | boolean | null)
|
|
11
|
+
*
|
|
12
|
+
* Rationale: prepared-statement params are key/value maps; rejecting null/array
|
|
13
|
+
* and non-plain objects keeps binding behavior predictable and avoids passing
|
|
14
|
+
* complex host objects to Ladybug parameter binding.
|
|
15
|
+
*/
|
|
16
|
+
const isBindableScalar = (value) => value === null || ['string', 'number', 'boolean'].includes(typeof value);
|
|
17
|
+
export const isValidQueryParams = (value) => value !== null &&
|
|
18
|
+
typeof value === 'object' &&
|
|
19
|
+
!Array.isArray(value) &&
|
|
20
|
+
(Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null) &&
|
|
21
|
+
Object.values(value).every(isBindableScalar);
|
|
@@ -12,16 +12,14 @@ import { FTS_INDEXES } from './fts-schema.js';
|
|
|
12
12
|
* caller can distinguish "zero matches" from "index missing".
|
|
13
13
|
*/
|
|
14
14
|
async function queryFTSViaExecutor(executor, tableName, indexName, query, limit) {
|
|
15
|
-
// Escape single quotes and backslashes to prevent Cypher injection
|
|
16
|
-
const escapedQuery = query.replace(/\\/g, '\\\\').replace(/'/g, "''");
|
|
17
15
|
const cypher = `
|
|
18
|
-
CALL QUERY_FTS_INDEX('${tableName}', '${indexName}',
|
|
16
|
+
CALL QUERY_FTS_INDEX('${tableName}', '${indexName}', $query, conjunctive := false)
|
|
19
17
|
RETURN node, score
|
|
20
18
|
ORDER BY score DESC
|
|
21
19
|
LIMIT ${limit}
|
|
22
20
|
`;
|
|
23
21
|
try {
|
|
24
|
-
const rows = await executor(cypher);
|
|
22
|
+
const rows = await executor(cypher, { query });
|
|
25
23
|
return rows.map((row) => {
|
|
26
24
|
const node = row.node || row[0] || {};
|
|
27
25
|
const score = row.score ?? row[1] ?? 0;
|
|
@@ -55,8 +53,8 @@ export const searchFTSFromLbug = async (query, limit = 20, repoId) => {
|
|
|
55
53
|
// IMPORTANT: FTS queries run sequentially to avoid connection contention.
|
|
56
54
|
// The MCP pool supports multiple connections, but FTS is best run serially.
|
|
57
55
|
const poolMod = await import('../lbug/pool-adapter.js');
|
|
58
|
-
const {
|
|
59
|
-
const executor = (cypher) =>
|
|
56
|
+
const { executeParameterized } = poolMod;
|
|
57
|
+
const executor = (cypher, params) => executeParameterized(repoId, cypher, params);
|
|
60
58
|
for (const { table, indexName } of FTS_INDEXES) {
|
|
61
59
|
const result = await queryFTSViaExecutor(executor, table, indexName, query, limit);
|
|
62
60
|
if (result !== null) {
|
|
@@ -5,8 +5,6 @@
|
|
|
5
5
|
* Supports multiple indexed repositories via a global registry.
|
|
6
6
|
* LadybugDB connections are opened lazily per repo on first query.
|
|
7
7
|
*/
|
|
8
|
-
import { isWriteQuery } from '../../core/lbug/pool-adapter.js';
|
|
9
|
-
export { isWriteQuery };
|
|
10
8
|
import { type RegistryEntry } from '../../storage/repo-manager.js';
|
|
11
9
|
import { GroupService } from '../../core/group/service.js';
|
|
12
10
|
/**
|
|
@@ -59,6 +57,22 @@ interface RepoHandle {
|
|
|
59
57
|
remoteUrl?: string;
|
|
60
58
|
stats?: RegistryEntry['stats'];
|
|
61
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* Resolve the git diff cwd for detect_changes, auto-detecting linked worktrees.
|
|
62
|
+
*
|
|
63
|
+
* When `launchCwd` is a linked worktree of the same canonical repository as
|
|
64
|
+
* `repoPath` (i.e. `getGitRoot(launchCwd)` differs from `repoPath` but both
|
|
65
|
+
* share the same `getCanonicalRepoRoot`), returns the worktree's git root so
|
|
66
|
+
* that `git diff` sees the correct working directory and index.
|
|
67
|
+
*
|
|
68
|
+
* Returns `repoPath` unchanged in all other cases (non-worktree, git
|
|
69
|
+
* unavailable, unrelated repo).
|
|
70
|
+
*
|
|
71
|
+
* Extracted as a module-level export so tests can pass any `launchCwd` instead
|
|
72
|
+
* of relying on `process.cwd()`, which is fixed to the server launch directory
|
|
73
|
+
* and cannot be changed mid-process.
|
|
74
|
+
*/
|
|
75
|
+
export declare function resolveWorktreeCwd(repoPath: string, launchCwd: string): string;
|
|
62
76
|
export declare class LocalBackend {
|
|
63
77
|
private repos;
|
|
64
78
|
private contextCache;
|
|
@@ -192,7 +206,7 @@ export declare class LocalBackend {
|
|
|
192
206
|
* Semantic vector search helper
|
|
193
207
|
*/
|
|
194
208
|
private semanticSearch;
|
|
195
|
-
executeCypher(repoName: string, query: string): Promise<any>;
|
|
209
|
+
executeCypher(repoName: string, query: string, params?: Record<string, unknown>): Promise<any>;
|
|
196
210
|
private cypher;
|
|
197
211
|
/**
|
|
198
212
|
* Format raw Cypher result rows as a markdown table for LLM readability.
|
|
@@ -354,3 +368,4 @@ export declare class LocalBackend {
|
|
|
354
368
|
queryProcessDetail(name: string, repoName?: string): Promise<any>;
|
|
355
369
|
disconnect(): Promise<void>;
|
|
356
370
|
}
|
|
371
|
+
export {};
|
|
@@ -7,14 +7,15 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import fs from 'fs/promises';
|
|
9
9
|
import path from 'path';
|
|
10
|
-
import { initLbug, executeQuery, executeParameterized, closeLbug, isLbugReady,
|
|
10
|
+
import { initLbug, executeQuery, executeParameterized, closeLbug, isLbugReady, } from '../../core/lbug/pool-adapter.js';
|
|
11
|
+
import { isValidQueryParams } from '../../core/lbug/query-params.js';
|
|
11
12
|
import { isWalCorruptionError, WAL_RECOVERY_SUGGESTION } from '../../core/lbug/lbug-config.js';
|
|
12
|
-
export { isWriteQuery };
|
|
13
13
|
// Embedding imports are lazy (dynamic import) to avoid loading onnxruntime-node
|
|
14
14
|
// at MCP server startup — crashes on unsupported Node ABI versions (#89)
|
|
15
15
|
// git utilities available if needed
|
|
16
16
|
// import { isGitRepo, getCurrentCommit, getGitRoot } from '../../storage/git.js';
|
|
17
|
-
import { parseDiffHunks } from '../../storage/git.js';
|
|
17
|
+
import { parseDiffHunks, getCanonicalRepoRoot, getGitRoot, } from '../../storage/git.js';
|
|
18
|
+
import { realpathSync } from 'fs';
|
|
18
19
|
import { listRegisteredRepos, cleanupOldKuzuFiles, } from '../../storage/repo-manager.js';
|
|
19
20
|
import { GroupService } from '../../core/group/service.js';
|
|
20
21
|
import { resolveAtGroupMemberRepoPath } from '../../core/group/resolve-at-member.js';
|
|
@@ -141,6 +142,7 @@ function logQueryError(context, err) {
|
|
|
141
142
|
const msg = err instanceof Error ? err.message : String(err);
|
|
142
143
|
logger.error({ context, err: msg }, 'GitNexus query failed');
|
|
143
144
|
}
|
|
145
|
+
const isReadOnlyDbError = (err) => /read-only database/i.test(err instanceof Error ? err.message : String(err));
|
|
144
146
|
/**
|
|
145
147
|
* Per-query latency telemetry for production aggregation (#553).
|
|
146
148
|
*
|
|
@@ -160,6 +162,55 @@ function logQueryTiming(query, phases) {
|
|
|
160
162
|
const truncated = query.length > 80 ? `${query.slice(0, 80)}…` : query;
|
|
161
163
|
logger.debug({ query: truncated, totalMs, phases }, 'GitNexus query timing');
|
|
162
164
|
}
|
|
165
|
+
/** Resolve symlinks for path comparison; falls back to path.resolve on error.
|
|
166
|
+
* Uses `realpathSync.native` (not the pure-JS `realpathSync`) so that Windows
|
|
167
|
+
* 8.3 short names (e.g. RUNNER~1 → runneradmin) are expanded to long form,
|
|
168
|
+
* matching the output of `git rev-parse --show-toplevel`. */
|
|
169
|
+
function tryRealpath(p) {
|
|
170
|
+
try {
|
|
171
|
+
return realpathSync.native(p);
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return path.resolve(p);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Resolve the git diff cwd for detect_changes, auto-detecting linked worktrees.
|
|
179
|
+
*
|
|
180
|
+
* When `launchCwd` is a linked worktree of the same canonical repository as
|
|
181
|
+
* `repoPath` (i.e. `getGitRoot(launchCwd)` differs from `repoPath` but both
|
|
182
|
+
* share the same `getCanonicalRepoRoot`), returns the worktree's git root so
|
|
183
|
+
* that `git diff` sees the correct working directory and index.
|
|
184
|
+
*
|
|
185
|
+
* Returns `repoPath` unchanged in all other cases (non-worktree, git
|
|
186
|
+
* unavailable, unrelated repo).
|
|
187
|
+
*
|
|
188
|
+
* Extracted as a module-level export so tests can pass any `launchCwd` instead
|
|
189
|
+
* of relying on `process.cwd()`, which is fixed to the server launch directory
|
|
190
|
+
* and cannot be changed mid-process.
|
|
191
|
+
*/
|
|
192
|
+
export function resolveWorktreeCwd(repoPath, launchCwd) {
|
|
193
|
+
try {
|
|
194
|
+
const launchGitRoot = getGitRoot(launchCwd);
|
|
195
|
+
if (launchGitRoot) {
|
|
196
|
+
// Normalise via realpathSync before comparing so macOS /var → /private/var
|
|
197
|
+
// symlinks (and Windows 8.3 short names) don't create false mismatches.
|
|
198
|
+
const realLaunch = tryRealpath(launchGitRoot);
|
|
199
|
+
const realRepo = tryRealpath(repoPath);
|
|
200
|
+
if (realLaunch !== realRepo) {
|
|
201
|
+
const launchCanonical = getCanonicalRepoRoot(launchCwd);
|
|
202
|
+
const repoCanonical = getCanonicalRepoRoot(repoPath);
|
|
203
|
+
if (launchCanonical && repoCanonical && launchCanonical === repoCanonical) {
|
|
204
|
+
return launchGitRoot;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
// Best-effort; fall through to repoPath.
|
|
211
|
+
}
|
|
212
|
+
return repoPath;
|
|
213
|
+
}
|
|
163
214
|
export class LocalBackend {
|
|
164
215
|
repos = new Map();
|
|
165
216
|
contextCache = new Map();
|
|
@@ -1017,27 +1068,31 @@ export class LocalBackend {
|
|
|
1017
1068
|
return [];
|
|
1018
1069
|
}
|
|
1019
1070
|
}
|
|
1020
|
-
async executeCypher(repoName, query) {
|
|
1071
|
+
async executeCypher(repoName, query, params = {}) {
|
|
1021
1072
|
const repo = await this.resolveRepo(repoName);
|
|
1022
|
-
return this.cypher(repo, { query });
|
|
1073
|
+
return this.cypher(repo, { query, params });
|
|
1023
1074
|
}
|
|
1024
|
-
async cypher(repo,
|
|
1075
|
+
async cypher(repo, request) {
|
|
1025
1076
|
await this.ensureInitialized(repo.id);
|
|
1026
1077
|
if (!isLbugReady(repo.id)) {
|
|
1027
1078
|
return { error: 'LadybugDB not ready. Index may be corrupted.' };
|
|
1028
1079
|
}
|
|
1029
|
-
|
|
1030
|
-
if (isWriteQuery(params.query)) {
|
|
1080
|
+
if (request.params !== undefined && !isValidQueryParams(request.params)) {
|
|
1031
1081
|
return {
|
|
1032
|
-
error: '
|
|
1082
|
+
error: '"params" must be a plain object with scalar values (string/number/boolean/null).',
|
|
1033
1083
|
};
|
|
1034
1084
|
}
|
|
1035
1085
|
try {
|
|
1036
|
-
const result = await
|
|
1086
|
+
const result = await executeParameterized(repo.id, request.query, request.params ?? {});
|
|
1037
1087
|
return result;
|
|
1038
1088
|
}
|
|
1039
1089
|
catch (err) {
|
|
1040
1090
|
const msg = err.message || 'Query failed';
|
|
1091
|
+
if (isReadOnlyDbError(err)) {
|
|
1092
|
+
return {
|
|
1093
|
+
error: 'Write operations (CREATE, DELETE, SET, MERGE, REMOVE, DROP, ALTER, COPY, DETACH) are not allowed. The knowledge graph is read-only.',
|
|
1094
|
+
};
|
|
1095
|
+
}
|
|
1041
1096
|
if (isWalCorruptionError(err)) {
|
|
1042
1097
|
return {
|
|
1043
1098
|
error: msg,
|
|
@@ -1770,11 +1825,50 @@ export class LocalBackend {
|
|
|
1770
1825
|
}
|
|
1771
1826
|
let diffOutput;
|
|
1772
1827
|
try {
|
|
1828
|
+
// Resolve the cwd for git diff.
|
|
1829
|
+
//
|
|
1830
|
+
// In a linked worktree (e.g. /repo/wt-feature/), the user's staged and
|
|
1831
|
+
// unstaged changes live in that worktree's separate working directory and
|
|
1832
|
+
// index. Running `git diff` from the canonical repo root sees a different
|
|
1833
|
+
// working tree and returns empty output.
|
|
1834
|
+
//
|
|
1835
|
+
// Resolution order (see resolveWorktreeCwd for details):
|
|
1836
|
+
// 1. params.worktree — explicit override, validated against the
|
|
1837
|
+
// registered repo's canonical root.
|
|
1838
|
+
// 2. Auto-detect — if the server's launch cwd (process.cwd()) is a
|
|
1839
|
+
// linked worktree of the same canonical repo, use its git root.
|
|
1840
|
+
// 3. repo.repoPath — fallback (original behaviour, handled inside
|
|
1841
|
+
// resolveWorktreeCwd when no worktree is detected).
|
|
1842
|
+
//
|
|
1843
|
+
// Start with the auto-detected value; override with the validated
|
|
1844
|
+
// explicit param when provided. This avoids a dead initial assignment.
|
|
1845
|
+
let diffCwd = resolveWorktreeCwd(repo.repoPath, process.cwd());
|
|
1846
|
+
if (params.worktree) {
|
|
1847
|
+
if (!path.isAbsolute(params.worktree)) {
|
|
1848
|
+
return {
|
|
1849
|
+
error: `worktree must be an absolute path, got: "${params.worktree}"`,
|
|
1850
|
+
};
|
|
1851
|
+
}
|
|
1852
|
+
const providedResolved = path.resolve(params.worktree);
|
|
1853
|
+
const repoCanonical = getCanonicalRepoRoot(repo.repoPath);
|
|
1854
|
+
if (!repoCanonical) {
|
|
1855
|
+
return {
|
|
1856
|
+
error: `Could not determine canonical root for repo "${repo.repoPath}". Is git available?`,
|
|
1857
|
+
};
|
|
1858
|
+
}
|
|
1859
|
+
const worktreeCanonical = getCanonicalRepoRoot(providedResolved);
|
|
1860
|
+
if (!worktreeCanonical || tryRealpath(worktreeCanonical) !== tryRealpath(repoCanonical)) {
|
|
1861
|
+
return {
|
|
1862
|
+
error: `worktree "${params.worktree}" is not a worktree of repo "${repo.repoPath}". Ensure the path is inside the same git repository.`,
|
|
1863
|
+
};
|
|
1864
|
+
}
|
|
1865
|
+
diffCwd = providedResolved;
|
|
1866
|
+
}
|
|
1773
1867
|
// maxBuffer raised from Node's 1MB default to 256MB to avoid ENOBUFS on
|
|
1774
1868
|
// repos with large unstaged/untracked diffs (e.g. unignored build folders).
|
|
1775
1869
|
// See issue: spawnSync git ENOBUFS in detect_changes(scope="unstaged").
|
|
1776
1870
|
diffOutput = execFileSync('git', diffArgs, {
|
|
1777
|
-
cwd:
|
|
1871
|
+
cwd: diffCwd,
|
|
1778
1872
|
encoding: 'utf-8',
|
|
1779
1873
|
maxBuffer: 256 * 1024 * 1024,
|
|
1780
1874
|
});
|
package/dist/mcp/tools.js
CHANGED
|
@@ -155,6 +155,10 @@ TIPS:
|
|
|
155
155
|
type: 'object',
|
|
156
156
|
properties: {
|
|
157
157
|
query: { type: 'string', description: 'Cypher query to execute' },
|
|
158
|
+
params: {
|
|
159
|
+
type: 'object',
|
|
160
|
+
description: 'Optional query parameters for placeholders (e.g. $name) to execute via prepared statement binding.',
|
|
161
|
+
},
|
|
158
162
|
repo: {
|
|
159
163
|
type: 'string',
|
|
160
164
|
description: 'Repository name or path. Omit if only one repo is indexed.',
|
|
@@ -218,6 +222,8 @@ Maps git diff hunks to indexed symbols, then traces which processes are impacted
|
|
|
218
222
|
WHEN TO USE: Before committing — to understand what your changes affect. Pre-commit review, PR preparation.
|
|
219
223
|
AFTER THIS: Review affected processes. Use context() on high-risk symbols. READ gitnexus://repo/{name}/process/{name} for full traces.
|
|
220
224
|
|
|
225
|
+
GIT WORKTREE SUPPORT: GitNexus automatically detects when the MCP server was launched from inside a linked git worktree and runs git diff against that worktree — no extra parameters needed in the common case. Pass "worktree" explicitly only when the server was started from a different directory than the worktree you are editing (e.g., the server runs from the canonical root but your changes are in a linked worktree at a different path).
|
|
226
|
+
|
|
221
227
|
Returns: changed symbols, affected processes, and a risk summary.`,
|
|
222
228
|
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
|
223
229
|
inputSchema: {
|
|
@@ -233,6 +239,10 @@ Returns: changed symbols, affected processes, and a risk summary.`,
|
|
|
233
239
|
type: 'string',
|
|
234
240
|
description: 'Branch/commit for "compare" scope (e.g., "main")',
|
|
235
241
|
},
|
|
242
|
+
worktree: {
|
|
243
|
+
type: 'string',
|
|
244
|
+
description: 'Absolute path to a linked git worktree. Pass this when your changes are in a worktree (the .git entry at that path is a file, not a directory). GitNexus will run git diff from that worktree so staged/unstaged changes are correctly detected.',
|
|
245
|
+
},
|
|
236
246
|
repo: {
|
|
237
247
|
type: 'string',
|
|
238
248
|
description: 'Repository name or path. Omit if only one repo is indexed.',
|
package/dist/server/api.d.ts
CHANGED
|
@@ -68,5 +68,8 @@ export declare const handleFileRequest: (req: {
|
|
|
68
68
|
};
|
|
69
69
|
json: (body: any) => void;
|
|
70
70
|
}, repoPath: string) => Promise<void>;
|
|
71
|
+
export declare const handleQueryRequest: (req: express.Request, res: express.Response, resolveRepo: (repoName?: string) => Promise<{
|
|
72
|
+
storagePath: string;
|
|
73
|
+
} | undefined>) => Promise<void>;
|
|
71
74
|
export declare const createServer: (port: number, host?: string) => Promise<void>;
|
|
72
75
|
export {};
|
package/dist/server/api.js
CHANGED
|
@@ -13,8 +13,8 @@ import path from 'path';
|
|
|
13
13
|
import fs from 'fs/promises';
|
|
14
14
|
import { createRequire } from 'node:module';
|
|
15
15
|
import { loadMeta, listRegisteredRepos, getStoragePath } from '../storage/repo-manager.js';
|
|
16
|
-
import { executeQuery, executePrepared, executeWithReusedStatement, streamQuery, flushWAL, closeLbug, withLbugDb, } from '../core/lbug/lbug-adapter.js';
|
|
17
|
-
import {
|
|
16
|
+
import { executeQuery, executePrepared, executeWithReusedStatement, streamQuery, flushWAL, closeLbug, withLbugDb, isReadOnlyDbError, } from '../core/lbug/lbug-adapter.js';
|
|
17
|
+
import { isValidQueryParams } from '../core/lbug/query-params.js';
|
|
18
18
|
import { NODE_TABLES } from '../_shared/index.js';
|
|
19
19
|
import { searchFTSFromLbug } from '../core/search/bm25-index.js';
|
|
20
20
|
import { hybridSearch } from '../core/search/hybrid-search.js';
|
|
@@ -533,6 +533,39 @@ export const handleFileRequest = async (req, res, repoPath) => {
|
|
|
533
533
|
}
|
|
534
534
|
}
|
|
535
535
|
};
|
|
536
|
+
export const handleQueryRequest = async (req, res, resolveRepo) => {
|
|
537
|
+
try {
|
|
538
|
+
const cypher = req.body.cypher;
|
|
539
|
+
if (!cypher) {
|
|
540
|
+
res.status(400).json({ error: 'Missing "cypher" in request body' });
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
const queryParams = req.body.params;
|
|
544
|
+
if (queryParams !== undefined && !isValidQueryParams(queryParams)) {
|
|
545
|
+
res.status(400).json({
|
|
546
|
+
error: '"params" must be a plain object with scalar values (string/number/boolean/null)',
|
|
547
|
+
});
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
const entry = await resolveRepo(requestedRepo(req));
|
|
551
|
+
if (!entry) {
|
|
552
|
+
res.status(404).json({ error: 'Repository not found' });
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
const lbugPath = path.join(entry.storagePath, 'lbug');
|
|
556
|
+
const result = await withLbugDb(lbugPath, () => executePrepared(cypher, queryParams ?? {}), {
|
|
557
|
+
readOnly: true,
|
|
558
|
+
});
|
|
559
|
+
res.json({ result });
|
|
560
|
+
}
|
|
561
|
+
catch (err) {
|
|
562
|
+
if (isReadOnlyDbError(err)) {
|
|
563
|
+
res.status(403).json({ error: 'Write queries are not allowed via the HTTP API' });
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
res.status(500).json({ error: err.message || 'Query failed' });
|
|
567
|
+
}
|
|
568
|
+
};
|
|
536
569
|
export const createServer = async (port, host = '127.0.0.1') => {
|
|
537
570
|
const app = express();
|
|
538
571
|
app.disable('x-powered-by');
|
|
@@ -892,28 +925,7 @@ export const createServer = async (port, host = '127.0.0.1') => {
|
|
|
892
925
|
});
|
|
893
926
|
// Execute Cypher query
|
|
894
927
|
app.post('/api/query', async (req, res) => {
|
|
895
|
-
|
|
896
|
-
const cypher = req.body.cypher;
|
|
897
|
-
if (!cypher) {
|
|
898
|
-
res.status(400).json({ error: 'Missing "cypher" in request body' });
|
|
899
|
-
return;
|
|
900
|
-
}
|
|
901
|
-
if (isWriteQuery(cypher)) {
|
|
902
|
-
res.status(403).json({ error: 'Write queries are not allowed via the HTTP API' });
|
|
903
|
-
return;
|
|
904
|
-
}
|
|
905
|
-
const entry = await resolveRepo(requestedRepo(req));
|
|
906
|
-
if (!entry) {
|
|
907
|
-
res.status(404).json({ error: 'Repository not found' });
|
|
908
|
-
return;
|
|
909
|
-
}
|
|
910
|
-
const lbugPath = path.join(entry.storagePath, 'lbug');
|
|
911
|
-
const result = await withLbugDb(lbugPath, () => executeQuery(cypher));
|
|
912
|
-
res.json({ result });
|
|
913
|
-
}
|
|
914
|
-
catch (err) {
|
|
915
|
-
res.status(500).json({ error: err.message || 'Query failed' });
|
|
916
|
-
}
|
|
928
|
+
await handleQueryRequest(req, res, resolveRepo);
|
|
917
929
|
});
|
|
918
930
|
// Search (supports mode: 'hybrid' | 'semantic' | 'bm25', and optional enrichment)
|
|
919
931
|
app.post('/api/search', async (req, res) => {
|
package/package.json
CHANGED