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; 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 { const record = (value ?? {}) as Record; return Object.fromEntries( Object.keys(record) .sort() .map((key) => [key, normalizeRows(record[key])]), ); } function normalizeContext(value: unknown): Record { const record = value as Record; const symbol = (record.symbol ?? {}) as Record; 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; const staleness = record.staleness; if (staleness === undefined || typeof staleness !== 'object' || staleness === null) { return record; } const { indexedAt: _indexedAt, ...stableStaleness } = staleness as Record; return { ...record, staleness: stableStaleness }; } async function readPersistedObjectiveCSurface(repoRoot: string): Promise> { 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).outgoing as | Record | undefined )?.uses ?? [] ) .map((entry) => (entry as Record).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; 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 { 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 ', '- (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 \n' + '@end\n' + '@protocol CycleA \n' + '- (void)cycleA;\n' + '@end\n' + '@protocol CycleB \n' + '- (void)cycleB;\n' + '@end\n' + '@interface BaseWorker \n' + '@end\n' + '@interface ConcreteWorker : BaseWorker\n' + '- (void)ping;\n' + '@end\n' + '@interface Caller\n' + '- (void)run:(id)worker;\n' + '- (void)runCycle:(id)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)worker { [worker ping]; }\n' + '- (void)runCycle:(id)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); });