mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-10 03:27:59 +00:00
Merge 8a4ebd985f into 5f9f95f224
This commit is contained in:
commit
f26e53877c
6 changed files with 428 additions and 10 deletions
85
gitnexus/src/mcp/local/cypher-error-hint.ts
Normal file
85
gitnexus/src/mcp/local/cypher-error-hint.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import {
|
||||
EMBEDDING_TABLE_NAME,
|
||||
NODE_TABLES,
|
||||
REL_TABLE_NAME,
|
||||
REL_TYPES,
|
||||
SCHEMA_QUERIES,
|
||||
} from '../../core/lbug/schema.js';
|
||||
|
||||
// Use the DDL's actual columns, not a second hand-maintained schema. The binder
|
||||
// names the failing variable, not its table, so these are only possible matches.
|
||||
const PROPERTY_NAMES = [
|
||||
...new Set(
|
||||
SCHEMA_QUERIES.flatMap((ddl) =>
|
||||
[
|
||||
...ddl.matchAll(
|
||||
/^\s+([A-Za-z_][A-Za-z0-9_]*)\s+(?:STRING|INT64|INT32|DOUBLE|FLOAT|BOOLEAN)\b/gm,
|
||||
),
|
||||
].map((match) => match[1]),
|
||||
),
|
||||
),
|
||||
];
|
||||
const TABLE_NAMES = [...NODE_TABLES, REL_TABLE_NAME, EMBEDDING_TABLE_NAME];
|
||||
|
||||
/** Return one unambiguous, nearby spelling; never guess from a long prefix. */
|
||||
function closestName(input: string, candidates: readonly string[]): string | undefined {
|
||||
if (input.length < 3 || input.length > 64) return undefined;
|
||||
const lower = input.toLowerCase();
|
||||
// A valid name on the wrong table is not a spelling error.
|
||||
if (candidates.some((candidate) => candidate.toLowerCase() === lower)) return undefined;
|
||||
let bestDistance = input.length < 5 ? 1 : 2;
|
||||
let best: string | undefined;
|
||||
let tied = false;
|
||||
for (const candidate of candidates) {
|
||||
const target = candidate.toLowerCase();
|
||||
if (Math.abs(target.length - lower.length) > bestDistance) continue;
|
||||
let previous = Array.from({ length: target.length + 1 }, (_, i) => i);
|
||||
for (let i = 1; i <= lower.length; i++) {
|
||||
const row = [i];
|
||||
for (let j = 1; j <= target.length; j++) {
|
||||
row[j] = Math.min(
|
||||
row[j - 1] + 1,
|
||||
previous[j] + 1,
|
||||
previous[j - 1] + (lower[i - 1] === target[j - 1] ? 0 : 1),
|
||||
);
|
||||
}
|
||||
previous = row;
|
||||
}
|
||||
const distance = previous[target.length];
|
||||
if (distance > bestDistance) continue;
|
||||
if (distance < bestDistance || best === undefined) {
|
||||
best = candidate;
|
||||
bestDistance = distance;
|
||||
tied = false;
|
||||
} else {
|
||||
tied = true;
|
||||
}
|
||||
}
|
||||
return tied ? undefined : best;
|
||||
}
|
||||
|
||||
/** Add guidance only for native schema errors; execution/recovery stays with the caller. */
|
||||
export function getCypherErrorHint(message: string, repoName: string): string | undefined {
|
||||
const schema = `Read gitnexus://repo/${encodeURIComponent(repoName)}/schema for the schema.`;
|
||||
const property = message.match(
|
||||
/^(?:Prepare failed: )?Binder exception: Cannot find property ([A-Za-z_][A-Za-z0-9_]*) for /i,
|
||||
)?.[1];
|
||||
if (property) {
|
||||
const suggestion = closestName(property, PROPERTY_NAMES);
|
||||
return `${suggestion ? `Did you mean '${suggestion}'? ` : ''}Schema properties vary by table. ${schema}`;
|
||||
}
|
||||
|
||||
const table = message.match(
|
||||
/^(?:Prepare failed: )?Binder exception: Table ([A-Za-z_][A-Za-z0-9_]*) does not exist\./i,
|
||||
)?.[1];
|
||||
if (!table) return undefined;
|
||||
const relation = REL_TYPES.find((type) => type.toLowerCase() === table.toLowerCase());
|
||||
if (relation === 'OVERRIDES') {
|
||||
return `Relationships use :${REL_TABLE_NAME} {type: 'METHOD_OVERRIDES'}, not a '${table}' table. OVERRIDES is a legacy type value used by older indexes. ${schema}`;
|
||||
}
|
||||
if (relation) {
|
||||
return `Relationships use :${REL_TABLE_NAME} {type: '${relation}'}, not a '${relation}' table. ${schema}`;
|
||||
}
|
||||
const suggestion = closestName(table, TABLE_NAMES);
|
||||
return `${suggestion ? `Did you mean '${suggestion}'? ` : ''}${schema}`;
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
*/
|
||||
|
||||
import { resolveGraphPath } from '../../storage/shared-store.js';
|
||||
import { getCypherErrorHint } from './cypher-error-hint.js';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { createHash } from 'crypto';
|
||||
|
|
@ -4220,7 +4221,8 @@ export class LocalBackend {
|
|||
recoverySuggestion: WAL_RECOVERY_SUGGESTION,
|
||||
};
|
||||
}
|
||||
return { error: msg };
|
||||
const hint = getCypherErrorHint(msg, repo.name);
|
||||
return hint ? { error: msg, hint } : { error: msg };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,14 @@ import { checkStaleness } from './staleness.js';
|
|||
import { loadMeta } from '../storage/repo-manager.js';
|
||||
import { ANALYZER_RUNNER_IDENTITY_SCHEMA_VERSION } from '../core/analyzer-identity.js';
|
||||
import { getIndexIncompleteReasons } from '../core/index-freshness.js';
|
||||
import {
|
||||
EMBEDDING_SCHEMA,
|
||||
EMBEDDING_TABLE_NAME,
|
||||
NODE_SCHEMA_QUERIES,
|
||||
NODE_TABLES,
|
||||
RELATION_SCHEMA,
|
||||
REL_TABLE_NAME,
|
||||
} from '../core/lbug/schema.js';
|
||||
import {
|
||||
checkoutIsDirectory,
|
||||
contentRetentionFromMeta,
|
||||
|
|
@ -517,8 +525,20 @@ async function getProcessesResource(backend: LocalBackend, repoName?: string): P
|
|||
* Schema resource — graph structure for Cypher queries
|
||||
*/
|
||||
function getSchemaResource(): string {
|
||||
// Render the stored columns, not the richer ingestion-only symbol metadata.
|
||||
const properties = (ddl: string): string =>
|
||||
[...ddl.matchAll(/^\s+(\w+) ([A-Z][A-Z0-9]*(?:\[\d*\])?),?$/gm)]
|
||||
.map(([, name, type]) => `${name} (${type})`)
|
||||
.join(', ');
|
||||
const nodeProperties = NODE_SCHEMA_QUERIES.map((ddl) => {
|
||||
const table = ddl.match(/CREATE NODE TABLE `?(\w+)`?\s*\(/)?.[1];
|
||||
return ` ${table}: "${properties(ddl)}"`;
|
||||
}).join('\n');
|
||||
|
||||
return `# GitNexus Graph Schema
|
||||
|
||||
schema_scope: "Schema created by this GitNexus version; older indexes may differ. Properties vary by table."
|
||||
|
||||
nodes:
|
||||
- File: Source code files
|
||||
- Folder: Directory containers
|
||||
|
|
@ -530,17 +550,18 @@ nodes:
|
|||
- Community: Auto-detected functional area (Leiden algorithm)
|
||||
- Process: Execution flow trace
|
||||
|
||||
additional_node_types: "Multi-language: Struct, Enum, Macro, Typedef, Union, Namespace, Trait, Impl, TypeAlias, Const, Static, Property, Record, Delegate, Annotation, Constructor, Template, Module (use backticks in queries: \`Struct\`, \`Enum\`, etc.)"
|
||||
node_tables: "${NODE_TABLES.join(', ')} (use backticks in queries: \`Struct\`, \`Enum\`, etc.)"
|
||||
|
||||
node_properties:
|
||||
common: "name (STRING), filePath (STRING), startLine (INT32), endLine (INT32)"
|
||||
${nodeProperties}
|
||||
|
||||
embedding_storage:
|
||||
${EMBEDDING_TABLE_NAME}: "${properties(EMBEDDING_SCHEMA)}"
|
||||
|
||||
property_notes:
|
||||
line_numbers: "startLine/endLine on symbol nodes are 0-BASED (tree-sitter rows) in storage AND in raw Cypher results. The context, query, impact, group/cross-repo trace, and explain/pdg_query (symbol anchor) tools present them 1-BASED (editor / sed / less -N aligned), so a symbol spans editor lines (startLine+1)..(endLine+1) — e.g. sed '<startLine+1>,<endLine+1>!d' <file>. Single-repo trace symbol lines stay 0-BASED for now (full-parity follow-up). content holds the exact symbol span. (BasicBlock / PDG statement lines are separately 1-based.) (#2377, #2380)"
|
||||
Method: "parameterCount (INT32), returnType (STRING), isVariadic (BOOL), visibility (STRING), isStatic (BOOL), isAbstract (BOOL), isFinal (BOOL), isVirtual (BOOL), isOverride (BOOL), isAsync (BOOL), isPartial (BOOL), requiredParameterCount (INT32), parameterTypes (STRING[]), annotations (STRING[])"
|
||||
Function: "parameterCount (INT32), returnType (STRING), isVariadic (BOOL), visibility (STRING), isStatic (BOOL), isAbstract (BOOL), isFinal (BOOL), isAsync (BOOL), parameterTypes (STRING[]), annotations (STRING[])"
|
||||
Property: "declaredType (STRING) — the field's type annotation (e.g., 'Address', 'City'). Used for field-access chain resolution."
|
||||
Constructor: "parameterCount (INT32), visibility (STRING), isStatic (BOOL), parameterTypes (STRING[])"
|
||||
Community: "heuristicLabel (STRING), cohesion (DOUBLE), symbolCount (INT32), keywords (STRING[]), description (STRING), enrichedBy (STRING)"
|
||||
Process: "heuristicLabel (STRING), processType (STRING — 'intra_community' or 'cross_community'), stepCount (INT32), communities (STRING[]), entryPointId (STRING), terminalId (STRING)"
|
||||
Process: "processType is 'intra_community' or 'cross_community'."
|
||||
|
||||
relationships:
|
||||
- CONTAINS: File/Folder contains child
|
||||
|
|
@ -558,12 +579,12 @@ relationships:
|
|||
- STEP_IN_PROCESS: Symbol is step N in process
|
||||
|
||||
pdg_layers: "Recorded ONLY when indexed with 'gitnexus analyze --pdg'. Intra-procedural, basic-block granular; both endpoints are BasicBlock nodes. Prefer the pdg_query tool over raw Cypher."
|
||||
- BasicBlock: "Basic-block node. Columns: id, filePath, startLine, endLine, text. id = 'BasicBlock:<filePath>:<fnStartLine>:<fnStartCol>:<blockIndex>'."
|
||||
- BasicBlock: "Basic-block node; columns are listed in node_properties. id = 'BasicBlock:<filePath>:<fnStartLine>:<fnStartCol>:<blockIndex>'."
|
||||
- CFG: "Control-flow edge BasicBlock->BasicBlock. Edge kind (seq/cond-true/cond-false/loop-back/...) is in reason."
|
||||
- CDG: "Control-DEPENDENCE edge BasicBlock->BasicBlock — the source predicate gates the target's execution. Branch sense 'T'|'F' in reason. Query via pdg_query mode:'controls'."
|
||||
- REACHING_DEF: "Data-dependence (def->use) edge BasicBlock->BasicBlock. Source-level variable name is in reason. Query via pdg_query mode:'flows'."
|
||||
|
||||
relationship_table: "All relationships use a single CodeRelation table with a 'type' property. Properties: type (STRING), confidence (DOUBLE), reason (STRING), step (INT32)"
|
||||
relationship_table: "All relationships use a single ${REL_TABLE_NAME} table with a 'type' property. Properties: ${properties(RELATION_SCHEMA)}"
|
||||
|
||||
example_queries:
|
||||
find_callers: |
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
import fs from 'fs/promises';
|
||||
import { describe, it, expect, beforeAll, vi } from 'vitest';
|
||||
import { LocalBackend } from '../../src/mcp/local/local-backend.js';
|
||||
import { readResource } from '../../src/mcp/resources.js';
|
||||
import { listRegisteredRepos, saveMeta } from '../../src/storage/repo-manager.js';
|
||||
import { withTestLbugDB } from '../helpers/test-indexed-db.js';
|
||||
import {
|
||||
|
|
@ -1051,3 +1052,202 @@ withTestLbugDB(
|
|||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Schema-error hints must run without the optional FTS extension, including on
|
||||
// offline installs. Exercise the public dispatch and the real native binder.
|
||||
withTestLbugDB(
|
||||
'cypher-schema-hints',
|
||||
(handle) => {
|
||||
let backend: LocalBackend;
|
||||
beforeAll(() => {
|
||||
backend = (handle as typeof handle & { _backend: LocalBackend })._backend;
|
||||
});
|
||||
|
||||
it.each([
|
||||
['Functon', 'Function', 'MATCH (n:Functon) RETURN n'],
|
||||
['Protocool', 'Protocol', 'MATCH (n:Protocool) RETURN n'],
|
||||
['CodeRelaton', 'CodeRelation', 'MATCH ()-[r:CodeRelaton]->() RETURN r.type'],
|
||||
])(
|
||||
'suggests the schema table for %s without replacing the error',
|
||||
async (typo, table, statement) => {
|
||||
const result = await backend.callTool('cypher', { statement });
|
||||
expect(result.error).toBe(
|
||||
`Prepare failed: Binder exception: Table ${typo} does not exist.`,
|
||||
);
|
||||
expect(result.hint).toContain(`Did you mean '${table}'?`);
|
||||
expect(result.hint).toContain('gitnexus://repo/schema-hints-repo/schema');
|
||||
expect(result).not.toHaveProperty('recoverySuggestion');
|
||||
const corrected = await backend.callTool('cypher', {
|
||||
statement: statement.replace(typo, table),
|
||||
});
|
||||
expect(corrected).not.toHaveProperty('error');
|
||||
expect(corrected).not.toHaveProperty('hint');
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
['MATCH (n:Function) RETURN n.filePth', 'filePth', 'filePath', 'n'],
|
||||
['MATCH (n:Method) RETURN n.parameterCont', 'parameterCont', 'parameterCount', 'n'],
|
||||
['MATCH ()-[r:CodeRelation]->() RETURN r.confidnce', 'confidnce', 'confidence', 'r'],
|
||||
])('suggests an actual schema property for %s', async (statement, typo, property, alias) => {
|
||||
const result = await backend.callTool('cypher', { statement });
|
||||
expect(result.error).toBe(
|
||||
`Prepare failed: Binder exception: Cannot find property ${typo} for ${alias}.`,
|
||||
);
|
||||
expect(result.hint).toContain(`Did you mean '${property}'?`);
|
||||
expect(result.hint).toContain('properties vary by table');
|
||||
});
|
||||
|
||||
it('explains relationship values used as relationship tables', async () => {
|
||||
const result = await backend.callTool('cypher', {
|
||||
statement: 'MATCH ()-[:CALLS]->() RETURN count(*)',
|
||||
});
|
||||
expect(result.error).toBe('Prepare failed: Binder exception: Table CALLS does not exist.');
|
||||
expect(result.hint).toContain(":CodeRelation {type: 'CALLS'}");
|
||||
});
|
||||
|
||||
it.each(['OVERRIDES', 'overrides'])(
|
||||
'directs the legacy %s table spelling to current override edges',
|
||||
async function recommendsCurrentOverrideEdges(table) {
|
||||
const result = await backend.callTool('cypher', {
|
||||
statement: `MATCH ()-[:${table}]->() RETURN count(*)`,
|
||||
});
|
||||
expect(result.error).toBe(
|
||||
`Prepare failed: Binder exception: Table ${table} does not exist.`,
|
||||
);
|
||||
expect(result.hint).toContain(":CodeRelation {type: 'METHOD_OVERRIDES'}");
|
||||
expect(result.hint).toMatch(/OVERRIDES.*legacy/);
|
||||
expect(result.hint).toContain('older indexes');
|
||||
const advisedPattern = result.hint.match(/:CodeRelation \{type: '[A-Z_]+'\}/)?.[0];
|
||||
expect(advisedPattern).toBeDefined();
|
||||
const corrected = await backend.callTool('cypher', {
|
||||
statement: `MATCH (a)-[${advisedPattern}]->(b) RETURN a.id AS source, b.id AS target`,
|
||||
});
|
||||
expect(corrected.row_count).toBe(1);
|
||||
expect(corrected.markdown).toContain('method:AuthService.authenticate');
|
||||
expect(corrected.markdown).toContain('method:BaseService.authenticate');
|
||||
expect(corrected).not.toHaveProperty('hint');
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps the canonical METHOD_OVERRIDES hint unchanged', async function preservesCanonicalOverrideHint() {
|
||||
const result = await backend.callTool('cypher', {
|
||||
statement: 'MATCH ()-[:METHOD_OVERRIDES]->() RETURN count(*)',
|
||||
});
|
||||
expect(result.error).toBe(
|
||||
'Prepare failed: Binder exception: Table METHOD_OVERRIDES does not exist.',
|
||||
);
|
||||
expect(result.hint).toBe(
|
||||
"Relationships use :CodeRelation {type: 'METHOD_OVERRIDES'}, not a 'METHOD_OVERRIDES' table. Read gitnexus://repo/schema-hints-repo/schema for the schema.",
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves a valid explicit legacy-type query instead of rewriting it', async function preservesLegacyOverrideQuery() {
|
||||
const result = await backend.callTool('cypher', {
|
||||
statement: "MATCH ()-[r:CodeRelation {type: 'OVERRIDES'}]->() RETURN r.reason AS reason",
|
||||
});
|
||||
expect(result.row_count).toBe(1);
|
||||
expect(result.markdown).toContain('legacy-index-row');
|
||||
expect(result.markdown).not.toContain('mro-resolution');
|
||||
expect(result).not.toHaveProperty('hint');
|
||||
});
|
||||
|
||||
it('preserves wrong-table property errors and links to accurate per-table columns', async function linksToAccuratePropertyColumns() {
|
||||
const result = await backend.callTool('cypher', {
|
||||
statement: 'MATCH (n:Function) RETURN n.parameterCount',
|
||||
});
|
||||
expect(result.error).toBe(
|
||||
'Prepare failed: Binder exception: Cannot find property parameterCount for n.',
|
||||
);
|
||||
expect(result.hint).not.toContain('Did you mean');
|
||||
expect(result.hint).toContain('properties vary by table');
|
||||
const resourceUri = result.hint.match(/gitnexus:\/\/\S+/)?.[0] ?? '';
|
||||
expect(resourceUri).toBe('gitnexus://repo/schema-hints-repo/schema');
|
||||
const resource = await readResource(resourceUri, backend);
|
||||
const properties = resource.split('node_properties:\n')[1].split('\n\n')[0];
|
||||
expect(properties.match(/^ Function: (.+)$/m)?.[1]).not.toContain('parameterCount');
|
||||
expect(properties.match(/^ Method: (.+)$/m)?.[1]).toContain('parameterCount (INT32)');
|
||||
});
|
||||
|
||||
it.each([
|
||||
'MATCH (n:UnrelatedMissingThing) RETURN n',
|
||||
'MATCH (n:FunctionWithAnUnrelatedSuffix) RETURN n',
|
||||
'MATCH (n:Function) RETURN n.unrelatedMissingProperty',
|
||||
'MATCH (n:Function) RETURN n.heuristicLabel',
|
||||
'MATCH (n:Stait) RETURN n',
|
||||
'MATCH (n:Function) RETURN n.lebel',
|
||||
'MATCH (n:Function) RETURN n.ix',
|
||||
`MATCH (n:${'F'.repeat(65)}) RETURN n`,
|
||||
])('points to the schema without guessing for %s', async (statement) => {
|
||||
const result = await backend.callTool('cypher', { statement });
|
||||
expect(result.error).toContain('Binder exception:');
|
||||
expect(result.hint).toContain('gitnexus://repo/schema-hints-repo/schema');
|
||||
expect(result.hint).not.toContain('Did you mean');
|
||||
expect(result.hint).not.toContain('analyze');
|
||||
});
|
||||
|
||||
it('preserves the executeCypher entrypoint used by internal callers', async () => {
|
||||
const result = await backend.executeCypher('schema-hints-repo', 'MATCH (n:Functon) RETURN n');
|
||||
expect(result.error).toBe('Prepare failed: Binder exception: Table Functon does not exist.');
|
||||
expect(result.hint).toContain("Did you mean 'Function'?");
|
||||
});
|
||||
|
||||
it('keeps corrected queries and statement precedence unchanged', async () => {
|
||||
const result = await backend.callTool('cypher', {
|
||||
statement: 'MATCH (n:Function {name: $name}) RETURN n.filePath AS filePath',
|
||||
query: 'MATCH (n:Functon) RETURN n',
|
||||
params: { name: 'login' },
|
||||
});
|
||||
expect(result.row_count).toBe(1);
|
||||
expect(result.markdown).toContain('src/auth.ts');
|
||||
expect(result).not.toHaveProperty('hint');
|
||||
});
|
||||
|
||||
it.each([
|
||||
"MATCH (n:Function) WHERE n.name = '__missing__' RETURN n.name",
|
||||
"MATCH ()-[r:CodeRelation]->() WHERE r.type = 'CALS' RETURN r.type",
|
||||
])('does not diagnose a successful empty result for %s', async (statement) => {
|
||||
expect(await backend.callTool('cypher', { statement })).toEqual([]);
|
||||
});
|
||||
|
||||
it('preserves native case-insensitive names', async () => {
|
||||
const result = await backend.callTool('cypher', {
|
||||
statement: 'MATCH (n:function) RETURN n.FilePath AS filePath',
|
||||
});
|
||||
expect(result.row_count).toBeGreaterThan(0);
|
||||
expect(result).not.toHaveProperty('error');
|
||||
expect(result).not.toHaveProperty('hint');
|
||||
});
|
||||
|
||||
it('leaves parser and out-of-scope variable errors unchanged', async () => {
|
||||
for (const statement of ['NOT CYPHER', 'MATCH (n:Function) RETURN missing']) {
|
||||
const result = await backend.callTool('cypher', { statement });
|
||||
expect(result.error).toBeDefined();
|
||||
expect(result).not.toHaveProperty('hint');
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
seed: [
|
||||
...LOCAL_BACKEND_SEED_DATA,
|
||||
`MATCH (a:Method {id: 'method:AuthService.authenticate'}),
|
||||
(b:Method {id: 'method:BaseService.authenticate'})
|
||||
CREATE (a)-[:CodeRelation {type: 'OVERRIDES', reason: 'legacy-index-row'}]->(b)`,
|
||||
],
|
||||
poolAdapter: true,
|
||||
afterSetup: async (handle) => {
|
||||
vi.mocked(listRegisteredRepos).mockResolvedValue([
|
||||
{
|
||||
name: 'schema-hints-repo',
|
||||
path: handle.tmpHandle.dbPath,
|
||||
storagePath: handle.tmpHandle.dbPath,
|
||||
indexedAt: new Date().toISOString(),
|
||||
lastCommit: 'abc123',
|
||||
},
|
||||
]);
|
||||
const backend = new LocalBackend();
|
||||
await backend.init();
|
||||
(handle as typeof handle & { _backend: LocalBackend })._backend = backend;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -144,4 +144,44 @@ describe('WAL corruption feedback in MCP responses (#1402)', () => {
|
|||
}),
|
||||
).rejects.toThrow('Some other error');
|
||||
});
|
||||
|
||||
it('cypher keeps WAL recovery ahead of schema-looking diagnostics', async () => {
|
||||
const backend = await makeBackend();
|
||||
const message = 'Binder exception: Table Functon does not exist. Corrupted wal file';
|
||||
lbugMocks.executeParameterized.mockRejectedValueOnce(new Error(message));
|
||||
const result = await backend.callTool('cypher', {
|
||||
repo: 'test-repo',
|
||||
statement: 'MATCH (n) RETURN n',
|
||||
});
|
||||
expect(result.error).toBe(message);
|
||||
expect(result.recoverySuggestion).toBeDefined();
|
||||
expect(result).not.toHaveProperty('hint');
|
||||
});
|
||||
|
||||
it('cypher keeps wrapped read-only failures ahead of schema-looking diagnostics', async () => {
|
||||
const backend = await makeBackend();
|
||||
lbugMocks.executeParameterized.mockRejectedValueOnce(
|
||||
new Error('Binder exception: Table Functon does not exist.', {
|
||||
cause: new Error('Cannot execute write operations in a read-only database!'),
|
||||
}),
|
||||
);
|
||||
const result = await backend.callTool('cypher', {
|
||||
repo: 'test-repo',
|
||||
statement: 'CREATE (:Function)',
|
||||
});
|
||||
expect(result.error).toMatch(/^Write operations .* are not allowed/);
|
||||
expect(result).not.toHaveProperty('hint');
|
||||
expect(result).not.toHaveProperty('recoverySuggestion');
|
||||
});
|
||||
|
||||
it('cypher preserves unrelated failures without adding a schema hint', async () => {
|
||||
const backend = await makeBackend();
|
||||
lbugMocks.executeParameterized.mockRejectedValueOnce(new Error('Connection failed'));
|
||||
const result = await backend.callTool('cypher', {
|
||||
repo: 'test-repo',
|
||||
statement: 'MATCH (n) RETURN n',
|
||||
});
|
||||
expect(result.error).toBe('Connection failed');
|
||||
expect(result).not.toHaveProperty('hint');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,6 +16,14 @@ import {
|
|||
readResource,
|
||||
} from '../../src/mcp/resources.js';
|
||||
import type { RepoMeta } from '../../src/storage/repo-manager.js';
|
||||
import {
|
||||
EMBEDDING_DIMS,
|
||||
EMBEDDING_SCHEMA,
|
||||
EMBEDDING_TABLE_NAME,
|
||||
NODE_SCHEMA_QUERIES,
|
||||
NODE_TABLES,
|
||||
RELATION_SCHEMA,
|
||||
} from '../../src/core/lbug/schema.js';
|
||||
|
||||
// Mock loadMeta so getContextResource doesn't hit the filesystem (#2438 fix).
|
||||
// Default: returns null (simulates no on-disk meta — falls back to cached handle).
|
||||
|
|
@ -268,6 +276,68 @@ describe('readResource', () => {
|
|||
expect(result).toContain('IMPORTS');
|
||||
});
|
||||
|
||||
it('documents every canonical node table and its exact DDL columns', async function documentsCanonicalNodeColumns() {
|
||||
const result = await readResource('gitnexus://repo/any/schema', createMockBackend());
|
||||
const section = result.split('node_properties:\n')[1].split('\n\n')[0];
|
||||
const documented = Object.fromEntries(
|
||||
[...section.matchAll(/^ (\w+): "([^"]*)"$/gm)].map((match) => [match[1], match[2]]),
|
||||
);
|
||||
expect(Object.keys(documented).sort()).toEqual([...NODE_TABLES].sort());
|
||||
|
||||
for (const ddl of NODE_SCHEMA_QUERIES) {
|
||||
const [declaration, ...lines] = ddl
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.trim()
|
||||
.split('\n');
|
||||
const table = declaration.split(/\s+/)[3].replaceAll('`', '');
|
||||
// Independently read declarations, including array types, without the
|
||||
// renderer's column-matching regex or a hand-maintained property list.
|
||||
const expected = lines
|
||||
.map((line) => line.trim().replace(/,$/, ''))
|
||||
.filter((line) => line && line !== ')' && !line.startsWith('PRIMARY KEY'))
|
||||
.map((line) => {
|
||||
const [name, type] = line.split(/\s+/);
|
||||
return `${name} (${type})`;
|
||||
});
|
||||
expect(documented[table].split(', '), table).toEqual(expected);
|
||||
}
|
||||
expect(documented.Function).not.toContain('parameterCount');
|
||||
expect(documented.Constructor).not.toContain('parameterCount');
|
||||
expect(documented.Method).toContain('parameterCount (INT32), returnType (STRING)');
|
||||
expect(documented.BasicBlock).toContain('callees (STRING), calleeIds (STRING)');
|
||||
expect(documented.File).not.toContain('startLine');
|
||||
expect(documented.Function).toContain('startLine (INT64)');
|
||||
expect(result).not.toContain('common:');
|
||||
expect(result).toContain('0-BASED');
|
||||
expect(result).toContain('find_callers:');
|
||||
expect(result).toContain('declaredType');
|
||||
});
|
||||
|
||||
it('documents embedding storage and every relationship column from the DDL', async function documentsEmbeddingAndRelationshipColumns() {
|
||||
const result = await readResource('gitnexus://repo/any/schema', createMockBackend());
|
||||
expect(result).toContain('embedding_storage:\n');
|
||||
const embedding = result.split('embedding_storage:\n')[1].split('\n\n')[0];
|
||||
expect(embedding).toContain(`${EMBEDDING_TABLE_NAME}:`);
|
||||
expect(embedding).toContain(`embedding (FLOAT[${EMBEDDING_DIMS}])`);
|
||||
const relation = result.split('relationship_table: ')[1].split('\n')[0];
|
||||
for (const [ddl, rendered] of [
|
||||
[EMBEDDING_SCHEMA, embedding],
|
||||
[RELATION_SCHEMA, relation],
|
||||
]) {
|
||||
const declarations = ddl.trim().split('\n').slice(1, -1);
|
||||
const columns = declarations
|
||||
.map((line) => line.trim().replace(/,$/, ''))
|
||||
.filter((line) => !line.startsWith('PRIMARY KEY') && !line.startsWith('FROM '))
|
||||
.map((line) => line.split(/\s+/));
|
||||
const actual = [...rendered.matchAll(/(\w+) \(([^)]+)\)/g)].map((match) => [
|
||||
match[1],
|
||||
match[2],
|
||||
]);
|
||||
expect(actual).toEqual(columns);
|
||||
}
|
||||
expect(relation).toContain('staticGated (BOOLEAN)');
|
||||
});
|
||||
|
||||
it('routes gitnexus://repo/{name}/clusters correctly', async () => {
|
||||
const backend = createMockBackend({
|
||||
clusters: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue