diff --git a/gitnexus/src/mcp/local/cypher-error-hint.ts b/gitnexus/src/mcp/local/cypher-error-hint.ts new file mode 100644 index 000000000..73c40d8de --- /dev/null +++ b/gitnexus/src/mcp/local/cypher-error-hint.ts @@ -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}`; +} diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index cb8546543..003330993 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -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 }; } } diff --git a/gitnexus/src/mcp/resources.ts b/gitnexus/src/mcp/resources.ts index 49687fa9b..3cbd13948 100644 --- a/gitnexus/src/mcp/resources.ts +++ b/gitnexus/src/mcp/resources.ts @@ -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 ',!d' . 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::::'." + - BasicBlock: "Basic-block node; columns are listed in node_properties. id = 'BasicBlock::::'." - 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: | diff --git a/gitnexus/test/integration/local-backend-calltool.test.ts b/gitnexus/test/integration/local-backend-calltool.test.ts index 35eabbdd4..9a40a25dc 100644 --- a/gitnexus/test/integration/local-backend-calltool.test.ts +++ b/gitnexus/test/integration/local-backend-calltool.test.ts @@ -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; + }, + }, +); diff --git a/gitnexus/test/unit/mcp-wal-feedback.test.ts b/gitnexus/test/unit/mcp-wal-feedback.test.ts index 65515a987..14520db67 100644 --- a/gitnexus/test/unit/mcp-wal-feedback.test.ts +++ b/gitnexus/test/unit/mcp-wal-feedback.test.ts @@ -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'); + }); }); diff --git a/gitnexus/test/unit/resources.test.ts b/gitnexus/test/unit/resources.test.ts index 4d50a610d..3cc666bcb 100644 --- a/gitnexus/test/unit/resources.test.ts +++ b/gitnexus/test/unit/resources.test.ts @@ -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: {