From 9d4f02900197eb25e228479dc738eaaaabeb65a8 Mon Sep 17 00:00:00 2001 From: azizur100389 Date: Wed, 26 Aug 2026 12:54:00 +0100 Subject: [PATCH] fix(impact): mark Convex caller results incomplete (#3044) * fix(impact): mark Convex caller results incomplete * fix(storage): align Convex Const persistence --- ARCHITECTURE.md | 1 + .../bench/emit-persistence/baselines.json | 3 +- .../src/core/ingestion/language-provider.ts | 42 ++++ .../core/ingestion/languages/typescript.ts | 3 + .../typescript/convex-endpoint-metadata.ts | 113 +++++++++ .../core/ingestion/workers/parse-worker.ts | 38 ++- gitnexus/src/core/lbug/csv-generator.ts | 11 +- gitnexus/src/core/lbug/lbug-adapter.ts | 26 +++ gitnexus/src/core/lbug/schema.ts | 14 +- gitnexus/src/mcp/local/convex-metadata.ts | 72 ++++++ gitnexus/src/mcp/local/local-backend.ts | 60 +++-- gitnexus/src/mcp/tools.ts | 9 +- gitnexus/src/storage/parse-cache.ts | 11 +- .../convex-impact-epistemic-e2e.test.ts | 218 ++++++++++++++++++ .../impact-epistemic-lower-bound.test.ts | 49 ++++ .../unit/convex-dispatch-metadata.test.ts | 53 +++++ ...nvex-metadata-persistence-contract.test.ts | 123 ++++++++++ gitnexus/test/unit/convex-metadata.test.ts | 125 ++++++++++ .../test/unit/definition-properties.test.ts | 68 ++++++ .../test/unit/incremental-parse-cache.test.ts | 6 +- 20 files changed, 1007 insertions(+), 38 deletions(-) create mode 100644 gitnexus/src/core/ingestion/languages/typescript/convex-endpoint-metadata.ts create mode 100644 gitnexus/src/mcp/local/convex-metadata.ts create mode 100644 gitnexus/test/integration/convex-impact-epistemic-e2e.test.ts create mode 100644 gitnexus/test/unit/convex-dispatch-metadata.test.ts create mode 100644 gitnexus/test/unit/convex-metadata-persistence-contract.test.ts create mode 100644 gitnexus/test/unit/convex-metadata.test.ts create mode 100644 gitnexus/test/unit/definition-properties.test.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 357d337ae..7ae30a56b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -403,6 +403,7 @@ Each language implements `LanguageProvider` (`language-provider.ts`). Key fields | `typeConfig` | Type annotation extraction rules | | `mroStrategy` | `first-wins` / `c3` / `none` | | `descriptionExtractor` | Optional hook returning a symbol's doc-comment text as its `description`; feeds the embedding metadata header so doc-only terms are semantically searchable (issue #2270). Most languages register `createLeadingDocDescriptionExtractor` (shared, language-neutral; per-language comment/wrapper config passed at the call site) | +| `definitionPropertiesExtractor` | Optional language-owned hook for structured, clone-safe definition metadata. Shared ingestion persists these properties opaquely; the owning provider supplies the extraction semantics. | 16 providers in `languages/index.ts` via `satisfies Record` — missing a language is a compile error. diff --git a/gitnexus/bench/emit-persistence/baselines.json b/gitnexus/bench/emit-persistence/baselines.json index 1d295bd19..5eeceb02b 100644 --- a/gitnexus/bench/emit-persistence/baselines.json +++ b/gitnexus/bench/emit-persistence/baselines.json @@ -1,7 +1,8 @@ { - "fingerprint": "4ee15e742a9839671a900df4f57c1c91196c64256c8cab2ac445bec605a092d5", + "fingerprint": "c4d799c5336d616955b3530ba051b7dca300d1a0e412a66741cf2f27e04c533e", "scaling_budget": 1.8, "max_ms_large": 1000, "_rebaselined_2856_property_is_detail": "Third and last of the bench guards this branch left red. The Property node table gained an `isDetail` BOOLEAN column (see PROPERTY_SCHEMA in src/core/lbug/schema.ts), so `streamAllCSVsToDisk` writes one more header field and one more cell per Property row — csv-generator.ts `propertyHeader` and the `node.label === 'Property'` tail. Verified to be header-only drift rather than a change in what is emitted: dumping every CSV this bench produces on `origin/main` and on this branch and diffing per-file (filename, byte length, sha256) shows the file SET is identical at 35 CSVs on both sides, 34 of the 35 are byte-identical, and the sole difference is `property.csv` growing 68 -> 77 bytes, `id,name,filePath,startLine,endLine,content,description,declaredType` -> `...,declaredType,isDetail`. The synthetic graph has no Property nodes, so no ROW moved at all. That is the check that matters here: a row routed to the wrong pair file, or a within-file reordering, is what this fingerprint exists to catch, and neither happened. Prior 69e9182ae205183ade24c3d8ad5d7292aea677144b1cbe443dd631bc25b0cafe -> 4ee15e742a9839671a900df4f57c1c91196c64256c8cab2ac445bec605a092d5. Both timing gates passed unchanged while this was red (scaling_ratio 0.783 vs budget 1.8, elapsed_ms_large 229ms vs the 1000ms backstop), so no throughput claim is being rebaselined away.", + "_rebaselined_3040_convex_endpoint_factory": "Const and Function gained a trailing convexEndpointFactory column. A deterministic 2,400-entity emit produced the same 35 CSV files and fingerprint c4d799c5336d616955b3530ba051b7dca300d1a0e412a66741cf2f27e04c533e. Removing the new Const and Function header fields plus the new trailing empty Function cell from each of 4,800 Function rows restored the exact prior fingerprint 4ee15e742a9839671a900df4f57c1c91196c64256c8cab2ac445bec605a092d5. No file or row moved or reordered. The measured scaling ratio remained 0.826 against the 1.8 budget and elapsed_ms_large was 307.75ms against the 1000ms backstop.", "_note": "fingerprint = sha256 over per-file digests (filename + sha256(file bytes)), entry list sorted — binds each emitted line to its file so a row routed to the WRONG pair file changes the hash, AND catches within-file row reordering (file bytes hashed as-written). Byte-identity gate for #2203 U2/U3. NOTE: a future change that legitimately reorders emit (without changing the node/edge SET) will trip --check; regenerate then, and record WHY in a `_rebaselined_` key alongside — bench/scope-capture/baselines.json sets that convention and it is what makes a regenerated hash reviewable. scaling_budget bounds (t_large/t_small)/(LARGE/SMALL): observed ~0.95-1.05 (linear); 1.8 tolerates disk-I/O timing noise on CI while still catching an O(n^2) re-regression (~4x). max_ms_large=1000ms is a coarse absolute backstop (observed ~200ms) that catches a gross uniform slowdown the ratio gate misses; generous so CI host noise won't flake it. Regenerate via `node --import tsx bench/emit-persistence/measure.mjs`." } diff --git a/gitnexus/src/core/ingestion/language-provider.ts b/gitnexus/src/core/ingestion/language-provider.ts index 71802100d..2775cf07b 100644 --- a/gitnexus/src/core/ingestion/language-provider.ts +++ b/gitnexus/src/core/ingestion/language-provider.ts @@ -51,6 +51,44 @@ import type { ExtractedDecoratorRoute } from './workers/parse-worker.js'; /** Tree-sitter query captures: capture name → AST node (or undefined if not captured). */ export type CaptureMap = Record; +export interface DefinitionPropertiesContext { + readonly nodeLabel: NodeLabel; + readonly nodeName: string; + readonly definitionNode: SyntaxNode; + readonly parsedImports: readonly ParsedImport[]; + readonly isExported: boolean; +} + +export type DefinitionPropertiesExtractor = ( + context: DefinitionPropertiesContext, +) => Readonly> | undefined; + +/** Run optional provider enrichment without allowing one hook failure to drop + * the rest of the worker's language batch. */ +export function runDefinitionPropertiesExtractor( + extractor: DefinitionPropertiesExtractor, + context: DefinitionPropertiesContext, + onError: (error: unknown) => void, +): Readonly> | undefined { + try { + return extractor(context); + } catch (error) { + onError(error); + return undefined; + } +} + +/** Provider metadata is additive; graph identity and source-location fields + * supplied by the worker remain authoritative. */ +export function mergeCanonicalDefinitionProperties< + TCanonical extends Readonly>, +>( + providerProperties: Readonly>, + canonicalProperties: TCanonical, +): Record & TCanonical { + return { ...providerProperties, ...canonicalProperties } as Record & TCanonical; +} + // ── Strategy tag types ───────────────────────────────────────────────────── // NOTE: `MroStrategy` is defined in `gitnexus-shared` and re-exported above // so `core/ingestion/model/resolve.ts` can consume it without importing from @@ -276,6 +314,10 @@ interface LanguageProviderConfig { * constant, and static declarations. Produces VariableInfo with type, visibility, * isConst, isStatic, isMutable metadata. Default: undefined (no variable extraction). */ readonly variableExtractor?: VariableExtractor; + /** Add language-owned, structured properties to a definition node. Values + * cross the worker boundary and must therefore be structured-clone-safe. + * Shared ingestion code treats these properties as opaque. */ + readonly definitionPropertiesExtractor?: DefinitionPropertiesExtractor; /** Class/type extractor for deriving canonical qualified names for class-like symbols. * Uses the same provider-driven strategy pattern as method/field extraction so * namespace/package/module rules stay language-specific. */ diff --git a/gitnexus/src/core/ingestion/languages/typescript.ts b/gitnexus/src/core/ingestion/languages/typescript.ts index 40b91cd8d..e6106df69 100644 --- a/gitnexus/src/core/ingestion/languages/typescript.ts +++ b/gitnexus/src/core/ingestion/languages/typescript.ts @@ -126,6 +126,7 @@ import { } from './javascript/index.js'; import { extractDispatchGuardRoutes } from '../route-extractors/dispatch-guard.js'; import { extractDataRouteTableRoutes } from '../route-extractors/data-route-table.js'; +import { extractConvexEndpointProperties } from './typescript/convex-endpoint-metadata.js'; const extractJsTsRoutes = (...args: Parameters) => [ ...extractDispatchGuardRoutes(...args), @@ -418,6 +419,7 @@ export const typescriptProvider = defineLanguage({ extractFunctionName: tsExtractFunctionName, }), variableExtractor: createVariableExtractor(typescriptVariableConfig), + definitionPropertiesExtractor: extractConvexEndpointProperties, classExtractor: createClassExtractor(typescriptClassConfig), // ── JSDoc → description (issue #2270). An exported decl is captured as the // inner declaration; its JSDoc precedes the wrapping `export_statement`. ── @@ -505,6 +507,7 @@ export const javascriptProvider = defineLanguage({ extractFunctionName: tsExtractFunctionName, }), variableExtractor: createVariableExtractor(javascriptVariableConfig), + definitionPropertiesExtractor: extractConvexEndpointProperties, classExtractor: createClassExtractor(javascriptClassConfig), // ── JSDoc → description (issue #2270). An exported decl is captured as the // inner declaration; its JSDoc precedes the wrapping `export_statement`. ── diff --git a/gitnexus/src/core/ingestion/languages/typescript/convex-endpoint-metadata.ts b/gitnexus/src/core/ingestion/languages/typescript/convex-endpoint-metadata.ts new file mode 100644 index 000000000..b31a079d1 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/typescript/convex-endpoint-metadata.ts @@ -0,0 +1,113 @@ +import type { ParsedImport } from 'gitnexus-shared'; +import type { DefinitionPropertiesContext } from '../../language-provider.js'; +import type { SyntaxNode } from '../../utils/ast-helpers.js'; +import { assertCloneable } from '../../workers/clone-safety.js'; + +const GENERATED_ENDPOINT_FACTORIES: ReadonlySet = new Set([ + 'query', + 'mutation', + 'action', + 'internalQuery', + 'internalMutation', + 'internalAction', + 'httpAction', +]); + +const GENERIC_ENDPOINT_FACTORIES: ReadonlyMap = new Map( + [...GENERATED_ENDPOINT_FACTORIES].map((factory) => [`${factory}Generic`, factory]), +); + +const normalizeModuleTarget = (targetRaw: string): string => + targetRaw.replace(/\\/g, '/').replace(/\.(?:[cm]?[jt]s)$/, ''); + +const isGeneratedServerModule = (targetRaw: string): boolean => + /(?:^|\/)_generated\/server$/.test(normalizeModuleTarget(targetRaw)); + +function importedConvexFactory( + imports: readonly ParsedImport[], + localName: string, +): string | undefined { + for (const parsedImport of imports) { + if (parsedImport.kind !== 'named' && parsedImport.kind !== 'alias') continue; + if (parsedImport.localName !== localName) continue; + + const target = normalizeModuleTarget(parsedImport.targetRaw); + if (target === 'convex/server') { + return GENERIC_ENDPOINT_FACTORIES.get(parsedImport.importedName); + } + if (isGeneratedServerModule(target)) { + return GENERATED_ENDPOINT_FACTORIES.has(parsedImport.importedName) + ? parsedImport.importedName + : undefined; + } + } + return undefined; +} + +function matchingDeclarator(node: SyntaxNode, nodeName: string): SyntaxNode | undefined { + if (node.type === 'variable_declarator' && node.childForFieldName('name')?.text === nodeName) { + return node; + } + + if (node.type === 'export_statement') { + const declaration = node.childForFieldName('declaration'); + return declaration ? matchingDeclarator(declaration, nodeName) : undefined; + } + if (node.type !== 'lexical_declaration' && node.type !== 'variable_declaration') { + return undefined; + } + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if ( + child?.type === 'variable_declarator' && + child.childForFieldName('name')?.text === nodeName + ) { + return child; + } + } + return undefined; +} + +function findDeclarator(node: SyntaxNode, nodeName: string): SyntaxNode | undefined { + let current: SyntaxNode | null = node; + while (current) { + const declarator = matchingDeclarator(current, nodeName); + if (declarator) return declarator; + if (current.type === 'program' || current.type === 'statement_block') break; + current = current.parent; + } + return undefined; +} + +/** + * Stamp Convex runtime-dispatch metadata only when both the declaration shape + * and the factory import provenance are known. The MCP layer consumes the + * resulting property without reparsing lossy FTS text. + */ +export function extractConvexEndpointProperties( + context: DefinitionPropertiesContext, +): Readonly> | undefined { + if ((context.nodeLabel !== 'Const' && context.nodeLabel !== 'Function') || !context.isExported) { + return undefined; + } + + const declarator = findDeclarator(context.definitionNode, context.nodeName); + const value = declarator?.childForFieldName('value'); + if (!value || value.type !== 'call_expression') return undefined; + + const callee = value.childForFieldName('function'); + if (!callee || callee.type !== 'identifier') return undefined; + const factory = importedConvexFactory(context.parsedImports, callee.text); + if (factory === undefined) return undefined; + + const args = value.childForFieldName('arguments'); + if (!args || args.namedChildCount !== 1) return undefined; + const endpointDefinition = args.namedChild(0); + if ( + !endpointDefinition || + !['object', 'arrow_function', 'function_expression'].includes(endpointDefinition.type) + ) { + return undefined; + } + return assertCloneable({ convexEndpointFactory: factory }); +} diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index b8417c7a6..1d1f3dab4 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -141,7 +141,11 @@ import { templateConstraintsIdTag, } from '../utils/template-arguments.js'; import type { LanguageProvider } from '../language-provider.js'; -import { shouldHarvestModuleConstants } from '../language-provider.js'; +import { + mergeCanonicalDefinitionProperties, + runDefinitionPropertiesExtractor, + shouldHarvestModuleConstants, +} from '../language-provider.js'; import type { ParsedFile } from 'gitnexus-shared'; import { extractParsedFile, type ScopeCaptureSourceKind } from '../scope-extractor-bridge.js'; import { @@ -2821,19 +2825,38 @@ const processFileGroup = ( } } + const isExported = + language === SupportedLanguages.Vue && isVueSetup + ? isVueSetupTopLevel(nameNode || definitionNode) + : cachedExportCheck(provider.exportChecker, nameNode || definitionNode, nodeName); + if (definitionNode && provider.definitionPropertiesExtractor) { + const definitionProperties = runDefinitionPropertiesExtractor( + provider.definitionPropertiesExtractor, + { + nodeLabel, + nodeName, + definitionNode, + parsedImports: parsedFile?.parsedImports ?? [], + isExported, + }, + (error) => + reportWarning( + `Definition property extraction failed for ${file.path}:${nodeName}: ${error instanceof Error ? error.message : String(error)}`, + ), + ); + if (definitionProperties !== undefined) Object.assign(methodProps, definitionProperties); + } + result.nodes.push({ id: nodeId, label: nodeLabel, - properties: { + properties: mergeCanonicalDefinitionProperties(methodProps, { name: nodeName, filePath: file.path, startLine, endLine: definitionNode ? definitionNode.endPosition.row + lineOffset : startLine, language: language, - isExported: - language === SupportedLanguages.Vue && isVueSetup - ? isVueSetupTopLevel(nameNode || definitionNode) - : cachedExportCheck(provider.exportChecker, nameNode || definitionNode, nodeName), + isExported, ...(qualifiedTypeName !== undefined ? { qualifiedName: qualifiedTypeName } : {}), ...(classTemplateArguments !== undefined && classTemplateArguments.length > 0 ? { templateArguments: classTemplateArguments } @@ -2848,10 +2871,9 @@ const processFileGroup = ( } : {}), ...(description !== undefined ? { description } : {}), - ...methodProps, ...(declaredType !== undefined ? { declaredType } : {}), ...(returnShapeProperty ? { fromReturnShape: true, isDetail: true } : {}), - }, + }), }); // enclosingClassId already computed above (before nodeId generation) diff --git a/gitnexus/src/core/lbug/csv-generator.ts b/gitnexus/src/core/lbug/csv-generator.ts index 398ae0db9..d0d9a70bb 100644 --- a/gitnexus/src/core/lbug/csv-generator.ts +++ b/gitnexus/src/core/lbug/csv-generator.ts @@ -483,7 +483,7 @@ export const streamAllCSVsToDisk = async ( const codeElementHeader = 'id,name,filePath,startLine,endLine,isExported,content,description'; const functionWriter = new BufferedCSVWriter( path.join(csvDir, 'function.csv'), - codeElementHeader, + `${codeElementHeader},convexEndpointFactory`, ); const classWriter = new BufferedCSVWriter( path.join(csvDir, 'class.csv'), @@ -536,6 +536,7 @@ export const streamAllCSVsToDisk = async ( // Multi-language node types share the same CSV shape (no isExported column) const multiLangHeader = 'id,name,filePath,startLine,endLine,content,description'; + const constHeader = `${multiLangHeader},convexEndpointFactory`; const MULTI_LANG_TYPES = [ 'Struct', 'Enum', @@ -565,7 +566,7 @@ export const streamAllCSVsToDisk = async ( t, new BufferedCSVWriter( path.join(csvDir, `${t.toLowerCase()}.csv`), - t === 'Property' ? propertyHeader : multiLangHeader, + t === 'Property' ? propertyHeader : t === 'Const' ? constHeader : multiLangHeader, ), ); } @@ -734,6 +735,8 @@ export const streamAllCSVsToDisk = async ( ]; if (node.label === 'Class') { row.push(escapeCSVField(formatCSVStringArray(node.properties.frameworkAnnotations))); + } else if (node.label === 'Function') { + row.push(escapeCSVField(String(node.properties.convexEndpointFactory ?? ''))); } pending = writer.addRow(row.join(',')); } else { @@ -758,7 +761,9 @@ export const streamAllCSVsToDisk = async ( // empty BOOLEAN cell fails the COPY. node.properties.isDetail === true ? 'true' : 'false', ] - : []), + : node.label === 'Const' + ? [escapeCSVField(String(node.properties.convexEndpointFactory ?? ''))] + : []), ].join(','), ); } else { diff --git a/gitnexus/src/core/lbug/lbug-adapter.ts b/gitnexus/src/core/lbug/lbug-adapter.ts index 3eeeaaee9..3058c3d81 100644 --- a/gitnexus/src/core/lbug/lbug-adapter.ts +++ b/gitnexus/src/core/lbug/lbug-adapter.ts @@ -1547,9 +1547,15 @@ export const getCopyQuery = (table: NodeTableName, filePath: string): string => if (table === 'Method') { return `COPY ${t}(id, name, filePath, startLine, endLine, isExported, content, description, parameterCount, returnType) FROM "${filePath}" ${COPY_CSV_OPTS}`; } + if (table === 'Function') { + return `COPY ${t}(id, name, filePath, startLine, endLine, isExported, content, description, convexEndpointFactory) FROM "${filePath}" ${COPY_CSV_OPTS}`; + } if (table === 'Property') { return `COPY ${t}(id, name, filePath, startLine, endLine, content, description, declaredType, isDetail) FROM "${filePath}" ${COPY_CSV_OPTS}`; } + if (table === 'Const') { + return `COPY ${t}(id, name, filePath, startLine, endLine, content, description, convexEndpointFactory) FROM "${filePath}" ${COPY_CSV_OPTS}`; + } // TypeScript/JS code element tables have isExported; multi-language tables do not if (TABLES_WITH_EXPORTED.has(table)) { return `COPY ${t}(id, name, filePath, startLine, endLine, isExported, content, description) FROM "${filePath}" ${COPY_CSV_OPTS}`; @@ -1602,6 +1608,16 @@ export const insertNodeToLbug = async ( ? `, description: ${formatCypherValue(properties.description)}` : ''; query = `CREATE (n:Class {id: ${formatCypherValue(properties.id)}, name: ${formatCypherValue(properties.name)}, filePath: ${formatCypherValue(properties.filePath)}, startLine: ${properties.startLine || 0}, endLine: ${properties.endLine || 0}, isExported: ${!!properties.isExported}, content: ${formatCypherValue(properties.content || '')}${descPart}, frameworkAnnotations: ${formatCypherStringArray(properties.frameworkAnnotations)}})`; + } else if (label === 'Function') { + const descPart = properties.description + ? `, description: ${formatCypherValue(properties.description)}` + : ''; + query = `CREATE (n:Function {id: ${formatCypherValue(properties.id)}, name: ${formatCypherValue(properties.name)}, filePath: ${formatCypherValue(properties.filePath)}, startLine: ${properties.startLine || 0}, endLine: ${properties.endLine || 0}, isExported: ${!!properties.isExported}, content: ${formatCypherValue(properties.content || '')}${descPart}, convexEndpointFactory: ${formatCypherValue(properties.convexEndpointFactory ?? '')}})`; + } else if (label === 'Const') { + const descPart = properties.description + ? `, description: ${formatCypherValue(properties.description)}` + : ''; + query = `CREATE (n:Const {id: ${formatCypherValue(properties.id)}, name: ${formatCypherValue(properties.name)}, filePath: ${formatCypherValue(properties.filePath)}, startLine: ${properties.startLine || 0}, endLine: ${properties.endLine || 0}, content: ${formatCypherValue(properties.content || '')}${descPart}, convexEndpointFactory: ${formatCypherValue(properties.convexEndpointFactory ?? '')}})`; } else if (TABLES_WITH_EXPORTED.has(label)) { const descPart = properties.description ? `, description: ${formatCypherValue(properties.description)}` @@ -1692,6 +1708,16 @@ export const batchInsertNodesToLbug = async ( ? `, n.description = ${formatCypherValue(properties.description)}` : ''; query = `MERGE (n:Class {id: ${formatCypherValue(properties.id)}}) SET n.name = ${formatCypherValue(properties.name)}, n.filePath = ${formatCypherValue(properties.filePath)}, n.startLine = ${properties.startLine || 0}, n.endLine = ${properties.endLine || 0}, n.isExported = ${!!properties.isExported}, n.content = ${formatCypherValue(properties.content || '')}${descPart}, n.frameworkAnnotations = ${formatCypherStringArray(properties.frameworkAnnotations)}`; + } else if (label === 'Function') { + const descPart = properties.description + ? `, n.description = ${formatCypherValue(properties.description)}` + : ''; + query = `MERGE (n:Function {id: ${formatCypherValue(properties.id)}}) SET n.name = ${formatCypherValue(properties.name)}, n.filePath = ${formatCypherValue(properties.filePath)}, n.startLine = ${properties.startLine || 0}, n.endLine = ${properties.endLine || 0}, n.isExported = ${!!properties.isExported}, n.content = ${formatCypherValue(properties.content || '')}${descPart}, n.convexEndpointFactory = ${formatCypherValue(properties.convexEndpointFactory ?? '')}`; + } else if (label === 'Const') { + const descPart = properties.description + ? `, n.description = ${formatCypherValue(properties.description)}` + : ''; + query = `MERGE (n:Const {id: ${formatCypherValue(properties.id)}}) SET n.name = ${formatCypherValue(properties.name)}, n.filePath = ${formatCypherValue(properties.filePath)}, n.startLine = ${properties.startLine || 0}, n.endLine = ${properties.endLine || 0}, n.content = ${formatCypherValue(properties.content || '')}${descPart}, n.convexEndpointFactory = ${formatCypherValue(properties.convexEndpointFactory ?? '')}`; } else if (TABLES_WITH_EXPORTED.has(label)) { const descPart = properties.description ? `, n.description = ${formatCypherValue(properties.description)}` diff --git a/gitnexus/src/core/lbug/schema.ts b/gitnexus/src/core/lbug/schema.ts index 11471f2e6..058ae1308 100644 --- a/gitnexus/src/core/lbug/schema.ts +++ b/gitnexus/src/core/lbug/schema.ts @@ -51,6 +51,7 @@ CREATE NODE TABLE Function ( isExported BOOLEAN, content STRING, description STRING, + convexEndpointFactory STRING, PRIMARY KEY (id) )`; @@ -170,7 +171,18 @@ export const NAMESPACE_SCHEMA = CODE_ELEMENT_BASE('Namespace'); export const TRAIT_SCHEMA = CODE_ELEMENT_BASE('Trait'); export const IMPL_SCHEMA = CODE_ELEMENT_BASE('Impl'); export const TYPE_ALIAS_SCHEMA = CODE_ELEMENT_BASE('TypeAlias'); -export const CONST_SCHEMA = CODE_ELEMENT_BASE('Const'); +export const CONST_SCHEMA = ` +CREATE NODE TABLE \`Const\` ( + id STRING, + name STRING, + filePath STRING, + startLine INT64, + endLine INT64, + content STRING, + description STRING, + convexEndpointFactory STRING, + PRIMARY KEY (id) +)`; export const STATIC_SCHEMA = CODE_ELEMENT_BASE('Static'); export const VARIABLE_SCHEMA = CODE_ELEMENT_BASE('Variable'); export const PROPERTY_SCHEMA = ` diff --git a/gitnexus/src/mcp/local/convex-metadata.ts b/gitnexus/src/mcp/local/convex-metadata.ts new file mode 100644 index 000000000..82f711198 --- /dev/null +++ b/gitnexus/src/mcp/local/convex-metadata.ts @@ -0,0 +1,72 @@ +import { executeParameterized } from '../../core/lbug/pool-adapter.js'; +import { logger } from '../../core/logger.js'; + +export interface ConvexDispatchMetadata { + readonly factory?: string; + readonly boundary: string; + readonly staleIndex?: true; + readonly probeFailed?: true; +} + +function isMissingConvexMetadataProperty(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error ?? ''); + return /cannot find property\s+convexEndpointFactory|property[^\n]*convexEndpointFactory[^\n]*not (?:defined|found)/i.test( + message, + ); +} + +export async function queryConvexDispatchMetadata( + lbugPath: string, + symbolId: string, + symbolName: string, + symbolType: string, + runQuery: typeof executeParameterized = executeParameterized, +): Promise { + if (symbolType !== 'Const' && symbolType !== 'Function') return undefined; + + const nodeLabel = symbolType === 'Function' ? 'Function' : 'Const'; + + try { + const rows = await runQuery( + lbugPath, + `MATCH (n:${nodeLabel} {id: $symbolId}) + RETURN n.convexEndpointFactory AS factory`, + { symbolId }, + ); + const row = rows[0]; + if (row === undefined) return undefined; + + const factory = String(row.factory ?? row[0] ?? ''); + if (factory.length === 0) return undefined; + + return { + factory, + boundary: + `${symbolName} is exported through Convex ${factory}({...}) and can be addressed through ` + + `the anyApi runtime proxy; callers across that dynamic-dispatch boundary leave no static ` + + `edge, so actual impact may be higher.`, + }; + } catch (error) { + if (isMissingConvexMetadataProperty(error)) { + return { + staleIndex: true, + boundary: + 'Convex runtime-proxy metadata is unavailable because this index predates ' + + 'convexEndpointFactory; re-index before treating impact as exact.', + }; + } + logger.warn( + { + context: 'impact:convex-metadata', + err: error instanceof Error ? error.message : String(error), + }, + 'GitNexus Convex metadata probe failed (degraded)', + ); + return { + probeFailed: true, + boundary: + 'Convex runtime-proxy metadata could not be checked; impact remains a lower bound ' + + 'until the metadata probe succeeds.', + }; + } +} diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 47cb50efd..854b7f267 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -20,6 +20,7 @@ import { } from '../../core/lbug/pool-adapter.js'; import { queryClassBeanMetadata } from './bean-metadata.js'; import { querySpringAopMetadata } from './aop-metadata.js'; +import { queryConvexDispatchMetadata } from './convex-metadata.js'; import { isValidQueryParams } from '../../core/lbug/query-params.js'; import { toDisplayLine } from './line-display.js'; import { LBUG_ID_PROBE_BATCH_SIZE, LBUG_QUERY_BATCH_SIZE } from '../../core/lbug/query-batch.js'; @@ -659,9 +660,8 @@ export interface EpistemicCauses { */ readonly receiverTyping: number; /** - * Symbols on the far side of a dispatch boundary that the traversal could not - * attribute to the queried symbol: implementations plus interface-level - * consumers, summed over the boundary nodes that were flagged. + * Symbols on or beyond a dispatch boundary that the traversal could not + * attribute statically: implementations plus interface-level consumers. * * Unit: SYMBOLS, not call sites — deliberately, because a call-site count is * not derivable on this side. The graph does not retain per-site multiplicity @@ -671,6 +671,10 @@ export interface EpistemicCauses { * symbol reachable through two flagged boundary nodes is counted once per * node, so this is itself a lower bound. * + * Framework runtime-proxy metadata can prove that impact is incomplete but + * cannot provide this magnitude, so it contributes a boundary note while + * leaving this count unchanged. + * * It is still directly comparable in magnitude with `receiverTyping` — both * answer "how much is missing" — which `boundaries.length` was not. */ @@ -711,6 +715,7 @@ function epistemicFrom(dropped: { sites: number; external: number; undecided: number; + dispatch: number; }): { epistemic: 'exact' | 'lower-bound'; boundaries?: string[]; @@ -725,7 +730,7 @@ function epistemicFrom(dropped: { epistemic: 'exact', causes: { receiverTyping: 0, - dispatchBoundary: 0, + dispatchBoundary: dropped.dispatch, externalBoundary: dropped.external, undecidedSatisfaction: 0, }, @@ -740,7 +745,7 @@ function epistemicFrom(dropped: { // would read a different magnitude than the human reading the text. causes: { receiverTyping: dropped.sites, - dispatchBoundary: 0, + dispatchBoundary: dropped.dispatch, externalBoundary: dropped.external, undecidedSatisfaction: dropped.undecided, }, @@ -6775,6 +6780,7 @@ export class LocalBackend { symId: string, symType: string, symName: string, + direction?: 'upstream' | 'downstream', ): Promise<{ epistemic: 'exact' | 'lower-bound'; boundaries?: string[]; @@ -6811,6 +6817,19 @@ export class LocalBackend { // the owning-type hop below would be a graph round-trip per method query in // every index that has no such record — which is every non-Go one, since Go // is the only language with a structural-satisfaction hook. + const convexDispatchPromise = + direction === 'downstream' + ? Promise.resolve(undefined) + : queryConvexDispatchMetadata(repo.lbugPath, symId, symName, symType); + const interfaceRowsPromise = executeParameterized( + repo.lbugPath, + `MATCH (x)-[r:CodeRelation]->(iface) + WHERE x.id = $symId AND r.type IN $heritage + RETURN DISTINCT iface.id AS id, iface.name AS name, labels(iface)[0] AS label + ORDER BY id + LIMIT 25`, + { symId, heritage: HERITAGE_TYPES }, + ).catch(() => []); const undecidedSummary = meta?.undecidedInterfaceSatisfaction; const undecidedDrops = undecidedSummary === undefined @@ -6821,10 +6840,19 @@ export class LocalBackend { ? await this.owningTypeNames(repo, symId) : []), ]); + const convexDispatch = await convexDispatchPromise; const droppedBoundaries = { ...receiverDrops, - notes: [...receiverDrops.notes, ...undecidedDrops.notes], + notes: [ + ...receiverDrops.notes, + ...undecidedDrops.notes, + ...(convexDispatch === undefined ? [] : [convexDispatch.boundary]), + ], undecided: undecidedDrops.undecided, + // Endpoint/probe evidence proves incompleteness but does not expose a + // count of omitted symbols. Keep the magnitude at zero rather than + // inventing one from the presence of a note. + dispatch: 0, }; try { // Discover the interface / abstract supertypes on the target's boundary. @@ -6833,15 +6861,7 @@ export class LocalBackend { if (symType === 'Interface') { boundary.set(symId, { name: symName || '', label: 'Interface' }); } - const ifaceRows = await executeParameterized( - repo.lbugPath, - `MATCH (x)-[r:CodeRelation]->(iface) - WHERE x.id = $symId AND r.type IN $heritage - RETURN DISTINCT iface.id AS id, iface.name AS name, labels(iface)[0] AS label - ORDER BY id - LIMIT 25`, - { symId, heritage: HERITAGE_TYPES }, - ).catch(() => []); + const ifaceRows = await interfaceRowsPromise; for (const r of ifaceRows) { const id = (r.id ?? r[0]) as string; if (id && !boundary.has(id)) { @@ -6920,7 +6940,7 @@ export class LocalBackend { boundaries: [...droppedBoundaries.notes, ...boundaries], causes: { receiverTyping: droppedBoundaries.sites, - dispatchBoundary: dispatchBoundarySymbols, + dispatchBoundary: droppedBoundaries.dispatch + dispatchBoundarySymbols, externalBoundary: droppedBoundaries.external, undecidedSatisfaction: droppedBoundaries.undecided, }, @@ -7027,7 +7047,13 @@ export class LocalBackend { causes?: EpistemicCauses; }> = opts.skipEpistemic ? Promise.resolve({}) - : this.computeEpistemicBoundary(repo, symId, symType, (sym.name || sym[1]) as string); + : this.computeEpistemicBoundary( + repo, + symId, + symType, + (sym.name || sym[1]) as string, + direction, + ); const beanMetadataPromise = opts.skipEpistemic || summaryOnly ? Promise.resolve(undefined) diff --git a/gitnexus/src/mcp/tools.ts b/gitnexus/src/mcp/tools.ts index 4d59f3ffd..7fea547a5 100644 --- a/gitnexus/src/mcp/tools.ts +++ b/gitnexus/src/mcp/tools.ts @@ -290,9 +290,10 @@ COMPLETENESS OF incoming: alongside symbol/incoming/outgoing the result carries - causes: { receiverTyping, dispatchBoundary, externalBoundary, undecidedSatisfaction } — machine-readable WHY. Every field counts MISSING THINGS, never sentences: - causes.receiverTyping (unit: call sites) > 0 — RESOLVER GAP: the analyzer dropped that many call sites on this name because it could not type the receiver, so they are missing from incoming. Do not read an absent caller as proof none exists. - causes.externalBoundary (unit: call sites) > 0 — the calls left the indexed program (System.out.println, fetch(...)). NOT a defect: no in-graph node could have been reached. An epistemic:'exact' result can carry this. - - causes.dispatchBoundary (unit: symbols) > 0 — DI / interface dispatch: implementations plus interface-level consumers behind a boundary static analysis cannot cross. Irreducible. + - causes.dispatchBoundary (unit: symbols) > 0 — DI or interface dispatch: that many symbols sit on or beyond a boundary static analysis cannot cross. Irreducible. A symbol count, not a site count — per-site multiplicity is not retained for these edges — so compare its magnitude with receiverTyping, not its exact value. A framework runtime-proxy boundary can make epistemic lower-bound while this value remains 0 because endpoint metadata proves the gap but cannot count omitted symbols. + - causes.undecidedSatisfaction (unit: unjudged interface/type pairs) > 0 — the analyzer could not decide whether a type satisfies an interface, so no IMPLEMENTS edge exists and no dispatch boundary was left for the walk to notice. Usually fixable by making the missing dependency available to analysis. -REQUIRES RE-INDEX: causes.receiverTyping and causes.externalBoundary come from index-time metadata only a current analyzer writes; against an older index they read as absent/0, which is indistinguishable from "nothing was dropped". Re-run \`gitnexus analyze\` before trusting a zero there. +REQUIRES RE-INDEX: causes.receiverTyping, causes.externalBoundary, causes.undecidedSatisfaction, and framework runtime-proxy boundary detection depend on index-time metadata that only a current analyzer writes. Against an older index the metadata can be absent, which is indistinguishable from "nothing was dropped" unless the schema probe detects the stale index — re-run \`gitnexus analyze\` before trusting a zero or an apparently exact result. GROUP MODE: set "repo" to "@" to run context in each member repo (aggregated list), or "@/" for one member. If you use "@" only, the member defaults to the lexicographically first key in group.yaml "repos". @@ -484,11 +485,11 @@ Output includes: - causes: { receiverTyping, dispatchBoundary, externalBoundary, undecidedSatisfaction } — the machine-readable split of WHY, so an agent gating its own edits can tell a fixable analyzer gap from an irreducible one. Every field counts MISSING THINGS, never sentences: - causes.receiverTyping (unit: call sites) > 0 — the RESOLVER GAP signal: the analyzer dropped that many call sites because it could not establish the receiver's type (unresolved constructor, factory, chained expression). Those callers are absent from byDepth. Treat the result as incomplete: grep the symbol name before deleting or renaming. - causes.externalBoundary (unit: call sites) > 0 — those calls left the indexed program (System.out.println, fetch(...), os.environ.*). NOT a defect and NOT a reason the count is short: there is no in-graph node any edge could have reached. An epistemic:'exact' result can carry this. - - causes.dispatchBoundary (unit: symbols) > 0 — DI / interface dispatch: that many implementations plus interface-level consumers sit on the far side of a boundary a static walk cannot cross. Irreducible; a compiler refuses here too. A symbol count, not a site count — per-site multiplicity is not retained for these edges — so compare its magnitude with receiverTyping, not its exact value. + - causes.dispatchBoundary (unit: symbols) > 0 — DI or interface dispatch: that many symbols sit on or beyond a boundary a static walk cannot cross. Irreducible. A symbol count, not a site count — per-site multiplicity is not retained for these edges — so compare its magnitude with receiverTyping, not its exact value. A framework runtime-proxy boundary can make epistemic lower-bound while this value remains 0 because endpoint metadata proves the gap but cannot count omitted symbols. - causes.undecidedSatisfaction (unit: unjudged interface/type pairs) > 0 — the analyzer could not DECIDE whether a type satisfies an interface (a type in a required signature named a package it could not resolve), so no IMPLEMENTS edge exists and no dispatch boundary was left for the walk to notice. Distinct from every cause above, which count decided facts that could not be attributed; this one counts questions never answered. It is the only cause that shortens a result WITHOUT leaving a trace in the graph, so an unhedged zero on a symbol reached only through such an interface would otherwise read as 'nobody calls this'. Usually fixable: it most often means a dependency is missing from the analyzed tree. -REQUIRES RE-INDEX: causes.receiverTyping, causes.externalBoundary and causes.undecidedSatisfaction are read from index-time metadata that only a current analyzer writes. Against an older index they read as absent/0, which is indistinguishable from "nothing was dropped" — re-run \`gitnexus analyze\` before trusting a zero there. +REQUIRES RE-INDEX: causes.receiverTyping, causes.externalBoundary, causes.undecidedSatisfaction, and framework runtime-proxy boundary detection depend on index-time metadata that only a current analyzer writes. Against an older index the metadata can be absent, which is indistinguishable from "nothing was dropped" unless the schema probe detects the stale index — re-run \`gitnexus analyze\` before trusting a zero or an apparently exact result. Depth groups: - d=1: WILL BREAK (direct callers/importers) diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index 6de50176a..903a57016 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -569,7 +569,16 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j // IN-FLIGHT claim, not above origin/main. Every open PR touching gitnexus/ was // scanned; #3017 is the only other claimant. // RE-CHECK AGAINST origin/main AND OPEN PRs IMMEDIATELY BEFORE MERGING. -const SCHEMA_BUMP = 72; +// +// 72 -> 74 adds import-proven Convex endpoint metadata to Const/Function worker +// output. A warm v72 cache has no convexEndpointFactory property, so the MCP +// impact probe would keep claiming exact results for unchanged endpoints. The +// parse-cache bump makes unchanged files re-parse; analyzer runner identity +// drift separately forces the graph re-emit (run-analyze.ts), and an id/schema +// migration needs both guarantees. Version 73 is intentionally skipped because +// concurrent PR #3046 (fixes #3041) claims it. Re-check main and open PRs +// immediately before merge. +const SCHEMA_BUMP = 74; const GITNEXUS_PKG_VERSION = (() => { try { // package.json sits at gitnexus/package.json — two levels up from diff --git a/gitnexus/test/integration/convex-impact-epistemic-e2e.test.ts b/gitnexus/test/integration/convex-impact-epistemic-e2e.test.ts new file mode 100644 index 000000000..74e1f1073 --- /dev/null +++ b/gitnexus/test/integration/convex-impact-epistemic-e2e.test.ts @@ -0,0 +1,218 @@ +import fs from 'fs'; +import path from 'path'; +import { beforeAll, expect, it, vi } from 'vitest'; +import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; +import { + loadParseCache, + PARSE_CACHE_VERSION, + pruneCache, + saveParseCache, + type ParseCache, +} from '../../src/storage/parse-cache.js'; +import { + getDurableParsedFileDir, + pruneAndSaveDurableParsedFileStore, +} from '../../src/storage/parsedfile-store.js'; +import { LocalBackend } from '../../src/mcp/local/local-backend.js'; +import { listRegisteredRepos } from '../../src/storage/repo-manager.js'; +import { withTestLbugDB } from '../helpers/test-indexed-db.js'; + +vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => ({ + ...(await importOriginal()), + listRegisteredRepos: vi.fn().mockResolvedValue([]), + cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }), + findSiblingClones: vi.fn().mockResolvedValue([]), +})); + +let repoDir = ''; +let warmReplayUsedWorkers = true; +const replayProperties = new Map(); +let bareHandlerFunctionId = ''; + +withTestLbugDB( + 'convex-impact-epistemic-e2e', + (handle) => { + let backend: LocalBackend; + + beforeAll(() => { + backend = (handle as typeof handle & { _backend: LocalBackend })._backend; + }); + + it('persists import-proven endpoint metadata through a warm parse cache', () => { + expect(warmReplayUsedWorkers).toBe(false); + expect(replayProperties).toEqual( + new Map([ + ['aliasedWrite', 'mutation'], + ['generatedAction', 'internalAction'], + ['javascriptQuery', 'query'], + ['bareHandler', 'query'], + ['publicQuery', 'query'], + ]), + ); + }); + + it.each([ + ['publicQuery', 'endpoints.ts', 'query'], + ['aliasedWrite', 'endpoints.ts', 'mutation'], + ['generatedAction', 'endpoints.ts', 'internalAction'], + ['javascriptQuery', 'endpoints.js', 'query'], + ])( + 'marks real indexed Convex endpoint %s as lower-bound', + async (target, filePath, factory) => { + const result = await backend.callTool('impact', { + target, + file_path: filePath, + direction: 'upstream', + }); + + expect(result.epistemic).toBe('lower-bound'); + expect(result.boundaries.join(' ')).toContain(`Convex ${factory}`); + expect(result.causes.dispatchBoundary).toBe(0); + }, + ); + + it('marks a bare Function handler as lower-bound', async () => { + expect(bareHandlerFunctionId).not.toBe(''); + const result = await backend.callTool('impact', { + target_uid: bareHandlerFunctionId, + direction: 'upstream', + }); + + expect(result.epistemic).toBe('lower-bound'); + expect(result.boundaries.join(' ')).toContain('Convex query'); + expect(result.causes.dispatchBoundary).toBe(0); + }); + + it.each([ + ['unrelatedQuery', 'endpoints.ts'], + ['localQuery', 'local.ts'], + ])('keeps non-Convex same-shape control %s exact', async (target, filePath) => { + const result = await backend.callTool('impact', { + target, + file_path: filePath, + direction: 'upstream', + }); + + expect(result.epistemic).toBe('exact'); + expect(result.boundaries).toBeUndefined(); + }); + + it('does not apply the inbound Convex boundary to downstream impact', async () => { + const result = await backend.callTool('impact', { + target: 'publicQuery', + file_path: 'endpoints.ts', + direction: 'downstream', + }); + + expect(result.epistemic).toBe('exact'); + expect(result.boundaries).toBeUndefined(); + }); + + it('carries the Convex boundary through context()', async () => { + const result = await backend.callTool('context', { + name: 'publicQuery', + file_path: 'endpoints.ts', + }); + + expect(result.status).toBe('found'); + expect(result.epistemic).toBe('lower-bound'); + expect(result.boundaries.join(' ')).toContain('Convex query'); + }); + + it('keeps a non-Convex same-shape context exact', async () => { + const result = await backend.callTool('context', { + name: 'localQuery', + file_path: 'local.ts', + }); + + expect(result.status).toBe('found'); + expect(result.epistemic).toBe('exact'); + expect(result.boundaries).toBeUndefined(); + }); + }, + { + beforeFTS: async (dbPath) => { + const storageDir = path.dirname(dbPath); + repoDir = path.join(storageDir, 'repo'); + const cacheDir = path.join(storageDir, 'parse-cache'); + fs.mkdirSync(repoDir, { recursive: true }); + fs.writeFileSync( + path.join(repoDir, 'endpoints.ts'), + `import { queryGeneric as query, mutationGeneric as write } from 'convex/server'; +import { query as generatedQuery, internalAction as internalRun } from './_generated/server'; +import { query as dbQuery } from './database'; + +export const publicQuery = // legal line-comment trivia + query({ handler: async () => null }); +export const aliasedWrite = write({ handler: async () => null }); +export const generatedAction = internalRun({ handler: async () => null }); +export const bareHandler = generatedQuery(async () => null); +export const unrelatedQuery = dbQuery({ handler: async () => null }); +`, + ); + fs.writeFileSync( + path.join(repoDir, 'local.ts'), + `function query(config: unknown) { return config; } +export const localQuery = query({ handler: async () => null }); +`, + ); + fs.writeFileSync( + path.join(repoDir, 'endpoints.js'), + `import { query } from './_generated/server.js'; +export const javascriptQuery = query({ handler: async () => null }); +`, + ); + + const cold: ParseCache = { + version: PARSE_CACHE_VERSION, + entries: new Map(), + usedKeys: new Set(), + storagePath: cacheDir, + onDiskKeys: new Set(), + }; + await runPipelineFromRepo(repoDir, () => {}, { parseCache: cold, workerPoolSize: 1 }); + pruneCache(cold, cold.usedKeys); + const savedKeys = await saveParseCache(cacheDir, cold); + await pruneAndSaveDurableParsedFileStore( + getDurableParsedFileDir(cacheDir), + PARSE_CACHE_VERSION, + new Set(savedKeys), + ); + + const warm = await loadParseCache(cacheDir); + const replay = await runPipelineFromRepo(repoDir, () => {}, { + parseCache: warm ?? undefined, + workerPoolSize: 1, + }); + warmReplayUsedWorkers = replay.usedWorkerPool; + replay.graph.forEachNode((node) => { + if (node.properties.convexEndpointFactory !== undefined) { + replayProperties.set(node.properties.name, node.properties.convexEndpointFactory); + if (node.label === 'Function' && node.properties.name === 'bareHandler') { + bareHandlerFunctionId = node.id; + } + } + }); + + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + await adapter.loadGraphToLbug(replay.graph, repoDir, storageDir); + }, + poolAdapter: true, + afterSetup: async (handle) => { + vi.mocked(listRegisteredRepos).mockResolvedValue([ + { + name: 'convex-e2e', + path: repoDir, + storagePath: handle.tmpHandle.dbPath, + indexedAt: new Date().toISOString(), + lastCommit: 'convex-e2e', + stats: { files: 3, nodes: 6, communities: 0, processes: 0 }, + }, + ]); + const backend = new LocalBackend(); + await backend.init(); + (handle as typeof handle & { _backend?: LocalBackend })._backend = backend; + }, + timeout: 180_000, + }, +); diff --git a/gitnexus/test/integration/impact-epistemic-lower-bound.test.ts b/gitnexus/test/integration/impact-epistemic-lower-bound.test.ts index 58557daad..6ca1d6dde 100644 --- a/gitnexus/test/integration/impact-epistemic-lower-bound.test.ts +++ b/gitnexus/test/integration/impact-epistemic-lower-bound.test.ts @@ -42,6 +42,21 @@ const SEED = [ `CREATE (leaf:Function {id: 'Function:src/util.ts:formatDate', name: 'formatDate', filePath: 'src/util.ts', startLine: 1, endLine: 3, isExported: true, content: '', description: ''})`, `CREATE (caller:Function {id: 'Function:src/page.ts:renderHeader', name: 'renderHeader', filePath: 'src/page.ts', startLine: 1, endLine: 10, isExported: true, content: '', description: ''})`, `MATCH (a:Function {id:'Function:src/page.ts:renderHeader'}), (b:Function {id:'Function:src/util.ts:formatDate'}) CREATE (a)-[:CodeRelation {type:'CALLS', confidence:0.9, reason:'direct', step:0}]->(b)`, + + ...[ + ['listOrders', 'query'], + ['createOrder', 'mutation'], + ['syncOrders', 'action'], + ['readInternal', 'internalQuery'], + ['writeInternal', 'internalMutation'], + ['runInternal', 'internalAction'], + ].map( + ([name, factory]) => + `CREATE (:Const {id: 'Const:src/convex.ts:${name}', name: '${name}', filePath: 'src/convex.ts', startLine: 1, endLine: 3, content: '', description: '', convexEndpointFactory: '${factory}'})`, + ), + `CREATE (:Const {id: 'Const:src/negative.ts:localQuery', name: 'localQuery', filePath: 'src/negative.ts', startLine: 1, endLine: 1, content: '', description: '', convexEndpointFactory: ''})`, + `CREATE (:Const {id: 'Const:src/negative.ts:nestedQuery', name: 'nestedQuery', filePath: 'src/negative.ts', startLine: 2, endLine: 2, content: '', description: '', convexEndpointFactory: ''})`, + `CREATE (:Const {id: 'Const:src/negative.ts:memberQuery', name: 'memberQuery', filePath: 'src/negative.ts', startLine: 3, endLine: 3, content: '', description: '', convexEndpointFactory: ''})`, ]; withTestLbugDB( @@ -101,6 +116,40 @@ withTestLbugDB( expect(result.impactedCount).toBeGreaterThanOrEqual(1); }); + it.each([ + ['listOrders', 'query'], + ['createOrder', 'mutation'], + ['syncOrders', 'action'], + ['readInternal', 'internalQuery'], + ['writeInternal', 'internalMutation'], + ['runInternal', 'internalAction'], + ])('marks Convex %s/%s runtime dispatch as a lower bound', async (target, factory) => { + const result = await backend.callTool('impact', { + target, + file_path: 'src/convex.ts', + direction: 'upstream', + }); + + expect(result.epistemic).toBe('lower-bound'); + expect(result.boundaries.join(' ')).toContain(`Convex ${factory}`); + expect(result.boundaries.join(' ')).toContain('anyApi'); + expect(result.causes.dispatchBoundary).toBe(0); + }); + + it.each(['localQuery', 'nestedQuery', 'memberQuery'])( + 'keeps non-wrapper control %s exact', + async (target) => { + const result = await backend.callTool('impact', { + target, + file_path: 'src/negative.ts', + direction: 'upstream', + }); + + expect(result.epistemic).toBe('exact'); + expect(result.boundaries).toBeUndefined(); + }, + ); + it('context() carries the same epistemic signal', async () => { const result = await backend.callTool('context', { name: 'EmailLogger', diff --git a/gitnexus/test/unit/convex-dispatch-metadata.test.ts b/gitnexus/test/unit/convex-dispatch-metadata.test.ts new file mode 100644 index 000000000..f192e7550 --- /dev/null +++ b/gitnexus/test/unit/convex-dispatch-metadata.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; + +import { queryConvexDispatchMetadata } from '../../src/mcp/local/convex-metadata.js'; + +describe('Convex dispatch metadata compatibility', () => { + it('marks a pre-property index as conservatively incomplete', async () => { + const missingProperty = async (): Promise => { + throw new Error('Cannot find property convexEndpointFactory for n'); + }; + + const result = await queryConvexDispatchMetadata( + '/tmp/old-index', + 'Const:x', + 'x', + 'Const', + missingProperty, + ); + + expect(result?.staleIndex).toBe(true); + expect(result?.boundary).toContain('re-index'); + }); + + it('marks unrelated query failures as conservatively incomplete', async () => { + const transientFailure = async (): Promise => { + throw new Error('database busy'); + }; + + const result = await queryConvexDispatchMetadata( + '/tmp/index', + 'Const:x', + 'x', + 'Const', + transientFailure, + ); + + expect(result?.probeFailed).toBe(true); + expect(result?.boundary).toContain('could not be checked'); + }); + + it('queries Function metadata without an undeclared deterministic LIMIT', async () => { + let cypher = ''; + const runQuery = async (_path: string, query: string) => { + cypher = query; + return [{ factory: 'query' }]; + }; + + await expect( + queryConvexDispatchMetadata('/tmp/index', 'Function:x', 'x', 'Function', runQuery), + ).resolves.toMatchObject({ factory: 'query' }); + expect(cypher).toContain('MATCH (n:Function'); + expect(cypher).not.toContain('LIMIT'); + }); +}); diff --git a/gitnexus/test/unit/convex-metadata-persistence-contract.test.ts b/gitnexus/test/unit/convex-metadata-persistence-contract.test.ts new file mode 100644 index 000000000..8fd89e820 --- /dev/null +++ b/gitnexus/test/unit/convex-metadata-persistence-contract.test.ts @@ -0,0 +1,123 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { CONST_SCHEMA, FUNCTION_SCHEMA } from '../../src/core/lbug/schema.js'; + +interface FakeQueryResult { + getAll: () => Promise; + close: () => void; +} + +function makeConfigMock() { + const queries: string[] = []; + const queryResult: FakeQueryResult = { getAll: async () => [], close: vi.fn() }; + const conn = { + query: vi.fn(async (cypher: string) => { + queries.push(cypher); + return queryResult; + }), + close: vi.fn(async () => {}), + }; + const db = { close: vi.fn(async () => {}) }; + return { + queries, + mock: { + openLbugConnection: vi.fn(async () => ({ db, conn })), + closeLbugConnection: async () => { + await conn.close(); + await db.close(); + }, + isDbBusyError: vi.fn(() => false), + isOpenRetryExhausted: vi.fn(() => false), + isWalCorruptionError: vi.fn(() => false), + toNativeSafePath: (value: string) => value, + resolveNativeSafeStorageDir: (value: string) => value, + WAL_RECOVERY_SUGGESTION: 'run analyze --force', + waitForWindowsHandleRelease: vi.fn(async () => true), + }, + }; +} + +const endpoint = { + id: 'Const:src/endpoints.ts:getUser', + name: 'getUser', + filePath: 'src/endpoints.ts', + startLine: 1, + endLine: 3, + isExported: true, + content: 'query({ handler: getUser })', + convexEndpointFactory: 'query', +}; + +describe('Convex endpoint metadata persistence contract', () => { + afterEach(() => { + vi.doUnmock('../../src/core/lbug/lbug-config.js'); + vi.resetModules(); + vi.clearAllMocks(); + }); + + it('keeps the Const schema and COPY column list aligned', async () => { + const { getCopyQuery } = await import('../../src/core/lbug/lbug-adapter.js'); + const copyQuery = getCopyQuery('Const', '/tmp/const.csv'); + const functionCopyQuery = getCopyQuery('Function', '/tmp/function.csv'); + + expect(CONST_SCHEMA).toContain('convexEndpointFactory STRING'); + expect(FUNCTION_SCHEMA).toContain('convexEndpointFactory STRING'); + expect(CONST_SCHEMA).not.toContain('isExported BOOLEAN'); + expect(copyQuery).toContain('content, description, convexEndpointFactory'); + expect(functionCopyQuery).toContain('isExported, content, description, convexEndpointFactory'); + expect(copyQuery).not.toContain('isExported'); + }); + + it('persists the property through single-node CREATE', async () => { + const { mock, queries } = makeConfigMock(); + vi.doMock('../../src/core/lbug/lbug-config.js', () => mock); + const { insertNodeToLbug } = await import('../../src/core/lbug/lbug-adapter.js'); + + await expect(insertNodeToLbug('Const', endpoint, '/tmp/convex-create/lbug')).resolves.toBe( + true, + ); + + const createQuery = queries.find((query) => query.startsWith('CREATE (n:Const')); + expect(createQuery).toContain("convexEndpointFactory: 'query'"); + expect(createQuery).not.toContain('isExported'); + + await expect( + insertNodeToLbug( + 'Function', + { ...endpoint, id: 'Function:src/endpoints.ts:getUser' }, + '/tmp/convex-create/lbug', + ), + ).resolves.toBe(true); + const functionQuery = queries.find((query) => query.startsWith('CREATE (n:Function')); + expect(functionQuery).toContain("convexEndpointFactory: 'query'"); + expect(functionQuery).toContain('isExported: true'); + }); + + it('persists the property through incremental MERGE', async () => { + const { mock, queries } = makeConfigMock(); + vi.doMock('../../src/core/lbug/lbug-config.js', () => mock); + const { batchInsertNodesToLbug } = await import('../../src/core/lbug/lbug-adapter.js'); + + await expect( + batchInsertNodesToLbug([{ label: 'Const', properties: endpoint }], '/tmp/convex-merge/lbug'), + ).resolves.toEqual({ inserted: 1, failed: 0 }); + + const mergeQuery = queries.find((query) => query.startsWith('MERGE (n:Const')); + expect(mergeQuery).toContain("n.convexEndpointFactory = 'query'"); + expect(mergeQuery).not.toContain('isExported'); + + await expect( + batchInsertNodesToLbug( + [ + { + label: 'Function', + properties: { ...endpoint, id: 'Function:src/endpoints.ts:getUser' }, + }, + ], + '/tmp/convex-merge/lbug', + ), + ).resolves.toEqual({ inserted: 1, failed: 0 }); + const functionQuery = queries.find((query) => query.startsWith('MERGE (n:Function')); + expect(functionQuery).toContain("n.convexEndpointFactory = 'query'"); + expect(functionQuery).toContain('n.isExported = true'); + }); +}); diff --git a/gitnexus/test/unit/convex-metadata.test.ts b/gitnexus/test/unit/convex-metadata.test.ts new file mode 100644 index 000000000..59961c946 --- /dev/null +++ b/gitnexus/test/unit/convex-metadata.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest'; +import Parser from 'tree-sitter'; +import TypeScript from 'tree-sitter-typescript'; +import type { ParsedImport } from 'gitnexus-shared'; +import { extractConvexEndpointProperties } from '../../src/core/ingestion/languages/typescript/convex-endpoint-metadata.js'; +import type { SyntaxNode } from '../../src/core/ingestion/utils/ast-helpers.js'; + +const parser = new Parser(); +parser.setLanguage(TypeScript.typescript as Parameters[0]); + +function nodeOfType(source: string, type: string): SyntaxNode { + const root = parser.parse(source).rootNode as unknown as SyntaxNode; + const stack = [root]; + while (stack.length > 0) { + const node = stack.pop()!; + if (node.type === type) return node; + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child) stack.push(child); + } + } + throw new Error(`fixture has no ${type}`); +} + +const namedImport = ( + targetRaw: string, + importedName: string, + localName = importedName, +): ParsedImport => ({ + kind: localName === importedName ? 'named' : 'alias', + targetRaw, + importedName, + localName, + ...(localName === importedName ? {} : { alias: localName }), +}); + +function extract( + source: string, + imports: readonly ParsedImport[], + isExported = true, + nodeLabel = 'Const', + definitionType = 'export_statement', +) { + return extractConvexEndpointProperties({ + nodeLabel, + nodeName: 'updateDraft', + definitionNode: nodeOfType(source, definitionType), + parsedImports: imports, + isExported, + }); +} + +describe('Convex endpoint metadata extraction', () => { + it('canonicalizes a generic convex/server factory across line-comment trivia', () => { + expect( + extract( + `export const updateDraft = // legal trivia\n mutation({ handler: async () => null });`, + [namedImport('convex/server', 'mutationGeneric', 'mutation')], + ), + ).toEqual({ convexEndpointFactory: 'mutation' }); + }); + + it('preserves the canonical factory through a generated-server import alias', () => { + expect( + extract(`export const updateDraft = write({ handler: async () => null });`, [ + namedImport('../_generated/server', 'internalMutation', 'write'), + ]), + ).toEqual({ convexEndpointFactory: 'internalMutation' }); + }); + + it('accepts generated-server package paths and httpAction', () => { + expect( + extract(`export const updateDraft = route(async () => null);`, [ + namedImport('convex/_generated/server', 'httpAction', 'route'), + ]), + ).toEqual({ convexEndpointFactory: 'httpAction' }); + }); + + it.each(['arrow_function', 'function_expression'])('stamps a bare %s handler capture', (type) => { + const expression = + type === 'arrow_function' ? 'async () => null' : 'async function () { return null; }'; + expect( + extract( + `export const updateDraft = query(${expression});`, + [namedImport('./_generated/server', 'query')], + true, + 'Function', + 'export_statement', + ), + ).toEqual({ convexEndpointFactory: 'query' }); + }); + + it.each([ + ['unrelated import', [namedImport('./database', 'query')], true], + ['non-generic convex/server API', [namedImport('convex/server', 'query')], true], + ['unexported declaration', [namedImport('./_generated/server', 'query')], false], + ] as const)('rejects %s', (_case, imports, isExported) => { + expect( + extract(`export const updateDraft = query({ handler: () => null });`, imports, isExported), + ).toBeUndefined(); + }); + + it.each([ + 'export const updateDraft = sdk.query({ handler: () => null });', + 'export const updateDraft = wrap(query({ handler: () => null }));', + 'export const updateDraft = query(buildConfig());', + ])('rejects unsupported wrapper shape: %s', (source) => { + expect(extract(source, [namedImport('./_generated/server', 'query')])).toBeUndefined(); + }); + + it('does not search into a nested same-name declarator', () => { + expect( + extract( + `export function updateDraft() { + const updateDraft = query({ handler: () => null }); + return updateDraft; + }`, + [namedImport('./_generated/server', 'query')], + true, + 'Function', + 'function_declaration', + ), + ).toBeUndefined(); + }); +}); diff --git a/gitnexus/test/unit/definition-properties.test.ts b/gitnexus/test/unit/definition-properties.test.ts new file mode 100644 index 000000000..bf7446864 --- /dev/null +++ b/gitnexus/test/unit/definition-properties.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + mergeCanonicalDefinitionProperties, + runDefinitionPropertiesExtractor, + type DefinitionPropertiesContext, +} from '../../src/core/ingestion/language-provider.js'; + +const context = { + nodeLabel: 'Const', + nodeName: 'endpoint', + definitionNode: {}, + parsedImports: [], + isExported: true, +} as unknown as DefinitionPropertiesContext; + +describe('definition property provider guardrails', () => { + it('isolates a throwing extractor and permits the next definition to continue', () => { + const failure = new Error('provider failed'); + const onError = vi.fn(); + + expect( + runDefinitionPropertiesExtractor( + () => { + throw failure; + }, + context, + onError, + ), + ).toBeUndefined(); + expect(onError).toHaveBeenCalledOnce(); + expect(onError).toHaveBeenCalledWith(failure); + + expect( + runDefinitionPropertiesExtractor( + () => ({ convexEndpointFactory: 'query' }), + context, + onError, + ), + ).toEqual({ convexEndpointFactory: 'query' }); + expect(onError).toHaveBeenCalledOnce(); + }); + + it('keeps canonical identity and location fields authoritative', () => { + const properties = mergeCanonicalDefinitionProperties( + { + name: 'spoofed', + filePath: 'wrong.ts', + startLine: 999, + isExported: false, + convexEndpointFactory: 'query', + }, + { + name: 'endpoint', + filePath: 'src/endpoints.ts', + startLine: 7, + isExported: true, + }, + ); + + expect(properties).toEqual({ + name: 'endpoint', + filePath: 'src/endpoints.ts', + startLine: 7, + isExported: true, + convexEndpointFactory: 'query', + }); + }); +}); diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index cd353e800..57a76437b 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -230,14 +230,14 @@ describe('PARSE_CACHE_VERSION', () => { // replayed pre-feature captures and the feature was inert. 71 is the next // free value above every claim at this merge — origin/main is 70 and open // PR #3017 already claims 71, so 71 would have collided. - it('pins SCHEMA_BUMP to 72 so concurrent bumps cannot silently collide (#2766)', () => { - expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(72); + it('pins SCHEMA_BUMP to 74 so concurrent bumps cannot silently collide (#2766)', () => { + expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(74); // The PREVIOUS version must fail the reuse gate, not merely differ from the // current one — a hardcoded number outside the conflict hunk rebases cleanly // while being wrong, which is exactly how the 37/38 exact clashes landed. // Every nearby historical or in-flight value is rejected, including 69, // which carried the route-table payload before this merge. - for (const taken of [59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71]) { + for (const taken of [59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73]) { expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(taken); } });