GitNexus/gitnexus/test/unit/objective-c-provider.test.ts
mengkaka 4154b63131
feat(indexing): add Objective-C semantic indexing support (#3179)
* docs: add Objective-C fork provider notes

* feat(objective-c): add deterministic provider and grammar

* feat(objective-c): finalize provider MVP

* fix(objective-c): harden provider integration

* fix(objective-c): normalize bare macro markers

* docs(objective-c): integrate provider documentation

* fix(objective-c): harden resolution and header classification

* fix(objective-c): complete provider follow-ups

* fix: address Objective-C review follow-ups

* chore: format Objective-C grammar sources

* fix(objective-c): harden review follow-ups

* Address PR review feedback (#3179)

Keep Objective-C chunking and macro recovery aligned with the grammar, and stop Community MEMBER_OF edges from leaking into symbol context.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address follow-up review on ObjC chunking and language fallback.

Keep preprocessor directive text from changing file-scope brace depth, group real ivar nodes, skip header modifiers, and restore Rakefile/Gemfile detection through getLanguageFromFilename.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Parse Objective-C headers with the objc grammar in embeddings.

ensureAndParse and structural extraction now use the same content classifier as ingest, including method snippets from .h files, so Protocol/Category/Class chunks are not re-parsed as C++.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3179)

Keep file-scope macro elision off C line splices and @interface/@protocol/@implementation bodies, and attach ivar attributes to the following instance variable when chunking.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(bench): rebaseline Objective-C CSV emit

* feat(objective-c): add workspace resolution and linear emit benches

Plain .h files are classified as C++, so the ObjC pass could not
resolve #import of those headers. Load a C/C#-style workspace once
per pass, and keep protocol-candidate USES linear.

Refs #3179

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3179)

- Compare LadybugDB labels() as a scalar when excluding Community MEMBER_OF edges.
- Walk superclass members, skip file-static C sibling defs, and ignore comments in ObjC header/macro scans.

Note: pre-existing failure in objective-c-provider integration (worker-pool ready timeout) not addressed by this PR.
Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3179)

Emit Objective-C declaration captures so compilation-unit siblings can share
header/implementation bindings, and keep class vs protocol visibility groups
distinct.

Note: pre-existing failure in worker-pool startup (GITNEXUS_WORKER_READY_TIMEOUT_MS) not addressed by this PR.
Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3179)

Emit every comma-separated property/ivar declarator, and count @interface
after a multiline block comment closes so in-declaration macros stay intact.

Note: pre-existing failure in worker-pool startup (GITNEXUS_WORKER_READY_TIMEOUT_MS) not addressed by this PR.
Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: ximengkai <ximengkai@soyoung.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 09:40:21 +00:00

963 lines
30 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import path from 'path';
import Parser from 'tree-sitter';
import {
getLanguageFromFilename,
getSyntaxLanguageFromFilename,
SupportedLanguages,
} from 'gitnexus-shared';
import { getLanguageForFileContent } from '../../src/core/ingestion/languages/index.js';
import {
classifyObjectiveCFileContent,
objectiveCProvider,
} from '../../src/core/ingestion/languages/objective-c.js';
import {
buildObjectiveCScopeCaptures,
buildObjectiveCSemanticGraph,
collectObjectiveCFacts,
objcCategoryQualifiedName,
objcClassQualifiedName,
objcFunctionQualifiedName,
objcMethodQualifiedName,
objcProtocolQualifiedName,
} from '../../src/core/ingestion/languages/objective-c/facts.js';
import { isLanguageAvailable } from '../../src/core/tree-sitter/parser-loader.js';
import { requireVendoredGrammar } from '../../src/core/tree-sitter/vendored-grammars.js';
import { objectiveCScopeResolver } from '../../src/core/ingestion/languages/objective-c/scope-resolver.js';
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
import { extractParsedFile } from '../../src/core/ingestion/scope-extractor-bridge.js';
import { populateObjectiveCCompilationUnitSiblings } from '../../src/core/ingestion/languages/objective-c/compilation-unit-siblings.js';
import type { ScopeResolutionIndexes } from '../../src/core/ingestion/model/scope-resolution-indexes.js';
const FIXTURE = `#import "SYModuleCaller.h"
#include "SYModuleSupport.h"
@import Foundation;
@protocol SYModuleRunnable <NSObject>
- (void)runTask:(NSString *)name completion:(void (^)(BOOL ok))completion;
@end
@interface SYBaseCaller : NSObject
- (void)loadData:(NSString *)name completion:(void (^)(BOOL ok))completion;
@end
@interface SYModuleCaller : SYBaseCaller <SYModuleRunnable> {
SYBaseCaller *_base;
}
@property (nonatomic, strong) SYBaseCaller *helper;
+ (instancetype)sharedCaller;
- (void)runTask:(NSString *)name completion:(void (^)(BOOL ok))completion;
@end
@interface SYModuleCaller ()
@property (nonatomic, strong) SYBaseCaller *privateHelper;
@end
@interface SYModuleCaller (Tracing)
- (void)traceEvent:(NSString *)name;
@end
@implementation SYModuleCaller
+ (instancetype)sharedCaller { return [SYModuleCaller new]; }
- (void)runTask:(NSString *)name completion:(void (^)(BOOL ok))completion {
SYBaseCaller *typed = self.helper;
id dynamic = typed;
[self traceEvent:name];
[super loadData:name completion:completion];
[typed loadData:name completion:completion];
[dynamic loadData:name completion:completion];
}
- (void)runProtocol:(id<SYModuleRunnable>)runner {
[runner runTask:@"x" completion:^(BOOL ok) {}];
}
@end
@implementation SYModuleCaller (Tracing)
- (void)traceEvent:(NSString *)name {}
@end
static int SYModuleCompute(int value) { return value + 1; }
`;
function parseFixture() {
const parser = new Parser();
parser.setLanguage(requireVendoredGrammar('tree-sitter-objc'));
return parser.parse(FIXTURE);
}
function parseSource(source: string) {
const parser = new Parser();
parser.setLanguage(requireVendoredGrammar('tree-sitter-objc'));
return parser.parse(source);
}
function legacyResolveObjectiveCImportTarget(
targetRaw: string,
fromFile: string,
allFilePaths: ReadonlySet<string>,
): string | null {
const target = targetRaw.trim();
if (target.length === 0 || (target.startsWith('<') && target.endsWith('>'))) return null;
if (!(target.startsWith('.') || target.includes('/') || path.posix.extname(target).length > 0)) {
return null;
}
const normalize = (value: string) => value.replaceAll('\\', '/').replace(/^\.\//, '');
const normalizedTarget = normalize(target);
const fromDir = normalize(path.posix.dirname(normalize(fromFile)));
const candidates = new Set<string>([
normalize(path.posix.join(fromDir, normalizedTarget)),
normalizedTarget,
]);
if (path.posix.extname(normalizedTarget).length === 0) {
for (const candidate of [...candidates]) {
candidates.add(`${candidate}.h`);
candidates.add(`${candidate}.m`);
candidates.add(`${candidate}.mm`);
}
}
for (const candidate of candidates) {
if (allFilePaths.has(candidate)) return candidate;
}
const suffixes = [...candidates].map((candidate) => `/${candidate}`);
for (const filePath of allFilePaths) {
if (suffixes.some((suffix) => normalize(filePath).endsWith(suffix))) return filePath;
}
return null;
}
describe('Objective-C provider', () => {
it('loads the vendored grammar and maps unambiguous Objective-C extensions', () => {
expect(isLanguageAvailable(SupportedLanguages.ObjectiveC)).toBe(true);
expect(getLanguageFromFilename('SYModuleCaller.m')).toBe(SupportedLanguages.ObjectiveC);
expect(getLanguageFromFilename('SYModuleCaller.mm')).toBe(SupportedLanguages.ObjectiveC);
expect(getSyntaxLanguageFromFilename('SYModuleCaller.m')).toBe('objectivec');
});
it('classifies Objective-C headers only from explicit Objective-C syntax', () => {
expect(
classifyObjectiveCFileContent(
'SYModuleCaller.h',
'@interface SYModuleCaller : NSObject\n@end',
),
).toBe(true);
expect(getLanguageForFileContent('SYModuleCaller.h', '@protocol SYModuleRunnable\n@end')).toBe(
SupportedLanguages.ObjectiveC,
);
expect(
getLanguageForFileContent('plain.h', '#ifndef PLAIN_H\nint add(int a, int b);\n#endif\n'),
).toBe(SupportedLanguages.CPlusPlus);
expect(
getLanguageForFileContent(
'core-foundation-cpp.h',
'#import <CoreFoundation/CoreFoundation.h>\nclass Widget { int value; };\n',
),
).toBe(SupportedLanguages.CPlusPlus);
expect(
classifyObjectiveCFileContent('framework.h', '#import <Foundation/Foundation.h>\n'),
).toBe(false);
expect(classifyObjectiveCFileContent('plain-cpp.h', 'class Widget { int value; };\n')).toBe(
false,
);
expect(classifyObjectiveCFileContent('forward.h', '@class Widget;\n')).toBe(true);
expect(classifyObjectiveCFileContent('MethodSnippet.h', '- (void)run;\n')).toBe(true);
expect(classifyObjectiveCFileContent('ClassSnippet.h', '+ (instancetype)shared;\n')).toBe(true);
expect(classifyObjectiveCFileContent('unary.h', '-(x);\n')).toBe(false);
expect(
classifyObjectiveCFileContent('commented.h', '// - (void)run;\nint add(int a, int b);\n'),
).toBe(false);
expect(
classifyObjectiveCFileContent(
'block-comment.h',
'/* - (void)run; */\nint add(int a, int b);\n',
),
).toBe(false);
});
it('preserves local Objective-C import resolution while indexing suffixes', () => {
const allFilePaths = new Set([
'Vendor/Widget.h',
'Headers/Widget.h',
'Sources/Local.h',
'Shared/Widget.m',
'Shared/Widget.mm',
'Nested/Shared/Widget.h',
'Windows\\Path\\Win.h',
'Sources/main.m',
]);
const cases = [
['Widget.h', 'Sources/main.m'],
['./Local.h', 'Sources/main.m'],
['../Shared/Widget', 'Sources/Sub/child.m'],
['Widget', 'Sources/main.m'],
['Windows/Path/Win.h', 'Sources/main.m'],
['Missing.h', 'Sources/main.m'],
['<Foundation/Foundation.h>', 'Sources/main.m'],
] as const;
for (const [target, fromFile] of cases) {
expect(objectiveCScopeResolver.resolveImportTarget(target, fromFile, allFilePaths)).toBe(
legacyResolveObjectiveCImportTarget(target, fromFile, allFilePaths),
);
}
});
it('extracts nested C function declarators without claiming function pointers', () => {
const facts = collectObjectiveCFacts(
parseSource(`
int add(int value);
int *returnsPointer(int value);
int (*callback)(int value);
int first(void), second(void);
`),
'functions.h',
);
expect(facts.functions.map((fn) => fn.name)).toEqual(
expect.arrayContaining(['add', 'returnsPointer', 'first', 'second']),
);
expect(facts.functions.map((fn) => fn.name)).not.toContain('callback');
});
it('extracts C helper functions declared inside an Objective-C implementation', () => {
const facts = collectObjectiveCFacts(
parseSource(`
@implementation Worker
static int helper(void) { return 1; }
@end
`),
'Worker.m',
);
expect(facts.functions).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: 'helper',
linkage: 'internal',
qualifiedName: objcFunctionQualifiedName('helper', 'internal', 'Worker.m'),
returnType: 'int',
parameterTypes: ['void'],
}),
]),
);
});
it('keeps internal C function identities file-local while preserving external identities', () => {
const first = collectObjectiveCFacts(
parseSource('static int helper(void) { return 1; }\nint shared(void);'),
'First.m',
);
const second = collectObjectiveCFacts(
parseSource('static int helper(void) { return 2; }\nint shared(void);'),
'Second.m',
);
const firstStatic = first.functions.find((fn) => fn.name === 'helper');
const secondStatic = second.functions.find((fn) => fn.name === 'helper');
const firstExternal = first.functions.find((fn) => fn.name === 'shared');
const secondExternal = second.functions.find((fn) => fn.name === 'shared');
expect(firstStatic?.qualifiedName).toBe(
objcFunctionQualifiedName('helper', 'internal', 'First.m'),
);
expect(secondStatic?.qualifiedName).toBe(
objcFunctionQualifiedName('helper', 'internal', 'Second.m'),
);
expect(firstStatic?.qualifiedName).not.toBe(secondStatic?.qualifiedName);
expect(firstExternal?.qualifiedName).toBe(objcFunctionQualifiedName('shared'));
expect(secondExternal?.qualifiedName).toBe(firstExternal?.qualifiedName);
});
it('collects guarded headers and protocol members in optional and required sections', () => {
const facts = collectObjectiveCFacts(
parseSource(`
#ifndef WORKER_H
#define WORKER_H
#import "Dep.h"
@interface Worker
- (void)run;
@end
#endif
@protocol P
@optional
- (void)ping;
@property Helper *optionalHelper;
@required
- (void)pong;
@end
`),
'Worker.h',
);
expect(facts.imports).toContainEqual(expect.objectContaining({ targetRaw: 'Dep.h' }));
expect(facts.containers).toContainEqual(
expect.objectContaining({ kind: 'class', name: 'Worker' }),
);
expect(facts.methods.map((method) => `${method.ownerName}:${method.selector}`)).toEqual(
expect.arrayContaining(['Worker:run', 'P:ping', 'P:pong']),
);
expect(facts.members).toContainEqual(
expect.objectContaining({ name: 'optionalHelper', declaredType: 'Helper' }),
);
});
it('uses lexical bindings at each message position and keeps bare id bindings dynamic', () => {
const facts = collectObjectiveCFacts(
parseSource(`
@protocol P
- (void)ping;
@end
@interface First
- (void)ping;
@end
@interface Second
- (void)ping;
@end
@interface A
+ (void)ping;
@end
@interface Worker
- (void)run:(First *)value;
@end
@implementation Worker
- (void)run:(First *)value {
id<P> worker;
id A;
[value ping];
{ Second *value = nil; [value ping]; }
[value ping];
[worker ping];
[A ping];
}
@end
`),
'Worker.m',
);
const valueMessages = facts.messages.filter(
(message) => message.receiverText === 'value' && message.selector === 'ping',
);
expect(valueMessages.map((message) => message.receiverType?.name)).toEqual([
'First',
'Second',
'First',
]);
expect(facts.messages).toContainEqual(
expect.objectContaining({
receiverText: 'worker',
receiverKind: 'local',
receiverType: { kind: 'protocol', name: 'P', raw: 'id<P>' },
}),
);
expect(facts.messages).toContainEqual(
expect.objectContaining({
receiverText: 'A',
receiverKind: 'dynamic',
receiverType: { kind: 'dynamic', raw: 'id' },
}),
);
});
it('does not treat protocol-qualified parameter types as conformance', () => {
const facts = collectObjectiveCFacts(
parseSource(`
@protocol P <NSObject>
- (void)run:(id<Q>)value;
@end
@interface Child : Base <P>
- (void)run:(id<Q>)value;
@end
`),
'protocols.h',
);
expect(facts.containers.find((container) => container.name === 'P')?.protocols).toEqual([
'NSObject',
]);
expect(facts.containers.find((container) => container.name === 'Child')?.protocols).toEqual([
'P',
]);
});
it('keeps explicit class receivers and macro receivers separate', () => {
const facts = collectObjectiveCFacts(
parseSource(`
#define RECEIVER_MACRO(x) x
@interface A
+ (void)run;
@end
@interface Base
- (void)ping;
@end
@interface Child : Base
- (void)call;
@end
@implementation Child
- (void)call {
[A run];
[self ping];
[RECEIVER_MACRO(self) ping];
}
@end
`),
'receivers.m',
);
expect(
facts.messages.map((message) => `${message.receiverKind}:${message.receiverText}`),
).toEqual(expect.arrayContaining(['class:A', 'self:self', 'dynamic:RECEIVER_MACRO(self)']));
expect(facts.unresolvedMessages).toEqual(
expect.arrayContaining([
expect.objectContaining({
receiverText: 'RECEIVER_MACRO(self)',
reason: 'macro receiver RECEIVER_MACRO is dynamic',
}),
]),
);
});
it('retains protocol qualifications on property and ivar types', () => {
const facts = collectObjectiveCFacts(
parseSource(`
@protocol WorkerProtocol
- (void)run;
@end
@interface Host {
id<WorkerProtocol> ivar;
}
@property id<WorkerProtocol> member;
- (void)go;
@end
@implementation Host
- (void)go { [self.member run]; }
@end
`),
'Host.m',
);
expect(facts.members).toContainEqual(
expect.objectContaining({ name: 'member', declaredType: 'id<WorkerProtocol>' }),
);
expect(facts.members).toContainEqual(
expect.objectContaining({ name: 'ivar', declaredType: 'id<WorkerProtocol>' }),
);
expect(facts.messages).toContainEqual(
expect.objectContaining({
receiverText: 'self.member',
receiverType: { kind: 'protocol', name: 'WorkerProtocol', raw: 'id<WorkerProtocol>' },
}),
);
});
it('emits every declarator in a comma-separated property or ivar declaration', () => {
const facts = collectObjectiveCFacts(
parseSource(`
@interface Host {
Widget *primary, *secondary;
}
@property Widget *left, *right;
@end
`),
'Host.h',
);
expect(facts.members).toEqual(
expect.arrayContaining([
expect.objectContaining({ kind: 'ivar', name: 'primary', declaredType: 'Widget' }),
expect.objectContaining({ kind: 'ivar', name: 'secondary', declaredType: 'Widget' }),
expect.objectContaining({ kind: 'property', name: 'left', declaredType: 'Widget' }),
expect.objectContaining({ kind: 'property', name: 'right', declaredType: 'Widget' }),
]),
);
});
it('resolves a property inherited from the superclass', () => {
const facts = collectObjectiveCFacts(
parseSource(`
@interface Helper
- (void)run;
@end
@interface Base
@property (nonatomic, strong) Helper *baseHelper;
@end
@interface Child : Base
- (void)go;
@end
@implementation Child
- (void)go { [self.baseHelper run]; }
@end
`),
'Child.m',
);
expect(facts.messages).toContainEqual(
expect.objectContaining({
receiverText: 'self.baseHelper',
selector: 'run',
receiverKind: 'property',
receiverMemberName: 'baseHelper',
}),
);
const graph = createKnowledgeGraph();
for (const container of facts.containers) {
graph.addNode({
id: container.nodeId,
label: container.label,
properties: { filePath: facts.filePath, qualifiedName: container.qualifiedName },
});
}
for (const method of facts.methods) {
graph.addNode({
id: method.nodeId,
label: 'Method',
properties: { filePath: facts.filePath, qualifiedName: method.qualifiedName },
});
}
objectiveCScopeResolver.emitPostResolutionEdges?.(graph, [
{
filePath: facts.filePath,
moduleScope: 0,
scopes: [],
parsedImports: [],
localDefs: [],
referenceSites: [],
captureSideChannel: { kind: 'objective-c', facts },
},
]);
const go = facts.methods.find(
(method) => method.selector === 'go' && method.declarationRole === 'implementation',
);
const run = facts.methods.find(
(method) => method.selector === 'run' && method.ownerName === 'Helper',
);
expect(go).toBeDefined();
expect(run).toBeDefined();
expect(
[...graph.iterRelationships()].some(
(rel) =>
rel.type === 'CALLS' && rel.sourceId === go?.nodeId && rel.targetId === run?.nodeId,
),
).toBe(true);
});
it('resolves a property declared after its caller in a class extension', () => {
const facts = collectObjectiveCFacts(
parseSource(`
@interface LaterOwner
@end
@implementation LaterOwner
- (void)run {
[self.helper performWork];
}
@end
@interface LaterOwner (Private)
@property (nonatomic, strong) Worker *helper;
@end
`),
'LaterOwner.m',
);
expect(facts.messages).toContainEqual(
expect.objectContaining({
receiverText: 'self.helper',
selector: 'performWork',
receiverKind: 'property',
receiverType: { kind: 'class', name: 'Worker', raw: 'Worker' },
}),
);
expect(facts.unresolvedMessages).not.toContainEqual(
expect.objectContaining({ receiverText: 'self.helper' }),
);
});
it('resolves extensionless local imports to Objective-C source/header files', () => {
expect(
objectiveCScopeResolver.resolveImportTarget(
'./NestedHeader',
'src/Caller.m',
new Set(['src/NestedHeader.h']),
),
).toBe('src/NestedHeader.h');
expect(
objectiveCScopeResolver.resolveImportTarget(
'./NestedImpl',
'src/Caller.m',
new Set(['src/NestedImpl.mm']),
),
).toBe('src/NestedImpl.mm');
expect(
objectiveCScopeResolver.resolveImportTarget(
'Foundation',
'src/Caller.m',
new Set(['src/Foundation.h']),
),
).toBeNull();
});
it('keeps angle-bracket system headers out of local import resolution', () => {
const tree = parseSource('#import "Local.h"\n#import <Foundation/Foundation.h>\n');
const facts = collectObjectiveCFacts(tree, 'src/Caller.m');
const captures = buildObjectiveCScopeCaptures(facts, tree.rootNode).filter(
(capture) => capture['@import.source'] !== undefined,
);
const parsed = captures.map((capture) => objectiveCProvider.interpretImport?.(capture));
expect(parsed.map((entry) => entry?.targetRaw)).toEqual([
'./Local.h',
'<Foundation/Foundation.h>',
]);
expect(
objectiveCScopeResolver.resolveImportTarget(
parsed[1]?.targetRaw ?? '',
'src/Caller.m',
new Set(['src/Foundation/Foundation.h']),
),
).toBeNull();
});
it('emits declaration captures so the shared extractor populates localDefs', () => {
const parsed = extractParsedFile(objectiveCProvider, FIXTURE, 'SYModuleCaller.m');
expect(parsed).toBeDefined();
const qualifiedNames = parsed!.localDefs.map((def) => def.qualifiedName);
expect(qualifiedNames).toEqual(
expect.arrayContaining([
'objc:protocol:SYModuleRunnable',
'objc:class:SYBaseCaller',
'objc:class:SYModuleCaller',
'objc:category:SYModuleCaller:Tracing',
objcFunctionQualifiedName('SYModuleCompute', 'internal', 'SYModuleCaller.m'),
]),
);
expect(parsed!.localDefs.some((def) => def.type === 'Method')).toBe(false);
});
it('shares provider-extracted header/implementation defs, not file-static functions', () => {
const headerPath = 'Classes/Foo.h';
const implPath = 'Classes/Foo.m';
const header = extractParsedFile(
objectiveCProvider,
['@interface Foo', '@end', 'int FooShared(void);', ''].join('\n'),
headerPath,
);
const impl = extractParsedFile(
objectiveCProvider,
[
'#import "Foo.h"',
'@implementation Foo',
'@end',
'int FooShared(void) { return 1; }',
'static int hiddenHelper(void) { return 0; }',
'',
].join('\n'),
implPath,
);
expect(header).toBeDefined();
expect(impl).toBeDefined();
const parsedFiles = [header!, impl!];
const indexes = {
moduleScopes: {
byFilePath: new Map(parsedFiles.map((file) => [file.filePath, file.moduleScope])),
},
imports: new Map(),
bindings: new Map(),
bindingAugmentations: new Map(),
} as unknown as ScopeResolutionIndexes;
populateObjectiveCCompilationUnitSiblings(parsedFiles, indexes);
expect(
indexes.bindingAugmentations.get(impl!.moduleScope)?.get('FooShared')?.[0]?.def.filePath,
).toBe(headerPath);
expect(
indexes.bindingAugmentations.get(header!.moduleScope)?.get('hiddenHelper'),
).toBeUndefined();
expect(
impl!.localDefs.some(
(def) =>
def.qualifiedName === objcFunctionQualifiedName('hiddenHelper', 'internal', implPath),
),
).toBe(true);
});
it('extracts first-version Objective-C semantic facts and unresolved evidence', () => {
const facts = collectObjectiveCFacts(parseFixture(), 'SYModuleCaller.m');
expect(facts.containers.map((c) => `${c.kind}:${c.name}`)).toEqual(
expect.arrayContaining([
'protocol:SYModuleRunnable',
'class:SYBaseCaller',
'class:SYModuleCaller',
'extension:SYModuleCaller ()',
'category:SYModuleCaller (Tracing)',
]),
);
expect(
facts.containers.find((c) => c.name === 'SYModuleCaller' && c.kind === 'class'),
).toMatchObject({
superclass: 'SYBaseCaller',
protocols: ['SYModuleRunnable'],
});
expect(
facts.methods.map((m) => ({
kind: m.methodKind,
selector: m.selector,
owner: m.ownerQualifiedName,
})),
).toEqual(
expect.arrayContaining([
{
kind: '-',
selector: 'runTask:completion:',
owner: objcClassQualifiedName('SYModuleCaller'),
},
{
kind: '+',
selector: 'sharedCaller',
owner: objcClassQualifiedName('SYModuleCaller'),
},
{
kind: '-',
selector: 'traceEvent:',
owner: objcCategoryQualifiedName('SYModuleCaller', 'Tracing'),
},
{
kind: '-',
selector: 'runTask:completion:',
owner: 'objc:protocol:SYModuleRunnable',
},
]),
);
expect(facts.members.map((m) => `${m.kind}:${m.name}:${m.declaredType ?? ''}`)).toEqual(
expect.arrayContaining(['property:helper:SYBaseCaller', 'ivar:_base:SYBaseCaller']),
);
expect(facts.functions.map((fn) => fn.name)).toContain('SYModuleCompute');
expect(facts.imports.map((imp) => `${imp.kind}:${imp.targetRaw}`)).toEqual(
expect.arrayContaining([
'import:SYModuleCaller.h',
'include:SYModuleSupport.h',
'module:Foundation',
]),
);
expect(
facts.messages.map((msg) => `${msg.receiverKind}:${msg.receiverText}:${msg.selector}`),
).toEqual(
expect.arrayContaining([
'self:self:traceEvent:',
'super:super:loadData:completion:',
'local:typed:loadData:completion:',
'dynamic:dynamic:loadData:completion:',
'local:runner:runTask:completion:',
]),
);
expect(facts.unresolvedMessages).toEqual(
expect.arrayContaining([
expect.objectContaining({
receiverText: 'dynamic',
selector: 'loadData:completion:',
reason: 'id receiver is dynamic',
}),
]),
);
});
it('emits protocol implementer evidence once per protocol selector', () => {
const classCount = 8;
const protocolQn = objcProtocolQualifiedName('Runnable');
const protocolPath = 'Runnable.h';
const protocol = {
kind: 'protocol' as const,
declarationRole: 'interface' as const,
name: 'Runnable',
qualifiedName: protocolQn,
nodeId: `Protocol:${protocolQn}`,
label: 'Protocol' as const,
filePath: protocolPath,
startLine: 1,
endLine: 3,
protocols: [] as string[],
};
const protocolRunQn = objcMethodQualifiedName(protocolQn, '-', 'run');
const factsList = [
{
providerVersion: '0',
grammarPackage: 'tree-sitter-objc',
grammarVersion: '0',
filePath: protocolPath,
containers: [protocol],
methods: [
{
name: 'run',
selector: 'run',
methodKind: '-' as const,
ownerQualifiedName: protocolQn,
ownerName: 'Runnable',
ownerKind: 'protocol' as const,
qualifiedName: protocolRunQn,
nodeId: `Method:${protocolRunQn}`,
filePath: protocolPath,
startLine: 2,
endLine: 2,
declarationRole: 'implementation' as const,
parameterTypes: [] as string[],
parameterNames: [] as string[],
},
],
members: [],
functions: [],
imports: [],
messages: [],
unresolvedMessages: [],
},
];
for (let i = 0; i < classCount; i++) {
const filePath = `Class${i}.m`;
const classQn = objcClassQualifiedName(`Class${i}`);
const runQn = objcMethodQualifiedName(classQn, '-', 'run');
const tickQn = objcMethodQualifiedName(classQn, '-', 'tick:');
const owner = {
kind: 'class' as const,
declarationRole: 'implementation' as const,
name: `Class${i}`,
qualifiedName: classQn,
nodeId: `Class:${classQn}`,
label: 'Class' as const,
filePath,
startLine: 1,
endLine: 12,
protocols: ['Runnable'],
};
const run = {
name: 'run',
selector: 'run',
methodKind: '-' as const,
ownerQualifiedName: classQn,
ownerName: `Class${i}`,
ownerKind: 'class' as const,
qualifiedName: runQn,
nodeId: `Method:${runQn}`,
filePath,
startLine: 4,
endLine: 4,
declarationRole: 'implementation' as const,
parameterTypes: [] as string[],
parameterNames: [] as string[],
};
const tick = {
name: 'tick:',
selector: 'tick:',
methodKind: '-' as const,
ownerQualifiedName: classQn,
ownerName: `Class${i}`,
ownerKind: 'class' as const,
qualifiedName: tickQn,
nodeId: `Method:${tickQn}`,
filePath,
startLine: 8,
endLine: 10,
declarationRole: 'implementation' as const,
parameterTypes: [] as string[],
parameterNames: [] as string[],
};
factsList.push({
providerVersion: '0',
grammarPackage: 'tree-sitter-objc',
grammarVersion: '0',
filePath,
containers: [owner],
methods: [run, tick],
members: [],
functions: [],
imports: [],
messages: [
{
selector: 'run',
receiverText: 'runner',
receiverKind: 'local',
receiverType: { kind: 'protocol', name: 'Runnable', raw: 'id<Runnable>' },
sourceMethodQualifiedName: tickQn,
sourceMethodId: tick.nodeId,
sourceOwnerQualifiedName: classQn,
sourceOwnerName: `Class${i}`,
sourceMethodKind: '-',
filePath,
startLine: 9,
startCol: 2,
},
],
unresolvedMessages: [],
});
}
const graph = createKnowledgeGraph();
const parsedFiles = factsList.map((facts) => ({
filePath: facts.filePath,
moduleScope: 0,
scopes: [],
parsedImports: [],
localDefs: [],
referenceSites: [],
captureSideChannel: { kind: 'objective-c', facts },
}));
for (const facts of factsList) {
graph.addNode({
id: `File:${facts.filePath}`,
label: 'File',
properties: { filePath: facts.filePath, qualifiedName: facts.filePath },
});
for (const container of facts.containers) {
graph.addNode({
id: container.nodeId,
label: container.label,
properties: { filePath: facts.filePath, qualifiedName: container.qualifiedName },
});
}
for (const method of facts.methods) {
graph.addNode({
id: method.nodeId,
label: 'Method',
properties: { filePath: facts.filePath, qualifiedName: method.qualifiedName },
});
}
}
objectiveCScopeResolver.emitPostResolutionEdges?.(graph, parsedFiles);
let implementerUses = 0;
let receiverUses = 0;
for (const rel of graph.iterRelationships()) {
if (rel.type !== 'USES') continue;
if (String(rel.reason).startsWith('objc-protocol-candidate:')) implementerUses++;
if (String(rel.reason).startsWith('objc-message: protocol receiver candidates:')) {
receiverUses++;
}
}
expect(implementerUses).toBe(classCount);
expect(receiverUses).toBe(classCount);
expect(graph.getNode('CodeElement:objc:protocol-candidates:Runnable:run')).toBeDefined();
});
it('uses owner, selector, and method kind in stable method identities', () => {
const facts = collectObjectiveCFacts(parseFixture(), 'SYModuleCaller.m');
const graph = buildObjectiveCSemanticGraph(facts);
const methodIds = new Set(
graph.nodes.filter((node) => node.label === 'Method').map((node) => node.id),
);
expect(methodIds).toContain(
`Method:${objcMethodQualifiedName(objcClassQualifiedName('SYModuleCaller'), '-', 'runTask:completion:')}`,
);
expect(methodIds).toContain(
`Method:${objcMethodQualifiedName(objcClassQualifiedName('SYModuleCaller'), '+', 'sharedCaller')}`,
);
expect(methodIds).toContain(
`Method:${objcMethodQualifiedName(
objcCategoryQualifiedName('SYModuleCaller', 'Tracing'),
'-',
'traceEvent:',
)}`,
);
expect(methodIds.size).toBeGreaterThan(4);
});
});