fix(cypher): align schema fallback and override hints

Derive resource columns from canonical DDL and recommend METHOD_OVERRIDES for legacy table-name errors without changing legacy queries.

Add schema consistency and native backend regressions.
This commit is contained in:
articultur 2026-10-04 00:03:40 -07:00
parent 7c54d7a661
commit 8a4ebd985f
4 changed files with 173 additions and 10 deletions

View file

@ -74,6 +74,9 @@ export function getCypherErrorHint(message: string, repoName: string): string |
)?.[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}`;
}

View file

@ -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: |

View file

@ -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 {
@ -1105,6 +1106,69 @@ withTestLbugDB(
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',
@ -1164,7 +1228,12 @@ withTestLbugDB(
});
},
{
seed: LOCAL_BACKEND_SEED_DATA,
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([

View file

@ -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: {