diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index 529e905d7..e7f6ebf07 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -234,7 +234,8 @@ }, "typescript": { "_lexical_ownership_3532": "#3532: lexical ownership captures and fixture updates; fixture identities are relative to lang-resolution so serialized scope IDs are independent of checkout location. Full capture content remains hashed; scaling_budget is unchanged.", - "fingerprint": "112cb06df38cb8144aaf4cdb5e1209f5d74ff5fa38005299fb39c853df311a68", + "fingerprint": "c32c400ad55d97605af7d48320194f39387199c34bc78cd4208c69277d479fd6", + "_rebaselined_3725_class_expression_owners": "#3725 adds qualified class/member identities. Comparing base 34b873696 and head 7e0414915 over the identical 174 fixture entries attributes the entire drift to 356 added @declaration.qualified_name tags across 85 entries. Removing only those tags restores 112cb06df38cb8144aaf4cdb5e1209f5d74ff5fa38005299fb39c853df311a68 exactly; no capture groups are added or removed (2939 total). Synthetic counts remain 4503/14403 and the scaling budget is unchanged.", "_rebaselined_3489_inline_handlers": "#3489: Compared main 22a7608 and candidate 7456546 emitters on the same 174-entry TypeScript corpus. Exactly four callback declaration groups were added: two in typescript-hoc-wrapped/src/negative-bare-call.ts, one in typescript-hof-callbacks/src/store.ts, and one in typescript-mcp-tools/src/server.ts. Every added group has @callable.unbound; none was removed or changed. Capture groups 2935 -> 2939. JavaScript is byte-identical. Synthetic counts remain 4503/14403 and scaling budgets are unchanged; CI measured TypeScript 1.048 < 1.5 and JavaScript 0.911 < 1.5.", "_rebaselined_3446_mcp_tools_fixture": "#3446 adds typescript-mcp-tools/src/{handlers,server,tools}.ts: corpus growth only, with fixture_count 171 -> 174 and capture_groups_fp 2753 -> 2894. Excluding only that fixture directory restores the prior fingerprint 77c9b4ea654123a64972db8348190ec250b669472b150db65ea8c7f20b355467 exactly. The scope emitter, synthetic capture counts (4503/14403), scaling budget, and other language baselines are unchanged.", "_rebaselined_3190": "Capture matches now retain explicit ESM export/private evidence, including synthesized default HOCs; CommonJS surfaces remain undecided. Capture group counts unchanged. Scaling budget unchanged.", @@ -266,7 +267,8 @@ }, "javascript": { "_lexical_ownership_3532": "#3532: lexical ownership captures and fixture updates; fixture identities are relative to lang-resolution so serialized scope IDs are independent of checkout location. Full capture content remains hashed; scaling_budget is unchanged.", - "fingerprint": "857b1257a001f8741412ea08a4f43d9253cc4addfa63421f3013b3ff77a390c9", + "fingerprint": "ae7b2a3dc4072c8e4cda166cb5ea11b50b8a4b9173144518129e19007f628013", + "_rebaselined_3725_class_expression_owners": "#3725 adds qualified class/member identities. Comparing base 34b873696 and head 7e0414915 over the identical 43 fixture entries attributes the entire drift to 107 added @declaration.qualified_name tags across 21 entries. Removing only those tags restores 857b1257a001f8741412ea08a4f43d9253cc4addfa63421f3013b3ff77a390c9 exactly; no capture groups are added or removed (856 total). Synthetic counts remain 3253/10403 and the scaling budget is unchanged.", "_rebaselined_3190": "Capture matches now retain explicit ESM export/private evidence, including synthesized default HOCs; CommonJS surfaces remain undecided. Capture group counts unchanged. Scaling budget unchanged.", "scaling_budget": 1.5, "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior b59fe8135b6a31a12bc3f872b224054b16592588153ae3661d03958d787c76f3 -> 479927409bbdd9852a36172c8260aa56df260e99129a7a9c20a0d1903dd5538b; scaling 1.050 < 1.5.", diff --git a/gitnexus/scripts/cross-platform-tests.ts b/gitnexus/scripts/cross-platform-tests.ts index b9e98a8d4..c5c589a5d 100644 --- a/gitnexus/scripts/cross-platform-tests.ts +++ b/gitnexus/scripts/cross-platform-tests.ts @@ -344,6 +344,7 @@ const WORKER_THREADS = [ 'test/integration/java-imported-return-types.test.ts', 'test/unit/community-processor.test.ts', 'test/integration/worker-pool.test.ts', + 'test/integration/class-expression-owners.test.ts', 'test/integration/parse-impl-quarantine-cache-skip.test.ts', // Real parse workers and both on-disk parse stores must retain lexical // local-import ownership on each platform's native grammar/runtime pair. diff --git a/gitnexus/src/core/ingestion/class-extractors/configs/typescript-javascript.ts b/gitnexus/src/core/ingestion/class-extractors/configs/typescript-javascript.ts index b2262432d..02cb33230 100644 --- a/gitnexus/src/core/ingestion/class-extractors/configs/typescript-javascript.ts +++ b/gitnexus/src/core/ingestion/class-extractors/configs/typescript-javascript.ts @@ -2,20 +2,34 @@ import { SupportedLanguages } from 'gitnexus-shared'; import type { ClassExtractionConfig } from '../../class-types.js'; +import type { LanguageProvider } from '../../language-provider.js'; +import { createClassExtractor } from '../generic.js'; +import { positionQualifiedCallableName } from '../../workers/callable-id.js'; const shared: Omit = { + // Nested class expressions and their member-owner edges must use the same + // qualified identity, including when an outer class has the same name. + qualifiedNodeId: true, typeDeclarationNodes: [ + 'class', 'class_declaration', 'abstract_class_declaration', 'interface_declaration', 'enum_declaration', ], ancestorScopeNodeTypes: [ + 'class', 'class_declaration', 'abstract_class_declaration', 'interface_declaration', 'enum_declaration', ], + extractScopeSegments(node) { + const name = node.childForFieldName('name'); + return node.type === 'class' && name !== null + ? [positionQualifiedCallableName(name.text, node.startPosition)] + : undefined; + }, }; export const typescriptClassConfig: ClassExtractionConfig = { @@ -32,3 +46,38 @@ export const vueClassConfig: ClassExtractionConfig = { ...shared, language: SupportedLanguages.Vue, }; + +/** Expressions can reuse a local name in sibling factories or methods. */ +export const createTsJsClassExtractor = (config: ClassExtractionConfig) => { + const extractor = createClassExtractor(config); + const extract: typeof extractor.extract = (node, fallback) => { + const owner = extractor.extract(node, fallback); + return owner !== null && node.type === 'class' + ? { + ...owner, + qualifiedName: positionQualifiedCallableName(owner.qualifiedName, node.startPosition), + } + : owner; + }; + return { + ...extractor, + extract, + extractQualifiedName: (node: Parameters[0], name: string) => + extract(node, { name })?.qualifiedName ?? null, + // TypeAlias nodes keep simple IDs, even inside qualified class owners. + qualifyScopeName: (node: Parameters[0], name: string) => + node.type === 'type_alias_declaration' + ? name + : (extractor.qualifyScopeName?.(node, name) ?? name), + }; +}; + +const ownerExtractor = createTsJsClassExtractor(typescriptClassConfig); + +/** Use the definition's qualified name for both the owner and its member IDs. */ +export const resolveTsJsContainerTypeOwner: NonNullable< + LanguageProvider['resolveContainerTypeOwner'] +> = (node) => { + const owner = ownerExtractor.extract(node); + return owner === null ? null : { name: owner.qualifiedName, label: owner.type }; +}; diff --git a/gitnexus/src/core/ingestion/class-extractors/generic.ts b/gitnexus/src/core/ingestion/class-extractors/generic.ts index e20d84829..57b42c670 100644 --- a/gitnexus/src/core/ingestion/class-extractors/generic.ts +++ b/gitnexus/src/core/ingestion/class-extractors/generic.ts @@ -92,17 +92,18 @@ export function createClassExtractor(config: ClassExtractionConfig): ClassExtrac ]); const buildQualifiedName = (node: SyntaxNode, simpleName: string): string => { - let root = node; - while (root.parent) root = root.parent; - const readScopeSegments = (scopeNode: SyntaxNode): string[] => config.extractScopeSegments?.(scopeNode) ?? extractScopeSegmentsFromNode(scopeNode, scopeNameNodeTypes); const fileScopeSegments: string[] = []; - for (const child of root.namedChildren ?? []) { - if (fileScopeSet.has(child.type)) { - fileScopeSegments.push(...readScopeSegments(child)); + if (fileScopeSet.size > 0) { + let root = node; + while (root.parent) root = root.parent; + for (const child of root.namedChildren ?? []) { + if (fileScopeSet.has(child.type)) { + fileScopeSegments.push(...readScopeSegments(child)); + } } } diff --git a/gitnexus/src/core/ingestion/field-extractors/configs/typescript-javascript.ts b/gitnexus/src/core/ingestion/field-extractors/configs/typescript-javascript.ts index 1ae3e40cb..c12d032d3 100644 --- a/gitnexus/src/core/ingestion/field-extractors/configs/typescript-javascript.ts +++ b/gitnexus/src/core/ingestion/field-extractors/configs/typescript-javascript.ts @@ -34,6 +34,7 @@ export function constructedTypeOfFieldInitializer(node: SyntaxNode): string | un const shared: Omit = { typeDeclarationNodes: [ + 'class', 'class_declaration', 'abstract_class_declaration', 'interface_declaration', diff --git a/gitnexus/src/core/ingestion/field-extractors/typescript.ts b/gitnexus/src/core/ingestion/field-extractors/typescript.ts index 6a3d7c307..31263fc1e 100644 --- a/gitnexus/src/core/ingestion/field-extractors/typescript.ts +++ b/gitnexus/src/core/ingestion/field-extractors/typescript.ts @@ -30,6 +30,7 @@ export class TypeScriptFieldExtractor extends BaseFieldExtractor { * Node types that represent type declarations with fields in TypeScript */ private static readonly TYPE_DECLARATION_NODES = new Set([ + 'class', 'class_declaration', 'interface_declaration', 'abstract_class_declaration', @@ -258,7 +259,11 @@ export class TypeScriptFieldExtractor extends BaseFieldExtractor { const nestedTypes: string[] = []; // Handle different declaration types - if (node.type === 'class_declaration' || node.type === 'abstract_class_declaration') { + if ( + node.type === 'class' || + node.type === 'class_declaration' || + node.type === 'abstract_class_declaration' + ) { // Find the class body const bodyNode = node.childForFieldName('body'); if (bodyNode) { diff --git a/gitnexus/src/core/ingestion/languages/javascript/captures.ts b/gitnexus/src/core/ingestion/languages/javascript/captures.ts index e2890c50b..9b2723dde 100644 --- a/gitnexus/src/core/ingestion/languages/javascript/captures.ts +++ b/gitnexus/src/core/ingestion/languages/javascript/captures.ts @@ -32,6 +32,7 @@ import { recordCommonJsModuleKind } from '../typescript/module-kind.js'; import type { Capture, CaptureMatch } from 'gitnexus-shared'; +import { synthesizeTsJsClassIdentity } from '../typescript/class-identity.js'; import { findNodeAtRange, nodeToCapture, @@ -913,6 +914,7 @@ export function emitJsScopeCaptures( grouped[tag] = nodeToCapture(tag, c.node); groupedNodes[tag] = c.node; } + synthesizeTsJsClassIdentity(grouped, groupedNodes); if (Object.keys(grouped).length === 0) continue; // Decompose ESM import_statement / re-export export_statement. diff --git a/gitnexus/src/core/ingestion/languages/javascript/query.ts b/gitnexus/src/core/ingestion/languages/javascript/query.ts index cdb7ccb54..93d2676e2 100644 --- a/gitnexus/src/core/ingestion/languages/javascript/query.ts +++ b/gitnexus/src/core/ingestion/languages/javascript/query.ts @@ -101,6 +101,9 @@ ${unboundCallableQuery('declaration.function')} (class_declaration name: (identifier) @declaration.name) @declaration.class +(class + name: (identifier) @declaration.name) @declaration.class @class.expression + ;; Declarations — methods (inside class bodies) (method_definition name: (property_identifier) @declaration.name) @declaration.method diff --git a/gitnexus/src/core/ingestion/languages/typescript.ts b/gitnexus/src/core/ingestion/languages/typescript.ts index 115a9b486..83b945b94 100644 --- a/gitnexus/src/core/ingestion/languages/typescript.ts +++ b/gitnexus/src/core/ingestion/languages/typescript.ts @@ -13,10 +13,11 @@ import { unboundCallableName } from './typescript/unbound-callables.js'; import type { CaptureMatch, NodeLabel, ParsedFile } from 'gitnexus-shared'; import { defineLanguage } from '../language-provider.js'; import type { AstFrameworkPatternConfig } from '../language-provider.js'; -import { createClassExtractor } from '../class-extractors/generic.js'; import { typescriptClassConfig, javascriptClassConfig, + resolveTsJsContainerTypeOwner, + createTsJsClassExtractor, } from '../class-extractors/configs/typescript-javascript.js'; import type { SyntaxNode } from '../utils/ast-helpers.js'; import { @@ -524,7 +525,8 @@ export const typescriptProvider = defineLanguage({ }), variableExtractor: createVariableExtractor(typescriptVariableConfig), definitionPropertiesExtractor: extractConvexEndpointProperties, - classExtractor: createClassExtractor(typescriptClassConfig), + classExtractor: createTsJsClassExtractor(typescriptClassConfig), + resolveContainerTypeOwner: resolveTsJsContainerTypeOwner, // ── JSDoc → description (issue #2270). An exported decl is captured as the // inner declaration; its JSDoc precedes the wrapping `export_statement`. ── descriptionExtractor: createLeadingDocDescriptionExtractor({ @@ -627,7 +629,8 @@ export const javascriptProvider = defineLanguage({ }), variableExtractor: createVariableExtractor(javascriptVariableConfig), definitionPropertiesExtractor: extractConvexEndpointProperties, - classExtractor: createClassExtractor(javascriptClassConfig), + classExtractor: createTsJsClassExtractor(javascriptClassConfig), + resolveContainerTypeOwner: resolveTsJsContainerTypeOwner, // ── JSDoc → description (issue #2270). An exported decl is captured as the // inner declaration; its JSDoc precedes the wrapping `export_statement`. ── descriptionExtractor: createLeadingDocDescriptionExtractor({ diff --git a/gitnexus/src/core/ingestion/languages/typescript/captures.ts b/gitnexus/src/core/ingestion/languages/typescript/captures.ts index 3ba0f2a94..f70468675 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/captures.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/captures.ts @@ -28,6 +28,7 @@ import { recordCommonJsModuleKind } from './module-kind.js'; import type { Capture, CaptureMatch } from 'gitnexus-shared'; +import { synthesizeTsJsClassIdentity } from './class-identity.js'; import { findNodeAtRange, nodeToCapture, @@ -420,6 +421,7 @@ export function emitTsScopeCaptures( groupedNodes[tag] = c.node; } if (Object.keys(grouped).length === 0) continue; + synthesizeTsJsClassIdentity(grouped, groupedNodes); // Decompose each `import_statement` / re-export `export_statement` // so `interpretTsImport` sees the kind/source/name/alias markers diff --git a/gitnexus/src/core/ingestion/languages/typescript/class-identity.ts b/gitnexus/src/core/ingestion/languages/typescript/class-identity.ts new file mode 100644 index 000000000..e029017c1 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/typescript/class-identity.ts @@ -0,0 +1,54 @@ +import type { Capture } from 'gitnexus-shared'; +import { resolveTsJsContainerTypeOwner } from '../../class-extractors/configs/typescript-javascript.js'; +import { syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; + +/** Keep named-expression scope declarations on the graph's owner/member IDs. */ +export function synthesizeTsJsClassIdentity( + captures: Record, + nodes: Record, +): void { + const type = + nodes['@declaration.class'] ?? nodes['@declaration.interface'] ?? nodes['@declaration.enum']; + if (type !== undefined) { + const owner = resolveTsJsContainerTypeOwner(type, ''); + if (owner !== null) { + captures['@declaration.qualified_name'] = syntheticCapture( + '@declaration.qualified_name', + type, + owner.name, + ); + } + return; + } + const member = + nodes['@declaration.method'] ?? + nodes['@declaration.constructor'] ?? + nodes['@declaration.property']; + const name = nodes['@declaration.name']; + if (member === undefined || name === undefined) return; + for (let parent = member.parent; parent !== null; parent = parent.parent) { + // Object members and executable-body locals belong to their own scope. + if ( + parent.type === 'object' || + parent.type === 'object_type' || + parent.type === 'statement_block' + ) + return; + if ( + parent.type === 'class' || + parent.type === 'class_declaration' || + parent.type === 'abstract_class_declaration' || + parent.type === 'interface_declaration' + ) { + const owner = resolveTsJsContainerTypeOwner(parent, ''); + if (owner !== null) { + captures['@declaration.qualified_name'] = syntheticCapture( + '@declaration.qualified_name', + member, + `${owner.name}.${name.text}`, + ); + } + return; + } + } +} diff --git a/gitnexus/src/core/ingestion/languages/typescript/query.ts b/gitnexus/src/core/ingestion/languages/typescript/query.ts index 597aeaefe..6c71985ff 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/query.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/query.ts @@ -161,6 +161,10 @@ ${unboundCallableQuery('declaration.function')} name: (type_identifier) @declaration.name type_parameters: (type_parameters)? @declaration.type-parameters) @declaration.class +(class + name: (type_identifier) @declaration.name + type_parameters: (type_parameters)? @declaration.type-parameters) @declaration.class @class.expression + (abstract_class_declaration name: (type_identifier) @declaration.name type_parameters: (type_parameters)? @declaration.type-parameters) @declaration.class diff --git a/gitnexus/src/core/ingestion/languages/typescript/simple-hooks.ts b/gitnexus/src/core/ingestion/languages/typescript/simple-hooks.ts index 453a32533..bfbfba7e0 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/simple-hooks.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/simple-hooks.ts @@ -44,7 +44,8 @@ export function tsBindingScopeFor( ): ScopeId | null { // A named function expression binds only inside itself. Anonymous callables // explicitly suppress their binding through @declaration.binding-name. - if (decl['@callable.unbound'] !== undefined) return innermost.id; + if (decl['@callable.unbound'] !== undefined || decl['@class.expression'] !== undefined) + return innermost.id; // Method return type: hoist to Module (mirrors csharpBindingScopeFor). if (decl['@type-binding.return'] !== undefined) { return walkToScope(innermost, tree, 'Module'); diff --git a/gitnexus/src/core/ingestion/languages/vue.ts b/gitnexus/src/core/ingestion/languages/vue.ts index 0c421724d..ac602ef90 100644 --- a/gitnexus/src/core/ingestion/languages/vue.ts +++ b/gitnexus/src/core/ingestion/languages/vue.ts @@ -13,8 +13,11 @@ import { SupportedLanguages } from 'gitnexus-shared'; import { isBuiltin } from 'node:module'; -import { createClassExtractor } from '../class-extractors/generic.js'; -import { vueClassConfig } from '../class-extractors/configs/typescript-javascript.js'; +import { + vueClassConfig, + resolveTsJsContainerTypeOwner, + createTsJsClassExtractor, +} from '../class-extractors/configs/typescript-javascript.js'; import { defineLanguage } from '../language-provider.js'; import { typeConfig as typescriptConfig } from '../type-extractors/typescript.js'; import { tsExportChecker } from '../export-detection.js'; @@ -77,7 +80,7 @@ const VUE_SPECIFIC_BUILT_INS = [ const VUE_BUILT_INS: ReadonlySet = new Set([...TS_BUILT_INS, ...VUE_SPECIFIC_BUILT_INS]); -const vueClassExtractor = createClassExtractor(vueClassConfig); +const vueClassExtractor = createTsJsClassExtractor(vueClassConfig); export const vueProvider = defineLanguage({ id: SupportedLanguages.Vue, @@ -92,6 +95,7 @@ export const vueProvider = defineLanguage({ fieldExtractor: typescriptFieldExtractor, variableExtractor: createVariableExtractor(typescriptVariableConfig), classExtractor: vueClassExtractor, + resolveContainerTypeOwner: resolveTsJsContainerTypeOwner, builtInNames: VUE_BUILT_INS, // A `` : source, + }, + ]); + for (const [name, property, method] of [ + ['Shape', 'p', 'f'], + ['Local', 'value', 'run'], + ]) { + const owner = `TypeAlias:${file}:${name}`; + expect(graph.getNode(owner)).toBeDefined(); + const members = graph.relationships.filter( + (edge) => + edge.sourceId === owner && (edge.type === 'HAS_PROPERTY' || edge.type === 'HAS_METHOD'), + ); + expect(members.map((edge) => edge.targetId).sort()).toEqual([ + `Method:${file}:${name}.${method}${methodSuffix}`, + `Property:${file}:${name}.${property}`, + ]); + for (const edge of members) expect(graph.getNode(edge.targetId)).toBeDefined(); + } + expect( + graph.relationships.filter( + (edge) => + (edge.type === 'HAS_METHOD' || edge.type === 'HAS_PROPERTY') && + (!graph.getNode(edge.sourceId) || !graph.getNode(edge.targetId)), + ), + ).toEqual([]); + }, + ); + + it('persists method-local type alias member relationships through native COPY', async () => { + const temp = await createTempDir(); + try { + await fs.writeFile( + path.join(temp.dbPath, 'alias.ts'), + 'class Named { m() { type Shape = { p: number; f(): void }; } }', + ); + const result = await runPipelineFromRepo(temp.dbPath, () => {}, { workerPoolSize: 1 }); + expect(result.usedWorkerPool).toBe(true); + await initLbug(path.join(temp.dbPath, 'lbug'), { skipFts: true }); + await loadGraphToLbug(result.graph, temp.dbPath, path.join(temp.dbPath, 'storage')); + const aliases = await executeQuery('MATCH (a:TypeAlias) RETURN a.id AS id'); + expect(aliases.map((row) => row.id)).toEqual(['TypeAlias:alias.ts:Shape']); + const members = await executeQuery( + "MATCH (a:TypeAlias)-[r:CodeRelation]->(m) WHERE r.type IN ['HAS_METHOD', 'HAS_PROPERTY'] RETURN a.id AS owner, m.id AS member, r.type AS type ORDER BY type", + ); + expect(members).toEqual([ + { + owner: 'TypeAlias:alias.ts:Shape', + member: 'Method:alias.ts:Shape.f#0', + type: 'HAS_METHOD', + }, + { + owner: 'TypeAlias:alias.ts:Shape', + member: 'Property:alias.ts:Shape.p', + type: 'HAS_PROPERTY', + }, + ]); + } finally { + await closeLbug(); + await temp.cleanup(); + } + }); + + it.each(['ts', 'tsx', 'js', 'mjs'])( + 'resolves a declaration shadowing an outer class inside an expression in .%s', + async (extension) => { + const temp = await createTempDir(); + try { + await fs.writeFile( + path.join(temp.dbPath, `shadow.${extension}`), + 'class Local {} const A = class Outer { m() { class Local {} return new Local(); } };', + ); + const result = await runPipelineFromRepo(temp.dbPath, () => {}, { workerPoolSize: 1 }); + const locals = result.graph.nodes.filter( + (node) => node.label === 'Class' && node.properties.name === 'Local', + ); + expect(locals).toHaveLength(2); + const inner = locals.find((node) => node.id.includes('.Local'))!; + expect(inner).toBeDefined(); + const method = result.graph.nodes.find( + (node) => node.label === 'Method' && node.properties.name === 'm', + )!; + const calls = result.graph.relationships.filter( + (edge) => edge.type === 'CALLS' && edge.sourceId === method.id, + ); + expect(calls.map((edge) => edge.targetId)).toEqual([inner.id]); + } finally { + await temp.cleanup(); + } + }, + ); + it.each(['jsx', 'vue'])('preserves owners for the shared .%s provider', async (extension) => { + const source = 'const X = class Named { status = 500; run() { return this.status; } };'; + const { graph } = await parseFilesWithWorkers([ + { + path: `shared.${extension}`, + content: extension === 'vue' ? `` : source, + }, + ]); + const owner = graph.nodes.find( + (node) => node.label === 'Class' && node.properties.name === 'Named', + ); + expect(owner).toBeDefined(); + const edges = graph.relationships.filter( + (edge) => + edge.sourceId === owner!.id && (edge.type === 'HAS_METHOD' || edge.type === 'HAS_PROPERTY'), + ); + expect(edges.map((edge) => graph.getNode(edge.targetId)?.properties.name).sort()).toEqual([ + 'run', + 'status', + ]); + }); + + it.each(['ts', 'js'])( + 'keeps declaration and anonymous-expression controls in .%s', + async (extension) => { + const { graph } = await parseFilesWithWorkers([ + { + path: `control.${extension}`, + content: + 'class Control { status = 1; run() {} } const Anonymous = class { value = 2; method() {} };', + }, + ]); + expect( + graph.nodes.filter((node) => node.label === 'Class').map((node) => node.properties.name), + ).toEqual(['Control']); + const owner = graph.getNode(`Class:control.${extension}:Control`); + expect(owner).toBeDefined(); + const edges = graph.relationships.filter((edge) => edge.sourceId === owner!.id); + expect(edges.map((edge) => graph.getNode(edge.targetId)?.properties.name).sort()).toEqual([ + 'run', + 'status', + ]); + }, + ); + it.each(['ts', 'tsx', 'js', 'mjs'])( + 'retains the class and its members in .%s', + async (extension) => { + const { graph } = await parseFilesWithWorkers([ + { + path: `mock.${extension}`, + content: `vi.mock('api', () => ({ + ApiError: class ApiError extends Error { + status = 500; + messageForUser() { return 'Failed'; } + }, +}));`, + }, + ]); + const owner = graph.nodes.find( + (node) => node.label === 'Class' && node.properties.name === 'ApiError', + ); + expect(owner).toBeDefined(); + expect(owner!.properties).toMatchObject({ + filePath: `mock.${extension}`, + startLine: 1, + endLine: 4, + }); + const edges = graph.relationships.filter( + (edge) => edge.type === 'HAS_METHOD' || edge.type === 'HAS_PROPERTY', + ); + expect(edges).toHaveLength(2); + expect(edges.map((edge) => graph.getNode(edge.targetId)?.properties.name).sort()).toEqual([ + 'messageForUser', + 'status', + ]); + expect( + graph.nodes.find((node) => node.properties.name === 'messageForUser')!.properties + .parameterCount, + ).toBe(0); + expect( + graph.nodes.find((node) => node.properties.name === 'status')!.properties.isStatic, + ).toBe(false); + for (const edge of edges) { + expect(edge.sourceId).toBe(owner!.id); + expect(graph.getNode(edge.sourceId)).toBeDefined(); + expect(graph.getNode(edge.targetId)).toBeDefined(); + } + }, + ); + + it('retains a TypeScript constructor parameter property on its expression owner', async () => { + const { graph } = await parseFilesWithWorkers([ + { + path: 'parameter.ts', + content: `vi.mock('api', () => ({ + ApiError: class ApiError extends Error { + constructor(public readonly status: number) { super('HTTP ' + status); } + }, +}));`, + }, + ]); + const owner = graph.nodes.find( + (node) => node.label === 'Class' && node.properties.name === 'ApiError', + ); + expect(owner).toBeDefined(); + const property = graph.nodes.find( + (node) => node.label === 'Property' && node.properties.name === 'status', + ); + expect(property).toBeDefined(); + expect(property!.id).toBe('Property:parameter.ts:ApiError@1:12.status'); + expect(graph.relationships).toContainEqual( + expect.objectContaining({ + type: 'HAS_PROPERTY', + sourceId: owner!.id, + targetId: property!.id, + }), + ); + for (const edge of graph.relationships.filter( + (edge) => edge.type === 'HAS_METHOD' || edge.type === 'HAS_PROPERTY', + )) { + expect(graph.getNode(edge.sourceId)).toBeDefined(); + expect(graph.getNode(edge.targetId)).toBeDefined(); + } + }); + + it.each(['ts', 'tsx', 'js', 'mjs'])( + 'keeps nested expression members distinct from declarations in .%s', + async (extension) => { + const { graph } = await parseFilesWithWorkers([ + { + path: `nested.${extension}`, + content: `class ApiError { status = 200; messageForUser() { return 'outer'; } } +class Holder { + nested = class ApiError { status = 500; messageForUser() { return 'inner'; } }; +}`, + }, + ]); + const owners = graph.nodes.filter( + (node) => node.label === 'Class' && node.properties.name === 'ApiError', + ); + expect(owners).toHaveLength(2); + expect(new Set(owners.map((node) => node.id)).size).toBe(2); + const memberIds = new Set(); + for (const owner of owners) { + const edges = graph.relationships.filter( + (edge) => + edge.sourceId === owner.id && + (edge.type === 'HAS_METHOD' || edge.type === 'HAS_PROPERTY'), + ); + expect(edges.map((edge) => graph.getNode(edge.targetId)?.properties.name).sort()).toEqual([ + 'messageForUser', + 'status', + ]); + edges.forEach((edge) => memberIds.add(edge.targetId)); + } + expect(memberIds.size).toBe(4); + for (const edge of graph.relationships.filter( + (edge) => edge.type === 'HAS_METHOD' || edge.type === 'HAS_PROPERTY', + )) { + expect(graph.getNode(edge.sourceId)).toBeDefined(); + expect(graph.getNode(edge.targetId)).toBeDefined(); + } + }, + ); + + it.each(['ts', 'tsx', 'js', 'mjs'])( + 'distinguishes same-named expressions in sibling methods in .%s', + async (extension) => { + const { graph } = await parseFilesWithWorkers([ + { + path: `siblings.${extension}`, + content: `class Holder { + first() { return class Local { status = 1; run() {} }; } + second() { return class Local { status = 2; run() {} }; } +}`, + }, + ]); + const owners = graph.nodes.filter( + (node) => node.label === 'Class' && node.properties.name === 'Local', + ); + expect(owners).toHaveLength(2); + const targets = new Set(); + for (const owner of owners) { + const edges = graph.relationships.filter( + (edge) => + edge.sourceId === owner.id && + (edge.type === 'HAS_METHOD' || edge.type === 'HAS_PROPERTY'), + ); + expect(edges.map((edge) => graph.getNode(edge.targetId)?.properties.name).sort()).toEqual([ + 'run', + 'status', + ]); + for (const edge of edges) { + targets.add(edge.targetId); + const member = graph.getNode(edge.targetId)!; + expect(member.properties.startLine).toBe(owner.properties.startLine); + } + } + expect(targets.size).toBe(4); + }, + ); + + it.each(['ts', 'tsx', 'js', 'mjs'])( + 'resolves expression members and keeps self names internal in .%s', + async (extension) => { + const temp = await createTempDir(); + try { + await fs.writeFile( + path.join(temp.dbPath, `self.${extension}`), + `class Holder { child = class Child { p=1; m(){return this.p;} }; } +const X = class Named { static s() {} m() { Named.s(); } }; +Named.s();`, + ); + const result = await runPipelineFromRepo(temp.dbPath, () => {}, { workerPoolSize: 1 }); + const member = (ownerName: string, memberName: string) => { + const owner = result.graph.nodes.find( + (node) => node.label === 'Class' && node.properties.name === ownerName, + )!; + expect(owner).toBeDefined(); + const edge = result.graph.relationships.find( + (edge) => + edge.sourceId === owner.id && + (edge.type === 'HAS_METHOD' || edge.type === 'HAS_PROPERTY') && + result.graph.getNode(edge.targetId)?.properties.name === memberName, + ); + expect(edge).toBeDefined(); + return edge!.targetId; + }; + expect(result.graph.relationships).toContainEqual( + expect.objectContaining({ + type: 'ACCESSES', + sourceId: member('Child', 'm'), + targetId: member('Child', 'p'), + }), + ); + const staticId = member('Named', 's'); + const calls = result.graph.relationships.filter( + (edge) => edge.type === 'CALLS' && edge.targetId === staticId, + ); + expect(calls).toHaveLength(1); + expect(calls[0]!.sourceId).toBe(member('Named', 'm')); + expect( + findDanglingEdges(result, ['HAS_METHOD', 'HAS_PROPERTY', 'CALLS', 'ACCESSES']), + ).toEqual([]); + } finally { + await temp.cleanup(); + } + }, + ); + + it('preserves owners through the full pipeline, both durable caches, and native COPY', async () => { + const temp = await createTempDir(); + const repo = path.join(temp.dbPath, 'repo'); + const storage = path.join(temp.dbPath, 'storage'); + const identity = await runtimeParseIdentity(); + const extensions = ['ts', 'tsx', 'js', 'mjs']; + const source = `class ApiError { status = 200; messageForUser() { return 'outer'; } } +vi.mock('api', () => ({ + ApiError: class MockError extends Error { status = 500; messageForUser() { return 'mock'; } }, +})); +class Holder { + nested = class ApiError { status = 501; messageForUser() { return 'inner'; } }; +}`; + try { + await fs.mkdir(repo); + for (const extension of extensions) + await fs.writeFile(path.join(repo, `mock.${extension}`), source); + const coldCache = await loadParseCache(storage, identity, storage); + const cold = await runPipelineFromRepo(repo, () => {}, { + workerPoolSize: 1, + parseCache: coldCache, + }); + expect(cold.usedWorkerPool).toBe(true); + expect(cold.reparsedFileCount).toBe(4); + expect(cold.parseCacheHitFileCount).toBe(0); + expect(cold.scopeExtractionFailures).toEqual([]); + + const assertOwners = (result: typeof cold) => { + expect(findDanglingEdges(result, ['HAS_METHOD', 'HAS_PROPERTY'])).toEqual([]); + for (const extension of extensions) { + for (const name of ['ApiError', 'MockError@2:12', 'Holder.ApiError@5:11']) { + const ownerId = `Class:mock.${extension}:${name}`; + expect(result.graph.getNode(ownerId)).toBeDefined(); + const edges = result.graph.relationships.filter( + (edge) => + edge.sourceId === ownerId && + (edge.type === 'HAS_METHOD' || edge.type === 'HAS_PROPERTY'), + ); + expect( + edges.map((edge) => result.graph.getNode(edge.targetId)?.properties.name).sort(), + ).toEqual(['messageForUser', 'status']); + expect(edges.map((edge) => edge.targetId).sort()).toEqual([ + `Method:mock.${extension}:${name}.messageForUser#0`, + `Property:mock.${extension}:${name}.status`, + ]); + } + } + }; + assertOwners(cold); + pruneCache(coldCache, coldCache.usedKeys); + const keys = await saveParseCache(storage, coldCache); + await pruneAndSaveDurableParsedFileStore( + getDurableParsedFileDir(storage), + identity.base, + new Set(keys), + ); + await clearParsedFileStore(storage); + const durable = await loadDurableParsedFileIndex( + getDurableParsedFileDir(storage), + identity.base, + ); + for (const [key, wanted] of durable) + expect(await durableChunkHasShards(storage, key, wanted)).toBe(true); + const paths = new Set([...durable.values()].flatMap((paths) => [...paths])); + expect([...paths].sort()).toEqual(extensions.map((extension) => `mock.${extension}`).sort()); + const parsed = await loadParsedFilesForPaths(storage, paths); + expect(parsed.size).toBe(4); + for (const file of parsed.values()) { + expect(file.scopes.filter((scope) => scope.kind === 'Class')).toHaveLength(4); + expect( + file.localDefs + .filter((definition) => definition.type === 'Property') + .map((definition) => definition.qualifiedName) + .sort(), + ).toEqual([ + 'ApiError.status', + 'Holder.ApiError@5:11.status', + 'Holder.nested', + 'MockError@2:12.status', + ]); + } + + const warmCache = await loadParseCache(storage, identity, storage); + const chunks = await Promise.all( + [...warmCache.onDiskKeys!].map((key) => loadParseCacheChunk(warmCache, key)), + ); + expect(chunks.length).toBeGreaterThan(0); + // ParsedFiles must come from their durable shards, not worker-result chunks. + expect( + chunks.flatMap((chunk) => chunk ?? []).flatMap((part) => part.parsedFiles ?? []), + ).toEqual([]); + const warm = await runPipelineFromRepo(repo, () => {}, { + workerPoolSize: 1, + parseCache: warmCache, + }); + expect(warm.usedWorkerPool).toBe(false); + expect(warm.reparsedFileCount).toBe(0); + expect(warm.parseCacheHitFileCount).toBe(4); + expect(warm.scopeExtractionFailures).toEqual([]); + assertOwners(warm); + const members = (result: typeof cold) => + result.graph.relationships + .filter((edge) => edge.type === 'HAS_METHOD' || edge.type === 'HAS_PROPERTY') + .map((edge) => `${edge.type}:${edge.sourceId}->${edge.targetId}`) + .sort(); + expect(members(warm)).toEqual(members(cold)); + + await initLbug(path.join(temp.dbPath, 'lbug'), { skipFts: true }); + const loaded = await loadGraphToLbug(warm.graph, repo, storage); + expect(loaded.warnings).toEqual([]); + expect(loaded.skippedRels).toBe(0); + const rows = await executeQuery( + "MATCH (c:Class)-[r:CodeRelation]->(m) WHERE r.type IN ['HAS_METHOD', 'HAS_PROPERTY'] RETURN c.id AS owner, m.id AS member, r.type AS type", + ); + expect(rows.map((row) => `${row.type}:${row.owner}->${row.member}`).sort()).toEqual( + members(warm), + ); + const classes = await executeQuery('MATCH (c:Class) RETURN c.id AS id'); + expect(classes).toHaveLength(16); + } finally { + await closeLbug(); + await clearParsedFileStore(storage); + await temp.cleanup(); + } + }, 120000); +}); diff --git a/gitnexus/test/integration/qualified-class-lookups.test.ts b/gitnexus/test/integration/qualified-class-lookups.test.ts index 53563fefb..7dbfecd9b 100644 --- a/gitnexus/test/integration/qualified-class-lookups.test.ts +++ b/gitnexus/test/integration/qualified-class-lookups.test.ts @@ -2,6 +2,38 @@ import { describe, expect, it } from 'vitest'; import { parseFilesWithWorkers } from '../helpers/worker-parse.js'; describe('qualified class lookups', () => { + it('preserves nested names with and without file-level package scopes', async () => { + const { graph, model } = await parseFilesWithWorkers([ + { + path: 'nested.ts', + content: + 'class Outer { make() { class Inner { value = 1; run() { return this.value; } } return Inner; } }', + }, + { + path: 'Nested.java', + content: 'package example.models; class Outer { class Inner {} }', + }, + { + path: 'nested.rb', + content: 'module Root\n class Outer\n class Inner\n end\n end\nend\n', + }, + ]); + for (const name of ['Outer.Inner', 'example.models.Outer.Inner', 'Root.Outer.Inner']) { + expect(model.types.lookupClassByQualifiedName(name)).toHaveLength(1); + } + const ownerId = 'Class:nested.ts:Outer.Inner'; + expect(graph.getNode(ownerId)).toBeDefined(); + const members = graph.relationships.filter( + (edge) => + edge.sourceId === ownerId && (edge.type === 'HAS_METHOD' || edge.type === 'HAS_PROPERTY'), + ); + expect(members.map((edge) => graph.getNode(edge.targetId)?.properties.name).sort()).toEqual([ + 'run', + 'value', + ]); + for (const edge of members) expect(graph.getNode(edge.targetId)).toBeDefined(); + }); + it('derives canonical dot-separated names from namespaces, packages, and modules', async () => { const { model } = await parseFilesWithWorkers([ { diff --git a/gitnexus/vitest.config.ts b/gitnexus/vitest.config.ts index 1f8179356..a9c3fa2ed 100644 --- a/gitnexus/vitest.config.ts +++ b/gitnexus/vitest.config.ts @@ -74,6 +74,7 @@ export default defineConfig({ test: { name: 'lbug-db', include: [ + 'test/integration/class-expression-owners.test.ts', 'test/integration/rename-provider-*.test.ts', 'test/integration/pdg-explicit-owner.test.ts', 'test/integration/skip-fts.test.ts', @@ -182,6 +183,7 @@ export default defineConfig({ sequence: { groupOrder: 3 }, include: ['test/**/*.test.ts'], exclude: [ + 'test/integration/class-expression-owners.test.ts', 'test/integration/rename-provider-*.test.ts', 'test/integration/pdg-explicit-owner.test.ts', 'test/integration/skip-fts.test.ts',