From 90ce8f9098af102f6f80a7c55a156ecd67db0ec3 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 25 Mar 2026 18:52:52 +0100 Subject: [PATCH] feat: detect SwiftUI navigation patterns and create NAVIGATES_TO edges Add regex-based detection for NavigationLink, .sheet, .fullScreenCover, .navigationDestination, and TabView navigation patterns in Swift files. Creates NAVIGATES_TO edges between SwiftUI View structs in the graph. Co-Authored-By: Claude Opus 4.6 (1M context) --- gitnexus-shared/src/lbug/schema-constants.ts | 1 + gitnexus/src/core/ingestion/call-processor.ts | 41 ++++++++++++ .../src/core/ingestion/parsing-processor.ts | 5 ++ gitnexus/src/core/ingestion/pipeline.ts | 6 ++ .../src/core/ingestion/swiftui-navigation.ts | 66 +++++++++++++++++++ .../core/ingestion/workers/parse-worker.ts | 8 +++ gitnexus/src/core/lbug/schema.ts | 1 + .../swiftui-nav-repo/CameraView.swift | 7 ++ .../swiftui-nav-repo/ContentView.swift | 28 ++++++++ .../fixtures/swiftui-nav-repo/HomeView.swift | 7 ++ .../swiftui-nav-repo/MainTabView.swift | 14 ++++ .../swiftui-nav-repo/ProductDetailView.swift | 7 ++ .../swiftui-nav-repo/ProfileView.swift | 7 ++ .../swiftui-nav-repo/SearchView.swift | 7 ++ .../swiftui-nav-repo/SettingsView.swift | 7 ++ .../integration/swiftui-navigation.test.ts | 46 +++++++++++++ 16 files changed, 258 insertions(+) create mode 100644 gitnexus/src/core/ingestion/swiftui-navigation.ts create mode 100644 gitnexus/test/fixtures/swiftui-nav-repo/CameraView.swift create mode 100644 gitnexus/test/fixtures/swiftui-nav-repo/ContentView.swift create mode 100644 gitnexus/test/fixtures/swiftui-nav-repo/HomeView.swift create mode 100644 gitnexus/test/fixtures/swiftui-nav-repo/MainTabView.swift create mode 100644 gitnexus/test/fixtures/swiftui-nav-repo/ProductDetailView.swift create mode 100644 gitnexus/test/fixtures/swiftui-nav-repo/ProfileView.swift create mode 100644 gitnexus/test/fixtures/swiftui-nav-repo/SearchView.swift create mode 100644 gitnexus/test/fixtures/swiftui-nav-repo/SettingsView.swift create mode 100644 gitnexus/test/integration/swiftui-navigation.test.ts diff --git a/gitnexus-shared/src/lbug/schema-constants.ts b/gitnexus-shared/src/lbug/schema-constants.ts index 656ffe552..ca7347c1b 100644 --- a/gitnexus-shared/src/lbug/schema-constants.ts +++ b/gitnexus-shared/src/lbug/schema-constants.ts @@ -67,6 +67,7 @@ export const REL_TYPES = [ 'ENTRY_POINT_OF', 'WRAPS', 'QUERIES', + 'NAVIGATES_TO', // SwiftUI View → View navigation ] as const; export type RelType = (typeof REL_TYPES)[number]; diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index c30d83848..dd0f09e8a 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -40,6 +40,7 @@ import type { TieredCandidates } from './model/resolution-context.js'; import { isLanguageAvailable, loadParser, loadLanguage } from '../tree-sitter/parser-loader.js'; import { getProvider } from './languages/index.js'; import { generateId } from '../../lib/utils.js'; +import type { ExtractedNavigation } from './swiftui-navigation.js'; import { getLanguageFromFilename, SupportedLanguages } from 'gitnexus-shared'; import { isVerboseIngestionEnabled } from './utils/verbose.js'; import { yieldToEventLoop } from './utils/event-loop.js'; @@ -3300,3 +3301,43 @@ export const extractFetchCallsFromFiles = async ( return result; }; + +// ============================================================================ +// SwiftUI Navigation Processing +// ============================================================================ + +export const processSwiftUINavigation = (graph: KnowledgeGraph, navigations: ExtractedNavigation[]): number => { + const structsByName = new Map(); + graph.forEachNode((node) => { + if (node.label === 'Struct' && node.properties.name) { + const name = node.properties.name as string; + let entries = structsByName.get(name); + if (!entries) { entries = []; structsByName.set(name, entries); } + entries.push({ id: node.id, filePath: node.properties.filePath as string }); + } + }); + const seenEdges = new Set(); + let edgesCreated = 0; + for (const nav of navigations) { + let sourceId: string; + if (nav.sourceView) { + const structId = findStructInIndex(structsByName, nav.sourceView, nav.filePath); + sourceId = structId || generateId('File', nav.filePath); + } else { sourceId = generateId('File', nav.filePath); } + const targetId = findStructInIndex(structsByName, nav.targetView); + if (!targetId) continue; + const relId = generateId('NAVIGATES_TO', `${sourceId}->${targetId}:${nav.navigationType}`); + if (seenEdges.has(relId)) continue; + seenEdges.add(relId); + graph.addRelationship({ id: relId, sourceId, targetId, type: 'NAVIGATES_TO', confidence: 0.9, reason: nav.navigationType }); + edgesCreated++; + } + return edgesCreated; +}; + +const findStructInIndex = (index: Map, name: string, preferFilePath?: string): string | undefined => { + const entries = index.get(name); + if (!entries || entries.length === 0) return undefined; + if (preferFilePath) { const sameFile = entries.find(e => e.filePath === preferFilePath); if (sameFile) return sameFile.id; } + return entries[0].id; +}; diff --git a/gitnexus/src/core/ingestion/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index 90186c659..5510f7bc0 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -47,6 +47,7 @@ import type { ExtractedORMQuery, } from './workers/parse-worker.js'; import { getTreeSitterBufferSize, TREE_SITTER_MAX_BUFFER } from './constants.js'; +import type { ExtractedNavigation } from './swiftui-navigation.js'; export type FileProgressCallback = (current: number, total: number, filePath: string) => void; @@ -60,6 +61,7 @@ export interface WorkerExtractedData { decoratorRoutes: ExtractedDecoratorRoute[]; toolDefs: ExtractedToolDef[]; ormQueries: ExtractedORMQuery[]; + navigations: ExtractedNavigation[]; constructorBindings: FileConstructorBindings[]; fileScopeBindings: FileScopeBindings[]; } @@ -94,6 +96,7 @@ const processParsingWithWorkers = async ( decoratorRoutes: [], toolDefs: [], ormQueries: [], + navigations: [], constructorBindings: [], fileScopeBindings: [], }; @@ -118,6 +121,7 @@ const processParsingWithWorkers = async ( const allDecoratorRoutes: ExtractedDecoratorRoute[] = []; const allToolDefs: ExtractedToolDef[] = []; const allORMQueries: ExtractedORMQuery[] = []; + const allNavigations: ExtractedNavigation[] = []; const allConstructorBindings: FileConstructorBindings[] = []; const fileScopeBindingsByFile: FileScopeBindings[] = []; for (const result of chunkResults) { @@ -185,6 +189,7 @@ const processParsingWithWorkers = async ( decoratorRoutes: allDecoratorRoutes, toolDefs: allToolDefs, ormQueries: allORMQueries, + navigations: allNavigations, constructorBindings: allConstructorBindings, fileScopeBindings: fileScopeBindingsByFile, }; diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index e1f226289..adb12d3b9 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -37,6 +37,12 @@ import { type CommunitiesOutput, type ProcessesOutput, } from './pipeline-phases/index.js'; +import { + processImports, + processImportsFromExtracted, + buildImportResolutionContext +} from './import-processor.js'; +import { extractSwiftUINavigations } from './swiftui-navigation.js'; export interface PipelineOptions { /** Skip MRO, community detection, and process extraction for faster test runs. */ diff --git a/gitnexus/src/core/ingestion/swiftui-navigation.ts b/gitnexus/src/core/ingestion/swiftui-navigation.ts new file mode 100644 index 000000000..b817f768f --- /dev/null +++ b/gitnexus/src/core/ingestion/swiftui-navigation.ts @@ -0,0 +1,66 @@ +/** + * SwiftUI Navigation Pattern Detection + * + * Regex-based extraction of NavigationLink, .sheet, .fullScreenCover, + * .navigationDestination, and TabView patterns from Swift source files. + */ + +export type SwiftUINavigationType = 'navigation-link' | 'sheet' | 'full-screen-cover' | 'navigation-destination' | 'tab-view'; + +export interface ExtractedNavigation { + filePath: string; + sourceView?: string; + targetView: string; + navigationType: SwiftUINavigationType; + lineNumber: number; +} + +const NAVIGATION_LINK_RE = /NavigationLink\s*\(\s*(?:destination\s*:\s*)?([A-Z]\w*View\w*)\s*\(/g; +const SHEET_RE = /\.(sheet|fullScreenCover)\s*\([^)]*\)\s*\{[\s\S]*?([A-Z]\w*View\w*)\s*\(/g; +const NAV_DESTINATION_RE = /\.navigationDestination\s*\([^)]*\)\s*\{[\s\S]*?([A-Z]\w*View\w*)\s*\(/g; +const TABVIEW_RE = /TabView\s*(?:\([^)]*\))?\s*\{([\s\S]*?)\n\}/g; +const TABVIEW_CHILD_RE = /([A-Z]\w*View\w*)\s*\(\s*\)/g; + +const findEnclosingSwiftView = (content: string, offset: number): string | undefined => { + const before = content.slice(0, offset); + const structRe = /struct\s+(\w+)\s*(?::\s*[\w,\s]+)?\{/g; + let lastMatch: string | undefined; + let m; + while ((m = structRe.exec(before)) !== null) { lastMatch = m[1]; } + return lastMatch; +}; + +export const extractSwiftUINavigations = (filePath: string, content: string, navigations: ExtractedNavigation[]): void => { + if (!filePath.endsWith('.swift')) return; + let match; + + NAVIGATION_LINK_RE.lastIndex = 0; + while ((match = NAVIGATION_LINK_RE.exec(content)) !== null) { + const lineNumber = content.slice(0, match.index).split('\n').length - 1; + navigations.push({ filePath, sourceView: findEnclosingSwiftView(content, match.index), targetView: match[1], navigationType: 'navigation-link', lineNumber }); + } + + SHEET_RE.lastIndex = 0; + while ((match = SHEET_RE.exec(content)) !== null) { + const lineNumber = content.slice(0, match.index).split('\n').length - 1; + const navType = match[1] === 'fullScreenCover' ? 'full-screen-cover' as const : 'sheet' as const; + navigations.push({ filePath, sourceView: findEnclosingSwiftView(content, match.index), targetView: match[2], navigationType: navType, lineNumber }); + } + + NAV_DESTINATION_RE.lastIndex = 0; + while ((match = NAV_DESTINATION_RE.exec(content)) !== null) { + const lineNumber = content.slice(0, match.index).split('\n').length - 1; + navigations.push({ filePath, sourceView: findEnclosingSwiftView(content, match.index), targetView: match[1], navigationType: 'navigation-destination', lineNumber }); + } + + TABVIEW_RE.lastIndex = 0; + while ((match = TABVIEW_RE.exec(content)) !== null) { + const tabLineNumber = content.slice(0, match.index).split('\n').length - 1; + const sourceView = findEnclosingSwiftView(content, match.index); + let childMatch; + TABVIEW_CHILD_RE.lastIndex = 0; + while ((childMatch = TABVIEW_CHILD_RE.exec(match[1])) !== null) { + navigations.push({ filePath, sourceView, targetView: childMatch[1], navigationType: 'tab-view', lineNumber: tabLineNumber }); + } + } +}; diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 203e027fa..f4dfd0842 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -14,6 +14,7 @@ import Ruby from 'tree-sitter-ruby'; import { createRequire } from 'node:module'; import { SupportedLanguages } from 'gitnexus-shared'; import { getProvider } from '../languages/index.js'; +import { extractSwiftUINavigations, type ExtractedNavigation, type SwiftUINavigationType } from '../swiftui-navigation.js'; import { getTreeSitterBufferSize, TREE_SITTER_MAX_BUFFER } from '../constants.js'; import type { SymbolTableReader } from '../model/symbol-table.js'; import type { ExtractedHeritage } from '../model/heritage-map.js'; @@ -266,6 +267,7 @@ export interface ParseWorkerResult { decoratorRoutes: ExtractedDecoratorRoute[]; toolDefs: ExtractedToolDef[]; ormQueries: ExtractedORMQuery[]; + navigations: ExtractedNavigation[]; constructorBindings: FileConstructorBindings[]; /** All-scope type bindings from TypeEnv for BindingAccumulator (includes function-local). */ fileScopeBindings: FileScopeBindings[]; @@ -709,6 +711,7 @@ const processBatch = ( decoratorRoutes: [], toolDefs: [], ormQueries: [], + navigations: [], constructorBindings: [], fileScopeBindings: [], skippedLanguages: {}, @@ -2246,6 +2249,8 @@ const processFileGroup = ( // Extract ORM queries (Prisma, Supabase) extractORMQueries(file.path, parseContent, result.ormQueries); + // ── SwiftUI Navigation Detection ── + extractSwiftUINavigations(file.path, file.content, result.navigations); // Vue: emit CALLS edges for components used in