Merge branch 'main' into fix/fts-non-fatal-in-analyze

This commit is contained in:
Gergő Magyar 2026-05-27 06:50:37 +01:00 • committed by GitHub
commit 840b71b90e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
117 changed files with 9954 additions and 618 deletions

View file

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

View file

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

View file

@ -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 <span class="truncate font-mono text-xs">.
// 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 });
});
});

View file

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

View file

@ -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<GraphCanvasHandle>((_, ref) => {
clearAICitationHighlights,
clearBlastRadius,
animatedNodes,
graphViewMode,
setGraphViewMode,
} = useAppState();
const [hoveredNodeName, setHoveredNodeName] = useState<string | null>(null);
@ -149,8 +156,22 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, 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<GraphCanvasHandle>((_, ref) => {
useEffect(() => {
if (!graph) return;
// Build communityMemberships map from MEMBER_OF relationships
// MEMBER_OF edges: nodeId -> communityId (stored as targetId)
const communityMemberships = new Map<string, number>();
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<SigmaNodeAttributes, SigmaEdgeAttributes>;
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<string, number>();
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<GraphCanvasHandle>((_, 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<GraphCanvasHandle>((_, ref) => {
/>
</div>
{/* View Mode Tabs */}
<div
role="tablist"
aria-label={t('canvas.viewModes.label')}
className="absolute top-4 left-1/2 z-20 flex -translate-x-1/2 gap-1 rounded-lg border border-border-subtle bg-elevated/90 p-1 backdrop-blur-sm"
>
<button
role="tab"
aria-selected={graphViewMode === 'force'}
onClick={() => handleViewModeChange('force')}
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-all ${
graphViewMode === 'force'
? 'bg-accent text-white'
: 'text-text-secondary hover:bg-hover hover:text-text-primary'
}`}
>
<Network className="h-3.5 w-3.5" />
{t('canvas.viewModes.force')}
</button>
<button
role="tab"
aria-selected={graphViewMode === 'tree'}
onClick={() => handleViewModeChange('tree')}
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-all ${
graphViewMode === 'tree'
? 'bg-accent text-white'
: 'text-text-secondary hover:bg-hover hover:text-text-primary'
}`}
>
<GitBranch className="h-3.5 w-3.5" />
{t('canvas.viewModes.tree')}
</button>
<button
role="tab"
aria-selected={graphViewMode === 'circles'}
onClick={() => handleViewModeChange('circles')}
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-all ${
graphViewMode === 'circles'
? 'bg-accent text-white'
: 'text-text-secondary hover:bg-hover hover:text-text-primary'
}`}
>
<Target className="h-3.5 w-3.5" />
{t('canvas.viewModes.circles')}
</button>
</div>
{/* Sigma container */}
<div
ref={containerRef}

View file

@ -13,6 +13,7 @@ import {
Check,
Loader2,
AlertCircle,
Square,
} from '@/lib/lucide-icons';
import type { ToolCallInfo } from '../core/llm/types';
import type { TFunction } from 'i18next';
@ -77,6 +78,13 @@ const getStatusDisplay = (status: ToolCallInfo['status']) => {
bgColor: 'bg-rose-500/10',
borderColor: 'border-rose-500/30',
};
case 'stopped':
return {
icon: <Square className="h-3.5 w-3.5 fill-current" />,
color: 'text-amber-300',
bgColor: 'bg-amber-500/10',
borderColor: 'border-amber-500/30',
};
default:
return {
icon: <Sparkles className="h-3.5 w-3.5" />,

View file

@ -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) {

View file

@ -254,7 +254,7 @@ export interface ToolCallInfo {
name: string;
args: Record<string, unknown>;
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

View file

@ -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 <GraphStateProvider>{children}</GraphStateProvider>;
}
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');
});
});

View file

@ -16,6 +16,8 @@ interface GraphStateContextValue {
setDepthFilter: (depth: number | null) => void;
highlightedNodeIds: Set<string>;
setHighlightedNodeIds: (ids: Set<string>) => void;
graphViewMode: 'force' | 'tree' | 'circles';
setGraphViewMode: (mode: 'force' | 'tree' | 'circles') => void;
}
const GraphStateContext = createContext<GraphStateContextValue | null>(null);
@ -27,6 +29,7 @@ export const GraphStateProvider = ({ children }: { children: ReactNode }) => {
const [visibleEdgeTypes, setVisibleEdgeTypes] = useState<EdgeType[]>(DEFAULT_VISIBLE_EDGES);
const [depthFilter, setDepthFilter] = useState<number | null>(null);
const [highlightedNodeIds, setHighlightedNodeIds] = useState<Set<string>>(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 <GraphStateContext.Provider value={value}>{children}</GraphStateContext.Provider>;

View file

@ -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<string>;
setHighlightedNodeIds: (ids: Set<string>) => 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<any>(null);
const chatAbortRef = useRef<AbortController | null>(null);
const chatStateRef = useRef<'idle' | 'streaming' | 'aborting'>('idle');
const initializeAgent = useCallback(
async (overrideProjectName?: string): Promise<void> => {
@ -640,7 +648,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
const sendChatMessage = useCallback(
async (message: string): Promise<void> => {
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,

File diff suppressed because it is too large Load diff

View file

@ -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<string, number> = {
// 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<string, string[]>();
const parentsByChild = new Map<string, string[]>();
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<string, string[]>,
): Map<string, CirclesNodePosition> {
const positions = new Map<string, CirclesNodePosition>();
// Group nodes by ring
const nodesByRing: GraphNode[][] = Array.from({ length: RING_COUNT }, () => []);
const nodeRingMap = new Map<string, number>();
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<string, string>();
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<string, GraphNode[]>();
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<string, CirclesNodePosition> {
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;
}

View file

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

View file

@ -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<string, { isHierarchyEdge?: boolean; color: string }>();
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<string, { isHierarchyEdge?: boolean; color: string }>();
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);
});
});
});

View file

@ -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<string, string[]>();
// child -> parent
const childToParent = new Map<string, string>();
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<SigmaNodeAttributes, SigmaEdgeAttributes> => {
const graph = new MultiGraph<SigmaNodeAttributes, SigmaEdgeAttributes>();
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<string, { color: string; sizeMultiplier: number }> = {
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<string, { color: string; sizeMultiplier: number }> = {
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<string>();
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<SigmaNodeAttributes, SigmaEdgeAttributes> => {
const graph = new MultiGraph<SigmaNodeAttributes, SigmaEdgeAttributes>();
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<string, { color: string; sizeMultiplier: number }> = {
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<string, { color: string; sizeMultiplier: number }> = {
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<string>();
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;

View file

@ -79,6 +79,7 @@ export {
Loader2,
Maximize2,
MousePointerClick,
Network,
PanelLeft,
PanelLeftClose,
PanelRightClose,

View file

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

View file

@ -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<string, number> = {
// 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<string, number> = {
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<string, string[]>();
const parentsByChild = new Map<string, string[]>();
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<string, TreeNodePosition>,
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<string, TreeNodePosition>,
anchorXByNode: Map<string, number>,
) {
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<string, string[]>,
): Map<string, TreeNodePosition> {
const positions = new Map<string, TreeNodePosition>();
// Group nodes by layer and build a fast layer-lookup map.
const nodesByLayer: GraphNode[][] = Array.from({ length: LAYER_COUNT }, () => []);
const nodeLayerMap = new Map<string, number>();
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<string, string>();
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<string, GraphNode[]>();
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<string, TreeNodePosition> {
// 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<string, { sum: number; count: number }>();
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<string, { sum: number; count: number }>();
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<string, number>();
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<string, number>();
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;
}

View file

@ -24,6 +24,7 @@
"clearChat": "Clear chat",
"stopResponse": "Stop response"
},
"stopped": "Stopped by user",
"badges": {
"configureAI": "Configure AI",
"connecting": "Connecting"

View file

@ -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",

View file

@ -24,6 +24,7 @@
"clearChat": "清空聊天",
"stopResponse": "停止响应"
},
"stopped": "用户已停止",
"badges": {
"configureAI": "配置 AI",
"connecting": "连接中"

View file

@ -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": "适应屏幕",

View file

@ -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<string, unknown> | undefined;
const agent = {
stream: async (_input: unknown, config: Record<string, unknown>) => {
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' }]);
});
});

View file

@ -28,6 +28,8 @@ const ICON_MAP: Record<string, string> = {
Decorator: 'AtSign',
Import: 'FileCode',
Variable: 'Variable',
Property: 'Variable',
Const: 'Target',
};
describe('filter panel icon mappings', () => {

View file

@ -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',

View file

@ -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"

View file

@ -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',

View file

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

View file

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

View file

@ -103,6 +103,9 @@ const OPTION_DESCRIPTION_KEYS = {
'impact|-r, --repo <name>': 'help.option.repo.target',
'impact|--depth <n>': 'help.option.impact.depth',
'impact|--include-tests': 'help.option.impact.includeTests',
'impact|--limit <n>': 'help.option.impact.limit',
'impact|--offset <n>': 'help.option.impact.offset',
'impact|--summary-only': 'help.option.impact.summaryOnly',
'cypher|-r, --repo <name>': 'help.option.repo.target',
'detect-changes|-s, --scope <scope>': 'help.option.detectChanges.scope',
'detect-changes|-b, --base-ref <ref>': 'help.option.detectChanges.baseRef',

View file

@ -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':

View file

@ -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 暴露到所有网卡)',

View file

@ -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 <n>', 'Number of chunks per embedding model call')
.option('--embedding-device <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>', 'Port number', '4747')
.option('--host <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 <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 <pattern>')
.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 <text>', 'What you want to find')
.option('-l, --limit <n>', '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 <uid>', 'Direct symbol UID (zero-ambiguity lookup)')
.option('-f, --file <path>', '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 <target>')
@ -225,13 +225,16 @@ program
.option('-r, --repo <name>', 'Target repository')
.option('--depth <n>', 'Max relationship depth (default: 3)')
.option('--include-tests', 'Include test files in results')
.action(createLazyAction(() => import('./tool.js'), 'impactCommand'));
.option('--limit <n>', 'Max symbols per depth level (default: 100)')
.option('--offset <n>', '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 <query>')
.description('Execute raw Cypher query against the knowledge graph')
.option('-r, --repo <name>', '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 <scope>', 'What to analyze: unstaged, staged, all, or compare', 'unstaged')
.option('-b, --base-ref <ref>', 'Branch/commit for compare scope (e.g. main)')
.option('-r, --repo <name>', '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 <seconds>', '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);

View file

@ -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<string, unknown>,
TKey extends string & keyof TModule,
>(loader: () => Promise<TModule>, exportName: TKey): (...args: unknown[]) => Promise<void> {
return async (...args: unknown[]): Promise<void> => {
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);
};
}

View file

@ -117,6 +117,9 @@ export async function impactCommand(
repo?: string;
depth?: string;
includeTests?: boolean;
limit?: string;
offset?: string;
summaryOnly?: boolean;
},
): Promise<void> {
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) {

View file

@ -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.') ||

View file

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

View file

@ -43,6 +43,7 @@ export interface GroupToolPort {
relationTypes?: string[];
includeTests?: boolean;
minConfidence?: number;
limit?: number;
},
): Promise<unknown>;
query(

View file

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

View file

@ -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<string, Capture>): 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<string, Capture> = {
'@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<string, Capture> = {
'@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<string, Capture> = {
'@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<string, Capture> = {
'@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<string, Capture> = {
'@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<string, Capture> = {
'@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<string, Capture> = {
'@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<string, Capture> = {
'@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, '\\$&');
}

View file

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

View file

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

View file

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

View file

@ -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<SyntaxNode>
*/
function isBaseDependent(baseNode: SyntaxNode, templateParams: Set<string>): 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<T>`) 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<T>`, returns `'detail'`.
* For unqualified bases (`Inner<T>`, `Base<int>`), returns `''`.
* Nested qualifiers (`a::b::Inner<T>`) 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.
*/

View file

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

View file

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

View file

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

View file

@ -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<T>`).
*
* Key: filePath
* Value: Map<className, Set<dependentBaseSimpleName>>
* Value: Map<className, Map<baseName, qualifier>>
* qualifier is '' when the base was unqualified.
*/
const dependentBasesByFile = new Map<string, Map<string, Set<string>>>();
const dependentBasesByFile = new Map<string, Map<string, Map<string, Set<string>>>>();
/**
* Post-`populateOwners` resolution: per-class-nodeId, the set of
@ -55,12 +57,19 @@ const dependentBaseNodeIds = new Map<string, Set<string>>();
* 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<T>`), 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<T>`), 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<string, { nodeId: string; nsPrefix: string }[]>();
const entryByNodeId = new Map<string, { nodeId: string; nsPrefix: string; simple: string }>();
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<string, ParsedFile>();
@ -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<T>`), 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<T> in ns::outer find Inner<T> 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?

View file

@ -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<string>();
const pendingUserDefinedConversions: PendingUserDefinedConversion[] = [];
const classIdentitiesBySimpleName = new Map<string, Set<string>>();
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<ScopeId, (typeof parsed.scopes)[number]>();
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<ScopeId, (typeof parsed.scopes)[number]>,
): 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<string>();
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';
}

View file

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

View file

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

View file

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

View file

@ -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<string> = new Set(['include', 'extend', 'prepend']);
const ATTR_CALL_NAMES: ReadonlySet<string> = new Set([
'attr_accessor',
'attr_reader',
'attr_writer',
]);
export function emitRubyScopeCaptures(
sourceText: string,
_filePath: string,
cachedTree?: unknown,
): readonly CaptureMatch[] {
let tree = cachedTree as ReturnType<ReturnType<typeof getRubyParser>['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<ReturnType<typeof getRubyScopeQuery>['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<string, Capture> = {};
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<string, Capture | undefined>)['@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<User>` or `Array[User]` keep the inner type; for multi-param
* generics like `Hash<Symbol, User>` 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<Inner> or Type<A, B>
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;
}

View file

@ -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<string>;
}
// ─── 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<string>,
_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>,
): 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>): string | null {
const normalizedFileList = [...allFilePaths].map((f) => f.replace(/\\/g, '/'));
const allFileList = [...allFilePaths];
const index = buildSuffixIndex(normalizedFileList, allFileList);
return resolveRubyImportInternal(targetRaw, normalizedFileList, allFileList, index);
}

View file

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

View file

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

View file

@ -0,0 +1,27 @@
import type { BindingRef } from 'gitnexus-shared';
const TIER: Record<BindingRef['origin'], number> = {
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<string>();
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;
});
}

View file

@ -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<Parser['setLanguage']>[0]);
}
return _parser;
}
export function getRubyScopeQuery(): Parser.Query {
if (_query === null) {
_query = new Parser.Query(Ruby as Parameters<Parser['setLanguage']>[0], RUBY_SCOPE_QUERY);
}
return _query;
}

View file

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

View file

@ -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<string, string>();
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<string>();
// 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<string>();
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<ScopeResolver['buildMro']>[0],
parsedFiles: readonly ParsedFile[],
nodeLookup: Parameters<ScopeResolver['buildMro']>[2],
): Map<string, string[]> {
// 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<string, string>();
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<string, string[]>();
const includeByChild = new Map<string, string[]>();
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<string>();
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,
};

View file

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

View file

@ -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<string, string | null>;
/** 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,
);
}

View file

@ -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<string>;
readonly allFileList: readonly string[];
readonly normalizedFileList: readonly string[];
readonly index: SuffixIndex;
readonly resolveCache: Map<string, string | null>;
}
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,
};

View file

@ -11,8 +11,9 @@
* ## Contract
*
* - Env-var name per language: `REGISTRY_PRIMARY_<UPPER(enum-value)>`.
* 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<SupportedLanguages> = new Set<Suppo
SupportedLanguages.Kotlin,
SupportedLanguages.Java,
SupportedLanguages.Rust,
SupportedLanguages.Ruby,
]);
/**

View file

@ -574,6 +574,7 @@ function buildDefFromDeclarationMatch(
const declaredType = match['@declaration.field-type']?.text;
const returnType = match['@declaration.return-type']?.text;
const templateConstraints = parseJsonCapture(match['@declaration.template-constraints']);
const isExplicit = parseBooleanCapture(match['@declaration.is-explicit']);
return {
nodeId: makeDefId(filePath, anchor.range, type, nameCap.text),
@ -588,6 +589,7 @@ function buildDefFromDeclarationMatch(
...(returnType !== undefined ? { returnType } : {}),
...(templateArguments !== undefined ? { templateArguments } : {}),
...(templateConstraints !== undefined ? { templateConstraints } : {}),
...(isExplicit === true ? { isExplicit: true } : {}),
};
}
@ -610,6 +612,13 @@ function parseIntCapture(cap: { readonly text: string } | undefined): number | u
return Number.isFinite(n) ? n : undefined;
}
function parseBooleanCapture(cap: { readonly text: string } | undefined): boolean | undefined {
if (cap === undefined) return undefined;
if (cap.text === 'true') return true;
if (cap.text === 'false') return false;
return undefined;
}
function parseJsonParameterTypeClassesCapture(
cap: { readonly text: string } | undefined,
): ParameterTypeClass[] | undefined {
@ -1079,6 +1088,7 @@ const KNOWN_SUB_TAGS: ReadonlySet<string> = new Set<string>([
'@declaration.parameter-types',
'@declaration.parameter-type-classes',
'@declaration.template-constraints',
'@declaration.is-explicit',
]);
/**

View file

@ -437,6 +437,28 @@ export interface ScopeResolver {
nodeLookup: GraphNodeLookup,
) => Map<string /* DefId */, string[] /* ancestor DefIds */>;
/**
* 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

View file

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

View file

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

View file

@ -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<SupportedLanguages, ScopeResolver> = n
[SupportedLanguages.Rust, rustScopeResolver],
[SupportedLanguages.JavaScript, javascriptScopeResolver],
[SupportedLanguages.Kotlin, kotlinScopeResolver],
[SupportedLanguages.Ruby, rubyScopeResolver],
[SupportedLanguages.Cobol, cobolScopeResolver],
]);

View file

@ -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(

View file

@ -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<void>;
};
const retiredWorkers = new Set<RetiredWorkerRecord>();
const respawnCount: number[] = new Array(size).fill(0);
const activeSlots: Set<number> = 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<void> => {
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<boolean> => {
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<void> => {
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<boolean> => {
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();
};

View file

@ -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 <T>(operation: () => Promise<T>): Promise<T> =>
}
};
const normalizeCopyPath = (filePath: string): string => filePath.replace(/\\/g, '/');
const normalizeCopyPath = (filePath: string): string =>
toNativeSafePath(filePath).replace(/\\/g, '/');
const closeQueryResult = async (result: lbug.QueryResult): Promise<void> => {
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);

View file

@ -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<string>();
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<LbugConnectionHandle> {
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(() => {});

View file

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

View file

@ -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<void> {
async function replayShadowPagesWithWritableOpen(dbPath: string): Promise<void> {
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<lbug.Database> {
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<lbug.Database> {
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<lbug.Database> {
await db.close().catch(() => {});
db = undefined;
await replayShadowPagesWithWritableOpen(dbPath);
db = createLbugDatabase(lbug, dbPath, {
db = createLbugDatabase(lbug, toNativeSafePath(dbPath), {
readOnly: true,
throwOnWalReplayFailure: false,
});

View file

@ -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<string, string[]>;
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<Record<string, string[]>> {
const batches = this.batchFilesForGrouping(files);
const partials: Record<string, string[]>[] = [];
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<string, FileWithExports[]>();
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<string, string[]>[]): Record<string, string[]> {
const merged: Record<string, string[]> = {};
const seen = new Set<string>();
const slugToCanonical = new Map<string, string>();
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.
*/

View file

@ -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<string, RepoHandle> = new Map();
private contextCache: Map<string, CodebaseContext> = 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<any> {
private async impact(repo: RepoHandle, params: ImpactParams): Promise<any> {
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<any> {
private async _impactImpl(repo: RepoHandle, params: ImpactParams): Promise<any> {
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<any> {
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<number, number> = {};
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<number, any[]> = {};
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') {

View file

@ -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:

View file

@ -0,0 +1,2 @@
01 WS-CONSTANT-1 PIC X(10) VALUE 'AUDIT'.
01 WS-CONSTANT-2 PIC X(10) VALUE 'LOG'.

View file

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

View file

@ -0,0 +1,2 @@
01 WS-VAR-1 PIC X(10).
01 WS-VAR-2 PIC X(10).

View file

@ -0,0 +1,3 @@
01 PREFIX-RECORD.
05 PREFIX-CODE PIC X(10).
05 PREFIX-NAME PIC X(30).

View file

@ -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).

View file

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

33
gitnexus/test/fixtures/cobol/NESTED.cbl vendored Normal file
View file

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

94
gitnexus/test/fixtures/cobol/RPTGEN.cbl vendored Normal file
View file

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

View file

View file

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

View file

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

View file

@ -0,0 +1,4 @@

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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', () => {

View file

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

View file

@ -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<string, unknown>[], 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<string, unknown>[],
name: string,
): Record<string, unknown> | undefined {
return captures.find((m) => {
const n = (m as Record<string, { text: string }>)['@declaration.name'];
return n?.text === name;
});
}
/** Find a capture match where @reference.name.text equals a value */
function findRef(
captures: readonly Record<string, unknown>[],
name: string,
): Record<string, unknown> | undefined {
return captures.find((m) => {
const n = (m as Record<string, { text: string }>)['@reference.name'];
return n?.text === name;
});
}
/** Find a capture match with @import.name.text */
function findImport(
captures: readonly Record<string, unknown>[],
name: string,
): Record<string, unknown> | undefined {
return captures.find((m) => {
const n = (m as Record<string, { text: string }>)['@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<string, { text: string }>;
// 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<string, { text: string }>;
// 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<string, { text: string }>;
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<string, { name: string }>;
expect(ref['@reference.call']).toBeDefined();
});
});

View file

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

Some files were not shown because too many files have changed in this diff Show more