mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-04 02:31:36 +00:00
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:
parent
969b4623ca
commit
90ce8f9098
16 changed files with 258 additions and 0 deletions
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
66
gitnexus/src/core/ingestion/swiftui-navigation.ts
Normal file
66
gitnexus/src/core/ingestion/swiftui-navigation.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -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: {},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
7
gitnexus/test/fixtures/swiftui-nav-repo/CameraView.swift
vendored
Normal file
7
gitnexus/test/fixtures/swiftui-nav-repo/CameraView.swift
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import SwiftUI
|
||||
|
||||
struct CameraView: View {
|
||||
var body: some View {
|
||||
Text("CameraView")
|
||||
}
|
||||
}
|
||||
28
gitnexus/test/fixtures/swiftui-nav-repo/ContentView.swift
vendored
Normal file
28
gitnexus/test/fixtures/swiftui-nav-repo/ContentView.swift
vendored
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
7
gitnexus/test/fixtures/swiftui-nav-repo/HomeView.swift
vendored
Normal file
7
gitnexus/test/fixtures/swiftui-nav-repo/HomeView.swift
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import SwiftUI
|
||||
|
||||
struct HomeView: View {
|
||||
var body: some View {
|
||||
Text("HomeView")
|
||||
}
|
||||
}
|
||||
14
gitnexus/test/fixtures/swiftui-nav-repo/MainTabView.swift
vendored
Normal file
14
gitnexus/test/fixtures/swiftui-nav-repo/MainTabView.swift
vendored
Normal 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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
7
gitnexus/test/fixtures/swiftui-nav-repo/ProductDetailView.swift
vendored
Normal file
7
gitnexus/test/fixtures/swiftui-nav-repo/ProductDetailView.swift
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import SwiftUI
|
||||
|
||||
struct ProductDetailView: View {
|
||||
var body: some View {
|
||||
Text("ProductDetailView")
|
||||
}
|
||||
}
|
||||
7
gitnexus/test/fixtures/swiftui-nav-repo/ProfileView.swift
vendored
Normal file
7
gitnexus/test/fixtures/swiftui-nav-repo/ProfileView.swift
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import SwiftUI
|
||||
|
||||
struct ProfileView: View {
|
||||
var body: some View {
|
||||
Text("ProfileView")
|
||||
}
|
||||
}
|
||||
7
gitnexus/test/fixtures/swiftui-nav-repo/SearchView.swift
vendored
Normal file
7
gitnexus/test/fixtures/swiftui-nav-repo/SearchView.swift
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import SwiftUI
|
||||
|
||||
struct SearchView: View {
|
||||
var body: some View {
|
||||
Text("SearchView")
|
||||
}
|
||||
}
|
||||
7
gitnexus/test/fixtures/swiftui-nav-repo/SettingsView.swift
vendored
Normal file
7
gitnexus/test/fixtures/swiftui-nav-repo/SettingsView.swift
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
var body: some View {
|
||||
Text("SettingsView")
|
||||
}
|
||||
}
|
||||
46
gitnexus/test/integration/swiftui-navigation.test.ts
Normal file
46
gitnexus/test/integration/swiftui-navigation.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue