feat(cypher): add schema hints to query errors

Adapt Cypher guidance from articultur/GitNexus@17f71ea using canonical schema constants and native backend regression tests.
This commit is contained in:
articultur 2026-10-03 03:20:01 -07:00
parent a47cd17f27
commit 7c54d7a661
4 changed files with 256 additions and 1 deletions

View file

@ -0,0 +1,82 @@
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) {
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}`;
}

View file

@ -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 };
}
}

View file

@ -1051,3 +1051,134 @@ 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([
'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,
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;
},
},
);

View file

@ -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');
});
});