mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-02 02:11:29 +00:00
918 lines
34 KiB
TypeScript
918 lines
34 KiB
TypeScript
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
import { execSync } from 'child_process';
|
|
import fs from 'fs';
|
|
import os from 'os';
|
|
import path from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
import type { GraphNode, RelationshipType } from 'gitnexus-shared';
|
|
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
|
|
import { runFullAnalysis } from '../../src/core/run-analyze.js';
|
|
import { LocalBackend } from '../../src/mcp/local/local-backend.js';
|
|
import { classifyObjectiveCFileContent } from '../../src/core/ingestion/languages/objective-c.js';
|
|
import type { PipelineResult } from '../../src/types/pipeline.js';
|
|
|
|
const FIXTURE_DIR = path.resolve(
|
|
path.dirname(fileURLToPath(import.meta.url)),
|
|
'../fixtures/objective-c',
|
|
);
|
|
|
|
function readFixture(name: string): string {
|
|
return fs.readFileSync(path.join(FIXTURE_DIR, name), 'utf8');
|
|
}
|
|
|
|
const HEADER = readFixture('SYModuleCaller.h');
|
|
const IMPL = readFixture('SYModuleCaller.m');
|
|
const MM_IMPL = readFixture('SYModuleBridge.mm');
|
|
const PLAIN_C_HEADER = readFixture('SYModuleSupport.h');
|
|
|
|
const HEADER_V2 = HEADER.replace(
|
|
'- (void)runTask:(NSString *)name completion:(void (^)(BOOL ok))completion;\n@end',
|
|
'- (void)runTask:(NSString *)name completion:(void (^)(BOOL ok))completion;\n- (void)cancelTask;\n@end',
|
|
).replace(
|
|
'- (void)traceEvent:(NSString *)name;\n@end',
|
|
'- (void)traceEvent:(NSString *)name;\n- (void)traceDetail:(NSString *)name level:(NSInteger)level;\n@end',
|
|
);
|
|
|
|
const IMPL_V2 = IMPL.replace(
|
|
'[self traceEvent:name];',
|
|
'[self traceEvent:name];\n [self traceDetail:name level:1];',
|
|
).replace(
|
|
'@implementation SYModuleCaller (Tracing)\n- (void)traceEvent:(NSString *)name {}\n@end',
|
|
'@implementation SYModuleCaller (Tracing)\n- (void)traceEvent:(NSString *)name {}\n- (void)traceDetail:(NSString *)name level:(NSInteger)level {}\n@end',
|
|
);
|
|
|
|
function git(repoRoot: string, command: string): void {
|
|
execSync(command, { cwd: repoRoot, stdio: 'pipe' });
|
|
}
|
|
|
|
function gitCommitAll(repoRoot: string, message: string): void {
|
|
git(repoRoot, 'git -c user.name=test -c user.email=t@t -c commit.gpgsign=false add -A');
|
|
git(
|
|
repoRoot,
|
|
`git -c user.name=test -c user.email=t@t -c commit.gpgsign=false commit -q -m "${message}"`,
|
|
);
|
|
}
|
|
|
|
function writeObjectiveCRepo(repoRoot: string, header = HEADER, impl = IMPL): void {
|
|
fs.writeFileSync(path.join(repoRoot, 'SYModuleCaller.h'), header);
|
|
fs.writeFileSync(path.join(repoRoot, 'SYModuleCaller.m'), impl);
|
|
fs.writeFileSync(path.join(repoRoot, 'SYModuleBridge.mm'), MM_IMPL);
|
|
fs.writeFileSync(path.join(repoRoot, 'SYModuleSupport.h'), PLAIN_C_HEADER);
|
|
}
|
|
|
|
function normalizeRows(rows: unknown): unknown[] {
|
|
if (!Array.isArray(rows)) return [];
|
|
return rows
|
|
.map((row) => {
|
|
const record = row as Record<string, unknown>;
|
|
return {
|
|
uid: record.uid ?? record.id,
|
|
name: record.name,
|
|
filePath: record.filePath,
|
|
kind: record.kind,
|
|
};
|
|
})
|
|
.sort((left, right) =>
|
|
`${left.uid ?? ''}:${left.name ?? ''}:${left.filePath ?? ''}`.localeCompare(
|
|
`${right.uid ?? ''}:${right.name ?? ''}:${right.filePath ?? ''}`,
|
|
),
|
|
);
|
|
}
|
|
|
|
function normalizeBuckets(value: unknown): Record<string, unknown[]> {
|
|
const record = (value ?? {}) as Record<string, unknown>;
|
|
return Object.fromEntries(
|
|
Object.keys(record)
|
|
.sort()
|
|
.map((key) => [key, normalizeRows(record[key])]),
|
|
);
|
|
}
|
|
|
|
function normalizeContext(value: unknown): Record<string, unknown> {
|
|
const record = value as Record<string, unknown>;
|
|
const symbol = (record.symbol ?? {}) as Record<string, unknown>;
|
|
return {
|
|
status: record.status,
|
|
symbol: {
|
|
uid: symbol.uid,
|
|
name: symbol.name,
|
|
kind: symbol.kind,
|
|
filePath: symbol.filePath,
|
|
},
|
|
incoming: normalizeBuckets(record.incoming),
|
|
outgoing: normalizeBuckets(record.outgoing),
|
|
};
|
|
}
|
|
|
|
/** Drop analyze-time `indexedAt` so incremental vs force surfaces can compare graph fields. */
|
|
function withoutVolatileStaleness(value: unknown): unknown {
|
|
if (value === null || typeof value !== 'object' || Array.isArray(value)) return value;
|
|
const record = value as Record<string, unknown>;
|
|
const staleness = record.staleness;
|
|
if (staleness === undefined || typeof staleness !== 'object' || staleness === null) {
|
|
return record;
|
|
}
|
|
const { indexedAt: _indexedAt, ...stableStaleness } = staleness as Record<string, unknown>;
|
|
return { ...record, staleness: stableStaleness };
|
|
}
|
|
|
|
async function readPersistedObjectiveCSurface(repoRoot: string): Promise<Record<string, unknown>> {
|
|
const backend = new LocalBackend();
|
|
try {
|
|
const classContext = await backend.callTool('context', {
|
|
name: 'SYModuleCaller',
|
|
file_path: 'SYModuleCaller.h',
|
|
repo: repoRoot,
|
|
});
|
|
const runTaskContext = await backend.callTool('context', {
|
|
uid: 'Method:objc:method:objc:class:SYModuleCaller:-:runTask:completion:',
|
|
repo: repoRoot,
|
|
});
|
|
const runProtocolContext = await backend.callTool('context', {
|
|
uid: 'Method:objc:method:objc:class:SYModuleCaller:-:runProtocol:',
|
|
repo: repoRoot,
|
|
});
|
|
const candidateEvidenceId = (
|
|
(
|
|
(runProtocolContext as Record<string, unknown>).outgoing as
|
|
| Record<string, unknown[]>
|
|
| undefined
|
|
)?.uses ?? []
|
|
)
|
|
.map((entry) => (entry as Record<string, unknown>).uid)
|
|
.find(
|
|
(uid): uid is string =>
|
|
typeof uid === 'string' && uid.startsWith('CodeElement:objc:protocol-candidates:'),
|
|
);
|
|
if (candidateEvidenceId === undefined) {
|
|
throw new Error('Persisted protocol candidate evidence was not reachable from context');
|
|
}
|
|
const candidateEvidenceContext = await backend.callTool('context', {
|
|
uid: candidateEvidenceId,
|
|
repo: repoRoot,
|
|
});
|
|
const categoryContext = await backend.callTool('context', {
|
|
uid: 'Category:objc:category:SYModuleCaller:Tracing',
|
|
repo: repoRoot,
|
|
});
|
|
const queryResult = (await backend.callTool('query', {
|
|
search_query: 'SYModuleCaller',
|
|
repo: repoRoot,
|
|
limit: 5,
|
|
include_content: false,
|
|
})) as Record<string, unknown>;
|
|
const protocolAndCategoryResult = await backend.callTool('cypher', {
|
|
query:
|
|
"MATCH (n) WHERE labels(n) IN ['Protocol', 'Category'] " +
|
|
'RETURN n.id AS id, labels(n)[0] AS kind ORDER BY kind, id',
|
|
repo: repoRoot,
|
|
});
|
|
const categoryHostResult = await backend.callTool('cypher', {
|
|
query:
|
|
'MATCH (category:Category)-[r:CodeRelation]->(host:Class) ' +
|
|
"WHERE r.type = 'MEMBER_OF' " +
|
|
'RETURN category.id AS category, host.id AS host',
|
|
repo: repoRoot,
|
|
});
|
|
const protocolCandidateResult = await backend.callTool('cypher', {
|
|
query:
|
|
'MATCH (source:Method)-[sourceRel:CodeRelation]->(e:CodeElement)-[candidateRel:CodeRelation]->(candidate:Method) ' +
|
|
"WHERE sourceRel.type = 'USES' AND candidateRel.type = 'USES' " +
|
|
"AND e.id STARTS WITH 'CodeElement:objc:protocol-candidates:' " +
|
|
'RETURN source.id AS sourceId, candidate.id AS candidateId, candidateRel.reason AS reason ' +
|
|
'ORDER BY sourceId, candidateId',
|
|
repo: repoRoot,
|
|
});
|
|
const unresolvedReasonResult = await backend.callTool('cypher', {
|
|
query:
|
|
'MATCH (source:Method)-[r:CodeRelation]->(e:CodeElement) ' +
|
|
"WHERE r.type = 'USES' AND e.id STARTS WITH 'CodeElement:objc:unresolved:' " +
|
|
'RETURN source.id AS sourceId, e.name AS evidence, r.reason AS reason ' +
|
|
'ORDER BY sourceId, evidence',
|
|
repo: repoRoot,
|
|
});
|
|
return {
|
|
classContext: normalizeContext(classContext),
|
|
runTaskContext: normalizeContext(runTaskContext),
|
|
runProtocolContext: normalizeContext(runProtocolContext),
|
|
candidateEvidenceContext: normalizeContext(candidateEvidenceContext),
|
|
categoryContext: normalizeContext(categoryContext),
|
|
queryDefinitions: normalizeRows(queryResult.definitions),
|
|
protocolAndCategoryResult: withoutVolatileStaleness(protocolAndCategoryResult),
|
|
categoryHostResult: withoutVolatileStaleness(categoryHostResult),
|
|
protocolCandidateResult: withoutVolatileStaleness(protocolCandidateResult),
|
|
unresolvedReasonResult: withoutVolatileStaleness(unresolvedReasonResult),
|
|
};
|
|
} finally {
|
|
await backend.disconnect();
|
|
}
|
|
}
|
|
|
|
async function analyzeObjectiveCRepo(
|
|
repoRoot: string,
|
|
options: { force?: boolean } = {},
|
|
): Promise<string[]> {
|
|
const logs: string[] = [];
|
|
await runFullAnalysis(
|
|
repoRoot,
|
|
{
|
|
force: options.force,
|
|
skipAgentsMd: true,
|
|
skipSkills: true,
|
|
workerPoolSize: 1,
|
|
},
|
|
{
|
|
onProgress: () => undefined,
|
|
onLog: (message) => logs.push(message),
|
|
},
|
|
);
|
|
return logs;
|
|
}
|
|
|
|
describe('Objective-C provider integration', () => {
|
|
let repoRoot: string;
|
|
let result: PipelineResult;
|
|
|
|
beforeAll(async () => {
|
|
repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-objc-provider-'));
|
|
fs.writeFileSync(path.join(repoRoot, 'SYModuleCaller.h'), HEADER);
|
|
fs.writeFileSync(path.join(repoRoot, 'SYModuleCaller.m'), IMPL);
|
|
fs.writeFileSync(path.join(repoRoot, 'SYModuleBridge.mm'), MM_IMPL);
|
|
fs.writeFileSync(path.join(repoRoot, 'SYModuleSupport.h'), PLAIN_C_HEADER);
|
|
result = await runPipelineFromRepo(repoRoot, () => undefined, {
|
|
workerPoolSize: 1,
|
|
});
|
|
}, 60000);
|
|
|
|
afterAll(() => {
|
|
fs.rmSync(repoRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
function nodeByQualifiedName(qualifiedName: string): GraphNode | undefined {
|
|
return result.graph.nodes.find((node) => node.properties.qualifiedName === qualifiedName);
|
|
}
|
|
|
|
function expectNode(qualifiedName: string, label: GraphNode['label']): GraphNode {
|
|
const node = nodeByQualifiedName(qualifiedName);
|
|
expect(node, qualifiedName).toBeDefined();
|
|
expect(node?.label).toBe(label);
|
|
if (node === undefined) throw new Error(`Missing expected node ${qualifiedName}`);
|
|
return node;
|
|
}
|
|
|
|
function hasRelationship(
|
|
type: RelationshipType,
|
|
sourceId: string,
|
|
targetId: string,
|
|
reason?: string | RegExp,
|
|
): boolean {
|
|
return result.graph.relationships.some((rel) => {
|
|
if (rel.type !== type || rel.sourceId !== sourceId || rel.targetId !== targetId) return false;
|
|
if (reason === undefined) return true;
|
|
return typeof reason === 'string' ? rel.reason === reason : reason.test(rel.reason);
|
|
});
|
|
}
|
|
|
|
it('classifies a macro-wrapped Objective-C header by declarations after the marker', () => {
|
|
expect(
|
|
classifyObjectiveCFileContent(
|
|
'RCTBridgeModule.h',
|
|
[
|
|
'RCT_EXTERN_C_BEGIN',
|
|
'typedef struct RCTMethodInfo {',
|
|
' const char *const jsName;',
|
|
'} RCTMethodInfo;',
|
|
'RCT_EXTERN_C_END',
|
|
'@protocol RCTBridgeModule <NSObject>',
|
|
'- (void)run;',
|
|
'@end',
|
|
'',
|
|
].join('\n'),
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('indexes Objective-C semantic nodes beyond File nodes', () => {
|
|
expectNode('objc:protocol:SYModuleRunnable', 'Protocol');
|
|
expectNode('objc:class:SYBaseCaller', 'Class');
|
|
expectNode('objc:class:SYModuleCaller', 'Class');
|
|
expectNode('objc:class:SYModuleBridge', 'Class');
|
|
expectNode('objc:category:SYModuleCaller:Tracing', 'Category');
|
|
expectNode('objc:method:objc:class:SYModuleCaller:-:runTask:completion:', 'Method');
|
|
expectNode('objc:method:objc:class:SYModuleBridge:-:bridgeValue:', 'Method');
|
|
expectNode('objc:method:objc:class:SYModuleCaller:+:sharedCaller', 'Method');
|
|
expectNode('objc:method:objc:category:SYModuleCaller:Tracing:-:traceEvent:', 'Method');
|
|
expectNode('objc:property:objc:class:SYModuleCaller:helper', 'Property');
|
|
expectNode('objc:ivar:objc:class:SYModuleCaller:_base', 'Variable');
|
|
expectNode('objc:function:SYModuleSupportAdd', 'Function');
|
|
expectNode('objc:function:static:SYModuleCaller.m:SYModuleCompute', 'Function');
|
|
});
|
|
|
|
it('emits imports, inheritance, protocol, and category host relationships', () => {
|
|
const caller = expectNode('objc:class:SYModuleCaller', 'Class');
|
|
const base = expectNode('objc:class:SYBaseCaller', 'Class');
|
|
const protocol = expectNode('objc:protocol:SYModuleRunnable', 'Protocol');
|
|
const category = expectNode('objc:category:SYModuleCaller:Tracing', 'Category');
|
|
|
|
expect(hasRelationship('EXTENDS', caller.id, base.id)).toBe(true);
|
|
expect(hasRelationship('IMPLEMENTS', caller.id, protocol.id)).toBe(true);
|
|
expect(hasRelationship('MEMBER_OF', category.id, caller.id)).toBe(true);
|
|
|
|
const importNodes = result.graph.nodes.filter((node) => node.label === 'Import');
|
|
expect(importNodes.map((node) => node.properties.targetRaw)).toEqual(
|
|
expect.arrayContaining(['SYModuleCaller.h', 'SYModuleSupport.h', 'Foundation']),
|
|
);
|
|
|
|
const mFile = result.graph.nodes.find(
|
|
(node) => node.label === 'File' && node.properties.filePath === 'SYModuleCaller.m',
|
|
);
|
|
const hFile = result.graph.nodes.find(
|
|
(node) => node.label === 'File' && node.properties.filePath === 'SYModuleCaller.h',
|
|
);
|
|
const supportFile = result.graph.nodes.find(
|
|
(node) => node.label === 'File' && node.properties.filePath === 'SYModuleSupport.h',
|
|
);
|
|
expect(mFile).toBeDefined();
|
|
expect(hFile).toBeDefined();
|
|
expect(supportFile).toBeDefined();
|
|
if (mFile === undefined || hFile === undefined || supportFile === undefined) {
|
|
throw new Error('Missing Objective-C fixture file nodes');
|
|
}
|
|
expect(hasRelationship('IMPORTS', mFile.id, hFile.id)).toBe(true);
|
|
expect(hasRelationship('IMPORTS', mFile.id, supportFile.id)).toBe(true);
|
|
});
|
|
|
|
it('records implementation evidence for merged declarations', () => {
|
|
const caller = expectNode('objc:class:SYModuleCaller', 'Class');
|
|
const runTask = expectNode(
|
|
'objc:method:objc:class:SYModuleCaller:-:runTask:completion:',
|
|
'Method',
|
|
);
|
|
|
|
const implementationEvidence = result.graph.nodes.filter(
|
|
(node) =>
|
|
node.label === 'CodeElement' &&
|
|
node.properties.objectiveCKind === 'implementation-evidence' &&
|
|
node.properties.filePath === 'SYModuleCaller.m',
|
|
);
|
|
expect(implementationEvidence.map((node) => node.properties.targetQualifiedName)).toEqual(
|
|
expect.arrayContaining([
|
|
'objc:class:SYModuleCaller',
|
|
'objc:method:objc:class:SYModuleCaller:-:runTask:completion:',
|
|
]),
|
|
);
|
|
expect(
|
|
implementationEvidence.some((node) =>
|
|
hasRelationship('DECLARES', node.id, caller.id, 'objc: implementation of merged symbol'),
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
implementationEvidence.some((node) =>
|
|
hasRelationship('DECLARES', node.id, runTask.id, 'objc: implementation of merged symbol'),
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('emits conservative Objective-C message-send call edges and unresolved evidence', () => {
|
|
const runTask = expectNode(
|
|
'objc:method:objc:class:SYModuleCaller:-:runTask:completion:',
|
|
'Method',
|
|
);
|
|
const loadData = expectNode(
|
|
'objc:method:objc:class:SYBaseCaller:-:loadData:completion:',
|
|
'Method',
|
|
);
|
|
const traceEvent = expectNode(
|
|
'objc:method:objc:category:SYModuleCaller:Tracing:-:traceEvent:',
|
|
'Method',
|
|
);
|
|
const runProtocol = expectNode(
|
|
'objc:method:objc:class:SYModuleCaller:-:runProtocol:',
|
|
'Method',
|
|
);
|
|
const protocolRun = expectNode(
|
|
'objc:method:objc:protocol:SYModuleRunnable:-:runTask:completion:',
|
|
'Method',
|
|
);
|
|
|
|
expect(
|
|
hasRelationship('CALLS', runTask.id, loadData.id, /objc-message: (super|local) receiver/),
|
|
).toBe(true);
|
|
expect(hasRelationship('CALLS', runTask.id, loadData.id, 'objc-message: self receiver')).toBe(
|
|
true,
|
|
);
|
|
expect(hasRelationship('CALLS', runTask.id, traceEvent.id, 'objc-message: self receiver')).toBe(
|
|
true,
|
|
);
|
|
expect(
|
|
hasRelationship('CALLS', runProtocol.id, protocolRun.id, 'objc-message: protocol receiver'),
|
|
).toBe(true);
|
|
|
|
const unresolved = result.graph.nodes.find(
|
|
(node) =>
|
|
node.label === 'CodeElement' &&
|
|
node.properties.objectiveCKind === 'unresolved-message' &&
|
|
node.properties.receiver === 'dynamic',
|
|
);
|
|
expect(unresolved).toBeDefined();
|
|
if (unresolved === undefined) throw new Error('Missing unresolved dynamic message evidence');
|
|
expect(
|
|
hasRelationship('CALLS', runTask.id, unresolved.id),
|
|
'dynamic id receiver must not become a certain CALLS edge',
|
|
).toBe(false);
|
|
expect(
|
|
hasRelationship(
|
|
'USES',
|
|
runTask.id,
|
|
unresolved.id,
|
|
'objc-message: unresolved: id receiver is dynamic',
|
|
),
|
|
).toBe(true);
|
|
expect(unresolved.properties.name).toContain('unresolved: id receiver is dynamic');
|
|
|
|
const macroUnresolved = result.graph.nodes.find(
|
|
(node) =>
|
|
node.label === 'CodeElement' &&
|
|
node.properties.objectiveCKind === 'unresolved-message' &&
|
|
node.properties.receiver === 'SY_OBJC_RECEIVER(self)',
|
|
);
|
|
expect(macroUnresolved?.properties.name).toContain(
|
|
'unresolved: macro receiver SY_OBJC_RECEIVER is dynamic',
|
|
);
|
|
|
|
const candidates = result.graph.nodes.find(
|
|
(node) =>
|
|
node.label === 'CodeElement' &&
|
|
String(node.properties.qualifiedName).startsWith('objc:protocol-candidates:'),
|
|
);
|
|
expect(candidates).toBeDefined();
|
|
if (candidates === undefined) throw new Error('Missing protocol candidate evidence');
|
|
expect(
|
|
hasRelationship(
|
|
'USES',
|
|
runProtocol.id,
|
|
candidates.id,
|
|
'objc-message: protocol receiver candidates: SYModuleRunnable runTask:completion:',
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
hasRelationship(
|
|
'USES',
|
|
candidates.id,
|
|
runTask.id,
|
|
'objc-protocol-candidate: SYModuleRunnable runTask:completion:',
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
hasRelationship('CALLS', runProtocol.id, runTask.id),
|
|
'candidate implementations must not become certain CALLS edges',
|
|
).toBe(false);
|
|
});
|
|
|
|
it('resolves header member types across files and keeps static C helpers distinct', async () => {
|
|
const crossFileRepo = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-objc-cross-file-'));
|
|
try {
|
|
fs.writeFileSync(
|
|
path.join(crossFileRepo, 'Helper.h'),
|
|
'@interface Helper\n- (void)ping;\n@end\n',
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(crossFileRepo, 'Worker.h'),
|
|
'#import "Helper.h"\n@interface Worker { Helper *_helper; }\n@property Helper *helper;\n- (void)run;\n@end\n',
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(crossFileRepo, 'Worker.m'),
|
|
'#import "Worker.h"\n@implementation Worker\n- (void)run { [self.helper ping]; [_helper ping]; }\n@end\n',
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(crossFileRepo, 'First.m'),
|
|
'static int helper(void) { return 1; }\n',
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(crossFileRepo, 'Second.m'),
|
|
'static int helper(void) { return 2; }\n',
|
|
);
|
|
|
|
const crossFileResult = await runPipelineFromRepo(crossFileRepo, () => undefined, {
|
|
workerPoolSize: 1,
|
|
});
|
|
const run = crossFileResult.graph.nodes.find(
|
|
(node) =>
|
|
node.label === 'Method' &&
|
|
node.properties.qualifiedName === 'objc:method:objc:class:Worker:-:run',
|
|
);
|
|
const ping = crossFileResult.graph.nodes.find(
|
|
(node) =>
|
|
node.label === 'Method' &&
|
|
node.properties.qualifiedName === 'objc:method:objc:class:Helper:-:ping',
|
|
);
|
|
expect(run).toBeDefined();
|
|
expect(ping).toBeDefined();
|
|
if (run === undefined || ping === undefined) {
|
|
throw new Error('Missing cross-file Objective-C method nodes');
|
|
}
|
|
|
|
const memberCalls = crossFileResult.graph.relationships.filter(
|
|
(relationship) =>
|
|
relationship.type === 'CALLS' &&
|
|
relationship.sourceId === run.id &&
|
|
relationship.targetId === ping.id,
|
|
);
|
|
expect(memberCalls).toHaveLength(2);
|
|
|
|
const staticHelpers = crossFileResult.graph.nodes.filter(
|
|
(node) => node.label === 'Function' && node.properties.name === 'helper',
|
|
);
|
|
expect(staticHelpers.map((node) => node.properties.filePath).sort()).toEqual([
|
|
'First.m',
|
|
'Second.m',
|
|
]);
|
|
expect(new Set(staticHelpers.map((node) => node.properties.qualifiedName)).size).toBe(2);
|
|
} finally {
|
|
fs.rmSync(crossFileRepo, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('resolves inherited protocol members and candidates without looping on protocol cycles', async () => {
|
|
const protocolRepo = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-objc-protocols-'));
|
|
try {
|
|
fs.writeFileSync(
|
|
path.join(protocolRepo, 'Protocols.h'),
|
|
'@protocol Parent\n' +
|
|
'- (void)ping;\n' +
|
|
'@end\n' +
|
|
'@protocol Child <Parent>\n' +
|
|
'@end\n' +
|
|
'@protocol CycleA <CycleB>\n' +
|
|
'- (void)cycleA;\n' +
|
|
'@end\n' +
|
|
'@protocol CycleB <CycleA>\n' +
|
|
'- (void)cycleB;\n' +
|
|
'@end\n' +
|
|
'@interface BaseWorker <Child>\n' +
|
|
'@end\n' +
|
|
'@interface ConcreteWorker : BaseWorker\n' +
|
|
'- (void)ping;\n' +
|
|
'@end\n' +
|
|
'@interface Caller\n' +
|
|
'- (void)run:(id<Child>)worker;\n' +
|
|
'- (void)runCycle:(id<CycleA>)worker;\n' +
|
|
'@end\n',
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(protocolRepo, 'Protocols.m'),
|
|
'#import "Protocols.h"\n' +
|
|
'@implementation ConcreteWorker\n' +
|
|
'- (void)ping {}\n' +
|
|
'@end\n' +
|
|
'@implementation Caller\n' +
|
|
'- (void)run:(id<Child>)worker { [worker ping]; }\n' +
|
|
'- (void)runCycle:(id<CycleA>)worker { [worker cycleB]; }\n' +
|
|
'@end\n',
|
|
);
|
|
|
|
const protocolResult = await runPipelineFromRepo(protocolRepo, () => undefined, {
|
|
workerPoolSize: 1,
|
|
});
|
|
const node = (qualifiedName: string): GraphNode => {
|
|
const found = protocolResult.graph.nodes.find(
|
|
(item) => item.properties.qualifiedName === qualifiedName,
|
|
);
|
|
if (found === undefined) throw new Error(`Missing protocol fixture node ${qualifiedName}`);
|
|
return found;
|
|
};
|
|
const has = (type: RelationshipType, sourceId: string, targetId: string): boolean =>
|
|
protocolResult.graph.relationships.some(
|
|
(relationship) =>
|
|
relationship.type === type &&
|
|
relationship.sourceId === sourceId &&
|
|
relationship.targetId === targetId,
|
|
);
|
|
|
|
const caller = node('objc:method:objc:class:Caller:-:run:');
|
|
const parentMethod = node('objc:method:objc:protocol:Parent:-:ping');
|
|
const workerMethod = node('objc:method:objc:class:ConcreteWorker:-:ping');
|
|
const cycleCaller = node('objc:method:objc:class:Caller:-:runCycle:');
|
|
const cycleMethod = node('objc:method:objc:protocol:CycleB:-:cycleB');
|
|
const candidateEvidence = protocolResult.graph.nodes.find(
|
|
(item) =>
|
|
item.label === 'CodeElement' &&
|
|
String(item.properties.qualifiedName).startsWith('objc:protocol-candidates:'),
|
|
);
|
|
|
|
expect(has('CALLS', caller.id, parentMethod.id)).toBe(true);
|
|
expect(has('CALLS', caller.id, workerMethod.id)).toBe(false);
|
|
expect(candidateEvidence).toBeDefined();
|
|
if (candidateEvidence === undefined)
|
|
throw new Error('Missing inherited protocol candidate evidence');
|
|
expect(has('USES', candidateEvidence.id, workerMethod.id)).toBe(true);
|
|
expect(has('CALLS', cycleCaller.id, cycleMethod.id)).toBe(true);
|
|
} finally {
|
|
fs.rmSync(protocolRepo, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('emits category membership only when the host class exists locally', async () => {
|
|
const categoryRepo = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-objc-category-host-'));
|
|
try {
|
|
fs.writeFileSync(
|
|
path.join(categoryRepo, 'Categories.m'),
|
|
'@interface LocalHost\n@end\n' +
|
|
'@interface LocalHost (Tracing)\n@end\n' +
|
|
'@interface UIView (Tracing)\n@end\n',
|
|
);
|
|
|
|
const categoryResult = await runPipelineFromRepo(categoryRepo, () => undefined, {
|
|
workerPoolSize: 1,
|
|
});
|
|
const localHost = categoryResult.graph.nodes.find(
|
|
(node) => node.properties.qualifiedName === 'objc:class:LocalHost',
|
|
);
|
|
const localCategory = categoryResult.graph.nodes.find(
|
|
(node) => node.properties.qualifiedName === 'objc:category:LocalHost:Tracing',
|
|
);
|
|
const sdkCategory = categoryResult.graph.nodes.find(
|
|
(node) => node.properties.qualifiedName === 'objc:category:UIView:Tracing',
|
|
);
|
|
expect(localHost).toBeDefined();
|
|
expect(localCategory).toBeDefined();
|
|
expect(sdkCategory).toBeDefined();
|
|
if (localHost === undefined || localCategory === undefined || sdkCategory === undefined) {
|
|
throw new Error('Missing category host fixture nodes');
|
|
}
|
|
|
|
expect(
|
|
categoryResult.graph.relationships.some(
|
|
(relationship) =>
|
|
relationship.type === 'MEMBER_OF' &&
|
|
relationship.sourceId === localCategory.id &&
|
|
relationship.targetId === localHost.id,
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
categoryResult.graph.relationships.some(
|
|
(relationship) =>
|
|
relationship.type === 'MEMBER_OF' && relationship.sourceId === sdkCategory.id,
|
|
),
|
|
).toBe(false);
|
|
} finally {
|
|
fs.rmSync(categoryRepo, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('keeps category selector collisions as candidate evidence instead of certain calls', async () => {
|
|
const collisionRepo = fs.mkdtempSync(
|
|
path.join(os.tmpdir(), 'gitnexus-objc-category-collision-'),
|
|
);
|
|
try {
|
|
fs.writeFileSync(
|
|
path.join(collisionRepo, 'Collision.m'),
|
|
`@interface CollisionWorker
|
|
- (void)run;
|
|
- (void)refresh;
|
|
@end
|
|
|
|
@interface CollisionWorker (Tracing)
|
|
- (void)refresh;
|
|
- (void)categoryOnly;
|
|
@end
|
|
|
|
@interface CollisionWorker (Metrics)
|
|
- (void)refresh;
|
|
@end
|
|
|
|
@implementation CollisionWorker
|
|
- (void)run {
|
|
[self refresh];
|
|
CollisionWorker *worker = self;
|
|
[worker refresh];
|
|
[self categoryOnly];
|
|
}
|
|
- (void)refresh {}
|
|
@end
|
|
|
|
@implementation CollisionWorker (Tracing)
|
|
- (void)refresh {}
|
|
- (void)categoryOnly {}
|
|
@end
|
|
|
|
@implementation CollisionWorker (Metrics)
|
|
- (void)refresh {}
|
|
@end
|
|
`,
|
|
);
|
|
|
|
const collisionResult = await runPipelineFromRepo(collisionRepo, () => undefined, {
|
|
workerPoolSize: 1,
|
|
});
|
|
const node = (qualifiedName: string): GraphNode => {
|
|
const found = collisionResult.graph.nodes.find(
|
|
(item) => item.properties.qualifiedName === qualifiedName,
|
|
);
|
|
if (found === undefined)
|
|
throw new Error(`Missing category collision node ${qualifiedName}`);
|
|
return found;
|
|
};
|
|
const caller = node('objc:method:objc:class:CollisionWorker:-:run');
|
|
const collisionTargets = [
|
|
node('objc:method:objc:class:CollisionWorker:-:refresh'),
|
|
node('objc:method:objc:category:CollisionWorker:Tracing:-:refresh'),
|
|
node('objc:method:objc:category:CollisionWorker:Metrics:-:refresh'),
|
|
];
|
|
const categoryOnly = node('objc:method:objc:category:CollisionWorker:Tracing:-:categoryOnly');
|
|
const collisionTargetIds = new Set(collisionTargets.map((target) => target.id));
|
|
const certainCollisionCalls = collisionResult.graph.relationships.filter(
|
|
(relationship) =>
|
|
relationship.type === 'CALLS' &&
|
|
relationship.sourceId === caller.id &&
|
|
collisionTargetIds.has(relationship.targetId),
|
|
);
|
|
|
|
expect(certainCollisionCalls).toHaveLength(0);
|
|
|
|
const evidenceNodes = collisionResult.graph.nodes.filter(
|
|
(item) =>
|
|
item.label === 'CodeElement' &&
|
|
String(item.properties.qualifiedName).startsWith('objc:category-dispatch-candidates:'),
|
|
);
|
|
expect(evidenceNodes).toHaveLength(2);
|
|
|
|
for (const evidence of evidenceNodes) {
|
|
expect(
|
|
collisionResult.graph.relationships.some(
|
|
(relationship) =>
|
|
relationship.type === 'USES' &&
|
|
relationship.sourceId === caller.id &&
|
|
relationship.targetId === evidence.id &&
|
|
relationship.confidence === 0.7,
|
|
),
|
|
).toBe(true);
|
|
for (const target of collisionTargets) {
|
|
expect(
|
|
collisionResult.graph.relationships.some(
|
|
(relationship) =>
|
|
relationship.type === 'USES' &&
|
|
relationship.sourceId === evidence.id &&
|
|
relationship.targetId === target.id &&
|
|
relationship.confidence === 0.5,
|
|
),
|
|
).toBe(true);
|
|
}
|
|
}
|
|
|
|
const categoryOnlyCalls = collisionResult.graph.relationships.filter(
|
|
(relationship) =>
|
|
relationship.type === 'CALLS' &&
|
|
relationship.sourceId === caller.id &&
|
|
relationship.targetId === categoryOnly.id,
|
|
);
|
|
expect(categoryOnlyCalls).toHaveLength(1);
|
|
expect(categoryOnlyCalls[0]?.confidence).toBe(0.9);
|
|
} finally {
|
|
fs.rmSync(collisionRepo, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Objective-C provider persisted index behavior', () => {
|
|
it('drops analyze-time indexedAt from hot-tool staleness so increment vs force can compare', () => {
|
|
expect(
|
|
withoutVolatileStaleness({
|
|
markdown: 'ok',
|
|
row_count: 1,
|
|
staleness: {
|
|
status: 'current',
|
|
lastCommit: 'abc',
|
|
indexedAt: '2026-09-15T00:00:00Z',
|
|
measuredAgainst: 'HEAD',
|
|
},
|
|
}),
|
|
).toEqual({
|
|
markdown: 'ok',
|
|
row_count: 1,
|
|
staleness: { status: 'current', lastCommit: 'abc', measuredAgainst: 'HEAD' },
|
|
});
|
|
});
|
|
|
|
it('surfaces query/context semantics and keeps incremental results aligned with force rebuild', async () => {
|
|
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-objc-provider-index-'));
|
|
try {
|
|
writeObjectiveCRepo(repoRoot);
|
|
git(repoRoot, 'git init');
|
|
gitCommitAll(repoRoot, 'initial Objective-C fixture');
|
|
|
|
await analyzeObjectiveCRepo(repoRoot);
|
|
const initialSurface = await readPersistedObjectiveCSurface(repoRoot);
|
|
const reopenedInitialSurface = await readPersistedObjectiveCSurface(repoRoot);
|
|
expect(reopenedInitialSurface).toEqual(initialSurface);
|
|
expect(initialSurface).toMatchObject({
|
|
classContext: {
|
|
status: 'found',
|
|
symbol: {
|
|
uid: 'Class:objc:class:SYModuleCaller',
|
|
kind: 'Class',
|
|
filePath: 'SYModuleCaller.h',
|
|
},
|
|
incoming: {
|
|
declares: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
uid: expect.stringContaining(
|
|
'CodeElement:objc:implementation:objc:class:SYModuleCaller:SYModuleCaller.m:',
|
|
),
|
|
filePath: 'SYModuleCaller.m',
|
|
}),
|
|
]),
|
|
imports: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
uid: 'File:SYModuleCaller.m',
|
|
filePath: 'SYModuleCaller.m',
|
|
}),
|
|
]),
|
|
member_of: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
uid: 'Category:objc:category:SYModuleCaller:Tracing',
|
|
}),
|
|
]),
|
|
},
|
|
},
|
|
categoryContext: {
|
|
outgoing: {
|
|
member_of: expect.arrayContaining([
|
|
expect.objectContaining({ uid: 'Class:objc:class:SYModuleCaller' }),
|
|
]),
|
|
},
|
|
},
|
|
runProtocolContext: {
|
|
outgoing: {
|
|
uses: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
uid: expect.stringContaining('CodeElement:objc:protocol-candidates:'),
|
|
}),
|
|
]),
|
|
},
|
|
},
|
|
candidateEvidenceContext: {
|
|
outgoing: {
|
|
uses: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
uid: 'Method:objc:method:objc:class:SYModuleCaller:-:runTask:completion:',
|
|
}),
|
|
]),
|
|
},
|
|
},
|
|
queryDefinitions: expect.arrayContaining([
|
|
expect.objectContaining({ uid: 'Class:objc:class:SYModuleCaller' }),
|
|
expect.objectContaining({
|
|
uid: expect.stringMatching(/^Method:objc:method:objc:class:SYModuleCaller:/),
|
|
}),
|
|
]),
|
|
protocolAndCategoryResult: expect.objectContaining({
|
|
markdown: expect.stringContaining('Protocol:objc:protocol:SYModuleRunnable'),
|
|
}),
|
|
categoryHostResult: expect.objectContaining({
|
|
markdown: expect.stringContaining('Category:objc:category:SYModuleCaller:Tracing'),
|
|
}),
|
|
protocolCandidateResult: expect.objectContaining({
|
|
markdown: expect.stringContaining(
|
|
'objc-protocol-candidate: SYModuleRunnable runTask:completion:',
|
|
),
|
|
}),
|
|
unresolvedReasonResult: expect.objectContaining({
|
|
markdown: expect.stringContaining('objc-message: unresolved: id receiver is dynamic'),
|
|
}),
|
|
});
|
|
|
|
writeObjectiveCRepo(repoRoot, HEADER_V2, IMPL_V2);
|
|
gitCommitAll(repoRoot, 'change Objective-C declarations and implementations');
|
|
const incrementalLogs = await analyzeObjectiveCRepo(repoRoot);
|
|
expect(incrementalLogs).toContainEqual(expect.stringContaining('Incremental: changed='));
|
|
const incrementalSurface = await readPersistedObjectiveCSurface(repoRoot);
|
|
|
|
await analyzeObjectiveCRepo(repoRoot, { force: true });
|
|
const forceSurface = await readPersistedObjectiveCSurface(repoRoot);
|
|
expect(incrementalSurface).toEqual(forceSurface);
|
|
expect(forceSurface).toMatchObject({
|
|
classContext: {
|
|
outgoing: {
|
|
has_method: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
uid: 'Method:objc:method:objc:category:SYModuleCaller:Tracing:-:traceDetail:level:',
|
|
}),
|
|
]),
|
|
},
|
|
},
|
|
runTaskContext: {
|
|
outgoing: {
|
|
calls: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
uid: 'Method:objc:method:objc:category:SYModuleCaller:Tracing:-:traceDetail:level:',
|
|
}),
|
|
]),
|
|
},
|
|
},
|
|
});
|
|
} finally {
|
|
fs.rmSync(repoRoot, { recursive: true, force: true });
|
|
}
|
|
}, 180000);
|
|
});
|