mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-11-11 08:37:35 +00:00
fix(ingestion): retain named class-expression owners (#3725)
* fix(ingestion): retain class-expression owners Keep named JS/TS expressions, members and scope declarations on matching graph identities. Distinguish expressions that reuse a local class name. Avoid root enumeration when no file-scope types are configured. 1088 focused tests pass. Full suite: 28407 passed, one pre-existing SSH-signing-sensitive golden failure, 91 skipped. * fix(ingestion): preserve type-alias owners Keep method-local type-alias member edges on the existing simple TypeAlias IDs when JS/TS class qualification is enabled. Otherwise native COPY drops both method and property relationships. Cover TS, TSX and Vue workers plus direct native-persistence reads. Refs #3725 * fix(bench): align JS/TS capture baselines Record the fingerprints introduced by qualified class and member identities. Same-corpus comparisons attribute every changed byte to the new qualified-name tags; removing those tags restores both previous hashes. Keep capture counts and scaling budgets unchanged. --------- Co-authored-by: Gergő Magyar <gergomagyar@icloud.com> Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
This commit is contained in:
parent
34b873696b
commit
63f2f8e590
19 changed files with 705 additions and 18 deletions
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<ClassExtractionConfig, 'language'> = {
|
||||
// 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<typeof extract>[0], name: string) =>
|
||||
extract(node, { name })?.qualifiedName ?? null,
|
||||
// TypeAlias nodes keep simple IDs, even inside qualified class owners.
|
||||
qualifyScopeName: (node: Parameters<typeof extract>[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 };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ export function constructedTypeOfFieldInitializer(node: SyntaxNode): string | un
|
|||
|
||||
const shared: Omit<FieldExtractionConfig, 'language'> = {
|
||||
typeDeclarationNodes: [
|
||||
'class',
|
||||
'class_declaration',
|
||||
'abstract_class_declaration',
|
||||
'interface_declaration',
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<string, Capture>,
|
||||
nodes: Record<string, SyntaxNode>,
|
||||
): 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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<string> = 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 `<script>` block runs on the same host as any other module.
|
||||
externalReceiverNames: TS_EXTERNAL_RECEIVER_NAMES,
|
||||
|
|
|
|||
|
|
@ -280,6 +280,7 @@ function extractDecoratorName(decorator: SyntaxNode): string | undefined {
|
|||
// nested type discovery. Methods have no such requirement.
|
||||
const shared: Omit<MethodExtractionConfig, 'language'> = {
|
||||
typeDeclarationNodes: [
|
||||
'class',
|
||||
'class_declaration',
|
||||
'abstract_class_declaration',
|
||||
'interface_declaration',
|
||||
|
|
@ -289,8 +290,6 @@ const shared: Omit<MethodExtractionConfig, 'language'> = {
|
|||
// Known gaps:
|
||||
// - call_signature and construct_signature (e.g., interface Fn { (x: string): void; })
|
||||
// are not extracted — they have no name field and are uncommon in practice.
|
||||
// - class_expression (const Foo = class { ... }) — methods inside class expressions
|
||||
// are not discovered because class_expression is not in typeDeclarationNodes.
|
||||
// - declare module / declare global augmentations — methods inside ambient_module_declaration
|
||||
// wrappers are not surfaced because the top-level walker doesn't descend into them.
|
||||
methodNodeTypes: [
|
||||
|
|
|
|||
|
|
@ -23,6 +23,11 @@ ${unboundCallableQuery('definition.function')}
|
|||
(class_declaration
|
||||
name: (type_identifier) @name) @definition.class
|
||||
|
||||
; Named class expressions own members just like declarations. Anonymous
|
||||
; expressions have no name and deliberately do not match this pattern.
|
||||
(class
|
||||
name: (type_identifier) @name) @definition.class
|
||||
|
||||
(abstract_class_declaration
|
||||
name: (type_identifier) @name) @definition.class
|
||||
|
||||
|
|
@ -795,6 +800,9 @@ ${unboundCallableQuery('definition.function')}
|
|||
(class_declaration
|
||||
name: (identifier) @name) @definition.class
|
||||
|
||||
(class
|
||||
name: (identifier) @name) @definition.class
|
||||
|
||||
(function_declaration
|
||||
name: (identifier) @name) @definition.function
|
||||
|
||||
|
|
|
|||
514
gitnexus/test/integration/class-expression-owners.test.ts
Normal file
514
gitnexus/test/integration/class-expression-owners.test.ts
Normal file
|
|
@ -0,0 +1,514 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
closeLbug,
|
||||
executeQuery,
|
||||
initLbug,
|
||||
loadGraphToLbug,
|
||||
} from '../../src/core/lbug/lbug-adapter.js';
|
||||
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
|
||||
import {
|
||||
loadParseCache,
|
||||
loadParseCacheChunk,
|
||||
pruneCache,
|
||||
saveParseCache,
|
||||
} from '../../src/storage/parse-cache.js';
|
||||
import {
|
||||
clearParsedFileStore,
|
||||
durableChunkHasShards,
|
||||
getDurableParsedFileDir,
|
||||
loadDurableParsedFileIndex,
|
||||
loadParsedFilesForPaths,
|
||||
pruneAndSaveDurableParsedFileStore,
|
||||
} from '../../src/storage/parsedfile-store.js';
|
||||
import { runtimeParseIdentity } from '../helpers/runtime-parse-identity.js';
|
||||
import { createTempDir } from '../helpers/test-db.js';
|
||||
import { parseFilesWithWorkers } from '../helpers/worker-parse.js';
|
||||
import { findDanglingEdges } from './resolvers/helpers.js';
|
||||
|
||||
describe('named class-expression owners through the real parse worker', () => {
|
||||
it.each([
|
||||
['ts', '#0'],
|
||||
['tsx', '#0'],
|
||||
['vue', ''],
|
||||
])(
|
||||
'keeps method-local type alias owners aligned with their members in .%s',
|
||||
async (extension, methodSuffix) => {
|
||||
const source = `class Named {
|
||||
m() { type Shape = { p: number; f(): void }; }
|
||||
}
|
||||
const Expr = class Internal {
|
||||
m() { type Local = { value: string; run(): void }; }
|
||||
};`;
|
||||
const file = `alias.${extension}`;
|
||||
const { graph } = await parseFilesWithWorkers([
|
||||
{
|
||||
path: file,
|
||||
content: extension === 'vue' ? `<script setup lang="ts">\n${source}\n</script>` : 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' ? `<script setup lang="ts">\n${source}\n</script>` : 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<string>();
|
||||
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<string>();
|
||||
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);
|
||||
});
|
||||
|
|
@ -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([
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue