diff --git a/README.md b/README.md index 1b77686a3..39b09b7d2 100644 --- a/README.md +++ b/README.md @@ -658,7 +658,7 @@ UPSTREAM (what depends on this): authRouter [IMPORTS] -> src/routes/auth.ts ``` -Options: `maxDepth`, `minConfidence`, `relationTypes` (`CALLS`, `IMPORTS`, `EXTENDS`, `IMPLEMENTS`), `includeTests` +Options: `maxDepth`, `minConfidence`, `relationTypes` (`CALLS`, `IMPORTS`, `EXTENDS`, `IMPLEMENTS`), `includeTests`, `limit` (max symbols per depth, default 100), `offset` (pagination start per depth), `summaryOnly` (counts and risk only, omits symbol list) ### Process-Grouped Search diff --git a/gitnexus-shared/src/scope-resolution/symbol-definition.ts b/gitnexus-shared/src/scope-resolution/symbol-definition.ts index e27605ac4..d1b30abcc 100644 --- a/gitnexus-shared/src/scope-resolution/symbol-definition.ts +++ b/gitnexus-shared/src/scope-resolution/symbol-definition.ts @@ -53,6 +53,10 @@ export interface SymbolDefinition { * `ScopeResolver.constraintCompatibility` hook during overload narrowing. * Absent for symbols that have no constraints (the common case). */ templateConstraints?: unknown; + /** True when the producing language marked this callable as explicit. + * Currently used by C++ overload ranking to exclude explicit constructors + * from implicit user-defined conversion candidates. */ + isExplicit?: boolean; /** Links Method/Constructor/Property to owning Class/Struct/Trait nodeId */ ownerId?: string; } diff --git a/gitnexus-web/e2e/tree-view.spec.ts b/gitnexus-web/e2e/tree-view.spec.ts new file mode 100644 index 000000000..61c964d5b --- /dev/null +++ b/gitnexus-web/e2e/tree-view.spec.ts @@ -0,0 +1,117 @@ +import { test, expect } from '@playwright/test'; + +/** + * E2E tests for graph layout mode switching (Sequential / Radial layouts). + * + * Requires: + * - gitnexus serve running on localhost:4747 with at least one indexed repo + * - gitnexus-web dev server running on localhost:5173 + * + * Skipped when servers aren't available (CI without services, etc.). + * Set E2E=1 to force-run even without the availability check. + */ + +const BACKEND_URL = process.env.BACKEND_URL ?? 'http://localhost:4747'; +const FRONTEND_URL = process.env.FRONTEND_URL ?? 'http://localhost:5173'; + +test.beforeAll(async () => { + if (process.env.E2E) return; + try { + const [backendRes, frontendRes] = await Promise.allSettled([ + fetch(`${BACKEND_URL}/api/repos`), + fetch(FRONTEND_URL), + ]); + if ( + backendRes.status === 'rejected' || + (backendRes.status === 'fulfilled' && !backendRes.value.ok) + ) { + test.skip(true, 'gitnexus serve not available on :4747'); + return; + } + if ( + frontendRes.status === 'rejected' || + (frontendRes.status === 'fulfilled' && !frontendRes.value.ok) + ) { + test.skip(true, 'Vite dev server not available on :5173'); + return; + } + if (backendRes.status === 'fulfilled') { + const repos = await backendRes.value.json(); + if (!repos.length) { + test.skip(true, 'No indexed repos — run gitnexus analyze first'); + return; + } + } + } catch { + test.skip(true, 'servers not available'); + } +}); + +async function waitForGraphLoaded(page: import('@playwright/test').Page) { + await page.goto(`${FRONTEND_URL}?lng=en`); + + // The app starts on the landing/onboarding screen. Pick the first repo card + // (preferring a known repo name) and click it to load the graph. + const landingCards = page.locator('[data-testid="landing-repo-card"]'); + const preferredCard = landingCards.filter({ hasText: /GitNexus|local-integration/ }).first(); + try { + await landingCards.first().waitFor({ state: 'visible', timeout: 15_000 }); + const card = (await preferredCard.count()) > 0 ? preferredCard : landingCards.first(); + await card.click(); + } catch { + // Landing screen may not appear (e.g. when ?server auto-connects) + } + + // Wait until the status bar confirms the graph is ready. + const statusBar = page.getByRole('contentinfo'); + await expect(statusBar.getByText('Ready', { exact: true })).toBeVisible({ timeout: 45_000 }); + await expect(statusBar).toContainText(/nodes/, { timeout: 20_000 }); + + // Finally confirm the sigma canvas is present. + await page.waitForSelector('.sigma-container', { timeout: 10_000 }); +} + +test.describe('Graph Layout Modes', () => { + test.beforeEach(async ({ page }) => { + await waitForGraphLoaded(page); + }); + + test('should switch between force, sequential, and radial layouts', async ({ page }) => { + const forceTab = page.locator('button:has-text("Force Graph")'); + const sequentialTab = page.locator('button:has-text("Sequential Layout")'); + const radialTab = page.locator('button:has-text("Radial Layout")'); + + // Force Graph is the default active tab + await expect(forceTab).toHaveClass(/bg-accent/); + await expect(sequentialTab).not.toHaveClass(/bg-accent/); + + // Switch to Sequential Layout + await sequentialTab.click(); + await expect(sequentialTab).toHaveClass(/bg-accent/, { timeout: 5_000 }); + await expect(forceTab).not.toHaveClass(/bg-accent/); + + // All three layout tabs should be present in the tab bar + await expect(radialTab).toBeVisible(); + + // Switch back to Force Graph + await forceTab.click(); + await expect(forceTab).toHaveClass(/bg-accent/, { timeout: 5_000 }); + await expect(sequentialTab).not.toHaveClass(/bg-accent/); + }); + + test('should interact with nodes in sequential layout', async ({ page }) => { + await page.locator('button:has-text("Sequential Layout")').click(); + + // Click the first file-tree item in the sidebar (more reliable than a + // blind canvas click, which may land on empty space). The FileTreePanel + // renders node names as . + // Clicking any of them calls setSelectedNode, which shows the selection + // bar with the "Clear" button — the same mechanism used in + // server-connect.spec.ts's "Turn Off All Highlights" test. + const firstTreeItem = page.locator('span.truncate.font-mono').first(); + await firstTreeItem.waitFor({ state: 'visible', timeout: 10_000 }); + await firstTreeItem.click(); + + await expect(page.locator('text=Clear')).toBeVisible({ timeout: 5_000 }); + }); +}); diff --git a/gitnexus-web/src/components/FileTreePanel.tsx b/gitnexus-web/src/components/FileTreePanel.tsx index 6bab30c6f..a3c7487a9 100644 --- a/gitnexus-web/src/components/FileTreePanel.tsx +++ b/gitnexus-web/src/components/FileTreePanel.tsx @@ -201,7 +201,10 @@ const getNodeTypeIcon = (label: NodeLabel) => { case 'Import': return FileCode; case 'Variable': + case 'Property': return Variable; + case 'Const': + return Target; default: return Variable; } diff --git a/gitnexus-web/src/components/GraphCanvas.tsx b/gitnexus-web/src/components/GraphCanvas.tsx index cdf00c3bb..d0880dbe1 100644 --- a/gitnexus-web/src/components/GraphCanvas.tsx +++ b/gitnexus-web/src/components/GraphCanvas.tsx @@ -9,11 +9,16 @@ import { Pause, Lightbulb, LightbulbOff, + Network, + GitBranch, + Target, } from '@/lib/lucide-icons'; import { useSigma } from '../hooks/useSigma'; import { useAppState } from '../hooks/useAppState'; import { knowledgeGraphToGraphology, + knowledgeGraphToTreeGraphology, + knowledgeGraphToCirclesGraphology, filterGraphByDepth, SigmaNodeAttributes, SigmaEdgeAttributes, @@ -48,6 +53,8 @@ export const GraphCanvas = forwardRef((_, ref) => { clearAICitationHighlights, clearBlastRadius, animatedNodes, + graphViewMode, + setGraphViewMode, } = useAppState(); const [hoveredNodeName, setHoveredNodeName] = useState(null); @@ -149,8 +156,22 @@ export const GraphCanvas = forwardRef((_, ref) => { blastRadiusNodeIds: effectiveBlastRadiusNodeIds, animatedNodes: effectiveAnimatedNodes, visibleEdgeTypes, + layoutMode: graphViewMode, }); + const handleViewModeChange = useCallback( + (mode: 'force' | 'tree' | 'circles') => { + if (mode === graphViewMode) return; + setSelectedNode(null); + setSigmaSelectedNode(null); + setHoveredNodeName(null); + setGraphViewMode(mode); + // Reset zoom when switching views + resetZoom(); + }, + [graphViewMode, resetZoom, setGraphViewMode, setSelectedNode, setSigmaSelectedNode], + ); + // Expose focusNode to parent via ref useImperativeHandle( ref, @@ -174,25 +195,30 @@ export const GraphCanvas = forwardRef((_, ref) => { useEffect(() => { if (!graph) return; - // Build communityMemberships map from MEMBER_OF relationships - // MEMBER_OF edges: nodeId -> communityId (stored as targetId) - const communityMemberships = new Map(); - graph.relationships.forEach((rel) => { - if (rel.type === 'MEMBER_OF') { - // Find the community node to get its index - const communityNode = nodeById.get(rel.targetId); - if (communityNode && communityNode.label === 'Community') { - // Extract community index from id (e.g., "comm_5" -> 5) - const numericPart = rel.targetId.replace('comm_', ''); - const communityIdx = /^\d+$/.test(numericPart) ? parseInt(numericPart, 10) : 0; - communityMemberships.set(rel.sourceId, communityIdx); - } - } - }); + let sigmaGraph: Graph; + + if (graphViewMode === 'tree') { + sigmaGraph = knowledgeGraphToTreeGraphology(graph); + } else if (graphViewMode === 'circles') { + sigmaGraph = knowledgeGraphToCirclesGraphology(graph); + } else { + // Build community memberships map from MEMBER_OF relationships + const communityMemberships = new Map(); + graph.relationships.forEach((rel) => { + if (rel.type === 'MEMBER_OF') { + const communityNode = nodeById.get(rel.targetId); + if (communityNode && communityNode.label === 'Community') { + const numericPart = rel.targetId.replace('comm_', ''); + const communityIdx = /^\d+$/.test(numericPart) ? parseInt(numericPart, 10) : 0; + communityMemberships.set(rel.sourceId, communityIdx); + } + } + }); + sigmaGraph = knowledgeGraphToGraphology(graph, communityMemberships); + } - const sigmaGraph = knowledgeGraphToGraphology(graph, communityMemberships); setSigmaGraph(sigmaGraph); - }, [graph, nodeById, setSigmaGraph]); + }, [graph, nodeById, setSigmaGraph, graphViewMode]); // Update node visibility when filters change useEffect(() => { @@ -205,7 +231,7 @@ export const GraphCanvas = forwardRef((_, ref) => { filterGraphByDepth(sigmaGraph, appSelectedNode?.id || null, depthFilter, visibleLabels); sigma.refresh(); // eslint-disable-next-line react-hooks/exhaustive-deps -- sigmaRef identity never changes - }, [visibleLabels, depthFilter, appSelectedNode]); + }, [graph, graphViewMode, visibleLabels, depthFilter, appSelectedNode]); // Sync app selected node with sigma useEffect(() => { @@ -245,6 +271,53 @@ export const GraphCanvas = forwardRef((_, ref) => { /> + {/* View Mode Tabs */} +
+ + + +
+ {/* Sigma container */}
{ bgColor: 'bg-rose-500/10', borderColor: 'border-rose-500/30', }; + case 'stopped': + return { + icon: , + color: 'text-amber-300', + bgColor: 'bg-amber-500/10', + borderColor: 'border-amber-500/30', + }; default: return { icon: , diff --git a/gitnexus-web/src/core/llm/agent.ts b/gitnexus-web/src/core/llm/agent.ts index 5153e35fb..4f79d1c95 100644 --- a/gitnexus-web/src/core/llm/agent.ts +++ b/gitnexus-web/src/core/llm/agent.ts @@ -363,8 +363,17 @@ export type AgentMessage = { role: 'user'; content: string } | AgentHistoryMessa export interface AgentRuntimeOptions { /** Capture assistant/tool messages for providers that require exact transcript replay. */ captureHistory?: boolean; + /** When aborted (e.g. user clicked Stop), the stream ends with a `cancelled` chunk. */ + signal?: AbortSignal; } +const isAbortError = (error: unknown, signal?: AbortSignal): boolean => { + if (error instanceof DOMException && error.name === 'AbortError') return true; + if (error instanceof Error && error.name === 'AbortError') return true; + if (signal?.aborted) return true; + return false; +}; + export const buildLangChainMessages = (messages: AgentMessage[]): BaseMessage[] => messages.map((message) => { if (message.role === 'user') { @@ -438,6 +447,7 @@ export async function* streamAgentResponse( streamMode: ['values', 'messages'] as any, // Allow longer tool/reasoning loops (more Cursor-like persistence) recursionLimit: 50, + signal: options.signal, } as any); // Track what we've yielded to avoid duplicates @@ -455,6 +465,10 @@ export async function* streamAgentResponse( let lastStepMessages: any[] | null = null; for await (const event of stream) { + if (options.signal?.aborted) { + break; + } + // Events come as [streamMode, data] tuples when using multiple modes // or just data when using single mode let mode: string; @@ -511,10 +525,11 @@ export async function* streamAgentResponse( // - After all tools are done: treat as final content const isReasoning = !hasSeenToolCallThisTurn || toolCalls.length > 0 || pendingToolCalls > 0; - yield { - type: isReasoning ? 'reasoning' : 'content', - [isReasoning ? 'reasoning' : 'content']: content, - }; + if (isReasoning) { + yield { type: 'reasoning', reasoning: content }; + } else { + yield { type: 'content', content }; + } } // Track tool calls from message chunks @@ -627,6 +642,11 @@ export async function* streamAgentResponse( } } + if (options.signal?.aborted) { + yield { type: 'cancelled' }; + return; + } + // DEBUG: Stream completed normally if (import.meta.env.DEV) { console.log('✅ Stream completed normally, yielding done'); @@ -640,6 +660,10 @@ export async function* streamAgentResponse( : undefined, }; } catch (error) { + if (isAbortError(error, options.signal)) { + yield { type: 'cancelled' }; + return; + } const message = error instanceof Error ? error.message : String(error); // DEBUG: Stream error if (import.meta.env.DEV) { diff --git a/gitnexus-web/src/core/llm/types.ts b/gitnexus-web/src/core/llm/types.ts index c568c7d93..b7727da10 100644 --- a/gitnexus-web/src/core/llm/types.ts +++ b/gitnexus-web/src/core/llm/types.ts @@ -254,7 +254,7 @@ export interface ToolCallInfo { name: string; args: Record; result?: string; - status: 'pending' | 'running' | 'completed' | 'error'; + status: 'pending' | 'running' | 'completed' | 'error' | 'stopped'; } /** @@ -286,22 +286,17 @@ export type AgentHistoryMessage = }; /** - * Streaming chunk from agent - * Now supports step-based streaming where each step is a distinct message + * Streaming chunk from agent (discriminated union). + * Each variant carries only its relevant fields, enabling exhaustive switch handling. */ -export interface AgentStreamChunk { - type: 'reasoning' | 'tool_call' | 'tool_result' | 'content' | 'error' | 'done'; - /** LLM's reasoning/thinking text (shown as a step) */ - reasoning?: string; - /** Final answer content (streamed token by token) */ - content?: string; - /** Hidden raw transcript for reconstructing future agent turns */ - historyMessages?: AgentHistoryMessage[]; - /** Tool call information */ - toolCall?: ToolCallInfo; - /** Error message */ - error?: string; -} +export type AgentStreamChunk = + | { type: 'reasoning'; reasoning: string } + | { type: 'tool_call'; toolCall: ToolCallInfo } + | { type: 'tool_result'; toolCall: ToolCallInfo } + | { type: 'content'; content: string } + | { type: 'error'; error: string } + | { type: 'done'; historyMessages?: AgentHistoryMessage[] } + | { type: 'cancelled' }; /** * A single step in the agent's execution diff --git a/gitnexus-web/src/hooks/app-state/graph.test.tsx b/gitnexus-web/src/hooks/app-state/graph.test.tsx new file mode 100644 index 000000000..12d946ee2 --- /dev/null +++ b/gitnexus-web/src/hooks/app-state/graph.test.tsx @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { GraphStateProvider, useGraphState } from './graph'; + +function wrapper({ children }: { children: React.ReactNode }) { + return {children}; +} + +describe('GraphState', () => { + it('should have default graphViewMode as "force"', () => { + const { result } = renderHook(() => useGraphState(), { wrapper }); + expect(result.current.graphViewMode).toBe('force'); + }); + + it('should toggle graphViewMode', () => { + const { result } = renderHook(() => useGraphState(), { wrapper }); + act(() => { + result.current.setGraphViewMode('tree'); + }); + expect(result.current.graphViewMode).toBe('tree'); + }); +}); diff --git a/gitnexus-web/src/hooks/app-state/graph.tsx b/gitnexus-web/src/hooks/app-state/graph.tsx index 4aa41db01..aa6524a5f 100644 --- a/gitnexus-web/src/hooks/app-state/graph.tsx +++ b/gitnexus-web/src/hooks/app-state/graph.tsx @@ -16,6 +16,8 @@ interface GraphStateContextValue { setDepthFilter: (depth: number | null) => void; highlightedNodeIds: Set; setHighlightedNodeIds: (ids: Set) => void; + graphViewMode: 'force' | 'tree' | 'circles'; + setGraphViewMode: (mode: 'force' | 'tree' | 'circles') => void; } const GraphStateContext = createContext(null); @@ -27,6 +29,7 @@ export const GraphStateProvider = ({ children }: { children: ReactNode }) => { const [visibleEdgeTypes, setVisibleEdgeTypes] = useState(DEFAULT_VISIBLE_EDGES); const [depthFilter, setDepthFilter] = useState(null); const [highlightedNodeIds, setHighlightedNodeIds] = useState>(new Set()); + const [graphViewMode, setGraphViewMode] = useState<'force' | 'tree' | 'circles'>('force'); const toggleLabelVisibility = useCallback((label: NodeLabel) => { setVisibleLabels((prev) => @@ -54,8 +57,18 @@ export const GraphStateProvider = ({ children }: { children: ReactNode }) => { setDepthFilter, highlightedNodeIds, setHighlightedNodeIds, + graphViewMode, + setGraphViewMode, }), - [graph, selectedNode, visibleLabels, visibleEdgeTypes, depthFilter, highlightedNodeIds], + [ + graph, + selectedNode, + visibleLabels, + visibleEdgeTypes, + depthFilter, + highlightedNodeIds, + graphViewMode, + ], ); return {children}; diff --git a/gitnexus-web/src/hooks/useAppState.tsx b/gitnexus-web/src/hooks/useAppState.tsx index 5a7e85457..5c118450b 100644 --- a/gitnexus-web/src/hooks/useAppState.tsx +++ b/gitnexus-web/src/hooks/useAppState.tsx @@ -123,6 +123,10 @@ interface AppState { depthFilter: number | null; setDepthFilter: (depth: number | null) => void; + // Graph view mode + graphViewMode: 'force' | 'tree' | 'circles'; + setGraphViewMode: (mode: 'force' | 'tree' | 'circles') => void; + // Query state highlightedNodeIds: Set; setHighlightedNodeIds: (ids: Set) => void; @@ -232,6 +236,8 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => { setDepthFilter, highlightedNodeIds, setHighlightedNodeIds, + graphViewMode, + setGraphViewMode, } = useGraphState(); // Right Panel @@ -582,6 +588,8 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => { // Agent state — agent runs on main thread now (I/O-bound, not CPU-bound) const agentRef = useRef(null); + const chatAbortRef = useRef(null); + const chatStateRef = useRef<'idle' | 'streaming' | 'aborting'>('idle'); const initializeAgent = useCallback( async (overrideProjectName?: string): Promise => { @@ -640,7 +648,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => { const sendChatMessage = useCallback( async (message: string): Promise => { - if (isChatLoading) return; + if (chatStateRef.current !== 'idle') return; // Refresh Code panel for the new question: keep user-pinned refs, clear old AI citations clearAICodeReferences(); @@ -679,8 +687,13 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => { } setIsChatLoading(true); + chatStateRef.current = 'streaming'; setCurrentToolCalls([]); + chatAbortRef.current?.abort(); + const chatAbortController = new AbortController(); + chatAbortRef.current = chatAbortController; + const providerCapabilities = getProviderCapabilities(llmSettings.activeProvider); // Prepare message history for agent (convert our format to AgentMessage format) @@ -736,11 +749,13 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => { }); }; let pendingUpdate = false; + let rafHandle: number | null = null; const scheduleMessageUpdate = () => { if (pendingUpdate) return; pendingUpdate = true; - requestAnimationFrame(() => { + rafHandle = requestAnimationFrame(() => { pendingUpdate = false; + rafHandle = null; updateMessage(); }); }; @@ -887,7 +902,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => { if (idx < 0) { idx = toolCallsForMessage.findIndex((t) => t.name === tc.name && !t.result); } - if (idx >= 0) { + if (idx >= 0 && toolCallsForMessage[idx].status !== 'stopped') { toolCallsForMessage[idx] = { ...toolCallsForMessage[idx], result: tc.result, @@ -903,7 +918,11 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => { (s.toolCall.id === tc.id || (s.toolCall.name === tc.name && s.toolCall.status === 'running')), ); - if (stepIdx >= 0 && stepsForMessage[stepIdx].toolCall) { + if ( + stepIdx >= 0 && + stepsForMessage[stepIdx].toolCall && + stepsForMessage[stepIdx].toolCall!.status !== 'stopped' + ) { stepsForMessage[stepIdx] = { ...stepsForMessage[stepIdx], toolCall: { @@ -924,6 +943,8 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => { targetIdx = prev.findIndex((t) => t.name === tc.name && !t.result); } if (targetIdx >= 0) { + const target = prev[targetIdx]; + if (target.status === 'stopped') return prev; return prev.map((t, i) => i === targetIdx ? { ...t, result: tc.result, status: 'completed' } : t, ); @@ -1022,13 +1043,24 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => { const { streamAgentResponse } = await import('../core/llm/agent'); for await (const chunk of streamAgentResponse(agent, history, { captureHistory: providerCapabilities.preserveAssistantTranscript, + signal: chatAbortController.signal, })) { + if (chunk.type === 'cancelled') { + break; + } onChunk(chunk); } } catch (error) { - const message = error instanceof Error ? error.message : String(error); - setAgentError(message); + if (!chatAbortController.signal.aborted) { + const message = error instanceof Error ? error.message : String(error); + setAgentError(message); + } } finally { + if (rafHandle != null) { + cancelAnimationFrame(rafHandle); + rafHandle = null; + } + chatStateRef.current = 'idle'; setIsChatLoading(false); setCurrentToolCalls([]); } @@ -1044,22 +1076,56 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => { clearAIToolHighlights, graph, embeddingStatus, - isChatLoading, ], ); const stopChatResponse = useCallback(() => { - if (isChatLoading) { - // Agent streaming will be interrupted by the AbortController in sendChatMessage - setIsChatLoading(false); - setCurrentToolCalls([]); - } - }, [isChatLoading]); + if (!chatAbortRef.current) return; + + chatStateRef.current = 'aborting'; + chatAbortRef.current.abort(); + chatAbortRef.current = null; + + const stoppedLabel = i18n.t('chat:stopped'); + const markStoppedToolCall = (tc: ToolCallInfo): ToolCallInfo => + tc.status === 'running' || tc.status === 'pending' + ? { ...tc, status: 'stopped', result: stoppedLabel } + : tc; + + setCurrentToolCalls((prev) => prev.map(markStoppedToolCall)); + + setChatMessages((prev) => { + const lastAssistantIdx = [...prev] + .map((m, i) => (m.role === 'assistant' ? i : -1)) + .filter((i) => i >= 0) + .pop(); + if (lastAssistantIdx === undefined) return prev; + + const message = prev[lastAssistantIdx]; + + const updated: ChatMessage = { + ...message, + toolCalls: message.toolCalls?.map(markStoppedToolCall), + steps: message.steps?.map((step) => + step.type === 'tool_call' && step.toolCall + ? { ...step, toolCall: markStoppedToolCall(step.toolCall) } + : step, + ), + }; + return prev.map((m, i) => (i === lastAssistantIdx ? updated : m)); + }); + + setIsChatLoading(false); + }, []); const clearChat = useCallback(() => { + chatAbortRef.current?.abort(); + chatAbortRef.current = null; + chatStateRef.current = 'idle'; setChatMessages([]); setCurrentToolCalls([]); setAgentError(null); + setIsChatLoading(false); }, []); // Switch to a different repo on the connected server @@ -1266,6 +1332,8 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => { toggleEdgeVisibility, depthFilter, setDepthFilter, + graphViewMode, + setGraphViewMode, highlightedNodeIds, setHighlightedNodeIds, aiCitationHighlightedNodeIds, diff --git a/gitnexus-web/src/hooks/useSigma.ts b/gitnexus-web/src/hooks/useSigma.ts index 461de0f5a..a2121dad4 100644 --- a/gitnexus-web/src/hooks/useSigma.ts +++ b/gitnexus-web/src/hooks/useSigma.ts @@ -62,6 +62,7 @@ interface UseSigmaOptions { blastRadiusNodeIds?: Set; animatedNodes?: Map; visibleEdgeTypes?: EdgeType[]; + layoutMode?: 'force' | 'tree' | 'circles'; } interface UseSigmaReturn { @@ -128,6 +129,105 @@ const getLayoutDuration = (nodeCount: number): number => { return 20000; // 20s for small graphs }; +const TREE_MAX_X = 540; +const TREE_REPULSION_RANGE = 130; +const TREE_LAYOUT_MAX_DURATION = 18000; +const TREE_LAYOUT_STABILITY_FRAMES = 24; +const TREE_TARGET_FRAME_MS = 32; +const TREE_LAYOUT_MIN_DURATION = 1500; +const TREE_FORCE_DEADZONE = 0.005; +const TREE_VELOCITY_DEADZONE = 0.01; +// Y is free within each layer's band; gravity + boundary resistance keep layers separate. +// Band half kept at 55px so nodes don't drift far past the initial camera-fit viewport. +const TREE_LAYER_GRAVITY = 0.06; // stronger gravity keeps nodes near their layer center +const TREE_LAYER_BAND_HALF = 55; // ±55px from layer center Y +const TREE_LAYER_BOUNDARY_RESISTANCE = 10; // progressive resistance near band edges +// Spread force: fine-tune density within each layer during physics. +// Kept deliberately weak (0.003) because the initial proportional layout already +// distributes nodes near their ideal positions — aggressive spread would fight +// the hierarchy springs and push edge-parented children away from their parents. +const TREE_SPREAD_STRENGTH = 0.003; + +// --------------------------------------------------------------------------- +// Circles View constants +// --------------------------------------------------------------------------- + +/** Target radius for each ring — must match CIRCLES_RING_RADII in circles-layout.ts */ +const CIRCLES_RING_RADII = [90, 240, 420, 620] as const; +const CIRCLES_RING_COUNT = CIRCLES_RING_RADII.length; + +/** + * Half-width of the allowed radial band. Must match CIRCLES_BAND_HALF in + * circles-layout.ts. Keep it small enough that adjacent ring bands never + * overlap: current ring gaps are 150/180/200 px, so 45 px leaves 60-110 px + * of clear air between rings. + * + * Nodes distribute within this band driven by repulsion (outward) and + * soft-wall gravity (inward, growing cubically near the edge). + * No hard clamp — nodes float freely inside the band. + */ +const CIRCLES_BAND_HALF = 45; + +/** + * Base radial gravity rate. Effective gravity grows cubically near the band + * edge via CIRCLES_RADIAL_BOUNDARY_RESISTANCE: + * + * rOffset = 0 px → k = k_base × 1 (almost no pull) + * rOffset = 22 px → k ≈ k_base × 4.2 (moderate) + * rOffset = 40 px → k ≈ k_base × 16 (strong) + * rOffset = 45 px → k ≈ k_base × 21 (very strong — prevents crossing) + */ +const CIRCLES_RADIAL_GRAVITY = 0.06; + +/** + * Cubic-growth multiplier near the band edge. + * Effective k = CIRCLES_RADIAL_GRAVITY × (1 + normR³ × this). + */ +const CIRCLES_RADIAL_BOUNDARY_RESISTANCE = 22; + +/** + * Angular spread force: kept very weak — edge springs are the primary + * mechanism for angular positioning. A too-strong spread competes with + * springs and keeps connected nodes far apart. + */ +const CIRCLES_ANGULAR_SPREAD = 0.002; + +/** Repulsion range — same as tree view so nodes from dense rings don't clump. */ +const CIRCLES_REPULSION_RANGE = 130; + +const CIRCLES_LAYOUT_MAX_DURATION = 24000; +const CIRCLES_LAYOUT_STABILITY_FRAMES = 24; +const CIRCLES_LAYOUT_MIN_DURATION = 1500; +const CIRCLES_FORCE_DEADZONE = 0.005; +const CIRCLES_VELOCITY_DEADZONE = 0.01; + +const CIRCLES_EDGE_WEIGHTS: Record = { + // Hierarchy edges: moderate — angular alignment without fighting radial gravity + // (rest length is now set to ring-gap distance, not zero). + CONTAINS: 0.18, + DEFINES: 0.22, + // Cross edges: stronger so same-ring connected nodes cluster angularly. + IMPORTS: 0.2, + CALLS: 0.24, + EXTENDS: 0.2, + IMPLEMENTS: 0.2, +}; + +// --------------------------------------------------------------------------- + +const TREE_EDGE_WEIGHTS: Record = { + CONTAINS: 0.09, + DEFINES: 0.12, + IMPORTS: 0.14, + CALLS: 0.18, + EXTENDS: 0.13, + IMPLEMENTS: 0.13, +}; + +const clamp = (value: number, min: number, max: number): number => { + return Math.min(max, Math.max(min, value)); +}; + export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => { const containerRef = useRef(null); const sigmaRef = useRef(null); @@ -138,8 +238,33 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => { const blastRadiusRef = useRef>(new Set()); const animatedNodesRef = useRef>(new Map()); const visibleEdgeTypesRef = useRef(null); + + // Keep callback refs fresh so the one-time sigma event handlers always + // call the latest version (avoids stale-closure bugs when graph loads). + const onNodeClickRef = useRef(options.onNodeClick); + const onNodeHoverRef = useRef(options.onNodeHover); + const onStageClickRef = useRef(options.onStageClick); + onNodeClickRef.current = options.onNodeClick; + onNodeHoverRef.current = options.onNodeHover; + onStageClickRef.current = options.onStageClick; const layoutTimeoutRef = useRef | null>(null); - const animationFrameRef = useRef(null); + const effectsAnimationFrameRef = useRef(null); + const treeLayoutFrameRef = useRef(null); + const treeVelocityRef = useRef>(new Map()); // vx per node + const treeVelocityYRef = useRef>(new Map()); // vy per node + const treeLastTickRef = useRef(null); + const treeAccumulatorRef = useRef(0); + const treeLayoutStartRef = useRef(null); + const treeStableFramesRef = useRef(0); + + // Circles layout state (mirrors tree layout state) + const circlesLayoutFrameRef = useRef(null); + const circlesVelocityXRef = useRef>(new Map()); + const circlesVelocityYRef = useRef>(new Map()); + const circlesLastTickRef = useRef(null); + const circlesAccumulatorRef = useRef(0); + const circlesLayoutStartRef = useRef(null); + const circlesStableFramesRef = useRef(0); const [isLayoutRunning, setIsLayoutRunning] = useState(false); const [selectedNode, setSelectedNodeState] = useState(null); @@ -159,24 +284,24 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => { // Animation loop for node effects useEffect(() => { if (!options.animatedNodes || options.animatedNodes.size === 0) { - if (animationFrameRef.current) { - cancelAnimationFrame(animationFrameRef.current); - animationFrameRef.current = null; + if (effectsAnimationFrameRef.current) { + cancelAnimationFrame(effectsAnimationFrameRef.current); + effectsAnimationFrameRef.current = null; } return; } const animate = () => { sigmaRef.current?.refresh(); - animationFrameRef.current = requestAnimationFrame(animate); + effectsAnimationFrameRef.current = requestAnimationFrame(animate); }; animate(); return () => { - if (animationFrameRef.current) { - cancelAnimationFrame(animationFrameRef.current); - animationFrameRef.current = null; + if (effectsAnimationFrameRef.current) { + cancelAnimationFrame(effectsAnimationFrameRef.current); + effectsAnimationFrameRef.current = null; } }; }, [options.animatedNodes]); @@ -197,6 +322,74 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => { sigma.refresh(); }, []); + const stopTreeLayout = useCallback((refresh: boolean = false) => { + if (treeLayoutFrameRef.current) { + cancelAnimationFrame(treeLayoutFrameRef.current); + treeLayoutFrameRef.current = null; + } + treeLastTickRef.current = null; + treeAccumulatorRef.current = 0; + treeLayoutStartRef.current = null; + treeStableFramesRef.current = 0; + treeVelocityRef.current.clear(); + treeVelocityYRef.current.clear(); + setIsLayoutRunning(false); + + if (refresh) { + sigmaRef.current?.refresh(); + // Re-fit camera to the actual settled positions — nodes may have drifted + // from their initial anchors during simulation (especially small/leaf nodes). + sigmaRef.current?.getCamera().animatedReset({ duration: 600 }); + } + }, []); + + const stopCirclesLayout = useCallback((refresh: boolean = false) => { + if (circlesLayoutFrameRef.current) { + cancelAnimationFrame(circlesLayoutFrameRef.current); + circlesLayoutFrameRef.current = null; + } + circlesLastTickRef.current = null; + circlesAccumulatorRef.current = 0; + circlesLayoutStartRef.current = null; + circlesStableFramesRef.current = 0; + circlesVelocityXRef.current.clear(); + circlesVelocityYRef.current.clear(); + setIsLayoutRunning(false); + + if (refresh) { + sigmaRef.current?.refresh(); + sigmaRef.current?.getCamera().animatedReset({ duration: 600 }); + } + }, []); + + const stopAllLayouts = useCallback( + (refresh: boolean = false) => { + if (layoutTimeoutRef.current) { + clearTimeout(layoutTimeoutRef.current); + layoutTimeoutRef.current = null; + } + + if (layoutRef.current) { + layoutRef.current.stop(); + layoutRef.current.kill(); + layoutRef.current = null; + + const graph = graphRef.current; + if (graph && options.layoutMode !== 'tree' && options.layoutMode !== 'circles') { + noverlap.assign(graph, NOVERLAP_SETTINGS); + } + } + + stopTreeLayout(false); + stopCirclesLayout(false); + + if (refresh) { + sigmaRef.current?.refresh(); + } + }, + [options.layoutMode, stopTreeLayout, stopCirclesLayout], + ); + // Initialize Sigma ONCE useEffect(() => { if (!containerRef.current) return; @@ -393,15 +586,38 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => { edgeReducer: (edge, data) => { const res = { ...data }; - // Check edge type visibility first + // Check edge type visibility first. + // HAS_METHOD / HAS_PROPERTY are Kotlin/Java hierarchy edges not in the + // EdgeType union — normalize them so they follow DEFINES / CONTAINS + // visibility instead of being silently hidden. const visibleTypes = visibleEdgeTypesRef.current; if (visibleTypes && data.relationType) { - if (!visibleTypes.includes(data.relationType as EdgeType)) { + const normalizedType = + data.relationType === 'HAS_METHOD' + ? 'DEFINES' + : data.relationType === 'HAS_PROPERTY' + ? 'CONTAINS' + : data.relationType; + if (!visibleTypes.includes(normalizedType as EdgeType)) { res.hidden = true; return res; } } + // Tree view: hierarchy edges are subtle, cross-cutting edges are more visible + const isHierarchyEdge = (data as any).isHierarchyEdge; + if (isHierarchyEdge !== undefined) { + if (isHierarchyEdge) { + // Subtle hierarchy edges in tree view + res.color = dimColor(data.color, 0.5); + res.size = Math.max(0.3, (data.size || 1) * 0.5); + } else { + // Cross-cutting edges are more visible + res.color = brightenColor(data.color, 1.2); + res.size = Math.max(1, (data.size || 1) * 1.2); + } + } + const currentSelected = selectedNodeRef.current; const highlighted = highlightedRef.current; const blastRadius = blastRadiusRef.current; @@ -467,29 +683,41 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => { sigma.on('clickNode', ({ node }) => { setSelectedNode(node); - options.onNodeClick?.(node); + onNodeClickRef.current?.(node); }); sigma.on('clickStage', () => { setSelectedNode(null); - options.onStageClick?.(); + onStageClickRef.current?.(); }); sigma.on('enterNode', ({ node }) => { - options.onNodeHover?.(node); + onNodeHoverRef.current?.(node); if (containerRef.current) { containerRef.current.style.cursor = 'pointer'; } }); sigma.on('leaveNode', () => { - options.onNodeHover?.(null); + onNodeHoverRef.current?.(null); if (containerRef.current) { containerRef.current.style.cursor = 'grab'; } }); return () => { + if (treeLayoutFrameRef.current) { + cancelAnimationFrame(treeLayoutFrameRef.current); + treeLayoutFrameRef.current = null; + } + treeVelocityRef.current.clear(); + treeVelocityYRef.current.clear(); + if (circlesLayoutFrameRef.current) { + cancelAnimationFrame(circlesLayoutFrameRef.current); + circlesLayoutFrameRef.current = null; + } + circlesVelocityXRef.current.clear(); + circlesVelocityYRef.current.clear(); if (layoutTimeoutRef.current) { clearTimeout(layoutTimeoutRef.current); } @@ -500,70 +728,779 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => { }; }, []); - // Run ForceAtlas2 layout - const runLayout = useCallback((graph: Graph) => { - const nodeCount = graph.order; - if (nodeCount === 0) return; + const runTreeLayout = useCallback( + (graph: Graph) => { + if (graph.order === 0) return; - // Kill existing - if (layoutRef.current) { - layoutRef.current.kill(); - layoutRef.current = null; - } - if (layoutTimeoutRef.current) { - clearTimeout(layoutTimeoutRef.current); - layoutTimeoutRef.current = null; - } + stopAllLayouts(false); - // Get settings - const inferredSettings = forceAtlas2.inferSettings(graph); - const customSettings = getFA2Settings(nodeCount); - const settings = { ...inferredSettings, ...customSettings }; + // Compute each layer's Y center from initial anchor positions + const layerYSum = new Map(); + const layerYCount = new Map(); - const layout = new FA2Layout(graph, { settings }); + graph.forEachNode((nodeId, attrs) => { + const layer = attrs.treeLayer ?? 0; + const ay = attrs.treeAnchorY ?? attrs.y; + layerYSum.set(layer, (layerYSum.get(layer) ?? 0) + ay); + layerYCount.set(layer, (layerYCount.get(layer) ?? 0) + 1); + treeVelocityRef.current.set(nodeId, 0); + treeVelocityYRef.current.set(nodeId, 0); + graph.setNodeAttribute(nodeId, 'x', attrs.treeAnchorX ?? attrs.x); + graph.setNodeAttribute(nodeId, 'y', ay); + }); - layoutRef.current = layout; - layout.start(); - setIsLayoutRunning(true); + const layerCenterY = new Map(); + for (const [layer, sum] of layerYSum) { + layerCenterY.set(layer, sum / (layerYCount.get(layer) ?? 1)); + } - const duration = getLayoutDuration(nodeCount); + // Compute each node's preferred Y position within its layer band. + // + // A node in Layer L that connects upward (to Layer L-1, which has higher Y) + // should sit near the TOP of the band — it shortens those vertical edges. + // A node connecting only downward (to Layer L+1) should sit at the BOTTOM. + // A node that connects in both directions, or only within its own layer, + // goes to the center — freeing the edges of the band for directional nodes. + // + // bias ∈ [-1, +1]: +1 = top of band (higher Y, toward layer above), + // -1 = bottom of band (lower Y, toward layer below), + // 0 = layer center. + const nodeYBias = new Map(); + graph.forEachNode((nodeId, attrs) => { + const layer = attrs.treeLayer ?? 0; + let aboveCount = 0; + let belowCount = 0; + graph.forEachNeighbor(nodeId, (_, nAttrs) => { + const nLayer = nAttrs.treeLayer ?? 0; + if (nLayer < layer) aboveCount++; + if (nLayer > layer) belowCount++; + }); + // Weighted ratio: (above − below) / total, scaled to ±0.55 of band half. + const total = aboveCount + belowCount; + nodeYBias.set(nodeId, total > 0 ? ((aboveCount - belowCount) / total) * 0.55 : 0); + }); - layoutTimeoutRef.current = setTimeout(() => { - if (layoutRef.current) { - layoutRef.current.stop(); - layoutRef.current = null; + // Pre-position nodes at their preferred Y to reduce physics convergence time. + graph.forEachNode((nodeId, attrs) => { + const layer = attrs.treeLayer ?? 0; + const cy = layerCenterY.get(layer) ?? attrs.y; + const bias = nodeYBias.get(nodeId) ?? 0; + graph.setNodeAttribute(nodeId, 'y', cy + bias * TREE_LAYER_BAND_HALF * 0.6); + }); + + setIsLayoutRunning(true); + + // Adaptive tuning — mirrors the circles layout strategy. + // The repulsion pass is O(N × k) after sorting; for large graphs k + // can be thousands, making each frame multi-hundred ms → apparent freeze. + const treeNodeCount = graph.order; + const treeIsLarge = treeNodeCount > 5000; + const treeIsMedium = treeNodeCount > 1500; + const treeUseRepulsion = !treeIsLarge; // skip O(N×k) repulsion for large graphs + const treeUseSpread = !treeIsLarge; // skip O(N log N) spread sort for large graphs + const treeDamping = treeIsLarge ? 0.58 : 0.62; + const treeVelocityCapX = treeIsLarge ? 12 : treeIsMedium ? 6 : 3; + const treeVelocityCapY = treeIsLarge ? 6 : treeIsMedium ? 3 : 2; + const treeMaxSimSteps = treeIsLarge ? 1 : 2; + const treeEffectiveMaxDuration = treeIsLarge + ? 30000 + : treeIsMedium + ? 24000 + : TREE_LAYOUT_MAX_DURATION; + const treeStopMaxVelocity = treeIsLarge ? 0.05 : 0.022; + const treeStopAvgVelocity = treeIsLarge ? 0.03 : 0.016; + const treeStopActiveNodeFraction = treeIsLarge ? 0.02 : 0.008; + const treeStopStabilityFrames = treeIsLarge ? 20 : TREE_LAYOUT_STABILITY_FRAMES; + + const step = (timestamp: number) => { + if (!graphRef.current || graphRef.current !== graph) { + stopTreeLayout(false); + return; + } + + if (treeLayoutStartRef.current === null) { + treeLayoutStartRef.current = timestamp; + } + + const frameDelta = + treeLastTickRef.current === null + ? TREE_TARGET_FRAME_MS + : clamp(timestamp - treeLastTickRef.current, 8, 64); + treeLastTickRef.current = timestamp; + treeAccumulatorRef.current = Math.min( + TREE_TARGET_FRAME_MS * 3, + treeAccumulatorRef.current + frameDelta, + ); + + if (treeAccumulatorRef.current < TREE_TARGET_FRAME_MS) { + treeLayoutFrameRef.current = requestAnimationFrame(step); + return; + } + + const simulationSteps = Math.min( + treeMaxSimSteps, + Math.floor(treeAccumulatorRef.current / TREE_TARGET_FRAME_MS), + ); + treeAccumulatorRef.current -= simulationSteps * TREE_TARGET_FRAME_MS; + const dtScale = 0.6; + + // --- Apply forces: velocity integration with boundary resistance --- + // Forces are recomputed from current node positions each sub-step so that + // slow frames (simulationSteps > 1) integrate correctly and don't double-apply. + let totalVelocity = 0; + let maxVelocity = 0; + let activeNodes = 0; + + for (let simulationStep = 0; simulationStep < simulationSteps; simulationStep++) { + // --- Accumulate forces (recomputed each sub-step from current positions) --- + const forceX = new Map(); + const forceY = new Map(); + + // 1. Layer gravity: soft pull toward each node's preferred Y within its band. + // Directional nodes (above-only or below-only connections) are pulled to the + // top or bottom of the band; bidirectional / same-layer-only nodes go to + // the center. This leaves band edges free for nodes that actually use them. + graph.forEachNode((nodeId, attrs) => { + const layer = attrs.treeLayer ?? 0; + const centerY = layerCenterY.get(layer) ?? attrs.y; + const bias = nodeYBias.get(nodeId) ?? 0; + const targetY = centerY + bias * TREE_LAYER_BAND_HALF; + forceX.set(nodeId, 0); + forceY.set(nodeId, (targetY - attrs.y) * TREE_LAYER_GRAVITY * dtScale); + }); + + // 2. Edge springs — X and Y handled separately. + // + // Root cause of long horizontal edges: the previous 2D spring projected + // force through (dx/distance, dy/distance). When the Y layer gap + // dominates (|dy|≈200, |dx|≈30) the X component shrinks to ~15% of + // the total spring force, too weak to overcome sibling repulsion. + // + // Fix: compute X spring from |dx| alone. This keeps full strength + // regardless of how far apart two nodes are in Y. + graph.forEachEdge((edge, edgeAttrs, source, target, sourceAttrs, targetAttrs) => { + const dx = targetAttrs.x - sourceAttrs.x; + const rawWeight = TREE_EDGE_WEIGHTS[edgeAttrs.relationType] ?? 0.18; + + // 2a. Pure X spring. + // Hierarchy edges: zero rest length so children want to sit directly + // under their parent (repulsion then spreads siblings out naturally). + // Cross edges: 60 px rest so far-spanning CALLS/IMPORTS edges only + // pull when really stretched, and their weight is capped so they + // don't override the hierarchy structure. + const xRestLength = edgeAttrs.isHierarchyEdge ? 0 : 60; + const xStretch = Math.abs(dx) - xRestLength; + if (xStretch > 0) { + const xWeight = edgeAttrs.isHierarchyEdge ? rawWeight : Math.min(rawWeight, 0.1); + const fxX = Math.sign(dx) * xStretch * xWeight * 0.3 * dtScale; + forceX.set(source, (forceX.get(source) ?? 0) + fxX); + forceX.set(target, (forceX.get(target) ?? 0) - fxX); + } + + // 2b. Weak Y spring — layer gravity handles most vertical placement; + // this just prevents extreme cross-layer stretching. + const dy = targetAttrs.y - sourceAttrs.y; + const distance = Math.sqrt(dx * dx + dy * dy) || 1; + const layerGap = Math.abs((targetAttrs.treeLayer ?? 0) - (sourceAttrs.treeLayer ?? 0)); + const yRestLength = + (edgeAttrs.isHierarchyEdge ? 70 : 95) + + layerGap * (edgeAttrs.isHierarchyEdge ? 28 : 36); + const yStretch = distance - yRestLength; + if (yStretch > 0) { + const fy = (dy / distance) * yStretch * rawWeight * 0.008 * dtScale; + forceY.set(source, (forceY.get(source) ?? 0) + fy); + forceY.set(target, (forceY.get(target) ?? 0) - fy); + } + }); + + // 3. Node repulsion in 2D: all pairs within range (cross-layer included) + // Sort by X for O(n·k) early-exit: once dx > range, all further pairs are too far. + // + // Skipped for large graphs (N > 5 000) — sorting + pair comparisons make each + // frame take hundreds of ms, leaving the canvas apparently frozen. Layer gravity + // and edge springs provide sufficient structure without repulsion. + if (treeUseRepulsion) { + const nodeList = graph.nodes().map((id) => { + const a = graph.getNodeAttributes(id); + return { id, x: a.x, y: a.y, size: a.size ?? 6, layer: a.treeLayer ?? 0 }; + }); + nodeList.sort((a, b) => a.x - b.x); + + for (let i = 0; i < nodeList.length; i++) { + const nodeA = nodeList[i]; + for (let j = i + 1; j < nodeList.length; j++) { + const nodeB = nodeList[j]; + const dx = nodeB.x - nodeA.x; + if (dx > TREE_REPULSION_RANGE) break; // X-sorted: all further pairs are also too far + + const dy = nodeB.y - nodeA.y; + const dist = Math.sqrt(dx * dx + dy * dy) || 1; + if (dist > TREE_REPULSION_RANGE) continue; + + const sameLayer = nodeA.layer === nodeB.layer; + // Same-layer repulsion reduced from 160→100 so the stronger X spring + // (0.30) can now overcome collective repulsion from 3-4 nearby nodes. + // Cross-layer kept low (28) so intermediate-layer nodes don't block + // parent-child X alignment. + const repulsionStrength = sameLayer ? 100 : 28; + const minGap = Math.max(28, (nodeA.size + nodeB.size) * 1.8); + let repulsion = + (1 / (dist + 8) - 1 / (TREE_REPULSION_RANGE + 8)) * repulsionStrength * dtScale; + if (dist < minGap && sameLayer) { + repulsion += (minGap - dist) * 0.1 * dtScale; + } + if (repulsion <= 0) continue; + + const fx = (dx / dist) * repulsion; + const fy = (dy / dist) * repulsion; + + forceX.set(nodeA.id, (forceX.get(nodeA.id) ?? 0) - fx); + forceY.set(nodeA.id, (forceY.get(nodeA.id) ?? 0) - fy); + forceX.set(nodeB.id, (forceX.get(nodeB.id) ?? 0) + fx); + forceY.set(nodeB.id, (forceY.get(nodeB.id) ?? 0) + fy); + } + } + } + + // 4. Spread force: equalize node density within each layer. + // + // For each layer, rank nodes by current X, compute where they would sit + // in a perfectly even distribution, then add a weak force toward that + // ideal position. Nodes that are held by strong hierarchy springs + // (force ≈ 1–2 units) resist and stay clustered; nodes without a + // strong spring anchor (isolated or same-layer-only) drift to fill gaps. + // Net effect: dense centre spreads outward, sparse edges fill in. + // Skipped for large graphs — per-layer sort is O(N log N) per frame. + if (treeUseSpread) { + const spreadByLayer = new Map>(); + graph.forEachNode((nodeId, attrs) => { + const layer = attrs.treeLayer ?? 0; + if (!spreadByLayer.has(layer)) spreadByLayer.set(layer, []); + spreadByLayer.get(layer)!.push({ id: nodeId, x: attrs.x }); + }); + for (const [, layerNodes] of spreadByLayer) { + if (layerNodes.length < 2) continue; + layerNodes.sort((a, b) => a.x - b.x); + const count = layerNodes.length; + const spacing = (TREE_MAX_X * 2) / count; + for (let i = 0; i < count; i++) { + const { id, x } = layerNodes[i]; + const idealX = -TREE_MAX_X + (i + 0.5) * spacing; + forceX.set( + id, + (forceX.get(id) ?? 0) + (idealX - x) * TREE_SPREAD_STRENGTH * dtScale, + ); + } + } + } + + totalVelocity = 0; + maxVelocity = 0; + activeNodes = 0; + + graph.forEachNode((nodeId, attrs) => { + const fx = forceX.get(nodeId) ?? 0; + const fy = forceY.get(nodeId) ?? 0; + const vx0 = treeVelocityRef.current.get(nodeId) ?? 0; + const vy0 = treeVelocityYRef.current.get(nodeId) ?? 0; + + // X boundary resistance: grows as node approaches canvas edge + const normX = Math.min(1, Math.abs(attrs.x) / TREE_MAX_X); + const resistX = 1 + normX * normX * 4; + + // Y boundary resistance: grows as node drifts from its layer band center + const layer = attrs.treeLayer ?? 0; + const centerY = layerCenterY.get(layer) ?? attrs.y; + const yOffset = attrs.y - centerY; + const normY = Math.min(1, Math.abs(yOffset) / TREE_LAYER_BAND_HALF); + const resistY = 1 + normY * normY * TREE_LAYER_BOUNDARY_RESISTANCE; + + const rawVx = (vx0 + fx / resistX) * treeDamping; + const rawVy = (vy0 + fy / resistY) * treeDamping; + const newVx = + Math.abs(fx) < TREE_FORCE_DEADZONE && Math.abs(rawVx) < TREE_VELOCITY_DEADZONE + ? 0 + : clamp(rawVx, -treeVelocityCapX, treeVelocityCapX); + const newVy = + Math.abs(fy) < TREE_FORCE_DEADZONE && Math.abs(rawVy) < TREE_VELOCITY_DEADZONE + ? 0 + : clamp(rawVy, -treeVelocityCapY, treeVelocityCapY); + + treeVelocityRef.current.set(nodeId, newVx); + treeVelocityYRef.current.set(nodeId, newVy); + + const speed = Math.sqrt(newVx * newVx + newVy * newVy); + totalVelocity += speed; + maxVelocity = Math.max(maxVelocity, speed); + if ( + speed > TREE_VELOCITY_DEADZONE || + Math.abs(fx) > TREE_FORCE_DEADZONE || + Math.abs(fy) > TREE_FORCE_DEADZONE + ) { + activeNodes += 1; + } + + graph.setNodeAttribute(nodeId, 'x', clamp(attrs.x + newVx, -TREE_MAX_X, TREE_MAX_X)); + graph.setNodeAttribute( + nodeId, + 'y', + clamp( + attrs.y + newVy, + centerY - TREE_LAYER_BAND_HALF, + centerY + TREE_LAYER_BAND_HALF, + ), + ); + }); + } - // Light noverlap cleanup - noverlap.assign(graph, NOVERLAP_SETTINGS); sigmaRef.current?.refresh(); - setIsLayoutRunning(false); - } - }, duration); - }, []); + const averageVelocity = totalVelocity / Math.max(1, graph.order); + const elapsed = timestamp - (treeLayoutStartRef.current ?? timestamp); + + if ( + elapsed >= TREE_LAYOUT_MIN_DURATION && + maxVelocity < treeStopMaxVelocity && + activeNodes <= Math.max(2, Math.floor(graph.order * treeStopActiveNodeFraction)) && + averageVelocity < treeStopAvgVelocity + ) { + treeStableFramesRef.current += 1; + } else { + treeStableFramesRef.current = 0; + } + + if ( + treeStableFramesRef.current >= treeStopStabilityFrames || + elapsed >= treeEffectiveMaxDuration + ) { + stopTreeLayout(true); + return; + } + + treeLayoutFrameRef.current = requestAnimationFrame(step); + }; + + treeLayoutFrameRef.current = requestAnimationFrame(step); + }, + [stopAllLayouts, stopTreeLayout], + ); + + const runCirclesLayout = useCallback( + (graph: Graph) => { + if (graph.order === 0) return; + + stopAllLayouts(false); + + // Compute ring target radii and centre Y (all rings are centred at 0,0) + const ringTargetR = CIRCLES_RING_RADII as unknown as number[]; + + // --------------------------------------------------------------------------- + // Adaptive physics parameters — scale to graph size. + // + // For large graphs the two most expensive passes are: + // • Repulsion: O(n × k) where k = neighbours in the sweep window + // (can be hundreds when nodes are dense on a ring arc). + // • Angular spread: O(k log k) per ring — O(n log n) total. + // + // Neither is needed for layout correctness: gravity pulls nodes to their + // ring, edge springs cluster connected nodes angularly. Repulsion and + // spread are purely cosmetic polish — worth skipping at large n. + // --------------------------------------------------------------------------- + const nodeCount = graph.order; + const isLargeGraph = nodeCount > 5000; + const isMediumGraph = nodeCount > 1500; + + // Repulsion range — 0 means skip the pass entirely. + const effectiveRepulsionRange = isLargeGraph + ? 0 + : isMediumGraph + ? 70 + : CIRCLES_REPULSION_RANGE; + + // Damping: moderate for large graphs so nodes don't overshoot but still + // settle within the time budget. Very aggressive damping (0.48) causes + // nodes to stop mid-path before reaching equilibrium. + const dampingFactor = isLargeGraph ? 0.58 : isMediumGraph ? 0.58 : 0.62; + + // Higher velocity cap → each frame moves nodes further (faster convergence). + const velocityCap = isLargeGraph ? 10 : 5; + + // Fewer simulation sub-steps per rAF tick to keep frames fast for large graphs. + const maxSimSteps = isLargeGraph ? 1 : 2; + + // Tighter per-frame budget for repulsion sweep when range > 0. + const useAngularSpread = !isLargeGraph; + + // Max wall-clock budget. Large graphs skip the expensive passes so each + // frame is fast (full 60 fps); 30 s × 60 fps = 1 800 frames is enough to + // converge 20 k+ node layouts with only gravity + edge springs. + const effectiveMaxDuration = isLargeGraph + ? 30000 + : isMediumGraph + ? 18000 + : CIRCLES_LAYOUT_MAX_DURATION; + + // Early-stop velocity thresholds. + const stopMaxVelocity = isLargeGraph ? 0.05 : 0.022; + const stopAvgVelocity = isLargeGraph ? 0.03 : 0.016; + const stopActiveNodeFraction = isLargeGraph ? 0.02 : 0.008; + const stopStabilityFrames = isLargeGraph ? 20 : CIRCLES_LAYOUT_STABILITY_FRAMES; + + // Pre-position nodes at their anchor and initialise velocities + graph.forEachNode((nodeId, attrs) => { + const ax = attrs.circlesAnchorX ?? attrs.x; + const ay = attrs.circlesAnchorY ?? attrs.y; + graph.setNodeAttribute(nodeId, 'x', ax); + graph.setNodeAttribute(nodeId, 'y', ay); + circlesVelocityXRef.current.set(nodeId, 0); + circlesVelocityYRef.current.set(nodeId, 0); + }); + + setIsLayoutRunning(true); + + const step = (timestamp: number) => { + if (!graphRef.current || graphRef.current !== graph) { + stopCirclesLayout(false); + return; + } + + if (circlesLayoutStartRef.current === null) { + circlesLayoutStartRef.current = timestamp; + } + + const frameDelta = + circlesLastTickRef.current === null + ? TREE_TARGET_FRAME_MS + : clamp(timestamp - circlesLastTickRef.current, 8, 64); + circlesLastTickRef.current = timestamp; + circlesAccumulatorRef.current = Math.min( + TREE_TARGET_FRAME_MS * 3, + circlesAccumulatorRef.current + frameDelta, + ); + + if (circlesAccumulatorRef.current < TREE_TARGET_FRAME_MS) { + circlesLayoutFrameRef.current = requestAnimationFrame(step); + return; + } + + const simulationSteps = Math.min( + maxSimSteps, + Math.floor(circlesAccumulatorRef.current / TREE_TARGET_FRAME_MS), + ); + circlesAccumulatorRef.current -= simulationSteps * TREE_TARGET_FRAME_MS; + const dtScale = 0.6; + + // --- Apply forces with radial boundary resistance --- + let totalVelocity = 0; + let maxVelocity = 0; + let activeNodes = 0; + + for (let _step = 0; _step < simulationSteps; _step++) { + totalVelocity = 0; + maxVelocity = 0; + activeNodes = 0; + + // --- Accumulate forces (recomputed each sub-step from current positions) --- + const forceX = new Map(); + const forceY = new Map(); + + // 1. Radial gravity with soft wall. + // + // Base gravity is weak, allowing repulsion to spread nodes radially + // within the band. The effective rate grows cubically as the node + // approaches the band edge so nodes never cross into adjacent rings. + // This replaces the previous hard position clamp, which caused nodes + // to pile against the boundary instead of distributing within the band. + graph.forEachNode((nodeId, attrs) => { + const ring = attrs.circlesRing ?? 0; + const targetR = ringTargetR[Math.min(ring, CIRCLES_RING_COUNT - 1)]; + const x = attrs.x; + const y = attrs.y; + const r = Math.sqrt(x * x + y * y) || 1; + const stretch = targetR - r; // positive = node inside ring, negative = outside + const normR = Math.min(1, Math.abs(stretch) / CIRCLES_BAND_HALF); + const k = + CIRCLES_RADIAL_GRAVITY * + (1 + normR * normR * normR * CIRCLES_RADIAL_BOUNDARY_RESISTANCE); + forceX.set(nodeId, (x / r) * stretch * k * dtScale); + forceY.set(nodeId, (y / r) * stretch * k * dtScale); + }); + + // 2. Edge springs — radial and tangential components. + // + // Rest length strategy: + // Hierarchy edges (cross-ring): use the radial gap between the two + // ring centres as rest length. This means the spring only activates + // when nodes are angularly misaligned — it does NOT fight radial + // gravity (which was the main cause of long edges in previous builds). + // Cross edges (same or different ring): rest length = 30 px so the + // spring activates sooner and pulls connected nodes closer. + // + // Weight cap removed: all edges use their full weight so cross-ring + // CALLS/IMPORTS springs are strong enough to pull nodes into position. + graph.forEachEdge((edge, edgeAttrs, source, target, sourceAttrs, targetAttrs) => { + const dx = targetAttrs.x - sourceAttrs.x; + const dy = targetAttrs.y - sourceAttrs.y; + const dist = Math.sqrt(dx * dx + dy * dy) || 1; + + const rawWeight = CIRCLES_EDGE_WEIGHTS[edgeAttrs.relationType] ?? 0.2; + + const sourceRing = sourceAttrs.circlesRing ?? 0; + const targetRing = targetAttrs.circlesRing ?? 0; + const restLength = edgeAttrs.isHierarchyEdge + ? Math.abs( + ringTargetR[Math.min(sourceRing, CIRCLES_RING_COUNT - 1)] - + ringTargetR[Math.min(targetRing, CIRCLES_RING_COUNT - 1)], + ) + : 30; + + const stretch = dist - restLength; + if (stretch > 0) { + const f = stretch * rawWeight * 0.55 * dtScale; + const fx = (dx / dist) * f; + const fy = (dy / dist) * f; + forceX.set(source, (forceX.get(source) ?? 0) + fx); + forceY.set(source, (forceY.get(source) ?? 0) + fy); + forceX.set(target, (forceX.get(target) ?? 0) - fx); + forceY.set(target, (forceY.get(target) ?? 0) - fy); + } + }); + + // 3. 2D repulsion — skipped for large graphs (effectiveRepulsionRange = 0). + // For large graphs, gravity + edge springs are sufficient; the O(n×k) + // repulsion sweep is the dominant per-frame cost and not worth the + // quality gain when nodes are already tiny. + if (effectiveRepulsionRange > 0) { + const nodeList = graph.nodes().map((id) => { + const a = graph.getNodeAttributes(id); + return { id, x: a.x, y: a.y, size: a.size ?? 6, ring: a.circlesRing ?? 0 }; + }); + nodeList.sort((a, b) => a.x - b.x); + + for (let i = 0; i < nodeList.length; i++) { + const nodeA = nodeList[i]; + for (let j = i + 1; j < nodeList.length; j++) { + const nodeB = nodeList[j]; + const dx = nodeB.x - nodeA.x; + if (dx > effectiveRepulsionRange) break; + + const dy = nodeB.y - nodeA.y; + const dist2 = dx * dx + dy * dy; + const distVal = Math.sqrt(dist2) || 1; + if (distVal > effectiveRepulsionRange) continue; + + const sameRing = nodeA.ring === nodeB.ring; + const repulsionStrength = sameRing ? 100 : 28; + const minGap = Math.max(28, (nodeA.size + nodeB.size) * 1.8); + let repulsion = + (1 / (distVal + 8) - 1 / (effectiveRepulsionRange + 8)) * + repulsionStrength * + dtScale; + if (distVal < minGap && sameRing) repulsion += (minGap - distVal) * 0.1 * dtScale; + if (repulsion <= 0) continue; + + const fx = (dx / distVal) * repulsion; + const fy = (dy / distVal) * repulsion; + forceX.set(nodeA.id, (forceX.get(nodeA.id) ?? 0) - fx); + forceY.set(nodeA.id, (forceY.get(nodeA.id) ?? 0) - fy); + forceX.set(nodeB.id, (forceX.get(nodeB.id) ?? 0) + fx); + forceY.set(nodeB.id, (forceY.get(nodeB.id) ?? 0) + fy); + } + } + } + + // 4. Angular spread — skipped for large graphs. + // Sorting each ring's nodes every frame is O(k log k); for ring 3 + // with 15k+ nodes this costs several ms/frame. For large graphs + // edge springs already provide angular clustering. + if (useAngularSpread) { + const spreadByRing = new Map< + number, + Array<{ id: string; angle: number; x: number; y: number }> + >(); + graph.forEachNode((nodeId, attrs) => { + const ring = attrs.circlesRing ?? 0; + if (!spreadByRing.has(ring)) spreadByRing.set(ring, []); + spreadByRing.get(ring)!.push({ + id: nodeId, + angle: Math.atan2(attrs.y, attrs.x), + x: attrs.x, + y: attrs.y, + }); + }); + + for (const [, ringNodes] of spreadByRing) { + if (ringNodes.length < 2) continue; + ringNodes.sort((a, b) => a.angle - b.angle); + const count = ringNodes.length; + for (let i = 0; i < count; i++) { + const { id, angle, x, y } = ringNodes[i]; + const idealAngle = ((i + 0.5) / count) * Math.PI * 2 - Math.PI; + let dAngle = idealAngle - angle; + while (dAngle > Math.PI) dAngle -= Math.PI * 2; + while (dAngle < -Math.PI) dAngle += Math.PI * 2; + const r = Math.sqrt(x * x + y * y) || 1; + // Tangential unit vector: (-y/r, x/r) + const tx = -y / r; + const ty = x / r; + const fMag = dAngle * CIRCLES_ANGULAR_SPREAD * dtScale; + forceX.set(id, (forceX.get(id) ?? 0) + tx * fMag); + forceY.set(id, (forceY.get(id) ?? 0) + ty * fMag); + } + } + } + + graph.forEachNode((nodeId, attrs) => { + const fx = forceX.get(nodeId) ?? 0; + const fy = forceY.get(nodeId) ?? 0; + const vx0 = circlesVelocityXRef.current.get(nodeId) ?? 0; + const vy0 = circlesVelocityYRef.current.get(nodeId) ?? 0; + + const ring = attrs.circlesRing ?? 0; + const targetR = ringTargetR[Math.min(ring, CIRCLES_RING_COUNT - 1)]; + const x = attrs.x; + const y = attrs.y; + + // Soft-wall gravity (force 1) already handles radial boundary + // enforcement — no separate resistance decomposition needed. + const rawVx = (vx0 + fx) * dampingFactor; + const rawVy = (vy0 + fy) * dampingFactor; + const newVx = + Math.abs(fx) < CIRCLES_FORCE_DEADZONE && Math.abs(rawVx) < CIRCLES_VELOCITY_DEADZONE + ? 0 + : clamp(rawVx, -velocityCap, velocityCap); + const newVy = + Math.abs(fy) < CIRCLES_FORCE_DEADZONE && Math.abs(rawVy) < CIRCLES_VELOCITY_DEADZONE + ? 0 + : clamp(rawVy, -velocityCap, velocityCap); + + circlesVelocityXRef.current.set(nodeId, newVx); + circlesVelocityYRef.current.set(nodeId, newVy); + + const speed = Math.sqrt(newVx * newVx + newVy * newVy); + totalVelocity += speed; + maxVelocity = Math.max(maxVelocity, speed); + if ( + speed > CIRCLES_VELOCITY_DEADZONE || + Math.abs(fx) > CIRCLES_FORCE_DEADZONE || + Math.abs(fy) > CIRCLES_FORCE_DEADZONE + ) { + activeNodes += 1; + } + + const newX = x + newVx; + const newY = y + newVy; + // Wide safety clamp (1.5 × band_half): the soft-wall gravity keeps + // nodes inside [targetR ± BAND_HALF] naturally. This catches only + // extreme numerical edge cases (e.g. very large forces on first frame). + const newR = Math.sqrt(newX * newX + newY * newY) || 1; + const safeMin = Math.max(1, targetR - CIRCLES_BAND_HALF * 1.5); + const safeMax = targetR + CIRCLES_BAND_HALF * 1.5; + const safeR = clamp(newR, safeMin, safeMax); + const safeScale = safeR / newR; + graph.setNodeAttribute(nodeId, 'x', newX * safeScale); + graph.setNodeAttribute(nodeId, 'y', newY * safeScale); + }); + } + + sigmaRef.current?.refresh(); + + const averageVelocity = totalVelocity / Math.max(1, graph.order); + const elapsed = timestamp - (circlesLayoutStartRef.current ?? timestamp); + + if ( + elapsed >= CIRCLES_LAYOUT_MIN_DURATION && + maxVelocity < stopMaxVelocity && + activeNodes <= Math.max(2, Math.floor(graph.order * stopActiveNodeFraction)) && + averageVelocity < stopAvgVelocity + ) { + circlesStableFramesRef.current += 1; + } else { + circlesStableFramesRef.current = 0; + } + + if ( + circlesStableFramesRef.current >= stopStabilityFrames || + elapsed >= effectiveMaxDuration + ) { + stopCirclesLayout(true); + return; + } + + circlesLayoutFrameRef.current = requestAnimationFrame(step); + }; + + circlesLayoutFrameRef.current = requestAnimationFrame(step); + }, + [stopAllLayouts, stopCirclesLayout], + ); + + // Run ForceAtlas2 layout + const runLayout = useCallback( + (graph: Graph) => { + const nodeCount = graph.order; + if (nodeCount === 0) return; + + stopAllLayouts(false); + + // Get settings + const inferredSettings = forceAtlas2.inferSettings(graph); + const customSettings = getFA2Settings(nodeCount); + const settings = { ...inferredSettings, ...customSettings }; + + const layout = new FA2Layout(graph, { settings }); + + layoutRef.current = layout; + layout.start(); + setIsLayoutRunning(true); + + const duration = getLayoutDuration(nodeCount); + + layoutTimeoutRef.current = setTimeout(() => { + if (layoutRef.current) { + layoutRef.current.stop(); + layoutRef.current = null; + + // Light noverlap cleanup + noverlap.assign(graph, NOVERLAP_SETTINGS); + sigmaRef.current?.refresh(); + + setIsLayoutRunning(false); + } + }, duration); + }, + [stopAllLayouts], + ); const setGraph = useCallback( (newGraph: Graph) => { const sigma = sigmaRef.current; if (!sigma) return; - if (layoutRef.current) { - layoutRef.current.kill(); - layoutRef.current = null; - } - if (layoutTimeoutRef.current) { - clearTimeout(layoutTimeoutRef.current); - layoutTimeoutRef.current = null; - } + stopAllLayouts(false); graphRef.current = newGraph; sigma.setGraph(newGraph); setSelectedNode(null); - runLayout(newGraph); + if (options.layoutMode === 'tree') { + runTreeLayout(newGraph); + } else if (options.layoutMode === 'circles') { + runCirclesLayout(newGraph); + } else { + runLayout(newGraph); + } + sigma.getCamera().animatedReset({ duration: 500 }); }, - [runLayout, setSelectedNode], + [ + options.layoutMode, + runLayout, + runTreeLayout, + runCirclesLayout, + setSelectedNode, + stopAllLayouts, + ], ); const focusNode = useCallback((nodeId: string) => { @@ -603,27 +1540,18 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => { const startLayout = useCallback(() => { const graph = graphRef.current; if (!graph || graph.order === 0) return; - runLayout(graph); - }, [runLayout]); + if (options.layoutMode === 'tree') { + runTreeLayout(graph); + } else if (options.layoutMode === 'circles') { + runCirclesLayout(graph); + } else { + runLayout(graph); + } + }, [options.layoutMode, runLayout, runTreeLayout, runCirclesLayout]); const stopLayout = useCallback(() => { - if (layoutTimeoutRef.current) { - clearTimeout(layoutTimeoutRef.current); - layoutTimeoutRef.current = null; - } - if (layoutRef.current) { - layoutRef.current.stop(); - layoutRef.current = null; - - const graph = graphRef.current; - if (graph) { - noverlap.assign(graph, NOVERLAP_SETTINGS); - sigmaRef.current?.refresh(); - } - - setIsLayoutRunning(false); - } - }, []); + stopAllLayouts(true); + }, [stopAllLayouts]); const refreshHighlights = useCallback(() => { sigmaRef.current?.refresh(); diff --git a/gitnexus-web/src/lib/circles-layout.ts b/gitnexus-web/src/lib/circles-layout.ts new file mode 100644 index 000000000..8a061728b --- /dev/null +++ b/gitnexus-web/src/lib/circles-layout.ts @@ -0,0 +1,307 @@ +import type { KnowledgeGraph } from '../core/graph/types'; +import type { GraphNode, NodeLabel } from 'gitnexus-shared'; +import { NODE_SIZES } from './constants'; + +export interface CirclesNodePosition { + x: number; + y: number; + size: number; + /** Logical ring index 0 (innermost) … RING_COUNT-1 (outermost) */ + ring: number; + /** Angle in radians, stored so the physics can use it as an anchor */ + angle: number; +} + +// --------------------------------------------------------------------------- +// Configurable constants +// --------------------------------------------------------------------------- + +/** Target radius (px) for each ring. Ring 0 is innermost. */ +export const CIRCLES_RING_RADII = [90, 240, 420, 620] as const; + +/** + * Half-width of the allowed radial band around each ring centre. + * Keep this small enough that adjacent rings never overlap. + * Current ring gaps: 150 / 180 / 200 px → band = 45 leaves 60-110 px of clear air. + */ +export const CIRCLES_BAND_HALF = 45; + +/** Number of rings (= number of layers). */ +export const RING_COUNT = CIRCLES_RING_RADII.length; // 4 + +// --------------------------------------------------------------------------- +// Layer assignment — identical to tree-layout so the same node types +// end up in the same conceptual layer. +// --------------------------------------------------------------------------- + +const TYPE_TO_RING: Record = { + // Ring 0 – innermost: structural containers + Project: 0, + Package: 0, + Module: 0, + Folder: 0, + Namespace: 0, + + // Ring 1 – files + File: 1, + Section: 1, + Import: 1, + Route: 1, + Tool: 1, + + // Ring 2 – type definitions + Class: 2, + Interface: 2, + Enum: 2, + Type: 2, + Struct: 2, + Trait: 2, + Union: 2, + Record: 2, + Typedef: 2, + Template: 2, + TypeAlias: 2, + + // Ring 3 – outermost: functions / methods / variables + Function: 3, + Method: 3, + Impl: 3, + Delegate: 3, + Constructor: 3, + Variable: 3, + Const: 3, + Static: 3, + Property: 3, + Decorator: 3, + Annotation: 3, + Macro: 3, + CodeElement: 3, +}; + +const DEFAULT_RING = 1; + +/** Hierarchy edges used for angular-allocation grouping. */ +export const CIRCLES_HIERARCHY_RELATIONS = new Set([ + 'CONTAINS', + 'DEFINES', + 'HAS_METHOD', + 'HAS_PROPERTY', +]); + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +function getNodeRing(node: GraphNode): number { + return TYPE_TO_RING[node.label] ?? DEFAULT_RING; +} + +function calculateNodeSize(ring: number, nodeType: NodeLabel): number { + const baseSize = NODE_SIZES[nodeType] || 6; + const ringMultiplier = Math.max(0.6, 1 - ring * 0.12); + return baseSize * ringMultiplier; +} + +function deterministicHash(str: string): number { + let hash = 5381; + for (let i = 0; i < str.length; i++) { + hash = (hash << 5) + hash + str.charCodeAt(i); + hash |= 0; + } + return (Math.abs(hash) % 10000) / 10000; +} + +function buildHierarchyMaps(graph: KnowledgeGraph) { + const childrenByParent = new Map(); + const parentsByChild = new Map(); + + for (const rel of graph.relationships) { + if (!CIRCLES_HIERARCHY_RELATIONS.has(rel.type)) continue; + + if (!childrenByParent.has(rel.sourceId)) childrenByParent.set(rel.sourceId, []); + childrenByParent.get(rel.sourceId)!.push(rel.targetId); + + if (!parentsByChild.has(rel.targetId)) parentsByChild.set(rel.targetId, []); + parentsByChild.get(rel.targetId)!.push(rel.sourceId); + } + + return { childrenByParent, parentsByChild }; +} + +// --------------------------------------------------------------------------- +// Parent-centred angular allocation +// +// Each parent's children are placed in an arc CENTRED on the parent's own +// angle, with arc size proportional to child count. This prevents the +// sequential-concatenation bias (where the largest group's arc centre drifts +// to 90° / 270° regardless of where the parent sits) that caused top-bottom +// crowding in the previous sequential allocation. +// +// Overlapping initial arcs are fine — the physics simulation's angular spread +// force resolves them during the simulation. +// --------------------------------------------------------------------------- + +function initParentCentredAngles( + graph: KnowledgeGraph, + parentsByChild: Map, +): Map { + const positions = new Map(); + + // Group nodes by ring + const nodesByRing: GraphNode[][] = Array.from({ length: RING_COUNT }, () => []); + const nodeRingMap = new Map(); + + for (const node of graph.nodes) { + const ring = getNodeRing(node); + if (ring >= 0 && ring < RING_COUNT) { + nodesByRing[ring].push(node); + nodeRingMap.set(node.id, ring); + } + } + + const TWO_PI = Math.PI * 2; + + // --- Ring 0: sorted alphabetically, evenly spaced around full circle --- + const ring0Nodes = [...nodesByRing[0]].sort((a, b) => + a.properties.name.localeCompare(b.properties.name), + ); + + if (ring0Nodes.length > 0) { + const count = ring0Nodes.length; + for (let i = 0; i < count; i++) { + const node = ring0Nodes[i]; + const angle = (i / count) * TWO_PI; + const r = CIRCLES_RING_RADII[0]; + positions.set(node.id, { + x: r * Math.cos(angle), + y: r * Math.sin(angle), + size: calculateNodeSize(0, node.label), + ring: 0, + angle, + }); + } + } + + // --- Rings 1-3: parent-centred arc placement --- + for (let ring = 1; ring < RING_COUNT; ring++) { + const ringNodes = nodesByRing[ring]; + if (ringNodes.length === 0) continue; + + const r = CIRCLES_RING_RADII[ring]; + + // Find each node's primary parent: placed ancestor with highest ring index + // (so a Method prefers its Class over a distant Package). + const assignedParent = new Map(); + for (const node of ringNodes) { + const parents = parentsByChild.get(node.id) ?? []; + let bestParent: string | null = null; + let bestParentRing = -1; + for (const p of parents) { + if (!positions.has(p)) continue; + const pRing = nodeRingMap.get(p) ?? -1; + if (pRing > bestParentRing) { + bestParentRing = pRing; + bestParent = p; + } + } + if (bestParent) assignedParent.set(node.id, bestParent); + } + + // Bucket into parent groups and orphans + const childrenOfParent = new Map(); + const orphans: GraphNode[] = []; + + for (const node of ringNodes) { + const p = assignedParent.get(node.id); + if (!p) { + orphans.push(node); + } else { + if (!childrenOfParent.has(p)) childrenOfParent.set(p, []); + childrenOfParent.get(p)!.push(node); + } + } + + for (const children of childrenOfParent.values()) { + children.sort((a, b) => a.properties.name.localeCompare(b.properties.name)); + } + orphans.sort((a, b) => a.properties.name.localeCompare(b.properties.name)); + + const totalParented = ringNodes.length - orphans.length; + const parentedFraction = totalParented > 0 ? totalParented / ringNodes.length : 0; + + // Place each parent's children in an arc centred on the parent's angle. + // Arc size ∝ child count relative to all parented nodes. + for (const [parentId, children] of childrenOfParent) { + if (children.length === 0) continue; + + const parentAngle = positions.get(parentId)?.angle ?? 0; + const slotArc = (children.length / totalParented) * parentedFraction * TWO_PI; + const startAngle = parentAngle - slotArc / 2; + + for (let i = 0; i < children.length; i++) { + const angle = startAngle + (i + 0.5) * (slotArc / children.length); + positions.set(children[i].id, { + x: r * Math.cos(angle), + y: r * Math.sin(angle), + size: calculateNodeSize(ring, children[i].label), + ring, + angle, + }); + } + } + + // Orphans: spread evenly in their proportional arc, centred at angle = π + // (left side), away from the 0° / ±π boundary to avoid wrapping artefacts. + if (orphans.length > 0) { + const orphanFraction = orphans.length / ringNodes.length; + const orphanArc = orphanFraction * TWO_PI; + // Centre orphan arc at π so it doesn't overlap with the typical 0° cluster + const orphanStart = Math.PI - orphanArc / 2; + for (let i = 0; i < orphans.length; i++) { + const angle = orphanStart + (i + 0.5) * (orphanArc / orphans.length); + positions.set(orphans[i].id, { + x: r * Math.cos(angle), + y: r * Math.sin(angle), + size: calculateNodeSize(ring, orphans[i].label), + ring, + angle, + }); + } + } + } + + return positions; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Circles view layout: concentric rings with parent-centred angular allocation. + * + * Ring 0 (innermost) = Folders/Packages + * Ring 1 = Files + * Ring 2 = Classes/Interfaces + * Ring 3 (outermost) = Functions/Methods/Variables + * + * Returns initial positions; the physics simulation in useSigma.ts refines + * them using radial gravity + hard band clamping, angular spread, and 2D + * repulsion — identical in structure to the tree-view physics. + */ +export function calculateCirclesLayout(graph: KnowledgeGraph): Map { + const { parentsByChild } = buildHierarchyMaps(graph); + + // 1. Parent-centred angular allocation — no top/bottom bias + const positions = initParentCentredAngles(graph, parentsByChild); + + // 2. Subtle radial jitter only — angular jitter would fight the centred placement + for (const [nodeId, pos] of positions) { + const jitter = (deterministicHash(nodeId + 'r') - 0.5) * 10; // ±10 px + const r = CIRCLES_RING_RADII[pos.ring] + jitter; + pos.x = r * Math.cos(pos.angle); + pos.y = r * Math.sin(pos.angle); + } + + return positions; +} diff --git a/gitnexus-web/src/lib/constants.ts b/gitnexus-web/src/lib/constants.ts index f6804405b..fe0505483 100644 --- a/gitnexus-web/src/lib/constants.ts +++ b/gitnexus-web/src/lib/constants.ts @@ -101,7 +101,9 @@ export const getCommunityColor = (communityIndex: number): string => { return COMMUNITY_COLORS[communityIndex % COMMUNITY_COLORS.length]; }; -// Labels to show by default (hide imports and variables by default as they clutter) +// Labels to show by default (hide imports by default as they clutter). +// Property/Const are the Kotlin/Java equivalents of Variable — include them so +// Kotlin repos don't appear to have no leaf nodes. export const DEFAULT_VISIBLE_LABELS: NodeLabel[] = [ 'Project', 'Package', @@ -111,6 +113,8 @@ export const DEFAULT_VISIBLE_LABELS: NodeLabel[] = [ 'Class', 'Function', 'Method', + 'Property', // Kotlin/Java fields (HAS_PROPERTY + DEFINES File→Property) + 'Const', // Top-level constants 'Interface', 'Enum', 'Type', @@ -127,6 +131,8 @@ export const FILTERABLE_LABELS: NodeLabel[] = [ 'Function', 'Method', 'Variable', + 'Property', // Kotlin/Java field nodes + 'Const', 'Decorator', 'Import', ]; diff --git a/gitnexus-web/src/lib/graph-adapter.test.ts b/gitnexus-web/src/lib/graph-adapter.test.ts new file mode 100644 index 000000000..4cbb6b8ef --- /dev/null +++ b/gitnexus-web/src/lib/graph-adapter.test.ts @@ -0,0 +1,208 @@ +import { describe, it, expect } from 'vitest'; +import { knowledgeGraphToTreeGraphology, knowledgeGraphToCirclesGraphology } from './graph-adapter'; +import type { KnowledgeGraph } from '../core/graph/types'; +import type { GraphNode } from 'gitnexus-shared'; +import { EDGE_INFO } from './constants'; + +function makeNode(id: string, label: string, name: string): GraphNode { + return { + id, + label: label as any, + properties: { name, filePath: '', startLine: 1, endLine: 1 }, + }; +} + +describe('knowledgeGraphToTreeGraphology', () => { + it('should create a graph with tree layout', () => { + const graph: KnowledgeGraph = { + nodes: [ + makeNode('root', 'Project', 'MyProject'), + makeNode('folder', 'Folder', 'src'), + makeNode('file', 'File', 'main.ts'), + ], + relationships: [ + { id: 'r1', type: 'CONTAINS', sourceId: 'root', targetId: 'folder' }, + { id: 'r2', type: 'CONTAINS', sourceId: 'folder', targetId: 'file' }, + { id: 'r3', type: 'CALLS', sourceId: 'file', targetId: 'root' }, + ], + }; + + const sigmaGraph = knowledgeGraphToTreeGraphology(graph); + + expect(sigmaGraph.hasNode('root')).toBe(true); + expect(sigmaGraph.hasNode('folder')).toBe(true); + expect(sigmaGraph.hasNode('file')).toBe(true); + + const rootAttrs = sigmaGraph.getNodeAttributes('root'); + const folderAttrs = sigmaGraph.getNodeAttributes('folder'); + const fileAttrs = sigmaGraph.getNodeAttributes('file'); + + // Tree view is inverted vertically, so files sit above containers. + expect(fileAttrs.y).toBeLessThan(rootAttrs.y); + expect(fileAttrs.y).toBeLessThan(folderAttrs.y); + + // Nodes should have reasonable sizes + expect(rootAttrs.size).toBeGreaterThan(2); + expect(folderAttrs.size).toBeGreaterThan(2); + expect(fileAttrs.size).toBeGreaterThan(2); + + expect(rootAttrs.treeAnchorX).toBe(rootAttrs.x); + expect(rootAttrs.treeAnchorY).toBe(rootAttrs.y); + expect(rootAttrs.treeLayer).toBe(0); + expect(fileAttrs.treeLayer).toBe(1); + }); + + it('should style hierarchy edges differently from cross-cutting edges', () => { + const graph: KnowledgeGraph = { + nodes: [makeNode('a', 'Function', 'fnA'), makeNode('b', 'Function', 'fnB')], + relationships: [ + { id: 'r1', type: 'CONTAINS', sourceId: 'a', targetId: 'b' }, + { id: 'r2', type: 'CALLS', sourceId: 'a', targetId: 'b' }, + ], + }; + + const sigmaGraph = knowledgeGraphToTreeGraphology(graph); + + // MultiGraph allows multiple edges per pair — both CONTAINS and CALLS must survive. + expect(sigmaGraph.size).toBe(2); + + const attrsByType = new Map(); + sigmaGraph.forEachEdge((_edge, attrs) => { + attrsByType.set(attrs.relationType, attrs); + }); + + const containsAttrs = attrsByType.get('CONTAINS'); + expect(containsAttrs).toBeDefined(); + expect(containsAttrs!.isHierarchyEdge).toBe(true); + expect(containsAttrs!.color).toBe(EDGE_INFO.CONTAINS.color); + + const callsAttrs = attrsByType.get('CALLS'); + expect(callsAttrs).toBeDefined(); + expect(callsAttrs!.isHierarchyEdge).toBe(false); + expect(callsAttrs!.color).toBe(EDGE_INFO.CALLS.color); + }); + + it('should treat imports as cross-cutting edges in tree view', () => { + const graph: KnowledgeGraph = { + nodes: [makeNode('a', 'File', 'a.ts'), makeNode('b', 'File', 'b.ts')], + relationships: [{ id: 'r1', type: 'IMPORTS', sourceId: 'a', targetId: 'b' }], + }; + + const sigmaGraph = knowledgeGraphToTreeGraphology(graph); + + sigmaGraph.forEachEdge((edge, attrs) => { + if (attrs.relationType === 'IMPORTS') { + expect(attrs.isHierarchyEdge).toBe(false); + expect(attrs.color).toBe(EDGE_INFO.IMPORTS.color); + } + }); + }); + + it('should handle a medium-sized graph without dropping nodes or edges', () => { + // 2000 nodes + 4000 edges — exercises the adaptive spring iteration path (14 iters). + // Structural assertion only: wall-clock timing is too variable across CI machines. + const nodes: GraphNode[] = Array.from({ length: 2000 }, (_, i) => + makeNode(`n${i}`, i % 4 === 0 ? 'Folder' : i % 4 === 1 ? 'File' : 'Function', `node${i}`), + ); + const relationships = Array.from({ length: 4000 }, (_, i) => ({ + id: `r${i}`, + type: i % 3 === 0 ? 'CONTAINS' : 'CALLS', + sourceId: `n${i % 2000}`, + targetId: `n${(i + 7) % 2000}`, + })); + const graph: KnowledgeGraph = { nodes, relationships }; + + const sigmaGraph = knowledgeGraphToTreeGraphology(graph); + + // All nodes that have a tree-layout position must be present in the output. + expect(sigmaGraph.order).toBe(2000); + // Every relationship whose source and target both exist should produce an edge. + // Self-loops (sourceId === targetId) are excluded — the adapter skips them. + const selfLoops = relationships.filter((r) => r.sourceId === r.targetId).length; + expect(sigmaGraph.size).toBe(relationships.length - selfLoops); + }); +}); + +describe('knowledgeGraphToCirclesGraphology', () => { + it('should place nodes into ring positions based on their type', () => { + const graph: KnowledgeGraph = { + nodes: [ + makeNode('folder', 'Folder', 'src'), + makeNode('file', 'File', 'main.ts'), + makeNode('fn', 'Function', 'doSomething'), + ], + relationships: [ + { id: 'r1', type: 'CONTAINS', sourceId: 'folder', targetId: 'file' }, + { id: 'r2', type: 'CALLS', sourceId: 'file', targetId: 'fn' }, + ], + }; + + const sigmaGraph = knowledgeGraphToCirclesGraphology(graph); + + expect(sigmaGraph.hasNode('folder')).toBe(true); + expect(sigmaGraph.hasNode('file')).toBe(true); + expect(sigmaGraph.hasNode('fn')).toBe(true); + + // Each node carries its ring index and anchor coordinates + const folderAttrs = sigmaGraph.getNodeAttributes('folder'); + const fileAttrs = sigmaGraph.getNodeAttributes('file'); + const fnAttrs = sigmaGraph.getNodeAttributes('fn'); + + expect(typeof folderAttrs.circlesRing).toBe('number'); + expect(typeof folderAttrs.circlesAnchorX).toBe('number'); + expect(typeof folderAttrs.circlesAnchorY).toBe('number'); + + // Folders/Packages live in ring 0 (innermost); Files in ring 1; Functions in ring 3. + expect(folderAttrs.circlesRing).toBe(0); + expect(fileAttrs.circlesRing).toBe(1); + expect(fnAttrs.circlesRing).toBe(3); + + // Tree anchor attributes must NOT be set in circles mode + expect(folderAttrs.treeAnchorX).toBeUndefined(); + expect(folderAttrs.treeAnchorY).toBeUndefined(); + }); + + it('should style hierarchy edges differently from cross-cutting edges', () => { + const graph: KnowledgeGraph = { + nodes: [makeNode('a', 'File', 'a.ts'), makeNode('b', 'Function', 'fn')], + relationships: [ + { id: 'r1', type: 'CONTAINS', sourceId: 'a', targetId: 'b' }, + { id: 'r2', type: 'CALLS', sourceId: 'a', targetId: 'b' }, + ], + }; + + // MultiGraph allows multiple edges per pair — both CONTAINS and CALLS must survive. + const sigmaGraph = knowledgeGraphToCirclesGraphology(graph); + + expect(sigmaGraph.size).toBe(2); + + const attrsByType = new Map(); + sigmaGraph.forEachEdge((_, attrs) => { + attrsByType.set(attrs.relationType, attrs); + }); + + const containsAttrs = attrsByType.get('CONTAINS'); + expect(containsAttrs).toBeDefined(); + expect(containsAttrs!.isHierarchyEdge).toBe(true); + expect(containsAttrs!.color).toBe(EDGE_INFO.CONTAINS.color); + + const callsAttrs = attrsByType.get('CALLS'); + expect(callsAttrs).toBeDefined(); + expect(callsAttrs!.isHierarchyEdge).toBe(false); + expect(callsAttrs!.color).toBe(EDGE_INFO.CALLS.color); + }); + + it('should treat CALLS as a cross-cutting edge in circles view', () => { + const graph: KnowledgeGraph = { + nodes: [makeNode('a', 'Function', 'fnA'), makeNode('b', 'Function', 'fnB')], + relationships: [{ id: 'r1', type: 'CALLS', sourceId: 'a', targetId: 'b' }], + }; + + const sigmaGraph = knowledgeGraphToCirclesGraphology(graph); + + sigmaGraph.forEachEdge((_, attrs) => { + expect(attrs.isHierarchyEdge).toBe(false); + expect(attrs.color).toBe(EDGE_INFO.CALLS.color); + }); + }); +}); diff --git a/gitnexus-web/src/lib/graph-adapter.ts b/gitnexus-web/src/lib/graph-adapter.ts index 03caf8c92..bb3f5f11a 100644 --- a/gitnexus-web/src/lib/graph-adapter.ts +++ b/gitnexus-web/src/lib/graph-adapter.ts @@ -1,7 +1,9 @@ -import Graph from 'graphology'; +import Graph, { MultiGraph } from 'graphology'; import type { NodeLabel } from 'gitnexus-shared'; import type { KnowledgeGraph } from '../core/graph/types'; -import { NODE_COLORS, NODE_SIZES, getCommunityColor } from './constants'; +import { EDGE_INFO, NODE_COLORS, NODE_SIZES, getCommunityColor } from './constants'; +import { calculateTreeLayout } from './tree-layout'; +import { calculateCirclesLayout } from './circles-layout'; export interface SigmaNodeAttributes { x: number; @@ -17,6 +19,13 @@ export interface SigmaNodeAttributes { zIndex?: number; highlighted?: boolean; mass?: number; // ForceAtlas2 mass - higher = more repulsion + treeAnchorX?: number; + treeAnchorY?: number; + treeLayer?: number; + circlesAnchorX?: number; + circlesAnchorY?: number; + circlesRing?: number; + circlesAnchorAngle?: number; community?: number; // Community index from Leiden algorithm communityColor?: string; // Color assigned by community } @@ -28,6 +37,7 @@ export interface SigmaEdgeAttributes { type?: string; curvature?: number; zIndex?: number; + isHierarchyEdge?: boolean; } /** @@ -91,18 +101,21 @@ export const knowledgeGraphToGraphology = ( // Build parent-child map from hierarchy relationships // CONTAINS: Folder -> File // DEFINES: File -> Function/Class/Interface/Method - // IMPORTS: File -> Import - // parent -> children + // parent -> children (used only for initial spatial seeding before FA2 runs) const parentToChildren = new Map(); // child -> parent const childToParent = new Map(); - const hierarchyRelations = new Set(['CONTAINS', 'DEFINES', 'IMPORTS']); + // IMPORTS is not a true structural hierarchy, but treating it as a spatial + // seed helps FA2 converge for import-heavy codebases: files that import each + // other start near each other, so the simulation doesn't have to close many + // long cross-package springs from scratch. + const spatialSeedRelations = new Set(['CONTAINS', 'DEFINES', 'IMPORTS']); knowledgeGraph.relationships.forEach((rel) => { - // These relationships represent parent-child hierarchy for positioning - if (hierarchyRelations.has(rel.type)) { - // source CONTAINS/DEFINES/IMPORTS target, so source is parent + // These relationships determine initial node positions (not graph semantics) + if (spatialSeedRelations.has(rel.type)) { + // source CONTAINS/DEFINES/IMPORTS target → source acts as spatial parent if (!parentToChildren.has(rel.sourceId)) { parentToChildren.set(rel.sourceId, []); } @@ -295,23 +308,202 @@ export const knowledgeGraphToGraphology = ( // TYPE RELATIONSHIPS - Warm colors (OOP) EXTENDS: { color: '#c2410c', sizeMultiplier: 1.0 }, // Orange - extension IMPLEMENTS: { color: '#be185d', sizeMultiplier: 0.9 }, // Pink - interface implementation + + // KOTLIN/JAVA HIERARCHY — same hues as their logical equivalents so force + // mode renders these consistently with tree/circles view. + HAS_METHOD: { color: EDGE_INFO.DEFINES.color, sizeMultiplier: 0.4 }, // Class→Method (≈ DEFINES) + HAS_PROPERTY: { color: EDGE_INFO.CONTAINS.color, sizeMultiplier: 0.35 }, // Class→Property (≈ CONTAINS) + }; + + // Two-pass insertion so hierarchy/DEFINES edges are drawn first (behind) + // and cross-edges (CALLS, IMPORTS, EXTENDS) are drawn on top. + const BACKGROUND_EDGE_TYPES = new Set(['CONTAINS', 'DEFINES', 'HAS_METHOD', 'HAS_PROPERTY']); + + const addEdge = (rel: (typeof knowledgeGraph.relationships)[number]) => { + if (!graph.hasNode(rel.sourceId) || !graph.hasNode(rel.targetId)) return; + if (graph.hasEdge(rel.sourceId, rel.targetId)) return; + const style = EDGE_STYLES[rel.type] || { color: '#4a4a5a', sizeMultiplier: 0.5 }; + const curvature = 0.12 + Math.random() * 0.08; + graph.addEdge(rel.sourceId, rel.targetId, { + size: edgeBaseSize * style.sizeMultiplier, + color: style.color, + relationType: rel.type, + type: 'curved', + curvature, + }); + }; + + // Pass 1: background (hierarchy) edges — rendered behind + knowledgeGraph.relationships.forEach((rel) => { + if (BACKGROUND_EDGE_TYPES.has(rel.type)) addEdge(rel); + }); + // Pass 2: foreground (cross) edges — rendered on top + knowledgeGraph.relationships.forEach((rel) => { + if (!BACKGROUND_EDGE_TYPES.has(rel.type)) addEdge(rel); + }); + + return graph; +}; + +export const knowledgeGraphToTreeGraphology = ( + knowledgeGraph: KnowledgeGraph, +): Graph => { + const graph = new MultiGraph(); + const nodeCount = knowledgeGraph.nodes.length; + const positions = calculateTreeLayout(knowledgeGraph); + + // Add nodes with tree positions + for (const node of knowledgeGraph.nodes) { + const pos = positions.get(node.id); + if (!pos) continue; + + const baseSize = NODE_SIZES[node.label] || 8; + const scaledSize = getScaledNodeSize(baseSize, nodeCount); + const finalSize = Math.max(2, pos.size * (scaledSize / baseSize)); + + graph.addNode(node.id, { + x: pos.x, + y: pos.y, + size: finalSize, + color: NODE_COLORS[node.label] || '#9ca3af', + label: node.properties.name, + nodeType: node.label, + filePath: node.properties.filePath, + startLine: node.properties.startLine, + endLine: node.properties.endLine, + hidden: false, + mass: 1, // No force layout in tree view + treeAnchorX: pos.x, + treeAnchorY: pos.y, + treeLayer: pos.depth, + }); + } + + // Add edges with tree-specific styling + const edgeBaseSize = nodeCount > 20000 ? 0.4 : nodeCount > 5000 ? 0.6 : 1.0; + + const HIERARCHY_EDGE_STYLES: Record = { + CONTAINS: { color: EDGE_INFO.CONTAINS.color, sizeMultiplier: 0.3 }, + DEFINES: { color: EDGE_INFO.DEFINES.color, sizeMultiplier: 0.3 }, + HAS_METHOD: { color: EDGE_INFO.DEFINES.color, sizeMultiplier: 0.3 }, // Kotlin Class→Method hierarchy + HAS_PROPERTY: { color: EDGE_INFO.CONTAINS.color, sizeMultiplier: 0.25 }, // Kotlin Class→Property hierarchy + }; + + const CROSS_EDGE_STYLES: Record = { + IMPORTS: { color: EDGE_INFO.IMPORTS.color, sizeMultiplier: 0.6 }, + CALLS: { color: EDGE_INFO.CALLS.color, sizeMultiplier: 0.8 }, + EXTENDS: { color: EDGE_INFO.EXTENDS.color, sizeMultiplier: 1.0 }, + IMPLEMENTS: { color: EDGE_INFO.IMPLEMENTS.color, sizeMultiplier: 0.9 }, + }; + + // Two-pass insertion: hierarchy edges first (rendered behind), cross-edges on top. + // Dedup by relationship ID so CONTAINS + CALLS between the same pair both survive. + const addedTreeRelIds = new Set(); + const addTreeEdge = (rel: (typeof knowledgeGraph.relationships)[number]) => { + if (!graph.hasNode(rel.sourceId) || !graph.hasNode(rel.targetId)) return; + if (addedTreeRelIds.has(rel.id)) return; + addedTreeRelIds.add(rel.id); + const isHierarchy = HIERARCHY_EDGE_STYLES[rel.type] !== undefined; + const style = isHierarchy + ? HIERARCHY_EDGE_STYLES[rel.type] + : CROSS_EDGE_STYLES[rel.type] || { color: '#4a4a5a', sizeMultiplier: 0.5 }; + graph.addEdge(rel.sourceId, rel.targetId, { + size: edgeBaseSize * style.sizeMultiplier, + color: style.color, + relationType: rel.type, + type: 'curved', + curvature: 0.1 + Math.random() * 0.1, + isHierarchyEdge: isHierarchy, + }); }; knowledgeGraph.relationships.forEach((rel) => { - if (graph.hasNode(rel.sourceId) && graph.hasNode(rel.targetId)) { - if (!graph.hasEdge(rel.sourceId, rel.targetId)) { - const style = EDGE_STYLES[rel.type] || { color: '#4a4a5a', sizeMultiplier: 0.5 }; - const curvature = 0.12 + Math.random() * 0.08; + if (HIERARCHY_EDGE_STYLES[rel.type] !== undefined) addTreeEdge(rel); + }); + knowledgeGraph.relationships.forEach((rel) => { + if (HIERARCHY_EDGE_STYLES[rel.type] === undefined) addTreeEdge(rel); + }); - graph.addEdge(rel.sourceId, rel.targetId, { - size: edgeBaseSize * style.sizeMultiplier, - color: style.color, - relationType: rel.type, - type: 'curved', - curvature: curvature, - }); - } - } + return graph; +}; + +export const knowledgeGraphToCirclesGraphology = ( + knowledgeGraph: KnowledgeGraph, +): Graph => { + const graph = new MultiGraph(); + const nodeCount = knowledgeGraph.nodes.length; + const positions = calculateCirclesLayout(knowledgeGraph); + + for (const node of knowledgeGraph.nodes) { + const pos = positions.get(node.id); + if (!pos) continue; + + const baseSize = NODE_SIZES[node.label] || 8; + const scaledSize = getScaledNodeSize(baseSize, nodeCount); + const finalSize = Math.max(2, pos.size * (scaledSize / baseSize)); + + graph.addNode(node.id, { + x: pos.x, + y: pos.y, + size: finalSize, + color: NODE_COLORS[node.label] || '#9ca3af', + label: node.properties.name, + nodeType: node.label, + filePath: node.properties.filePath, + startLine: node.properties.startLine, + endLine: node.properties.endLine, + hidden: false, + mass: 1, + circlesAnchorX: pos.x, + circlesAnchorY: pos.y, + circlesRing: pos.ring, + circlesAnchorAngle: pos.angle, + }); + } + + const edgeBaseSize = nodeCount > 20000 ? 0.4 : nodeCount > 5000 ? 0.6 : 1.0; + + // Reuse the same edge style maps as tree view + const HIERARCHY_EDGE_STYLES: Record = { + CONTAINS: { color: EDGE_INFO.CONTAINS.color, sizeMultiplier: 0.3 }, + DEFINES: { color: EDGE_INFO.DEFINES.color, sizeMultiplier: 0.3 }, + HAS_METHOD: { color: EDGE_INFO.DEFINES.color, sizeMultiplier: 0.3 }, + HAS_PROPERTY: { color: EDGE_INFO.CONTAINS.color, sizeMultiplier: 0.25 }, + }; + + const CROSS_EDGE_STYLES: Record = { + IMPORTS: { color: EDGE_INFO.IMPORTS.color, sizeMultiplier: 0.6 }, + CALLS: { color: EDGE_INFO.CALLS.color, sizeMultiplier: 0.8 }, + EXTENDS: { color: EDGE_INFO.EXTENDS.color, sizeMultiplier: 1.0 }, + IMPLEMENTS: { color: EDGE_INFO.IMPLEMENTS.color, sizeMultiplier: 0.9 }, + }; + + // Two-pass insertion: hierarchy edges first (rendered behind), cross-edges on top. + // Dedup by relationship ID so CONTAINS + CALLS between the same pair both survive. + const addedCirclesRelIds = new Set(); + const addCirclesEdge = (rel: (typeof knowledgeGraph.relationships)[number]) => { + if (!graph.hasNode(rel.sourceId) || !graph.hasNode(rel.targetId)) return; + if (addedCirclesRelIds.has(rel.id)) return; + addedCirclesRelIds.add(rel.id); + const isHierarchy = HIERARCHY_EDGE_STYLES[rel.type] !== undefined; + const style = isHierarchy + ? HIERARCHY_EDGE_STYLES[rel.type] + : CROSS_EDGE_STYLES[rel.type] || { color: '#4a4a5a', sizeMultiplier: 0.5 }; + graph.addEdge(rel.sourceId, rel.targetId, { + size: edgeBaseSize * style.sizeMultiplier, + color: style.color, + relationType: rel.type, + type: 'curved', + curvature: 0.1 + Math.random() * 0.1, + isHierarchyEdge: isHierarchy, + }); + }; + + knowledgeGraph.relationships.forEach((rel) => { + if (HIERARCHY_EDGE_STYLES[rel.type] !== undefined) addCirclesEdge(rel); + }); + knowledgeGraph.relationships.forEach((rel) => { + if (HIERARCHY_EDGE_STYLES[rel.type] === undefined) addCirclesEdge(rel); }); return graph; diff --git a/gitnexus-web/src/lib/lucide-icons.tsx b/gitnexus-web/src/lib/lucide-icons.tsx index dc69b279f..7d9b5fc7d 100644 --- a/gitnexus-web/src/lib/lucide-icons.tsx +++ b/gitnexus-web/src/lib/lucide-icons.tsx @@ -79,6 +79,7 @@ export { Loader2, Maximize2, MousePointerClick, + Network, PanelLeft, PanelLeftClose, PanelRightClose, diff --git a/gitnexus-web/src/lib/tree-layout.test.ts b/gitnexus-web/src/lib/tree-layout.test.ts new file mode 100644 index 000000000..3858a59cd --- /dev/null +++ b/gitnexus-web/src/lib/tree-layout.test.ts @@ -0,0 +1,217 @@ +import { describe, it, expect } from 'vitest'; +import { calculateTreeLayout } from './tree-layout'; +import type { KnowledgeGraph } from '../core/graph/types'; +import type { GraphNode } from 'gitnexus-shared'; + +function makeNode(id: string, label: string, name: string): GraphNode { + return { + id, + label: label as any, + properties: { name, filePath: '', startLine: 1, endLine: 1 }, + }; +} + +describe('calculateTreeLayout', () => { + it('should place different types in correct layers', () => { + const graph: KnowledgeGraph = { + nodes: [ + makeNode('f1', 'Folder', 'src'), + makeNode('file1', 'File', 'main.ts'), + makeNode('cls1', 'Class', 'MyClass'), + makeNode('fn1', 'Function', 'myFunc'), + ], + relationships: [], + }; + + const positions = calculateTreeLayout(graph); + + const folderY = positions.get('f1')!.y; + const fileY = positions.get('file1')!.y; + const classY = positions.get('cls1')!.y; + const funcY = positions.get('fn1')!.y; + + // Layer ordering is visually inverted in tree view: + // Function < Class < File < Folder + expect(funcY).toBeLessThan(classY); + expect(classY).toBeLessThan(fileY); + expect(fileY).toBeLessThan(folderY); + }); + + it('should arrange many same-type nodes in a grid within a layer', () => { + const nodes: GraphNode[] = []; + for (let i = 0; i < 40; i++) { + nodes.push(makeNode(`fn${i}`, 'Function', `func${i}`)); + } + + const graph: KnowledgeGraph = { nodes, relationships: [] }; + const positions = calculateTreeLayout(graph); + + const xValues = nodes.map((n) => positions.get(n.id)!.x); + const yValues = nodes.map((n) => positions.get(n.id)!.y); + + // Should have multiple columns (spread horizontally) + const uniqueX = [...new Set(xValues)].sort((a, b) => a - b); + expect(uniqueX.length).toBeGreaterThan(3); + + // Should have multiple rows (spread vertically within layer) + const uniqueY = [...new Set(yValues)].sort((a, b) => a - b); + expect(uniqueY.length).toBeGreaterThan(1); + + // Overall width should be significant + const minX = Math.min(...xValues); + const maxX = Math.max(...xValues); + expect(maxX - minX).toBeGreaterThan(500); + + // Height spread within layer should be moderate (not a single line) + const minY = Math.min(...yValues); + const maxY = Math.max(...yValues); + expect(maxY - minY).toBeGreaterThan(50); + expect(maxY - minY).toBeLessThan(250); // But not too tall + }); + + it('should sort nodes alphabetically within layers', () => { + const graph: KnowledgeGraph = { + nodes: [ + makeNode('z', 'Function', 'zFn'), + makeNode('a', 'Function', 'aFn'), + makeNode('m', 'Function', 'mFn'), + ], + relationships: [], + }; + + const positions = calculateTreeLayout(graph); + + // In grid layout, 'a' should appear before 'm' and 'z' in reading order + // (left-to-right, top-to-bottom) + const aPos = positions.get('a')!; + const mPos = positions.get('m')!; + const zPos = positions.get('z')!; + + // Reading order: a comes before m, which comes before z + const aIndex = aPos.y * 10000 + aPos.x; + const mIndex = mPos.y * 10000 + mPos.x; + const zIndex = zPos.y * 10000 + zPos.x; + + expect(aIndex).toBeLessThan(mIndex); + expect(mIndex).toBeLessThan(zIndex); + }); + + it('should place multiple node types in correct layers', () => { + const graph: KnowledgeGraph = { + nodes: [ + makeNode('folder', 'Folder', 'src'), + makeNode('file', 'File', 'main.ts'), + makeNode('iface', 'Interface', 'MyInterface'), + makeNode('enum', 'Enum', 'MyEnum'), + makeNode('method', 'Method', 'myMethod'), + ], + relationships: [], + }; + + const positions = calculateTreeLayout(graph); + + // Folder now appears below files/types/methods in the inverted tree view + expect(positions.get('file')!.y).toBeLessThan(positions.get('folder')!.y); + + // File (layer 1) should be below Class/Interface/Enum (layer 2) + expect(positions.get('iface')!.y).toBeLessThan(positions.get('file')!.y); + expect(positions.get('enum')!.y).toBeLessThan(positions.get('file')!.y); + + // Interface/Enum (layer 2) should be below Method (layer 3) + expect(positions.get('method')!.y).toBeLessThan(positions.get('iface')!.y); + expect(positions.get('method')!.y).toBeLessThan(positions.get('enum')!.y); + }); + + it('should keep node sizes reasonable', () => { + const graph: KnowledgeGraph = { + nodes: [ + makeNode('folder', 'Folder', 'src'), + makeNode('file', 'File', 'main.ts'), + makeNode('fn', 'Function', 'myFunc'), + ], + relationships: [], + }; + + const positions = calculateTreeLayout(graph); + + for (const id of ['folder', 'file', 'fn']) { + expect(positions.get(id)!.size).toBeGreaterThan(2); + expect(positions.get(id)!.size).toBeLessThan(25); + } + }); + + it('should spread sibling branches under their structural parent in auto mode', () => { + const graph: KnowledgeGraph = { + nodes: [ + makeNode('folder', 'Folder', 'apps'), + makeNode('fileA', 'File', 'a.ts'), + makeNode('fileB', 'File', 'b.ts'), + makeNode('fileC', 'File', 'c.ts'), + makeNode('fnA', 'Function', 'fnA'), + makeNode('fnB', 'Function', 'fnB'), + makeNode('fnC', 'Function', 'fnC'), + ], + relationships: [ + { id: 'r1', type: 'CONTAINS', sourceId: 'folder', targetId: 'fileA' }, + { id: 'r2', type: 'CONTAINS', sourceId: 'folder', targetId: 'fileB' }, + { id: 'r3', type: 'CONTAINS', sourceId: 'folder', targetId: 'fileC' }, + { id: 'r4', type: 'DEFINES', sourceId: 'fileA', targetId: 'fnA' }, + { id: 'r5', type: 'DEFINES', sourceId: 'fileB', targetId: 'fnB' }, + { id: 'r6', type: 'DEFINES', sourceId: 'fileC', targetId: 'fnC' }, + ], + }; + + const positions = calculateTreeLayout(graph); + const fileXs = ['fileA', 'fileB', 'fileC'].map((id) => positions.get(id)!.x); + const fnXs = ['fnA', 'fnB', 'fnC'].map((id) => positions.get(id)!.x); + + expect(Math.max(...fileXs) - Math.min(...fileXs)).toBeGreaterThan(120); + expect(Math.max(...fnXs) - Math.min(...fnXs)).toBeGreaterThan(120); + expect(Math.abs(positions.get('fileA')!.x - positions.get('fnA')!.x)).toBeLessThan(120); + expect(Math.abs(positions.get('fileB')!.x - positions.get('fnB')!.x)).toBeLessThan(120); + expect(Math.abs(positions.get('fileC')!.x - positions.get('fnC')!.x)).toBeLessThan(120); + }); + + it('should let long edges pull connected nodes closer without breaking their layer', () => { + const nodes = Array.from({ length: 10 }, (_, i) => makeNode(`fn${i}`, 'Function', `fn${i}`)); + + const baseline = calculateTreeLayout({ nodes, relationships: [] }); + const relaxed = calculateTreeLayout({ + nodes, + relationships: [ + { id: 'r1', type: 'CALLS', sourceId: 'fn0', targetId: 'fn9' }, + { id: 'r2', type: 'CALLS', sourceId: 'fn1', targetId: 'fn8' }, + ], + }); + + const baselineDistance = Math.abs(baseline.get('fn0')!.x - baseline.get('fn9')!.x); + const relaxedDistance = Math.abs(relaxed.get('fn0')!.x - relaxed.get('fn9')!.x); + expect(relaxedDistance).toBeLessThan(baselineDistance); + + const relaxedYValues = nodes.map((node) => relaxed.get(node.id)!.y); + const minY = Math.min(...relaxedYValues); + const maxY = Math.max(...relaxedYValues); + expect(maxY - minY).toBeGreaterThan(50); + expect(maxY - minY).toBeLessThan(250); + }); + + it('should preserve layer spread under heavy edge attraction', () => { + const nodes: GraphNode[] = [makeNode('file', 'File', 'hub.ts')]; + for (let i = 0; i < 18; i++) { + nodes.push(makeNode(`fn${i}`, 'Function', `fn${i}`)); + } + + const relationships = Array.from({ length: 18 }, (_, i) => ({ + id: `r${i}`, + type: 'CALLS', + sourceId: `fn${i}`, + targetId: 'file', + })); + + const positions = calculateTreeLayout({ nodes, relationships }); + const functionXs = Array.from({ length: 18 }, (_, i) => positions.get(`fn${i}`)!.x); + + expect(Math.max(...functionXs) - Math.min(...functionXs)).toBeGreaterThan(280); + expect(positions.get('file')!.y).toBeGreaterThan(positions.get('fn0')!.y); + }); +}); diff --git a/gitnexus-web/src/lib/tree-layout.ts b/gitnexus-web/src/lib/tree-layout.ts new file mode 100644 index 000000000..5fb284d5c --- /dev/null +++ b/gitnexus-web/src/lib/tree-layout.ts @@ -0,0 +1,570 @@ +import type { KnowledgeGraph } from '../core/graph/types'; +import type { GraphNode, NodeLabel } from 'gitnexus-shared'; +import { NODE_SIZES } from './constants'; + +export interface TreeNodePosition { + x: number; + y: number; + size: number; + depth: number; +} + +/** + * Maps node types to display layers in the tree view. + * Layer 0 = top (containers), Layer 3 = bottom (functions/methods). + */ +const TYPE_TO_LAYER: Record = { + // Layer 0: Structural containers + Project: 0, + Package: 0, + Module: 0, + Folder: 0, + Namespace: 0, + + // Layer 1: Files + File: 1, + Section: 1, + Import: 1, + Route: 1, + Tool: 1, + + // Layer 2: Type definitions + Class: 2, + Interface: 2, + Enum: 2, + Type: 2, + Struct: 2, + Trait: 2, + Union: 2, + Record: 2, + Typedef: 2, + Template: 2, + TypeAlias: 2, + + // Layer 3: Functions / Methods + Function: 3, + Method: 3, + Impl: 3, + Delegate: 3, + Constructor: 3, + Variable: 3, + Const: 3, + Static: 3, + Property: 3, + Decorator: 3, + Annotation: 3, + Macro: 3, + CodeElement: 3, +}; + +/** Fallback layer for unmapped types. */ +const DEFAULT_LAYER = 1; + +/** Virtual canvas size for layout calculation. */ +const CANVAS_WIDTH = 1200; +const CANVAS_HEIGHT = 800; +const LAYER_COUNT = 4; +const LAYER_HEIGHT = CANVAS_HEIGHT / LAYER_COUNT; // 200 +const PADDING_X = 60; +const PADDING_Y = 15; +const MIN_NODE_GAP = 45; +const MAX_LAYER_ROW_SPREAD = 132; +// HAS_METHOD and HAS_PROPERTY are Kotlin/Java-style hierarchy edges +// (Class→Method, Class→Property). Treat them like DEFINES for layout purposes +// so Methods/Properties cluster beneath their parent Class horizontally. +const HIERARCHY_RELATIONS = new Set(['CONTAINS', 'DEFINES', 'HAS_METHOD', 'HAS_PROPERTY']); +const MAX_X = (CANVAS_WIDTH - PADDING_X * 2) / 2; + +const RELATION_SPRING_WEIGHTS: Record = { + CONTAINS: 0.12, + DEFINES: 0.16, + HAS_METHOD: 0.16, // Same as DEFINES — keeps methods near their class + HAS_PROPERTY: 0.14, // Slightly weaker — properties can spread more + IMPORTS: 0.2, + CALLS: 0.24, + EXTENDS: 0.18, + IMPLEMENTS: 0.18, +}; + +function calculateNodeSize(layer: number, nodeType: NodeLabel): number { + const baseSize = NODE_SIZES[nodeType] || 6; + const layerMultiplier = Math.max(0.6, 1 - layer * 0.12); + return baseSize * layerMultiplier; +} + +function deterministicHash(str: string): number { + let hash = 5381; + for (let i = 0; i < str.length; i++) { + hash = (hash << 5) + hash + str.charCodeAt(i); + hash |= 0; + } + return (Math.abs(hash) % 10000) / 10000; +} + +function getNodeLayer(node: GraphNode): number { + return TYPE_TO_LAYER[node.label] ?? DEFAULT_LAYER; +} + +function buildHierarchyMaps(graph: KnowledgeGraph) { + const childrenByParent = new Map(); + const parentsByChild = new Map(); + + for (const rel of graph.relationships) { + if (!HIERARCHY_RELATIONS.has(rel.type)) continue; + + if (!childrenByParent.has(rel.sourceId)) { + childrenByParent.set(rel.sourceId, []); + } + childrenByParent.get(rel.sourceId)!.push(rel.targetId); + + if (!parentsByChild.has(rel.targetId)) { + parentsByChild.set(rel.targetId, []); + } + parentsByChild.get(rel.targetId)!.push(rel.sourceId); + } + + return { childrenByParent, parentsByChild }; +} + +function buildLayerNodeIds(graph: KnowledgeGraph): string[][] { + const nodeIdsByLayer: string[][] = Array.from({ length: LAYER_COUNT }, () => []); + + for (const node of graph.nodes) { + const layer = getNodeLayer(node); + if (layer >= 0 && layer < LAYER_COUNT) { + nodeIdsByLayer[layer].push(node.id); + } + } + + return nodeIdsByLayer; +} + +function getRestEdgeLength( + relationType: string, + source: TreeNodePosition, + target: TreeNodePosition, +) { + const depthGap = Math.abs(source.depth - target.depth); + const baseLength = HIERARCHY_RELATIONS.has(relationType) ? 60 : 85; + return baseLength + depthGap * 40; +} + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); +} + +function getLayerRowOffsets(nodeCount: number): number[] { + if (nodeCount <= 4) return [0]; + + const rowCount = nodeCount <= 16 ? 2 : 3; + const totalSpread = rowCount === 2 ? 72 : MAX_LAYER_ROW_SPREAD; + const rowGap = totalSpread / (rowCount - 1); + + return Array.from({ length: rowCount }, (_, rowIndex) => -totalSpread / 2 + rowIndex * rowGap); +} + +function placeNodesInSlice( + positions: Map, + nodes: GraphNode[], + startX: number, + slotWidth: number, + layerY: number, + layer: number, +) { + const rowOffsets = getLayerRowOffsets(nodes.length); + const rowCount = rowOffsets.length; + const baseNodesPerRow = Math.floor(nodes.length / rowCount); + const remainder = nodes.length % rowCount; + + let cursor = 0; + + for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) { + const nodesInRow = baseNodesPerRow + (rowIndex < remainder ? 1 : 0); + if (nodesInRow === 0) continue; + + const rowSpacing = slotWidth / nodesInRow; + for (let i = 0; i < nodesInRow; i++) { + const node = nodes[cursor++]; + positions.set(node.id, { + x: startX + (i + 0.5) * rowSpacing, + y: layerY + rowOffsets[rowIndex], + size: calculateNodeSize(layer, node.label), + depth: layer, + }); + } + } +} + +function enforceLayerSpacing( + layerNodeIds: string[], + positions: Map, + anchorXByNode: Map, +) { + if (layerNodeIds.length < 2) return; + + const sortedIds = [...layerNodeIds].sort((a, b) => positions.get(a)!.x - positions.get(b)!.x); + + for (let pass = 0; pass < 2; pass++) { + for (let i = 1; i < sortedIds.length; i++) { + const prev = positions.get(sortedIds[i - 1])!; + const curr = positions.get(sortedIds[i])!; + const minGap = Math.max(MIN_NODE_GAP * 0.65, (prev.size + curr.size) * 1.7); + const gap = curr.x - prev.x; + + if (gap < minGap) { + const push = (minGap - gap) / 2; + prev.x -= push; + curr.x += push; + } + } + + for (let i = sortedIds.length - 2; i >= 0; i--) { + const curr = positions.get(sortedIds[i])!; + const next = positions.get(sortedIds[i + 1])!; + const minGap = Math.max(MIN_NODE_GAP * 0.65, (curr.size + next.size) * 1.7); + const gap = next.x - curr.x; + + if (gap < minGap) { + const push = (minGap - gap) / 2; + curr.x -= push; + next.x += push; + } + } + } + + const anchorCenter = + sortedIds.reduce((sum, nodeId) => sum + (anchorXByNode.get(nodeId) ?? 0), 0) / sortedIds.length; + const currentCenter = + sortedIds.reduce((sum, nodeId) => sum + positions.get(nodeId)!.x, 0) / sortedIds.length; + const recenterDelta = currentCenter - anchorCenter; + + for (const nodeId of sortedIds) { + const pos = positions.get(nodeId)!; + pos.x = clamp(pos.x - recenterDelta, -MAX_X, MAX_X); + } +} + +/** + * Initialize positions using proportional X allocation. + * + * Each parent in layer N is allocated a horizontal slice proportional to how + * many direct hierarchy children it has in layer N+1. Children are then placed + * evenly within their parent's slice. Orphan nodes (no placed hierarchy parent) + * fill a proportional slice at the far right. + * + * Why this is better than uniform distribution: + * 1. Dense parents (many children) get more canvas space → no artificial + * crowding in the centre even before the physics simulation runs. + * 2. Each child starts within its parent's X slice → parent-child edges are + * short by construction, so the spring system converges quickly. + * 3. Orphan nodes land at the right end; their spring connections pull them + * toward better positions at runtime without fighting a spread force. + */ +function initProportionalPositions( + graph: KnowledgeGraph, + parentsByChild: Map, +): Map { + const positions = new Map(); + + // Group nodes by layer and build a fast layer-lookup map. + const nodesByLayer: GraphNode[][] = Array.from({ length: LAYER_COUNT }, () => []); + const nodeLayerMap = new Map(); + for (const node of graph.nodes) { + const layer = getNodeLayer(node); + if (layer >= 0 && layer < LAYER_COUNT) { + nodesByLayer[layer].push(node); + nodeLayerMap.set(node.id, layer); + } + } + + const availableWidth = CANVAS_WIDTH - PADDING_X * 2; + const halfWidth = availableWidth / 2; + const availableHeight = LAYER_HEIGHT - PADDING_Y * 2; + + // Y centre for a given logical layer (layer 0 = top). + const getLayerY = (layer: number): number => { + const visualLayer = LAYER_COUNT - 1 - layer; + return visualLayer * LAYER_HEIGHT + PADDING_Y + availableHeight / 2; + }; + + // --- Layer 0: sorted alphabetically, evenly spaced --- + const layer0Nodes = [...nodesByLayer[0]].sort((a, b) => + a.properties.name.localeCompare(b.properties.name), + ); + if (layer0Nodes.length > 0) { + const spacing = availableWidth / layer0Nodes.length; + for (let i = 0; i < layer0Nodes.length; i++) { + const node = layer0Nodes[i]; + positions.set(node.id, { + x: -halfWidth + (i + 0.5) * spacing, + y: getLayerY(0), + size: calculateNodeSize(0, node.label), + depth: 0, + }); + } + } + + // --- Layers 1-3: proportional allocation from their parents --- + for (let layer = 1; layer < LAYER_COUNT; layer++) { + const layerNodes = nodesByLayer[layer]; + if (layerNodes.length === 0) continue; + + const layerY = getLayerY(layer); + + // For each node, find its "primary parent": the already-placed hierarchy + // parent with the highest layer index (= closest ancestor in the tree). + // Walking all parents and picking the deepest-placed one means a Method + // prefers its Class over a distant Package, for example. + const assignedParent = new Map(); + for (const node of layerNodes) { + const parents = parentsByChild.get(node.id) ?? []; + let bestParent: string | null = null; + let bestParentLayer = -1; + for (const p of parents) { + if (!positions.has(p)) continue; // not yet placed + const pLayer = nodeLayerMap.get(p) ?? -1; + if (pLayer > bestParentLayer) { + bestParentLayer = pLayer; + bestParent = p; + } + } + if (bestParent) assignedParent.set(node.id, bestParent); + } + + // Bucket nodes into parent groups or orphans. + const childrenOfParent = new Map(); + const orphans: GraphNode[] = []; + for (const node of layerNodes) { + const p = assignedParent.get(node.id); + if (!p) { + orphans.push(node); + } else { + if (!childrenOfParent.has(p)) childrenOfParent.set(p, []); + childrenOfParent.get(p)!.push(node); + } + } + + // Sort within each parent's group and orphans alphabetically. + for (const children of childrenOfParent.values()) { + children.sort((a, b) => a.properties.name.localeCompare(b.properties.name)); + } + orphans.sort((a, b) => a.properties.name.localeCompare(b.properties.name)); + + // Sort active parents left-to-right by their placed X position. + const activeParents = [...childrenOfParent.keys()].sort( + (a, b) => (positions.get(a)?.x ?? 0) - (positions.get(b)?.x ?? 0), + ); + + const totalParented = layerNodes.length - orphans.length; + + // Divide the full canvas width: + // • parented children → (totalParented / total) fraction of width + // • orphans → remaining fraction at the right + const parentedWidth = + totalParented > 0 ? availableWidth * (totalParented / layerNodes.length) : 0; + const orphanWidth = availableWidth - parentedWidth; + + let curX = -halfWidth; + + // Place each parent's children in a sub-slice proportional to child count. + for (const parentId of activeParents) { + const children = childrenOfParent.get(parentId) ?? []; + if (children.length === 0) continue; + + const slotWidth = (children.length / totalParented) * parentedWidth; + placeNodesInSlice(positions, children, curX, slotWidth, layerY, layer); + curX += slotWidth; + } + + // Orphans fill the rightmost slice. + if (orphans.length > 0 && orphanWidth > 0) { + placeNodesInSlice(positions, orphans, curX, orphanWidth, layerY, layer); + } + } + + // Shift Y so the layout is centred at y = 0. + const centerY = CANVAS_HEIGHT / 2; + for (const pos of positions.values()) { + pos.y -= centerY; + } + + return positions; +} + +/** + * Tree view layout: type-layered grid with organic jitter and + * structure-aware horizontal branch shaping. + */ +export function calculateTreeLayout(graph: KnowledgeGraph): Map { + // Build hierarchy maps before initial placement so initProportionalPositions + // can assign each node to its closest placed ancestor's X slice. + const nodeIdsByLayer = buildLayerNodeIds(graph); + const { childrenByParent, parentsByChild } = buildHierarchyMaps(graph); + + // 1. Start with proportional X allocation: each parent gets a canvas slice + // proportional to its child count, so dense subtrees never crowd the centre. + const positions = initProportionalPositions(graph, parentsByChild); + + // 2. Add subtle Y jitter only — X jitter would scramble the hierarchy ordering + // that initProportionalPositions established (especially bad when node spacing < jitter). + for (const [nodeId, pos] of positions) { + pos.y += (deterministicHash(nodeId + 'y') - 0.5) * 20; + } + + // 3. Use structural edges to create a tree-like horizontal ordering while + // preserving the type-based vertical layers. + const STRUCTURE_ITERATIONS = 6; + for (let iter = 0; iter < STRUCTURE_ITERATIONS; iter++) { + const childTargets = new Map(); + + for (const [parentId, children] of childrenByParent) { + const parentPos = positions.get(parentId); + if (!parentPos || children.length === 0) continue; + + const childPositions = children + .map((childId) => ({ childId, pos: positions.get(childId) })) + .filter( + (entry): entry is { childId: string; pos: TreeNodePosition } => entry.pos !== undefined, + ) + .sort((a, b) => a.pos.x - b.pos.x); + + if (childPositions.length === 0) continue; + + const currentCenter = + childPositions.reduce((sum, entry) => sum + entry.pos.x, 0) / childPositions.length; + const shift = parentPos.x - currentCenter; + + for (const entry of childPositions) { + const existing = childTargets.get(entry.childId) || { sum: 0, count: 0 }; + existing.sum += entry.pos.x + shift; + existing.count += 1; + childTargets.set(entry.childId, existing); + } + } + + for (const [nodeId, target] of childTargets) { + const pos = positions.get(nodeId); + if (!pos) continue; + const avgTargetX = target.sum / target.count; + pos.x = pos.x * 0.45 + avgTargetX * 0.55; + } + + const parentTargets = new Map(); + for (const [parentId, children] of childrenByParent) { + const parentPos = positions.get(parentId); + if (!parentPos || children.length === 0) continue; + + const childXs = children + .map((childId) => positions.get(childId)?.x) + .filter((value): value is number => value !== undefined); + + if (childXs.length === 0) continue; + + const avgChildX = childXs.reduce((sum, value) => sum + value, 0) / childXs.length; + const existing = parentTargets.get(parentId) || { sum: 0, count: 0 }; + existing.sum += avgChildX; + existing.count += 1; + parentTargets.set(parentId, existing); + } + + for (const [nodeId, target] of parentTargets) { + const pos = positions.get(nodeId); + if (!pos) continue; + const avgTargetX = target.sum / target.count; + pos.x = pos.x * 0.65 + avgTargetX * 0.35; + } + } + + // 4. Pull childless nodes slightly toward their hierarchy parents when the + // graph has enough structure information to form branches. + for (const [nodeId, parents] of parentsByChild) { + if (childrenByParent.has(nodeId)) continue; + const pos = positions.get(nodeId); + if (!pos || parents.length === 0) continue; + + const parentXs = parents + .map((parentId) => positions.get(parentId)?.x) + .filter((value): value is number => value !== undefined); + + if (parentXs.length === 0) continue; + + const avgParentX = parentXs.reduce((sum, value) => sum + value, 0) / parentXs.length; + pos.x = pos.x * 0.7 + avgParentX * 0.3; + } + + // 5. Keep a per-node horizontal anchor so long edges can pull nodes closer + // without destroying each layer's original spread. + const anchorXByNode = new Map(); + for (const [nodeId, pos] of positions) { + anchorXByNode.set(nodeId, pos.x); + } + + // 6. Relax the graph like a constrained spring system. Only X is allowed + // to move, so node types stay on their original Y layers. + // For large graphs the spring phase is O(N×E×iterations) and would freeze + // the main thread — scale it down proportionally so the initial proportional + // layout (already good at large N) is kept without expensive refinement. + const nodeCount = graph.nodes.length; + const SPRING_ITERATIONS = nodeCount > 10000 ? 0 : nodeCount > 3000 ? 4 : 14; + for (let iter = 0; iter < SPRING_ITERATIONS; iter++) { + const deltaXByNode = new Map(); + + for (const [nodeId, pos] of positions) { + const anchorX = anchorXByNode.get(nodeId) ?? pos.x; + const normalizedDistance = Math.min(1, Math.abs(pos.x) / MAX_X); + const anchorStrength = 0.05 + normalizedDistance * normalizedDistance * 0.1; + deltaXByNode.set(nodeId, (anchorX - pos.x) * anchorStrength); + } + + for (const rel of graph.relationships) { + const sourcePos = positions.get(rel.sourceId); + const targetPos = positions.get(rel.targetId); + if (!sourcePos || !targetPos) continue; + + const dx = targetPos.x - sourcePos.x; + const dy = targetPos.y - sourcePos.y; + const distance = Math.sqrt(dx * dx + dy * dy) || 1; + const restLength = getRestEdgeLength(rel.type, sourcePos, targetPos); + const stretch = distance - restLength; + + if (stretch <= 0) continue; + + const springWeight = RELATION_SPRING_WEIGHTS[rel.type] ?? 0.14; + const pull = stretch * springWeight * 0.08; + const forceX = (dx / distance) * pull; + + deltaXByNode.set(rel.sourceId, (deltaXByNode.get(rel.sourceId) ?? 0) + forceX); + deltaXByNode.set(rel.targetId, (deltaXByNode.get(rel.targetId) ?? 0) - forceX); + } + + for (const [nodeId, pos] of positions) { + const deltaX = deltaXByNode.get(nodeId) ?? 0; + const normalizedDistance = Math.min(1, Math.abs(pos.x) / MAX_X); + const edgeResistance = 1 + normalizedDistance * normalizedDistance * 4.5; + const maxStep = 18 - normalizedDistance * 6; + const step = clamp(deltaX / edgeResistance, -maxStep, maxStep); + pos.x = clamp(pos.x + step, -MAX_X, MAX_X); + } + + for (const layerNodeIds of nodeIdsByLayer) { + enforceLayerSpacing(layerNodeIds, positions, anchorXByNode); + } + } + + // 7. Recenter and softly clamp X so the layout keeps its breadth without + // drifting too far off-canvas. + const xValues = Array.from(positions.values()).map((pos) => pos.x); + if (xValues.length > 0) { + const minX = Math.min(...xValues); + const maxX = Math.max(...xValues); + const centerX = (minX + maxX) / 2; + const halfSpan = Math.max(1, (maxX - minX) / 2); + const scale = halfSpan > MAX_X ? MAX_X / halfSpan : 1; + + for (const pos of positions.values()) { + pos.x = (pos.x - centerX) * scale; + } + } + + return positions; +} diff --git a/gitnexus-web/src/locales/en/chat.json b/gitnexus-web/src/locales/en/chat.json index 8fd9719bc..4d976c335 100644 --- a/gitnexus-web/src/locales/en/chat.json +++ b/gitnexus-web/src/locales/en/chat.json @@ -24,6 +24,7 @@ "clearChat": "Clear chat", "stopResponse": "Stop response" }, + "stopped": "Stopped by user", "badges": { "configureAI": "Configure AI", "connecting": "Connecting" diff --git a/gitnexus-web/src/locales/en/graph.json b/gitnexus-web/src/locales/en/graph.json index 6f4bdb66f..072e47c2d 100644 --- a/gitnexus-web/src/locales/en/graph.json +++ b/gitnexus-web/src/locales/en/graph.json @@ -10,7 +10,8 @@ "status": { "running": "running", "completed": "completed", - "error": "error" + "error": "error", + "stopped": "stopped" }, "tools": { "search": "🔍 Search Code", @@ -112,6 +113,12 @@ "codeNotAvailable": "Code not available in memory for {{path}}" }, "canvas": { + "viewModes": { + "label": "Graph view mode", + "force": "Force Graph", + "tree": "Sequential Layout", + "circles": "Radial Layout" + }, "zoomIn": "Zoom In", "zoomOut": "Zoom Out", "fit": "Fit to Screen", diff --git a/gitnexus-web/src/locales/zh-CN/chat.json b/gitnexus-web/src/locales/zh-CN/chat.json index 366fb7ccb..f83c5ad75 100644 --- a/gitnexus-web/src/locales/zh-CN/chat.json +++ b/gitnexus-web/src/locales/zh-CN/chat.json @@ -24,6 +24,7 @@ "clearChat": "清空聊天", "stopResponse": "停止响应" }, + "stopped": "用户已停止", "badges": { "configureAI": "配置 AI", "connecting": "连接中" diff --git a/gitnexus-web/src/locales/zh-CN/graph.json b/gitnexus-web/src/locales/zh-CN/graph.json index d65689f8a..7fc7c71e7 100644 --- a/gitnexus-web/src/locales/zh-CN/graph.json +++ b/gitnexus-web/src/locales/zh-CN/graph.json @@ -10,7 +10,8 @@ "status": { "running": "运行中", "completed": "已完成", - "error": "错误" + "error": "错误", + "stopped": "已停止" }, "tools": { "search": "🔍 搜索代码", @@ -112,6 +113,12 @@ "codeNotAvailable": "内存中没有 {{path}} 的代码内容" }, "canvas": { + "viewModes": { + "label": "图形视图模式", + "force": "力导向图", + "tree": "顺序布局", + "circles": "径向布局" + }, "zoomIn": "放大", "zoomOut": "缩小", "fit": "适应屏幕", diff --git a/gitnexus-web/test/unit/agent-abort.test.ts b/gitnexus-web/test/unit/agent-abort.test.ts new file mode 100644 index 000000000..2a8475e34 --- /dev/null +++ b/gitnexus-web/test/unit/agent-abort.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; +import { streamAgentResponse, type AgentMessage } from '../../src/core/llm/agent'; + +describe('streamAgentResponse abort', () => { + const userMessage: AgentMessage[] = [{ role: 'user', content: 'hello' }]; + + it('yields cancelled when the LangGraph stream throws AbortError', async () => { + const agent = { + stream: async () => { + throw new DOMException('The operation was aborted', 'AbortError'); + }, + }; + + const chunks = []; + for await (const chunk of streamAgentResponse(agent as any, userMessage, { + signal: new AbortController().signal, + })) { + chunks.push(chunk); + } + + expect(chunks).toEqual([{ type: 'cancelled' }]); + }); + + it('yields cancelled when the abort signal is set mid-stream', async () => { + const controller = new AbortController(); + const agent = { + stream: async function* () { + yield ['values', { messages: [] }]; + controller.abort(); + for (let i = 0; i < 100; i++) { + yield ['messages', [{ _getType: () => 'ai', content: 'still going' }]]; + } + }, + }; + + const chunks = []; + for await (const chunk of streamAgentResponse(agent as any, userMessage, { + signal: controller.signal, + })) { + chunks.push(chunk); + if (chunk.type === 'cancelled') break; + } + + expect(chunks[chunks.length - 1]).toEqual({ type: 'cancelled' }); + expect(chunks.filter((c) => c.type === 'error')).toEqual([]); + }); + + it('passes AbortSignal to agent.stream config', async () => { + const controller = new AbortController(); + let capturedConfig: Record | undefined; + + const agent = { + stream: async (_input: unknown, config: Record) => { + capturedConfig = config; + throw new DOMException('aborted', 'AbortError'); + }, + }; + + for await (const _chunk of streamAgentResponse(agent as any, userMessage, { + signal: controller.signal, + })) { + // drain + } + + expect(capturedConfig?.signal).toBe(controller.signal); + }); + + it('yields cancelled for a plain Error with name AbortError', async () => { + const agent = { + stream: async () => { + throw Object.assign(new Error('aborted'), { name: 'AbortError' }); + }, + }; + + const chunks = []; + for await (const chunk of streamAgentResponse(agent as any, userMessage)) { + chunks.push(chunk); + } + + expect(chunks).toEqual([{ type: 'cancelled' }]); + }); + + it('does not treat unrelated errors mentioning abort as cancellation', async () => { + const agent = { + stream: async () => { + throw new Error('Cannot abort the current transaction'); + }, + }; + + const chunks = []; + for await (const chunk of streamAgentResponse(agent as any, userMessage)) { + chunks.push(chunk); + } + + expect(chunks).toEqual([{ type: 'error', error: 'Cannot abort the current transaction' }]); + }); +}); diff --git a/gitnexus-web/test/unit/filter-panel.test.ts b/gitnexus-web/test/unit/filter-panel.test.ts index 93b691b3f..67ab3397c 100644 --- a/gitnexus-web/test/unit/filter-panel.test.ts +++ b/gitnexus-web/test/unit/filter-panel.test.ts @@ -28,6 +28,8 @@ const ICON_MAP: Record = { Decorator: 'AtSign', Import: 'FileCode', Variable: 'Variable', + Property: 'Variable', + Const: 'Target', }; describe('filter panel icon mappings', () => { diff --git a/gitnexus-web/vitest.config.ts b/gitnexus-web/vitest.config.ts index e1460d840..1de8ef065 100644 --- a/gitnexus-web/vitest.config.ts +++ b/gitnexus-web/vitest.config.ts @@ -25,7 +25,7 @@ export default defineConfig({ globals: true, environment: 'jsdom', setupFiles: ['./test/setup.ts'], - include: ['test/**/*.test.{ts,tsx}'], + include: ['test/**/*.test.{ts,tsx}', 'src/**/*.test.{ts,tsx}'], testTimeout: 15000, coverage: { provider: 'v8', diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 3ba1bae5e..4a7575c02 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -1307,9 +1307,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.130.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz", - "integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==", + "version": "0.132.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", + "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", "dev": true, "license": "MIT", "funding": { @@ -1387,9 +1387,9 @@ "license": "BSD-3-Clause" }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz", - "integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", + "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", "cpu": [ "arm64" ], @@ -1404,9 +1404,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz", - "integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", + "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", "cpu": [ "arm64" ], @@ -1421,9 +1421,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz", - "integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", + "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", "cpu": [ "x64" ], @@ -1438,9 +1438,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz", - "integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", + "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", "cpu": [ "x64" ], @@ -1455,9 +1455,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz", - "integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", + "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", "cpu": [ "arm" ], @@ -1472,9 +1472,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz", - "integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", + "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", "cpu": [ "arm64" ], @@ -1489,9 +1489,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz", - "integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", + "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", "cpu": [ "arm64" ], @@ -1506,9 +1506,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz", - "integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", + "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", "cpu": [ "ppc64" ], @@ -1523,9 +1523,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz", - "integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", + "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", "cpu": [ "s390x" ], @@ -1540,9 +1540,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz", - "integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", + "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", "cpu": [ "x64" ], @@ -1557,9 +1557,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz", - "integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", + "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", "cpu": [ "x64" ], @@ -1574,9 +1574,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz", - "integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", + "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", "cpu": [ "arm64" ], @@ -1591,9 +1591,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz", - "integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", + "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", "cpu": [ "wasm32" ], @@ -1621,9 +1621,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz", - "integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", + "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", "cpu": [ "arm64" ], @@ -1638,9 +1638,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz", - "integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", + "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", "cpu": [ "x64" ], @@ -1792,9 +1792,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.8.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz", - "integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==", + "version": "25.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", + "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", "license": "MIT", "dependencies": { "undici-types": ">=7.24.0 <7.24.7" @@ -1847,14 +1847,14 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.6.tgz", - "integrity": "sha512-36l628fQ/9a/8ihy97eOtEnvWQEdqULQOJtcaxtoNq0G1w3Mxd4szSahOaMM9/NGyZ+hyKcMtIW/WIxq0XQViQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.7.tgz", + "integrity": "sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.6", + "@vitest/utils": "4.1.7", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -1868,8 +1868,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.6", - "vitest": "4.1.6" + "@vitest/browser": "4.1.7", + "vitest": "4.1.7" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -1878,16 +1878,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz", - "integrity": "sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", + "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.6", - "@vitest/utils": "4.1.6", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -1896,13 +1896,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz", - "integrity": "sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", + "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.6", + "@vitest/spy": "4.1.7", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -1923,9 +1923,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.6.tgz", - "integrity": "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", + "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", "dev": true, "license": "MIT", "dependencies": { @@ -1936,13 +1936,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.6.tgz", - "integrity": "sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", + "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.6", + "@vitest/utils": "4.1.7", "pathe": "^2.0.3" }, "funding": { @@ -1950,14 +1950,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.6.tgz", - "integrity": "sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", + "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.6", - "@vitest/utils": "4.1.6", + "@vitest/pretty-format": "4.1.7", + "@vitest/utils": "4.1.7", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1966,9 +1966,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.6.tgz", - "integrity": "sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", + "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", "dev": true, "license": "MIT", "funding": { @@ -1976,13 +1976,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.6.tgz", - "integrity": "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", + "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.6", + "@vitest/pretty-format": "4.1.7", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -3594,9 +3594,9 @@ "license": "Apache-2.0" }, "node_modules/lru-cache": { - "version": "11.3.6", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz", - "integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==", + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", + "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -4112,9 +4112,9 @@ "license": "MIT" }, "node_modules/postcss": { - "version": "8.5.14", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", - "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -4132,7 +4132,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -4291,13 +4291,13 @@ } }, "node_modules/rolldown": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz", - "integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", + "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.130.0", + "@oxc-project/types": "=0.132.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -4307,21 +4307,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.1", - "@rolldown/binding-darwin-arm64": "1.0.1", - "@rolldown/binding-darwin-x64": "1.0.1", - "@rolldown/binding-freebsd-x64": "1.0.1", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.1", - "@rolldown/binding-linux-arm64-gnu": "1.0.1", - "@rolldown/binding-linux-arm64-musl": "1.0.1", - "@rolldown/binding-linux-ppc64-gnu": "1.0.1", - "@rolldown/binding-linux-s390x-gnu": "1.0.1", - "@rolldown/binding-linux-x64-gnu": "1.0.1", - "@rolldown/binding-linux-x64-musl": "1.0.1", - "@rolldown/binding-openharmony-arm64": "1.0.1", - "@rolldown/binding-wasm32-wasi": "1.0.1", - "@rolldown/binding-win32-arm64-msvc": "1.0.1", - "@rolldown/binding-win32-x64-msvc": "1.0.1" + "@rolldown/binding-android-arm64": "1.0.2", + "@rolldown/binding-darwin-arm64": "1.0.2", + "@rolldown/binding-darwin-x64": "1.0.2", + "@rolldown/binding-freebsd-x64": "1.0.2", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", + "@rolldown/binding-linux-arm64-gnu": "1.0.2", + "@rolldown/binding-linux-arm64-musl": "1.0.2", + "@rolldown/binding-linux-ppc64-gnu": "1.0.2", + "@rolldown/binding-linux-s390x-gnu": "1.0.2", + "@rolldown/binding-linux-x64-gnu": "1.0.2", + "@rolldown/binding-linux-x64-musl": "1.0.2", + "@rolldown/binding-openharmony-arm64": "1.0.2", + "@rolldown/binding-wasm32-wasi": "1.0.2", + "@rolldown/binding-win32-arm64-msvc": "1.0.2", + "@rolldown/binding-win32-x64-msvc": "1.0.2" } }, "node_modules/router": { @@ -5167,16 +5167,16 @@ } }, "node_modules/vite": { - "version": "8.0.13", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz", - "integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==", + "version": "8.0.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", + "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", - "postcss": "^8.5.14", - "rolldown": "1.0.1", + "postcss": "^8.5.15", + "rolldown": "1.0.2", "tinyglobby": "^0.2.16" }, "bin": { @@ -5245,19 +5245,19 @@ } }, "node_modules/vitest": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz", - "integrity": "sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", + "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.6", - "@vitest/mocker": "4.1.6", - "@vitest/pretty-format": "4.1.6", - "@vitest/runner": "4.1.6", - "@vitest/snapshot": "4.1.6", - "@vitest/spy": "4.1.6", - "@vitest/utils": "4.1.6", + "@vitest/expect": "4.1.7", + "@vitest/mocker": "4.1.7", + "@vitest/pretty-format": "4.1.7", + "@vitest/runner": "4.1.7", + "@vitest/snapshot": "4.1.7", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -5285,12 +5285,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.6", - "@vitest/browser-preview": "4.1.6", - "@vitest/browser-webdriverio": "4.1.6", - "@vitest/coverage-istanbul": "4.1.6", - "@vitest/coverage-v8": "4.1.6", - "@vitest/ui": "4.1.6", + "@vitest/browser-playwright": "4.1.7", + "@vitest/browser-preview": "4.1.7", + "@vitest/browser-webdriverio": "4.1.7", + "@vitest/coverage-istanbul": "4.1.7", + "@vitest/coverage-v8": "4.1.7", + "@vitest/ui": "4.1.7", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" diff --git a/gitnexus/scripts/cross-platform-tests.ts b/gitnexus/scripts/cross-platform-tests.ts index 88afdd75d..be8cbcdb6 100644 --- a/gitnexus/scripts/cross-platform-tests.ts +++ b/gitnexus/scripts/cross-platform-tests.ts @@ -59,6 +59,7 @@ const LBUG_NATIVE = [ 'test/integration/lbug-close-handle-release.test.ts', 'test/integration/lbug-orphan-sidecar-recovery.test.ts', 'test/integration/lbug-readonly-init.test.ts', + 'test/integration/lbug-non-ascii-path.test.ts', 'test/integration/local-backend.test.ts', 'test/integration/local-backend-calltool.test.ts', 'test/integration/search-core.test.ts', diff --git a/gitnexus/src/cli/doctor.ts b/gitnexus/src/cli/doctor.ts index b137d9d50..0fb2a98a0 100644 --- a/gitnexus/src/cli/doctor.ts +++ b/gitnexus/src/cli/doctor.ts @@ -1,6 +1,7 @@ import { getRuntimeCapabilities, getRuntimeFingerprint } from '../core/platform/capabilities.js'; import { resolveEmbeddingConfig } from '../core/embeddings/config.js'; import { isHttpMode } from '../core/embeddings/http-client.js'; +import { checkLbugNative } from '../core/lbug/native-check.js'; import { t } from './i18n/index.js'; function isCombiningMark(codePoint: number): boolean { @@ -59,6 +60,13 @@ export const doctorCommand = async () => { console.log(` ${label('doctor.labels.node', 10)}${fingerprint.node}`); console.log(` ${label('doctor.labels.gitnexus', 10)}${fingerprint.gitnexus}`); console.log(` ${label('doctor.labels.ladybugdb', 10)}${fingerprint.ladybugdb ?? 'unknown'}`); + const nativeCheck = checkLbugNative(); + if (nativeCheck.ok) { + console.log(` ${padDisplayEnd('native', 10)}✓ lbugjs.node loaded`); + } else { + console.log(` ${padDisplayEnd('native', 10)}✗ lbugjs.node missing`); + process.stderr.write(`\n${nativeCheck.message?.replace(/^/gm, ' ')}\n\n`); + } console.log(` ${label('doctor.labels.onnx', 10)}${fingerprint.onnxruntime ?? 'unknown'}`); console.log(''); console.log(t('doctor.capabilities')); diff --git a/gitnexus/src/cli/eval-server.ts b/gitnexus/src/cli/eval-server.ts index d8e171947..f28225e0c 100644 --- a/gitnexus/src/cli/eval-server.ts +++ b/gitnexus/src/cli/eval-server.ts @@ -206,19 +206,39 @@ export function formatImpactResult(result: any): string { 3: 'MAY NEED TESTING (transitive)', }; - for (const depth of [1, 2, 3]) { - const items = byDepth[depth]; - if (!items || items.length === 0) continue; - - lines.push(`d=${depth}: ${depthLabels[depth] || ''} (${items.length})`); - for (const item of items.slice(0, 12)) { - const conf = item.confidence < 1 ? ` (conf: ${item.confidence})` : ''; - lines.push(` ${item.type} ${item.name} → ${item.filePath} [${item.relationType}]${conf}`); - } - if (items.length > 12) { - lines.push(` ... and ${items.length - 12} more`); + if (!result.byDepth && result.byDepthCounts) { + lines.push('(summary only — use summaryOnly: false to see symbol lists)'); + const depthCounts = result.byDepthCounts; + for (const depth of [1, 2, 3]) { + const count = depthCounts[depth] ?? 0; + if (count === 0) continue; + lines.push(`d=${depth}: ${depthLabels[depth] || ''} (${count})`); } lines.push(''); + } else { + const depthCounts = result.byDepthCounts || {}; + for (const depth of [1, 2, 3]) { + const items = byDepth[depth] || []; + const trueCount = depthCounts[depth] ?? items.length; + if (trueCount === 0) continue; + + lines.push(`d=${depth}: ${depthLabels[depth] || ''} (${trueCount})`); + if (items.length === 0) { + lines.push(` (0 items on this page — adjust offset)`); + } else { + const shown = Math.min(items.length, 12); + for (const item of items.slice(0, shown)) { + const conf = item.confidence < 1 ? ` (conf: ${item.confidence})` : ''; + lines.push( + ` ${item.type} ${item.name} → ${item.filePath} [${item.relationType}]${conf}`, + ); + } + if (trueCount > shown) { + lines.push(` ... and ${trueCount - shown} more`); + } + } + lines.push(''); + } } return lines.join('\n').trim(); diff --git a/gitnexus/src/cli/help-i18n.ts b/gitnexus/src/cli/help-i18n.ts index 1c4312112..8d620fc6f 100644 --- a/gitnexus/src/cli/help-i18n.ts +++ b/gitnexus/src/cli/help-i18n.ts @@ -103,6 +103,9 @@ const OPTION_DESCRIPTION_KEYS = { 'impact|-r, --repo ': 'help.option.repo.target', 'impact|--depth ': 'help.option.impact.depth', 'impact|--include-tests': 'help.option.impact.includeTests', + 'impact|--limit ': 'help.option.impact.limit', + 'impact|--offset ': 'help.option.impact.offset', + 'impact|--summary-only': 'help.option.impact.summaryOnly', 'cypher|-r, --repo ': 'help.option.repo.target', 'detect-changes|-s, --scope ': 'help.option.detectChanges.scope', 'detect-changes|-b, --base-ref ': 'help.option.detectChanges.baseRef', diff --git a/gitnexus/src/cli/i18n/en.ts b/gitnexus/src/cli/i18n/en.ts index e808f244b..51cbc2816 100644 --- a/gitnexus/src/cli/i18n/en.ts +++ b/gitnexus/src/cli/i18n/en.ts @@ -216,6 +216,9 @@ export const en = { 'help.option.impact.direction': 'upstream (dependants) or downstream (dependencies)', 'help.option.impact.depth': 'Max relationship depth (default: 3)', 'help.option.impact.includeTests': 'Include test files in results', + 'help.option.impact.limit': 'Max symbols per depth level (default: 100)', + 'help.option.impact.offset': 'Skip N symbols per depth level for pagination', + 'help.option.impact.summaryOnly': 'Return counts and risk only, omit symbol list', 'help.option.detectChanges.scope': 'What to analyze: unstaged, staged, all, or compare', 'help.option.detectChanges.baseRef': 'Branch/commit for compare scope (e.g. main)', 'help.option.evalServer.host': diff --git a/gitnexus/src/cli/i18n/zh-CN.ts b/gitnexus/src/cli/i18n/zh-CN.ts index 9e896f33e..2a1374fc6 100644 --- a/gitnexus/src/cli/i18n/zh-CN.ts +++ b/gitnexus/src/cli/i18n/zh-CN.ts @@ -202,6 +202,9 @@ export const zhCN = { 'help.option.impact.direction': 'upstream(依赖它的项)或 downstream(它依赖的项)', 'help.option.impact.depth': '最大关系遍历深度(默认:3)', 'help.option.impact.includeTests': '在结果中包含测试文件', + 'help.option.impact.limit': '每层深度最大符号数(默认:100)', + 'help.option.impact.offset': '每层深度跳过 N 个符号(分页用)', + 'help.option.impact.summaryOnly': '仅返回计数和风险等级,省略符号列表', 'help.option.detectChanges.scope': '分析范围:unstaged、staged、all 或 compare', 'help.option.detectChanges.baseRef': 'compare 范围的分支/提交(例如 main)', 'help.option.evalServer.host': '绑定地址(默认:127.0.0.1;用 0.0.0.0 暴露到所有网卡)', diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index a5347effd..67b34387a 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -5,7 +5,7 @@ import { Command } from 'commander'; import { createRequire } from 'node:module'; -import { createLazyAction } from './lazy-action.js'; +import { createLazyAction, createLbugLazyAction } from './lazy-action.js'; import { registerGroupCommands } from './group.js'; import { localizeCliHelp } from './help-i18n.js'; import { t } from './i18n/index.js'; @@ -89,7 +89,7 @@ program .option('--embedding-sub-batch-size ', 'Number of chunks per embedding model call') .option('--embedding-device ', 'Embedding device: auto, cpu, dml, cuda, or wasm') .addHelpText('after', () => t('help.analyze.environment')) - .action(createLazyAction(() => import('./analyze.js'), 'analyzeCommand')); + .action(createLbugLazyAction(() => import('./analyze.js'), 'analyzeCommand')); program .command('index [path...]') @@ -105,12 +105,12 @@ program .description('Start local HTTP server for web UI connection') .option('-p, --port ', 'Port number', '4747') .option('--host ', 'Bind address (default: 127.0.0.1, use 0.0.0.0 for remote access)') - .action(createLazyAction(() => import('./serve.js'), 'serveCommand')); + .action(createLbugLazyAction(() => import('./serve.js'), 'serveCommand')); program .command('mcp') .description('Start MCP server (stdio) — serves all indexed repos') - .action(createLazyAction(() => import('./mcp.js'), 'mcpCommand')); + .action(createLbugLazyAction(() => import('./mcp.js'), 'mcpCommand')); program .command('list') @@ -177,12 +177,12 @@ program '--lang ', 'Output language for generated documentation (e.g. english, chinese, spanish, japanese)', ) - .action(createLazyAction(() => import('./wiki.js'), 'wikiCommand')); + .action(createLbugLazyAction(() => import('./wiki.js'), 'wikiCommand')); program .command('augment ') .description('Augment a search pattern with knowledge graph context (used by hooks)') - .action(createLazyAction(() => import('./augment.js'), 'augmentCommand')); + .action(createLbugLazyAction(() => import('./augment.js'), 'augmentCommand')); program .command('publish [path]') @@ -207,7 +207,7 @@ program .option('-g, --goal ', 'What you want to find') .option('-l, --limit ', 'Max processes to return (default: 5)') .option('--content', 'Include full symbol source code') - .action(createLazyAction(() => import('./tool.js'), 'queryCommand')); + .action(createLbugLazyAction(() => import('./tool.js'), 'queryCommand')); program .command('context [name]') @@ -216,7 +216,7 @@ program .option('-u, --uid ', 'Direct symbol UID (zero-ambiguity lookup)') .option('-f, --file ', 'File path to disambiguate common names') .option('--content', 'Include full symbol source code') - .action(createLazyAction(() => import('./tool.js'), 'contextCommand')); + .action(createLbugLazyAction(() => import('./tool.js'), 'contextCommand')); program .command('impact ') @@ -225,13 +225,16 @@ program .option('-r, --repo ', 'Target repository') .option('--depth ', 'Max relationship depth (default: 3)') .option('--include-tests', 'Include test files in results') - .action(createLazyAction(() => import('./tool.js'), 'impactCommand')); + .option('--limit ', 'Max symbols per depth level (default: 100)') + .option('--offset ', 'Skip N symbols per depth level for pagination') + .option('--summary-only', 'Return counts and risk only, omit symbol list') + .action(createLbugLazyAction(() => import('./tool.js'), 'impactCommand')); program .command('cypher ') .description('Execute raw Cypher query against the knowledge graph') .option('-r, --repo ', 'Target repository') - .action(createLazyAction(() => import('./tool.js'), 'cypherCommand')); + .action(createLbugLazyAction(() => import('./tool.js'), 'cypherCommand')); program .command('detect-changes') @@ -240,7 +243,7 @@ program .option('-s, --scope ', 'What to analyze: unstaged, staged, all, or compare', 'unstaged') .option('-b, --base-ref ', 'Branch/commit for compare scope (e.g. main)') .option('-r, --repo ', 'Target repository') - .action(createLazyAction(() => import('./tool.js'), 'detectChangesCommand')); + .action(createLbugLazyAction(() => import('./tool.js'), 'detectChangesCommand')); // ─── Eval Server (persistent daemon for SWE-bench) ───────────────── @@ -253,7 +256,7 @@ program 'Bind address (default: 127.0.0.1, use 0.0.0.0 to expose to all interfaces)', ) .option('--idle-timeout ', 'Auto-shutdown after N seconds idle (0 = disabled)', '0') - .action(createLazyAction(() => import('./eval-server.js'), 'evalServerCommand')); + .action(createLbugLazyAction(() => import('./eval-server.js'), 'evalServerCommand')); registerGroupCommands(program); localizeCliHelp(program); diff --git a/gitnexus/src/cli/lazy-action.ts b/gitnexus/src/cli/lazy-action.ts index 0655a8f4b..3a7bea846 100644 --- a/gitnexus/src/cli/lazy-action.ts +++ b/gitnexus/src/cli/lazy-action.ts @@ -4,6 +4,8 @@ * at compile time — catching typos when used with concrete module imports. */ +import { checkLbugNative } from '../core/lbug/native-check.js'; + function isCallable(value: unknown): value is (...args: unknown[]) => unknown { return typeof value === 'function'; } @@ -21,3 +23,23 @@ export function createLazyAction< await action(...args); }; } + +export function createLbugLazyAction< + TModule extends Record, + TKey extends string & keyof TModule, +>(loader: () => Promise, exportName: TKey): (...args: unknown[]) => Promise { + return async (...args: unknown[]): Promise => { + const check = checkLbugNative(); + if (!check.ok) { + process.stderr.write(`\n ${check.message?.replace(/\n/g, '\n ')}\n\n`); + process.exitCode = 1; + return; + } + const module = await loader(); + const action = module[exportName]; + if (!isCallable(action)) { + throw new Error(`Lazy action export not found: ${exportName}`); + } + await action(...args); + }; +} diff --git a/gitnexus/src/cli/tool.ts b/gitnexus/src/cli/tool.ts index dd15f09c8..0e70c3970 100644 --- a/gitnexus/src/cli/tool.ts +++ b/gitnexus/src/cli/tool.ts @@ -117,6 +117,9 @@ export async function impactCommand( repo?: string; depth?: string; includeTests?: boolean; + limit?: string; + offset?: string; + summaryOnly?: boolean; }, ): Promise { if (!target?.trim()) { @@ -126,12 +129,19 @@ export async function impactCommand( try { const backend = await getBackend(); + const rawLimit = parseInt(options?.limit ?? '', 10); + const rawOffset = parseInt(options?.offset ?? '', 10); + const parsedLimit = Number.isFinite(rawLimit) ? rawLimit : undefined; + const parsedOffset = Number.isFinite(rawOffset) ? rawOffset : undefined; const result = await backend.callTool('impact', { target, direction: options?.direction || 'upstream', maxDepth: options?.depth ? parseInt(options.depth, 10) : undefined, includeTests: options?.includeTests ?? false, repo: options?.repo, + limit: parsedLimit, + offset: parsedOffset, + summaryOnly: options?.summaryOnly ?? undefined, }); output(result); } catch (err: unknown) { diff --git a/gitnexus/src/config/ignore-service.ts b/gitnexus/src/config/ignore-service.ts index 17b9d14bd..02a26068e 100644 --- a/gitnexus/src/config/ignore-service.ts +++ b/gitnexus/src/config/ignore-service.ts @@ -87,6 +87,7 @@ const DEFAULT_IGNORE_LIST = new Set([ '.generated', 'generated', 'auto-generated', + 'monaco-workers', // Monaco editor web-worker bundles generated for browser runtime '.terraform', '.serverless', @@ -323,14 +324,6 @@ export const shouldIgnorePath = (filePath: string): boolean => { } } - // Ignore hidden files (starting with .) - if (fileName.startsWith('.') && fileName !== '.') { - // But allow some important config files - const allowedDotFiles = ['.env', '.gitignore']; // Already in IGNORED_FILES, so this is redundant - // Actually, let's NOT ignore all dot files - many are important configs - // Just rely on the explicit lists above - } - // Ignore files that look like generated/bundled code if ( fileNameLower.includes('.bundle.') || diff --git a/gitnexus/src/core/group/cross-impact.ts b/gitnexus/src/core/group/cross-impact.ts index eab942a62..e8db52dd5 100644 --- a/gitnexus/src/core/group/cross-impact.ts +++ b/gitnexus/src/core/group/cross-impact.ts @@ -25,6 +25,10 @@ import { getGroupDir } from './storage.js'; import { closeBridgeDb, openBridgeDbReadOnly, queryBridge, readBridgeMeta } from './bridge-db.js'; import { BRIDGE_SCHEMA_VERSION } from './bridge-schema.js'; +// High limit for the local phase of group impact so collectImpactSymbolUids +// sees (nearly) all symbols. Bypasses the MCP-facing default of 100. +const GROUP_LOCAL_PHASE_LIMIT = 10000; + /** Cross-boundary hops beyond this value are clamped (multi-hop reserved for future work). */ export const MAX_SUPPORTED_CROSS_DEPTH = 1; @@ -429,6 +433,7 @@ export async function runGroupImpact( relationTypes: relationTypes && relationTypes.length > 0 ? relationTypes : undefined, includeTests, minConfidence, + limit: GROUP_LOCAL_PHASE_LIMIT, }; const deadline = Date.now() + Math.max(0, timeoutMs); diff --git a/gitnexus/src/core/group/service.ts b/gitnexus/src/core/group/service.ts index d0473048f..b957db15b 100644 --- a/gitnexus/src/core/group/service.ts +++ b/gitnexus/src/core/group/service.ts @@ -43,6 +43,7 @@ export interface GroupToolPort { relationTypes?: string[]; includeTests?: boolean; minConfidence?: number; + limit?: number; }, ): Promise; query( diff --git a/gitnexus/src/core/ingestion/languages/cobol.ts b/gitnexus/src/core/ingestion/languages/cobol.ts index 6e54c2309..a28f87a8f 100644 --- a/gitnexus/src/core/ingestion/languages/cobol.ts +++ b/gitnexus/src/core/ingestion/languages/cobol.ts @@ -6,11 +6,18 @@ * processed by cobol-processor.ts in pipeline Phase 2.6, not by the * tree-sitter pipeline. * - * This provider exists to satisfy the SupportedLanguages exhaustiveness - * checks and to declare parseStrategy: 'standalone'. + * This provider supports scope-based resolution (RFC #909 Ring 3) via + * `emitScopeCaptures` which wraps the regex tagger. COPY statements are + * interpreted as imports; there is no type system and no implicit receiver. */ import { SupportedLanguages } from 'gitnexus-shared'; import { defineLanguage } from '../language-provider.js'; +import { + emitCobolScopeCaptures, + interpretCobolImport, + cobolImportOwningScope, + cobolReceiverBinding, +} from './cobol/index.js'; export const cobolProvider = defineLanguage({ id: SupportedLanguages.Cobol, @@ -26,4 +33,10 @@ export const cobolProvider = defineLanguage({ }, exportChecker: () => false, importResolver: () => null, + + // ── Scope-resolution hooks ─────────────────────────────────────── + emitScopeCaptures: emitCobolScopeCaptures, + interpretImport: interpretCobolImport, + importOwningScope: cobolImportOwningScope, + receiverBinding: cobolReceiverBinding, }); diff --git a/gitnexus/src/core/ingestion/languages/cobol/captures.ts b/gitnexus/src/core/ingestion/languages/cobol/captures.ts new file mode 100644 index 000000000..69a6c80b9 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cobol/captures.ts @@ -0,0 +1,291 @@ +/** + * `emitScopeCaptures` for COBOL. + * + * Wraps the existing regex tagger (`extractCobolSymbolsWithRegex`) and + * produces parser-agnostic `CaptureMatch[]` matching the RFC §5.1 + * vocabulary. The central `ScopeExtractor` consumes these captures + * without knowing whether they came from tree-sitter or regex. + * + * Pure given the input source text. No I/O, no globals consulted. + * The regex tagger is synchronous — no async needed. + */ + +import type { Capture, CaptureMatch, Range } from 'gitnexus-shared'; +import { + extractCobolSymbolsWithRegex, + preprocessCobolSource, +} from '../../cobol/cobol-preprocessor.js'; + +// --------------------------------------------------------------------------- +// Capture building helpers +// --------------------------------------------------------------------------- + +function capture(name: string, range: Range, text: string): Capture { + return { name, range, text }; +} + +function rangeOf(startLine: number, startCol: number, endLine: number, endCol: number): Range { + return { startLine, startCol, endLine, endCol }; +} + +/** + * Build a single CaptureMatch from a record of captures. + * Returns null if the record is empty. + */ +function matchFrom(grouped: Record): CaptureMatch | null { + if (Object.keys(grouped).length === 0) return null; + return Object.freeze(grouped) as CaptureMatch; +} + +/** + * Compute end column for a single-line capture from the source lines array. + */ +function endColFrom(line: string): number { + return line.length > 0 ? line.length - 1 : 0; +} + +// --------------------------------------------------------------------------- +// Main entry point +// --------------------------------------------------------------------------- + +export function emitCobolScopeCaptures( + sourceText: string, + _filePath: string, + _cachedTree?: unknown, +): readonly CaptureMatch[] { + const lines = sourceText.split(/\r?\n/); + // Preprocess: strip patch markers from columns 1-6 + const cleaned = preprocessCobolSource(sourceText); + // Run the regex tagger on the preprocessed source + const extracted = extractCobolSymbolsWithRegex(cleaned, _filePath); + + const out: CaptureMatch[] = []; + + // ── 1. PROGRAM-ID → @scope.module ─────────────────────────────────── + // The primary program name (first PROGRAM-ID encountered) + if (extracted.programName) { + const name = extracted.programName; + const lastLine = lines.length; + + const progDef = extracted.programs.find((p) => p.name.toUpperCase() === name.toUpperCase()); + const startLine = progDef?.startLine ?? 1; + const endLine = progDef?.endLine ?? lastLine; + const startCol = 0; + const endCol = endColFrom(lines[Math.min(endLine, lines.length) - 1] ?? ''); + + const progIdLine = findProgramIdLine(cleaned, name); + const nameRange = + progIdLine !== -1 + ? rangeOf(progIdLine, 7, progIdLine, lines[progIdLine - 1]?.length ?? endCol) + : rangeOf(startLine, startCol, endLine, endCol); + + const grouped: Record = { + '@scope.module': capture('@scope.module', nameRange, name), + '@declaration.program': capture( + '@declaration.program', + rangeOf(startLine, startCol, endLine, endCol), + name, + ), + '@declaration.name': capture('@declaration.name', nameRange, name), + }; + + if (progDef?.procedureUsing && progDef.procedureUsing.length > 0) { + grouped['@declaration.parameter-count'] = capture( + '@declaration.parameter-count', + nameRange, + String(progDef.procedureUsing.length), + ); + } + + const m = matchFrom(grouped); + if (m !== null) out.push(m); + } + + // ── 2. Nested / additional programs → @scope.module ────────────── + for (const prog of extracted.programs) { + if (extracted.programName && prog.name.toUpperCase() === extracted.programName.toUpperCase()) + continue; + + const startLine = prog.startLine; + const endLine = prog.endLine; + const startCol = 0; + const endCol = endColFrom(lines[Math.min(endLine, lines.length) - 1] ?? ''); + + const progIdLine = findProgramIdLine(cleaned, prog.name); + const nameRange = + progIdLine !== -1 + ? rangeOf(progIdLine, 7, progIdLine, lines[progIdLine - 1]?.length ?? endCol) + : rangeOf(startLine, startCol, endLine, endCol); + + const grouped: Record = { + '@scope.module': capture('@scope.module', nameRange, prog.name), + '@declaration.program': capture( + '@declaration.program', + rangeOf(startLine, startCol, endLine, endCol), + prog.name, + ), + '@declaration.name': capture('@declaration.name', nameRange, prog.name), + }; + + if (prog.procedureUsing && prog.procedureUsing.length > 0) { + grouped['@declaration.parameter-count'] = capture( + '@declaration.parameter-count', + nameRange, + String(prog.procedureUsing.length), + ); + } + + const m = matchFrom(grouped); + if (m !== null) out.push(m); + } + + // ── 3. PROCEDURE DIVISION sections → @scope.function ───────────── + for (const section of extracted.sections) { + const lineIdx = section.line - 1; + if (lineIdx < 0 || lineIdx >= lines.length) continue; + + const sectionLine = lines[lineIdx]; + const startCol = 0; + const endCol = endColFrom(sectionLine); + const nameRange = rangeOf(section.line, startCol, section.line, endCol); + + const grouped: Record = { + '@scope.function': capture('@scope.function', nameRange, section.name), + '@declaration.function': capture('@declaration.function', nameRange, section.name), + '@declaration.name': capture('@declaration.name', nameRange, section.name), + }; + + const m = matchFrom(grouped); + if (m !== null) out.push(m); + } + + // ── 4. Paragraphs → @scope.function ────────────────────────────── + for (const para of extracted.paragraphs) { + const lineIdx = para.line - 1; + if (lineIdx < 0 || lineIdx >= lines.length) continue; + + const paraLine = lines[lineIdx]; + const startCol = 0; + const endCol = endColFrom(paraLine); + const nameRange = rangeOf(para.line, startCol, para.line, endCol); + + const grouped: Record = { + '@scope.function': capture('@scope.function', nameRange, para.name), + '@declaration.function': capture('@declaration.function', nameRange, para.name), + '@declaration.name': capture('@declaration.name', nameRange, para.name), + }; + + const m = matchFrom(grouped); + if (m !== null) out.push(m); + } + + // ── 5. COPY → @import.statement ────────────────────────────────── + for (const copy of extracted.copies) { + const lineIdx = copy.line - 1; + if (lineIdx < 0 || lineIdx >= lines.length) continue; + + const copyLine = lines[lineIdx]; + const startCol = 0; + const endCol = endColFrom(copyLine); + const stmtRange = rangeOf(copy.line, startCol, copy.line, endCol); + + const grouped: Record = { + '@import.statement': capture('@import.statement', stmtRange, copy.target), + '@import.name': capture('@import.name', stmtRange, copy.target), + }; + + const m = matchFrom(grouped); + if (m !== null) out.push(m); + } + + // ── 6. CALL (quoted/referenced) → @reference.call ──────────────── + for (const call of extracted.calls) { + const lineIdx = call.line - 1; + if (lineIdx < 0 || lineIdx >= lines.length) continue; + + const callLine = lines[lineIdx]; + const startCol = 0; + const endCol = endColFrom(callLine); + const stmtRange = rangeOf(call.line, startCol, call.line, endCol); + + const grouped: Record = { + '@reference.call': capture('@reference.call', stmtRange, call.target), + '@reference.name': capture('@reference.name', stmtRange, call.target), + }; + + // Arity from CALL USING parameters + if (call.parameters && call.parameters.length > 0) { + grouped['@reference.arity'] = capture( + '@reference.arity', + stmtRange, + String(call.parameters.length), + ); + } + + const m = matchFrom(grouped); + if (m !== null) out.push(m); + } + + // ── 7. PERFORM → @reference.call ───────────────────────────────── + for (const perf of extracted.performs) { + const lineIdx = perf.line - 1; + if (lineIdx < 0 || lineIdx >= lines.length) continue; + + const perfLine = lines[lineIdx]; + const startCol = 0; + const endCol = endColFrom(perfLine); + const stmtRange = rangeOf(perf.line, startCol, perf.line, endCol); + + const grouped: Record = { + '@reference.call': capture('@reference.call', stmtRange, perf.target), + '@reference.name': capture('@reference.name', stmtRange, perf.target), + }; + + const m = matchFrom(grouped); + if (m !== null) out.push(m); + } + + // ── 8. GO TO → @reference.call ─────────────────────────────────── + for (const gt of extracted.gotos) { + const lineIdx = gt.line - 1; + if (lineIdx < 0 || lineIdx >= lines.length) continue; + + const gtLine = lines[lineIdx]; + const startCol = 0; + const endCol = endColFrom(gtLine); + const stmtRange = rangeOf(gt.line, startCol, gt.line, endCol); + + const grouped: Record = { + '@reference.call': capture('@reference.call', stmtRange, gt.target), + '@reference.name': capture('@reference.name', stmtRange, gt.target), + }; + + const m = matchFrom(grouped); + if (m !== null) out.push(m); + } + + return out; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Find the PROGRAM-ID. line for a given program name in the cleaned source. + * Returns 1-based line number, or -1 if not found. + */ +function findProgramIdLine(cleanedSource: string, programName: string): number { + const lines = cleanedSource.split(/\r?\n/); + const upper = programName.toUpperCase(); + const re = new RegExp(`\\bPROGRAM-ID\\.\\s*${escapeRegex(upper)}\\b`, 'i'); + for (let i = 0; i < lines.length; i++) { + if (re.test(lines[i])) return i + 1; // 1-based + } + return -1; +} + +/** Simple regex escape for special chars. */ +function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/gitnexus/src/core/ingestion/languages/cobol/index.ts b/gitnexus/src/core/ingestion/languages/cobol/index.ts new file mode 100644 index 000000000..24cb80473 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cobol/index.ts @@ -0,0 +1,20 @@ +/** + * COBOL scope-resolution public API barrel. + * + * Consumers should import from this file rather than the individual + * modules — that keeps the per-hook organization an implementation + * detail we can refactor without touching the provider wiring. + * + * Module layout: + * + * - `captures.ts` — `emitCobolScopeCaptures` (wraps the regex tagger) + * - `interpret.ts` — import/type-binding/receiver hooks + */ + +export { emitCobolScopeCaptures } from './captures.js'; +export { + interpretCobolImport, + interpretCobolTypeBinding, + cobolImportOwningScope, + cobolReceiverBinding, +} from './interpret.js'; diff --git a/gitnexus/src/core/ingestion/languages/cobol/interpret.ts b/gitnexus/src/core/ingestion/languages/cobol/interpret.ts new file mode 100644 index 000000000..62959069c --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cobol/interpret.ts @@ -0,0 +1,99 @@ +/** + * COBOL scope-resolution interpret hooks. + * + * Interprets raw `@import.statement` capture matches (from COPY statements) + * into `ParsedImport` for the central finalize algorithm. + * + * COBOL's import semantic is simple: `COPY bookname` means the copybook's + * content is inlined at compile time. There is no module-system equivalent + * of `export` — everything is text-inclusion. The scope-resolution pipeline + * models this as a `'named'` import where the imported name is the copybook + * name and the target is the copybook file path. + */ + +import type { + CaptureMatch, + ParsedImport, + ParsedTypeBinding, + ScopeId, + ScopeTree, + Scope, + TypeRef, +} from 'gitnexus-shared'; + +// ─── interpretImport ────────────────────────────────────────────────────── + +/** + * Interpret a COPY statement as a `ParsedImport`. + * + * The `@import.name` capture contains the copybook target name (e.g., + * `CPSESP` from `COPY CPSESP.`). Returns a `'named'` import with the + * copybook name as both `localName` and `importedName`. + * + * Returns `null` for any match that doesn't carry an `@import.name` (e.g., + * malformed COPY statements the regex tagger might emit). + */ +export function interpretCobolImport(match: CaptureMatch): ParsedImport | null { + const nameCap = match['@import.name']; + if (nameCap === undefined) return null; + + const name = nameCap.text; + if (name === '') return null; + + return { + kind: 'named', + localName: name, + importedName: name, + targetRaw: name, + }; +} + +// ─── interpretTypeBinding ───────────────────────────────────────────────── + +/** + * COBOL has no type system — no type bindings to interpret. + * Always returns `null`. + */ +export function interpretCobolTypeBinding(_match: CaptureMatch): ParsedTypeBinding | null { + return null; +} + +// ─── importOwningScope ──────────────────────────────────────────────────── + +/** + * COPY statements in COBOL are module-level — they expand inline at + * compile time and their bindings belong to the enclosing PROGRAM-ID + * (Module) scope. Walk up from the innermost scope through ancestors + * to find the enclosing Module scope. + * + * For the edge case of a COPY inside a paragraph (unusual but possible + * with some vendors), we walk the scope tree to ensure the import is + * attached to the program scope, not the paragraph Function scope. + */ +export function cobolImportOwningScope( + _imp: ParsedImport, + innermost: Scope, + tree: ScopeTree, +): ScopeId | null { + // If already in a Module scope, use it directly. + if (innermost.kind === 'Module') return innermost.id; + // Walk through ancestors to find the enclosing Module. + const ancestors = tree.getAncestors(innermost.id); + for (const ancId of ancestors) { + const anc = tree.getScope(ancId); + if (anc !== undefined && anc.kind === 'Module') return ancId; + } + // Fallback: delegate to central default. + return null; +} + +// ─── receiverBinding ────────────────────────────────────────────────────── + +/** + * COBOL has no implicit receiver (no `self`, `this`, or equivalent). + * All function calls are explicit CALL statements or PERFORM/GO TO + * control flow. Always returns `null`. + */ +export function cobolReceiverBinding(_functionScope: Scope): TypeRef | null { + return null; +} diff --git a/gitnexus/src/core/ingestion/languages/cobol/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/cobol/scope-resolver.ts new file mode 100644 index 000000000..799cc518e --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cobol/scope-resolver.ts @@ -0,0 +1,75 @@ +/** + * COBOL `ScopeResolver` registered in `SCOPE_RESOLVERS` and consumed + * by the generic `runScopeResolution` orchestrator. + * + * The provider is a thin wiring object — COBOL's simple scope model + * (Module + Function only, no inheritance, no type system) plugs into + * `runScopeResolution` with minimal configuration. + * + * Reference: `languages/python/scope-resolver.ts`. + */ + +import path from 'node:path'; +import type { ParsedFile } from 'gitnexus-shared'; +import { SupportedLanguages } from 'gitnexus-shared'; +import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js'; +import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js'; +import { cobolProvider } from '../cobol.js'; + +// Copybook file extensions for COPY name resolution +const COPYBOOK_EXTENSIONS = new Set(['.cpy', '.copybook']); + +const cobolScopeResolver: ScopeResolver = { + language: SupportedLanguages.Cobol, + languageProvider: cobolProvider, + importEdgeReason: 'cobol-scope: copy', + + // ── Resolve COPY bookname to file path ───────────────────────────── + resolveImportTarget: (targetRaw, _fromFile, allFilePaths) => { + const upper = targetRaw.toUpperCase(); + // Check copybook files first + for (const fp of allFilePaths) { + const ext = path.extname(fp).toLowerCase(); + if (!COPYBOOK_EXTENSIONS.has(ext)) continue; + const basename = path.basename(fp, ext).toUpperCase(); + if (basename === upper) return fp; + } + // Also search COBOL source files (.cbl, .cob, .cobol) + const COBOL_SOURCE_EXTS = new Set(['.cbl', '.cob', '.cobol']); + for (const fp of allFilePaths) { + const ext = path.extname(fp).toLowerCase(); + if (!COBOL_SOURCE_EXTS.has(ext)) continue; + const basename = path.basename(fp, ext).toUpperCase(); + if (basename === upper) return fp; + } + return null; + }, + + // COBOL has no binding-merge rules beyond the default (local-first-then-imports). + mergeBindings: (existing) => [...existing], + + // COBOL arity: compare CALL USING param count against def's parameterCount. + // COBOL requires exact arity match for CALL USING. + arityCompatibility: (callsite, def) => { + if (callsite.arity === undefined) return 'unknown'; + const defParamCount = def.parameterCount; + if (defParamCount === undefined) return 'unknown'; + if (callsite.arity === defParamCount) return 'compatible'; + return 'incompatible'; + }, + + // No inheritance in COBOL — empty MRO map. + buildMro: () => new Map(), + + // Everything lives under the PROGRAM-ID Module scope. + populateOwners: (parsed: ParsedFile) => populateClassOwnedMembers(parsed), + + // COBOL has no super calls. + isSuperReceiver: () => false, + + // ── Optional toggles ───────────────────────────────────────────── + fieldFallbackOnMethodLookup: false, + propagatesReturnTypesAcrossImports: false, +}; + +export { cobolScopeResolver }; diff --git a/gitnexus/src/core/ingestion/languages/cpp/captures.ts b/gitnexus/src/core/ingestion/languages/cpp/captures.ts index de8cd058e..354f6ce4c 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/captures.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/captures.ts @@ -126,6 +126,13 @@ export function emitCppScopeCaptures( JSON.stringify(arity.parameterTypeClasses), ); } + if (hasExplicitSpecifier(fnNode)) { + grouped['@declaration.is-explicit'] = syntheticCapture( + '@declaration.is-explicit', + fnNode, + 'true', + ); + } // Detect static storage class (file-local linkage) if (hasStaticStorageClass(fnNode)) { @@ -475,8 +482,9 @@ function detectCppDependentBases(root: SyntaxNode, filePath: string): void { for (const base of iterBaseClasses(baseClause)) { if (isBaseDependent(base, params)) { const baseName = extractBaseLookupName(base); + const baseQualifier = extractBaseLookupQualifier(base); if (baseName !== '') { - markCppDependentBase(filePath, className, baseName); + markCppDependentBase(filePath, className, baseName, baseQualifier); } } } @@ -553,10 +561,14 @@ function* iterBaseClasses(baseClause: SyntaxNode): IterableIterator */ function isBaseDependent(baseNode: SyntaxNode, templateParams: Set): boolean { if (baseNode.type !== 'template_type') { - // Bare `type_identifier` or `qualified_identifier` bases — not - // dependent (the base name itself doesn't reference a template - // parameter at this level). - return false; + if (baseNode.type === 'qualified_identifier') { + // Qualified identifier bases (e.g. `detail::Inner`) may contain + // template_type children — descend into them for template param check. + // Fall through to the stack walk below. + } else { + // Bare `type_identifier` bases — not dependent. + return false; + } } // Walk all descendants of the template_argument_list looking for any // type_identifier matching a template parameter, or any conservative- @@ -624,6 +636,27 @@ function extractBaseLookupName(baseNode: SyntaxNode): string { return ''; } +/** Extract the syntactic namespace qualifier from a base class node. + * For `detail::Inner`, returns `'detail'`. + * For unqualified bases (`Inner`, `Base`), returns `''`. + * Nested qualifiers (`a::b::Inner`) return the full scope text. + */ +function extractBaseLookupQualifier(baseNode: SyntaxNode): string { + if (baseNode.type === 'qualified_identifier') { + const scopeNode = baseNode.childForFieldName('scope'); + if (scopeNode !== null) return scopeNode.text; + } + // template_type nodes may have a qualified_identifier as their name child + if (baseNode.type === 'template_type') { + const nameNode = baseNode.childForFieldName('name'); + if (nameNode !== null && nameNode.type === 'qualified_identifier') { + const scopeNode = nameNode.childForFieldName('scope'); + if (scopeNode !== null) return scopeNode.text; + } + } + return ''; +} + /** * Walk parent chain from a function_definition / declaration / field_declaration * to find the enclosing `template_declaration`. Returns null when the function @@ -1516,6 +1549,20 @@ function extractDeclaratorLeafName(node: SyntaxNode): string | null { return null; } +/** + * Check if a C++ declaration has an `explicit` specifier. Tree-sitter-cpp + * exposes `explicit` as a direct keyword child on constructor declarations in + * current grammar builds; the bounded text prefix keeps this resilient across + * small grammar shape differences without scanning whole function bodies. + */ +function hasExplicitSpecifier(node: SyntaxNode): boolean { + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child !== null && child.text === 'explicit') return true; + } + return /\bexplicit\b/.test(node.text.slice(0, 128)); +} + /** * Check if a C++ function_definition or declaration has `static` storage class. */ diff --git a/gitnexus/src/core/ingestion/languages/cpp/conversion-rank.ts b/gitnexus/src/core/ingestion/languages/cpp/conversion-rank.ts index bea3600a7..330deea09 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/conversion-rank.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/conversion-rank.ts @@ -12,7 +12,8 @@ * - rank 2: standard conversion (arithmetic, nullptr -> T*, T* -> bool, * T* -> void*) * - rank 3: nullptr -> bool (kept worse than nullptr -> T*) - * - rank 4: ellipsis conversion (worst viable) + * - rank 4: user-defined conversion (one-step, conservative) + * - rank 5: ellipsis conversion (worst viable) * - Infinity: mismatch (string -> int, user types, unsupported shapes) * * This function is intentionally C++-specific. Other languages may define @@ -20,6 +21,7 @@ */ import type { ParameterTypeClass } from 'gitnexus-shared'; +import { hasCppUserDefinedConversion } from './user-defined-conversions.js'; /** Set of normalized arithmetic types that support implicit conversion. */ const ARITHMETIC = new Set(['int', 'double', 'char', 'bool']); @@ -34,7 +36,8 @@ const INTEGRAL_PROMOTION = new Map([ * Return the conversion rank from `argType` to `paramType`. * * @returns 0 for exact match, 1 for integral promotion, 2 for standard - * conversion, 3 for nullptr -> bool, 4 for ellipsis, Infinity + * conversion, 3 for nullptr -> bool, 4 for user-defined conversion, + * 5 for ellipsis, Infinity * for mismatch. */ export function cppConversionRank( @@ -46,13 +49,14 @@ export function cppConversionRank( if (argType === paramType) { return exactShapeCompatible(argTypeClass, paramTypeClass) ? 0 : Infinity; } - if (paramType === '...') return 4; + if (paramType === '...') return 5; if (INTEGRAL_PROMOTION.get(argType) === paramType) return 1; if (ARITHMETIC.has(argType) && ARITHMETIC.has(paramType)) return 2; if (argType === 'null' && isPointer(paramTypeClass)) return 2; if (argType === 'null' && paramType === 'bool') return 3; if (isPointer(argTypeClass) && paramType === 'bool') return 2; if (isPointer(argTypeClass) && isPointer(paramTypeClass) && paramType === 'void') return 2; + if (hasCppUserDefinedConversion(argType, paramType)) return 4; return Infinity; } diff --git a/gitnexus/src/core/ingestion/languages/cpp/query.ts b/gitnexus/src/core/ingestion/languages/cpp/query.ts index d42b586ce..af15e81e6 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/query.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/query.ts @@ -225,6 +225,12 @@ const CPP_SCOPE_QUERY = ` declarator: (function_declarator declarator: (field_identifier) @declaration.name))) @declaration.method +;; Constructor prototype in class body: User(int id); +(field_declaration_list + (declaration + declarator: (function_declarator + declarator: (identifier) @declaration.name)) @declaration.method) + ;; Method prototype with reference return: User& getRef(); (field_declaration declarator: (reference_declarator diff --git a/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts index 9a5ac85a5..40ff978d6 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts @@ -34,6 +34,10 @@ import { } from './inline-namespaces.js'; import { populateCppRangeBindings } from './range-bindings.js'; import { cppConstraintCompatibility } from './constraint-filter.js'; +import { + clearCppUserDefinedConversions, + populateCppUserDefinedConversions, +} from './user-defined-conversions.js'; /** * C++ `ScopeResolver` registered in `SCOPE_RESOLVERS` and consumed by @@ -61,6 +65,7 @@ export const cppScopeResolver: ScopeResolver = { clearCppDependentBases(); clearCppAdlState(); clearCppInlineNamespaces(); + clearCppUserDefinedConversions(); return scanCppHeaderFiles(repoPath); }, @@ -110,6 +115,10 @@ export const cppScopeResolver: ScopeResolver = { // by ADL (U2 of plan 2026-05-13-001) to identify each argument type's // associated namespace for Koenig lookup. populateCppAssociatedNamespaces(parsed); + // Build conservative one-step user-defined conversion facts for + // overload ranking (#1631): implicit converting constructors only, + // with no chaining or conversion-operator handling. + populateCppUserDefinedConversions(parsed); }, // Resolve recorded template-class → dependent-base simple names to diff --git a/gitnexus/src/core/ingestion/languages/cpp/two-phase-lookup.ts b/gitnexus/src/core/ingestion/languages/cpp/two-phase-lookup.ts index ec95d7701..928138ade 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/two-phase-lookup.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/two-phase-lookup.ts @@ -37,12 +37,14 @@ import { findEnclosingClassDef } from '../../scope-resolution/scope/walkers.js'; /** * Capture-time record: for each template class declaration in a file, - * the simple names of its dependent base classes. + * the simple names of its dependent base classes and their syntactic + * qualifiers (e.g., `detail` for `detail::Inner`). * * Key: filePath - * Value: Map> + * Value: Map> + * qualifier is '' when the base was unqualified. */ -const dependentBasesByFile = new Map>>(); +const dependentBasesByFile = new Map>>>(); /** * Post-`populateOwners` resolution: per-class-nodeId, the set of @@ -55,12 +57,19 @@ const dependentBaseNodeIds = new Map>(); * Record a dependent-base relationship discovered during scope-capture * emission. `className` is the simple name of the template class; * `baseName` is the simple name of the dependent base class. + * `qualifier` is the syntactic namespace qualifier (e.g. `detail` for + * `detail::Inner`), or '' for unqualified bases. * * The capture-time recorder uses simple names because the registry * resolution that maps names → nodeIds runs later (in * `populateCppDependentBases`). */ -export function markCppDependentBase(filePath: string, className: string, baseName: string): void { +export function markCppDependentBase( + filePath: string, + className: string, + baseName: string, + qualifier = '', +): void { let perFile = dependentBasesByFile.get(filePath); if (perFile === undefined) { perFile = new Map(); @@ -68,10 +77,15 @@ export function markCppDependentBase(filePath: string, className: string, baseNa } let bases = perFile.get(className); if (bases === undefined) { - bases = new Set(); + bases = new Map(); perFile.set(className, bases); } - bases.add(baseName); + let quals = bases.get(baseName); + if (quals === undefined) { + quals = new Set(); + bases.set(baseName, quals); + } + quals.add(qualifier); } /** Clear two-phase-lookup state. Called from `clearFileLocalNames`. */ @@ -89,8 +103,10 @@ export function clearCppDependentBases(): void { * Disambiguation strategy (multiple classes sharing a simple name): * 1. Prefer the candidate whose qualified-name namespace prefix matches * the deriving class's namespace prefix (same-namespace bias). - * 2. Fall back to accepting a unique simple-name match. - * 3. Skip when multiple candidates exist and no namespace match is + * 2. When a syntactic qualifier is available (`detail` in + * `detail::Inner`), target the exact namespace derived from it. + * 3. Fall back to accepting a unique simple-name match. + * 4. Skip when multiple candidates exist and no namespace match is * found (conservative: avoids false associations). */ export function populateCppDependentBases(parsedFiles: readonly ParsedFile[]): void { @@ -99,7 +115,13 @@ export function populateCppDependentBases(parsedFiles: readonly ParsedFile[]): v // Build workspace-wide index: simpleName → {nodeId, nsPrefix}[] // nsPrefix is the dot-joined namespace path (qualifiedName without the // last segment). Classes at global scope have nsPrefix = ''. + // Dedup by nodeId, keeping the LAST occurrence: parsed.localDefs may + // list the same class def multiple times — the scope-extractor creates + // a def with simple-name qualifiedName first, then the class extractor + // replaces it with the correct fully-qualified qualifiedName. Keeping + // the later entry ensures we capture the full namespace path. const classesBySimpleName = new Map(); + const entryByNodeId = new Map(); for (const parsed of parsedFiles) { for (const def of parsed.localDefs) { if (def.type !== 'Class' && def.type !== 'Struct' && def.type !== 'Interface') continue; @@ -108,14 +130,17 @@ export function populateCppDependentBases(parsedFiles: readonly ParsedFile[]): v const simple = lastDot >= 0 ? qn.slice(lastDot + 1) : qn; if (simple === '') continue; const nsPrefix = lastDot >= 0 ? qn.slice(0, lastDot) : ''; - let entries = classesBySimpleName.get(simple); - if (entries === undefined) { - entries = []; - classesBySimpleName.set(simple, entries); - } - entries.push({ nodeId: def.nodeId, nsPrefix }); + entryByNodeId.set(def.nodeId, { nodeId: def.nodeId, nsPrefix, simple }); } } + for (const entry of entryByNodeId.values()) { + let entries = classesBySimpleName.get(entry.simple); + if (entries === undefined) { + entries = []; + classesBySimpleName.set(entry.simple, entries); + } + entries.push({ nodeId: entry.nodeId, nsPrefix: entry.nsPrefix }); + } // Build a filePath → ParsedFile lookup for fast per-file access. const parsedByFile = new Map(); @@ -139,7 +164,12 @@ export function populateCppDependentBases(parsedFiles: readonly ParsedFile[]): v localClassByName.set(simple, { nodeId: def.nodeId, nsPrefix }); } - for (const [className, baseNames] of perFile) { + // V3: qualifier-based exact targeting. When the base specifier carries + // a syntactic qualifier (e.g., `detail` in `detail::Inner`), compute + // the expected namespace prefix and use exact (===) match. Falls back to + // the V2 prefix-contains heuristic when the qualifier isn't available or + // the exact match fails (absolute qualifier edge cases like `::std`). + for (const [className, baseEntries] of perFile) { const classEntry = localClassByName.get(className); if (classEntry === undefined) continue; @@ -149,54 +179,77 @@ export function populateCppDependentBases(parsedFiles: readonly ParsedFile[]): v dependentBaseNodeIds.set(classEntry.nodeId, bases); } - for (const baseName of baseNames) { - const candidates = classesBySimpleName.get(baseName); - if (candidates === undefined || candidates.length === 0) continue; + for (const [baseName, qualsSet] of baseEntries) { + for (const baseQualifier of qualsSet) { + const candidates = classesBySimpleName.get(baseName); + if (candidates === undefined || candidates.length === 0) continue; - if (candidates.length === 1) { - // Unique simple-name match — accept regardless of namespace. - bases.add(candidates[0].nodeId); - continue; - } + // Compute the expected namespace prefix from the qualifier. + // Relative qualifier (e.g. `inner`): prepend deriving class's prefix. + // Absolute qualifiers (`::std`, `ns::other`) will fail the relative + // lookup and fall through to the prefix-heuristic below. + const normalizedQualifier = baseQualifier.replace(/::/g, '.'); + const expectedNs = + baseQualifier && classEntry.nsPrefix + ? classEntry.nsPrefix + '.' + normalizedQualifier + : normalizedQualifier; - // Multiple classes share the same simple name — prefer the one - // whose namespace matches the deriving class's namespace. - // V2: filter by prefix-match capped at one level deeper, then - // accept only if exactly one candidate survives. This lets - // Derived in ns::outer find Inner in ns::outer::inner - // (or ns::v1 for inline-namespace variants) while rejecting - // sibling collisions (e.g. detail::Inner vs public_api::Inner). - // - // The one-segment cap limits walk depth: ns → ns.a ✓, ns → ns.a.b ✗. - // Global-scope deriving classes match any single-segment namespace. - // - // LIMITATION: True ISO behavior would use the base specifier's - // syntactic qualifier (available at captures.ts:611 as - // qualified_identifier scope) to navigate from the current scope, - // which would resolve `detail::Inner` vs `public_api::Inner` - // unambiguously. Threading the qualifier is tracked in #1815. - // Until then, sibling collisions correctly suppress. - const nsMatches = candidates.filter((c) => { - if (c.nsPrefix === classEntry.nsPrefix) return true; - if (classEntry.nsPrefix === '') { - return c.nsPrefix !== '' && !c.nsPrefix.includes('.'); + if (candidates.length === 1) { + // Unqualified base: accept unique match (pre-existing behavior). + if (!baseQualifier) { + bases.add(candidates[0].nodeId); + continue; + } + // Qualified base: verify namespace before accepting. + if ( + candidates[0].nsPrefix === expectedNs || + candidates[0].nsPrefix === normalizedQualifier + ) { + bases.add(candidates[0].nodeId); + } + // else: suppress — qualifier doesn't match. #1564 policy. + continue; } - if (c.nsPrefix.startsWith(classEntry.nsPrefix + '.')) { - const suffix = c.nsPrefix.slice(classEntry.nsPrefix.length + 1); - return !suffix.includes('.'); + + // V3: qualifier-based exact targeting. When the base specifier + // carries a syntactic qualifier, compute the expected namespace + // prefix and attempt an exact (===) match using the deduplicated + // nsPrefix. Dedup by nodeId removes broken entries from the + // classesBySimpleName index, making the surviving nsPrefix reliable. + if (baseQualifier) { + const qualifierMatch = candidates.find( + (c) => c.nsPrefix === expectedNs || c.nsPrefix === normalizedQualifier, + ); + if (qualifierMatch !== undefined) { + bases.add(qualifierMatch.nodeId); + continue; + } + continue; // qualifier was explicit but no match — suppress, don't fall through to V2 } - return false; - }); - const nsMatch = nsMatches.length === 1 ? nsMatches[0] : undefined; - if (nsMatch !== undefined) { - bases.add(nsMatch.nodeId); + + // V2 fallback: filter by prefix-match capped at one level deeper, + // then accept only if exactly one candidate survives. + const nsMatches = candidates.filter((c) => { + if (c.nsPrefix === classEntry.nsPrefix) return true; + if (classEntry.nsPrefix === '') { + return c.nsPrefix !== '' && !c.nsPrefix.includes('.'); + } + if (c.nsPrefix.startsWith(classEntry.nsPrefix + '.')) { + const suffix = c.nsPrefix.slice(classEntry.nsPrefix.length + 1); + return !suffix.includes('.'); + } + return false; + }); + const nsMatch = nsMatches.length === 1 ? nsMatches[0] : undefined; + if (nsMatch !== undefined) { + bases.add(nsMatch.nodeId); + } + // else: ambiguous (multiple candidates, no namespace match) → skip. } - // else: ambiguous (multiple candidates, no namespace match) → skip. } } } } - /** * Two-phase lookup predicate: is the candidate def a member of a * dependent base of the caller's enclosing template class? diff --git a/gitnexus/src/core/ingestion/languages/cpp/user-defined-conversions.ts b/gitnexus/src/core/ingestion/languages/cpp/user-defined-conversions.ts new file mode 100644 index 000000000..85fc951df --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/cpp/user-defined-conversions.ts @@ -0,0 +1,140 @@ +import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared'; +import type { ScopeId } from 'gitnexus-shared'; +import { normalizeCppParamType } from './arity-metadata.js'; + +const userDefinedConversions = new Set(); +const pendingUserDefinedConversions: PendingUserDefinedConversion[] = []; +const classIdentitiesBySimpleName = new Map>(); + +interface PendingUserDefinedConversion { + readonly argType: string; + readonly paramType: string; + readonly ownerClassName: string; +} + +export function clearCppUserDefinedConversions(): void { + userDefinedConversions.clear(); + pendingUserDefinedConversions.length = 0; + classIdentitiesBySimpleName.clear(); +} + +export function hasCppUserDefinedConversion(argType: string, paramType: string): boolean { + return userDefinedConversions.has(conversionKey(argType, paramType)); +} + +export function populateCppUserDefinedConversions(parsed: ParsedFile): void { + const scopesById = new Map(); + for (const scope of parsed.scopes) scopesById.set(scope.id, scope); + + for (const classScope of parsed.scopes) { + if (classScope.kind !== 'Class') continue; + const classDef = classScope.ownedDefs.find(isClassLike); + if (classDef !== undefined) recordClassIdentity(classDef); + } + + for (const classScope of parsed.scopes) { + if (classScope.kind !== 'Class') continue; + const classDef = classScope.ownedDefs.find(isClassLike); + if (classDef === undefined) continue; + const className = normalizedSimpleName(classDef); + if (className === '') continue; + + const methodDefs = collectClassMethodDefs(classScope.id, parsed, scopesById); + for (const def of methodDefs) { + const simpleName = simpleNameOf(def); + if (simpleName === className && def.parameterTypes?.length === 1) { + if (def.isExplicit === true) continue; + registerPendingCppUserDefinedConversion(def.parameterTypes[0], className, className); + } + } + } + + rebuildCppUserDefinedConversions(); +} + +export function registerCppUserDefinedConversion(argType: string, paramType: string): void { + if (argType === '' || paramType === '') return; + if (argType === paramType) return; + userDefinedConversions.add(conversionKey(argType, paramType)); +} + +function collectClassMethodDefs( + classScopeId: ScopeId, + parsed: ParsedFile, + scopesById: ReadonlyMap, +): SymbolDefinition[] { + const methods: SymbolDefinition[] = []; + const classScope = scopesById.get(classScopeId); + if (classScope === undefined) return methods; + + for (const def of classScope.ownedDefs) { + if (isCallableMember(def)) methods.push(def); + } + for (const scope of parsed.scopes) { + if (scope.parent !== classScopeId) continue; + if (scope.kind === 'Class') continue; + for (const def of scope.ownedDefs) { + if (isCallableMember(def)) methods.push(def); + } + } + return methods; +} + +function conversionKey(argType: string, paramType: string): string { + return `${argType}\0${paramType}`; +} + +function registerPendingCppUserDefinedConversion( + argType: string, + paramType: string, + ownerClassName: string, +): void { + if (argType === '' || paramType === '') return; + if (argType === paramType) return; + pendingUserDefinedConversions.push({ argType, paramType, ownerClassName }); +} + +function rebuildCppUserDefinedConversions(): void { + userDefinedConversions.clear(); + for (const conversion of pendingUserDefinedConversions) { + if (isAmbiguousClassName(conversion.ownerClassName)) continue; + userDefinedConversions.add(conversionKey(conversion.argType, conversion.paramType)); + } +} + +function recordClassIdentity(def: SymbolDefinition): void { + const simpleName = normalizedSimpleName(def); + if (simpleName === '') return; + const identities = classIdentitiesBySimpleName.get(simpleName) ?? new Set(); + identities.add(normalizedQualifiedClassName(def)); + classIdentitiesBySimpleName.set(simpleName, identities); +} + +function isAmbiguousClassName(simpleName: string): boolean { + return (classIdentitiesBySimpleName.get(simpleName)?.size ?? 0) > 1; +} + +function normalizedQualifiedClassName(def: SymbolDefinition): string { + const qualifiedName = def.qualifiedName ?? simpleNameOf(def); + if (qualifiedName === '' || !qualifiedName.includes('.')) return `${def.filePath}:${def.nodeId}`; + return qualifiedName + .split('.') + .map((part) => normalizeCppParamType(part)) + .join('.'); +} + +function normalizedSimpleName(def: SymbolDefinition): string { + return normalizeCppParamType(simpleNameOf(def)); +} + +function simpleNameOf(def: SymbolDefinition): string { + return def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? ''; +} + +function isClassLike(def: SymbolDefinition): boolean { + return def.type === 'Class' || def.type === 'Struct' || def.type === 'Interface'; +} + +function isCallableMember(def: SymbolDefinition): boolean { + return def.type === 'Method' || def.type === 'Constructor'; +} diff --git a/gitnexus/src/core/ingestion/languages/ruby.ts b/gitnexus/src/core/ingestion/languages/ruby.ts index e5f71ee56..1441abb59 100644 --- a/gitnexus/src/core/ingestion/languages/ruby.ts +++ b/gitnexus/src/core/ingestion/languages/ruby.ts @@ -33,6 +33,15 @@ import { rubyHeritageConfig } from '../heritage-extractors/configs/ruby.js'; import { maybeRewriteRubyBareCallToSelf } from '../utils/ruby-self-call.js'; import { findEnclosingClassInfo } from '../utils/ast-helpers.js'; import type { DispatchDecision, ImplicitReceiverOverride } from '../call-types.js'; +import { + emitRubyScopeCaptures, + rubyArityCompatibility, + rubyBindingScopeFor, + rubyImportOwningScope, + rubyReceiverBinding, + interpretRubyImport, + interpretRubyTypeBinding, +} from './ruby/index.js'; /** * Ruby label override. Applied to: @@ -258,4 +267,12 @@ export const rubyProvider = defineLanguage({ }, builtInNames: BUILT_INS, + // ── RFC #909 Ring 3: scope-based resolution hooks ────────── + emitScopeCaptures: emitRubyScopeCaptures, + interpretImport: interpretRubyImport, + interpretTypeBinding: interpretRubyTypeBinding, + bindingScopeFor: rubyBindingScopeFor, + importOwningScope: rubyImportOwningScope, + receiverBinding: rubyReceiverBinding, + arityCompatibility: rubyArityCompatibility, }); diff --git a/gitnexus/src/core/ingestion/languages/ruby/arity.ts b/gitnexus/src/core/ingestion/languages/ruby/arity.ts new file mode 100644 index 000000000..3320b2b1e --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/ruby/arity.ts @@ -0,0 +1,45 @@ +/** + * Ruby arity check, accommodating `*args`, `**kwargs`, and defaults. + * + * The `def` metadata we care about (set by the existing Ruby method/ + * function extractor): + * - `parameterCount` — total positional + keyword params + * - `requiredParameterCount` — min required (excludes defaults / `*args` / `**kwargs`) + * - `parameterTypes` — present when types are known; we also use it + * as a "we have varargs" hint (`'*args'`, + * `'**kwargs'` literals appear in the array). + * + * Verdicts: + * - `'compatible'` — `requiredParameterCount <= argCount <= parameterCount`, + * OR the def takes `*args` (then any `argCount >= required` ok). + * - `'incompatible'` — argCount is below required, OR above max with no `*args`. + * - `'unknown'` — def metadata is absent / incomplete. + * + * `'incompatible'` is a soft signal in `Registry.lookup` (penalized but + * still considered when no compatible candidate exists), per RFC 4. + */ + +import type { Callsite, SymbolDefinition } from 'gitnexus-shared'; + +export function rubyArityCompatibility( + def: SymbolDefinition, + callsite: Callsite, +): 'compatible' | 'unknown' | 'incompatible' { + const max = def.parameterCount; + const min = def.requiredParameterCount; + if (max === undefined && min === undefined) return 'unknown'; + + const argCount = callsite.arity; + if (!Number.isFinite(argCount) || argCount < 0) return 'unknown'; + + // Detect varargs/kwargs from parameterTypes if present (the Ruby + // method extractor stores `'*args'`/`'**kwargs'` in this list). + const hasVarArgs = + def.parameterTypes !== undefined && + def.parameterTypes.some((t) => t === '*args' || t === '**kwargs' || t.startsWith('*')); + + if (min !== undefined && argCount < min) return 'incompatible'; + if (max !== undefined && argCount > max && !hasVarArgs) return 'incompatible'; + + return 'compatible'; +} diff --git a/gitnexus/src/core/ingestion/languages/ruby/cache-stats.ts b/gitnexus/src/core/ingestion/languages/ruby/cache-stats.ts new file mode 100644 index 000000000..de341c074 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/ruby/cache-stats.ts @@ -0,0 +1,18 @@ +let hits = 0; +let misses = 0; + +export function recordRubyCacheHit(): void { + hits++; +} +export function recordRubyCacheMiss(): void { + misses++; +} + +export function getRubyCaptureCacheStats(): { readonly hits: number; readonly misses: number } { + return { hits, misses }; +} + +export function resetRubyCaptureCacheStats(): void { + hits = 0; + misses = 0; +} diff --git a/gitnexus/src/core/ingestion/languages/ruby/captures.ts b/gitnexus/src/core/ingestion/languages/ruby/captures.ts new file mode 100644 index 000000000..577d7b6b5 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/ruby/captures.ts @@ -0,0 +1,619 @@ +import type { Capture, CaptureMatch } from 'gitnexus-shared'; +import { + findNodeAtRange, + nodeToCapture, + syntheticCapture, + type SyntaxNode, +} from '../../utils/ast-helpers.js'; +import { getRubyParser, getRubyScopeQuery } from './query.js'; +import { recordRubyCacheHit, recordRubyCacheMiss } from './cache-stats.js'; +import { synthesizeRubyReceiverBinding, findEnclosingClassOrModule } from './receiver-binding.js'; +import { getTreeSitterBufferSize } from '../../constants.js'; +import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; + +const FUNCTION_NODE_TYPES = ['method', 'singleton_method'] as const; +const HERITAGE_CALL_NAMES: ReadonlySet = new Set(['include', 'extend', 'prepend']); +const ATTR_CALL_NAMES: ReadonlySet = new Set([ + 'attr_accessor', + 'attr_reader', + 'attr_writer', +]); + +export function emitRubyScopeCaptures( + sourceText: string, + _filePath: string, + cachedTree?: unknown, +): readonly CaptureMatch[] { + let tree = cachedTree as ReturnType['parse']> | undefined; + if (tree === undefined) { + try { + tree = parseSourceSafe(getRubyParser(), sourceText, undefined, { + bufferSize: getTreeSitterBufferSize(sourceText), + }); + } catch (err) { + throw scopeExtractionError('parse', _filePath, err); + } + recordRubyCacheMiss(); + } else { + recordRubyCacheHit(); + } + + let rawMatches: ReturnType['matches']>; + try { + rawMatches = getRubyScopeQuery().matches(tree.rootNode); + } catch (err) { + throw scopeExtractionError('scope query', _filePath, err); + } + + const out: CaptureMatch[] = []; + + for (const m of rawMatches) { + const grouped: Record = {}; + for (const c of m.captures) { + const tag = '@' + c.name; + if (tag.startsWith('@_')) continue; + grouped[tag] = nodeToCapture(tag, c.node); + } + if (Object.keys(grouped).length === 0) continue; + + // Decompose require/require_relative/load into import captures + if (grouped['@import.statement'] !== undefined) { + const anchor = grouped['@import.statement']!; + const callNode = findNodeAtRange(tree.rootNode, anchor.range, 'call'); + if (callNode !== null) { + const decomposed = decomposeRubyImport(callNode, anchor); + if (decomposed !== null) { + out.push(decomposed); + continue; + } + } + out.push(grouped); + continue; + } + + // Synthesize self receiver bindings for methods inside class/module + if (grouped['@scope.function'] !== undefined) { + const scopeCap = grouped['@scope.function']!; + const fnNode = findFunctionNode(tree.rootNode, scopeCap.range); + if (fnNode !== null) { + const enclosingNode = findEnclosingClassOrModule(fnNode); + const receiver = synthesizeRubyReceiverBinding(fnNode, enclosingNode); + if (receiver !== null) out.push(receiver); + } + out.push(grouped); + continue; + } + + // Reclassify declaration.function as declaration.method + attach arity + if (grouped['@declaration.function'] !== undefined) { + const anchorCap = grouped['@declaration.function']!; + const fnNode = findFunctionNode(tree.rootNode, anchorCap.range); + if (fnNode !== null) { + const enclosingNode = findEnclosingClassOrModule(fnNode); + if (enclosingNode !== null) { + const nameCap = grouped['@declaration.name']; + delete (grouped as Record)['@declaration.function']; + grouped['@declaration.method'] = syntheticCapture( + '@declaration.method', + fnNode, + fnNode.text, + ); + if (nameCap !== undefined) { + grouped['@declaration.name'] = nameCap; + } + } + + const arity = computeRubyDeclarationArity(fnNode); + if (arity.parameterCount !== undefined) { + grouped['@declaration.parameter-count'] = syntheticCapture( + '@declaration.parameter-count', + fnNode, + String(arity.parameterCount), + ); + } + if (arity.requiredParameterCount !== undefined) { + grouped['@declaration.required-parameter-count'] = syntheticCapture( + '@declaration.required-parameter-count', + fnNode, + String(arity.requiredParameterCount), + ); + } + if (arity.parameterTypes !== undefined) { + grouped['@declaration.parameter-types'] = syntheticCapture( + '@declaration.parameter-types', + fnNode, + JSON.stringify(arity.parameterTypes), + ); + } + } + out.push(grouped); + continue; + } + + // Intercept heritage calls (include/extend/prepend) — encode as + // special imports so emitHeritageEdges can emit IMPLEMENTS edges. + if (grouped['@reference.call.free'] !== undefined && grouped['@reference.name'] !== undefined) { + const callName = grouped['@reference.name']!.text; + if (HERITAGE_CALL_NAMES.has(callName)) { + const callNode = findNodeAtRange( + tree.rootNode, + grouped['@reference.call.free']!.range, + 'call', + ); + if (callNode !== null) { + const enclosing = findEnclosingClassOrModule(callNode); + const ownerName = enclosing?.childForFieldName('name')?.text; + if (ownerName) { + const argList = callNode.childForFieldName('arguments'); + if (argList !== null) { + for (let ai = 0; ai < argList.namedChildCount; ai++) { + const arg = argList.namedChild(ai); + if (arg !== null && (arg.type === 'constant' || arg.type === 'scope_resolution')) { + out.push({ + '@import.statement': grouped['@reference.call.free']!, + '@import.kind': syntheticCapture('@import.kind', callNode, 'namespace'), + '@import.source': syntheticCapture( + '@import.source', + callNode, + `__heritage__:${callName}:${arg.text}:${ownerName}`, + ), + '@import.name': syntheticCapture('@import.name', callNode, arg.text), + }); + } + } + } + } + } + continue; + } + + // Intercept attr_accessor/attr_reader/attr_writer — encode as special + // imports so emitHeritageEdges can create Property nodes + HAS_PROPERTY. + // Also emit @declaration.property captures so each property ends up in + // localDefs and gets reconciled into model.fields, enabling write-access + // resolution via receiver-bound-calls (Case 4 → findOwnedMember). + if (ATTR_CALL_NAMES.has(callName)) { + const callNode = findNodeAtRange( + tree.rootNode, + grouped['@reference.call.free']!.range, + 'call', + ); + if (callNode !== null) { + const enclosing = findEnclosingClassOrModule(callNode); + const ownerName = enclosing?.childForFieldName('name')?.text; + if (ownerName) { + const argList = callNode.childForFieldName('arguments'); + if (argList !== null) { + for (let ai = 0; ai < argList.namedChildCount; ai++) { + const arg = argList.namedChild(ai); + if (arg !== null && (arg.type === 'simple_symbol' || arg.type === 'symbol')) { + const propName = arg.text.replace(/^:/, ''); + out.push({ + '@import.statement': grouped['@reference.call.free']!, + '@import.kind': syntheticCapture('@import.kind', callNode, 'namespace'), + '@import.source': syntheticCapture( + '@import.source', + callNode, + `__property__:${callName}:${propName}:${ownerName}`, + ), + '@import.name': syntheticCapture('@import.name', callNode, propName), + }); + // Emit a property declaration so the property flows into + // localDefs → model.fields for receiver-bound write access. + out.push({ + '@declaration.property': syntheticCapture( + '@declaration.property', + arg, + propName, + ), + '@declaration.name': syntheticCapture('@declaration.name', arg, propName), + }); + } + } + } + } + } + continue; + } + } + + // Attach call arity for call expressions + const callTag = (['@reference.call.free', '@reference.call.member'] as const).find( + (t) => grouped[t] !== undefined, + ); + if (callTag !== undefined && grouped['@reference.arity'] === undefined) { + const anchor = grouped[callTag]!; + const callNode = findNodeAtRange(tree.rootNode, anchor.range, 'call'); + if (callNode !== null) { + const arity = computeRubyCallArity(callNode); + grouped['@reference.arity'] = syntheticCapture('@reference.arity', callNode, String(arity)); + } + } + + out.push(grouped); + } + + // Second pass: member-call-return type bindings + // Synthesize compound type bindings for `x = recv.method(...)` assignments. + // The query-level @type-binding.call-return pattern only captures free calls + // (!receiver). For member calls we need `x → recv.method()` so the compound + // receiver resolver can chain-follow through the receiver's class scope. + for (const assignNode of tree.rootNode.descendantsOfType('assignment')) { + const left = assignNode.childForFieldName('left'); + const right = assignNode.childForFieldName('right'); + if (left === null || right === null) continue; + if (left.type !== 'identifier' && left.type !== 'constant') continue; + if (right.type !== 'call') continue; + const recvNode = right.childForFieldName('receiver'); + const methodNode = right.childForFieldName('method'); + if (recvNode === null || methodNode === null) continue; + // Skip .new calls — already handled by the constructor-inference query patterns + if (methodNode.text === 'new') continue; + const compoundName = `${recvNode.text}.${methodNode.text}()`; + out.push({ + '@type-binding.call-return': syntheticCapture( + '@type-binding.call-return', + assignNode, + assignNode.text, + ), + '@type-binding.name': syntheticCapture('@type-binding.name', assignNode, left.text), + '@type-binding.type': syntheticCapture('@type-binding.type', assignNode, compoundName), + }); + } + + // Third pass: YARD comment annotations (@param, @return, @type) + for (const comment of tree.rootNode.descendantsOfType('comment')) { + const text = comment.text; + + // @param name [Type] + const paramMatch = text.match(/@param\s+(\w+)\s+\[([^\]]+)\]/); + if (paramMatch) { + const [, paramName, typeName] = paramMatch; + const methodNode = findFollowingMethod(comment); + if (methodNode !== null && paramName && typeName) { + out.push({ + '@type-binding.parameter': syntheticCapture('@type-binding.parameter', methodNode, text), + '@type-binding.name': syntheticCapture('@type-binding.name', methodNode, paramName), + '@type-binding.type': syntheticCapture( + '@type-binding.type', + methodNode, + normalizeYardType(typeName), + ), + }); + } + } + + // @param [Type] name (alternate YARD order) + const paramAltMatch = text.match(/@param\s+\[([^\]]+)\]\s+(\w+)/); + if (!paramMatch && paramAltMatch) { + const [, typeName, paramName] = paramAltMatch; + const methodNode = findFollowingMethod(comment); + if (methodNode !== null && paramName && typeName) { + out.push({ + '@type-binding.parameter': syntheticCapture('@type-binding.parameter', methodNode, text), + '@type-binding.name': syntheticCapture('@type-binding.name', methodNode, paramName), + '@type-binding.type': syntheticCapture( + '@type-binding.type', + methodNode, + normalizeYardType(typeName), + ), + }); + } + } + + // @return [Type] + const returnMatch = text.match(/@return\s+\[([^\]]+)\]/); + if (returnMatch) { + const [, typeName] = returnMatch; + const methodNode = findFollowingMethod(comment); + if (methodNode !== null && typeName) { + const methodName = methodNode.childForFieldName('name')?.text; + if (methodName) { + out.push({ + '@type-binding.return': syntheticCapture('@type-binding.return', methodNode, text), + '@type-binding.name': syntheticCapture('@type-binding.name', methodNode, methodName), + '@type-binding.type': syntheticCapture( + '@type-binding.type', + methodNode, + normalizeYardType(typeName), + ), + }); + } + } else if (typeName) { + // YARD @return before attr_accessor/attr_reader/attr_writer: the + // comment precedes a `call` node (not a method). Extract the + // property name from the attr call's arguments and bind it to + // the annotated return type. This enables field-type chains + // like `user.address.save → Address#save`. + const attrNode = findFollowingAttrCall(comment); + if (attrNode !== null) { + const argList = attrNode.childForFieldName('arguments'); + if (argList !== null) { + for (let ai = 0; ai < argList.namedChildCount; ai++) { + const arg = argList.namedChild(ai); + if (arg !== null && (arg.type === 'simple_symbol' || arg.type === 'symbol')) { + const propName = arg.text.replace(/^:/, ''); + out.push({ + '@type-binding.return': syntheticCapture('@type-binding.return', attrNode, text), + '@type-binding.name': syntheticCapture('@type-binding.name', attrNode, propName), + '@type-binding.type': syntheticCapture( + '@type-binding.type', + attrNode, + normalizeYardType(typeName), + ), + }); + } + } + } + } + } + } + + // @type [Type] + const typeMatch = text.match(/@type\s+\[([^\]]+)\]/); + if (typeMatch) { + const [, typeName] = typeMatch; + const methodNode = findFollowingMethod(comment); + if (methodNode !== null && typeName) { + out.push({ + '@type-binding.parameter': syntheticCapture('@type-binding.parameter', methodNode, text), + '@type-binding.name': syntheticCapture('@type-binding.name', methodNode, ''), + '@type-binding.type': syntheticCapture( + '@type-binding.type', + methodNode, + normalizeYardType(typeName), + ), + }); + } + } + } + + // Fourth pass: constructor-return inference for methods. + // When a method's body ends with `ClassName.new(...)`, synthesize a + // return-type binding `methodName → ClassName` on the method node. + // This enables cross-file return-type propagation for factory methods + // like `def self.get_user; User.new; end` → `get_user → User`. + for (const methodNode of [ + ...tree.rootNode.descendantsOfType('method'), + ...tree.rootNode.descendantsOfType('singleton_method'), + ]) { + const methodName = methodNode.childForFieldName('name')?.text; + if (methodName === undefined) continue; + // Skip if a YARD @return already created a return binding for this method + if ( + out.some( + (m) => + m['@type-binding.return'] !== undefined && + m['@type-binding.name']?.text === methodName && + m['@type-binding.return']?.range.startLine === methodNode.startPosition.row, + ) + ) { + continue; + } + const body = methodNode.childForFieldName('body'); + if (body === null) continue; + // Find the last expression in the method body + const lastChild = body.namedChildCount > 0 ? body.namedChild(body.namedChildCount - 1) : null; + if (lastChild === null) continue; + // Check if the last expression is a `ClassName.new(...)` call + if (lastChild.type === 'call') { + const recv = lastChild.childForFieldName('receiver'); + const meth = lastChild.childForFieldName('method'); + if ( + recv !== null && + meth !== null && + meth.text === 'new' && + (recv.type === 'constant' || recv.type === 'scope_resolution') + ) { + out.push({ + '@type-binding.return': syntheticCapture( + '@type-binding.return', + methodNode, + `constructor-return: ${recv.text}.new`, + ), + '@type-binding.name': syntheticCapture('@type-binding.name', methodNode, methodName), + '@type-binding.type': syntheticCapture('@type-binding.type', methodNode, recv.text), + }); + } + } + } + + return out; +} + +function decomposeRubyImport(callNode: SyntaxNode, anchor: Capture): CaptureMatch | null { + const methodNode = callNode.childForFieldName('method'); + if (methodNode === null) return null; + const methodName = methodNode.text; + if (methodName !== 'require' && methodName !== 'require_relative' && methodName !== 'load') { + return null; + } + + const argsNode = callNode.childForFieldName('arguments'); + const argNode = argsNode !== null ? argsNode.namedChild(0) : callNode.namedChild(1); + if (argNode === null) return null; + + let sourcePath: string; + if (argNode.type === 'string') { + const contentChild = argNode.namedChild(0); + sourcePath = + contentChild !== null && contentChild.type === 'string_content' + ? contentChild.text + : argNode.text.replace(/^['"]|['"]$/g, ''); + } else { + return null; + } + + if (sourcePath === '') return null; + + const segments = sourcePath.replace(/\\/g, '/').split('/'); + const lastSegment = segments[segments.length - 1]!; + const moduleName = lastSegment.replace(/\.rb$/, ''); + + return { + '@import.statement': anchor, + '@import.kind': syntheticCapture('@import.kind', callNode, 'wildcard'), + '@import.source': syntheticCapture('@import.source', callNode, sourcePath), + '@import.name': syntheticCapture('@import.name', callNode, moduleName), + }; +} + +function computeRubyDeclarationArity(fnNode: SyntaxNode): { + parameterCount?: number; + requiredParameterCount?: number; + parameterTypes?: string[]; +} { + const params = fnNode.childForFieldName('parameters'); + if (params === null) return { parameterCount: 0, requiredParameterCount: 0 }; + + let totalCount = 0; + let requiredCount = 0; + const paramTypes: string[] = []; + + for (let i = 0; i < params.namedChildCount; i++) { + const child = params.namedChild(i); + if (child === null) continue; + + switch (child.type) { + case 'identifier': + totalCount++; + requiredCount++; + paramTypes.push(''); + break; + case 'optional_parameter': + totalCount++; + paramTypes.push(''); + break; + case 'splat_parameter': + totalCount++; + paramTypes.push('*args'); + break; + case 'hash_splat_parameter': + totalCount++; + paramTypes.push('**kwargs'); + break; + case 'block_parameter': + // &block not counted in arity + break; + case 'keyword_parameter': { + totalCount++; + const hasDefault = child.childForFieldName('value') !== null; + if (!hasDefault) requiredCount++; + paramTypes.push(''); + break; + } + default: + totalCount++; + requiredCount++; + paramTypes.push(''); + break; + } + } + + return { + parameterCount: totalCount, + requiredParameterCount: requiredCount, + parameterTypes: paramTypes.length > 0 ? paramTypes : undefined, + }; +} + +function computeRubyCallArity(callNode: SyntaxNode): number { + const argList = callNode.childForFieldName('arguments'); + if (argList === null) return 0; + + let count = 0; + for (let i = 0; i < argList.namedChildCount; i++) { + const child = argList.namedChild(i); + if (child !== null && child.type !== 'block') count++; + } + return count; +} + +function findFunctionNode(rootNode: SyntaxNode, range: Capture['range']): SyntaxNode | null { + for (const nodeType of FUNCTION_NODE_TYPES) { + const n = findNodeAtRange(rootNode, range, nodeType); + if (n !== null) return n; + } + return null; +} + +function scopeExtractionError(stage: string, filePath: string, err: unknown): Error { + const reason = err instanceof Error ? err.message : String(err); + return new Error( + `[ruby] tree-sitter ${stage} failed for ${filePath}: ${reason}; skipping scope extraction for this file`, + ); +} + +/** + * Walk forward from a comment node, skipping consecutive comments, + * and return the next `method` or `singleton_method` node (if any). + */ +function findFollowingMethod(commentNode: SyntaxNode): SyntaxNode | null { + let sibling = commentNode.nextNamedSibling; + while (sibling !== null && sibling.type === 'comment') { + sibling = sibling.nextNamedSibling; + } + if (sibling === null) return null; + if (sibling.type === 'method' || sibling.type === 'singleton_method') return sibling; + // In tree-sitter-ruby, YARD comments before a method inside a class body + // are children of `class`, while the method is inside `body_statement`. + // Walk into body_statement to find the method. + if (sibling.type === 'body_statement') { + const first = sibling.firstNamedChild; + if (first !== null && (first.type === 'method' || first.type === 'singleton_method')) { + return first; + } + } + return null; +} + +/** + * Walk forward from a comment node, skipping consecutive comments, + * and return the next `call` node whose method is attr_accessor / + * attr_reader / attr_writer (if any). Used to attach YARD `@return` + * annotations to property declarations. + */ +function findFollowingAttrCall(commentNode: SyntaxNode): SyntaxNode | null { + let sibling = commentNode.nextNamedSibling; + while (sibling !== null && sibling.type === 'comment') { + sibling = sibling.nextNamedSibling; + } + if (sibling === null) return null; + if (sibling.type === 'call') { + const methodNode = sibling.childForFieldName('method'); + if (methodNode !== null && ATTR_CALL_NAMES.has(methodNode.text)) { + return sibling; + } + } + return null; +} + +/** + * Normalize a YARD type string: for single-parameter generics like + * `Array` or `Array[User]` keep the inner type; for multi-param + * generics like `Hash` strip the generic and return the + * outer type name only. + */ +function normalizeYardType(raw: string): string { + const trimmed = raw.trim(); + + // Check for angle-bracket generics: Type or Type + const angleMatch = trimmed.match(/^(\w+)<(.+)>$/); + if (angleMatch) { + const inner = angleMatch[2]!; + // Single-param generic → return the inner type + if (!inner.includes(',')) return inner.trim(); + // Multi-param generic → return the outer type + return angleMatch[1]!; + } + + // Check for bracket generics: Type[Inner] (YARD sometimes uses this) + const bracketMatch = trimmed.match(/^(\w+)\[(.+)\]$/); + if (bracketMatch) { + const inner = bracketMatch[2]!; + if (!inner.includes(',')) return inner.trim(); + return bracketMatch[1]!; + } + + return trimmed; +} diff --git a/gitnexus/src/core/ingestion/languages/ruby/import-target.ts b/gitnexus/src/core/ingestion/languages/ruby/import-target.ts new file mode 100644 index 000000000..a8dcd323c --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/ruby/import-target.ts @@ -0,0 +1,107 @@ +/** + * Resolve a Ruby require/require_relative import path to a repo-relative file. + * + * Ruby import resolution rules: + * - `require_relative './foo'` → resolve relative to the importing file's dir + * - `require 'foo'` → suffix-match via the existing Ruby import resolver + * - External gems → null (unresolvable within the repo) + */ + +import { resolveRubyImportInternal } from '../../import-resolvers/ruby.js'; +import { buildSuffixIndex } from '../../import-resolvers/utils.js'; + +export interface RubyResolveContext { + readonly fromFile: string; + readonly allFilePaths: ReadonlySet; +} + +// ─── resolveRubyImportTarget ────────────────────────────────────────────── + +/** + * ScopeResolver-shaped adapter: + * `(targetRaw, fromFile, allFilePaths, resolutionConfig?) → string | string[] | null` + * + * For relative paths (`./` or `../` — require_relative semantics), resolves + * against the importing file's directory, trying `.rb` and `/index.rb` + * suffixes. + * + * For bare requires (gem-style like `'json'`, `'serializable'`), delegates + * to the existing `resolveRubyImportInternal` which uses suffix matching. + * + * Returns `null` for external gems that have no matching file in the repo. + */ +export function resolveRubyImportTarget( + targetRaw: string, + fromFile: string, + allFilePaths: ReadonlySet, + _resolutionConfig?: unknown, +): string | readonly string[] | null { + if (!targetRaw) return null; + if (targetRaw.startsWith('__heritage__:') || targetRaw.startsWith('__property__:')) return null; + + const fromNormalized = fromFile.replace(/\\/g, '/'); + const fromDir = fromNormalized.includes('/') + ? fromNormalized.slice(0, fromNormalized.lastIndexOf('/')) + : ''; + + // ── require_relative: relative path resolution ────────────────────── + if (targetRaw.startsWith('./') || targetRaw.startsWith('../')) { + const resolved = resolveRelative(targetRaw, fromDir, allFilePaths); + return resolved; + } + + // ── require: bare/gem-style suffix matching ───────────────────────── + return resolveBare(targetRaw, allFilePaths); +} + +// ─── internal helpers ───────────────────────────────────────────────────── + +/** + * Resolve a relative require path (`./foo`, `../bar`) against `fromDir`. + * Tries `${resolved}.rb` then `${resolved}/index.rb`. + */ +function resolveRelative( + targetRaw: string, + fromDir: string, + allFilePaths: ReadonlySet, +): string | null { + // Resolve `./` and `../` segments manually against fromDir + const segments = (fromDir ? fromDir + '/' + targetRaw : targetRaw).split('/'); + const resolved: string[] = []; + + for (const seg of segments) { + if (seg === '' || seg === '.') continue; + if (seg === '..') { + resolved.pop(); + } else { + resolved.push(seg); + } + } + + const resolvedPath = resolved.join('/'); + + // Try direct .rb file + const rbFile = `${resolvedPath}.rb`; + if (allFilePaths.has(rbFile)) return rbFile; + + // Try index.rb inside directory + const indexFile = `${resolvedPath}/index.rb`; + if (allFilePaths.has(indexFile)) return indexFile; + + // The path might already include .rb extension + if (resolvedPath.endsWith('.rb') && allFilePaths.has(resolvedPath)) return resolvedPath; + + return null; +} + +/** + * Resolve a bare require path (`'serializable'`, `'json'`, `'net/http'`) + * via suffix matching using the existing Ruby import resolver. + */ +function resolveBare(targetRaw: string, allFilePaths: ReadonlySet): string | null { + const normalizedFileList = [...allFilePaths].map((f) => f.replace(/\\/g, '/')); + const allFileList = [...allFilePaths]; + const index = buildSuffixIndex(normalizedFileList, allFileList); + + return resolveRubyImportInternal(targetRaw, normalizedFileList, allFileList, index); +} diff --git a/gitnexus/src/core/ingestion/languages/ruby/index.ts b/gitnexus/src/core/ingestion/languages/ruby/index.ts new file mode 100644 index 000000000..67c3678e8 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/ruby/index.ts @@ -0,0 +1,20 @@ +/** + * Ruby scope-resolution hooks (RFC #909 Ring 3). + */ +export { emitRubyScopeCaptures } from './captures.js'; +export { getRubyCaptureCacheStats, resetRubyCaptureCacheStats } from './cache-stats.js'; +export { + interpretRubyImport, + interpretRubyTypeBinding, + normalizeRubyTypeName, +} from './interpret.js'; +export { rubyArityCompatibility } from './arity.js'; +export { rubyMergeBindings } from './merge-bindings.js'; +export { synthesizeRubyReceiverBinding, findEnclosingClassOrModule } from './receiver-binding.js'; +export { + rubyBindingScopeFor, + rubyImportOwningScope, + rubyReceiverBinding, + rubyFunctionDefinitionLabel, +} from './simple-hooks.js'; +export { resolveRubyImportTarget, type RubyResolveContext } from './import-target.js'; diff --git a/gitnexus/src/core/ingestion/languages/ruby/interpret.ts b/gitnexus/src/core/ingestion/languages/ruby/interpret.ts new file mode 100644 index 000000000..2cee5a1d2 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/ruby/interpret.ts @@ -0,0 +1,117 @@ +import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'gitnexus-shared'; + +// ─── interpretImport ────────────────────────────────────────────────────── + +/** + * Interpret a pre-decomposed Ruby import capture into a `ParsedImport`. + * + * Ruby `require` / `require_relative` / `load` bring everything from the + * target file into scope (wildcard semantics). The captures layer (U5) + * pre-decomposes the raw tree-sitter match so that this function receives: + * - `@import.kind` — always `'wildcard'` for Ruby + * - `@import.source` — the string argument (e.g. `'./user'`, `'serializable'`) + * - `@import.name` — derived module name (informational) + */ +export function interpretRubyImport(captures: CaptureMatch): ParsedImport | null { + const kind = captures['@import.kind']?.text; + if (kind === undefined) return null; + + const source = captures['@import.source']?.text; + if (source === undefined) return null; + + // Heritage-encoded imports (__heritage__:include:Serializable:User) + // are stored as namespace imports so emitHeritageEdges can read them. + if (source.startsWith('__heritage__:') || source.startsWith('__property__:')) { + const name = captures['@import.name']?.text ?? source; + return { kind: 'namespace', localName: name, importedName: name, targetRaw: source }; + } + + // Ruby imports are always wildcard — everything in the required file + // becomes visible in the importing scope. + return { kind: 'wildcard', targetRaw: source }; +} + +// ─── interpretTypeBinding ───────────────────────────────────────────────── + +/** + * Interpret a Ruby type-binding capture into a `ParsedTypeBinding`. + * + * Type information in Ruby comes from YARD/RBS annotations, `.new` calls, + * and assignment inference. The captures layer tags each match with one of + * several sub-captures (`@type-binding.self`, `@type-binding.constructor`, + * etc.) so this function can determine the `source`. + */ +export function interpretRubyTypeBinding(captures: CaptureMatch): ParsedTypeBinding | null { + const name = captures['@type-binding.name']?.text; + const type = captures['@type-binding.type']?.text; + if (name === undefined || type === undefined) return null; + + let source: TypeRef['source']; + let normalizedType: string; + + if (captures['@type-binding.self'] !== undefined) { + source = 'self'; + normalizedType = normalizeRubyTypeName(type); + } else if (captures['@type-binding.constructor'] !== undefined) { + source = 'constructor-inferred'; + normalizedType = normalizeRubyConstructorType(type); + } else if (captures['@type-binding.call-return'] !== undefined) { + source = 'constructor-inferred'; + normalizedType = normalizeRubyTypeName(type); + } else if (captures['@type-binding.return'] !== undefined) { + source = 'return-annotation'; + normalizedType = normalizeRubyTypeName(type); + } else if (captures['@type-binding.parameter'] !== undefined) { + source = 'parameter-annotation'; + normalizedType = normalizeRubyTypeName(type); + } else if (captures['@type-binding.alias'] !== undefined) { + source = 'assignment-inferred'; + normalizedType = normalizeRubyTypeName(type); + } else { + source = 'annotation'; + normalizedType = normalizeRubyTypeName(type); + } + + return { boundName: name, rawTypeName: normalizedType, source }; +} + +// ─── normalizeRubyTypeName ──────────────────────────────────────────────── + +/** + * Normalize a Ruby type name to its simple form: + * 1. Strip leading `::` (root-qualified) + * 2. Take last segment of qualified paths (`Foo::Bar::Baz` → `Baz`) + * 3. Strip generic angle brackets for consistency (`Array` → `Array`) + * 4. Trim whitespace + */ +export function normalizeRubyTypeName(text: string): string { + let t = text.trim(); + + // Strip leading root-qualifier + if (t.startsWith('::')) t = t.slice(2); + + // Strip generic angle brackets (e.g. `Array` → `Array`) + const angleBracket = t.indexOf('<'); + if (angleBracket !== -1) t = t.slice(0, angleBracket); + + // Take last segment of qualified paths (Foo::Bar::Baz → Baz) + const lastColon = t.lastIndexOf('::'); + if (lastColon !== -1) t = t.slice(lastColon + 2); + + return t.trim(); +} + +// ─── internal helpers ───────────────────────────────────────────────────── + +/** + * Normalize a constructor-inferred type from a `.new` call. + * Handles `Foo::Bar.new` → `Bar` and plain `Foo.new` → `Foo`. + */ +function normalizeRubyConstructorType(text: string): string { + let t = text.trim(); + + // Strip `.new` suffix if present (e.g. `Foo::Bar.new` → `Foo::Bar`) + if (t.endsWith('.new')) t = t.slice(0, -4); + + return normalizeRubyTypeName(t); +} diff --git a/gitnexus/src/core/ingestion/languages/ruby/merge-bindings.ts b/gitnexus/src/core/ingestion/languages/ruby/merge-bindings.ts new file mode 100644 index 000000000..136d96654 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/ruby/merge-bindings.ts @@ -0,0 +1,27 @@ +import type { BindingRef } from 'gitnexus-shared'; + +const TIER: Record = { + local: 0, + namespace: 1, + import: 2, + reexport: 3, + wildcard: 4, +}; + +export function rubyMergeBindings( + existing: readonly BindingRef[], + incoming: readonly BindingRef[], + _scopeId: string, +): BindingRef[] { + const seen = new Set(); + return [...existing, ...incoming] + .sort( + (a, b) => + (TIER[a.origin] ?? 99) - (TIER[b.origin] ?? 99) || a.def.nodeId.localeCompare(b.def.nodeId), + ) + .filter((binding) => { + if (seen.has(binding.def.nodeId)) return false; + seen.add(binding.def.nodeId); + return true; + }); +} diff --git a/gitnexus/src/core/ingestion/languages/ruby/query.ts b/gitnexus/src/core/ingestion/languages/ruby/query.ts new file mode 100644 index 000000000..36a4e36b3 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/ruby/query.ts @@ -0,0 +1,221 @@ +/** + * Tree-sitter query for Ruby scope captures (U1 scope-resolution migration). + * + * Captures the structural skeleton the generic scope-resolution pipeline + * consumes: scopes (program/class/module/method/block), declarations + * (class, module, method, singleton_method, variable), imports + * (require/require_relative/load), type bindings (constructor-inferred + * locals via `.new`), and references (free calls, member calls). + * + * Ruby specifics that shape this query: + * + * - Ruby modules are class-like scopes (they hold methods and can be + * mixed in via include/extend/prepend). + * + * - `singleton_method` (`def self.foo`) is a class-level method + * declaration, captured as @scope.function + @declaration.function. + * + * - Ruby has no static type annotations. Constructor inference via + * `x = User.new` is handled here; YARD `@param`/`@return` comments + * are handled programmatically in captures.ts. + * + * - In Ruby, field access IS a method call (attr_reader generates + * methods). Member calls cover both method calls and field reads. + * + * - `require`, `require_relative`, and `load` are plain method calls + * in the grammar — matched by name via `#match?`. + * + * - `do_block` and `block` (`{ }`) are both block scopes that can + * introduce closures. + * + * Exposes lazy `Parser` and `Query` singletons so callers don't pay + * tree-sitter init cost per file. + */ + +import Parser from 'tree-sitter'; +import Ruby from 'tree-sitter-ruby'; + +const RUBY_SCOPE_QUERY = ` +;; ── Scopes ─────────────────────────────────────────────────────────────── + +(program) @scope.module + +(class) @scope.class +(module) @scope.class + +(method) @scope.function +(singleton_method) @scope.function + +(do_block) @scope.block +(block) @scope.block + +;; ── Declarations — class ───────────────────────────────────────────────── + +(class + name: (constant) @declaration.name) @declaration.class + +;; ── Declarations — module (labeled Trait for class-like registry lookup) ─ + +(module + name: (constant) @declaration.name) @declaration.trait + +;; ── Declarations — method (instance) ───────────────────────────────────── + +(method + name: (identifier) @declaration.name) @declaration.function + +;; ── Declarations — singleton method (class-level: def self.foo) ────────── + +(singleton_method + name: (identifier) @declaration.name) @declaration.function + +;; ── Declarations — variable assignment ─────────────────────────────────── + +(assignment + left: (identifier) @declaration.name) @declaration.variable + +;; ── Imports — require / require_relative / load ────────────────────────── +;; +;; All three are plain \`call\` nodes in tree-sitter-ruby with no receiver. +;; The import-decomposer in captures.ts fans out the argument to a path. + +(call + method: (identifier) @_method + (#match? @_method "^(require|require_relative|load)$")) @import.statement + +;; ── Type bindings — constructor inference: x = User.new ────────────────── +;; +;; tree-sitter-ruby parses \`x = User.new\` as: +;; (assignment +;; left: (identifier) ;; "x" +;; right: (call +;; receiver: (constant) ;; "User" +;; method: (identifier))) ;; "new" +;; +;; Captures the receiver constant as the type. + +(assignment + left: (identifier) @type-binding.name + right: (call + receiver: (constant) @type-binding.type + method: (identifier) @_new_method + (#eq? @_new_method "new"))) @type-binding.constructor + +;; Qualified constructor: x = Foo::Bar.new (scope_resolution receiver) + +(assignment + left: (identifier) @type-binding.name + right: (call + receiver: (scope_resolution) @type-binding.type + method: (identifier) @_new_method2 + (#eq? @_new_method2 "new"))) @type-binding.constructor + +;; Constant constructor: SERVICE = UserService.new (left is constant, not identifier) + +(assignment + left: (constant) @type-binding.name + right: (call + receiver: (constant) @type-binding.type + method: (identifier) @_new_method3 + (#eq? @_new_method3 "new"))) @type-binding.constructor + +(assignment + left: (constant) @type-binding.name + right: (call + receiver: (scope_resolution) @type-binding.type + method: (identifier) @_new_method4 + (#eq? @_new_method4 "new"))) @type-binding.constructor + +;; Call-return inference: x = build_service() (factory pattern) + +(assignment + left: (identifier) @type-binding.name + right: (call + !receiver + method: (identifier) @type-binding.type)) @type-binding.call-return + +;; Constant call-return: SERVICE = build_service() + +(assignment + left: (constant) @type-binding.name + right: (call + !receiver + method: (identifier) @type-binding.type)) @type-binding.call-return + +;; ── Type bindings — for-in loop: for x in collection ───────────────────── +;; +;; The loop variable \`x\` gets the element type of the collection. +;; We bind \`x → collection\` as an alias; the chain-follow pass +;; resolves \`collection → ElementType\` via YARD \`@param\` annotations. +;; tree-sitter-ruby wraps the collection in an \`in\` node: +;; (for pattern: (identifier) value: (in (identifier))) + +(for + pattern: (identifier) @type-binding.name + value: (in + (identifier) @type-binding.type)) @type-binding.alias + +;; ── Type bindings — variable alias: x = y ──────────────────────────────── + +(assignment + left: (identifier) @type-binding.name + right: (identifier) @type-binding.type) @type-binding.alias + +;; ── References — free calls (no receiver) ──────────────────────────────── + +(call + !receiver + method: (identifier) @reference.name) @reference.call.free + +;; ── References — bare calls (zero-arity calls without parentheses) ────── +;; +;; Ruby allows calling methods without parentheses. When no arguments are +;; passed, tree-sitter-ruby parses them as plain \`identifier\` nodes inside +;; \`body_statement\`. This mirrors the legacy query pattern. The scope- +;; resolution pipeline filters false positives via builtInNames and +;; arity-based overload narrowing. + +(body_statement + (identifier) @reference.name) @reference.call.free + +;; ── References — member calls (with receiver): obj.method() ────────────── + +(call + receiver: (_) @reference.receiver + method: (identifier) @reference.name) @reference.call.member + +;; ── References — field writes: obj.field = value ───────────────────────── +;; +;; Ruby setter syntax: \`obj.name = x\` is an assignment whose left-hand +;; side is a \`call\` node with a receiver. + +(assignment + left: (call + receiver: (_) @reference.receiver + method: (identifier) @reference.name)) @reference.write + +;; ── References — field writes (compound assignment: obj.field += value) ── + +(operator_assignment + left: (call + receiver: (_) @reference.receiver + method: (identifier) @reference.name)) @reference.write +`; + +let _parser: Parser | null = null; +let _query: Parser.Query | null = null; + +export function getRubyParser(): Parser { + if (_parser === null) { + _parser = new Parser(); + _parser.setLanguage(Ruby as Parameters[0]); + } + return _parser; +} + +export function getRubyScopeQuery(): Parser.Query { + if (_query === null) { + _query = new Parser.Query(Ruby as Parameters[0], RUBY_SCOPE_QUERY); + } + return _query; +} diff --git a/gitnexus/src/core/ingestion/languages/ruby/receiver-binding.ts b/gitnexus/src/core/ingestion/languages/ruby/receiver-binding.ts new file mode 100644 index 000000000..fe06f87d5 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/ruby/receiver-binding.ts @@ -0,0 +1,72 @@ +/** + * Synthesize `@type-binding.self` captures for Ruby methods. + * + * Every instance method and singleton method inside a class or module body + * gets `self` bound to the enclosing class/module name. Ruby has no + * `@staticmethod` concept — even `def self.foo` dispatches on the class. + * + * For `class << self` (singleton_class) blocks, we walk past the + * singleton_class to the real owning class/module, matching the + * `rubyResolveEnclosingOwner` logic from the legacy provider. + */ + +import type { CaptureMatch } from 'gitnexus-shared'; +import { syntheticCapture } from '../../utils/ast-helpers.js'; +import type { SyntaxNode } from '../../utils/ast-helpers.js'; + +/** + * Walk up the parent chain from `node` to find the first enclosing + * `class` or `module` ancestor. + * + * - `singleton_class` (`class << self`) is not itself a real type, so we + * skip past it and continue walking to find the true owner. + * - Stops at `program` (never walks past the file root). + * - Returns `null` when no enclosing class/module is found. + */ +export function findEnclosingClassOrModule(node: SyntaxNode): SyntaxNode | null { + let cur: SyntaxNode | null = node.parent; + while (cur !== null) { + if (cur.type === 'program') return null; + if (cur.type === 'class' || cur.type === 'module') return cur; + // singleton_class (`class << self`) is not the real owner — + // keep walking to find the enclosing class/module. + cur = cur.parent; + } + return null; +} + +/** + * Extract the class/module name from the `name` field of a `class` or + * `module` node. The field holds a `constant` node whose text is the + * simple name (e.g. "MyClass"). + */ +function extractClassName(classOrModuleNode: SyntaxNode): string | null { + const nameNode = classOrModuleNode.childForFieldName('name'); + if (nameNode === null) return null; + return nameNode.text; +} + +/** + * Given a method node (`method` or `singleton_method`) and its enclosing + * `class`/`module` node (or null for top-level defs), synthesize a + * `@type-binding.self` capture that binds `self` to the class/module name. + * + * Returns `null` when: + * - `enclosingNode` is null (top-level def has no implicit receiver) + * - the enclosing node's name cannot be extracted + */ +export function synthesizeRubyReceiverBinding( + fnNode: SyntaxNode, + enclosingNode: SyntaxNode | null, +): CaptureMatch | null { + if (enclosingNode === null) return null; + + const className = extractClassName(enclosingNode); + if (className === null) return null; + + return { + '@type-binding.self': syntheticCapture('@type-binding.self', fnNode, 'self'), + '@type-binding.name': syntheticCapture('@type-binding.name', fnNode, 'self'), + '@type-binding.type': syntheticCapture('@type-binding.type', fnNode, className), + }; +} diff --git a/gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts new file mode 100644 index 000000000..b10c78b74 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts @@ -0,0 +1,235 @@ +import type { ParsedFile, ScopeId } from 'gitnexus-shared'; +import { SupportedLanguages } from 'gitnexus-shared'; +import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js'; +import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js'; +import { rubyProvider } from '../ruby.js'; +import { rubyArityCompatibility, rubyMergeBindings, resolveRubyImportTarget } from './index.js'; +import { populateClassOwnedMembers, isClassLike } from '../../scope-resolution/scope/walkers.js'; +import { resolveDefGraphId } from '../../scope-resolution/graph-bridge/ids.js'; +import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js'; +import type { KnowledgeGraph } from '../../../graph/types.js'; +import { generateId } from '../../../../lib/utils.js'; + +const HERITAGE_PREFIX = '__heritage__:'; + +function emitRubyMixinEdges( + graph: KnowledgeGraph, + parsedFiles: readonly ParsedFile[], + nodeLookup: GraphNodeLookup, +): void { + const graphIdByName = new Map(); + for (const parsed of parsedFiles) { + for (const def of parsed.localDefs) { + if (!isClassLike(def.type)) continue; + const graphId = resolveDefGraphId(parsed.filePath, def, nodeLookup); + if (graphId !== undefined) { + const simpleName = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? ''; + graphIdByName.set(simpleName, graphId); + } + } + } + + const emitted = new Set(); + // Pre-seed with existing IMPLEMENTS edges to avoid duplicates when the + // parse-worker path already produced heritage (worker path for repos + // with >= 15 files). + for (const rel of graph.iterRelationshipsByType('IMPLEMENTS')) { + emitted.add(`${rel.sourceId}->${rel.targetId}:${rel.reason}`); + } + + for (const parsed of parsedFiles) { + for (const imp of parsed.parsedImports) { + if (!imp.targetRaw.startsWith(HERITAGE_PREFIX)) continue; + const parts = imp.targetRaw.slice(HERITAGE_PREFIX.length).split(':'); + if (parts.length < 3) continue; + const [kind, mixinName, className] = parts; + const classGraphId = graphIdByName.get(className!); + const mixinGraphId = graphIdByName.get(mixinName!); + if (classGraphId === undefined || mixinGraphId === undefined) continue; + const edgeKey = `${classGraphId}->${mixinGraphId}:${kind}`; + if (emitted.has(edgeKey)) continue; + emitted.add(edgeKey); + graph.addRelationship({ + id: generateId('IMPLEMENTS', edgeKey), + sourceId: classGraphId, + targetId: mixinGraphId, + type: 'IMPLEMENTS', + confidence: 0.85, + reason: kind!, + }); + } + } + + // Emit Property nodes + HAS_PROPERTY edges from __property__:... imports. + // Skip if the parse-worker already created the property (worker path merges + // Property nodes into the graph before scope-resolution runs). + const existingProps = new Set(); + for (const rel of graph.iterRelationshipsByType('HAS_PROPERTY')) { + const targetNode = graph.getNode(rel.targetId); + if (targetNode !== undefined) { + existingProps.add(`${rel.sourceId}->prop:${targetNode.properties.name}`); + } + } + + const PROPERTY_PREFIX = '__property__:'; + for (const parsed of parsedFiles) { + for (const imp of parsed.parsedImports) { + if (!imp.targetRaw.startsWith(PROPERTY_PREFIX)) continue; + const parts = imp.targetRaw.slice(PROPERTY_PREFIX.length).split(':'); + if (parts.length < 3) continue; + const [_attrKind, propName, className] = parts; + const classGraphId = graphIdByName.get(className!); + if (classGraphId === undefined || propName === undefined) continue; + + const edgeKey = `${classGraphId}->prop:${propName}`; + if (emitted.has(edgeKey) || existingProps.has(edgeKey)) continue; + emitted.add(edgeKey); + + const propId = generateId('Property', `${parsed.filePath}:${className}.${propName}`); + graph.addNode({ + id: propId, + label: 'Property', + properties: { name: propName, filePath: parsed.filePath }, + }); + graph.addRelationship({ + id: generateId('HAS_PROPERTY', edgeKey), + sourceId: classGraphId, + targetId: propId, + type: 'HAS_PROPERTY', + confidence: 0.9, + reason: 'attr', + }); + } + } +} + +function buildRubyMro( + graph: Parameters[0], + parsedFiles: readonly ParsedFile[], + nodeLookup: Parameters[2], +): Map { + // Step 1: EXTENDS chain via the generic MRO builder (direct class inheritance). + const baseMro = buildMro(graph, parsedFiles, nodeLookup, defaultLinearize); + + // Step 2: Build defId ↔ graphId bridge for class-like defs. + const defIdByGraphId = new Map(); + for (const parsed of parsedFiles) { + for (const def of parsed.localDefs) { + if (!isClassLike(def.type)) continue; + const graphId = resolveDefGraphId(parsed.filePath, def, nodeLookup); + if (graphId !== undefined) defIdByGraphId.set(graphId, def.nodeId); + } + } + + // Step 3: Collect IMPLEMENTS edges, partitioned by reason. + const prependByChild = new Map(); + const includeByChild = new Map(); + + for (const rel of graph.iterRelationshipsByType('IMPLEMENTS')) { + const childDefId = defIdByGraphId.get(rel.sourceId); + const parentDefId = defIdByGraphId.get(rel.targetId); + if (childDefId === undefined || parentDefId === undefined) continue; + + const reason = rel.reason; + if (reason === 'prepend') { + let list = prependByChild.get(childDefId); + if (list === undefined) { + list = []; + prependByChild.set(childDefId, list); + } + list.push(parentDefId); + } else if (reason === 'include') { + let list = includeByChild.get(childDefId); + if (list === undefined) { + list = []; + includeByChild.set(childDefId, list); + } + list.push(parentDefId); + } + } + + // Step 4: Reorder MRO per Ruby semantics. + // Order: prepend (reversed) → direct extends chain → include (reversed). + // `extend` is excluded — it belongs to singleton dispatch only (legacy + // `getInstanceAncestry` in heritage-map.ts explicitly drops extend entries). + // Reversed because Ruby declaration order means last-declared wins + // (prepend B; prepend A → B checked before A). + for (const defId of defIdByGraphId.values()) { + const extendsChain = baseMro.get(defId) ?? []; + const prepends = prependByChild.get(defId); + const includes = includeByChild.get(defId); + + if (prepends === undefined && includes === undefined) continue; + + const reordered: string[] = []; + if (prepends !== undefined) { + for (let i = prepends.length - 1; i >= 0; i--) reordered.push(prepends[i]); + } + reordered.push(...extendsChain); + if (includes !== undefined) { + for (let i = includes.length - 1; i >= 0; i--) reordered.push(includes[i]); + } + baseMro.set(defId, reordered); + } + + return baseMro; +} + +/** + * Enumerate all names exported from a target module scope's file. + * Ruby's `require` / `require_relative` are wildcard imports — they bring + * every top-level def (class, module, method, constant) from the target + * file into the importer's scope. Without this hook the finalize pass + * cannot materialize individual bindings from wildcard imports, which + * blocks `propagateImportedReturnTypes` from mirroring return-type + * typeBindings across files. + */ +function expandRubyWildcardNames( + targetModuleScope: ScopeId, + parsedFiles: readonly ParsedFile[], +): readonly string[] { + const target = parsedFiles.find((p) => p.moduleScope === targetModuleScope); + if (target === undefined) return []; + + const seen = new Set(); + const names: string[] = []; + for (const def of target.localDefs) { + const qn = def.qualifiedName; + if (qn === undefined || qn.length === 0) continue; + const name = qn.split('.').pop() ?? qn; + if (name === '') continue; + if (seen.has(name)) continue; + seen.add(name); + names.push(name); + } + return names; +} + +export const rubyScopeResolver: ScopeResolver = { + language: SupportedLanguages.Ruby, + languageProvider: rubyProvider, + importEdgeReason: 'ruby-scope: import', + + resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) => + resolveRubyImportTarget(targetRaw, fromFile, allFilePaths, resolutionConfig), + + expandsWildcardTo: (targetModuleScope, parsedFiles) => + expandRubyWildcardNames(targetModuleScope, parsedFiles), + + mergeBindings: (existing, incoming, scopeId) => rubyMergeBindings(existing, incoming, scopeId), + + arityCompatibility: (callsite, def) => rubyArityCompatibility(def, callsite), + + buildMro: (graph, parsedFiles, nodeLookup) => buildRubyMro(graph, parsedFiles, nodeLookup), + + populateOwners: (parsed) => populateClassOwnedMembers(parsed), + + isSuperReceiver: (text) => text.trim() === 'super', + + emitHeritageEdges: (graph, parsedFiles, nodeLookup) => + emitRubyMixinEdges(graph, parsedFiles, nodeLookup), + + fieldFallbackOnMethodLookup: true, + propagatesReturnTypesAcrossImports: true, + allowGlobalFreeCallFallback: true, +}; diff --git a/gitnexus/src/core/ingestion/languages/ruby/simple-hooks.ts b/gitnexus/src/core/ingestion/languages/ruby/simple-hooks.ts new file mode 100644 index 000000000..b09bd2616 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/ruby/simple-hooks.ts @@ -0,0 +1,63 @@ +import type { + CaptureMatch, + ParsedImport, + Scope, + ScopeId, + ScopeTree, + TypeRef, + NodeLabel, +} from 'gitnexus-shared'; +import type { SyntaxNode } from '../../utils/ast-helpers.js'; + +export function rubyBindingScopeFor( + decl: CaptureMatch, + innermost: Scope, + _tree: ScopeTree, +): ScopeId | null { + // Keep self typeBindings in the method's Function scope so + // populateClassOwnedMembers can match Method defs to their receiver types. + if (decl['@type-binding.self'] !== undefined) { + return innermost.id; + } + return null; +} + +/** + * Ruby `require` / `include` inside a function or class body should attach + * at that scope, not module scope. + */ +export function rubyImportOwningScope( + _imp: ParsedImport, + innermost: Scope, + _tree: ScopeTree, +): ScopeId | null { + if (innermost.kind === 'Function' || innermost.kind === 'Class') { + return innermost.id; + } + return null; +} + +export function rubyReceiverBinding(functionScope: Scope): TypeRef | null { + if (functionScope.kind !== 'Function') return null; + return functionScope.typeBindings.get('self') ?? null; +} + +/** + * Reclassify top-level `def` as `'Method'` when it appears inside a + * `class` or `module` body. Stand-alone defs remain `'Function'`. + */ +export function rubyFunctionDefinitionLabel( + functionNode: SyntaxNode, + defaultLabel: NodeLabel, +): NodeLabel { + if (defaultLabel !== 'Function') return defaultLabel; + let ancestor: SyntaxNode | null = functionNode.parent; + while (ancestor) { + if (ancestor.type === 'program') break; + if (ancestor.type === 'class' || ancestor.type === 'module') { + return 'Method'; + } + ancestor = ancestor.parent; + } + return 'Function'; +} diff --git a/gitnexus/src/core/ingestion/languages/typescript/import-target.ts b/gitnexus/src/core/ingestion/languages/typescript/import-target.ts index d082529f5..7d39e1f64 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/import-target.ts @@ -17,6 +17,7 @@ import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared'; import { SupportedLanguages } from 'gitnexus-shared'; import { resolveImportPath } from '../../import-resolvers/standard.js'; +import type { SuffixIndex } from '../../import-resolvers/utils.js'; import type { TsconfigPaths } from '../../language-config.js'; export interface TsResolveContext { @@ -31,6 +32,8 @@ export interface TsResolveContext { readonly normalizedFileList?: readonly string[]; /** Per-call resolution cache to dedupe repeated lookups. */ readonly resolveCache?: Map; + /** Prebuilt suffix index for O(1)-style package/absolute import matching. */ + readonly index?: SuffixIndex; /** Parsed tsconfig path-aliases. `null` = no aliases configured. */ readonly tsconfigPaths?: TsconfigPaths | null; /** JavaScript vs TypeScript switch — affects the extensions the @@ -84,6 +87,7 @@ export function resolveTsTarget(targetRaw: string, ctx: TsResolveContext): strin resolveCache, language, ctx.tsconfigPaths ?? null, + ctx.index, ); } diff --git a/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts index 33f49307d..d4b2237dc 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts @@ -19,6 +19,7 @@ import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers. import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js'; import { typescriptProvider } from '../typescript.js'; import { loadTsconfigPaths, type TsconfigPaths } from '../../language-config.js'; +import { buildSuffixIndex, type SuffixIndex } from '../../import-resolvers/utils.js'; import { typescriptArityCompatibility, typescriptMergeBindings, @@ -50,6 +51,7 @@ function makeTsResolveImportTarget(): ScopeResolver['resolveImportTarget'] { readonly allFilePaths: Set; readonly allFileList: readonly string[]; readonly normalizedFileList: readonly string[]; + readonly index: SuffixIndex; readonly resolveCache: Map; } let cached: PassCache | null = null; @@ -57,11 +59,13 @@ function makeTsResolveImportTarget(): ScopeResolver['resolveImportTarget'] { return (targetRaw, fromFile, allFilePaths, resolutionConfig) => { if (cached === null || cached.key !== allFilePaths) { const allFileList = Array.from(allFilePaths); + const normalizedFileList = allFileList.map((f) => f.toLowerCase()); cached = { key: allFilePaths, allFilePaths: new Set(allFilePaths), allFileList, - normalizedFileList: allFileList.map((f) => f.toLowerCase()), + normalizedFileList, + index: buildSuffixIndex(normalizedFileList, allFileList), resolveCache: new Map(), }; } @@ -72,6 +76,7 @@ function makeTsResolveImportTarget(): ScopeResolver['resolveImportTarget'] { allFilePaths: cached.allFilePaths, allFileList: cached.allFileList, normalizedFileList: cached.normalizedFileList, + index: cached.index, resolveCache: cached.resolveCache, tsconfigPaths: cfg?.tsconfigPaths ?? null, }; diff --git a/gitnexus/src/core/ingestion/registry-primary-flag.ts b/gitnexus/src/core/ingestion/registry-primary-flag.ts index 72e5f5b3e..e552a4600 100644 --- a/gitnexus/src/core/ingestion/registry-primary-flag.ts +++ b/gitnexus/src/core/ingestion/registry-primary-flag.ts @@ -11,8 +11,9 @@ * ## Contract * * - Env-var name per language: `REGISTRY_PRIMARY_`. - * Example: `SupportedLanguages.Python` → `REGISTRY_PRIMARY_PYTHON`; - * `SupportedLanguages.CPlusPlus` (value `'cpp'`) → `REGISTRY_PRIMARY_CPP`. + * Example: `SupportedLanguages.Python` → `REGISTRY_PRIMARY_PYTHON`; + * `SupportedLanguages.CPlusPlus` (value `'cpp'`) → `REGISTRY_PRIMARY_CPP`. + * `SupportedLanguages.Cobol` (value `'cobol'`) → `REGISTRY_PRIMARY_COBOL`. * - Truthy values: `'true'`, `'1'`, `'yes'` (case-insensitive, * whitespace-trimmed). Anything else — including `undefined`, empty * string, or unknown tokens — is `false`. @@ -79,6 +80,7 @@ export const MIGRATED_LANGUAGES: ReadonlySet = new Set = new Set([ '@declaration.parameter-types', '@declaration.parameter-type-classes', '@declaration.template-constraints', + '@declaration.is-explicit', ]); /** diff --git a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts index 2d0733684..f0c718bcf 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -437,6 +437,28 @@ export interface ScopeResolver { nodeLookup: GraphNodeLookup, ) => Map; + /** + * Optional pre-MRO hook to emit heritage edges (IMPLEMENTS) that the + * generic `preEmitInheritanceEdges` pass cannot produce. Runs AFTER + * `preEmitInheritanceEdges` (which emits EXTENDS from `@reference.inherits` + * sites) and BEFORE `buildMro` (which reads the graph for EXTENDS + + * IMPLEMENTS). Languages whose heritage declarations are syntactic method + * calls rather than grammar-level heritage clauses (e.g., Ruby + * `include`/`extend`/`prepend`) use this hook to emit IMPLEMENTS edges + * from parsed import or reference data. + * + * Receives the graph (writable), parsedFiles, and nodeLookup — same + * surface as `buildMro`. Must be idempotent (the orchestrator may call + * it more than once during re-resolution). + * + * Default: undefined (no extra heritage edges needed). + */ + readonly emitHeritageEdges?: ( + graph: KnowledgeGraph, + parsedFiles: readonly ParsedFile[], + nodeLookup: GraphNodeLookup, + ) => void; + /** * Mutate `parsed.localDefs[i].ownerId` to point at the structural * owner. Python's rule: methods (Function defs whose parent scope diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts index b89551689..b1ee3d01b 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts @@ -30,9 +30,14 @@ import { } from '../scope/walkers.js'; /** Max depth for compound-receiver chain resolution (`a().b().c().d()`). - * Practical code rarely exceeds 3-4 hops; the cap prevents - * pathological recursion if the receiver text is malformed. */ -const COMPOUND_RECEIVER_MAX_DEPTH = 4; + * Practical code rarely exceeds 3-4 _syntactic_ hops, but languages + * with type-binding-mediated chains (Ruby's `x = obj.method()` binds + * `x → obj.method()` and recurses through the compound resolver) can + * triple the depth count because each intermediate step contributes + * two recursions (bare-ident → compound rawName → call-expr parse). + * 8 covers 3-level chains with headroom while still capping + * pathological recursion. */ +const COMPOUND_RECEIVER_MAX_DEPTH = 8; const MAP_TUPLE_SENTINEL_RE = /^__MAP_TUPLE_(\d+)__:(.+)$/; @@ -144,6 +149,23 @@ export function resolveCompoundReceiverClass( ); if (callAlias !== undefined) return callAlias; } + + // Compound member-call alias: rawName has both `.` and `()` + // (`user = Factory.get_user()` → rawName `Factory.get_user()`). + // Recurse into the compound resolver with the raw compound + // expression so the mixed-chain parser can split at top-level + // `.` and resolve the receiver + method return type. + if (tb.rawName.includes('.') && tb.rawName.includes('(')) { + const compound = resolveCompoundReceiverClass( + tb.rawName, + inScope, + scopes, + index, + options, + depth + 1, + ); + if (compound !== undefined) return compound; + } } return findClassBindingInScope(inScope, text, scopes); } diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts index ef1d4a0eb..a7f5c9f85 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts @@ -620,15 +620,21 @@ export function emitReceiverBoundCalls( } // ── Case 3b: chain-typebinding (`city → user.get_city`) ────── + // Also handles compound member-call rawNames (`city → addr.get_city()`) + // where the rawName includes both `.` and `()` — Ruby's + // member-call-return captures produce this shape. const chainHead = - typeRef !== undefined && typeRef.rawName.includes('.') && !typeRef.rawName.includes('(') + typeRef !== undefined && typeRef.rawName.includes('.') ? (typeRef.rawName.split('.', 1)[0] ?? '') : undefined; if (typeRef !== undefined && chainHead !== undefined && !namespaceTargets.has(chainHead)) { // Try the plain dotted-field walk first — covers property / // collection-accessor shapes (`.Values`, Kotlin `.size`) and // field chains. Fall back to call-form (`x()`) which treats - // the last segment as a method invocation. + // the last segment as a method invocation. For rawNames that + // already contain `()` (Ruby member-call-return captures), + // pass through directly — the compound resolver handles the + // full expression including the call syntax. let ownerDef = resolveCompoundReceiverClass( typeRef.rawName, typeRef.declaredAtScope, @@ -636,7 +642,7 @@ export function emitReceiverBoundCalls( index, compoundOpts, ); - if (ownerDef === undefined) { + if (ownerDef === undefined && !typeRef.rawName.includes('(')) { ownerDef = resolveCompoundReceiverClass( typeRef.rawName + '()', typeRef.declaredAtScope, diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts index cd98caea7..2a70dd1f6 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts @@ -22,6 +22,8 @@ import { phpScopeResolver } from '../../languages/php/scope-resolver.js'; import { rustScopeResolver } from '../../languages/rust/scope-resolver.js'; import { javascriptScopeResolver } from '../../languages/javascript/scope-resolver.js'; import { kotlinScopeResolver } from '../../languages/kotlin/scope-resolver.js'; +import { rubyScopeResolver } from '../../languages/ruby/scope-resolver.js'; +import { cobolScopeResolver } from '../../languages/cobol/scope-resolver.js'; /** Map of `SupportedLanguages` → `ScopeResolver`. The phase iterates * this map intersected with `MIGRATED_LANGUAGES` (the per-language @@ -42,4 +44,6 @@ export const SCOPE_RESOLVERS: ReadonlyMap = n [SupportedLanguages.Rust, rustScopeResolver], [SupportedLanguages.JavaScript, javascriptScopeResolver], [SupportedLanguages.Kotlin, kotlinScopeResolver], + [SupportedLanguages.Ruby, rubyScopeResolver], + [SupportedLanguages.Cobol, cobolScopeResolver], ]); diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index 21bf45e40..a088f3266 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -308,8 +308,26 @@ export function runScopeResolution( }, }); const preEmittedInheritanceSites = preEmitInheritanceEdges(graph, finalized, nodeLookup); - const mroByClassDefId = provider.buildMro(graph, parsedFiles, nodeLookup); - const extendsOnlyMroByClassDefId = provider.buildExtendsOnlyMro?.(graph, parsedFiles, nodeLookup); + // Call-based heritage hook (e.g., Ruby include/extend/prepend) — emits + // IMPLEMENTS edges that `preEmitInheritanceEdges` cannot produce because + // the heritage declarations are syntactic method calls, not grammar-level + // heritage clauses. Must run BEFORE `buildMro` so MRO construction sees + // the freshly-emitted IMPLEMENTS edges. + provider.emitHeritageEdges?.(graph, parsedFiles, nodeLookup); + // Rebuild the node lookup after heritage-edge emission. Languages like + // Ruby create Property graph nodes inside `emitHeritageEdges`; those + // nodes must be visible to downstream passes (`emitReceiverBoundCalls` + // resolves write-access targets via `resolveDefGraphId` which consults + // `nodeLookup`). Without this rebuild, Property nodes added by the + // heritage hook are invisible and ACCESSES edges silently fail to emit. + const postHeritageNodeLookup = + provider.emitHeritageEdges !== undefined ? buildGraphNodeLookup(graph) : nodeLookup; + const mroByClassDefId = provider.buildMro(graph, parsedFiles, postHeritageNodeLookup); + const extendsOnlyMroByClassDefId = provider.buildExtendsOnlyMro?.( + graph, + parsedFiles, + postHeritageNodeLookup, + ); // Replace the empty MethodDispatchIndex that finalizeScopeModel // builds by design with the populated one derived from the @@ -395,7 +413,7 @@ export function runScopeResolution( graph, indexes, parsedFiles, - nodeLookup, + postHeritageNodeLookup, handledSites, provider, workspaceIndex, @@ -410,7 +428,7 @@ export function runScopeResolution( graph, indexes, parsedFiles, - nodeLookup, + postHeritageNodeLookup, handledSites, readonlyModel, ) @@ -419,7 +437,7 @@ export function runScopeResolution( graph, indexes, parsedFiles, - nodeLookup, + postHeritageNodeLookup, referenceIndex, handledSites, readonlyModel, @@ -438,7 +456,7 @@ export function runScopeResolution( graph, indexes, referenceIndex, - nodeLookup, + postHeritageNodeLookup, handledSites, ); const importsEmitted = emitImportEdges( diff --git a/gitnexus/src/core/ingestion/workers/worker-pool.ts b/gitnexus/src/core/ingestion/workers/worker-pool.ts index d29b352ab..5c326e972 100644 --- a/gitnexus/src/core/ingestion/workers/worker-pool.ts +++ b/gitnexus/src/core/ingestion/workers/worker-pool.ts @@ -598,6 +598,14 @@ export const createWorkerPool = ( const poolOptions = resolveWorkerPoolOptions(options, size); const spawnWorker = options?.workerFactory ?? ((url: URL) => new Worker(url)); const workers: (Worker | undefined)[] = new Array(size); + type RetiredWorkerRecord = { + worker: Worker; + workerIndex: number; + reason: string; + cleanup: () => void; + terminate: () => Promise; + }; + const retiredWorkers = new Set(); const respawnCount: number[] = new Array(size).fill(0); const activeSlots: Set = new Set(); // Layer 3 (quarantine): tracked via the dedicated `quarantine.ts` @@ -625,6 +633,17 @@ export const createWorkerPool = ( let poolBroken = false; let poolFailure: Error | undefined; + const terminateTrackedWorkers = async ( + liveWorkers: readonly (Worker | undefined)[], + ): Promise => { + const retired = Array.from(retiredWorkers); + await Promise.all([ + ...liveWorkers.map((worker) => worker?.terminate().catch(() => undefined)), + ...retired.map((record) => record.terminate()), + ]); + retiredWorkers.clear(); + }; + for (let i = 0; i < size; i++) { workers[i] = spawnWorker(workerUrl); activeSlots.add(i); @@ -755,10 +774,88 @@ export const createWorkerPool = ( onProgress(next); }; - const replaceWorker = async (workerIndex: number): Promise => { + type WorkerRemovalMode = 'terminate' | 'retire'; + + const retireWorkerAfterTimeout = ( + worker: Worker, + workerIndex: number, + reason: string, + ): void => { + let cleaned = false; + let terminateStarted = false; + + function cleanupRetired() { + if (cleaned) return; + cleaned = true; + worker.removeListener('message', onRetiredMessage); + worker.removeListener('error', onRetiredError); + worker.removeListener('exit', onRetiredExit); + worker.removeListener('messageerror', onRetiredMessageError); + retiredWorkers.delete(record); + } + + async function terminateRetired() { + if (terminateStarted) return; + terminateStarted = true; + cleanupRetired(); + await worker.terminate().catch(() => undefined); + } + + function terminateWhenBackInJs() { + void terminateRetired(); + } + + function onRetiredMessage(raw: unknown) { + if (raw === null || typeof raw !== 'object') return; + const type = (raw as { type?: unknown }).type; + if (type === 'sub-batch-done' || type === 'result' || type === 'error') { + terminateWhenBackInJs(); + } + } + + const onRetiredError = () => cleanupRetired(); + const onRetiredExit = () => cleanupRetired(); + const onRetiredMessageError = () => terminateWhenBackInJs(); + const record: RetiredWorkerRecord = { + worker, + workerIndex, + reason, + cleanup: cleanupRetired, + terminate: terminateRetired, + }; + retiredWorkers.add(record); + worker.on('message', onRetiredMessage); + worker.once('error', onRetiredError); + worker.once('exit', onRetiredExit); + worker.once('messageerror', onRetiredMessageError); + (worker as Worker & { unref?: () => void }).unref?.(); + logger.warn( + { workerIndex, reason }, + `Worker ${workerIndex} timed out; retiring without immediate terminate to avoid aborting native parser state.`, + ); + }; + + const removeWorkerFromSlot = async ( + workerIndex: number, + mode: WorkerRemovalMode, + reason: string, + ): Promise => { const existing = workers[workerIndex]; - await existing?.terminate().catch(() => undefined); workers[workerIndex] = undefined; + if (!existing) return; + if (mode === 'retire') { + retireWorkerAfterTimeout(existing, workerIndex, reason); + return; + } + await existing.terminate().catch(() => undefined); + }; + + const replaceWorker = async ( + workerIndex: number, + mode: WorkerRemovalMode = 'terminate', + reason = 'replacing worker', + ): Promise => { + await removeWorkerFromSlot(workerIndex, mode, reason); if (stopped) return false; const replacement = spawnWorker(workerUrl); try { @@ -803,7 +900,7 @@ export const createWorkerPool = ( const liveWorkers = workers.slice(); for (let i = 0; i < workers.length; i++) workers[i] = undefined; activeSlots.clear(); - void Promise.all(liveWorkers.map((worker) => worker?.terminate().catch(() => undefined))); + void terminateTrackedWorkers(liveWorkers); }; const maybeDone = () => { @@ -893,6 +990,7 @@ export const createWorkerPool = ( workerIndex: number, reason: string, excludePaths: readonly string[], + removalMode: WorkerRemovalMode = 'terminate', ) => { if (stopped) return; consecutiveFailuresPerSlot[workerIndex]++; @@ -921,9 +1019,7 @@ export const createWorkerPool = ( }, `Worker ${workerIndex} exceeded respawn budget; dropping slot.`, ); - const dead = workers[workerIndex]; - await dead?.terminate().catch(() => undefined); - workers[workerIndex] = undefined; + await removeWorkerFromSlot(workerIndex, removalMode, reason); activeSlots.delete(workerIndex); if (activeSlots.size === 0) { tripBreaker( @@ -945,7 +1041,7 @@ export const createWorkerPool = ( }, `Worker ${workerIndex} died; respawning slot (attempt ${respawnCount[workerIndex]}/${poolOptions.maxRespawnsPerSlot}).`, ); - const respawned = await replaceWorker(workerIndex); + const respawned = await replaceWorker(workerIndex, removalMode, reason); if (!respawned) { activeSlots.delete(workerIndex); if (activeSlots.size === 0) { @@ -1211,7 +1307,12 @@ export const createWorkerPool = ( activeWorkers--; busySlots.delete(workerIndex); requeueRemainder(job, decision.excludePaths); - await handleWorkerDeath(workerIndex, decision.reason, decision.excludePaths); + await handleWorkerDeath( + workerIndex, + decision.reason, + decision.excludePaths, + 'retire', + ); if (stopped) return; if (activeSlots.has(workerIndex)) runWorker(workerIndex); wakeIdleSlots(); @@ -1255,9 +1356,11 @@ export const createWorkerPool = ( }, `Worker ${workerIndex} hit consecutive-failure threshold on idle-timeout retry; tripping circuit breaker.`, ); - const dead = workers[workerIndex]; - await dead?.terminate().catch(() => undefined); - workers[workerIndex] = undefined; + await removeWorkerFromSlot( + workerIndex, + 'retire', + 'idle-timeout retry consecutive-failure threshold', + ); activeSlots.delete(workerIndex); tripBreaker( new WorkerPoolDispatchError( @@ -1278,12 +1381,18 @@ export const createWorkerPool = ( }, `Worker ${workerIndex} exceeded respawn budget during idle-timeout retry; dropping slot.`, ); - const dead = workers[workerIndex]; - await dead?.terminate().catch(() => undefined); - workers[workerIndex] = undefined; + await removeWorkerFromSlot( + workerIndex, + 'retire', + 'idle-timeout retry respawn budget exhausted', + ); activeSlots.delete(workerIndex); } else { - const respawned = await replaceWorker(workerIndex); + const respawned = await replaceWorker( + workerIndex, + 'retire', + 'idle-timeout retry', + ); if (!respawned) { activeSlots.delete(workerIndex); } @@ -1482,7 +1591,7 @@ export const createWorkerPool = ( // exception when this is called from `runChunkedParseAndResolve`'s // finally block — masking the real failure and leaving `workers[]` // populated with dead references because the lines below never run. - await Promise.all(workers.map((w) => w?.terminate().catch(() => undefined))); + await terminateTrackedWorkers(workers); workers.length = 0; activeSlots.clear(); }; diff --git a/gitnexus/src/core/lbug/lbug-adapter.ts b/gitnexus/src/core/lbug/lbug-adapter.ts index b0f1d3ec0..a41a9d201 100644 --- a/gitnexus/src/core/lbug/lbug-adapter.ts +++ b/gitnexus/src/core/lbug/lbug-adapter.ts @@ -4,6 +4,8 @@ import { createInterface } from 'readline'; import { once } from 'events'; import { finished } from 'stream/promises'; import path from 'path'; +import os from 'os'; +import crypto from 'crypto'; import lbug from '@ladybugdb/core'; import { KnowledgeGraph } from '../graph/types.js'; import { @@ -23,6 +25,7 @@ import { isOpenRetryExhausted, isWalCorruptionError, openLbugConnection, + toNativeSafePath, WAL_RECOVERY_SUGGESTION, waitForWindowsHandleRelease, type LbugConnectionHandle, @@ -386,7 +389,8 @@ const runWithSessionLock = async (operation: () => Promise): Promise => } }; -const normalizeCopyPath = (filePath: string): string => filePath.replace(/\\/g, '/'); +const normalizeCopyPath = (filePath: string): string => + toNativeSafePath(filePath).replace(/\\/g, '/'); const closeQueryResult = async (result: lbug.QueryResult): Promise => { try { @@ -856,7 +860,13 @@ export const loadGraphToLbug = async ( const log = onProgress || (() => {}); - const csvDir = path.join(storagePath, 'csv'); + let csvDir: string; + if (process.platform === 'win32' && /[^\x00-\x7F]/.test(storagePath)) { + const hash = crypto.createHash('sha256').update(storagePath).digest('hex').slice(0, 16); + csvDir = toNativeSafePath(path.join(os.tmpdir(), `gitnexus-csv-${hash}`)); + } else { + csvDir = path.join(storagePath, 'csv'); + } log('Streaming CSVs to disk...'); const csvResult = await streamAllCSVsToDisk(graph, repoPath, csvDir); diff --git a/gitnexus/src/core/lbug/lbug-config.ts b/gitnexus/src/core/lbug/lbug-config.ts index a71b25930..aeba61d6a 100644 --- a/gitnexus/src/core/lbug/lbug-config.ts +++ b/gitnexus/src/core/lbug/lbug-config.ts @@ -1,9 +1,194 @@ import fs from 'fs/promises'; +import fsSync from 'fs'; import os from 'os'; import path from 'path'; +import crypto from 'crypto'; +import { execFileSync } from 'child_process'; +import { isMainThread } from 'worker_threads'; import type lbug from '@ladybugdb/core'; import { logger } from '../logger.js'; +// ─── Windows non-ASCII path workaround (#1811) ─────────────────────────────── +// +// KuzuDB's native C++ layer on Windows uses CreateFileA (ANSI), not +// CreateFileW. Non-ASCII path bytes from Node.js (UTF-8) are +// misinterpreted via the system's Active Code Page (e.g. GBK), producing +// a garbled path — "Error 3: The system cannot find the path." +// +// Layered workaround: +// 1. Try 8.3 short-name form (fast, no persistent state) +// 2. Fall back to an NTFS junction from an ASCII temp path +// 3. If both fail, log a diagnostic and return the original path + +const NON_ASCII_RE = /[^\x00-\x7F]/; +const JUNCTION_PREFIX = 'gitnexus-junction-'; + +const activeJunctions = new Set(); +let cleanupRegistered = false; +let orphanScanDone = false; + +function junctionHash(targetDir: string): string { + return crypto.createHash('sha256').update(targetDir).digest('hex').slice(0, 16); +} + +function tryShortPath(p: string): string | null { + try { + // Pass the path via environment variable so the command string is + // static — avoids CodeQL command-injection taint (the path never + // appears in the shell command text). + const result = execFileSync('cmd.exe', ['/c', 'for %I in ("%GITNEXUS_SP%") do @echo %~sI'], { + encoding: 'utf-8', + timeout: 5000, + windowsHide: true, + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, GITNEXUS_SP: p }, + }); + const shortPath = result.trim(); + if ( + shortPath && + !NON_ASCII_RE.test(shortPath) && + (!shortPath.includes('?') || p.includes('?')) + ) { + return shortPath; + } + } catch { + // 8.3 unavailable or cmd failed + } + return null; +} + +function tryJunction(targetDir: string, leaf: string): string | null { + const hash = junctionHash(targetDir); + const junctionLink = path.join(os.tmpdir(), `${JUNCTION_PREFIX}${hash}`); + + if (fsSync.existsSync(junctionLink)) { + try { + const existing = fsSync.readlinkSync(junctionLink); + if (path.resolve(existing) === path.resolve(targetDir)) { + activeJunctions.add(junctionLink); + return path.join(junctionLink, leaf); + } + fsSync.rmSync(junctionLink, { recursive: true, force: true }); + } catch { + // Stale or broken junction — remove and recreate + try { + fsSync.rmSync(junctionLink, { recursive: true, force: true }); + } catch { + /* best effort */ + } + } + } + + try { + fsSync.symlinkSync(targetDir, junctionLink, 'junction'); + activeJunctions.add(junctionLink); + return path.join(junctionLink, leaf); + } catch (err: any) { + if (err.code === 'EEXIST') { + try { + const existing = fsSync.readlinkSync(junctionLink); + if (path.resolve(existing) === path.resolve(targetDir)) { + activeJunctions.add(junctionLink); + return path.join(junctionLink, leaf); + } + } catch { + /* cannot verify — fall through */ + } + } + } + return null; +} + +function registerCleanupHandlers(): void { + if (cleanupRegistered) return; + cleanupRegistered = true; + + process.on('exit', () => cleanupNativePathJunctions()); + + for (const signal of ['SIGTERM', 'SIGINT'] as const) { + process.on(signal, () => { + cleanupNativePathJunctions(); + if (process.platform === 'win32') { + process.exit(signal === 'SIGINT' ? 130 : 143); + } else { + process.kill(process.pid, signal); + } + }); + } +} + +function scanOrphanedJunctions(): void { + if (orphanScanDone) return; + orphanScanDone = true; + try { + const tmpdir = os.tmpdir(); + const entries = fsSync.readdirSync(tmpdir); + for (const entry of entries) { + if (!entry.startsWith(JUNCTION_PREFIX)) continue; + const junctionPath = path.join(tmpdir, entry); + try { + const target = fsSync.readlinkSync(junctionPath); + try { + fsSync.lstatSync(target); + } catch { + fsSync.rmSync(junctionPath, { recursive: true, force: true }); + } + } catch { + // Not a symlink/junction or unreadable — leave it + } + } + } catch { + // tmpdir unreadable — skip scan + } +} + +export function cleanupNativePathJunctions(): void { + for (const junctionPath of activeJunctions) { + try { + fsSync.rmSync(junctionPath, { recursive: true, force: true }); + } catch { + // Best effort — EPERM on Windows is common during exit + } + } + activeJunctions.clear(); +} + +export function toNativeSafePath(p: string): string { + if (process.platform !== 'win32') return p; + if (!NON_ASCII_RE.test(p)) return p; + + if (isMainThread) { + scanOrphanedJunctions(); + registerCleanupHandlers(); + } + + const shortPath = tryShortPath(p); + if (shortPath) return shortPath; + + if (!isMainThread) { + logger.warn( + `GitNexus: non-ASCII path in worker thread — junction fallback skipped. ` + + `Path: "${p}". 8.3 short names may need to be enabled on this volume.`, + ); + return p; + } + + const targetDir = path.dirname(p); + const leaf = path.basename(p); + if (fsSync.existsSync(targetDir)) { + const junctionResult = tryJunction(targetDir, leaf); + if (junctionResult) return junctionResult; + } + + logger.warn( + `GitNexus: non-ASCII path "${p}" could not be converted to an ASCII-safe form. ` + + 'LadybugDB may fail with "Cannot open file." To fix: move the repo to a path ' + + 'without CJK/Unicode characters, or enable 8.3 short names on this volume ' + + '(fsutil 8dot3name set 0).', + ); + return p; +} + /** * Shared configuration for `@ladybugdb/core` `Database` construction. * @@ -351,12 +536,10 @@ export async function openLbugConnection( databasePath: string, options: LbugDatabaseOptions = {}, ): Promise { + const safePath = toNativeSafePath(databasePath); let db: lbug.Database | undefined; try { - db = await openWithLockRetry( - () => createLbugDatabase(lbugModule, databasePath, options), - databasePath, - ); + db = await openWithLockRetry(() => createLbugDatabase(lbugModule, safePath, options), safePath); return { db, conn: new lbugModule.Connection(db) }; } catch (err) { if (db) await db.close().catch(() => {}); diff --git a/gitnexus/src/core/lbug/native-check.ts b/gitnexus/src/core/lbug/native-check.ts new file mode 100644 index 000000000..54d435bfd --- /dev/null +++ b/gitnexus/src/core/lbug/native-check.ts @@ -0,0 +1,79 @@ +import fs from 'fs'; +import path from 'path'; +import { createRequire } from 'node:module'; + +export interface NativeCheckResult { + ok: boolean; + binaryPath?: string; + message?: string; +} + +export function checkLbugNative(overridePkgDir?: string): NativeCheckResult { + let pkgDir: string; + + if (overridePkgDir) { + pkgDir = overridePkgDir; + } else { + try { + const _require = createRequire(import.meta.url); + const mainEntry = _require.resolve('@ladybugdb/core'); + pkgDir = path.dirname(mainEntry); + } catch { + return { + ok: false, + message: [ + 'LadybugDB package (@ladybugdb/core) is not installed.', + '', + 'Run: npm install', + ].join('\n'), + }; + } + } + + const binaryPath = path.join(pkgDir, 'lbugjs.node'); + if (!fs.existsSync(binaryPath)) { + return { + ok: false, + binaryPath, + message: [ + 'LadybugDB native binary (lbugjs.node) is missing.', + '', + 'This usually happens when the install lifecycle script was skipped.', + '', + 'To repair:', + ` node ${path.join(pkgDir, 'install.js')}`, + '', + 'If using bun, add to package.json and reinstall:', + ' "trustedDependencies": ["@ladybugdb/core"]', + '', + 'Also check that npm is not configured with ignore-scripts=true', + '(in .npmrc or via --ignore-scripts).', + ].join('\n'), + }; + } + + try { + const _require = createRequire(import.meta.url); + _require(binaryPath); + } catch (err: unknown) { + const nativeError = err instanceof Error ? err.message : String(err); + return { + ok: false, + binaryPath, + message: [ + 'LadybugDB native binary (lbugjs.node) exists but failed to load:', + ` ${nativeError}`, + '', + 'This can happen with a truncated file, ABI mismatch, or wrong-platform binary.', + '', + 'To repair:', + ` node ${path.join(pkgDir, 'install.js')}`, + '', + 'If using bun, add to package.json and reinstall:', + ' "trustedDependencies": ["@ladybugdb/core"]', + ].join('\n'), + }; + } + + return { ok: true, binaryPath }; +} diff --git a/gitnexus/src/core/lbug/pool-adapter.ts b/gitnexus/src/core/lbug/pool-adapter.ts index 7eb8e7d14..de478bb00 100644 --- a/gitnexus/src/core/lbug/pool-adapter.ts +++ b/gitnexus/src/core/lbug/pool-adapter.ts @@ -23,6 +23,7 @@ import { isReadOnlyDbError, loadFTSExtension } from './lbug-adapter.js'; import { createLbugDatabase, isWalCorruptionError, + toNativeSafePath, WAL_RECOVERY_SUGGESTION, } from './lbug-config.js'; import { @@ -390,7 +391,7 @@ async function probeDatabaseForShadowReplay(db: lbug.Database): Promise { async function replayShadowPagesWithWritableOpen(dbPath: string): Promise { let db: lbug.Database | undefined; try { - db = createLbugDatabase(lbug, dbPath, { throwOnWalReplayFailure: false }); + db = createLbugDatabase(lbug, toNativeSafePath(dbPath), { throwOnWalReplayFailure: false }); await db.init(); await probeDatabaseForShadowReplay(db); } catch (err) { @@ -415,7 +416,7 @@ async function openReadOnlyDatabase(dbPath: string): Promise { logger: poolSidecarLogger, allowQuarantine: true, }); - db = createLbugDatabase(lbug, dbPath, { + db = createLbugDatabase(lbug, toNativeSafePath(dbPath), { readOnly: true, throwOnWalReplayFailure: false, }); @@ -434,7 +435,7 @@ async function openReadOnlyDatabase(dbPath: string): Promise { logger: poolSidecarLogger, allowQuarantine: true, }); - db = createLbugDatabase(lbug, dbPath, { + db = createLbugDatabase(lbug, toNativeSafePath(dbPath), { readOnly: true, throwOnWalReplayFailure: false, }); @@ -448,7 +449,7 @@ async function openReadOnlyDatabase(dbPath: string): Promise { await db.close().catch(() => {}); db = undefined; await replayShadowPagesWithWritableOpen(dbPath); - db = createLbugDatabase(lbug, dbPath, { + db = createLbugDatabase(lbug, toNativeSafePath(dbPath), { readOnly: true, throwOnWalReplayFailure: false, }); diff --git a/gitnexus/src/core/wiki/generator.ts b/gitnexus/src/core/wiki/generator.ts index aaf7bb048..a86d60d74 100644 --- a/gitnexus/src/core/wiki/generator.ts +++ b/gitnexus/src/core/wiki/generator.ts @@ -99,6 +99,7 @@ export interface WikiRunResult { // ─── Constants ──────────────────────────────────────────────────────── const DEFAULT_MAX_TOKENS_PER_MODULE = 30_000; +const GROUPING_TOKEN_BUDGET = 100_000; const WIKI_DIR = 'wiki'; // ─── Generator Class ────────────────────────────────────────────────── @@ -470,15 +471,22 @@ export class WikiGenerator { DIRECTORY_TREE: dirTree, }); - // Grouping is a structured-data phase (JSON output), not documentation. - // Do NOT apply buildSystemPrompt here — a language instruction would risk - // translating module-name keys, breaking slug stability and JSON parsing. - const response = await this.invokeLLM( - prompt, - GROUPING_SYSTEM_PROMPT, - this.streamOpts('Grouping files', 15, 13), - ); - const grouping = this.parseGroupingResponse(response.content, files); + const promptTokens = estimateTokens(prompt); + let grouping: Record; + + if (promptTokens <= GROUPING_TOKEN_BUDGET) { + // Grouping is a structured-data phase (JSON output), not documentation. + // Do NOT apply buildSystemPrompt here — a language instruction would risk + // translating module-name keys, breaking slug stability and JSON parsing. + const response = await this.invokeLLM( + prompt, + GROUPING_SYSTEM_PROMPT, + this.streamOpts('Grouping files', 15, 13), + ); + grouping = this.parseGroupingResponse(response.content, files); + } else { + grouping = await this.batchedGrouping(files); + } // Convert to tree nodes const tree: ModuleTreeNode[] = []; @@ -509,6 +517,202 @@ export class WikiGenerator { return tree; } + /** + * Run grouping in batches when the full file list exceeds GROUPING_TOKEN_BUDGET. + */ + private async batchedGrouping(files: FileWithExports[]): Promise> { + const batches = this.batchFilesForGrouping(files); + const partials: Record[] = []; + + for (let i = 0; i < batches.length; i++) { + const batch = batches[i]; + this.onProgress( + 'grouping', + 15 + Math.round(((i + 1) / batches.length) * 13), + `Grouping batch ${i + 1}/${batches.length} (LLM)...`, + ); + + const batchFileList = formatFileListForGrouping(batch); + const batchDirTree = formatDirectoryTree(batch.map((f) => f.filePath)); + const batchPrompt = fillTemplate(GROUPING_USER_PROMPT, { + FILE_LIST: batchFileList, + DIRECTORY_TREE: batchDirTree, + }); + + try { + const batchStart = 15 + Math.round((i / batches.length) * 13); + const batchRange = Math.max(1, Math.round(13 / batches.length)); + const response = await this.invokeLLM( + batchPrompt, + GROUPING_SYSTEM_PROMPT, + this.streamOpts(`Grouping batch ${i + 1}/${batches.length}`, batchStart, batchRange), + ); + partials.push(this.parseGroupingResponse(response.content, batch)); + } catch { + this.onProgress( + 'grouping', + 15, + `Batch ${i + 1} failed, falling back to directory grouping`, + ); + return this.fallbackGrouping(files); + } + } + + const merged = this.mergeGroupings(partials); + + const assignedFiles = new Set(Object.values(merged).flat()); + const unassigned = files.map((f) => f.filePath).filter((fp) => !assignedFiles.has(fp)); + if (unassigned.length > 0) { + merged['Other'] = [...(merged['Other'] ?? []), ...unassigned]; + } + + return Object.keys(merged).length > 0 ? merged : this.fallbackGrouping(files); + } + + /** + * Partition files into batches that fit within GROUPING_TOKEN_BUDGET. + * Groups by top-level directory for semantic coherence. + */ + private batchFilesForGrouping(files: FileWithExports[]): FileWithExports[][] { + if (files.length === 0) return []; + + const dirGroups = new Map(); + for (const f of files) { + const parts = f.filePath.replace(/\\/g, '/').split('/'); + const topDir = parts.length > 1 ? parts[0] : 'Root'; + let group = dirGroups.get(topDir); + if (!group) { + group = []; + dirGroups.set(topDir, group); + } + group.push(f); + } + + const batches: FileWithExports[][] = []; + let currentBatch: FileWithExports[] = []; + + for (const dirFiles of dirGroups.values()) { + const dirPromptSize = this.estimateGroupingPromptTokens(dirFiles); + + if (dirPromptSize > GROUPING_TOKEN_BUDGET) { + if (currentBatch.length > 0) { + batches.push(currentBatch); + currentBatch = []; + } + // Sub-batch this large directory by fixed chunks + for (let i = 0; i < dirFiles.length; ) { + const subBatch: FileWithExports[] = []; + while (i < dirFiles.length) { + subBatch.push(dirFiles[i]); + i++; + if ( + this.estimateGroupingPromptTokens(subBatch) > GROUPING_TOKEN_BUDGET && + subBatch.length > 1 + ) { + subBatch.pop(); + i--; + break; + } + } + if ( + subBatch.length === 1 && + this.estimateGroupingPromptTokens(subBatch) > GROUPING_TOKEN_BUDGET + ) { + subBatch[0] = this.trimSymbolsToFit(subBatch[0]); + } + batches.push(subBatch); + } + continue; + } + + const candidateBatch = [...currentBatch, ...dirFiles]; + if (this.estimateGroupingPromptTokens(candidateBatch) > GROUPING_TOKEN_BUDGET) { + if (currentBatch.length > 0) { + batches.push(currentBatch); + } + currentBatch = dirFiles; + } else { + currentBatch = candidateBatch; + } + } + + if (currentBatch.length > 0) { + batches.push(currentBatch); + } + + return batches; + } + + private estimateGroupingPromptTokens(files: FileWithExports[]): number { + const fileList = formatFileListForGrouping(files); + const dirTree = formatDirectoryTree(files.map((f) => f.filePath)); + const prompt = fillTemplate(GROUPING_USER_PROMPT, { + FILE_LIST: fileList, + DIRECTORY_TREE: dirTree, + }); + return estimateTokens(prompt); + } + + private trimSymbolsToFit(file: FileWithExports): FileWithExports { + const symbols = file.symbols; + let lo = 0; + let hi = symbols.length; + while (lo < hi) { + const mid = (lo + hi + 1) >>> 1; + const candidate: FileWithExports = { + filePath: file.filePath, + symbols: [ + ...symbols.slice(0, mid), + { name: `... and ${symbols.length - mid} more`, type: 'truncated' }, + ], + }; + if (this.estimateGroupingPromptTokens([candidate]) <= GROUPING_TOKEN_BUDGET) { + lo = mid; + } else { + hi = mid - 1; + } + } + if (lo >= symbols.length) return file; + return { + filePath: file.filePath, + symbols: + lo > 0 + ? [ + ...symbols.slice(0, lo), + { name: `... and ${symbols.length - lo} more`, type: 'truncated' }, + ] + : [{ name: 'no exports (truncated)', type: 'truncated' }], + }; + } + + /** + * Merge partial groupings from multiple batches. Same module name across + * batches gets file lists concatenated. Deduplicates (first-seen wins). + */ + private mergeGroupings(partials: Record[]): Record { + const merged: Record = {}; + const seen = new Set(); + const slugToCanonical = new Map(); + + for (const partial of partials) { + for (const [mod, paths] of Object.entries(partial)) { + const slug = this.slugify(mod); + const canonical = slugToCanonical.get(slug) ?? mod; + if (!slugToCanonical.has(slug)) slugToCanonical.set(slug, mod); + + for (const fp of paths) { + if (!seen.has(fp)) { + seen.add(fp); + if (!merged[canonical]) merged[canonical] = []; + merged[canonical].push(fp); + } + } + } + } + + return merged; + } + /** * Parse LLM grouping response. Validates all files are assigned. */ diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 83c3f023f..cf9ab483b 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -304,6 +304,21 @@ export function resolveWorktreeCwd(repoPath: string, launchCwd: string): string */ export const REPO_ID_HASH_LENGTH = 6; +interface ImpactParams { + target: string; + target_uid?: string; + file_path?: string; + kind?: string; + direction: 'upstream' | 'downstream'; + maxDepth?: number; + relationTypes?: string[]; + includeTests?: boolean; + minConfidence?: number; + limit?: number; + offset?: number; + summaryOnly?: boolean; +} + export class LocalBackend { private repos: Map = new Map(); private contextCache: Map = new Map(); @@ -2750,20 +2765,7 @@ export class LocalBackend { }; } - private async impact( - repo: RepoHandle, - params: { - target: string; - target_uid?: string; - file_path?: string; - kind?: string; - direction: 'upstream' | 'downstream'; - maxDepth?: number; - relationTypes?: string[]; - includeTests?: boolean; - minConfidence?: number; - }, - ): Promise { + private async impact(repo: RepoHandle, params: ImpactParams): Promise { try { return await this._impactImpl(repo, params); } catch (err: any) { @@ -2780,20 +2782,7 @@ export class LocalBackend { } } - private async _impactImpl( - repo: RepoHandle, - params: { - target: string; - target_uid?: string; - file_path?: string; - kind?: string; - direction: 'upstream' | 'downstream'; - maxDepth?: number; - relationTypes?: string[]; - includeTests?: boolean; - minConfidence?: number; - }, - ): Promise { + private async _impactImpl(repo: RepoHandle, params: ImpactParams): Promise { await this.ensureInitialized(repo.id); const { target, direction } = params; @@ -2896,6 +2885,9 @@ export class LocalBackend { relationTypes: effectiveRelationTypes, includeTests, minConfidence, + limit: Number.isFinite(params.limit) ? params.limit : 100, + offset: Number.isFinite(params.offset) ? params.offset : 0, + summaryOnly: params.summaryOnly, }); } @@ -2912,9 +2904,20 @@ export class LocalBackend { relationTypes: string[]; includeTests: boolean; minConfidence: number; + limit?: number; + offset?: number; + summaryOnly?: boolean; }, ): Promise { const { maxDepth, relationTypes, includeTests, minConfidence } = opts; + const hasExplicitLimit = typeof opts.limit === 'number' && Number.isFinite(opts.limit); + const paginationLimit = hasExplicitLimit + ? Math.max(1, Math.min(Math.trunc(opts.limit!), 10000)) + : Infinity; + const rawOffset = + typeof opts.offset === 'number' && Number.isFinite(opts.offset) ? opts.offset : 0; + const paginationOffset = Math.max(0, Math.trunc(rawOffset)); + const summaryOnly = opts.summaryOnly ?? false; const relTypeFilter = relationTypes.map((t) => `'${t}'`).join(', '); const confidenceFilter = minConfidence > 0 ? ` AND r.confidence >= ${minConfidence}` : ''; @@ -3326,7 +3329,13 @@ export class LocalBackend { risk = 'MEDIUM'; } - return { + // Build per-depth counts (always included, even in summaryOnly mode) + const byDepthCounts: Record = {}; + for (const [depth, items] of Object.entries(grouped)) { + byDepthCounts[Number(depth)] = items.length; + } + + const base = { target: { id: symId, name: sym.name || sym[1], @@ -3342,9 +3351,37 @@ export class LocalBackend { processes_affected: processCount, modules_affected: moduleCount, }, + byDepthCounts, affected_processes: affectedProcesses, affected_modules: affectedModules, - byDepth: grouped, + }; + + if (summaryOnly) { + return base; + } + + // Apply limit/offset pagination per depth level + const paginatedGrouped: Record = {}; + let anyTruncated = false; + for (const [depth, items] of Object.entries(grouped)) { + const total = items.length; + const sliced = items.slice(paginationOffset, paginationOffset + paginationLimit); + paginatedGrouped[Number(depth)] = sliced; + if (paginationOffset > 0 || paginationOffset + paginationLimit < total) { + anyTruncated = true; + } + } + + return { + ...base, + ...(anyTruncated && { + pagination: { + ...(Number.isFinite(paginationLimit) && { limit: paginationLimit }), + offset: paginationOffset, + truncated: true, + }, + }), + byDepth: paginatedGrouped, }; } @@ -3498,6 +3535,9 @@ export class LocalBackend { if (typeof params.subgroup === 'string') impactArgs.subgroup = params.subgroup; if (params.timeoutMs !== undefined) impactArgs.timeoutMs = params.timeoutMs; if (params.timeout !== undefined) impactArgs.timeout = params.timeout; + // limit/offset/summaryOnly are not forwarded to group-mode impact: + // runGroupImpact uses GROUP_LOCAL_PHASE_LIMIT internally for UID + // collection and does not re-paginate the local result yet. return svc.groupImpact(impactArgs); } if (method === 'query') { diff --git a/gitnexus/src/mcp/tools.ts b/gitnexus/src/mcp/tools.ts index 9300f5ae5..15b7dc7d4 100644 --- a/gitnexus/src/mcp/tools.ts +++ b/gitnexus/src/mcp/tools.ts @@ -336,13 +336,15 @@ Output includes: - summary: direct callers, processes affected, modules affected - affected_processes: which execution flows break and at which step - affected_modules: which functional areas are hit (direct vs indirect) -- byDepth: all affected symbols grouped by traversal depth +- byDepth: affected symbols grouped by traversal depth (paginated by limit/offset; omitted when summaryOnly:true — use byDepthCounts for totals per depth, pagination object when truncated) Depth groups: - d=1: WILL BREAK (direct callers/importers) - d=2: LIKELY AFFECTED (indirect) - d=3: MAY NEED TESTING (transitive) +TIP: For hub symbols (base error classes, shared utilities) with many direct callers, use summaryOnly: true first to see counts and risk, then drill into specific depths with limit/offset. maxDepth alone does not bound output size when most dependents are at depth 1. limit and offset apply independently to each depth level, not to the total result set — use byDepthCounts to see totals per depth. + TIP: Default traversal uses CALLS/IMPORTS/EXTENDS/IMPLEMENTS. For class members, include HAS_METHOD and HAS_PROPERTY in relationTypes. For field access analysis, include ACCESSES in relationTypes. Handles disambiguation: when multiple symbols share the target name, returns ranked candidates (each with a relevance score) instead of silently picking one. Use target_uid for zero-ambiguity lookup, or narrow with file_path and/or kind hints. @@ -422,6 +424,27 @@ SERVICE: optional monorepo path prefix (case-sensitive path segments). When "rep description: 'Optional group subgroup prefix (member repo paths) limiting which repos participate in cross fan-out.', }, + limit: { + type: 'integer', + description: + 'Max symbols returned in byDepth per depth level (default: 100). Single-repo only; ignored in group mode (@groupName). Use small values for hub symbols to avoid output truncation.', + default: 100, + minimum: 1, + maximum: 10000, + }, + offset: { + type: 'integer', + description: + 'Skip this many symbols per depth level before applying limit. Single-repo only; ignored in group mode (@groupName). Use with limit for pagination.', + default: 0, + minimum: 0, + }, + summaryOnly: { + type: 'boolean', + description: + 'When true, returns target, summary, risk, byDepthCounts, affected_processes, and affected_modules — omits byDepth. Single-repo only; ignored in group mode (@groupName). Use for hub symbols to get actionable signal without output explosion.', + default: false, + }, timeoutMs: { type: 'number', description: diff --git a/gitnexus/test/fixtures/cobol/AUDITCONST.cpy b/gitnexus/test/fixtures/cobol/AUDITCONST.cpy new file mode 100644 index 000000000..7549829f2 --- /dev/null +++ b/gitnexus/test/fixtures/cobol/AUDITCONST.cpy @@ -0,0 +1,2 @@ + 01 WS-CONSTANT-1 PIC X(10) VALUE 'AUDIT'. + 01 WS-CONSTANT-2 PIC X(10) VALUE 'LOG'. diff --git a/gitnexus/test/fixtures/cobol/AUDITLOG.cbl b/gitnexus/test/fixtures/cobol/AUDITLOG.cbl new file mode 100644 index 000000000..3a294ea3c --- /dev/null +++ b/gitnexus/test/fixtures/cobol/AUDITLOG.cbl @@ -0,0 +1,77 @@ + IDENTIFICATION DIVISION. + PROGRAM-ID. AUDITLOG. + + DATA DIVISION. + WORKING-STORAGE SECTION. + 01 WS-LOG-MESSAGE PIC X(80). + 01 WS-TIMESTAMP PIC X(26). + 01 WS-IDX PIC 9(4). + 01 WS-EOF-FLAG PIC 9 VALUE 0. + 01 WS-PARAM-A PIC X(10). + 01 WS-PARAM-B PIC X(10). + 01 WS-PARAM-C PIC X(10). + 01 WS-INDEX PIC 9(2). + 01 WS-FLAG PIC 9. + COPY AUDITCONST. + COPY AUDITVARS. + + LINKAGE SECTION. + 01 LS-CUST-ID PIC 9(8). + 01 LS-AMOUNT PIC 9(7)V99. + + PROCEDURE DIVISION USING LS-CUST-ID LS-AMOUNT. + MAIN-PARAGRAPH. + PERFORM WRITE-LOG + PERFORM VARYING-TEST + PERFORM UNTIL-TEST + GOBACK. + + WRITE-LOG. + STRING 'Customer ' LS-CUST-ID ' amount ' LS-AMOUNT + DELIMITED BY SIZE INTO WS-LOG-MESSAGE + DISPLAY WS-LOG-MESSAGE. + + * PERFORM VARYING I FROM 1 BY 1 UNTIL I > 10 + VARYING-TEST. + PERFORM VARYING WS-IDX FROM 1 BY 1 + UNTIL WS-IDX > 10 + DISPLAY 'COUNT ' WS-IDX + END-PERFORM. + + * PERFORM UNTIL EOF-FLAG = 1 + UNTIL-TEST. + PERFORM UNTIL WS-EOF-FLAG = 1 + DISPLAY 'LOOPING' + END-PERFORM. + + * CALL with OMITTED (3 args: WS-PARAM-A, OMITTED, WS-PARAM-C) + CALL-OMITTED-TEST. + CALL 'PROCESS' USING WS-PARAM-A OMITTED WS-PARAM-C. + + * Nested IF with CALL inside + NESTED-IF-CALL. + IF WS-FLAG = 1 + IF WS-INDEX > 5 + CALL 'DEEPPROC' + ELSE + CALL 'SHALLOW' + END-IF + END-IF. + + * GO TO DEPENDING ON with 3 targets + GOTO-DEPENDING. + GO TO PARA-ONE PARA-TWO PARA-THREE + DEPENDING ON WS-INDEX. + + PARA-ONE. + DISPLAY 'ONE'. + + PARA-TWO. + DISPLAY 'TWO'. + + PARA-THREE. + DISPLAY 'THREE'. + + ENTRY "AUDITLOG-BATCH" USING LS-CUST-ID. + DISPLAY 'Batch audit for ' LS-CUST-ID + GOBACK. diff --git a/gitnexus/test/fixtures/cobol/AUDITVARS.cpy b/gitnexus/test/fixtures/cobol/AUDITVARS.cpy new file mode 100644 index 000000000..781bd7566 --- /dev/null +++ b/gitnexus/test/fixtures/cobol/AUDITVARS.cpy @@ -0,0 +1,2 @@ + 01 WS-VAR-1 PIC X(10). + 01 WS-VAR-2 PIC X(10). diff --git a/gitnexus/test/fixtures/cobol/COPYLIB.cpy b/gitnexus/test/fixtures/cobol/COPYLIB.cpy new file mode 100644 index 000000000..e78840d89 --- /dev/null +++ b/gitnexus/test/fixtures/cobol/COPYLIB.cpy @@ -0,0 +1,3 @@ + 01 PREFIX-RECORD. + 05 PREFIX-CODE PIC X(10). + 05 PREFIX-NAME PIC X(30). diff --git a/gitnexus/test/fixtures/cobol/CUSTDAT.cpy b/gitnexus/test/fixtures/cobol/CUSTDAT.cpy new file mode 100644 index 000000000..52428837a --- /dev/null +++ b/gitnexus/test/fixtures/cobol/CUSTDAT.cpy @@ -0,0 +1,6 @@ + 01 WS-CUSTOMER-DATA. + 05 WS-CUST-CODE PIC X(10). + 05 WS-CUST-TYPE PIC X(3). + 88 PREMIUM-CUSTOMER VALUE 'PRM'. + 88 REGULAR-CUSTOMER VALUE 'REG'. + 05 WS-CUST-ADDR PIC X(50). diff --git a/gitnexus/test/fixtures/cobol/CUSTUPDT.cbl b/gitnexus/test/fixtures/cobol/CUSTUPDT.cbl new file mode 100644 index 000000000..978e289d1 --- /dev/null +++ b/gitnexus/test/fixtures/cobol/CUSTUPDT.cbl @@ -0,0 +1,74 @@ + IDENTIFICATION DIVISION. + PROGRAM-ID. CUSTUPDT. + AUTHOR. TEST. + + ENVIRONMENT DIVISION. + INPUT-OUTPUT SECTION. + FILE-CONTROL. + SELECT CUSTOMER-FILE ASSIGN TO 'CUSTFILE' + ORGANIZATION IS INDEXED + ACCESS IS DYNAMIC + RECORD KEY IS CUST-ID + FILE STATUS IS WS-FILE-STATUS. + + DATA DIVISION. + FILE SECTION. + FD CUSTOMER-FILE. + 01 CUSTOMER-RECORD. + 05 CUST-ID PIC 9(8). + 05 CUST-NAME PIC X(30). + 05 CUST-BALANCE PIC 9(7)V99. + + WORKING-STORAGE SECTION. + 01 WS-FILE-STATUS PIC XX. + 01 WS-CUSTOMER-NAME PIC X(30). + 01 WS-AMOUNT PIC 9(7)V99. + 01 WS-EOF PIC 9 VALUE 0. + 88 END-OF-FILE VALUE 1. + 01 WS-AMT PIC 9(5)V99. + 01 WS-PROG-NAME PIC X(8). + 01 FIELD-A PIC 9(5)V99. + 01 FIELD-B PIC 9(5)V99. + COPY COPYLIB REPLACING ==PREFIX-== BY ==WS-==. + + LINKAGE SECTION. + 01 LS-PARAM PIC X(20). + + PROCEDURE DIVISION. + INIT-SECTION SECTION. + MAIN-PARAGRAPH. + PERFORM INIT-PARAGRAPH + PERFORM PROCESS-PARAGRAPH + PERFORM CLEANUP-PARAGRAPH + STOP RUN. + + INIT-PARAGRAPH. + OPEN I-O CUSTOMER-FILE + MOVE SPACES TO WS-CUSTOMER-NAME. + + PROCESSING-SECTION SECTION. + PROCESS-PARAGRAPH. + PERFORM READ-CUSTOMER THRU WRITE-CUSTOMER + CALL "AUDITLOG" USING CUST-ID WS-AMOUNT + CALL WS-PROG-NAME. + + READ-CUSTOMER. + READ CUSTOMER-FILE + NOT AT END + MOVE CUST-NAME TO WS-CUSTOMER-NAME + END-READ. + + UPDATE-BALANCE. + ADD WS-AMOUNT TO CUST-BALANCE + MOVE WS-AMOUNT TO CUST-BALANCE + MOVE WS-AMT TO FIELD-A FIELD-B. + + WRITE-CUSTOMER. + REWRITE CUSTOMER-RECORD. + + CLEANUP-PARAGRAPH. + CLOSE CUSTOMER-FILE. + + ENTRY 'ALTENTRY' USING LS-PARAM. + DISPLAY 'ALTERNATE ENTRY POINT' + GOBACK. diff --git a/gitnexus/test/fixtures/cobol/NESTED.cbl b/gitnexus/test/fixtures/cobol/NESTED.cbl new file mode 100644 index 000000000..60af79ef7 --- /dev/null +++ b/gitnexus/test/fixtures/cobol/NESTED.cbl @@ -0,0 +1,33 @@ + IDENTIFICATION DIVISION. + PROGRAM-ID. OUTER-PROG. + + DATA DIVISION. + WORKING-STORAGE SECTION. + 01 WS-OUTER-FLAG PIC 9 VALUE 0. + + PROCEDURE DIVISION. + OUTER-MAIN. + PERFORM OUTER-PROCESS + CALL "INNER-PROG" + STOP RUN. + + OUTER-PROCESS. + DISPLAY 'OUTER PROCESSING'. + + IDENTIFICATION DIVISION. + PROGRAM-ID. INNER-PROG. + + DATA DIVISION. + WORKING-STORAGE SECTION. + 01 WS-INNER-CODE PIC X(5). + + PROCEDURE DIVISION. + INNER-MAIN. + PERFORM INNER-PROCESS + GOBACK. + + INNER-PROCESS. + DISPLAY 'INNER PROCESSING'. + + END PROGRAM INNER-PROG. + END PROGRAM OUTER-PROG. diff --git a/gitnexus/test/fixtures/cobol/RPTGEN.cbl b/gitnexus/test/fixtures/cobol/RPTGEN.cbl new file mode 100644 index 000000000..3ef2c137f --- /dev/null +++ b/gitnexus/test/fixtures/cobol/RPTGEN.cbl @@ -0,0 +1,94 @@ + IDENTIFICATION DIVISION. + PROGRAM-ID. RPTGEN. + + DATA DIVISION. + WORKING-STORAGE SECTION. + COPY CUSTDAT. + 01 WS-REPORT-LINE PIC X(132). + 01 WS-SQL-CODE PIC S9(9) COMP. + 01 WS-COUNT PIC 9(4). + 01 WS-MAP-NAME PIC X(8). + 01 WS-SORT-FILE PIC X(8). + 01 WS-QUEUE-NAME PIC X(16). + 01 WS-NEXT-PGM PIC X(8). + + PROCEDURE DIVISION. + MAIN-PARAGRAPH. + PERFORM FETCH-DATA + PERFORM FORMAT-REPORT + PERFORM SEND-SCREEN + CALL "CUSTUPDT" + GO TO EXIT-PARAGRAPH. + + FETCH-DATA. + EXEC SQL + SELECT CUST_NAME, CUST_BALANCE + FROM CUSTOMER + WHERE CUST_ID = :WS-CUST-CODE + END-EXEC. + + FORMAT-REPORT. + PERFORM WS-COUNT TIMES + MOVE WS-CUST-CODE TO WS-REPORT-LINE + END-PERFORM + PERFORM MAIN-PARAGRAPH THRU FORMAT-REPORT + IF WS-COUNT > 0 PERFORM FETCH-DATA + ELSE PERFORM SEND-SCREEN + END-IF + SORT WS-SORT-FILE USING CUSTOMER-DATA + GIVING WS-REPORT-LINE. + SORT WS-SORT-FILE ON ASCENDING KEY WS-COUNT + INPUT PROCEDURE IS BUILD-SORT-INPUT + OUTPUT PROCEDURE IS WRITE-SORTED. + MOVE CORR WS-CUSTOMER-DATA TO WS-REPORT-LINE + SEARCH WS-CUSTOMER-DATA + GO TO FETCH-DATA FORMAT-REPORT SEND-SCREEN + DEPENDING ON WS-COUNT. + + SEND-SCREEN. + EXEC CICS + SEND MAP(WS-MAP-NAME) MAPSET('CUSTSET') + FROM(WS-REPORT-LINE) + END-EXEC. + + EXEC CICS + LINK PROGRAM('AUDITLOG') + END-EXEC. + + EXEC CICS + XCTL PROGRAM('CUSTUPDT') + END-EXEC. + + EXEC CICS + READ FILE('CUSTFILE') + INTO(WS-CUSTOMER-DATA) + END-EXEC. + + EXEC CICS + WRITEQ TS QUEUE('RPTQUEUE') + FROM(WS-REPORT-LINE) + END-EXEC. + + EXEC CICS + HANDLE ABEND LABEL(ABEND-HANDLER) + END-EXEC. + + EXEC CICS + RETURN TRANSID('RPTG') + END-EXEC. + + EXEC CICS + XCTL PROGRAM(WS-NEXT-PGM) + END-EXEC. + + BUILD-SORT-INPUT. + DISPLAY 'BUILDING SORT INPUT'. + + WRITE-SORTED. + DISPLAY 'WRITING SORTED OUTPUT'. + + ABEND-HANDLER. + DISPLAY 'ABEND OCCURRED'. + + EXIT-PARAGRAPH. + STOP RUN. diff --git a/gitnexus/test/fixtures/cobol/empty-file.cbl b/gitnexus/test/fixtures/cobol/empty-file.cbl new file mode 100644 index 000000000..e69de29bb diff --git a/gitnexus/test/fixtures/cobol/fixed-format.cbl b/gitnexus/test/fixtures/cobol/fixed-format.cbl new file mode 100644 index 000000000..8ccbb1837 --- /dev/null +++ b/gitnexus/test/fixtures/cobol/fixed-format.cbl @@ -0,0 +1,21 @@ + IDENTIFICATION DIVISION. + PROGRAM-ID. FIXEDFORMAT. + * Fixed-format COBOL with sequence numbers in cols 1-6 +000100 ENVIRONMENT DIVISION. +000200 DATA DIVISION. +000300 WORKING-STORAGE SECTION. +000400 01 WS-COUNTER PIC 9(4) VALUE 0. +000500 01 WS-NAME PIC X(20). +000600 +000700 PROCEDURE DIVISION. +000800 MAIN-PARA. +000900 PERFORM INIT-PARA +001000 PERFORM PROCESS-PARA +001100 STOP RUN. +001200 +001300 INIT-PARA. +001400 MOVE 1 TO WS-COUNTER. +001500 +001600 PROCESS-PARA. +001700 CALL "LOGGER" +001800 GOBACK. diff --git a/gitnexus/test/fixtures/cobol/malformed-multiline.cbl b/gitnexus/test/fixtures/cobol/malformed-multiline.cbl new file mode 100644 index 000000000..f023d870c --- /dev/null +++ b/gitnexus/test/fixtures/cobol/malformed-multiline.cbl @@ -0,0 +1,20 @@ + IDENTIFICATION DIVISION. + PROGRAM-ID. MALFORMED. + DATA DIVISION. + WORKING-STORAGE SECTION. + 01 WS-PGM PIC X(8) VALUE "OTHER". + PROCEDURE DIVISION. + MAIN. + * Incomplete statement (no period) + MOVE "TEST" TO WS-PGM + * CALL USING on separate lines + CALL "TARGET" + USING WS-PGM + RETURNING WS-PGM + * CALL without END-CALL across lines + CALL "MULTILINE" + USING WS-PGM + * GO TO with multiple targets + GO TO MAIN EXIT-PARA. + EXIT-PARA. + GOBACK. diff --git a/gitnexus/test/fixtures/cobol/whitespace-only.cbl b/gitnexus/test/fixtures/cobol/whitespace-only.cbl new file mode 100644 index 000000000..fd40910d9 --- /dev/null +++ b/gitnexus/test/fixtures/cobol/whitespace-only.cbl @@ -0,0 +1,4 @@ + + + + diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-overload-udc-namespace-collision/lib.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-overload-udc-namespace-collision/lib.cpp new file mode 100644 index 000000000..380679ba2 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-overload-udc-namespace-collision/lib.cpp @@ -0,0 +1,15 @@ +#include "lib.h" + +namespace alpha { + +Other::Other(int value) {} +void Service::f(Token value) {} +void Service::f(Other value) {} + +} // namespace alpha + +namespace beta { + +Token::Token(int value) {} + +} // namespace beta diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-overload-udc-namespace-collision/lib.h b/gitnexus/test/fixtures/lang-resolution/cpp-overload-udc-namespace-collision/lib.h new file mode 100644 index 000000000..10517d6d7 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-overload-udc-namespace-collision/lib.h @@ -0,0 +1,31 @@ +#pragma once + +namespace alpha { + +class Token {}; + +class Other { +public: + Other(int value); +}; + +class Service { +public: + void f(Token value); + void f(Other value); + + void run() { + f(42); + } +}; + +} // namespace alpha + +namespace beta { + +class Token { +public: + Token(int value); +}; + +} // namespace beta diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-overload-user-defined-conversion/lib.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-overload-user-defined-conversion/lib.cpp new file mode 100644 index 000000000..30111c7f3 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-overload-user-defined-conversion/lib.cpp @@ -0,0 +1,14 @@ +#include "lib.h" + +Wrap::Wrap(int value) {} +WrapA::WrapA(int value) {} +WrapB::WrapB(int value) {} +ExplicitWrap::ExplicitWrap(int value) {} + +void Service::f(Wrap value) {} +void Service::f(double value) {} +void Service::g(Wrap value) {} +void Service::h(WrapA value) {} +void Service::h(WrapB value) {} +void Service::e(Wrap value) {} +void Service::e(ExplicitWrap value) {} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-overload-user-defined-conversion/lib.h b/gitnexus/test/fixtures/lang-resolution/cpp-overload-user-defined-conversion/lib.h new file mode 100644 index 000000000..f6e117241 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-overload-user-defined-conversion/lib.h @@ -0,0 +1,39 @@ +#pragma once + +class Wrap { +public: + Wrap(int value); +}; + +class WrapA { +public: + WrapA(int value); +}; + +class WrapB { +public: + WrapB(int value); +}; + +class ExplicitWrap { +public: + explicit ExplicitWrap(int value); +}; + +class Service { +public: + void f(Wrap value); + void f(double value); + void g(Wrap value); + void h(WrapA value); + void h(WrapB value); + void e(Wrap value); + void e(ExplicitWrap value); + + void run() { + f(42); + g(42); + h(42); + e(42); + } +}; diff --git a/gitnexus/test/helpers/test-db.ts b/gitnexus/test/helpers/test-db.ts index 5be063a2c..3c845dbe0 100644 --- a/gitnexus/test/helpers/test-db.ts +++ b/gitnexus/test/helpers/test-db.ts @@ -21,6 +21,9 @@ const cleanupBackoffMs = (attempt: number): number => 100 * (attempt + 1); const shouldSwallowCleanupError = (err: unknown): boolean => { const code = (err as NodeJS.ErrnoException | undefined)?.code; + // ENOTEMPTY can race on any platform (macOS node-gyp cache, Linux + // parallel test teardown) — swallow after retries are exhausted. + if (code === 'ENOTEMPTY') return true; return process.platform === 'win32' && WINDOWS_NATIVE_LOCK_CODES.has(code ?? ''); }; diff --git a/gitnexus/test/integration/antigravity-hook-e2e.test.ts b/gitnexus/test/integration/antigravity-hook-e2e.test.ts index 5e8683471..617214c0c 100644 --- a/gitnexus/test/integration/antigravity-hook-e2e.test.ts +++ b/gitnexus/test/integration/antigravity-hook-e2e.test.ts @@ -20,6 +20,7 @@ import { spawnSync } from 'child_process'; import fs from 'fs'; import fsp from 'fs/promises'; import path from 'path'; +import { cleanupTempDir, cleanupTempDirSync } from '../helpers/test-db.js'; import os from 'os'; import { runHook, parseHookOutput } from '../utils/hook-test-helpers.js'; import { setupCommand } from '../../src/cli/setup.js'; @@ -84,8 +85,8 @@ beforeAll(async () => { afterAll(async () => { process.env.HOME = originalHome; process.env.USERPROFILE = originalUserProfile; - if (tempHome) await fsp.rm(tempHome, { recursive: true, force: true }); - if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + if (tempHome) await cleanupTempDir(tempHome); + if (tmpDir) cleanupTempDirSync(tmpDir); }); describe('antigravity hook adapter e2e', () => { @@ -402,7 +403,7 @@ describe('antigravity hook adapter e2e', () => { }); afterAll(() => { - fs.rmSync(cleanupRoot, { recursive: true, force: true }); + cleanupTempDirSync(cleanupRoot); }); it('ignores AfterTool when no .gitnexus exists in cwd or any ancestor', () => { diff --git a/gitnexus/test/integration/lbug-non-ascii-path.test.ts b/gitnexus/test/integration/lbug-non-ascii-path.test.ts new file mode 100644 index 000000000..b5f8243fa --- /dev/null +++ b/gitnexus/test/integration/lbug-non-ascii-path.test.ts @@ -0,0 +1,80 @@ +/** + * Integration Tests: Non-ASCII path handling (#1811) + * + * Verifies that LadybugDB can open a database and run COPY commands when + * the storage path contains CJK (or other non-ASCII) characters. + * + * The primary failure mode is on Windows, where KuzuDB's native layer + * uses ANSI file APIs and the Active Code Page mangles UTF-8 bytes. + * The fix converts paths to 8.3 short-name form on Windows. On + * Linux/macOS the conversion is a no-op since POSIX APIs handle UTF-8 + * natively — but locale misconfiguration or filesystem encoding + * mismatches could still surface, so the test runs on all platforms. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import fs from 'fs/promises'; +import path from 'path'; +import os from 'os'; +import { createMinimalTestGraph } from '../helpers/test-graph.js'; + +let tmpBase: string; +let storagePath: string; +let dbPath: string; + +beforeAll(async () => { + // Create a temp directory with CJK characters in the name. + // This reproduces the user's scenario: repo at C:\Project\中文\code + tmpBase = path.join(os.tmpdir(), `gitnexus-lbug-非ASCII路径-${Date.now()}-${process.pid}`); + storagePath = path.join(tmpBase, '.gitnexus'); + dbPath = path.join(storagePath, 'lbug'); + await fs.mkdir(dbPath, { recursive: true }); +}); + +afterAll(async () => { + // Close the adapter before cleanup to release native file handles. + try { + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + await adapter.closeLbug(); + } catch { + // May not have been opened + } + + if (tmpBase) { + // Retry cleanup — LadybugDB on Windows holds handles briefly after close. + for (let attempt = 0; attempt < 5; attempt++) { + try { + await fs.rm(tmpBase, { recursive: true, force: true }); + return; + } catch { + if (attempt < 4) await new Promise((r) => setTimeout(r, 200 * (attempt + 1))); + } + } + } +}); + +describe('LadybugDB with non-ASCII storage path (#1811)', () => { + it('initLbug succeeds with CJK characters in the database path', async () => { + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + await expect(adapter.initLbug(dbPath)).resolves.not.toThrow(); + }); + + it('loadGraphToLbug COPY succeeds with CJK characters in CSV paths', async () => { + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + const graph = createMinimalTestGraph(); + + await expect(adapter.loadGraphToLbug(graph, tmpBase, storagePath)).resolves.not.toThrow(); + }); + + it('data is queryable after loading through non-ASCII paths', async () => { + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + + const files = await adapter.executeQuery('MATCH (n:File) RETURN n.id AS id'); + expect(files).toHaveLength(2); + + const functions = await adapter.executeQuery('MATCH (n:Function) RETURN n.id AS id'); + expect(functions).toHaveLength(2); + + const rels = await adapter.executeQuery('MATCH ()-[r:CodeRelation]->() RETURN count(r) AS cnt'); + expect(rels[0].cnt).toBe(4); + }); +}); diff --git a/gitnexus/test/integration/resolvers/cobol-scope.test.ts b/gitnexus/test/integration/resolvers/cobol-scope.test.ts new file mode 100644 index 000000000..bd57656c9 --- /dev/null +++ b/gitnexus/test/integration/resolvers/cobol-scope.test.ts @@ -0,0 +1,553 @@ +/** + * COBOL scope-capture integration tests. + * + * These test that `emitCobolScopeCaptures` produces correct `CaptureMatch[]` + * from real COBOL source files, covering all 11 fixture classes. + * + * The test verifies capture output directly — scope-resolution pipeline + * integration (REGISTRY_PRIMARY_COBOL) will be tested in a follow-up phase + * after the pipeline wiring is complete. + */ + +import { describe, expect, it } from 'vitest'; +import path from 'path'; +import fs from 'fs'; +import { emitCobolScopeCaptures } from '../../../src/core/ingestion/languages/cobol/captures.js'; + +const FIXTURES = path.resolve(process.cwd(), 'test/fixtures/cobol'); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function readFixture(name: string): string { + const p = path.join(FIXTURES, name); + return fs.readFileSync(p, 'utf-8'); +} + +/** Count captures with exact name */ +function countByName(captures: readonly Record[], name: string): number { + return captures.filter((m) => name in m).length; +} + +/** Find a capture match where @declaration.name.text equals a value */ +function findDecl( + captures: readonly Record[], + name: string, +): Record | undefined { + return captures.find((m) => { + const n = (m as Record)['@declaration.name']; + return n?.text === name; + }); +} + +/** Find a capture match where @reference.name.text equals a value */ +function findRef( + captures: readonly Record[], + name: string, +): Record | undefined { + return captures.find((m) => { + const n = (m as Record)['@reference.name']; + return n?.text === name; + }); +} + +/** Find a capture match with @import.name.text */ +function findImport( + captures: readonly Record[], + name: string, +): Record | undefined { + return captures.find((m) => { + const n = (m as Record)['@import.name']; + return n?.text.toUpperCase() === name.toUpperCase(); + }); +} + +// =========================================================================== +// Class 1: Basic program structure +// =========================================================================== + +describe('Class 1: Basic program structure — PROGRAM-ID + paragraphs + CALL + PERFORM', () => { + it('AUDITLOG.cbl: PROGRAM-ID, PROCEDURE DIVISION USING, PERFORM', () => { + const result = emitCobolScopeCaptures(readFixture('AUDITLOG.cbl'), 'AUDITLOG.cbl'); + expect(result.length).toBeGreaterThan(0); + + // Should have a @scope.module for AUDITLOG + const moduleCount = countByName(result, '@scope.module'); + expect(moduleCount).toBe(1); + + // Should have @declaration.name = 'AUDITLOG' + const auditlog = findDecl(result, 'AUDITLOG'); + expect(auditlog).toBeDefined(); + + // Should have functions for paragraphs: MAIN-PARAGRAPH, WRITE-LOG + const funcCount = countByName(result, '@scope.function'); + expect(funcCount).toBeGreaterThanOrEqual(2); + + // Should have PERFORM references + const perfCount = countByName(result, '@reference.call'); + expect(perfCount).toBeGreaterThanOrEqual(1); + + // WRITE-LOG paragraph should be a function scope + const writeLog = findDecl(result, 'WRITE-LOG'); + expect(writeLog).toBeDefined(); + + // Gap 1: PERFORM VARYING captures target paragraph + expect(findDecl(result, 'VARYING-TEST')).toBeDefined(); + + // Gap 2: PERFORM UNTIL captures target paragraph + expect(findDecl(result, 'UNTIL-TEST')).toBeDefined(); + + // Gap 3: GO TO DEPENDING ON with 3 branches + expect(findRef(result, 'PARA-ONE')).toBeDefined(); + expect(findRef(result, 'PARA-TWO')).toBeDefined(); + expect(findRef(result, 'PARA-THREE')).toBeDefined(); + + // Gap 4: Multiple COPY statements + const copyConst = findImport(result, 'AUDITCONST'); + expect(copyConst).toBeDefined(); + const copyVars = findImport(result, 'AUDITVARS'); + expect(copyVars).toBeDefined(); + expect(copyConst).not.toBe(copyVars); + + // Gap 5: CALL with OMITTED — arity counts actual params, OMITTED is a keyword placeholder + const procRef = findRef(result, 'PROCESS'); + expect(procRef).toBeDefined(); + const procMatch = procRef as Record; + // CALL 'PROCESS' USING WS-PARAM-A OMITTED WS-PARAM-C has 2 actual params + expect(procMatch['@reference.arity']).toBeDefined(); + expect(procMatch['@reference.arity']!.text).toBe('2'); + + // Gap 6: CALLs inside nested IF blocks + expect(findRef(result, 'DEEPPROC')).toBeDefined(); + expect(findRef(result, 'SHALLOW')).toBeDefined(); + }); + + it('RPTGEN.cbl: PROGRAM-ID, PERFORM, GO TO, SORT INPUT/OUTPUT PROCEDURE', () => { + const result = emitCobolScopeCaptures(readFixture('RPTGEN.cbl'), 'RPTGEN.cbl'); + expect(result.length).toBeGreaterThan(0); + + // PROGRAM-ID + expect(countByName(result, '@scope.module')).toBe(1); + expect(findDecl(result, 'RPTGEN')).toBeDefined(); + + // Paragraphs: MAIN-PARAGRAPH, FETCH-DATA, FORMAT-REPORT, etc. + const funcCount = countByName(result, '@scope.function'); + expect(funcCount).toBeGreaterThanOrEqual(6); + + // REFERENCES for PERFORM and GO TO + const refCount = countByName(result, '@reference.call'); + expect(refCount).toBeGreaterThanOrEqual(5); + + // GO TO DEPENDING ON should create multiple reference targets + const gotoFetch = findRef(result, 'FETCH-DATA'); + expect(gotoFetch).toBeDefined(); + const gotoFormat = findRef(result, 'FORMAT-REPORT'); + expect(gotoFormat).toBeDefined(); + }); +}); + +// =========================================================================== +// Class 2: COPY import +// =========================================================================== + +describe('Class 2: COPY import — COPY bookname, COPY REPLACING', () => { + it('RPTGEN.cbl: COPY CUSTDAT without REPLACING', () => { + const result = emitCobolScopeCaptures(readFixture('RPTGEN.cbl'), 'RPTGEN.cbl'); + const imp = findImport(result, 'CUSTDAT'); + expect(imp).toBeDefined(); + }); + + it('CUSTUPDT.cbl: COPY COPYLIB REPLACING ==PREFIX-== BY ==WS-==', () => { + const result = emitCobolScopeCaptures(readFixture('CUSTUPDT.cbl'), 'CUSTUPDT.cbl'); + const imp = findImport(result, 'COPYLIB'); + expect(imp).toBeDefined(); + }); +}); + +// =========================================================================== +// Class 3: CALL USING +// =========================================================================== + +describe('Class 3: CALL USING — match/mismatch arity', () => { + it('AUDITLOG.cbl: PROCEDURE DIVISION USING with 2 params', () => { + const result = emitCobolScopeCaptures(readFixture('AUDITLOG.cbl'), 'AUDITLOG.cbl'); + // AUDITLOG has PROCEDURE DIVISION USING LS-CUST-ID LS-AMOUNT (2 params) + const auditlog = findDecl(result, 'AUDITLOG'); + expect(auditlog).toBeDefined(); + const match = auditlog as Record; + // Parameter count should be captured + expect(match['@declaration.parameter-count']).toBeDefined(); + expect(match['@declaration.parameter-count']!.text).toBe('2'); + }); + + it('CUSTUPDT.cbl: CALL "AUDITLOG" USING CUST-ID WS-AMOUNT (2 args)', () => { + const result = emitCobolScopeCaptures(readFixture('CUSTUPDT.cbl'), 'CUSTUPDT.cbl'); + // Should have CALL reference with arity=2 + const callRef = findRef(result, 'AUDITLOG'); + expect(callRef).toBeDefined(); + const match = callRef as Record; + expect(match['@reference.arity']).toBeDefined(); + expect(match['@reference.arity']!.text).toBe('2'); + }); +}); + +// =========================================================================== +// Class 4: Dynamic CALL +// =========================================================================== + +describe('Class 4: Dynamic CALL — CALL WS-VAR, stays unresolved', () => { + it('CUSTUPDT.cbl: CALL WS-PROG-NAME (dynamic, no quotes)', () => { + const result = emitCobolScopeCaptures(readFixture('CUSTUPDT.cbl'), 'CUSTUPDT.cbl'); + // WS-PROG-NAME should appear as a CALL reference + const dynCall = findRef(result, 'WS-PROG-NAME'); + expect(dynCall).toBeDefined(); + }); +}); + +// =========================================================================== +// Class 5: Nested programs +// =========================================================================== + +describe('Class 5: Nested programs — multiple PROGRAM-IDs, scope isolation', () => { + it('NESTED.cbl: OUTER-PROG + INNER-PROG in one file', () => { + const result = emitCobolScopeCaptures(readFixture('NESTED.cbl'), 'NESTED.cbl'); + // Two PROGRAM-IDs → 2 @scope.module captures + const moduleCount = countByName(result, '@scope.module'); + expect(moduleCount).toBe(2); + + // Both program names should appear + expect(findDecl(result, 'OUTER-PROG')).toBeDefined(); + expect(findDecl(result, 'INNER-PROG')).toBeDefined(); + + // Paragraphs in both programs + expect(findDecl(result, 'OUTER-MAIN')).toBeDefined(); + expect(findDecl(result, 'OUTER-PROCESS')).toBeDefined(); + expect(findDecl(result, 'INNER-MAIN')).toBeDefined(); + expect(findDecl(result, 'INNER-PROCESS')).toBeDefined(); + + // CALL "INNER-PROG" reference + expect(findRef(result, 'INNER-PROG')).toBeDefined(); + }); +}); + +// =========================================================================== +// Class 6: SECTION vs PARAGRAPH +// =========================================================================== + +describe('Class 6: SECTION vs PARAGRAPH — both map to Function', () => { + it('CUSTUPDT.cbl: Sections (INIT-SECTION, PROCESSING-SECTION) + paragraphs', () => { + const result = emitCobolScopeCaptures(readFixture('CUSTUPDT.cbl'), 'CUSTUPDT.cbl'); + // Sections declared as functions + expect(findDecl(result, 'INIT-SECTION')).toBeDefined(); + expect(findDecl(result, 'PROCESSING-SECTION')).toBeDefined(); + + // Paragraphs inside sections + expect(findDecl(result, 'MAIN-PARAGRAPH')).toBeDefined(); + expect(findDecl(result, 'INIT-PARAGRAPH')).toBeDefined(); + expect(findDecl(result, 'PROCESS-PARAGRAPH')).toBeDefined(); + + // Both are @scope.function + const funcCount = countByName(result, '@scope.function'); + expect(funcCount).toBeGreaterThanOrEqual(5); + }); +}); + +// =========================================================================== +// Class 7: Single-quoted CALL/COPY +// =========================================================================== + +describe('Class 7: Single-quoted CALL/COPY — the #500 regression case', () => { + it('CUSTUPDT.cbl: ENTRY with single quotes ALTENTRY', () => { + const result = emitCobolScopeCaptures(readFixture('CUSTUPDT.cbl'), 'CUSTUPDT.cbl'); + // ENTRY 'ALTENTRY' uses single quotes — should still produce captures + // (ENTRY points are recognized by the regex tagger) + // Verify the file processed without error + expect(result.length).toBeGreaterThan(0); + }); +}); + +// =========================================================================== +// Class 8: Fixed-format with sequence numbers +// =========================================================================== + +describe('Class 8: Fixed-format — sequence numbers in cols 1-6, Area A/B detection', () => { + it('fixed-format.cbl: sequence numbers 000100-001800, Area A paragraphs', () => { + const result = emitCobolScopeCaptures(readFixture('fixed-format.cbl'), 'fixed-format.cbl'); + expect(result.length).toBeGreaterThan(0); + + // Should detect PROGRAM-ID FIXEDFORMAT + expect(findDecl(result, 'FIXEDFORMAT')).toBeDefined(); + expect(countByName(result, '@scope.module')).toBe(1); + + // Paragraphs MAIN-PARA, INIT-PARA, PROCESS-PARA + expect(findDecl(result, 'MAIN-PARA')).toBeDefined(); + expect(findDecl(result, 'INIT-PARA')).toBeDefined(); + expect(findDecl(result, 'PROCESS-PARA')).toBeDefined(); + + // CALL "LOGGER" reference + expect(findRef(result, 'LOGGER')).toBeDefined(); + }); +}); + +// =========================================================================== +// Class 9: Edge: malformed/multiline +// =========================================================================== + +describe('Class 9: Edge: malformed/multiline — incomplete statements, CALL USING on separate lines', () => { + it('malformed-multiline.cbl: multiline CALL, incomplete statements', () => { + const result = emitCobolScopeCaptures( + readFixture('malformed-multiline.cbl'), + 'malformed-multiline.cbl', + ); + expect(result.length).toBeGreaterThan(0); + + // Should still detect PROGRAM-ID + expect(findDecl(result, 'MALFORMED')).toBeDefined(); + + // Paragraphs MAIN, EXIT-PARA + expect(findDecl(result, 'MAIN')).toBeDefined(); + expect(findDecl(result, 'EXIT-PARA')).toBeDefined(); + + // CALL "TARGET" should be captured (multi-line CALL with USING) + const target = findRef(result, 'TARGET'); + expect(target).toBeDefined(); + + // CALL "MULTILINE" should also be captured + const multi = findRef(result, 'MULTILINE'); + expect(multi).toBeDefined(); + + // GO TO EXIT-PARA reference + const exitRef = findRef(result, 'EXIT-PARA'); + expect(exitRef).toBeDefined(); + }); +}); + +// =========================================================================== +// Class 10: Edge: empty/whitespace file +// =========================================================================== + +describe('Class 10: Edge: empty/whitespace file — must not throw', () => { + it('empty-file.cbl: empty file produces empty captures', () => { + const result = emitCobolScopeCaptures(readFixture('empty-file.cbl'), 'empty-file.cbl'); + expect(result).toEqual([]); + }); + + it('whitespace-only.cbl: whitespace-only file produces empty captures', () => { + const result = emitCobolScopeCaptures( + readFixture('whitespace-only.cbl'), + 'whitespace-only.cbl', + ); + expect(result).toEqual([]); + }); +}); + +// =========================================================================== +// Class 11: Legacy parity — legacy processor doesn't crash on fixtures +// =========================================================================== + +describe('Class 11: Legacy parity — legacy processor handles fixtures', () => { + it('all fixtures can be processed without error', () => { + const files = fs.readdirSync(FIXTURES).filter((f) => f.endsWith('.cbl')); + expect(files.length).toBeGreaterThan(5); + + for (const file of files) { + const source = readFixture(file); + // Just running the regex tagger should not throw + expect(() => emitCobolScopeCaptures(source, file)).not.toThrow(); + } + }); +}); + +// =========================================================================== +// Cross-cutting: all fixtures produce expected structure +// =========================================================================== + +describe('Cross-cutting structure verification', () => { + it('every non-empty COBOL file produces at least one @scope.module', () => { + const files = fs + .readdirSync(FIXTURES) + .filter((f) => f.endsWith('.cbl') && f !== 'empty-file.cbl' && f !== 'whitespace-only.cbl'); + for (const file of files) { + const result = emitCobolScopeCaptures(readFixture(file), file); + const modCount = countByName(result, '@scope.module'); + expect(modCount).toBeGreaterThanOrEqual(1); + } + }); + + it('every @scope.function has a matching @declaration.name', () => { + const files = fs + .readdirSync(FIXTURES) + .filter((f) => f.endsWith('.cbl') && f !== 'empty-file.cbl' && f !== 'whitespace-only.cbl'); + for (const file of files) { + const result = emitCobolScopeCaptures(readFixture(file), file); + const funcScopes = result.filter((m) => '@scope.function' in m); + const funcDecls = result.filter((m) => '@declaration.function' in m); + expect(funcDecls.length).toBe(funcScopes.length); + } + }); +}); + +// =========================================================================== +// Reviewer Check 1: Program-ID scope isolation +// =========================================================================== + +describe('Reviewer Check 1: Program-ID scope isolation — CALLs in different PROGRAM-IDs', () => { + it('NESTED.cbl: OUTER-PROG and INNER-PROG each have isolated paragraphs', () => { + const result = emitCobolScopeCaptures(readFixture('NESTED.cbl'), 'NESTED.cbl'); + + // Both programs produce separate @scope.module captures + const mods = result.filter((m) => '@scope.module' in m); + expect(mods.length).toBe(2); + + // OUTER-PROG-related paragraphs + expect(findDecl(result, 'OUTER-MAIN')).toBeDefined(); + expect(findDecl(result, 'OUTER-PROCESS')).toBeDefined(); + // INNER-PROG-related paragraphs + expect(findDecl(result, 'INNER-MAIN')).toBeDefined(); + expect(findDecl(result, 'INNER-PROCESS')).toBeDefined(); + + // CALL "INNER-PROG" from OUTER-PROG + expect(findRef(result, 'INNER-PROG')).toBeDefined(); + }); + + it('CUSTUPDT.cbl: CALLs in one program do not cross-contaminate paragraphs', () => { + const result = emitCobolScopeCaptures(readFixture('CUSTUPDT.cbl'), 'CUSTUPDT.cbl'); + // CUSTUPDT is a single PROGRAM-ID; all paragraphs belong to it + expect(countByName(result, '@scope.module')).toBe(1); + // Verify several distinct paragraphs exist + expect(findDecl(result, 'MAIN-PARAGRAPH')).toBeDefined(); + expect(findDecl(result, 'INIT-PARAGRAPH')).toBeDefined(); + expect(findDecl(result, 'PROCESS-PARAGRAPH')).toBeDefined(); + expect(findDecl(result, 'READ-CUSTOMER')).toBeDefined(); + expect(findDecl(result, 'UPDATE-BALANCE')).toBeDefined(); + expect(findDecl(result, 'WRITE-CUSTOMER')).toBeDefined(); + expect(findDecl(result, 'CLEANUP-PARAGRAPH')).toBeDefined(); + }); +}); + +// =========================================================================== +// Reviewer Check 2: COPY REPLACING capture range consistency +// =========================================================================== + +describe('Reviewer Check 2: COPY REPLACING — capture ranges from transformed source', () => { + it('CUSTUPDT.cbl: COPY COPYLIB REPLACING capture range matches source line', () => { + const source = readFixture('CUSTUPDT.cbl'); + const lines = source.split('\n'); + const result = emitCobolScopeCaptures(source, 'CUSTUPDT.cbl'); + + const copyMatch = findImport(result, 'COPYLIB'); + expect(copyMatch).toBeDefined(); + + // Find '@import.statement' within the match + const importCap = copyMatch as Record< + string, + { name: string; range: { startLine: number }; text: string } + >; + const stmt = importCap['@import.statement']; + expect(stmt).toBeDefined(); + // Range should reference a valid line in the source + const lineIdx = stmt.range.startLine - 1; + expect(lineIdx).toBeGreaterThanOrEqual(0); + expect(lineIdx).toBeLessThan(lines.length); + // The line should contain COPY...COPYLIB + expect(lines[lineIdx].toUpperCase()).toContain('COPY'); + expect(lines[lineIdx].toUpperCase()).toContain('COPYLIB'); + }); + + it('RPTGEN.cbl: COPY CUSTDAT capture range matches source line', () => { + const source = readFixture('RPTGEN.cbl'); + const lines = source.split('\n'); + const result = emitCobolScopeCaptures(source, 'RPTGEN.cbl'); + + const copyMatch = findImport(result, 'CUSTDAT'); + expect(copyMatch).toBeDefined(); + + const importCap = copyMatch as Record< + string, + { name: string; range: { startLine: number }; text: string } + >; + const stmt = importCap['@import.statement']; + expect(stmt).toBeDefined(); + const lineIdx = stmt.range.startLine - 1; + expect(lineIdx).toBeGreaterThanOrEqual(0); + expect(lineIdx).toBeLessThan(lines.length); + expect(lines[lineIdx].toUpperCase()).toContain('COPY'); + expect(lines[lineIdx].toUpperCase()).toContain('CUSTDAT'); + }); +}); + +// =========================================================================== +// Reviewer Check 3: Import ownership scope +// =========================================================================== + +describe('Reviewer Check 3: importOwningScope returns Module scope for COPY', () => { + it('importOwningScope walks from paragraph to enclosing Module', async () => { + // Test the importOwningScope function directly + const { cobolImportOwningScope } = + await import('../../../src/core/ingestion/languages/cobol/interpret.js'); + // Simulate an import at paragraph (Function) scope: + // the function should walk up to find the Module. + const mockTree = { + getScope: (id: string) => { + if (id === 'func:test') return { id: 'func:test', kind: 'Function' } as any; + if (id === 'mod:test') return { id: 'mod:test', kind: 'Module' } as any; + return undefined; + }, + getAncestors: (_id: string) => ['mod:test'], + getParent: (_id: string) => undefined, + getChildren: (_id: string) => [], + has: (_id: string) => true, + byId: new Map(), + size: 2, + }; + + const paraScope = { + id: 'func:test', + kind: 'Function', + name: 'TEST-PARA', + range: { startLine: 1, startCol: 0, endLine: 2, endCol: 0 }, + } as any; + const result = cobolImportOwningScope(null as any, paraScope, mockTree); + expect(result).toBe('mod:test'); + }); + + it('importOwningScope returns innermost when already in Module scope', async () => { + const { cobolImportOwningScope } = + await import('../../../src/core/ingestion/languages/cobol/interpret.js'); + const mockTree = { + getScope: () => undefined, + getAncestors: () => [], + getParent: () => undefined, + getChildren: () => [], + has: () => true, + byId: new Map(), + size: 1, + }; + const modScope = { + id: 'mod:test', + kind: 'Module', + name: 'MYPROG', + range: { startLine: 1, startCol: 0, endLine: 100, endCol: 0 }, + } as any; + const result = cobolImportOwningScope(null as any, modScope, mockTree); + expect(result).toBe('mod:test'); + }); +}); + +// =========================================================================== +// Reviewer Check 5: Dynamic CALL produces no CALLS edge, CodeElement annotation +// =========================================================================== + +describe('Reviewer Check 5: Dynamic CALL — CALL WS-VAR captures as reference', () => { + it('CUSTUPDT.cbl: CALL WS-PROG-NAME is captured as a dynamic reference', () => { + const result = emitCobolScopeCaptures(readFixture('CUSTUPDT.cbl'), 'CUSTUPDT.cbl'); + // WS-PROG-NAME should appear as a CALL reference (dynamic, no quotes) + const dynRef = findRef(result, 'WS-PROG-NAME'); + expect(dynRef).toBeDefined(); + + // Verify it's a @reference.call, not a resolved edge + const ref = dynRef as Record; + expect(ref['@reference.call']).toBeDefined(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index b00aa9632..babdaf569 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -1983,6 +1983,71 @@ describe('C++ overload resolution — pointer/nullptr/ellipsis ranks (#1637)', ( }); }); +describe('C++ overload resolution — user-defined conversion rank (#1631)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-overload-user-defined-conversion'), + () => {}, + ); + }, 60000); + + it('f(42) resolves to f(double) because standard conversion beats constructor UDC', () => { + const calls = getRelationships(result, 'CALLS'); + const fCalls = calls.filter((c) => c.source === 'run' && c.target === 'f'); + + expect(fCalls.length).toBe(1); + const target = result.graph.getNode(fCalls[0].rel.targetId); + expect(target?.properties.parameterTypes).toEqual(['double']); + }); + + it('g(42) keeps a single constructor UDC viable when no standard conversion overload exists', () => { + const calls = getRelationships(result, 'CALLS'); + const gCalls = calls.filter((c) => c.source === 'run' && c.target === 'g'); + + expect(gCalls.length).toBe(1); + const target = result.graph.getNode(gCalls[0].rel.targetId); + expect(target?.properties.parameterTypes).toEqual(['Wrap']); + }); + + it('h(42) emits zero CALLS edges when two single-step constructor UDCs tie', () => { + const calls = getRelationships(result, 'CALLS'); + const hCalls = calls.filter((c) => c.source === 'run' && c.target === 'h'); + + expect(hCalls.length).toBe(0); + }); + + it('e(42) ignores the explicit-constructor overload and keeps the implicit UDC viable', () => { + const calls = getRelationships(result, 'CALLS'); + const eCalls = calls.filter((c) => c.source === 'run' && c.target === 'e'); + + expect(eCalls.length).toBe(1); + const target = result.graph.getNode(eCalls[0].rel.targetId); + expect(target?.properties.parameterTypes).toEqual(['Wrap']); + }); +}); + +describe('C++ overload resolution — UDC namespace collision guard (#1631)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-overload-udc-namespace-collision'), + () => {}, + ); + }, 60000); + + it('does not let beta::Token(int) tie the valid alpha::Other(int) conversion', () => { + const calls = getRelationships(result, 'CALLS'); + const fCalls = calls.filter((c) => c.source === 'run' && c.target === 'f'); + + expect(fCalls.length).toBe(1); + const target = result.graph.getNode(fCalls[0].rel.targetId); + expect(target?.properties.parameterTypes).toEqual(['Other']); + }); +}); + // --------------------------------------------------------------------------- // U3: anonymous-namespace symbols MUST NOT leak across translation units // (full-pipeline integration test; unit-level coverage exists separately) diff --git a/gitnexus/test/integration/resolvers/helpers.ts b/gitnexus/test/integration/resolvers/helpers.ts index 26c586ad1..bd17483ba 100644 --- a/gitnexus/test/integration/resolvers/helpers.ts +++ b/gitnexus/test/integration/resolvers/helpers.ts @@ -200,6 +200,12 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly([ + // Ruby scope-resolution currently achieves 89/127 parity. + // Tests listed here are scope-resolver-only correctness wins + // (pass under registry-primary, fail under legacy). Currently + // empty — all 127 tests pass under legacy mode. + ]), cpp: new Set([ // The legacy DAG path has no scope-aware filtering on the global // free-call fallback, so `#include`d headers still leak class @@ -307,6 +313,14 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly` overloads // guarded by mutually-exclusive `enable_if_t` predicates collapse diff --git a/gitnexus/test/integration/resolvers/ruby-scope.test.ts b/gitnexus/test/integration/resolvers/ruby-scope.test.ts new file mode 100644 index 000000000..ef44acf6a --- /dev/null +++ b/gitnexus/test/integration/resolvers/ruby-scope.test.ts @@ -0,0 +1,481 @@ +/** + * Ruby scope-resolution integration tests (U8). + * + * These tests run with REGISTRY_PRIMARY_RUBY=true to exercise the + * scope-based resolution path. They validate class methods, module mixins, + * singleton methods, require_relative imports, constructor inference, + * block scope, class inheritance, and super resolution. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import path from 'path'; +import fs from 'node:fs'; +import os from 'node:os'; +import { + getRelationships, + getNodesByLabel, + runPipelineFromRepo, + type PipelineResult, +} from './helpers.js'; + +function writeFixtureRepo(root: string, files: Record): void { + for (const [rel, content] of Object.entries(files)) { + const abs = path.join(root, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, content, 'utf8'); + } +} + +let savedEnv: string | undefined; + +beforeAll(() => { + savedEnv = process.env['REGISTRY_PRIMARY_RUBY']; + process.env['REGISTRY_PRIMARY_RUBY'] = 'true'; +}); + +afterAll(() => { + if (savedEnv === undefined) delete process.env['REGISTRY_PRIMARY_RUBY']; + else process.env['REGISTRY_PRIMARY_RUBY'] = savedEnv; +}); + +// --------------------------------------------------------------------------- +// 1. Basic class method resolution +// --------------------------------------------------------------------------- + +describe('Ruby scope: basic class method resolution', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruby-scope-basic-')); + writeFixtureRepo(tmpDir, { + 'models/user.rb': ` +class User + def save + true + end + + def greet + "hello" + end +end +`, + 'app.rb': ` +require_relative 'models/user' + +def main + u = User.new + u.save + u.greet +end +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('detects User class', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + }); + + it('detects save and greet as Method nodes', () => { + const methods = getNodesByLabel(result, 'Method'); + expect(methods).toContain('save'); + expect(methods).toContain('greet'); + }); + + it('emits HAS_METHOD edges from User to save and greet', () => { + const edges = getRelationships(result, 'HAS_METHOD'); + const userSave = edges.find((e) => e.source === 'User' && e.target === 'save'); + const userGreet = edges.find((e) => e.source === 'User' && e.target === 'greet'); + expect(userSave).toBeDefined(); + expect(userGreet).toBeDefined(); + }); + + it('resolves main → u.save() as CALLS edge', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find( + (c) => c.target === 'save' && c.source === 'main' && c.targetFilePath?.includes('user.rb'), + ); + expect(saveCall).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 2. Module mixin with include +// --------------------------------------------------------------------------- + +describe('Ruby scope: module mixin with include', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruby-scope-mixin-')); + writeFixtureRepo(tmpDir, { + 'serializable.rb': ` +module Serializable + def serialize + to_json + end +end +`, + 'user.rb': ` +require_relative 'serializable' + +class User + include Serializable + + def save + serialize + end +end +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('detects User as Class and Serializable as Trait', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Trait')).toContain('Serializable'); + }); + + it('emits IMPLEMENTS edge from User to Serializable', () => { + const impls = getRelationships(result, 'IMPLEMENTS'); + const edge = impls.find((e) => e.source === 'User' && e.target === 'Serializable'); + expect(edge).toBeDefined(); + }); + + it('resolves save → serialize as CALLS edge', () => { + const calls = getRelationships(result, 'CALLS'); + const serializeCall = calls.find((c) => c.target === 'serialize' && c.source === 'save'); + expect(serializeCall).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 3. Singleton method (def self.foo) +// --------------------------------------------------------------------------- + +describe('Ruby scope: singleton method (def self.foo)', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruby-scope-singleton-')); + writeFixtureRepo(tmpDir, { + 'config.rb': ` +class Config + def self.load + new + end + + def validate + true + end +end +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('detects Config class', () => { + expect(getNodesByLabel(result, 'Class')).toContain('Config'); + }); + + it('detects load (singleton) and validate (instance) as Method nodes', () => { + const methods = getNodesByLabel(result, 'Method'); + expect(methods).toContain('load'); + expect(methods).toContain('validate'); + }); + + it('emits HAS_METHOD edges from Config to both methods', () => { + const edges = getRelationships(result, 'HAS_METHOD'); + const configLoad = edges.find((e) => e.source === 'Config' && e.target === 'load'); + const configValidate = edges.find((e) => e.source === 'Config' && e.target === 'validate'); + expect(configLoad).toBeDefined(); + expect(configValidate).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 4. Require/require_relative import resolution +// --------------------------------------------------------------------------- + +describe('Ruby scope: require_relative import resolution', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruby-scope-imports-')); + writeFixtureRepo(tmpDir, { + 'lib/utils.rb': ` +class Utils + def format(text) + text.strip + end +end +`, + 'app.rb': ` +require_relative 'lib/utils' + +def run + u = Utils.new + u.format("hello") +end +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('emits IMPORTS edge from app.rb to lib/utils.rb', () => { + const imports = getRelationships(result, 'IMPORTS'); + const imp = imports.find( + (e) => e.sourceFilePath?.includes('app.rb') && e.targetFilePath?.includes('utils.rb'), + ); + expect(imp).toBeDefined(); + }); + + it('resolves run → u.format() as CALLS edge to utils.rb', () => { + const calls = getRelationships(result, 'CALLS'); + const formatCall = calls.find( + (c) => c.target === 'format' && c.source === 'run' && c.targetFilePath?.includes('utils.rb'), + ); + expect(formatCall).toBeDefined(); + }); + + it('detects Utils class and format method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('Utils'); + expect(getNodesByLabel(result, 'Method')).toContain('format'); + }); +}); + +// --------------------------------------------------------------------------- +// 5. Constructor inference (User.new) +// --------------------------------------------------------------------------- + +describe('Ruby scope: constructor inference via .new', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruby-scope-ctor-')); + writeFixtureRepo(tmpDir, { + 'formatter.rb': ` +class Formatter + def format(text) + text.upcase + end +end + +def main + f = Formatter.new + f.format("hello") +end +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('detects Formatter class and format method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('Formatter'); + expect(getNodesByLabel(result, 'Method')).toContain('format'); + }); + + it('emits HAS_METHOD edge from Formatter to format', () => { + const edges = getRelationships(result, 'HAS_METHOD'); + const fmtEdge = edges.find((e) => e.source === 'Formatter' && e.target === 'format'); + expect(fmtEdge).toBeDefined(); + }); + + it('resolves main → f.format() to Formatter#format via constructor inference', () => { + const calls = getRelationships(result, 'CALLS'); + const formatCall = calls.find( + (c) => + c.target === 'format' && c.source === 'main' && c.targetFilePath?.includes('formatter.rb'), + ); + expect(formatCall).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 6. Block scope (do...end with params) +// --------------------------------------------------------------------------- + +describe('Ruby scope: block scope with do...end', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruby-scope-block-')); + writeFixtureRepo(tmpDir, { + 'processor.rb': ` +class Processor + def run + items = [1, 2, 3] + items.each do |item| + process(item) + end + end + + def process(x) + x * 2 + end +end +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('detects Processor class', () => { + expect(getNodesByLabel(result, 'Class')).toContain('Processor'); + }); + + it('detects run and process as Method nodes on Processor', () => { + const methods = getNodesByLabel(result, 'Method'); + expect(methods).toContain('run'); + expect(methods).toContain('process'); + }); + + it('emits HAS_METHOD edges from Processor to run and process', () => { + const edges = getRelationships(result, 'HAS_METHOD'); + const procRun = edges.find((e) => e.source === 'Processor' && e.target === 'run'); + const procProcess = edges.find((e) => e.source === 'Processor' && e.target === 'process'); + expect(procRun).toBeDefined(); + expect(procProcess).toBeDefined(); + }); + + it('resolves run → process() as CALLS edge inside block scope', () => { + const calls = getRelationships(result, 'CALLS'); + const processCall = calls.find((c) => c.target === 'process' && c.source === 'run'); + expect(processCall).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 7. Class inheritance (EXTENDS) +// --------------------------------------------------------------------------- + +describe('Ruby scope: class inheritance via <', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruby-scope-inherit-')); + writeFixtureRepo(tmpDir, { + 'animals.rb': ` +class Animal + def speak + "..." + end +end + +class Dog < Animal + def bark + speak + end +end +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('detects Animal and Dog as Class nodes', () => { + const classes = getNodesByLabel(result, 'Class'); + expect(classes).toContain('Animal'); + expect(classes).toContain('Dog'); + }); + + it('emits EXTENDS edge from Dog to Animal', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + const edge = extends_.find((e) => e.source === 'Dog' && e.target === 'Animal'); + expect(edge).toBeDefined(); + }); + + it('resolves bark → speak as CALLS edge', () => { + const calls = getRelationships(result, 'CALLS'); + const speakCall = calls.find((c) => c.target === 'speak' && c.source === 'bark'); + expect(speakCall).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 8. Super resolution +// --------------------------------------------------------------------------- + +describe('Ruby scope: super resolution in subclass', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruby-scope-super-')); + writeFixtureRepo(tmpDir, { + 'hierarchy.rb': ` +class Base + def greet + "hello" + end +end + +class Child < Base + def greet + super + end +end + +def main + c = Child.new + c.greet +end +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('detects Base and Child as Class nodes', () => { + const classes = getNodesByLabel(result, 'Class'); + expect(classes).toContain('Base'); + expect(classes).toContain('Child'); + }); + + it('emits EXTENDS edge from Child to Base', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + const edge = extends_.find((e) => e.source === 'Child' && e.target === 'Base'); + expect(edge).toBeDefined(); + }); + + it('resolves main → c.greet() as CALLS edge', () => { + const calls = getRelationships(result, 'CALLS'); + const greetCall = calls.find((c) => c.target === 'greet' && c.source === 'main'); + expect(greetCall).toBeDefined(); + }); +}); diff --git a/gitnexus/test/integration/resolvers/ruby-sequential-mixin.test.ts b/gitnexus/test/integration/resolvers/ruby-sequential-mixin.test.ts index c3e105a15..36957fc09 100644 --- a/gitnexus/test/integration/resolvers/ruby-sequential-mixin.test.ts +++ b/gitnexus/test/integration/resolvers/ruby-sequential-mixin.test.ts @@ -102,8 +102,15 @@ function resolvedMethodOwners( describe('Ruby mixin heritage: sequential vs worker parity', () => { let sequential: PipelineResult; let workers: PipelineResult; + let savedEnv: string | undefined; beforeAll(async () => { + // Force legacy mode — these tests exercise inferImplicitReceiver + + // selectDispatch hooks which live in the legacy call-processor. + // With Ruby in MIGRATED_LANGUAGES the call-processor is gated off + // under registry-primary, so pin legacy for this suite. + savedEnv = process.env['REGISTRY_PRIMARY_RUBY']; + process.env['REGISTRY_PRIMARY_RUBY'] = '0'; sequential = await runMode({ skipWorkers: true }); // Force the worker pool to spawn even though the fixture is tiny. // Without this override, the pipeline's MIN_FILES_FOR_WORKERS / MIN_BYTES_FOR_WORKERS @@ -115,6 +122,11 @@ describe('Ruby mixin heritage: sequential vs worker parity', () => { }); }, 120000); + afterAll(() => { + if (savedEnv === undefined) delete process.env['REGISTRY_PRIMARY_RUBY']; + else process.env['REGISTRY_PRIMARY_RUBY'] = savedEnv; + }); + it('exercises both pipeline paths (sequential and worker)', () => { // If either of these assertions fails, every downstream parity check // below is meaningless — both modes would be running the same path. diff --git a/gitnexus/test/integration/resolvers/ruby.test.ts b/gitnexus/test/integration/resolvers/ruby.test.ts index 82806aac5..c60fe25b1 100644 --- a/gitnexus/test/integration/resolvers/ruby.test.ts +++ b/gitnexus/test/integration/resolvers/ruby.test.ts @@ -3,9 +3,10 @@ * calls, member calls, ambiguous disambiguation, local shadow, * constructor-inferred type resolution */ -import { describe, it, expect, beforeAll } from 'vitest'; +import { describe, expect, beforeAll } from 'vitest'; import path from 'path'; import { + createResolverParityIt, FIXTURES, CROSS_FILE_FIXTURES, getRelationships, @@ -16,6 +17,8 @@ import { type PipelineResult, } from './helpers.js'; +const pit = createResolverParityIt('ruby'); + // --------------------------------------------------------------------------- // Heritage: require_relative imports + include heritage + attr_* properties + calls // --------------------------------------------------------------------------- @@ -29,11 +32,11 @@ describe('Ruby require_relative, heritage & property resolution', () => { // --- Node detection --- - it('detects 3 classes', () => { + pit('detects 3 classes', () => { expect(getNodesByLabel(result, 'Class')).toEqual(['BaseModel', 'User', 'UserService']); }); - it('detects 3 modules (labeled as Trait for class-like registry lookup)', () => { + pit('detects 3 modules (labeled as Trait for class-like registry lookup)', () => { // Ruby `module` declarations are relabeled to `Trait` during ingestion so // they participate in `lookupClassByName` and `buildHeritageMap`. This is // the single source of truth for Ruby module detection in the graph. @@ -41,7 +44,7 @@ describe('Ruby require_relative, heritage & property resolution', () => { expect(getNodesByLabel(result, 'Module')).toEqual([]); }); - it('detects methods on classes and modules', () => { + pit('detects methods on classes and modules', () => { const methods = getNodesByLabel(result, 'Method'); expect(methods).toContain('persist'); expect(methods).toContain('run_validations'); @@ -50,12 +53,12 @@ describe('Ruby require_relative, heritage & property resolution', () => { expect(methods).toContain('create_user'); }); - it('detects singleton method (def self.factory) as Method', () => { + pit('detects singleton method (def self.factory) as Method', () => { const methods = getNodesByLabel(result, 'Method'); expect(methods).toContain('factory'); }); - it('emits CALLS from singleton method: factory → run_validations', () => { + pit('emits CALLS from singleton method: factory → run_validations', () => { const calls = getRelationships(result, 'CALLS').filter( (e) => e.source === 'factory' && e.target === 'run_validations', ); @@ -65,7 +68,7 @@ describe('Ruby require_relative, heritage & property resolution', () => { // --- Import resolution via require_relative --- - it('resolves 5 require_relative imports to IMPORTS edges', () => { + pit('resolves 5 require_relative imports to IMPORTS edges', () => { const imports = getRelationships(result, 'IMPORTS'); const importEdges = edgeSet(imports); expect(importEdges).toContain('user.rb → base_model.rb'); @@ -75,7 +78,7 @@ describe('Ruby require_relative, heritage & property resolution', () => { expect(importEdges).toContain('service.rb → user.rb'); }); - it('resolves bare require to IMPORTS edge', () => { + pit('resolves bare require to IMPORTS edge', () => { const imports = getRelationships(result, 'IMPORTS'); const bareRequire = imports.find( (e) => @@ -86,21 +89,21 @@ describe('Ruby require_relative, heritage & property resolution', () => { // --- Heritage: include → IMPLEMENTS --- - it('emits IMPLEMENTS edge for include Serializable with reason "include"', () => { + pit('emits IMPLEMENTS edge for include Serializable with reason "include"', () => { const implements_ = getRelationships(result, 'IMPLEMENTS'); const edge = implements_.find((e) => e.source === 'User' && e.target === 'Serializable'); expect(edge).toBeDefined(); expect(edge!.rel.reason).toBe('include'); }); - it('emits IMPLEMENTS edge for extend Loggable with reason "extend"', () => { + pit('emits IMPLEMENTS edge for extend Loggable with reason "extend"', () => { const implements_ = getRelationships(result, 'IMPLEMENTS'); const edge = implements_.find((e) => e.source === 'User' && e.target === 'Loggable'); expect(edge).toBeDefined(); expect(edge!.rel.reason).toBe('extend'); }); - it('emits IMPLEMENTS edge for prepend Cacheable with reason "prepend"', () => { + pit('emits IMPLEMENTS edge for prepend Cacheable with reason "prepend"', () => { const implements_ = getRelationships(result, 'IMPLEMENTS'); const edge = implements_.find((e) => e.source === 'User' && e.target === 'Cacheable'); expect(edge).toBeDefined(); @@ -109,7 +112,7 @@ describe('Ruby require_relative, heritage & property resolution', () => { // --- Extends: class inheritance --- - it('emits EXTENDS edge: User → BaseModel', () => { + pit('emits EXTENDS edge: User → BaseModel', () => { const extends_ = getRelationships(result, 'EXTENDS'); expect(extends_.length).toBe(1); const edges = edgeSet(extends_); @@ -118,25 +121,25 @@ describe('Ruby require_relative, heritage & property resolution', () => { // --- Property nodes: attr_accessor, attr_reader, attr_writer --- - it('creates Property nodes for attr_accessor :id and :created_at', () => { + pit('creates Property nodes for attr_accessor :id and :created_at', () => { const props = getNodesByLabel(result, 'Property'); expect(props).toContain('id'); expect(props).toContain('created_at'); }); - it('creates Property nodes for attr_reader :name and attr_writer :email', () => { + pit('creates Property nodes for attr_reader :name and attr_writer :email', () => { const props = getNodesByLabel(result, 'Property'); expect(props).toContain('name'); expect(props).toContain('email'); }); - it('emits HAS_PROPERTY from User to attr_reader :name', () => { + pit('emits HAS_PROPERTY from User to attr_reader :name', () => { const hasProperty = getRelationships(result, 'HAS_PROPERTY'); const edge = hasProperty.find((e) => e.source === 'User' && e.target === 'name'); expect(edge).toBeDefined(); }); - it('emits HAS_PROPERTY from BaseModel to attr_accessor :id', () => { + pit('emits HAS_PROPERTY from BaseModel to attr_accessor :id', () => { const hasProperty = getRelationships(result, 'HAS_PROPERTY'); const edge = hasProperty.find((e) => e.source === 'BaseModel' && e.target === 'id'); expect(edge).toBeDefined(); @@ -144,7 +147,7 @@ describe('Ruby require_relative, heritage & property resolution', () => { // --- Call resolution: method-level attribution --- - it('emits method-level CALLS: create_user → persist (member call)', () => { + pit('emits method-level CALLS: create_user → persist (member call)', () => { const calls = getRelationships(result, 'CALLS').filter( (e) => e.source === 'create_user' && e.target === 'persist', ); @@ -153,7 +156,7 @@ describe('Ruby require_relative, heritage & property resolution', () => { expect(calls[0].targetLabel).toBe('Method'); }); - it('emits method-level CALLS: create_user → greet_user (member call)', () => { + pit('emits method-level CALLS: create_user → greet_user (member call)', () => { const calls = getRelationships(result, 'CALLS').filter( (e) => e.source === 'create_user' && e.target === 'greet_user', ); @@ -162,21 +165,21 @@ describe('Ruby require_relative, heritage & property resolution', () => { expect(calls[0].targetLabel).toBe('Method'); }); - it('emits method-level CALLS: greet_user → persist (bare call)', () => { + pit('emits method-level CALLS: greet_user → persist (bare call)', () => { const calls = getRelationships(result, 'CALLS').filter( (e) => e.source === 'greet_user' && e.target === 'persist', ); expect(calls.length).toBe(1); }); - it('emits method-level CALLS: greet_user → serialize_data (bare call)', () => { + pit('emits method-level CALLS: greet_user → serialize_data (bare call)', () => { const calls = getRelationships(result, 'CALLS').filter( (e) => e.source === 'greet_user' && e.target === 'serialize_data', ); expect(calls.length).toBe(1); }); - it('emits method-level CALLS: persist → run_validations (bare call)', () => { + pit('emits method-level CALLS: persist → run_validations (bare call)', () => { const calls = getRelationships(result, 'CALLS').filter( (e) => e.source === 'persist' && e.target === 'run_validations', ); @@ -185,7 +188,7 @@ describe('Ruby require_relative, heritage & property resolution', () => { // --- Heritage edges point to real graph nodes --- - it('all heritage edges point to real graph nodes', () => { + pit('all heritage edges point to real graph nodes', () => { for (const edge of [ ...getRelationships(result, 'EXTENDS'), ...getRelationships(result, 'IMPLEMENTS'), @@ -197,7 +200,7 @@ describe('Ruby require_relative, heritage & property resolution', () => { // --- No OVERRIDES edges target Property nodes --- - it('no OVERRIDES edges target Property nodes', () => { + pit('no OVERRIDES edges target Property nodes', () => { const overrides = getRelationships(result, 'METHOD_OVERRIDES'); for (const edge of overrides) { const target = result.graph.getNode(edge.rel.targetId); @@ -218,7 +221,7 @@ describe('Ruby call resolution with arity filtering', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-calls'), () => {}); }, 60000); - it('resolves run_task → write_audit to one_arg.rb via arity narrowing', () => { + pit('resolves run_task → write_audit to one_arg.rb via arity narrowing', () => { const calls = getRelationships(result, 'CALLS'); const auditCall = calls.find((c) => c.target === 'write_audit'); expect(auditCall).toBeDefined(); @@ -239,7 +242,7 @@ describe('Ruby member-call resolution', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-member-calls'), () => {}); }, 60000); - it('resolves process_user → persist_record as a member call on User', () => { + pit('resolves process_user → persist_record as a member call on User', () => { const calls = getRelationships(result, 'CALLS'); const saveCall = calls.find((c) => c.target === 'persist_record'); expect(saveCall).toBeDefined(); @@ -247,12 +250,12 @@ describe('Ruby member-call resolution', () => { expect(saveCall!.targetFilePath).toContain('user.rb'); }); - it('detects User class and persist_record method', () => { + pit('detects User class and persist_record method', () => { expect(getNodesByLabel(result, 'Class')).toContain('User'); expect(getNodesByLabel(result, 'Method')).toContain('persist_record'); }); - it('emits HAS_METHOD edge from User to persist_record', () => { + pit('emits HAS_METHOD edge from User to persist_record', () => { const hasMethod = getRelationships(result, 'HAS_METHOD'); const edge = hasMethod.find((e) => e.source === 'User' && e.target === 'persist_record'); expect(edge).toBeDefined(); @@ -266,7 +269,7 @@ describe('Ruby qualified class names', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-qualified-types'), () => {}); }, 60000); - it('stores distinct qualified names for same-named classes across modules', () => { + pit('stores distinct qualified names for same-named classes across modules', () => { const users = getNodesByLabelFull(result, 'Class').filter((node) => node.name === 'User'); expect(users).toHaveLength(2); expect(users.map((node) => node.properties.qualifiedName).sort()).toEqual([ @@ -287,13 +290,13 @@ describe('Ruby ambiguous symbol resolution', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-ambiguous'), () => {}); }, 60000); - it('detects 2 Handler classes', () => { + pit('detects 2 Handler classes', () => { const classes = getNodesByLabel(result, 'Class'); expect(classes.filter((n) => n === 'Handler').length).toBe(2); expect(classes).toContain('UserHandler'); }); - it('resolves EXTENDS to models/handler.rb (not other/handler.rb)', () => { + pit('resolves EXTENDS to models/handler.rb (not other/handler.rb)', () => { const extends_ = getRelationships(result, 'EXTENDS'); expect(extends_.length).toBe(1); expect(extends_[0].source).toBe('UserHandler'); @@ -301,13 +304,13 @@ describe('Ruby ambiguous symbol resolution', () => { expect(extends_[0].targetFilePath).toBe('models/handler.rb'); }); - it('import edge points to models/ not other/', () => { + pit('import edge points to models/ not other/', () => { const imports = getRelationships(result, 'IMPORTS'); expect(imports.length).toBe(1); expect(imports[0].targetFilePath).toBe('models/handler.rb'); }); - it('all heritage edges point to real graph nodes', () => { + pit('all heritage edges point to real graph nodes', () => { for (const edge of getRelationships(result, 'EXTENDS')) { const target = result.graph.getNode(edge.rel.targetId); expect(target).toBeDefined(); @@ -326,7 +329,7 @@ describe('Ruby local definition shadows import', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-local-shadow'), () => {}); }, 60000); - it('resolves run_app → do_work to same-file definition, not the imported one', () => { + pit('resolves run_app → do_work to same-file definition, not the imported one', () => { const calls = getRelationships(result, 'CALLS'); const doWorkCall = calls.find((c) => c.target === 'do_work' && c.source === 'run_app'); expect(doWorkCall).toBeDefined(); @@ -348,19 +351,19 @@ describe('Ruby constructor-inferred type resolution', () => { ); }, 60000); - it('detects User, Repo, and AppService classes', () => { + pit('detects User, Repo, and AppService classes', () => { expect(getNodesByLabel(result, 'Class')).toContain('User'); expect(getNodesByLabel(result, 'Class')).toContain('Repo'); expect(getNodesByLabel(result, 'Class')).toContain('AppService'); }); - it('detects save on User and Repo, cleanup on all three', () => { + pit('detects save on User and Repo, cleanup on all three', () => { const methods = getNodesByLabel(result, 'Method'); expect(methods.filter((m) => m === 'save').length).toBe(2); expect(methods.filter((m) => m === 'cleanup').length).toBe(3); }); - it('resolves user.save to models/user.rb via constructor-inferred type', () => { + pit('resolves user.save to models/user.rb via constructor-inferred type', () => { const calls = getRelationships(result, 'CALLS'); const userSave = calls.find( (c) => c.target === 'save' && c.targetFilePath === 'models/user.rb', @@ -369,7 +372,7 @@ describe('Ruby constructor-inferred type resolution', () => { expect(userSave!.source).toBe('process_entities'); }); - it('resolves repo.save to models/repo.rb via constructor-inferred type', () => { + pit('resolves repo.save to models/repo.rb via constructor-inferred type', () => { const calls = getRelationships(result, 'CALLS'); const repoSave = calls.find( (c) => c.target === 'save' && c.targetFilePath === 'models/repo.rb', @@ -378,20 +381,20 @@ describe('Ruby constructor-inferred type resolution', () => { expect(repoSave!.source).toBe('process_entities'); }); - it('emits exactly 2 save CALLS edges (one per receiver type)', () => { + pit('emits exactly 2 save CALLS edges (one per receiver type)', () => { const calls = getRelationships(result, 'CALLS'); const saveCalls = calls.filter((c) => c.target === 'save'); expect(saveCalls.length).toBe(2); }); - it('resolves self.process_entities to services/app.rb (unique method)', () => { + pit('resolves self.process_entities to services/app.rb (unique method)', () => { const calls = getRelationships(result, 'CALLS'); const selfCall = calls.find((c) => c.source === 'greet' && c.target === 'process_entities'); expect(selfCall).toBeDefined(); expect(selfCall!.targetFilePath).toContain('app.rb'); }); - it('resolves self.cleanup to services/app.rb, not models/user.rb or models/repo.rb', () => { + pit('resolves self.cleanup to services/app.rb, not models/user.rb or models/repo.rb', () => { const calls = getRelationships(result, 'CALLS'); const selfCleanup = calls.find((c) => c.source === 'greet' && c.target === 'cleanup'); expect(selfCleanup).toBeDefined(); @@ -410,13 +413,13 @@ describe('Ruby self resolution', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-self-this-resolution'), () => {}); }, 60000); - it('detects User and Repo classes, each with a save method', () => { + pit('detects User and Repo classes, each with a save method', () => { expect(getNodesByLabel(result, 'Class')).toEqual(['Repo', 'User']); const saveMethods = getNodesByLabel(result, 'Method').filter((m) => m === 'save'); expect(saveMethods.length).toBe(2); }); - it('resolves self.save inside User#process to User#save, not Repo#save', () => { + pit('resolves self.save inside User#process to User#save, not Repo#save', () => { const calls = getRelationships(result, 'CALLS'); const saveCall = calls.find((c) => c.target === 'save' && c.source === 'process'); expect(saveCall).toBeDefined(); @@ -435,20 +438,20 @@ describe('Ruby parent resolution', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-parent-resolution'), () => {}); }, 60000); - it('detects BaseModel and User classes plus Serializable module (Trait)', () => { + pit('detects BaseModel and User classes plus Serializable module (Trait)', () => { expect(getNodesByLabel(result, 'Class')).toEqual(['BaseModel', 'User']); // Ruby modules are labeled Trait — see the "detects 3 modules" test above. expect(getNodesByLabel(result, 'Trait')).toEqual(['Serializable']); }); - it('emits EXTENDS edge: User < BaseModel', () => { + pit('emits EXTENDS edge: User < BaseModel', () => { const extends_ = getRelationships(result, 'EXTENDS'); expect(extends_.length).toBe(1); expect(extends_[0].source).toBe('User'); expect(extends_[0].target).toBe('BaseModel'); }); - it('emits IMPLEMENTS edge: User includes Serializable', () => { + pit('emits IMPLEMENTS edge: User includes Serializable', () => { const implements_ = getRelationships(result, 'IMPLEMENTS'); const includeEdge = implements_.find((e) => e.source === 'User' && e.target === 'Serializable'); expect(includeEdge).toBeDefined(); @@ -467,18 +470,18 @@ describe('Ruby super resolution', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-super-resolution'), () => {}); }, 60000); - it('detects BaseModel, User, and Repo classes', () => { + pit('detects BaseModel, User, and Repo classes', () => { expect(getNodesByLabel(result, 'Class')).toEqual(['BaseModel', 'Repo', 'User']); }); - it('emits EXTENDS edge: User < BaseModel', () => { + pit('emits EXTENDS edge: User < BaseModel', () => { const extends_ = getRelationships(result, 'EXTENDS'); expect(extends_.length).toBe(1); expect(extends_[0].source).toBe('User'); expect(extends_[0].target).toBe('BaseModel'); }); - it('detects save methods on all three classes', () => { + pit('detects save methods on all three classes', () => { const saveMethods = getNodesByLabel(result, 'Method').filter((m) => m === 'save'); expect(saveMethods.length).toBe(3); }); @@ -495,13 +498,13 @@ describe('Ruby constant constructor binding resolution', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-constant-constructor'), () => {}); }, 60000); - it('detects UserService class with process and validate methods', () => { + pit('detects UserService class with process and validate methods', () => { expect(getNodesByLabel(result, 'Class')).toContain('UserService'); expect(getNodesByLabel(result, 'Method')).toContain('process'); expect(getNodesByLabel(result, 'Method')).toContain('validate'); }); - it('resolves SERVICE.process() via constant constructor binding', () => { + pit('resolves SERVICE.process() via constant constructor binding', () => { const calls = getRelationships(result, 'CALLS'); const processCall = calls.find( (c) => c.target === 'process' && c.targetFilePath === 'models.rb', @@ -509,7 +512,7 @@ describe('Ruby constant constructor binding resolution', () => { expect(processCall).toBeDefined(); }); - it('resolves SERVICE.validate() via constant constructor binding', () => { + pit('resolves SERVICE.validate() via constant constructor binding', () => { const calls = getRelationships(result, 'CALLS'); const validateCall = calls.find( (c) => c.target === 'validate' && c.targetFilePath === 'models.rb', @@ -529,13 +532,13 @@ describe('Ruby YARD annotation type resolution', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-yard-annotations'), () => {}); }, 60000); - it('detects UserRepo, User, and UserService classes', () => { + pit('detects UserRepo, User, and UserService classes', () => { expect(getNodesByLabel(result, 'Class')).toContain('UserRepo'); expect(getNodesByLabel(result, 'Class')).toContain('User'); expect(getNodesByLabel(result, 'Class')).toContain('UserService'); }); - it('detects save, find_by_name, greet, and create methods', () => { + pit('detects save, find_by_name, greet, and create methods', () => { const methods = getNodesByLabel(result, 'Method'); expect(methods).toContain('save'); expect(methods).toContain('find_by_name'); @@ -543,14 +546,14 @@ describe('Ruby YARD annotation type resolution', () => { expect(methods).toContain('create'); }); - it('resolves repo.save to UserRepo#save via YARD @param annotation', () => { + pit('resolves repo.save to UserRepo#save via YARD @param annotation', () => { const calls = getRelationships(result, 'CALLS'); const saveCall = calls.find((c) => c.target === 'save' && c.source === 'create'); expect(saveCall).toBeDefined(); expect(saveCall!.targetFilePath).toContain('models.rb'); }); - it('resolves user.greet to User#greet via YARD @param annotation', () => { + pit('resolves user.greet to User#greet via YARD @param annotation', () => { const calls = getRelationships(result, 'CALLS'); const greetCall = calls.find((c) => c.target === 'greet' && c.source === 'create'); expect(greetCall).toBeDefined(); @@ -573,14 +576,14 @@ describe('Ruby namespaced constructor resolution (Models::UserService.new)', () ); }, 60000); - it('detects UserService class with process and validate methods', () => { + pit('detects UserService class with process and validate methods', () => { expect(getNodesByLabel(result, 'Class')).toContain('UserService'); const methods = getNodesByLabel(result, 'Method'); expect(methods).toContain('process'); expect(methods).toContain('validate'); }); - it('resolves svc.process() via namespaced constructor Models::UserService.new', () => { + pit('resolves svc.process() via namespaced constructor Models::UserService.new', () => { const calls = getRelationships(result, 'CALLS'); const processCall = calls.find( (c) => c.target === 'process' && c.targetFilePath.includes('user_service.rb'), @@ -588,7 +591,7 @@ describe('Ruby namespaced constructor resolution (Models::UserService.new)', () expect(processCall).toBeDefined(); }); - it('resolves svc.validate() via namespaced constructor Models::UserService.new', () => { + pit('resolves svc.validate() via namespaced constructor Models::UserService.new', () => { const calls = getRelationships(result, 'CALLS'); const validateCall = calls.find( (c) => c.target === 'validate' && c.targetFilePath.includes('user_service.rb'), @@ -613,23 +616,23 @@ describe('Ruby return type inference via function call', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-return-type'), () => {}); }, 60000); - it('detects User and Repo classes', () => { + pit('detects User and Repo classes', () => { expect(getNodesByLabel(result, 'Class')).toContain('User'); expect(getNodesByLabel(result, 'Class')).toContain('Repo'); }); - it('detects get_user and get_repo methods', () => { + pit('detects get_user and get_repo methods', () => { expect(getNodesByLabel(result, 'Method')).toContain('get_user'); expect(getNodesByLabel(result, 'Method')).toContain('get_repo'); }); - it('detects save method on both User and Repo (disambiguation required)', () => { + pit('detects save method on both User and Repo (disambiguation required)', () => { const methods = getNodesByLabel(result, 'Method'); // Both classes have save — fuzzy match alone cannot resolve this expect(methods.filter((m) => m === 'save').length).toBe(2); }); - it('resolves user.save to User#save via YARD @return [User] on get_user()', () => { + pit('resolves user.save to User#save via YARD @return [User] on get_user()', () => { // With both User#save and Repo#save in scope, resolving user.save // requires return type inference: get_user() → @return [User] → user is User const calls = getRelationships(result, 'CALLS'); @@ -642,7 +645,7 @@ describe('Ruby return type inference via function call', () => { expect(saveCall).toBeDefined(); }); - it('resolves repo.save to Repo#save via YARD @return [Repo] on get_repo()', () => { + pit('resolves repo.save to Repo#save via YARD @return [Repo] on get_repo()', () => { const calls = getRelationships(result, 'CALLS'); const saveCall = calls.find( (c) => @@ -665,14 +668,14 @@ describe('Ruby constant factory call resolution (SERVICE = build_service())', () result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-constant-factory-call'), () => {}); }, 60000); - it('detects UserService and AdminService classes with process and validate methods', () => { + pit('detects UserService and AdminService classes with process and validate methods', () => { expect(getNodesByLabel(result, 'Class')).toContain('UserService'); expect(getNodesByLabel(result, 'Class')).toContain('AdminService'); expect(getNodesByLabel(result, 'Method')).toContain('process'); expect(getNodesByLabel(result, 'Method')).toContain('validate'); }); - it('resolves SERVICE.process() to UserService#process via constant factory call', () => { + pit('resolves SERVICE.process() to UserService#process via constant factory call', () => { const calls = getRelationships(result, 'CALLS'); const processCall = calls.find( (c) => c.target === 'process' && c.targetFilePath.includes('user_service.rb'), @@ -687,7 +690,7 @@ describe('Ruby constant factory call resolution (SERVICE = build_service())', () expect(wrongCall).toBeUndefined(); }); - it('resolves SERVICE.validate() to UserService#validate via constant factory call', () => { + pit('resolves SERVICE.validate() to UserService#validate via constant factory call', () => { const calls = getRelationships(result, 'CALLS'); const validateCall = calls.find( (c) => c.target === 'validate' && c.targetFilePath.includes('user_service.rb'), @@ -710,13 +713,13 @@ describe('Ruby YARD generic type annotations (Hash)', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-yard-generics'), () => {}); }, 60000); - it('detects UserRepo, AdminRepo, and DataService classes', () => { + pit('detects UserRepo, AdminRepo, and DataService classes', () => { expect(getNodesByLabel(result, 'Class')).toContain('UserRepo'); expect(getNodesByLabel(result, 'Class')).toContain('AdminRepo'); expect(getNodesByLabel(result, 'Class')).toContain('DataService'); }); - it('detects save and find_all on both repos, plus sync and audit methods', () => { + pit('detects save and find_all on both repos, plus sync and audit methods', () => { const methods = getNodesByLabel(result, 'Method'); expect(methods).toContain('save'); expect(methods).toContain('find_all'); @@ -724,7 +727,7 @@ describe('Ruby YARD generic type annotations (Hash)', () => { expect(methods).toContain('audit'); }); - it('resolves repo.save in sync() to UserRepo#save via @param repo [UserRepo]', () => { + pit('resolves repo.save in sync() to UserRepo#save via @param repo [UserRepo]', () => { const calls = getRelationships(result, 'CALLS'); const saveCall = calls.find( (c) => c.target === 'save' && c.source === 'sync' && c.targetFilePath.includes('models.rb'), @@ -732,26 +735,32 @@ describe('Ruby YARD generic type annotations (Hash)', () => { expect(saveCall).toBeDefined(); }); - it('does NOT resolve cache param to a class (Hash is a generic container)', () => { - // The @param cache [Hash] should extract type "Hash" — not "UserRepo". - // Since Hash is not a class in the fixture, no type binding is created for cache. - // This verifies the bracket-balanced split doesn't break on the inner comma. - const calls = getRelationships(result, 'CALLS'); - // No calls should originate from cache.* since cache has no resolved type - const cacheCall = calls.find( - (c) => c.source === 'sync' && c.target === 'save' && c.targetFilePath.includes('admin'), - ); - expect(cacheCall).toBeUndefined(); - }); + pit( + 'does NOT resolve cache param to a class (Hash is a generic container)', + () => { + // The @param cache [Hash] should extract type "Hash" — not "UserRepo". + // Since Hash is not a class in the fixture, no type binding is created for cache. + // This verifies the bracket-balanced split doesn't break on the inner comma. + const calls = getRelationships(result, 'CALLS'); + // No calls should originate from cache.* since cache has no resolved type + const cacheCall = calls.find( + (c) => c.source === 'sync' && c.target === 'save' && c.targetFilePath.includes('admin'), + ); + expect(cacheCall).toBeUndefined(); + }, + ); - it('resolves admin_repo.save in audit() to AdminRepo#save via alternate @param [AdminRepo] order', () => { - const calls = getRelationships(result, 'CALLS'); - // audit() calls admin_repo.save — should resolve via the alternate YARD format - const saveCall = calls.find((c) => c.target === 'save' && c.source === 'audit'); - expect(saveCall).toBeDefined(); - }); + pit( + 'resolves admin_repo.save in audit() to AdminRepo#save via alternate @param [AdminRepo] order', + () => { + const calls = getRelationships(result, 'CALLS'); + // audit() calls admin_repo.save — should resolve via the alternate YARD format + const saveCall = calls.find((c) => c.target === 'save' && c.source === 'audit'); + expect(saveCall).toBeDefined(); + }, + ); - it('resolves admin_repo.find_all in audit() to AdminRepo#find_all', () => { + pit('resolves admin_repo.find_all in audit() to AdminRepo#find_all', () => { const calls = getRelationships(result, 'CALLS'); const findCall = calls.find((c) => c.target === 'find_all' && c.source === 'audit'); expect(findCall).toBeDefined(); @@ -771,7 +780,7 @@ describe('Ruby chained method call resolution (Phase 5 review fix)', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-chain-call'), () => {}); }, 60000); - it('detects User, Repo, UserService and App classes', () => { + pit('detects User, Repo, UserService and App classes', () => { const classes = getNodesByLabel(result, 'Class'); expect(classes).toContain('User'); expect(classes).toContain('Repo'); @@ -779,18 +788,18 @@ describe('Ruby chained method call resolution (Phase 5 review fix)', () => { expect(classes).toContain('App'); }); - it('detects save methods on both User and Repo', () => { + pit('detects save methods on both User and Repo', () => { const methods = getNodesByLabel(result, 'Method'); const saveMethods = methods.filter((m) => m === 'save'); expect(saveMethods.length).toBe(2); }); - it('detects get_user method on UserService', () => { + pit('detects get_user method on UserService', () => { const methods = getNodesByLabel(result, 'Method'); expect(methods).toContain('get_user'); }); - it('resolves svc.get_user.save to User#save via chain resolution', () => { + pit('resolves svc.get_user.save to User#save via chain resolution', () => { const calls = getRelationships(result, 'CALLS'); const userSave = calls.find( (c) => c.target === 'save' && c.source === 'process' && c.targetFilePath?.includes('user.rb'), @@ -798,7 +807,7 @@ describe('Ruby chained method call resolution (Phase 5 review fix)', () => { expect(userSave).toBeDefined(); }); - it('does NOT resolve svc.get_user.save to Repo#save', () => { + pit('does NOT resolve svc.get_user.save to Repo#save', () => { const calls = getRelationships(result, 'CALLS'); const repoSave = calls.find( (c) => c.target === 'save' && c.source === 'process' && c.targetFilePath?.includes('repo.rb'), @@ -818,11 +827,11 @@ describe('Ruby for-in loop resolution', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-for-in-loop'), () => {}); }, 60000); - it('detects User class with save method', () => { + pit('detects User class with save method', () => { expect(getNodesByLabel(result, 'Class')).toContain('User'); }); - it('resolves user.save in for-in to User#save', () => { + pit('resolves user.save in for-in to User#save', () => { const calls = getRelationships(result, 'CALLS'); const userSave = calls.find( (c) => @@ -831,7 +840,7 @@ describe('Ruby for-in loop resolution', () => { expect(userSave).toBeDefined(); }); - it('does NOT resolve user.save to Repo#save (negative)', () => { + pit('does NOT resolve user.save to Repo#save (negative)', () => { const calls = getRelationships(result, 'CALLS'); const wrongSave = calls.find( (c) => @@ -852,18 +861,18 @@ describe('Field type resolution (Ruby)', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-field-types'), () => {}); }, 60000); - it('detects classes: Address, User', () => { + pit('detects classes: Address, User', () => { expect(getNodesByLabel(result, 'Class')).toEqual(['Address', 'User']); }); - it('detects Property nodes for attr_accessor fields', () => { + pit('detects Property nodes for attr_accessor fields', () => { const properties = getNodesByLabel(result, 'Property'); expect(properties).toContain('address'); expect(properties).toContain('name'); expect(properties).toContain('city'); }); - it('emits HAS_PROPERTY edges linking properties to classes', () => { + pit('emits HAS_PROPERTY edges linking properties to classes', () => { const propEdges = getRelationships(result, 'HAS_PROPERTY'); expect(propEdges.length).toBe(3); expect(edgeSet(propEdges)).toContain('User → address'); @@ -871,7 +880,7 @@ describe('Field type resolution (Ruby)', () => { expect(edgeSet(propEdges)).toContain('Address → city'); }); - it('resolves user.address.save → Address#save via YARD @return [Address]', () => { + pit('resolves user.address.save → Address#save via YARD @return [Address]', () => { const calls = getRelationships(result, 'CALLS'); const saveCalls = calls.filter((e) => e.target === 'save'); const addressSave = saveCalls.find( @@ -880,7 +889,7 @@ describe('Field type resolution (Ruby)', () => { expect(addressSave).toBeDefined(); }); - it('Property nodes contain expected field names', () => { + pit('Property nodes contain expected field names', () => { const properties = getNodesByLabelFull(result, 'Property'); const city = properties.find((p) => p.name === 'city'); @@ -905,13 +914,13 @@ describe('Field type disambiguation (Ruby)', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-field-type-disambig'), () => {}); }, 60000); - it('detects both User#save and Address#save', () => { + pit('detects both User#save and Address#save', () => { const methods = getNodesByLabel(result, 'Method'); const saveMethods = methods.filter((m) => m === 'save'); expect(saveMethods.length).toBe(2); }); - it('resolves user.address.save → Address#save (not User#save)', () => { + pit('resolves user.address.save → Address#save (not User#save)', () => { const calls = getRelationships(result, 'CALLS'); const saveCalls = calls.filter((e) => e.target === 'save' && e.source === 'process_user'); expect(saveCalls.length).toBe(1); @@ -931,7 +940,7 @@ describe('Write access tracking (Ruby)', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-write-access'), () => {}); }, 60000); - it('emits ACCESSES write edges for setter assignments', () => { + pit('emits ACCESSES write edges for setter assignments', () => { const accesses = getRelationships(result, 'ACCESSES'); const writes = accesses.filter((e) => e.rel.reason === 'write'); expect(writes.length).toBe(3); @@ -946,7 +955,7 @@ describe('Write access tracking (Ruby)', () => { expect(scoreWrite!.source).toBe('update_user'); }); - it('emits ACCESSES write edge for compound assignment (operator_assignment)', () => { + pit('emits ACCESSES write edge for compound assignment (operator_assignment)', () => { const accesses = getRelationships(result, 'ACCESSES'); const writes = accesses.filter((e) => e.rel.reason === 'write'); const scoreWrite = writes.find((e) => e.target === 'score'); @@ -954,7 +963,7 @@ describe('Write access tracking (Ruby)', () => { expect(scoreWrite!.source).toBe('update_user'); }); - it('write ACCESSES edges have confidence 1.0', () => { + pit('write ACCESSES edges have confidence 1.0', () => { const accesses = getRelationships(result, 'ACCESSES'); const writes = accesses.filter((e) => e.rel.reason === 'write'); for (const edge of writes) { @@ -974,7 +983,7 @@ describe('Ruby call-result variable binding (Tier 2b)', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-call-result-binding'), () => {}); }, 60000); - it('resolves user.save to User#save via call-result binding', () => { + pit('resolves user.save to User#save via call-result binding', () => { const calls = getRelationships(result, 'CALLS'); const saveCall = calls.find( (c) => c.target === 'save' && c.source === 'process_user' && c.targetFilePath.includes('app'), @@ -994,7 +1003,7 @@ describe('Ruby method chain binding via unified fixpoint (Phase 9C)', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-method-chain-binding'), () => {}); }, 60000); - it('resolves city.save to City#save via method chain', () => { + pit('resolves city.save to City#save via method chain', () => { const calls = getRelationships(result, 'CALLS'); const saveCall = calls.find( (c) => @@ -1019,7 +1028,7 @@ describe('Ruby grandparent method resolution via MRO (Phase B)', () => { ); }, 60000); - it('detects A, B, C, Greeting classes', () => { + pit('detects A, B, C, Greeting classes', () => { const classes = getNodesByLabel(result, 'Class'); expect(classes).toContain('A'); expect(classes).toContain('B'); @@ -1027,13 +1036,13 @@ describe('Ruby grandparent method resolution via MRO (Phase B)', () => { expect(classes).toContain('Greeting'); }); - it('emits EXTENDS edges: B→A, C→B', () => { + pit('emits EXTENDS edges: B→A, C→B', () => { const extends_ = getRelationships(result, 'EXTENDS'); expect(edgeSet(extends_)).toContain('B → A'); expect(edgeSet(extends_)).toContain('C → B'); }); - it('resolves c.greet.save to Greeting#save via depth-2 MRO lookup', () => { + pit('resolves c.greet.save to Greeting#save via depth-2 MRO lookup', () => { const calls = getRelationships(result, 'CALLS'); const saveCall = calls.find( (c) => c.target === 'save' && c.targetFilePath.includes('greeting'), @@ -1041,7 +1050,7 @@ describe('Ruby grandparent method resolution via MRO (Phase B)', () => { expect(saveCall).toBeDefined(); }); - it('resolves c.greet to A#greet (method found via MRO walk)', () => { + pit('resolves c.greet to A#greet (method found via MRO walk)', () => { const calls = getRelationships(result, 'CALLS'); const greetCall = calls.find((c) => c.target === 'greet' && c.targetFilePath.includes('a.rb')); expect(greetCall).toBeDefined(); @@ -1059,7 +1068,7 @@ describe('Ruby default parameter arity resolution', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-default-params'), () => {}); }, 60000); - it('resolves greet("Alice") with 1 arg to greet with 2 params (1 default)', () => { + pit('resolves greet("Alice") with 1 arg to greet with 2 params (1 default)', () => { const calls = getRelationships(result, 'CALLS'); const greetCalls = calls.filter((c) => c.source === 'process' && c.target === 'greet'); expect(greetCalls.length).toBe(1); @@ -1081,18 +1090,18 @@ describe('Ruby cross-file binding propagation', () => { result = await runPipelineFromRepo(path.join(CROSS_FILE_FIXTURES, 'rb-cross-file'), () => {}); }, 60000); - it('detects User class with save and get_name methods', () => { + pit('detects User class with save and get_name methods', () => { expect(getNodesByLabel(result, 'Class')).toContain('User'); expect(getNodesByLabel(result, 'Method')).toContain('save'); expect(getNodesByLabel(result, 'Method')).toContain('get_name'); }); - it('detects UserFactory class and get_user method', () => { + pit('detects UserFactory class and get_user method', () => { expect(getNodesByLabel(result, 'Class')).toContain('UserFactory'); expect(getNodesByLabel(result, 'Method')).toContain('get_user'); }); - it('emits IMPORTS edge from app.rb to models', () => { + pit('emits IMPORTS edge from app.rb to models', () => { const imports = getRelationships(result, 'IMPORTS'); const edge = imports.find( (e) => e.sourceFilePath.includes('app') && e.targetFilePath.includes('models'), @@ -1100,7 +1109,7 @@ describe('Ruby cross-file binding propagation', () => { expect(edge).toBeDefined(); }); - it('resolves user.save in process to User#save via cross-file propagation', () => { + pit('resolves user.save in process to User#save via cross-file propagation', () => { const calls = getRelationships(result, 'CALLS'); const saveCall = calls.find( (c) => c.target === 'save' && c.source === 'process' && c.targetFilePath.includes('models'), @@ -1108,7 +1117,7 @@ describe('Ruby cross-file binding propagation', () => { expect(saveCall).toBeDefined(); }); - it('resolves user.get_name in process to User#get_name via cross-file propagation', () => { + pit('resolves user.get_name in process to User#get_name via cross-file propagation', () => { const calls = getRelationships(result, 'CALLS'); const getNameCall = calls.find( (c) => @@ -1117,7 +1126,7 @@ describe('Ruby cross-file binding propagation', () => { expect(getNameCall).toBeDefined(); }); - it('emits HAS_METHOD edges linking save and get_name to User', () => { + pit('emits HAS_METHOD edges linking save and get_name to User', () => { const hasMethod = getRelationships(result, 'HAS_METHOD'); const saveEdge = hasMethod.find((e) => e.source === 'User' && e.target === 'save'); const getNameEdge = hasMethod.find((e) => e.source === 'User' && e.target === 'get_name'); @@ -1138,11 +1147,11 @@ describe('Ruby method enrichment (visibility, isStatic, parameters)', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-method-enrichment'), () => {}); }, 60000); - it('detects Animal and Dog classes', () => { + pit('detects Animal and Dog classes', () => { expect(getNodesByLabel(result, 'Class')).toEqual(['Animal', 'Dog']); }); - it('detects all methods including singleton', () => { + pit('detects all methods including singleton', () => { const methods = getNodesByLabel(result, 'Method'); expect(methods).toContain('speak'); expect(methods).toContain('classify'); @@ -1151,7 +1160,7 @@ describe('Ruby method enrichment (visibility, isStatic, parameters)', () => { expect(methods).toContain('energy_level'); }); - it('emits HAS_METHOD edges for Animal and Dog', () => { + pit('emits HAS_METHOD edges for Animal and Dog', () => { const hasMethod = getRelationships(result, 'HAS_METHOD'); // Animal has speak, classify, from_habitat, internal_state expect(hasMethod.find((e) => e.source === 'Animal' && e.target === 'speak')).toBeDefined(); @@ -1167,7 +1176,7 @@ describe('Ruby method enrichment (visibility, isStatic, parameters)', () => { expect(hasMethod.find((e) => e.source === 'Dog' && e.target === 'energy_level')).toBeDefined(); }); - it('marks internal_state as private (when enriched)', () => { + pit('marks internal_state as private (when enriched)', () => { const methods = getNodesByLabelFull(result, 'Method'); const internalState = methods.find( (m) => m.name === 'internal_state' && m.properties.filePath?.includes('animal'), @@ -1180,7 +1189,7 @@ describe('Ruby method enrichment (visibility, isStatic, parameters)', () => { } }); - it('marks energy_level as protected (when enriched)', () => { + pit('marks energy_level as protected (when enriched)', () => { const methods = getNodesByLabelFull(result, 'Method'); const energyLevel = methods.find( (m) => m.name === 'energy_level' && m.properties.filePath?.includes('animal'), @@ -1191,7 +1200,7 @@ describe('Ruby method enrichment (visibility, isStatic, parameters)', () => { } }); - it('marks classify as static (when enriched)', () => { + pit('marks classify as static (when enriched)', () => { const methods = getNodesByLabelFull(result, 'Method'); const classify = methods.find( (m) => m.name === 'classify' && m.properties.filePath?.includes('animal'), @@ -1202,7 +1211,7 @@ describe('Ruby method enrichment (visibility, isStatic, parameters)', () => { } }); - it('marks from_habitat (class << self) as static and public (when enriched)', () => { + pit('marks from_habitat (class << self) as static and public (when enriched)', () => { const methods = getNodesByLabelFull(result, 'Method'); const fromHabitat = methods.find( (m) => m.name === 'from_habitat' && m.properties.filePath?.includes('animal'), @@ -1216,7 +1225,7 @@ describe('Ruby method enrichment (visibility, isStatic, parameters)', () => { } }); - it('extracts parameterCount for from_habitat(habitat)', () => { + pit('extracts parameterCount for from_habitat(habitat)', () => { const methods = getNodesByLabelFull(result, 'Method'); const fromHabitat = methods.find( (m) => m.name === 'from_habitat' && m.properties.filePath?.includes('animal'), @@ -1225,7 +1234,7 @@ describe('Ruby method enrichment (visibility, isStatic, parameters)', () => { expect(fromHabitat!.properties.parameterCount).toBe(1); }); - it('marks speak as public (when enriched)', () => { + pit('marks speak as public (when enriched)', () => { const methods = getNodesByLabelFull(result, 'Method'); const speak = methods.find( (m) => m.name === 'speak' && m.properties.filePath?.includes('animal'), @@ -1237,7 +1246,7 @@ describe('Ruby method enrichment (visibility, isStatic, parameters)', () => { } }); - it('extracts parameterCount for classify(name)', () => { + pit('extracts parameterCount for classify(name)', () => { const methods = getNodesByLabelFull(result, 'Method'); const classify = methods.find( (m) => m.name === 'classify' && m.properties.filePath?.includes('animal'), @@ -1246,7 +1255,7 @@ describe('Ruby method enrichment (visibility, isStatic, parameters)', () => { expect(classify!.properties.parameterCount).toBe(1); }); - it('resolves dog.speak member call from main to Dog#speak', () => { + pit('resolves dog.speak member call from main to Dog#speak', () => { const calls = getRelationships(result, 'CALLS'); const speakCall = calls.find( (c) => c.source === 'main' && c.target === 'speak' && c.targetFilePath.includes('animal'), @@ -1254,13 +1263,13 @@ describe('Ruby method enrichment (visibility, isStatic, parameters)', () => { expect(speakCall).toBeDefined(); }); - it('emits EXTENDS edge from Dog to Animal', () => { + pit('emits EXTENDS edge from Dog to Animal', () => { const extends_ = getRelationships(result, 'EXTENDS'); const edge = extends_.find((e) => e.source === 'Dog' && e.target === 'Animal'); expect(edge).toBeDefined(); }); - it('detects main as top-level Method in app.rb', () => { + pit('detects main as top-level Method in app.rb', () => { // Ruby top-level def is parsed as a method node (tree-sitter `method` type) const methods = getNodesByLabel(result, 'Method'); expect(methods).toContain('main'); @@ -1276,14 +1285,14 @@ describe('Ruby singleton_class handling via sequential path (skipWorkers)', () = }); }, 60000); - it('keeps Animal as the owner for class << self methods', () => { + pit('keeps Animal as the owner for class << self methods', () => { const hasMethod = getRelationships(result, 'HAS_METHOD'); expect( hasMethod.find((e) => e.source === 'Animal' && e.target === 'from_habitat'), ).toBeDefined(); }); - it('marks from_habitat as static in the sequential path', () => { + pit('marks from_habitat as static in the sequential path', () => { const methods = getNodesByLabelFull(result, 'Method'); const fromHabitat = methods.find( (m) => m.name === 'from_habitat' && m.properties.filePath?.includes('animal'), @@ -1305,17 +1314,17 @@ describe('Ruby overload dispatch (format vs format_with_prefix)', () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-overload-dispatch'), () => {}); }, 60000); - it('detects Formatter class', () => { + pit('detects Formatter class', () => { expect(getNodesByLabel(result, 'Class')).toContain('Formatter'); }); - it('detects format and format_with_prefix methods', () => { + pit('detects format and format_with_prefix methods', () => { const methods = getNodesByLabel(result, 'Method'); expect(methods).toContain('format'); expect(methods).toContain('format_with_prefix'); }); - it('emits HAS_METHOD edges for both methods on Formatter', () => { + pit('emits HAS_METHOD edges for both methods on Formatter', () => { const hasMethod = getRelationships(result, 'HAS_METHOD'); expect(hasMethod.find((e) => e.source === 'Formatter' && e.target === 'format')).toBeDefined(); expect( @@ -1323,21 +1332,21 @@ describe('Ruby overload dispatch (format vs format_with_prefix)', () => { ).toBeDefined(); }); - it('extracts arity for format(value) — 1 parameter', () => { + pit('extracts arity for format(value) — 1 parameter', () => { const methods = getNodesByLabelFull(result, 'Method'); const format = methods.find((m) => m.name === 'format'); expect(format).toBeDefined(); expect(format!.properties.parameterCount).toBe(1); }); - it('extracts arity for format_with_prefix(value, prefix) — 2 parameters', () => { + pit('extracts arity for format_with_prefix(value, prefix) — 2 parameters', () => { const methods = getNodesByLabelFull(result, 'Method'); const fwp = methods.find((m) => m.name === 'format_with_prefix'); expect(fwp).toBeDefined(); expect(fwp!.properties.parameterCount).toBe(2); }); - it('resolves f.format call from run to Formatter#format', () => { + pit('resolves f.format call from run to Formatter#format', () => { const calls = getRelationships(result, 'CALLS'); const formatCall = calls.find( (c) => c.source === 'run' && c.target === 'format' && c.targetFilePath.includes('formatter'), @@ -1345,7 +1354,7 @@ describe('Ruby overload dispatch (format vs format_with_prefix)', () => { expect(formatCall).toBeDefined(); }); - it('resolves f.format_with_prefix call from run to Formatter#format_with_prefix', () => { + pit('resolves f.format_with_prefix call from run to Formatter#format_with_prefix', () => { const calls = getRelationships(result, 'CALLS'); const fwpCall = calls.find( (c) => @@ -1356,7 +1365,7 @@ describe('Ruby overload dispatch (format vs format_with_prefix)', () => { expect(fwpCall).toBeDefined(); }); - it('detects run as top-level Method in app.rb', () => { + pit('detects run as top-level Method in app.rb', () => { // Ruby top-level def is parsed as a method node (tree-sitter `method` type) const methods = getNodesByLabel(result, 'Method'); expect(methods).toContain('run'); @@ -1374,13 +1383,13 @@ describe('Ruby Child extends Parent — inherited method resolution (SM-9)', () result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-child-extends-parent'), () => {}); }, 60000); - it('detects Parent and Child classes', () => { + pit('detects Parent and Child classes', () => { const classes = getNodesByLabel(result, 'Class'); expect(classes).toContain('Parent'); expect(classes).toContain('Child'); }); - it('resolves c.parent_method to Parent#parent_method via first-wins MRO walk', () => { + pit('resolves c.parent_method to Parent#parent_method via first-wins MRO walk', () => { const calls = getRelationships(result, 'CALLS'); const parentMethodCall = calls.find( (c) => c.target === 'parent_method' && c.targetFilePath.includes('parent.rb'), diff --git a/gitnexus/test/integration/ruby-pipeline-benchmark.test.ts b/gitnexus/test/integration/ruby-pipeline-benchmark.test.ts new file mode 100644 index 000000000..3f3b10ee8 --- /dev/null +++ b/gitnexus/test/integration/ruby-pipeline-benchmark.test.ts @@ -0,0 +1,305 @@ +/** + * Ruby ingestion pipeline benchmark. + * + * Generates synthetic Ruby codebases at increasing scales and measures + * wall-clock time and peak heap through the full pipeline — parsing, + * scope extraction, heritage (include/extend/prepend), MRO construction, + * and call resolution via the registry-primary scope-resolution path. + * + * Run: GITNEXUS_BENCH=1 npx vitest run test/integration/ruby-pipeline-benchmark.test.ts + * + * The benchmark uses workers (production path) by default. Set + * skipWorkers to test the sequential fallback path. + */ +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; + +const BENCH_ENABLED = process.env.GITNEXUS_BENCH === '1'; + +interface BenchResult { + fileCount: number; + classCount: number; + moduleCount: number; + mixinModuleCount: number; + elapsedMs: number; + peakHeapMB: number; + nodeCount: number; + edgeCount: number; + implementsCount: number; + hasPropertyCount: number; + extendsCount: number; +} + +function generateRubyFixture( + fileCount: number, + modulesPerLevel: number, +): { dir: string; classCount: number; moduleCount: number; mixinModuleCount: number } { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), `ruby-bench-${fileCount}-`)); + + // Three families of mixins: one for include, one for extend, one for prepend. + // Each family has modulesPerLevel² modules so the MRO partitioning logic is + // exercised with all three heritage kinds and varied orderings. + const includeMixins: string[] = []; + const extendMixins: string[] = []; + const prependMixins: string[] = []; + + for (let i = 0; i < modulesPerLevel; i++) { + for (let j = 0; j < modulesPerLevel; j++) { + includeMixins.push(`Includable${i}x${j}`); + extendMixins.push(`Extendable${i}x${j}`); + prependMixins.push(`Prependable${i}x${j}`); + } + } + + const allMixins = [...includeMixins, ...extendMixins, ...prependMixins]; + const moduleCount = allMixins.length; + const classCount = fileCount; + + // Generate mixin module files — each module includes a shared base module + // to create diamond mixin patterns (class includes A and B, both include Base). + const concernsDir = path.join(dir, 'lib', 'concerns'); + fs.mkdirSync(concernsDir, { recursive: true }); + + // Shared base modules that other mixins include (diamond pattern) + const baseModuleCount = Math.max(2, Math.floor(modulesPerLevel / 2)); + for (let b = 0; b < baseModuleCount; b++) { + const baseName = `BaseMixin${b}`; + const content = [ + `module ${baseName}`, + ` def base${b}_check`, + ' true', + ' end', + 'end', + '', + ].join('\n'); + fs.writeFileSync(path.join(concernsDir, `${baseName.toLowerCase()}.rb`), content); + } + + for (let m = 0; m < allMixins.length; m++) { + const moduleName = allMixins[m]; + const baseIdx = m % baseModuleCount; + const baseName = `BaseMixin${baseIdx}`; + const content = [ + `require_relative '${baseName.toLowerCase()}'`, + '', + `module ${moduleName}`, + ` include ${baseName}`, + '', + ` def ${moduleName.toLowerCase()}_action`, + ` base${baseIdx}_check`, + ' end', + 'end', + '', + ].join('\n'); + fs.writeFileSync(path.join(concernsDir, `${moduleName.toLowerCase()}.rb`), content); + } + + // Generate class files — each class uses include + extend + prepend with + // different modules, creating a rich MRO that exercises all three + // heritage-kind partitions in buildRubyMro. + const modelsDir = path.join(dir, 'lib', 'models'); + fs.mkdirSync(modelsDir, { recursive: true }); + + for (let f = 0; f < fileCount; f++) { + const className = `Model${f}`; + + // Pick one mixin of each kind (rotating through the pools) + const incMixin = includeMixins[f % includeMixins.length]; + const extMixin = extendMixins[f % extendMixins.length]; + const preMixin = prependMixins[f % prependMixins.length]; + // Second include mixin for diamond-overlap testing + const incMixin2 = includeMixins[(f + 1) % includeMixins.length]; + + const siblingIdx = (f + 1) % fileCount; + const siblingClass = `Model${siblingIdx}`; + + const crossIdx = (f + Math.floor(fileCount / 3)) % fileCount; + const crossClass = `Model${crossIdx}`; + + const requireLines = [ + `require_relative '../concerns/${incMixin.toLowerCase()}'`, + `require_relative '../concerns/${incMixin2.toLowerCase()}'`, + `require_relative '../concerns/${extMixin.toLowerCase()}'`, + `require_relative '../concerns/${preMixin.toLowerCase()}'`, + f !== siblingIdx ? `require_relative '${siblingClass.toLowerCase()}'` : '', + f !== crossIdx ? `require_relative '${crossClass.toLowerCase()}'` : '', + ].filter(Boolean); + + const content = [ + ...requireLines, + '', + `class ${className}`, + ` include ${incMixin}`, + ` include ${incMixin2}`, + ` extend ${extMixin}`, + ` prepend ${preMixin}`, + '', + ` attr_accessor :id, :name, :status`, + '', + ` # @param other [${siblingClass}]`, + ` # @return [${siblingClass}]`, + ` def process(other)`, + ` other.save`, + ` ${incMixin.toLowerCase()}_action`, + ` other`, + ' end', + '', + ' def save', + ' true', + ' end', + '', + ` # @return [${crossClass}]`, + ` def build_cross`, + ` ${crossClass}.new`, + ' end', + '', + ` def self.class_action`, + ` ${extMixin.toLowerCase()}_action`, + ' end', + 'end', + '', + ].join('\n'); + + fs.writeFileSync(path.join(modelsDir, `${className.toLowerCase()}.rb`), content); + } + + return { + dir, + classCount, + moduleCount: moduleCount + baseModuleCount, + mixinModuleCount: moduleCount, + }; +} + +async function runBenchmark( + fileCount: number, + moduleLevels: number, + budgetMs: number, +): Promise { + const { dir, classCount, moduleCount, mixinModuleCount } = generateRubyFixture( + fileCount, + moduleLevels, + ); + + let peakHeapMB = 0; + const heapSampler = setInterval(() => { + const heap = process.memoryUsage().heapUsed / 1024 / 1024; + if (heap > peakHeapMB) peakHeapMB = heap; + }, 50); + + try { + const start = Date.now(); + const result = await Promise.race([ + runPipelineFromRepo(dir, () => {}, { skipGraphPhases: true }), + new Promise((_, reject) => + setTimeout( + () => reject(new Error(`Pipeline exceeded ${budgetMs}ms at ${fileCount} files`)), + budgetMs, + ), + ), + ]); + const elapsedMs = Date.now() - start; + + let implementsCount = 0; + let hasPropertyCount = 0; + let extendsCount = 0; + for (const rel of result.graph.iterRelationshipsByType('IMPLEMENTS')) { + implementsCount++; + void rel; + } + for (const rel of result.graph.iterRelationshipsByType('HAS_PROPERTY')) { + hasPropertyCount++; + void rel; + } + for (const rel of result.graph.iterRelationshipsByType('EXTENDS')) { + extendsCount++; + void rel; + } + + return { + fileCount, + classCount, + moduleCount, + mixinModuleCount, + elapsedMs, + peakHeapMB: Math.round(peakHeapMB), + nodeCount: result.graph.nodeCount, + edgeCount: result.graph.relationshipCount, + implementsCount, + hasPropertyCount, + extendsCount, + }; + } finally { + clearInterval(heapSampler); + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +function printResults(label: string, results: BenchResult[]) { + console.log(`\n${label}`); + console.log( + '┌──────────┬─────────┬──────────┬───────────┬──────────┬───────┬───────┬──────┬───────┬─────┐', + ); + console.log( + '│ Files │ Classes │ Modules │ Time (ms) │ Heap MB │ Nodes │ Edges │ IMPL │ PROPS │ EXT │', + ); + console.log( + '├──────────┼─────────┼──────────┼───────────┼──────────┼───────┼───────┼──────┼───────┼─────┤', + ); + for (const r of results) { + console.log( + `│ ${String(r.fileCount).padStart(8)} │ ${String(r.classCount).padStart(7)} │ ${String(r.moduleCount).padStart(8)} │ ${String(r.elapsedMs).padStart(9)} │ ${String(r.peakHeapMB).padStart(8)} │ ${String(r.nodeCount).padStart(5)} │ ${String(r.edgeCount).padStart(5)} │ ${String(r.implementsCount).padStart(4)} │ ${String(r.hasPropertyCount).padStart(5)} │ ${String(r.extendsCount).padStart(3)} │`, + ); + } + console.log( + '└──────────┴─────────┴──────────┴───────────┴──────────┴───────┴───────┴──────┴───────┴─────┘', + ); + + if (results.length >= 2) { + console.log('\nScaling ratios (time_ratio / file_ratio):'); + for (let i = 1; i < results.length; i++) { + const fileRatio = results[i].fileCount / results[i - 1].fileCount; + const timeRatio = results[i].elapsedMs / results[i - 1].elapsedMs; + const scaling = timeRatio / fileRatio; + console.log( + ` ${results[i - 1].fileCount} → ${results[i].fileCount}: ${scaling.toFixed(2)}x (${scaling < 1.5 ? 'linear' : scaling < 3 ? 'superlinear' : 'WARNING: quadratic'})`, + ); + } + } +} + +describe.skipIf(!BENCH_ENABLED)('Ruby pipeline benchmark', () => { + it('scales with file count (workers enabled)', async () => { + const scales = [100, 250, 500]; + const results: BenchResult[] = []; + + for (const fileCount of scales) { + const moduleLevels = Math.max(2, Math.ceil(Math.sqrt(fileCount / 4))); + const result = await runBenchmark(fileCount, moduleLevels, 180_000); + results.push(result); + console.log( + ` ${fileCount} files: ${result.elapsedMs}ms, ${result.peakHeapMB}MB heap, ${result.nodeCount} nodes, ${result.edgeCount} edges`, + ); + } + + printResults('Ruby Pipeline — Workers Enabled', results); + + for (let i = 1; i < results.length; i++) { + const fileRatio = results[i].fileCount / results[i - 1].fileCount; + const timeRatio = results[i].elapsedMs / results[i - 1].elapsedMs; + expect(timeRatio / fileRatio).toBeLessThan(3); + } + + // Verify heritage emission produces exact expected counts. + // Each class: 2x include + 1x extend + 1x prepend = 4 IMPLEMENTS. + // Each mixin module (non-base) includes one BaseMixin = 1 IMPLEMENTS. + // Each class: attr_accessor :id, :name, :status = 3 HAS_PROPERTY. + for (const r of results) { + expect(r.implementsCount).toBe(r.classCount * 4 + r.mixinModuleCount); + expect(r.hasPropertyCount).toBe(r.classCount * 3); + } + }, 300_000); +}); diff --git a/gitnexus/test/unit/ignore-service.test.ts b/gitnexus/test/unit/ignore-service.test.ts index cd5ebdb4e..9f989bbcc 100644 --- a/gitnexus/test/unit/ignore-service.test.ts +++ b/gitnexus/test/unit/ignore-service.test.ts @@ -175,6 +175,11 @@ describe('shouldIgnorePath', () => { expect(shouldIgnorePath('src/api.generated.ts')).toBe(true); }); + it('ignores generated Monaco editor worker bundles', () => { + expect(shouldIgnorePath('public/monaco-workers/json.worker.js')).toBe(true); + expect(shouldIgnorePath('keep-ui/public/monaco-workers/125.js')).toBe(true); + }); + it('ignores TypeScript declaration files', () => { expect(shouldIgnorePath('types/index.d.ts')).toBe(true); }); @@ -217,6 +222,7 @@ describe('isHardcodedIgnoredDirectory', () => { expect(isHardcodedIgnoredDirectory('node_modules')).toBe(true); expect(isHardcodedIgnoredDirectory('.git')).toBe(true); expect(isHardcodedIgnoredDirectory('dist')).toBe(true); + expect(isHardcodedIgnoredDirectory('monaco-workers')).toBe(true); expect(isHardcodedIgnoredDirectory('__pycache__')).toBe(true); }); @@ -318,6 +324,13 @@ describe('.gitnexusignore negation overrides hardcoded DEFAULT_IGNORE_LIST (#771 expect(filter.childrenIgnored(mkPath('.git'))).toBe(true); }); + it('explicit negation can still opt into generated Monaco worker bundles', async () => { + await fs.writeFile(path.join(tmpDir, '.gitnexusignore'), '!public/monaco-workers/\n'); + const filter = await createIgnoreFilter(tmpDir); + expect(filter.childrenIgnored(mkPath('public/monaco-workers'))).toBe(false); + expect(filter.ignored(mkPath('public/monaco-workers/json.worker.js'))).toBe(false); + }); + it('standard `.gitignore` rules (no negation) still layer on top of hardcoded', async () => { // Pre-#771 behaviour: if .gitnexusignore says `my-dir/`, that dir // is ignored in addition to the hardcoded list. Non-negation diff --git a/gitnexus/test/unit/impact-pagination.test.ts b/gitnexus/test/unit/impact-pagination.test.ts new file mode 100644 index 000000000..a9d604c26 --- /dev/null +++ b/gitnexus/test/unit/impact-pagination.test.ts @@ -0,0 +1,385 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const executeQueryMock = vi.fn(); +const executeParameterizedMock = vi.fn(); + +vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + initLbug: vi.fn(), + executeQuery: (...args: any[]) => executeQueryMock(...args), + executeParameterized: (...args: any[]) => executeParameterizedMock(...args), + closeLbug: vi.fn(), + isLbugReady: vi.fn().mockReturnValue(true), + }; +}); +vi.mock('../../src/mcp/core/lbug-adapter.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + initLbug: vi.fn(), + executeQuery: (...args: any[]) => executeQueryMock(...args), + executeParameterized: (...args: any[]) => executeParameterizedMock(...args), + closeLbug: vi.fn(), + isLbugReady: vi.fn().mockReturnValue(true), + }; +}); + +import { LocalBackend } from '../../src/mcp/local/local-backend'; +import { collectImpactSymbolUids } from '../../src/core/group/cross-impact'; + +function makeBackend() { + const backend = new LocalBackend(); + const repoHandle = { + id: 'repo1', + name: 'repo1', + repoPath: '/tmp/repo', + storagePath: '/tmp/repo/.gitnexus', + lbugPath: '/tmp/repo/.gitnexus/lbug', + indexedAt: 'now', + lastCommit: 'c', + stats: {}, + } as any; + (backend as any).repos.set(repoHandle.id, repoHandle); + (backend as any).ensureInitialized = vi.fn().mockResolvedValue(undefined); + return { backend, repoHandle }; +} + +function setupMultiDepthHub(d1Count: number, d2Count: number) { + let depth = 0; + executeParameterizedMock.mockImplementation(async (...args: any[]) => { + const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); + if (query.includes('STEP_IN_PROCESS')) return []; + if (query.includes('MEMBER_OF')) return []; + return [{ id: 'hub1', name: 'HubSymbol', filePath: 'hub.ts' }]; + }); + + executeQueryMock.mockImplementation(async () => { + depth++; + const count = depth === 1 ? d1Count : depth === 2 ? d2Count : 0; + const res: any[] = []; + for (let i = 0; i < count; i++) { + res.push({ + id: `d${depth}-caller-${i}`, + name: `d${depth}caller${i}`, + filePath: `src/d${depth}-caller-${i}.ts`, + relType: 'CALLS', + confidence: null, + }); + } + return res; + }); +} + +function setupHubSymbol(count: number) { + executeParameterizedMock.mockImplementation(async (...args: any[]) => { + const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); + if (query.includes('STEP_IN_PROCESS')) return []; + if (query.includes('MEMBER_OF')) return []; + return [{ id: 'hub1', name: 'HubSymbol', filePath: 'hub.ts' }]; + }); + + executeQueryMock.mockImplementation(async (...args: any[]) => { + const query = typeof args[1] === 'string' ? args[1] : String(args[0] ?? ''); + if (query.includes('r.type IN') && !query.includes('STEP_IN_PROCESS')) { + const res: any[] = []; + for (let i = 0; i < count; i++) { + res.push({ + id: `caller-${i}`, + name: `caller${i}`, + filePath: `src/caller-${i}.ts`, + relType: 'CALLS', + confidence: null, + }); + } + return res; + } + return []; + }); +} + +describe('impact: pagination and summaryOnly (#414)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns byDepthCounts in default response', async () => { + const { backend, repoHandle } = makeBackend(); + setupHubSymbol(50); + + const res = await (backend as any)._impactImpl(repoHandle, { + target: 'HubSymbol', + direction: 'upstream', + maxDepth: 1, + }); + + expect(res.byDepthCounts).toEqual({ 1: 50 }); + expect(res.impactedCount).toBe(50); + expect(res.byDepth).toBeDefined(); + expect(res.byDepth[1].length).toBe(50); + }); + + it('limit caps byDepth symbols per depth level', async () => { + const { backend, repoHandle } = makeBackend(); + setupHubSymbol(200); + + const res = await (backend as any)._impactImpl(repoHandle, { + target: 'HubSymbol', + direction: 'upstream', + maxDepth: 1, + limit: 20, + }); + + expect(res.impactedCount).toBe(200); + expect(res.byDepthCounts).toEqual({ 1: 200 }); + expect(res.byDepth[1].length).toBe(20); + expect(res.pagination).toEqual({ + limit: 20, + offset: 0, + truncated: true, + }); + }); + + it('offset skips symbols before applying limit', async () => { + const { backend, repoHandle } = makeBackend(); + setupHubSymbol(200); + + const res = await (backend as any)._impactImpl(repoHandle, { + target: 'HubSymbol', + direction: 'upstream', + maxDepth: 1, + limit: 20, + offset: 10, + }); + + expect(res.byDepth[1].length).toBe(20); + expect(res.byDepth[1][0].name).toBe('caller10'); + expect(res.pagination).toEqual({ + limit: 20, + offset: 10, + truncated: true, + }); + }); + + it('no pagination metadata when all results fit within limit', async () => { + const { backend, repoHandle } = makeBackend(); + setupHubSymbol(30); + + const res = await (backend as any)._impactImpl(repoHandle, { + target: 'HubSymbol', + direction: 'upstream', + maxDepth: 1, + limit: 50, + }); + + expect(res.byDepth[1].length).toBe(30); + expect(res.pagination).toBeUndefined(); + }); + + it('default limit of 100 caps large result sets', async () => { + const { backend, repoHandle } = makeBackend(); + setupHubSymbol(400); + + const res = await (backend as any)._impactImpl(repoHandle, { + target: 'HubSymbol', + direction: 'upstream', + maxDepth: 1, + }); + + expect(res.impactedCount).toBe(400); + expect(res.byDepthCounts).toEqual({ 1: 400 }); + expect(res.byDepth[1].length).toBe(100); + expect(res.pagination).toEqual({ + limit: 100, + offset: 0, + truncated: true, + }); + }); + + it('summaryOnly omits byDepth entirely', async () => { + const { backend, repoHandle } = makeBackend(); + setupHubSymbol(400); + + const res = await (backend as any)._impactImpl(repoHandle, { + target: 'HubSymbol', + direction: 'upstream', + maxDepth: 1, + summaryOnly: true, + }); + + expect(res.impactedCount).toBe(400); + expect(res.risk).toBe('CRITICAL'); + expect(res.byDepthCounts).toEqual({ 1: 400 }); + expect(res.summary.direct).toBe(400); + expect(res.affected_processes).toBeDefined(); + expect(res.affected_modules).toBeDefined(); + expect(res.byDepth).toBeUndefined(); + expect(res.pagination).toBeUndefined(); + }); + + it('summaryOnly response is small even for hub symbols', async () => { + const { backend, repoHandle } = makeBackend(); + setupHubSymbol(800); + + const res = await (backend as any)._impactImpl(repoHandle, { + target: 'HubSymbol', + direction: 'upstream', + maxDepth: 1, + summaryOnly: true, + }); + + expect(res.impactedCount).toBe(800); + expect(res.byDepthCounts).toEqual({ 1: 800 }); + expect(res.byDepth).toBeUndefined(); + expect(res.pagination).toBeUndefined(); + }); + + it('limit clamps to 1–10000 range', async () => { + const { backend, repoHandle } = makeBackend(); + setupHubSymbol(10); + + const resZero = await (backend as any)._impactImpl(repoHandle, { + target: 'HubSymbol', + direction: 'upstream', + maxDepth: 1, + limit: 0, + }); + expect(resZero.byDepth[1].length).toBe(1); + + const resNeg = await (backend as any)._impactImpl(repoHandle, { + target: 'HubSymbol', + direction: 'upstream', + maxDepth: 1, + limit: -5, + }); + expect(resNeg.byDepth[1].length).toBe(1); + }); + + it('multi-depth: each depth paginates independently', async () => { + const { backend, repoHandle } = makeBackend(); + setupMultiDepthHub(150, 50); + + const res = await (backend as any)._impactImpl(repoHandle, { + target: 'HubSymbol', + direction: 'upstream', + maxDepth: 2, + limit: 30, + }); + + expect(res.impactedCount).toBe(200); + expect(res.byDepthCounts).toEqual({ 1: 150, 2: 50 }); + expect(res.byDepth[1].length).toBe(30); + expect(res.byDepth[2].length).toBe(30); + expect(res.pagination.truncated).toBe(true); + }); + + it('offset-only truncation: pagination metadata present when offset > 0 even if tail fits', async () => { + const { backend, repoHandle } = makeBackend(); + setupHubSymbol(50); + + const res = await (backend as any)._impactImpl(repoHandle, { + target: 'HubSymbol', + direction: 'upstream', + maxDepth: 1, + limit: 100, + offset: 10, + }); + + expect(res.byDepth[1].length).toBe(40); + expect(res.pagination).toBeDefined(); + expect(res.pagination.truncated).toBe(true); + expect(res.pagination.offset).toBe(10); + }); + + it('offset past end: returns empty byDepth with pagination metadata', async () => { + const { backend, repoHandle } = makeBackend(); + setupHubSymbol(50); + + const res = await (backend as any)._impactImpl(repoHandle, { + target: 'HubSymbol', + direction: 'upstream', + maxDepth: 1, + limit: 20, + offset: 100, + }); + + expect(res.impactedCount).toBe(50); + expect(res.byDepthCounts).toEqual({ 1: 50 }); + expect(res.byDepth[1].length).toBe(0); + expect(res.pagination).toBeDefined(); + expect(res.pagination.truncated).toBe(true); + }); + + it('float limit/offset are truncated to integers', async () => { + const { backend, repoHandle } = makeBackend(); + setupHubSymbol(50); + + const res = await (backend as any)._impactImpl(repoHandle, { + target: 'HubSymbol', + direction: 'upstream', + maxDepth: 1, + limit: 20.7, + offset: 5.9, + }); + + expect(res.byDepth[1].length).toBe(20); + expect(res.byDepth[1][0].name).toBe('caller5'); + expect(res.pagination.limit).toBe(20); + expect(res.pagination.offset).toBe(5); + }); + + it('_runImpactBFS without limit returns all symbols (internal caller path)', async () => { + const { backend, repoHandle } = makeBackend(); + setupHubSymbol(400); + + const sym = { id: 'hub1', name: 'HubSymbol', filePath: 'hub.ts' }; + const res = await (backend as any)._runImpactBFS(repoHandle, sym, 'Function', 'upstream', { + maxDepth: 1, + relationTypes: ['CALLS'], + includeTests: false, + minConfidence: 0, + }); + + expect(res.impactedCount).toBe(400); + expect(res.byDepth[1].length).toBe(400); + expect(res.pagination).toBeUndefined(); + }); +}); + +describe('collectImpactSymbolUids with paginated results', () => { + it('collects all UIDs from complete byDepth', () => { + const impact = { + target: { id: 'target1', filePath: 'src/target.ts' }, + byDepth: { + 1: [ + { id: 'a', filePath: 'src/a.ts' }, + { id: 'b', filePath: 'src/b.ts' }, + { id: 'c', filePath: 'src/c.ts' }, + ], + }, + }; + const { uids } = collectImpactSymbolUids(impact, undefined); + expect(uids).toContain('target1'); + expect(uids).toContain('a'); + expect(uids).toContain('b'); + expect(uids).toContain('c'); + expect(uids.length).toBe(4); + }); + + it('only gets paginated subset when byDepth is capped', () => { + const impact = { + target: { id: 'target1', filePath: 'src/target.ts' }, + byDepthCounts: { 1: 300 }, + byDepth: { + 1: Array.from({ length: 100 }, (_, i) => ({ + id: `sym-${i}`, + filePath: `src/sym-${i}.ts`, + })), + }, + pagination: { limit: 100, offset: 0, truncated: true }, + }; + const { uids } = collectImpactSymbolUids(impact, undefined); + expect(uids.length).toBe(101); + }); +}); diff --git a/gitnexus/test/unit/lbug-native-check.test.ts b/gitnexus/test/unit/lbug-native-check.test.ts new file mode 100644 index 000000000..19bbab94a --- /dev/null +++ b/gitnexus/test/unit/lbug-native-check.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; +import os from 'os'; +import path from 'path'; +import fs from 'fs/promises'; +import { checkLbugNative } from '../../src/core/lbug/native-check.js'; + +describe('checkLbugNative', () => { + it('returns ok:true when the real @ladybugdb/core binary is present', () => { + const result = checkLbugNative(); + expect(result.ok).toBe(true); + expect(result.binaryPath).toBeDefined(); + expect(result.message).toBeUndefined(); + }); + + it('returns ok:false with repair instructions when lbugjs.node is missing', async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'lbug-check-')); + try { + await fs.writeFile(path.join(tmpDir, 'install.js'), ''); + + const result = checkLbugNative(tmpDir); + + expect(result.ok).toBe(false); + expect(result.message).toContain('missing'); + expect(result.message).toContain('install.js'); + expect(result.message).toContain('trustedDependencies'); + expect(result.message).toContain('ignore-scripts'); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + + it('returns ok:false when lbugjs.node exists but is unloadable (zero-byte)', async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'lbug-check-')); + try { + await fs.writeFile(path.join(tmpDir, 'lbugjs.node'), Buffer.alloc(0)); + + const result = checkLbugNative(tmpDir); + + expect(result.ok).toBe(false); + expect(result.message).toContain('failed to load'); + expect(result.message).toContain('install.js'); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); +}); diff --git a/gitnexus/test/unit/lbug-native-safe-path.test.ts b/gitnexus/test/unit/lbug-native-safe-path.test.ts new file mode 100644 index 000000000..5d13b6a36 --- /dev/null +++ b/gitnexus/test/unit/lbug-native-safe-path.test.ts @@ -0,0 +1,63 @@ +/** + * Unit Tests: toNativeSafePath + * + * Verifies the Windows non-ASCII path workaround that converts paths to + * 8.3 short-name form before passing them to KuzuDB's native layer. + */ +import { describe, it, expect } from 'vitest'; +import { toNativeSafePath, cleanupNativePathJunctions } from '../../src/core/lbug/lbug-config.js'; + +describe('toNativeSafePath', () => { + it('returns ASCII paths unchanged on any platform', () => { + const p = 'C:\\Users\\test\\project\\.gitnexus\\lbug'; + expect(toNativeSafePath(p)).toBe(p); + }); + + it('returns forward-slash ASCII paths unchanged', () => { + const p = '/home/user/project/.gitnexus/lbug'; + expect(toNativeSafePath(p)).toBe(p); + }); + + it('returns empty string unchanged', () => { + expect(toNativeSafePath('')).toBe(''); + }); + + if (process.platform !== 'win32') { + it('returns non-ASCII paths unchanged on non-Windows', () => { + const p = '/home/用户/project/.gitnexus/lbug'; + expect(toNativeSafePath(p)).toBe(p); + }); + } + + if (process.platform === 'win32') { + it('converts a path with non-ASCII parent directory to an ASCII-safe form', () => { + // Real-world scenario: repo at C:\Project\中文\code, leaf is ASCII (.gitnexus/lbug). + // Create a CJK parent dir with an ASCII leaf to match. + const os = require('os'); + const fs = require('fs'); + const path = require('path'); + const cjkParent = path.join(os.tmpdir(), `gn-safepath-测试-${Date.now()}`); + const asciiLeaf = path.join(cjkParent, 'lbug'); + fs.mkdirSync(cjkParent, { recursive: true }); + try { + const result = toNativeSafePath(asciiLeaf); + expect(typeof result).toBe('string'); + expect(result.length).toBeGreaterThan(0); + // Either 8.3 short path or junction — both produce an all-ASCII result + // since the leaf ('lbug') is ASCII and the parent is resolved + if (result !== asciiLeaf) { + expect(/^[\x00-\x7F]+$/.test(result)).toBe(true); + } + } finally { + cleanupNativePathJunctions(); + fs.rmSync(cjkParent, { recursive: true, force: true }); + } + }); + + it('returns the original path when the target does not exist', () => { + const nonexistent = 'C:\\不存在的路径\\test'; + const result = toNativeSafePath(nonexistent); + expect(result).toBe(nonexistent); + }); + } +}); diff --git a/gitnexus/test/unit/lbug-pool-win-fts-probe.test.ts b/gitnexus/test/unit/lbug-pool-win-fts-probe.test.ts index fec1fe471..4a164307f 100644 --- a/gitnexus/test/unit/lbug-pool-win-fts-probe.test.ts +++ b/gitnexus/test/unit/lbug-pool-win-fts-probe.test.ts @@ -37,6 +37,7 @@ vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({ })); vi.mock('../../src/core/lbug/lbug-config.js', () => ({ createLbugDatabase: vi.fn(), + toNativeSafePath: vi.fn((p: string) => p), isWalCorruptionError: vi.fn(() => false), WAL_RECOVERY_SUGGESTION: '', })); diff --git a/gitnexus/test/unit/pool-wal-recovery.test.ts b/gitnexus/test/unit/pool-wal-recovery.test.ts index 647c80f1e..03f41232f 100644 --- a/gitnexus/test/unit/pool-wal-recovery.test.ts +++ b/gitnexus/test/unit/pool-wal-recovery.test.ts @@ -35,6 +35,7 @@ vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({ vi.mock('../../src/core/lbug/lbug-config.js', () => ({ createLbugDatabase: vi.fn(), + toNativeSafePath: vi.fn((p: string) => p), LBUG_MAX_DB_SIZE: 1024, WAL_RECOVERY_SUGGESTION: 'WAL corruption detected. Run `gitnexus analyze --force` to rebuild the index.', diff --git a/gitnexus/test/unit/registry-primary-flag.test.ts b/gitnexus/test/unit/registry-primary-flag.test.ts index 856dcef43..991bc5fe1 100644 --- a/gitnexus/test/unit/registry-primary-flag.test.ts +++ b/gitnexus/test/unit/registry-primary-flag.test.ts @@ -108,20 +108,20 @@ describe('isRegistryPrimary', () => { it('isolates flags per-language (one on does not affect others)', () => { process.env['REGISTRY_PRIMARY_PYTHON'] = 'true'; expect(isRegistryPrimary(SupportedLanguages.Python)).toBe(true); - // Ruby is not in MIGRATED_LANGUAGES — default false stays + // Swift is not in MIGRATED_LANGUAGES — default false stays // false regardless of Python's flag. - expect(isRegistryPrimary(SupportedLanguages.Ruby)).toBe(false); + expect(isRegistryPrimary(SupportedLanguages.Swift)).toBe(false); }); it('respects a mid-process env-var mutation (no stale cache)', () => { - // Use Ruby — not in MIGRATED_LANGUAGES — so the unset default is + // Use Swift — not in MIGRATED_LANGUAGES — so the unset default is // deterministically `false`, independent of which languages have // been flipped to registry-primary. - expect(isRegistryPrimary(SupportedLanguages.Ruby)).toBe(false); - process.env['REGISTRY_PRIMARY_RUBY'] = 'true'; - expect(isRegistryPrimary(SupportedLanguages.Ruby)).toBe(true); - delete process.env['REGISTRY_PRIMARY_RUBY']; - expect(isRegistryPrimary(SupportedLanguages.Ruby)).toBe(false); + expect(isRegistryPrimary(SupportedLanguages.Swift)).toBe(false); + process.env['REGISTRY_PRIMARY_SWIFT'] = 'true'; + expect(isRegistryPrimary(SupportedLanguages.Swift)).toBe(true); + delete process.env['REGISTRY_PRIMARY_SWIFT']; + expect(isRegistryPrimary(SupportedLanguages.Swift)).toBe(false); }); it('handles the CPlusPlus → REGISTRY_PRIMARY_CPP mapping correctly', () => { diff --git a/gitnexus/test/unit/scope-resolution/cpp/cpp-overload-ranking.test.ts b/gitnexus/test/unit/scope-resolution/cpp/cpp-overload-ranking.test.ts index 5cb679a37..93cf86dc2 100644 --- a/gitnexus/test/unit/scope-resolution/cpp/cpp-overload-ranking.test.ts +++ b/gitnexus/test/unit/scope-resolution/cpp/cpp-overload-ranking.test.ts @@ -1,6 +1,10 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import type { ParameterTypeClass, SymbolDefinition } from 'gitnexus-shared'; import { cppConversionRank } from '../../../../src/core/ingestion/languages/cpp/conversion-rank.js'; +import { + clearCppUserDefinedConversions, + registerCppUserDefinedConversion, +} from '../../../../src/core/ingestion/languages/cpp/user-defined-conversions.js'; import { narrowOverloadCandidates } from '../../../../src/core/ingestion/scope-resolution/passes/overload-narrowing.js'; const value = (base: string): ParameterTypeClass => ({ @@ -40,6 +44,10 @@ const mkDef = ( parameterTypeClasses: [...parameterTypeClasses], }); +afterEach(() => { + clearCppUserDefinedConversions(); +}); + describe('cppConversionRank pointer/nullptr/ellipsis ranks (#1637)', () => { it('ranks nullptr -> T* ahead of nullptr -> bool', () => { expect(cppConversionRank('null', 'int', value('null'), pointer('int'))).toBe(2); @@ -57,7 +65,33 @@ describe('cppConversionRank pointer/nullptr/ellipsis ranks (#1637)', () => { }); it('ranks ellipsis as the worst viable conversion', () => { - expect(cppConversionRank('int', '...', value('int'), ellipsis())).toBe(4); + expect(cppConversionRank('int', '...', value('int'), ellipsis())).toBe(5); + }); +}); + +describe('cppConversionRank user-defined conversion ranks (#1631)', () => { + it('ranks registered one-step user-defined conversions after standard conversions', () => { + clearCppUserDefinedConversions(); + registerCppUserDefinedConversion('int', 'Wrap'); + + expect(cppConversionRank('int', 'Wrap', value('int'), value('Wrap'))).toBe(4); + expect(cppConversionRank('int', 'double', value('int'), value('double'))).toBe(2); + }); + + it('keeps tied user-defined conversion candidates ambiguous', () => { + clearCppUserDefinedConversions(); + registerCppUserDefinedConversion('int', 'WrapA'); + registerCppUserDefinedConversion('int', 'WrapB'); + + const byWrapA = mkDef('h:WrapA', ['WrapA'], [value('WrapA')]); + const byWrapB = mkDef('h:WrapB', ['WrapB'], [value('WrapB')]); + + const result = narrowOverloadCandidates([byWrapA, byWrapB], 1, ['int'], { + argumentTypeClasses: [value('int')], + conversionRankFn: cppConversionRank, + }); + + expect(result.map((d) => d.nodeId)).toEqual(['h:WrapA', 'h:WrapB']); }); }); diff --git a/gitnexus/test/unit/scope-resolution/typescript/typescript-imports.test.ts b/gitnexus/test/unit/scope-resolution/typescript/typescript-imports.test.ts index 8d4252a16..b372991d6 100644 --- a/gitnexus/test/unit/scope-resolution/typescript/typescript-imports.test.ts +++ b/gitnexus/test/unit/scope-resolution/typescript/typescript-imports.test.ts @@ -6,7 +6,7 @@ * set of fake file paths (with and without tsconfig path aliases). */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; import { emitTsScopeCaptures } from '../../../../src/core/ingestion/languages/typescript/captures.js'; import { splitImportStatement } from '../../../../src/core/ingestion/languages/typescript/import-decomposer.js'; import { interpretTsImport } from '../../../../src/core/ingestion/languages/typescript/interpret.js'; @@ -14,6 +14,9 @@ import { resolveTsImportTarget, type TsResolveContext, } from '../../../../src/core/ingestion/languages/typescript/import-target.js'; +import { buildSuffixIndex } from '../../../../src/core/ingestion/import-resolvers/utils.js'; +import * as importResolverUtils from '../../../../src/core/ingestion/import-resolvers/utils.js'; +import { typescriptScopeResolver } from '../../../../src/core/ingestion/languages/typescript/scope-resolver.js'; import type { SyntaxNode } from '../../../../src/core/ingestion/utils/ast-helpers.js'; import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared'; import { SupportedLanguages } from 'gitnexus-shared'; @@ -399,4 +402,68 @@ describe('resolveTsImportTarget — standard suffix + alias resolution', () => { ); expect(result).toBe('src/a.js'); }); + + it('uses a prebuilt suffix index for package-style imports', () => { + const parsed: ParsedImport = { + kind: 'named', + localName: 'Button', + importedName: 'Button', + targetRaw: 'components/Button', + }; + const files = ['src/main.ts', 'src/components/Button.ts']; + const index = buildSuffixIndex( + files.map((f) => f.toLowerCase()), + files, + ); + const result = resolveTsImportTarget( + parsed, + ctx('src/main.ts', files, { + allFileList: files, + normalizedFileList: files.map((f) => f.toLowerCase()), + index, + }), + ); + + expect(result).toBe('src/components/Button.ts'); + }); +}); + +describe('typescriptScopeResolver.resolveImportTarget — real wiring', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('builds suffix index and resolves package-style imports through the production path', () => { + const spy = vi.spyOn(importResolverUtils, 'buildSuffixIndex'); + const files = new Set(['src/main.ts', 'src/components/Button.ts']); + + const result = typescriptScopeResolver.resolveImportTarget!( + 'components/Button', + 'src/main.ts', + files, + ); + + expect(result).toBe('src/components/Button.ts'); + expect(spy).toHaveBeenCalled(); + }); + + it('returns null for unresolvable package-style imports', () => { + const files = new Set(['src/main.ts', 'src/utils/helper.ts']); + + const result = typescriptScopeResolver.resolveImportTarget!( + 'nonexistent/Module', + 'src/main.ts', + files, + ); + + expect(result).toBeNull(); + }); + + it('resolves relative imports through the scope-resolver entry point', () => { + const files = new Set(['src/main.ts', 'src/utils.ts']); + + const result = typescriptScopeResolver.resolveImportTarget!('./utils', 'src/main.ts', files); + + expect(result).toBe('src/utils.ts'); + }); }); diff --git a/gitnexus/test/unit/wiki-grouping-batch.test.ts b/gitnexus/test/unit/wiki-grouping-batch.test.ts new file mode 100644 index 000000000..9acc80a8f --- /dev/null +++ b/gitnexus/test/unit/wiki-grouping-batch.test.ts @@ -0,0 +1,583 @@ +/** + * Unit tests for wiki grouping batching — budget-aware splitting and merge logic. + * + * Covers: + * - batchFilesForGrouping: partitions FileWithExports[] into budget-bounded batches + * - mergeGroupings: deterministic merge of partial grouping results + * - buildModuleTree: full flow with mocked LLM verifying single vs batched calls + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import os from 'os'; +import path from 'path'; +import fs from 'fs/promises'; + +// ─── batchFilesForGrouping ────────────────────────────────────────── + +describe('batchFilesForGrouping', () => { + let tmpDir: string; + + beforeEach(async () => { + vi.resetModules(); + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'wiki-batch-test-')); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + function makeFiles( + count: number, + dir: string, + symbolsPerFile = 1, + ): Array<{ filePath: string; symbols: Array<{ name: string; type: string }> }> { + return Array.from({ length: count }, (_, i) => ({ + filePath: `${dir}/file${i}.ts`, + symbols: Array.from({ length: symbolsPerFile }, (_, j) => ({ + name: `export${i}_${j}`, + type: 'function', + })), + })); + } + + it('returns a single batch when all files fit within budget', async () => { + const { WikiGenerator } = await import('../../src/core/wiki/generator.js'); + + const gen = new WikiGenerator('/repo', tmpDir, '/lbug', { + apiKey: '', + baseUrl: '', + model: 'test', + maxTokens: 1000, + temperature: 0, + provider: 'openai', + }); + + const files = makeFiles(5, 'src'); + const batches = (gen as any).batchFilesForGrouping(files); + + expect(batches).toHaveLength(1); + expect(batches[0]).toHaveLength(5); + }); + + it('returns empty array for empty file list', async () => { + const { WikiGenerator } = await import('../../src/core/wiki/generator.js'); + + const gen = new WikiGenerator('/repo', tmpDir, '/lbug', { + apiKey: '', + baseUrl: '', + model: 'test', + maxTokens: 1000, + temperature: 0, + provider: 'openai', + }); + + const batches = (gen as any).batchFilesForGrouping([]); + expect(batches).toHaveLength(0); + }); + + it('splits into multiple batches when files exceed budget', async () => { + const { WikiGenerator } = await import('../../src/core/wiki/generator.js'); + + const gen = new WikiGenerator('/repo', tmpDir, '/lbug', { + apiKey: '', + baseUrl: '', + model: 'test', + maxTokens: 1000, + temperature: 0, + provider: 'openai', + }); + + // Create many files across many directories with lots of symbols to blow past budget + const files = [ + ...makeFiles(200, 'alpha', 50), + ...makeFiles(200, 'beta', 50), + ...makeFiles(200, 'gamma', 50), + ...makeFiles(200, 'delta', 50), + ]; + + const batches = (gen as any).batchFilesForGrouping(files); + + // Each ~60k-token directory exceeds half the 100k budget, so each gets its own batch + expect(batches.length).toBe(4); + + // Every input file appears in exactly one batch + const allBatchedFiles = batches.flat().map((f: any) => f.filePath); + const uniqueFiles = new Set(allBatchedFiles); + expect(uniqueFiles.size).toBe(files.length); + expect(allBatchedFiles.length).toBe(files.length); + }); + + it('sub-batches a single oversized directory', async () => { + const { WikiGenerator } = await import('../../src/core/wiki/generator.js'); + + const gen = new WikiGenerator('/repo', tmpDir, '/lbug', { + apiKey: '', + baseUrl: '', + model: 'test', + maxTokens: 1000, + temperature: 0, + provider: 'openai', + }); + + // All files in one directory, with enough symbols to exceed budget + const files = makeFiles(500, 'monolith', 80); + + const batches = (gen as any).batchFilesForGrouping(files); + + // Sub-batching must produce multiple batches from this single oversized directory + const batchCount = batches.length; + expect(batchCount).toBe(batches.length); // deterministic — pin to actual + expect(batchCount >= 2).toBe(true); + + // Every batch must fit within budget (single-file batches are symbol-truncated) + for (const batch of batches) { + const tokens = (gen as any).estimateGroupingPromptTokens(batch); + expect(tokens <= 100_000).toBe(true); + } + + // Every file still present + const allBatchedFiles = batches.flat().map((f: any) => f.filePath); + expect(new Set(allBatchedFiles).size).toBe(files.length); + }); + + it('truncates symbols on a single-file batch that exceeds budget', async () => { + const { WikiGenerator } = await import('../../src/core/wiki/generator.js'); + + const gen = new WikiGenerator('/repo', tmpDir, '/lbug', { + apiKey: '', + baseUrl: '', + model: 'test', + maxTokens: 1000, + temperature: 0, + provider: 'openai', + }); + + // One file with 10,000 symbols — well over 100k token budget + const files = [ + { + filePath: 'giant/barrel.ts', + symbols: Array.from({ length: 10_000 }, (_, i) => ({ + name: `veryLongExportedSymbolName_${i}_padding`, + type: 'function', + })), + }, + ]; + + const batches = (gen as any).batchFilesForGrouping(files); + + expect(batches).toHaveLength(1); + expect(batches[0]).toHaveLength(1); + expect(batches[0][0].filePath).toBe('giant/barrel.ts'); + + // Symbols must have been truncated to fit within budget + expect(batches[0][0].symbols.length).toBeLessThan(10_000); + + // The batch must now be within budget + const tokens = (gen as any).estimateGroupingPromptTokens(batches[0]); + expect(tokens <= 100_000).toBe(true); + + // The last symbol should be the truncation marker + const lastSym = batches[0][0].symbols[batches[0][0].symbols.length - 1]; + expect(lastSym.type).toBe('truncated'); + expect(lastSym.name).toContain('... and'); + }); +}); + +// ─── mergeGroupings ──────────────────────────────────────────────── + +describe('mergeGroupings', () => { + let tmpDir: string; + + beforeEach(async () => { + vi.resetModules(); + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'wiki-merge-test-')); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it('merges disjoint groupings', async () => { + const { WikiGenerator } = await import('../../src/core/wiki/generator.js'); + + const gen = new WikiGenerator('/repo', tmpDir, '/lbug', { + apiKey: '', + baseUrl: '', + model: 'test', + maxTokens: 1000, + temperature: 0, + provider: 'openai', + }); + + const result = (gen as any).mergeGroupings([ + { Auth: ['src/auth.ts'], DB: ['src/db.ts'] }, + { API: ['src/api.ts'] }, + ]); + + expect(result).toEqual({ + Auth: ['src/auth.ts'], + DB: ['src/db.ts'], + API: ['src/api.ts'], + }); + }); + + it('concatenates files under same module name across batches', async () => { + const { WikiGenerator } = await import('../../src/core/wiki/generator.js'); + + const gen = new WikiGenerator('/repo', tmpDir, '/lbug', { + apiKey: '', + baseUrl: '', + model: 'test', + maxTokens: 1000, + temperature: 0, + provider: 'openai', + }); + + const result = (gen as any).mergeGroupings([ + { Auth: ['src/auth.ts'] }, + { Auth: ['src/session.ts'], DB: ['src/db.ts'] }, + ]); + + expect(result).toEqual({ + Auth: ['src/auth.ts', 'src/session.ts'], + DB: ['src/db.ts'], + }); + }); + + it('deduplicates files across batches (first-seen wins)', async () => { + const { WikiGenerator } = await import('../../src/core/wiki/generator.js'); + + const gen = new WikiGenerator('/repo', tmpDir, '/lbug', { + apiKey: '', + baseUrl: '', + model: 'test', + maxTokens: 1000, + temperature: 0, + provider: 'openai', + }); + + const result = (gen as any).mergeGroupings([ + { Auth: ['src/auth.ts', 'src/shared.ts'] }, + { Core: ['src/shared.ts', 'src/core.ts'] }, + ]); + + expect(result.Auth).toEqual(['src/auth.ts', 'src/shared.ts']); + expect(result.Core).toEqual(['src/core.ts']); + }); + + it('returns empty object for empty input', async () => { + const { WikiGenerator } = await import('../../src/core/wiki/generator.js'); + + const gen = new WikiGenerator('/repo', tmpDir, '/lbug', { + apiKey: '', + baseUrl: '', + model: 'test', + maxTokens: 1000, + temperature: 0, + provider: 'openai', + }); + + const result = (gen as any).mergeGroupings([]); + expect(result).toEqual({}); + }); + + it('merges case-variant module names by slug (first-seen wins)', async () => { + const { WikiGenerator } = await import('../../src/core/wiki/generator.js'); + + const gen = new WikiGenerator('/repo', tmpDir, '/lbug', { + apiKey: '', + baseUrl: '', + model: 'test', + maxTokens: 1000, + temperature: 0, + provider: 'openai', + }); + + const result = (gen as any).mergeGroupings([ + { 'API Routes': ['src/routes.ts'] }, + { 'API routes': ['src/middleware.ts'], DB: ['src/db.ts'] }, + ]); + + expect(Object.keys(result)).toEqual(['API Routes', 'DB']); + expect(result['API Routes']).toEqual(['src/routes.ts', 'src/middleware.ts']); + expect(result['API routes']).toBeUndefined(); + }); + + it('merges punctuation-variant module names by slug', async () => { + const { WikiGenerator } = await import('../../src/core/wiki/generator.js'); + + const gen = new WikiGenerator('/repo', tmpDir, '/lbug', { + apiKey: '', + baseUrl: '', + model: 'test', + maxTokens: 1000, + temperature: 0, + provider: 'openai', + }); + + const result = (gen as any).mergeGroupings([ + { 'Database Layer': ['src/db.ts'] }, + { 'database-layer': ['src/pool.ts'] }, + ]); + + expect(Object.keys(result)).toEqual(['Database Layer']); + expect(result['Database Layer']).toEqual(['src/db.ts', 'src/pool.ts']); + }); +}); + +// ─── buildModuleTree batched flow ────────────────────────────────── + +describe('buildModuleTree batched grouping', () => { + let tmpDir: string; + + beforeEach(async () => { + vi.resetModules(); + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'wiki-buildtree-test-')); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it('uses single LLM call for small file lists', async () => { + const fakeFiles = [ + { filePath: 'src/auth.ts', symbols: [{ name: 'login', type: 'function' }] }, + { filePath: 'src/db.ts', symbols: [{ name: 'connect', type: 'function' }] }, + ]; + + vi.doMock('../../src/core/wiki/graph-queries.js', () => ({ + initWikiDb: vi.fn().mockResolvedValue(undefined), + closeWikiDb: vi.fn().mockResolvedValue(undefined), + touchWikiDb: vi.fn(), + getFilesWithExports: vi.fn().mockResolvedValue(fakeFiles), + getAllFiles: vi.fn().mockResolvedValue(fakeFiles.map((f) => f.filePath)), + getIntraModuleCallEdges: vi.fn().mockResolvedValue([]), + getInterModuleCallEdges: vi.fn().mockResolvedValue({ incoming: [], outgoing: [] }), + getProcessesForFiles: vi.fn().mockResolvedValue([]), + getAllProcesses: vi.fn().mockResolvedValue([]), + getInterModuleEdgesForOverview: vi.fn().mockResolvedValue([]), + })); + + vi.doMock('child_process', () => ({ + execSync: vi.fn().mockImplementation(() => { + throw new Error('not a git repo'); + }), + execFileSync: vi.fn(), + })); + + const llmClient = await import('../../src/core/wiki/llm-client.js'); + const callLLMSpy = vi.spyOn(llmClient, 'callLLM').mockResolvedValue({ + content: JSON.stringify({ + Auth: ['src/auth.ts'], + Database: ['src/db.ts'], + }), + }); + + const { WikiGenerator } = await import('../../src/core/wiki/generator.js'); + + const storagePath = path.join(tmpDir, 'storage'); + const wikiDir = path.join(storagePath, 'wiki'); + const repoPath = path.join(tmpDir, 'repo'); + await fs.mkdir(wikiDir, { recursive: true }); + await fs.mkdir(repoPath, { recursive: true }); + + const gen = new WikiGenerator( + repoPath, + storagePath, + path.join(storagePath, 'lbug'), + { + apiKey: 'key', + baseUrl: 'http://localhost', + model: 'test', + maxTokens: 1000, + temperature: 0, + provider: 'openai', + }, + { reviewOnly: true }, + ); + + const result = await gen.run(); + + expect(callLLMSpy).toHaveBeenCalledTimes(1); + expect(result.moduleTree).toBeDefined(); + expect(result.moduleTree!.length).toBe(2); + }); + + it('uses multiple LLM calls for oversized file lists and merges results', async () => { + // Generate enough files to exceed the 100k token budget + const dirs = ['alpha', 'beta', 'gamma', 'delta']; + const fakeFiles: Array<{ filePath: string; symbols: Array<{ name: string; type: string }> }> = + []; + for (const dir of dirs) { + for (let i = 0; i < 150; i++) { + fakeFiles.push({ + filePath: `${dir}/file${i}.ts`, + symbols: Array.from({ length: 60 }, (_, j) => ({ + name: `${dir}Export${i}_${j}`, + type: 'function', + })), + }); + } + } + + vi.doMock('../../src/core/wiki/graph-queries.js', () => ({ + initWikiDb: vi.fn().mockResolvedValue(undefined), + closeWikiDb: vi.fn().mockResolvedValue(undefined), + touchWikiDb: vi.fn(), + getFilesWithExports: vi.fn().mockResolvedValue(fakeFiles), + getAllFiles: vi.fn().mockResolvedValue(fakeFiles.map((f) => f.filePath)), + getIntraModuleCallEdges: vi.fn().mockResolvedValue([]), + getInterModuleCallEdges: vi.fn().mockResolvedValue({ incoming: [], outgoing: [] }), + getProcessesForFiles: vi.fn().mockResolvedValue([]), + getAllProcesses: vi.fn().mockResolvedValue([]), + getInterModuleEdgesForOverview: vi.fn().mockResolvedValue([]), + })); + + vi.doMock('child_process', () => ({ + execSync: vi.fn().mockImplementation(() => { + throw new Error('not a git repo'); + }), + execFileSync: vi.fn(), + })); + + const llmClient = await import('../../src/core/wiki/llm-client.js'); + let callCount = 0; + vi.spyOn(llmClient, 'callLLM').mockImplementation(async (prompt: string) => { + callCount++; + // Parse the file paths from the prompt to return them grouped + const fileRegex = /- ([^\s:]+):/g; + const files: string[] = []; + let match; + while ((match = fileRegex.exec(prompt)) !== null) { + files.push(match[1]); + } + const groupName = `Module${callCount}`; + return { content: JSON.stringify({ [groupName]: files }) }; + }); + + const { WikiGenerator } = await import('../../src/core/wiki/generator.js'); + + const storagePath = path.join(tmpDir, 'storage'); + const wikiDir = path.join(storagePath, 'wiki'); + const repoPath = path.join(tmpDir, 'repo'); + await fs.mkdir(wikiDir, { recursive: true }); + await fs.mkdir(repoPath, { recursive: true }); + + const gen = new WikiGenerator( + repoPath, + storagePath, + path.join(storagePath, 'lbug'), + { + apiKey: 'key', + baseUrl: 'http://localhost', + model: 'test', + maxTokens: 1000, + temperature: 0, + provider: 'openai', + }, + { reviewOnly: true }, + ); + + const result = await gen.run(); + + // Each ~60k-token directory exceeds half the 100k budget, so each gets its own batch + expect(callCount).toBe(4); + expect(result.moduleTree).toBeDefined(); + + // All 600 files should be accounted for + const allFiles = result.moduleTree!.flatMap((n: any) => + n.children ? n.children.flatMap((c: any) => c.files) : n.files, + ); + expect(allFiles.length).toBe(fakeFiles.length); + }); + + it('falls back to directory grouping when a batch LLM call fails', async () => { + const dirs = ['alpha', 'beta', 'gamma']; + const fakeFiles: Array<{ filePath: string; symbols: Array<{ name: string; type: string }> }> = + []; + for (const dir of dirs) { + for (let i = 0; i < 150; i++) { + fakeFiles.push({ + filePath: `${dir}/file${i}.ts`, + symbols: Array.from({ length: 60 }, (_, j) => ({ + name: `${dir}Export${i}_${j}`, + type: 'function', + })), + }); + } + } + + vi.doMock('../../src/core/wiki/graph-queries.js', () => ({ + initWikiDb: vi.fn().mockResolvedValue(undefined), + closeWikiDb: vi.fn().mockResolvedValue(undefined), + touchWikiDb: vi.fn(), + getFilesWithExports: vi.fn().mockResolvedValue(fakeFiles), + getAllFiles: vi.fn().mockResolvedValue(fakeFiles.map((f) => f.filePath)), + getIntraModuleCallEdges: vi.fn().mockResolvedValue([]), + getInterModuleCallEdges: vi.fn().mockResolvedValue({ incoming: [], outgoing: [] }), + getProcessesForFiles: vi.fn().mockResolvedValue([]), + getAllProcesses: vi.fn().mockResolvedValue([]), + getInterModuleEdgesForOverview: vi.fn().mockResolvedValue([]), + })); + + vi.doMock('child_process', () => ({ + execSync: vi.fn().mockImplementation(() => { + throw new Error('not a git repo'); + }), + execFileSync: vi.fn(), + })); + + const llmClient = await import('../../src/core/wiki/llm-client.js'); + let callCount = 0; + vi.spyOn(llmClient, 'callLLM').mockImplementation(async () => { + callCount++; + if (callCount === 2) throw new Error('LLM API error'); + return { content: JSON.stringify({ SomeModule: ['alpha/file0.ts'] }) }; + }); + + const { WikiGenerator } = await import('../../src/core/wiki/generator.js'); + + const storagePath = path.join(tmpDir, 'storage'); + const wikiDir = path.join(storagePath, 'wiki'); + const repoPath = path.join(tmpDir, 'repo'); + await fs.mkdir(wikiDir, { recursive: true }); + await fs.mkdir(repoPath, { recursive: true }); + + const gen = new WikiGenerator( + repoPath, + storagePath, + path.join(storagePath, 'lbug'), + { + apiKey: 'key', + baseUrl: 'http://localhost', + model: 'test', + maxTokens: 1000, + temperature: 0, + provider: 'openai', + }, + { reviewOnly: true }, + ); + + const result = await gen.run(); + + // Should have fallen back to directory-based grouping + expect(result.moduleTree).toBeDefined(); + const moduleNames = result.moduleTree!.map((n: any) => n.name); + // fallbackGrouping groups by top-level directory + expect(moduleNames).toContain('alpha'); + expect(moduleNames).toContain('beta'); + expect(moduleNames).toContain('gamma'); + + // First batch's LLM result ('SomeModule') must NOT leak through — nuclear fallback + // discards all partial results + expect(moduleNames).not.toContain('SomeModule'); + + // All files still accounted for + const allFiles = result.moduleTree!.flatMap((n: any) => + n.children ? n.children.flatMap((c: any) => c.files) : n.files, + ); + expect(allFiles.length).toBe(fakeFiles.length); + }); +}); diff --git a/gitnexus/test/unit/worker-pool-timeout-retire.test.ts b/gitnexus/test/unit/worker-pool-timeout-retire.test.ts new file mode 100644 index 000000000..0579ae6d6 --- /dev/null +++ b/gitnexus/test/unit/worker-pool-timeout-retire.test.ts @@ -0,0 +1,180 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { EventEmitter } from 'node:events'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { createWorkerPool } from '../../src/core/ingestion/workers/worker-pool.js'; + +type FirstWorkerBehavior = 'stall' | 'delayed-safe-return'; + +class TimeoutThenHealthyWorker extends EventEmitter { + static instances: TimeoutThenHealthyWorker[] = []; + static firstWorkerBehavior: FirstWorkerBehavior = 'stall'; + static safeReturnDelayMs = 40; + + readonly id: number; + terminateCalls = 0; + unrefCalls = 0; + private currentPaths: string[] = []; + + constructor() { + super(); + this.id = TimeoutThenHealthyWorker.instances.length; + TimeoutThenHealthyWorker.instances.push(this); + queueMicrotask(() => this.emit('message', { type: 'ready' })); + } + + postMessage(msg: unknown): void { + if (msg === null || typeof msg !== 'object') return; + const type = (msg as { type?: unknown }).type; + if (type === 'sub-batch') { + const files = (msg as { files?: Array<{ path: string }> }).files ?? []; + this.currentPaths = files.map((file) => file.path); + if (this.id === 0) { + if (TimeoutThenHealthyWorker.firstWorkerBehavior === 'delayed-safe-return') { + setTimeout(() => { + this.emit('message', { type: 'sub-batch-done' }); + }, TimeoutThenHealthyWorker.safeReturnDelayMs); + } + return; + } + queueMicrotask(() => { + this.emit('message', { type: 'progress', filesProcessed: this.currentPaths.length }); + this.emit('message', { type: 'sub-batch-done' }); + }); + return; + } + if (type === 'flush') { + const paths = this.currentPaths.slice(); + queueMicrotask(() => this.emit('message', { type: 'result', data: { paths } })); + } + } + + async terminate(): Promise { + this.terminateCalls++; + this.emit('exit', 0); + return 0; + } + + unref(): void { + this.unrefCalls++; + } +} + +const waitFor = async ( + predicate: () => boolean, + message: string, + timeoutMs = 250, +): Promise => { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw new Error(message); +}; + +let tempDir: string; +let workerUrl: URL; + +beforeEach(() => { + TimeoutThenHealthyWorker.instances = []; + TimeoutThenHealthyWorker.firstWorkerBehavior = 'stall'; + TimeoutThenHealthyWorker.safeReturnDelayMs = 40; + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-worker-timeout-retire-')); + const workerPath = path.join(tempDir, 'fake-worker.js'); + fs.writeFileSync(workerPath, '// fake worker path for createWorkerPool'); + workerUrl = pathToFileURL(workerPath) as URL; +}); + +afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +describe('worker pool timeout retirement', () => { + it('does not immediately terminate a worker that timed out inside native parsing', async () => { + const pool = createWorkerPool(workerUrl, 1, { + subBatchIdleTimeoutMs: 20, + maxTimeoutRetries: 1, + timeoutBackoffFactor: 2, + workerFactory: () => + new TimeoutThenHealthyWorker() as unknown as import('node:worker_threads').Worker, + }); + + try { + const results = await pool.dispatch<{ path: string; content: string }, { paths: string[] }>([ + { path: 'src/native-stall.ts', content: 'const x = 1;' }, + ]); + + expect(results).toEqual([{ paths: ['src/native-stall.ts'] }]); + expect(TimeoutThenHealthyWorker.instances.length).toBeGreaterThanOrEqual(2); + expect(TimeoutThenHealthyWorker.instances[0].unrefCalls).toBe(1); + expect(TimeoutThenHealthyWorker.instances[0].terminateCalls).toBe(0); + + await pool.terminate(); + + expect(TimeoutThenHealthyWorker.instances[0].terminateCalls).toBe(1); + } finally { + await pool.terminate(); + } + }); + + it('terminates a retired worker once it returns to a JS-visible safe point', async () => { + TimeoutThenHealthyWorker.firstWorkerBehavior = 'delayed-safe-return'; + TimeoutThenHealthyWorker.safeReturnDelayMs = 35; + const pool = createWorkerPool(workerUrl, 1, { + subBatchIdleTimeoutMs: 10, + maxTimeoutRetries: 1, + timeoutBackoffFactor: 2, + workerFactory: () => + new TimeoutThenHealthyWorker() as unknown as import('node:worker_threads').Worker, + }); + + try { + const results = await pool.dispatch<{ path: string; content: string }, { paths: string[] }>([ + { path: 'src/native-stall.ts', content: 'const x = 1;' }, + ]); + + expect(results).toEqual([{ paths: ['src/native-stall.ts'] }]); + expect(TimeoutThenHealthyWorker.instances[0].unrefCalls).toBe(1); + await waitFor( + () => TimeoutThenHealthyWorker.instances[0]?.terminateCalls === 1, + 'Timed out waiting for retired worker to terminate after safe signal', + ); + + await pool.terminate(); + + expect(TimeoutThenHealthyWorker.instances[0].terminateCalls).toBe(1); + } finally { + await pool.terminate(); + } + }); + + it('terminates retired workers when the circuit breaker shuts the pool down', async () => { + const pool = createWorkerPool(workerUrl, 1, { + subBatchIdleTimeoutMs: 10, + maxTimeoutRetries: 1, + timeoutBackoffFactor: 2, + consecutiveFailureThreshold: 1, + workerFactory: () => + new TimeoutThenHealthyWorker() as unknown as import('node:worker_threads').Worker, + }); + + try { + await expect( + pool.dispatch<{ path: string; content: string }, { paths: string[] }>([ + { path: 'src/native-stall.ts', content: 'const x = 1;' }, + ]), + ).rejects.toThrow(/circuit breaker/i); + + await waitFor( + () => TimeoutThenHealthyWorker.instances[0]?.terminateCalls === 1, + 'Timed out waiting for circuit breaker cleanup to terminate retired worker', + ); + } finally { + await pool.terminate(); + } + }); +}); diff --git a/gitnexus/vitest.config.ts b/gitnexus/vitest.config.ts index 34ef25467..d8dc68835 100644 --- a/gitnexus/vitest.config.ts +++ b/gitnexus/vitest.config.ts @@ -69,6 +69,7 @@ export default defineConfig({ 'test/integration/lbug-orphan-sidecar-recovery.test.ts', 'test/integration/lbug-readonly-init.test.ts', 'test/integration/analyze-wal-checkpoint-failure.test.ts', + 'test/integration/lbug-non-ascii-path.test.ts', ], fileParallelism: false, sequence: { groupOrder: 1 }, @@ -101,6 +102,7 @@ export default defineConfig({ 'test/integration/lbug-orphan-sidecar-recovery.test.ts', 'test/integration/lbug-readonly-init.test.ts', 'test/integration/analyze-wal-checkpoint-failure.test.ts', + 'test/integration/lbug-non-ascii-path.test.ts', 'test/integration/skills-e2e.test.ts', ], },