diff --git a/gitnexus/src/core/ingestion/field-extractors/configs/helpers.ts b/gitnexus/src/core/ingestion/field-extractors/configs/helpers.ts index 893731eb8f..39d7996f09 100644 --- a/gitnexus/src/core/ingestion/field-extractors/configs/helpers.ts +++ b/gitnexus/src/core/ingestion/field-extractors/configs/helpers.ts @@ -15,12 +15,17 @@ import type { FieldVisibility } from '../../field-types.js'; /** * Check whether any child of `node` (named or unnamed) has .text matching - * one of the given `keywords`. + * the given `keyword`. + * + * Skips the `name` field child to avoid false positives when a method is + * named after a contextual keyword (e.g. `abstract()` in TypeScript). */ export function hasKeyword(node: SyntaxNode, keyword: string): boolean { + const nameNode = node.childForFieldName('name'); for (let i = 0; i < node.childCount; i++) { const child = node.child(i); - if (child && child.text.trim() === keyword) return true; + if (!child || child === nameNode) continue; + if (child.text.trim() === keyword) return true; } return false; } @@ -46,6 +51,7 @@ export function hasModifier(node: SyntaxNode, modifierType: string, keyword: str /** * Return the first matching visibility keyword found either as a direct keyword * child or inside a modifier wrapper node. + * Skips the `name` field child (same rationale as hasKeyword). */ export function findVisibility( node: SyntaxNode, @@ -53,10 +59,12 @@ export function findVisibility( defaultVis: FieldVisibility, modifierNodeType?: string, ): FieldVisibility { + const nameNode = node.childForFieldName('name'); // Direct keyword children for (let i = 0; i < node.childCount; i++) { const child = node.child(i); - const text = child?.text.trim() as FieldVisibility | undefined; + if (!child || child === nameNode) continue; + const text = child.text.trim() as FieldVisibility | undefined; if (text && (keywords as ReadonlySet).has(text)) return text; } // Modifier wrapper diff --git a/gitnexus/src/core/ingestion/languages/typescript.ts b/gitnexus/src/core/ingestion/languages/typescript.ts index 838804bd02..5def76f1e1 100644 --- a/gitnexus/src/core/ingestion/languages/typescript.ts +++ b/gitnexus/src/core/ingestion/languages/typescript.ts @@ -17,6 +17,11 @@ import { TYPESCRIPT_QUERIES, JAVASCRIPT_QUERIES } from '../tree-sitter-queries.j import { typescriptFieldExtractor } from '../field-extractors/typescript.js'; import { createFieldExtractor } from '../field-extractors/generic.js'; import { javascriptConfig } from '../field-extractors/configs/typescript-javascript.js'; +import { createMethodExtractor } from '../method-extractors/generic.js'; +import { + typescriptMethodConfig, + javascriptMethodConfig, +} from '../method-extractors/configs/typescript-javascript.js'; const BUILT_INS: ReadonlySet = new Set([ 'console', @@ -124,6 +129,7 @@ export const typescriptProvider = defineLanguage({ importResolver: resolveTypescriptImport, namedBindingExtractor: extractTsNamedBindings, fieldExtractor: typescriptFieldExtractor, + methodExtractor: createMethodExtractor(typescriptMethodConfig), builtInNames: BUILT_INS, }); @@ -136,5 +142,6 @@ export const javascriptProvider = defineLanguage({ importResolver: resolveJavascriptImport, namedBindingExtractor: extractTsNamedBindings, fieldExtractor: createFieldExtractor(javascriptConfig), + methodExtractor: createMethodExtractor(javascriptMethodConfig), builtInNames: BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/method-extractors/configs/typescript-javascript.ts b/gitnexus/src/core/ingestion/method-extractors/configs/typescript-javascript.ts new file mode 100644 index 0000000000..a4a200a835 --- /dev/null +++ b/gitnexus/src/core/ingestion/method-extractors/configs/typescript-javascript.ts @@ -0,0 +1,278 @@ +// gitnexus/src/core/ingestion/method-extractors/configs/typescript-javascript.ts +// Verified against tree-sitter-typescript ^0.23.2, tree-sitter-javascript ^0.23.0 + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { + MethodExtractionConfig, + ParameterInfo, + MethodVisibility, +} from '../../method-types.js'; +import { hasKeyword } from '../../field-extractors/configs/helpers.js'; +import { extractSimpleTypeName } from '../../type-extractors/shared.js'; +import type { SyntaxNode } from '../../utils/ast-helpers.js'; + +// --------------------------------------------------------------------------- +// TS/JS helpers +// --------------------------------------------------------------------------- + +const VISIBILITY_KEYWORDS = new Set(['public', 'private', 'protected']); + +/** + * Extract parameters from formal_parameters. + * + * Handles both TS node types (required_parameter, optional_parameter, rest_parameter) + * and JS node types (identifier, assignment_pattern, rest_pattern), plus destructured + * parameters (object_pattern, array_pattern) in both grammars. + */ +function extractTsJsParameters(node: SyntaxNode): ParameterInfo[] { + const paramList = node.childForFieldName('parameters'); + if (!paramList) return []; + const params: ParameterInfo[] = []; + + for (let i = 0; i < paramList.namedChildCount; i++) { + const param = paramList.namedChild(i); + if (!param) continue; + + switch (param.type) { + case 'required_parameter': { + const patternNode = param.childForFieldName('pattern'); + if (!patternNode) break; + + // Skip TS `this` parameter — it's a compile-time type constraint, not a real param + if (patternNode.type === 'this') break; + + // Rest parameter: pattern is a rest_pattern (...args) — extract inner identifier + const isRest = patternNode.type === 'rest_pattern'; + const nameNode = isRest ? patternNode.firstNamedChild : patternNode; + if (!nameNode) break; + + // type field is a type_annotation — unwrap to get the inner type node + const typeAnnotation = param.childForFieldName('type'); + const typeNode = typeAnnotation?.firstNamedChild; + + // Default value: presence of a 'value' field means isOptional + const hasDefault = !!param.childForFieldName('value'); + + params.push({ + name: nameNode.text, + type: typeNode + ? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim() ?? null) + : null, + isOptional: hasDefault, + isVariadic: isRest, + }); + break; + } + case 'optional_parameter': { + const nameNode = param.childForFieldName('pattern'); + if (!nameNode) break; + const typeAnnotation = param.childForFieldName('type'); + const typeNode = typeAnnotation?.firstNamedChild; + params.push({ + name: nameNode.text, + type: typeNode + ? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim() ?? null) + : null, + isOptional: true, + isVariadic: false, + }); + break; + } + case 'rest_parameter': { + const nameNode = param.childForFieldName('pattern'); + if (!nameNode) break; + const typeAnnotation = param.childForFieldName('type'); + const typeNode = typeAnnotation?.firstNamedChild; + params.push({ + name: nameNode.text, + type: typeNode + ? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim() ?? null) + : null, + isOptional: false, + isVariadic: true, + }); + break; + } + case 'identifier': { + // JS: bare parameter name, no type info + params.push({ name: param.text, type: null, isOptional: false, isVariadic: false }); + break; + } + case 'assignment_pattern': { + // JS: param = defaultValue — the left side is the name, isOptional = true + const left = param.childForFieldName('left'); + if (left) { + params.push({ name: left.text, type: null, isOptional: true, isVariadic: false }); + } + break; + } + case 'rest_pattern': { + // JS: ...args + const inner = param.firstNamedChild; + if (inner) { + params.push({ name: inner.text, type: null, isOptional: false, isVariadic: true }); + } + break; + } + case 'object_pattern': + case 'array_pattern': { + // Destructured parameter — use full text as name + params.push({ name: param.text, type: null, isOptional: false, isVariadic: false }); + break; + } + } + } + return params; +} + +/** + * Extract return type from return_type field, unwrapping type_annotation. + * + * tree-sitter-typescript uses `return_type` as the field name (not `type` like JVM). + * The return_type field points to a type_annotation node that must be unwrapped. + */ +function extractTsJsReturnType(node: SyntaxNode): string | undefined { + const returnType = node.childForFieldName('return_type'); + if (returnType) { + if (returnType.type === 'type_annotation') { + const inner = returnType.firstNamedChild; + if (inner) return extractSimpleTypeName(inner) ?? inner.text?.trim(); + } + return extractSimpleTypeName(returnType) ?? returnType.text?.trim(); + } + return undefined; +} + +/** + * Extract visibility from accessibility_modifier or #private name. + * + * tree-sitter-typescript emits accessibility_modifier as a named child of method nodes + * (not as a modifiers wrapper like JVM). Pass 1 scans for that child; pass 2 checks for + * ES2022 private_property_identifier (#name). Default: public. + */ +function extractTsJsVisibility(node: SyntaxNode): MethodVisibility { + // Pass 1: check for accessibility_modifier named child (TS-specific) + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child && child.type === 'accessibility_modifier') { + const t = child.text.trim(); + if (VISIBILITY_KEYWORDS.has(t as MethodVisibility)) return t as MethodVisibility; + } + } + // Pass 2: ES2022 private methods (#name) are inherently private + const nameNode = node.childForFieldName('name'); + if (nameNode && nameNode.type === 'private_property_identifier') return 'private'; + // No accessibility_modifier found — default to public. + // Note: tree-sitter-typescript does not wrap modifiers in a 'modifiers' node + // (unlike JVM), so there is no wrapper to scan. + return 'public'; +} + +/** + * Extract decorator names, prefixed with '@'. + * + * In tree-sitter-typescript, decorators are **siblings** of the method_definition in the + * class_body — they are NOT children of the method node. We find them by walking backwards + * from the method node through its preceding siblings in the parent body. + */ +function extractTsJsDecorators(node: SyntaxNode): string[] { + const decorators: string[] = []; + // Walk backwards via previousNamedSibling to collect consecutive decorator siblings. + // This avoids the O(N) index-finding scan through the parent's children. + let sibling = node.previousNamedSibling; + while (sibling && sibling.type === 'decorator') { + const name = extractDecoratorName(sibling); + if (name) decorators.unshift(name); + sibling = sibling.previousNamedSibling; + } + return decorators; +} + +function extractDecoratorName(decorator: SyntaxNode): string | undefined { + const expr = decorator.firstNamedChild; + if (!expr) return undefined; + if (expr.type === 'call_expression') { + const fn = expr.childForFieldName('function'); + return fn ? '@' + fn.text : undefined; + } + if (expr.type === 'identifier') return '@' + expr.text; + if (expr.type === 'member_expression') return '@' + expr.text; + return undefined; +} + +// --------------------------------------------------------------------------- +// Config +// --------------------------------------------------------------------------- + +// TS and JS share the same config base. TS-only node types (abstract_class_declaration, +// interface_declaration, abstract_method_signature, method_signature, interface_body) are +// included because the JS grammar never produces these nodes — they are harmless no-ops. +// This mirrors the field extractor's typescript-javascript.ts shared pattern. +// +// Note: TS and JS share a method config but NOT a field extractor because the TS field +// extractor needs a hand-written class for type_alias_declaration object literals and +// nested type discovery. Methods have no such requirement. +const shared: Omit = { + typeDeclarationNodes: [ + 'class_declaration', + 'abstract_class_declaration', + 'interface_declaration', + ], + // Note: TS constructors are method_definition nodes (name = 'constructor'), so no + // explicit constructor_declaration entry is needed (unlike JVM/C# configs). + // 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: ['method_definition', 'method_signature', 'abstract_method_signature'], + bodyNodeTypes: ['class_body', 'interface_body'], + + extractName(node) { + const nameNode = node.childForFieldName('name'); + return nameNode?.text; + }, + + extractReturnType: extractTsJsReturnType, + extractParameters: extractTsJsParameters, + extractVisibility: extractTsJsVisibility, + + isStatic(node) { + return hasKeyword(node, 'static'); + }, + + isAbstract(node, ownerNode) { + // Explicit abstract keyword on the method itself + if (hasKeyword(node, 'abstract')) return true; + // Interface methods are implicitly abstract — TS interfaces never have method bodies + // (unlike Java default methods), so no !body check needed + if (ownerNode.type === 'interface_declaration') return true; + return false; + }, + + isFinal(_node) { + return false; // TS/JS has no final/sealed methods + }, + + extractAnnotations: extractTsJsDecorators, + + isAsync(node) { + return hasKeyword(node, 'async'); + }, + + isOverride(node) { + return hasKeyword(node, 'override'); + }, +}; + +export const typescriptMethodConfig: MethodExtractionConfig = { + ...shared, + language: SupportedLanguages.TypeScript, +}; + +export const javascriptMethodConfig: MethodExtractionConfig = { + ...shared, + language: SupportedLanguages.JavaScript, +}; diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index d9f51918fc..cbb648f0d4 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -24,6 +24,18 @@ export const TYPESCRIPT_QUERIES = ` (method_definition name: (property_identifier) @name) @definition.method +; ES2022 #private methods (private_property_identifier not matched by property_identifier) +(method_definition + name: (private_property_identifier) @name) @definition.method + +; Abstract method signatures in abstract classes +(abstract_method_signature + name: (property_identifier) @name) @definition.method + +; Interface method signatures +(method_signature + name: (property_identifier) @name) @definition.method + (lexical_declaration (variable_declarator name: (identifier) @name @@ -145,6 +157,10 @@ export const JAVASCRIPT_QUERIES = ` (method_definition name: (property_identifier) @name) @definition.method +; ES2022 #private methods +(method_definition + name: (private_property_identifier) @name) @definition.method + (lexical_declaration (variable_declarator name: (identifier) @name diff --git a/gitnexus/src/core/ingestion/utils/ast-helpers.ts b/gitnexus/src/core/ingestion/utils/ast-helpers.ts index 654526ce8e..833d5db164 100644 --- a/gitnexus/src/core/ingestion/utils/ast-helpers.ts +++ b/gitnexus/src/core/ingestion/utils/ast-helpers.ts @@ -664,6 +664,12 @@ export const extractMethodSignature = (node: SyntaxNode | null | undefined): Met ) { continue; } + // TypeScript: `this` parameter is a compile-time type constraint, not a real param + // e.g., handle(this: void, event: Event) — only count 'event' + if (param.type === 'required_parameter') { + const patternNode = param.childForFieldName('pattern'); + if (patternNode?.type === 'this') continue; + } // Kotlin: default values are siblings of the parameter node inside // function_value_parameters, so they appear as named children (e.g. // string_literal, integer_literal, boolean_literal, call_expression). diff --git a/gitnexus/test/integration/has-method.test.ts b/gitnexus/test/integration/has-method.test.ts index 81c78cb3e9..bf9ecce20e 100644 --- a/gitnexus/test/integration/has-method.test.ts +++ b/gitnexus/test/integration/has-method.test.ts @@ -535,6 +535,96 @@ public: }); }); +describe('HAS_METHOD integration — TypeScript: abstract, interface, and #private methods', () => { + beforeAll(async () => { + await loadLanguage(SupportedLanguages.TypeScript, 'methods.ts'); + }); + + it('abstract method signatures are captured and link to abstract class', () => { + const code = ` +abstract class Shape { + abstract area(): number; + describe(): string { return "shape"; } +} +`; + const results = parseAndExtractMethods(code, SupportedLanguages.TypeScript, 'src/shapes.ts'); + + const area = results.find((r) => r.name === 'area' && r.defType === 'definition.method'); + expect(area).toBeDefined(); + expect(area!.enclosingClassId).toBe('Class:src/shapes.ts:Shape'); + + const describe = results.find( + (r) => r.name === 'describe' && r.defType === 'definition.method', + ); + expect(describe).toBeDefined(); + expect(describe!.enclosingClassId).toBe('Class:src/shapes.ts:Shape'); + }); + + it('interface method signatures are captured and link to interface', () => { + const code = ` +interface Printable { + print(format: string): void; + getLabel(): string; +} +`; + const results = parseAndExtractMethods(code, SupportedLanguages.TypeScript, 'src/printable.ts'); + + const print = results.find((r) => r.name === 'print' && r.defType === 'definition.method'); + expect(print).toBeDefined(); + expect(print!.enclosingClassId).toBe('Interface:src/printable.ts:Printable'); + + const getLabel = results.find( + (r) => r.name === 'getLabel' && r.defType === 'definition.method', + ); + expect(getLabel).toBeDefined(); + expect(getLabel!.enclosingClassId).toBe('Interface:src/printable.ts:Printable'); + }); + + it('ES2022 #private methods are captured and link to class', () => { + const code = ` +class Vault { + #decrypt(data: string): string { return data; } + read(): string { return this.#decrypt("x"); } +} +`; + const results = parseAndExtractMethods(code, SupportedLanguages.TypeScript, 'src/vault.ts'); + + const decrypt = results.find((r) => r.name === '#decrypt'); + expect(decrypt).toBeDefined(); + expect(decrypt!.defType).toBe('definition.method'); + expect(decrypt!.enclosingClassId).toBe('Class:src/vault.ts:Vault'); + + const read = results.find((r) => r.name === 'read'); + expect(read).toBeDefined(); + expect(read!.enclosingClassId).toBe('Class:src/vault.ts:Vault'); + }); +}); + +describe('HAS_METHOD integration — JavaScript: ES2022 #private methods', () => { + beforeAll(async () => { + await loadLanguage(SupportedLanguages.JavaScript); + }); + + it('#private methods are captured and link to class', () => { + const code = ` +class Encapsulated { + #internal() { return 42; } + expose() { return this.#internal(); } +} +`; + const results = parseAndExtractMethods( + code, + SupportedLanguages.JavaScript, + 'src/encapsulated.js', + ); + + const internal = results.find((r) => r.name === '#internal'); + expect(internal).toBeDefined(); + expect(internal!.defType).toBe('definition.method'); + expect(internal!.enclosingClassId).toBe('Class:src/encapsulated.js:Encapsulated'); + }); +}); + describe('HAS_METHOD integration — C# struct and record', () => { beforeAll(async () => { await loadLanguage(SupportedLanguages.CSharp); diff --git a/gitnexus/test/integration/resolvers/typescript.test.ts b/gitnexus/test/integration/resolvers/typescript.test.ts index 7765f07681..577c85478c 100644 --- a/gitnexus/test/integration/resolvers/typescript.test.ts +++ b/gitnexus/test/integration/resolvers/typescript.test.ts @@ -58,10 +58,11 @@ describe('TypeScript heritage resolution', () => { it('emits HAS_METHOD edges linking methods to classes', () => { const hasMethod = getRelationships(result, 'HAS_METHOD'); - expect(hasMethod.length).toBe(4); + expect(hasMethod.length).toBe(5); expect(edgeSet(hasMethod)).toEqual([ 'BaseService → getName', 'ConsoleLogger → log', + 'ILogger → log', 'UserService → getUsers', 'UserService → log', ]); diff --git a/gitnexus/test/unit/field-extraction.test.ts b/gitnexus/test/unit/field-extraction.test.ts index a12e1882f6..751618146f 100644 --- a/gitnexus/test/unit/field-extraction.test.ts +++ b/gitnexus/test/unit/field-extraction.test.ts @@ -511,6 +511,25 @@ describe('GenericFieldExtractor — TypeScript config', () => { expect(countField!.isStatic).toBe(false); expect(countField!.isReadonly).toBe(false); }); + + it('does not false-positive visibility on fields named after visibility keywords', () => { + parser.setLanguage(TypeScript.typescript); + // A class field literally named 'private' with no accessibility modifier — + // findVisibility must not confuse the name with a keyword + const tree = parser.parse(` + class Flags { + private: boolean; + } + `); + const classNode = tree.rootNode.child(0); + const result = extractor.extract(classNode!, mockContext); + + expect(result).not.toBeNull(); + expect(result!.fields).toHaveLength(1); + expect(result!.fields[0].name).toBe('private'); + // Default visibility is public — the field NAME 'private' must not be treated as a keyword + expect(result!.fields[0].visibility).toBe('public'); + }); }); // --------------------------------------------------------------------------- diff --git a/gitnexus/test/unit/method-extraction.test.ts b/gitnexus/test/unit/method-extraction.test.ts index 09f206f928..eae62ce68d 100644 --- a/gitnexus/test/unit/method-extraction.test.ts +++ b/gitnexus/test/unit/method-extraction.test.ts @@ -5,10 +5,16 @@ import { kotlinMethodConfig, } from '../../src/core/ingestion/method-extractors/configs/jvm.js'; import { csharpMethodConfig } from '../../src/core/ingestion/method-extractors/configs/csharp.js'; +import { + typescriptMethodConfig, + javascriptMethodConfig, +} from '../../src/core/ingestion/method-extractors/configs/typescript-javascript.js'; import type { MethodExtractorContext } from '../../src/core/ingestion/method-types.js'; import Parser from 'tree-sitter'; import Java from 'tree-sitter-java'; import CSharp from 'tree-sitter-c-sharp'; +import TypeScript from 'tree-sitter-typescript'; +import JavaScript from 'tree-sitter-javascript'; import { SupportedLanguages } from '../../src/config/supported-languages.js'; let Kotlin: unknown; @@ -295,7 +301,7 @@ describe('Java MethodExtractor', () => { const classNode = tree.rootNode.child(0)!; const result = extractor.extract(classNode, javaCtx); - expect(result!.methods.length).toBeGreaterThanOrEqual(1); + expect(result!.methods).toHaveLength(1); const sg = result!.methods.find((m) => m.name === 'surfaceGravity'); expect(sg).toBeDefined(); expect(sg!.returnType).toBe('double'); @@ -1297,3 +1303,608 @@ describe('C# MethodExtractor', () => { }); }); }); + +// --------------------------------------------------------------------------- +// TypeScript +// --------------------------------------------------------------------------- + +const parseTypeScript = (code: string) => { + parser.setLanguage(TypeScript.typescript); + return parser.parse(code); +}; + +const tsCtx: MethodExtractorContext = { + filePath: 'Test.ts', + language: SupportedLanguages.TypeScript, +}; + +describe('TypeScript MethodExtractor', () => { + const extractor = createMethodExtractor(typescriptMethodConfig); + + describe('isTypeDeclaration', () => { + it('recognizes class_declaration', () => { + const tree = parseTypeScript('class Foo { }'); + expect(extractor.isTypeDeclaration(tree.rootNode.child(0)!)).toBe(true); + }); + + it('recognizes abstract_class_declaration', () => { + const tree = parseTypeScript('abstract class Foo { }'); + expect(extractor.isTypeDeclaration(tree.rootNode.child(0)!)).toBe(true); + }); + + it('recognizes interface_declaration', () => { + const tree = parseTypeScript('interface Bar { }'); + expect(extractor.isTypeDeclaration(tree.rootNode.child(0)!)).toBe(true); + }); + + it('rejects function_declaration', () => { + const tree = parseTypeScript('function hello() {}'); + expect(extractor.isTypeDeclaration(tree.rootNode.child(0)!)).toBe(false); + }); + }); + + describe('extract', () => { + it('extracts typed method with return type and parameters', () => { + const tree = parseTypeScript(` + class UserService { + greet(name: string, age: number): string { + return name; + } + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, tsCtx); + + expect(result).not.toBeNull(); + expect(result!.ownerName).toBe('UserService'); + expect(result!.methods).toHaveLength(1); + + const m = result!.methods[0]; + expect(m.name).toBe('greet'); + expect(m.returnType).toBe('string'); + expect(m.visibility).toBe('public'); + expect(m.isStatic).toBe(false); + expect(m.isAbstract).toBe(false); + expect(m.parameters).toHaveLength(2); + expect(m.parameters[0]).toEqual({ + name: 'name', + type: 'string', + isOptional: false, + isVariadic: false, + }); + expect(m.parameters[1]).toEqual({ + name: 'age', + type: 'number', + isOptional: false, + isVariadic: false, + }); + }); + + it('extracts static method', () => { + const tree = parseTypeScript(` + class MathUtils { + static add(a: number, b: number): number { return a + b; } + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, tsCtx); + + expect(result!.methods[0].isStatic).toBe(true); + expect(result!.methods[0].name).toBe('add'); + }); + + it('extracts abstract class with abstract and concrete methods', () => { + const tree = parseTypeScript(` + abstract class Shape { + abstract area(): number; + describe(): string { return "shape"; } + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, tsCtx); + + expect(result).not.toBeNull(); + expect(result!.methods).toHaveLength(2); + + const abstractMethod = result!.methods.find((m) => m.name === 'area'); + const concreteMethod = result!.methods.find((m) => m.name === 'describe'); + expect(abstractMethod!.isAbstract).toBe(true); + expect(abstractMethod!.returnType).toBe('number'); + expect(concreteMethod!.isAbstract).toBe(false); + expect(concreteMethod!.returnType).toBe('string'); + }); + + it('extracts interface methods as abstract', () => { + const tree = parseTypeScript(` + interface Printable { + print(format: string): void; + getLabel(): string; + } + `); + const interfaceNode = tree.rootNode.child(0)!; + const result = extractor.extract(interfaceNode, tsCtx); + + expect(result).not.toBeNull(); + expect(result!.ownerName).toBe('Printable'); + expect(result!.methods).toHaveLength(2); + expect(result!.methods.every((m) => m.isAbstract)).toBe(true); + + const printMethod = result!.methods.find((m) => m.name === 'print'); + expect(printMethod!.parameters[0]).toEqual({ + name: 'format', + type: 'string', + isOptional: false, + isVariadic: false, + }); + expect(printMethod!.returnType).toBe('void'); + }); + + it('extracts private and protected visibility', () => { + const tree = parseTypeScript(` + class Account { + private secret(): void {} + protected validate(): boolean { return true; } + public display(): void {} + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, tsCtx); + + expect(result!.methods).toHaveLength(3); + const secret = result!.methods.find((m) => m.name === 'secret'); + const validate = result!.methods.find((m) => m.name === 'validate'); + const display = result!.methods.find((m) => m.name === 'display'); + expect(secret!.visibility).toBe('private'); + expect(validate!.visibility).toBe('protected'); + expect(display!.visibility).toBe('public'); + }); + + it('extracts optional and rest parameters', () => { + const tree = parseTypeScript(` + class Logger { + log(message: string, level?: string, ...tags: string[]): void {} + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, tsCtx); + + const params = result!.methods[0].parameters; + expect(params).toHaveLength(3); + expect(params[0]).toEqual({ + name: 'message', + type: 'string', + isOptional: false, + isVariadic: false, + }); + expect(params[1].name).toBe('level'); + expect(params[1].isOptional).toBe(true); + expect(params[1].isVariadic).toBe(false); + expect(params[2].name).toBe('tags'); + expect(params[2].isOptional).toBe(false); + expect(params[2].isVariadic).toBe(true); + }); + + it('extracts default parameter as optional', () => { + const tree = parseTypeScript(` + class Formatter { + format(value: string, prefix: string = ">>") { return prefix + value; } + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, tsCtx); + + const params = result!.methods[0].parameters; + expect(params).toHaveLength(2); + expect(params[1].name).toBe('prefix'); + expect(params[1].isOptional).toBe(true); + }); + + it('extracts decorators as annotations', () => { + const tree = parseTypeScript(` + class Controller { + @Log + @deprecated("use newMethod") + handle(req: Request): void {} + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, tsCtx); + + const annotations = result!.methods[0].annotations; + expect(annotations).toContain('@Log'); + expect(annotations).toContain('@deprecated'); + }); + + it('extracts async method', () => { + const tree = parseTypeScript(` + class ApiClient { + async fetch(url: string): Promise { return new Response(); } + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, tsCtx); + + expect(result!.methods[0].isAsync).toBe(true); + expect(result!.methods[0].name).toBe('fetch'); + expect(result!.methods[0].returnType).toBe('Promise'); + }); + + it('extracts constructor', () => { + const tree = parseTypeScript(` + class Person { + constructor(public name: string, private age: number) {} + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, tsCtx); + + expect(result!.methods).toHaveLength(1); + const ctor = result!.methods[0]; + expect(ctor.name).toBe('constructor'); + expect(ctor.parameters).toHaveLength(2); + expect(ctor.parameters[0].name).toBe('name'); + expect(ctor.parameters[0].type).toBe('string'); + expect(ctor.parameters[1].name).toBe('age'); + expect(ctor.parameters[1].type).toBe('number'); + }); + + it('extracts override method', () => { + const tree = parseTypeScript(` + class Child extends Parent { + override toString(): string { return "child"; } + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, tsCtx); + + expect(result!.methods[0].name).toBe('toString'); + expect(result!.methods[0].isOverride).toBe(true); + }); + + it('extracts getter and setter as methods', () => { + const tree = parseTypeScript(` + class Config { + get value(): number { return 1; } + set value(v: number) {} + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, tsCtx); + + // Getter and setter both have name 'value' (no get/set prefix from extractName) + expect(result!.methods).toHaveLength(2); + const getter = result!.methods[0]; + const setter = result!.methods[1]; + expect(getter.name).toBe('value'); + expect(getter.parameters).toHaveLength(0); + expect(getter.returnType).toBe('number'); + expect(setter.name).toBe('value'); + expect(setter.parameters).toHaveLength(1); + expect(setter.parameters[0].name).toBe('v'); + }); + + it('extracts destructured parameter', () => { + const tree = parseTypeScript(` + class Handler { + handle({ method, path }: Request): void {} + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, tsCtx); + + const params = result!.methods[0].parameters; + expect(params).toHaveLength(1); + // Destructured params extract the pattern text and type from annotation + expect(params[0].name).toBe('{ method, path }'); + expect(params[0].type).toBe('Request'); + }); + + it('extracts generator method as method_definition', () => { + const tree = parseTypeScript(` + class Stream { + *items(): Generator { yield 1; } + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, tsCtx); + + expect(result!.methods).toHaveLength(1); + expect(result!.methods[0].name).toBe('items'); + expect(result!.methods[0].returnType).toBe('Generator'); + }); + + it('extracts async generator method with isAsync true', () => { + const tree = parseTypeScript(` + class Stream { + async *values(): AsyncGenerator { yield 1; } + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, tsCtx); + + expect(result!.methods).toHaveLength(1); + expect(result!.methods[0].name).toBe('values'); + expect(result!.methods[0].isAsync).toBe(true); + expect(result!.methods[0].returnType).toBe('AsyncGenerator'); + }); + + it('extracts computed property name with brackets', () => { + const tree = parseTypeScript(` + class Iterable { + [Symbol.iterator](): Iterator { return this; } + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, tsCtx); + + expect(result!.methods).toHaveLength(1); + // Computed names include brackets — this is intentional for static analysis disambiguation + expect(result!.methods[0].name).toBe('[Symbol.iterator]'); + }); + + it('extracts class-level method overloads', () => { + const tree = parseTypeScript(` + class Parser { + parse(input: string): string; + parse(input: number): number; + parse(input: string | number): string | number { return input; } + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, tsCtx); + + // Two overload signatures (method_signature) + one implementation (method_definition) = 3 + const parseMethods = result!.methods.filter((m) => m.name === 'parse'); + expect(parseMethods).toHaveLength(3); + // Overload signatures inside a class body are not abstract + for (const m of parseMethods) { + expect(m.isAbstract).toBe(false); + } + }); + + it('filters out this-parameter (compile-time constraint)', () => { + const tree = parseTypeScript(` + class Handler { + handle(this: void, event: Event): void {} + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, tsCtx); + + const params = result!.methods[0].parameters; + // 'this' is not a real parameter — only 'event' should appear + expect(params).toHaveLength(1); + expect(params[0].name).toBe('event'); + expect(params[0].type).toBe('Event'); + }); + + it('does not false-positive on methods named after soft keywords', () => { + const tree = parseTypeScript(` + class Foo { + static abstract() {} + static() {} + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, tsCtx); + + const abstractMethod = result!.methods.find((m) => m.name === 'abstract'); + expect(abstractMethod).toBeDefined(); + expect(abstractMethod!.isStatic).toBe(true); + expect(abstractMethod!.isAbstract).toBe(false); // name, not keyword + + const staticMethod = result!.methods.find((m) => m.name === 'static'); + expect(staticMethod).toBeDefined(); + expect(staticMethod!.isStatic).toBe(false); // name, not keyword + }); + + it('extracts destructured rest parameter via required_parameter + rest_pattern', () => { + const tree = parseTypeScript(` + class Router { + route(base: string, ...{ method, path }: RouteConfig): void {} + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, tsCtx); + + const params = result!.methods[0].parameters; + expect(params).toHaveLength(2); + expect(params[0].name).toBe('base'); + expect(params[1].name).toBe('{ method, path }'); + expect(params[1].isVariadic).toBe(true); + expect(params[1].type).toBe('RouteConfig'); + }); + + it('extracts ES2022 #private method as visibility private', () => { + const tree = parseTypeScript(` + class Vault { + #decrypt(data: string): string { return data; } + public read(): string { return this.#decrypt("x"); } + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, tsCtx); + + const decrypt = result!.methods.find((m) => m.name === '#decrypt'); + expect(decrypt).toBeDefined(); + expect(decrypt!.visibility).toBe('private'); + expect(decrypt!.parameters[0].type).toBe('string'); + + const read = result!.methods.find((m) => m.name === 'read'); + expect(read!.visibility).toBe('public'); + }); + + it('extracts generic method without type params in name', () => { + const tree = parseTypeScript(` + class Mapper { + transform(input: T, fn: (x: T) => U): U { return fn(input); } + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, tsCtx); + + const m = result!.methods[0]; + expect(m.name).toBe('transform'); + expect(m.parameters).toHaveLength(2); + expect(m.parameters[0].name).toBe('input'); + expect(m.parameters[0].type).toBe('T'); + }); + + it('returns empty methods for class with no methods', () => { + const tree = parseTypeScript(` + class Empty {} + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, tsCtx); + + expect(result).not.toBeNull(); + expect(result!.ownerName).toBe('Empty'); + expect(result!.methods).toHaveLength(0); + }); + }); +}); + +// --------------------------------------------------------------------------- +// JavaScript +// --------------------------------------------------------------------------- + +const parseJavaScript = (code: string) => { + parser.setLanguage(JavaScript); + return parser.parse(code); +}; + +const jsCtx: MethodExtractorContext = { + filePath: 'Test.js', + language: SupportedLanguages.JavaScript, +}; + +describe('JavaScript MethodExtractor', () => { + const extractor = createMethodExtractor(javascriptMethodConfig); + + describe('extract', () => { + it('extracts class method with default public visibility and null types', () => { + const tree = parseJavaScript(` + class Greeter { + greet(name) { return "Hello " + name; } + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, jsCtx); + + expect(result).not.toBeNull(); + expect(result!.ownerName).toBe('Greeter'); + expect(result!.methods).toHaveLength(1); + + const m = result!.methods[0]; + expect(m.name).toBe('greet'); + expect(m.returnType).toBeNull(); + expect(m.visibility).toBe('public'); + expect(m.isAbstract).toBe(false); + expect(m.parameters).toHaveLength(1); + expect(m.parameters[0]).toEqual({ + name: 'name', + type: null, + isOptional: false, + isVariadic: false, + }); + }); + + it('extracts static method and constructor', () => { + const tree = parseJavaScript(` + class Factory { + constructor(type) { this.type = type; } + static create(type) { return new Factory(type); } + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, jsCtx); + + expect(result!.methods).toHaveLength(2); + const ctor = result!.methods.find((m) => m.name === 'constructor'); + const create = result!.methods.find((m) => m.name === 'create'); + expect(ctor).toBeDefined(); + expect(create!.isStatic).toBe(true); + }); + + it('extracts default parameter as optional and rest as variadic', () => { + const tree = parseJavaScript(` + class EventEmitter { + emit(event, data = null, ...listeners) {} + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, jsCtx); + + const params = result!.methods[0].parameters; + expect(params).toHaveLength(3); + expect(params[0]).toEqual({ + name: 'event', + type: null, + isOptional: false, + isVariadic: false, + }); + expect(params[1].name).toBe('data'); + expect(params[1].isOptional).toBe(true); + expect(params[2].name).toBe('listeners'); + expect(params[2].isVariadic).toBe(true); + }); + + it('does not detect abstract or interface types (JS has neither)', () => { + const tree = parseJavaScript(` + class Shape { + area() { return 0; } + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, jsCtx); + + expect(result!.methods[0].isAbstract).toBe(false); + expect(result!.methods[0].isFinal).toBe(false); + }); + + it('extracts private field method with # prefix', () => { + const tree = parseJavaScript(` + class Encapsulated { + #internal() { return 42; } + expose() { return this.#internal(); } + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, jsCtx); + + const internal = result!.methods.find((m) => m.name === '#internal'); + expect(internal).toBeDefined(); + expect(internal!.name).toBe('#internal'); + // ES2022 private methods (#name) are inherently private + expect(internal!.visibility).toBe('private'); + }); + + it('extracts destructured object parameter', () => { + const tree = parseJavaScript(` + class Handler { + handle({ method, path }) {} + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, jsCtx); + + const params = result!.methods[0].parameters; + expect(params).toHaveLength(1); + expect(params[0].name).toBe('{ method, path }'); + expect(params[0].type).toBeNull(); + }); + + it('extracts async method', () => { + const tree = parseJavaScript(` + class Client { + async fetch(url) { return null; } + } + `); + const classNode = tree.rootNode.child(0)!; + const result = extractor.extract(classNode, jsCtx); + + expect(result!.methods[0].isAsync).toBe(true); + expect(result!.methods[0].name).toBe('fetch'); + }); + }); +}); diff --git a/gitnexus/test/unit/method-signature.test.ts b/gitnexus/test/unit/method-signature.test.ts index 350457331c..af9e79e55a 100644 --- a/gitnexus/test/unit/method-signature.test.ts +++ b/gitnexus/test/unit/method-signature.test.ts @@ -64,6 +64,31 @@ describe('extractMethodSignature', () => { expect(sig.parameterCount).toBe(1); expect(sig.returnType).toBeUndefined(); }); + + it('skips TypeScript this-parameter (compile-time constraint)', () => { + parser.setLanguage(TypeScript.typescript); + const code = `class Handler { + handle(this: void, event: Event): void {} +}`; + const tree = parser.parse(code); + const classNode = tree.rootNode.child(0)!; + const classBody = classNode.childForFieldName('body')!; + const methodNode = classBody.namedChild(0)!; + + const sig = extractMethodSignature(methodNode); + // 'this' is not a real parameter — only 'event' should be counted + expect(sig.parameterCount).toBe(1); + }); + + it('skips this-parameter in top-level function', () => { + parser.setLanguage(TypeScript.typescript); + const code = `function onClick(this: HTMLElement, ev: MouseEvent): void {}`; + const tree = parser.parse(code); + const funcNode = tree.rootNode.child(0)!; + + const sig = extractMethodSignature(funcNode); + expect(sig.parameterCount).toBe(1); + }); }); describe('Python', () => {