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) <noreply@anthropic.com>
This commit is contained in:
Test 2026-03-25 18:52:52 +01:00
parent 969b4623ca
commit 90ce8f9098
16 changed files with 258 additions and 0 deletions

View file

@ -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];

View file

@ -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<string, { id: string; filePath: string }[]>();
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<string>();
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<string, { id: string; filePath: string }[]>, 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;
};

View file

@ -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,
};

View file

@ -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. */

View file

@ -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 });
}
}
};

View file

@ -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 <template>
if (language === SupportedLanguages.Vue) {
@ -2280,6 +2285,7 @@ let accumulated: ParseWorkerResult = {
decoratorRoutes: [],
toolDefs: [],
ormQueries: [],
navigations: [],
constructorBindings: [],
fileScopeBindings: [],
skippedLanguages: {},
@ -2307,6 +2313,7 @@ const mergeResult = (target: ParseWorkerResult, src: ParseWorkerResult) => {
appendAll(target.decoratorRoutes, src.decoratorRoutes);
appendAll(target.toolDefs, src.toolDefs);
appendAll(target.ormQueries, src.ormQueries);
appendAll(target.navigations, src.navigations);
appendAll(target.constructorBindings, src.constructorBindings);
appendAll(target.fileScopeBindings, src.fileScopeBindings);
for (const [lang, count] of Object.entries(src.skippedLanguages)) {
@ -2358,6 +2365,7 @@ parentPort!.on('message', (msg: WorkerIncomingMessage) => {
decoratorRoutes: [],
toolDefs: [],
ormQueries: [],
navigations: [],
constructorBindings: [],
fileScopeBindings: [],
skippedLanguages: {},

View file

@ -338,6 +338,7 @@ CREATE REL TABLE ${REL_TABLE_NAME} (
FROM \`Struct\` TO Interface,
FROM \`Struct\` TO \`Constructor\`,
FROM \`Struct\` TO \`Property\`,
FROM \`Struct\` TO File,
FROM \`Enum\` TO \`Enum\`,
FROM \`Enum\` TO Community,
FROM \`Enum\` TO Class,

View file

@ -0,0 +1,7 @@
import SwiftUI
struct CameraView: View {
var body: some View {
Text("CameraView")
}
}

View file

@ -0,0 +1,28 @@
import SwiftUI
struct ContentView: View {
@State private var showSettings = false
@State private var showCamera = false
var body: some View {
NavigationStack {
List {
NavigationLink(destination: ProductDetailView(product: Product())) {
Text("Product")
}
NavigationLink(destination: ProfileView()) {
Text("Profile")
}
}
.sheet(isPresented: $showSettings) {
SettingsView()
}
.fullScreenCover(isPresented: $showCamera) {
CameraView()
}
.navigationDestination(for: Product.self) { product in
ProductDetailView(product: product)
}
}
}
}

View file

@ -0,0 +1,7 @@
import SwiftUI
struct HomeView: View {
var body: some View {
Text("HomeView")
}
}

View file

@ -0,0 +1,14 @@
import SwiftUI
struct MainTabView: View {
var body: some View {
TabView {
HomeView()
.tabItem { Label("Home", systemImage: "house") }
SearchView()
.tabItem { Label("Search", systemImage: "magnifyingglass") }
ProfileView()
.tabItem { Label("Profile", systemImage: "person") }
}
}
}

View file

@ -0,0 +1,7 @@
import SwiftUI
struct ProductDetailView: View {
var body: some View {
Text("ProductDetailView")
}
}

View file

@ -0,0 +1,7 @@
import SwiftUI
struct ProfileView: View {
var body: some View {
Text("ProfileView")
}
}

View file

@ -0,0 +1,7 @@
import SwiftUI
struct SearchView: View {
var body: some View {
Text("SearchView")
}
}

View file

@ -0,0 +1,7 @@
import SwiftUI
struct SettingsView: View {
var body: some View {
Text("SettingsView")
}
}

View file

@ -0,0 +1,46 @@
import { describe, it, expect, beforeAll } from 'vitest';
import path from 'path';
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
import type { PipelineResult } from '../../src/types/pipeline.js';
const FIXTURE = path.resolve(__dirname, '..', 'fixtures', 'swiftui-nav-repo');
describe('SwiftUI navigation graph', () => {
let result: PipelineResult;
beforeAll(async () => { result = await runPipelineFromRepo(FIXTURE, () => {}); }, 60000);
it('creates Struct nodes for SwiftUI Views', () => {
const structs: string[] = [];
result.graph.forEachNode(n => { if (n.label === 'Struct') structs.push(n.properties.name as string); });
expect(structs).toContain('ContentView');
expect(structs).toContain('ProductDetailView');
expect(structs).toContain('MainTabView');
});
it('creates NAVIGATES_TO edges', () => {
const navEdges: { target: string; reason: string }[] = [];
result.graph.forEachRelationship(r => {
if (r.type === 'NAVIGATES_TO') {
const tgt = result.graph.getNode(r.targetId);
navEdges.push({ target: (tgt?.properties.name ?? tgt?.id) as string, reason: r.reason ?? '' });
}
});
expect(navEdges.length).toBeGreaterThan(0);
expect(navEdges.some(e => e.reason === 'navigation-link' && e.target === 'ProductDetailView')).toBe(true);
expect(navEdges.some(e => e.reason === 'sheet' && e.target === 'SettingsView')).toBe(true);
expect(navEdges.some(e => e.reason === 'full-screen-cover' && e.target === 'CameraView')).toBe(true);
expect(navEdges.some(e => e.reason === 'tab-view' && e.target === 'HomeView')).toBe(true);
});
it('source Views are detected', () => {
const sources: string[] = [];
result.graph.forEachRelationship(r => {
if (r.type === 'NAVIGATES_TO') {
const src = result.graph.getNode(r.sourceId);
if (src?.label === 'Struct') sources.push(src.properties.name as string);
}
});
expect(sources).toContain('ContentView');
expect(sources).toContain('MainTabView');
});
});