mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-29 01:41:42 +00:00
Merge branch 'main' into fix/fts-non-fatal-in-analyze
This commit is contained in:
commit
840b71b90e
117 changed files with 9954 additions and 618 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
117
gitnexus-web/e2e/tree-view.spec.ts
Normal file
117
gitnexus-web/e2e/tree-view.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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" />,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
22
gitnexus-web/src/hooks/app-state/graph.test.tsx
Normal file
22
gitnexus-web/src/hooks/app-state/graph.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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
307
gitnexus-web/src/lib/circles-layout.ts
Normal file
307
gitnexus-web/src/lib/circles-layout.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
208
gitnexus-web/src/lib/graph-adapter.test.ts
Normal file
208
gitnexus-web/src/lib/graph-adapter.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ export {
|
|||
Loader2,
|
||||
Maximize2,
|
||||
MousePointerClick,
|
||||
Network,
|
||||
PanelLeft,
|
||||
PanelLeftClose,
|
||||
PanelRightClose,
|
||||
|
|
|
|||
217
gitnexus-web/src/lib/tree-layout.test.ts
Normal file
217
gitnexus-web/src/lib/tree-layout.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
570
gitnexus-web/src/lib/tree-layout.ts
Normal file
570
gitnexus-web/src/lib/tree-layout.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@
|
|||
"clearChat": "Clear chat",
|
||||
"stopResponse": "Stop response"
|
||||
},
|
||||
"stopped": "Stopped by user",
|
||||
"badges": {
|
||||
"configureAI": "Configure AI",
|
||||
"connecting": "Connecting"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@
|
|||
"clearChat": "清空聊天",
|
||||
"stopResponse": "停止响应"
|
||||
},
|
||||
"stopped": "用户已停止",
|
||||
"badges": {
|
||||
"configureAI": "配置 AI",
|
||||
"connecting": "连接中"
|
||||
|
|
|
|||
|
|
@ -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": "适应屏幕",
|
||||
|
|
|
|||
97
gitnexus-web/test/unit/agent-abort.test.ts
Normal file
97
gitnexus-web/test/unit/agent-abort.test.ts
Normal 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' }]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
264
gitnexus/package-lock.json
generated
264
gitnexus/package-lock.json
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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 暴露到所有网卡)',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.') ||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ export interface GroupToolPort {
|
|||
relationTypes?: string[];
|
||||
includeTests?: boolean;
|
||||
minConfidence?: number;
|
||||
limit?: number;
|
||||
},
|
||||
): Promise<unknown>;
|
||||
query(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
291
gitnexus/src/core/ingestion/languages/cobol/captures.ts
Normal file
291
gitnexus/src/core/ingestion/languages/cobol/captures.ts
Normal 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, '\\$&');
|
||||
}
|
||||
20
gitnexus/src/core/ingestion/languages/cobol/index.ts
Normal file
20
gitnexus/src/core/ingestion/languages/cobol/index.ts
Normal 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';
|
||||
99
gitnexus/src/core/ingestion/languages/cobol/interpret.ts
Normal file
99
gitnexus/src/core/ingestion/languages/cobol/interpret.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
45
gitnexus/src/core/ingestion/languages/ruby/arity.ts
Normal file
45
gitnexus/src/core/ingestion/languages/ruby/arity.ts
Normal 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';
|
||||
}
|
||||
18
gitnexus/src/core/ingestion/languages/ruby/cache-stats.ts
Normal file
18
gitnexus/src/core/ingestion/languages/ruby/cache-stats.ts
Normal 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;
|
||||
}
|
||||
619
gitnexus/src/core/ingestion/languages/ruby/captures.ts
Normal file
619
gitnexus/src/core/ingestion/languages/ruby/captures.ts
Normal 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;
|
||||
}
|
||||
107
gitnexus/src/core/ingestion/languages/ruby/import-target.ts
Normal file
107
gitnexus/src/core/ingestion/languages/ruby/import-target.ts
Normal 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);
|
||||
}
|
||||
20
gitnexus/src/core/ingestion/languages/ruby/index.ts
Normal file
20
gitnexus/src/core/ingestion/languages/ruby/index.ts
Normal 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';
|
||||
117
gitnexus/src/core/ingestion/languages/ruby/interpret.ts
Normal file
117
gitnexus/src/core/ingestion/languages/ruby/interpret.ts
Normal 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);
|
||||
}
|
||||
27
gitnexus/src/core/ingestion/languages/ruby/merge-bindings.ts
Normal file
27
gitnexus/src/core/ingestion/languages/ruby/merge-bindings.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
221
gitnexus/src/core/ingestion/languages/ruby/query.ts
Normal file
221
gitnexus/src/core/ingestion/languages/ruby/query.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
235
gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts
Normal file
235
gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts
Normal 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,
|
||||
};
|
||||
63
gitnexus/src/core/ingestion/languages/ruby/simple-hooks.ts
Normal file
63
gitnexus/src/core/ingestion/languages/ruby/simple-hooks.ts
Normal 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';
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]);
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(() => {});
|
||||
|
|
|
|||
79
gitnexus/src/core/lbug/native-check.ts
Normal file
79
gitnexus/src/core/lbug/native-check.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
2
gitnexus/test/fixtures/cobol/AUDITCONST.cpy
vendored
Normal file
2
gitnexus/test/fixtures/cobol/AUDITCONST.cpy
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
01 WS-CONSTANT-1 PIC X(10) VALUE 'AUDIT'.
|
||||
01 WS-CONSTANT-2 PIC X(10) VALUE 'LOG'.
|
||||
77
gitnexus/test/fixtures/cobol/AUDITLOG.cbl
vendored
Normal file
77
gitnexus/test/fixtures/cobol/AUDITLOG.cbl
vendored
Normal 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.
|
||||
2
gitnexus/test/fixtures/cobol/AUDITVARS.cpy
vendored
Normal file
2
gitnexus/test/fixtures/cobol/AUDITVARS.cpy
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
01 WS-VAR-1 PIC X(10).
|
||||
01 WS-VAR-2 PIC X(10).
|
||||
3
gitnexus/test/fixtures/cobol/COPYLIB.cpy
vendored
Normal file
3
gitnexus/test/fixtures/cobol/COPYLIB.cpy
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
01 PREFIX-RECORD.
|
||||
05 PREFIX-CODE PIC X(10).
|
||||
05 PREFIX-NAME PIC X(30).
|
||||
6
gitnexus/test/fixtures/cobol/CUSTDAT.cpy
vendored
Normal file
6
gitnexus/test/fixtures/cobol/CUSTDAT.cpy
vendored
Normal 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).
|
||||
74
gitnexus/test/fixtures/cobol/CUSTUPDT.cbl
vendored
Normal file
74
gitnexus/test/fixtures/cobol/CUSTUPDT.cbl
vendored
Normal 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
33
gitnexus/test/fixtures/cobol/NESTED.cbl
vendored
Normal 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
94
gitnexus/test/fixtures/cobol/RPTGEN.cbl
vendored
Normal 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.
|
||||
0
gitnexus/test/fixtures/cobol/empty-file.cbl
vendored
Normal file
0
gitnexus/test/fixtures/cobol/empty-file.cbl
vendored
Normal file
21
gitnexus/test/fixtures/cobol/fixed-format.cbl
vendored
Normal file
21
gitnexus/test/fixtures/cobol/fixed-format.cbl
vendored
Normal 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.
|
||||
20
gitnexus/test/fixtures/cobol/malformed-multiline.cbl
vendored
Normal file
20
gitnexus/test/fixtures/cobol/malformed-multiline.cbl
vendored
Normal 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.
|
||||
4
gitnexus/test/fixtures/cobol/whitespace-only.cbl
vendored
Normal file
4
gitnexus/test/fixtures/cobol/whitespace-only.cbl
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
|
||||
|
||||
|
||||
|
||||
15
gitnexus/test/fixtures/lang-resolution/cpp-overload-udc-namespace-collision/lib.cpp
vendored
Normal file
15
gitnexus/test/fixtures/lang-resolution/cpp-overload-udc-namespace-collision/lib.cpp
vendored
Normal 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
|
||||
31
gitnexus/test/fixtures/lang-resolution/cpp-overload-udc-namespace-collision/lib.h
vendored
Normal file
31
gitnexus/test/fixtures/lang-resolution/cpp-overload-udc-namespace-collision/lib.h
vendored
Normal 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
|
||||
14
gitnexus/test/fixtures/lang-resolution/cpp-overload-user-defined-conversion/lib.cpp
vendored
Normal file
14
gitnexus/test/fixtures/lang-resolution/cpp-overload-user-defined-conversion/lib.cpp
vendored
Normal 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) {}
|
||||
39
gitnexus/test/fixtures/lang-resolution/cpp-overload-user-defined-conversion/lib.h
vendored
Normal file
39
gitnexus/test/fixtures/lang-resolution/cpp-overload-user-defined-conversion/lib.h
vendored
Normal 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);
|
||||
}
|
||||
};
|
||||
|
|
@ -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 ?? '');
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
80
gitnexus/test/integration/lbug-non-ascii-path.test.ts
Normal file
80
gitnexus/test/integration/lbug-non-ascii-path.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
553
gitnexus/test/integration/resolvers/cobol-scope.test.ts
Normal file
553
gitnexus/test/integration/resolvers/cobol-scope.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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
Loading…
Add table
Reference in a new issue