Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62fdcf9b2d | ||
|
|
81f095df05 | ||
|
|
33d6ab5a33 | ||
|
|
fe51058fe5 | ||
|
|
397488df02 |
@@ -35,7 +35,7 @@ https://github.com/user-attachments/assets/172685ba-8e54-4ea7-9ad1-e31a3398da72
|
||||
|
||||
> _Like DeepWiki, but deeper._ DeepWiki helps you _understand_ code. GitNexus lets you _analyze_ it — because a knowledge graph tracks every relationship, not just descriptions.
|
||||
|
||||
**TL;DR:** The **Web UI** is a quick way to chat with any repo. The **CLI + MCP** is how you make your AI agent actually reliable — it gives Cursor, Claude Code, Antigravity, Codex, and friends a deep architectural view of your codebase so they stop missing dependencies, breaking call chains, and shipping blind edits. Even smaller models get full architectural clarity, making it compete with Goliath models.
|
||||
**TL;DR:** The **Web UI** is a quick way to chat with any repo. The **CLI + MCP** is how you make your AI agent actually reliable — it gives Cursor, Claude Code, Codex, and friends a deep architectural view of your codebase so they stop missing dependencies, breaking call chains, and shipping blind edits. Even smaller models get full architectural clarity, making it compete with Goliath models.
|
||||
|
||||
---
|
||||
|
||||
@@ -48,7 +48,7 @@ https://github.com/user-attachments/assets/172685ba-8e54-4ea7-9ad1-e31a3398da72
|
||||
| | **CLI + MCP** | **Web UI** |
|
||||
| ----------- | --------------------------------------------------------------------- | -------------------------------------------------------------------- |
|
||||
| **What** | Index repos locally, connect AI agents via MCP | Visual graph explorer + AI chat in browser |
|
||||
| **For** | Daily development with Cursor, Claude Code, Antigravity, Codex, Windsurf, OpenCode | Quick exploration, demos, one-off analysis |
|
||||
| **For** | Daily development with Cursor, Claude Code, Codex, Windsurf, OpenCode | Quick exploration, demos, one-off analysis |
|
||||
| **Scale** | Full repos, any size | Limited by browser memory (~5k files), or unlimited via backend mode |
|
||||
| **Install** | `npm install -g gitnexus` | No install — [gitnexus.vercel.app](https://gitnexus.vercel.app) |
|
||||
| **Storage** | LadybugDB native (fast, persistent) | LadybugDB WASM (in-memory, per session) |
|
||||
@@ -114,20 +114,16 @@ To configure MCP for your editor, run `npx gitnexus setup` once — or set it up
|
||||
|
||||
### Editor Support
|
||||
|
||||
| Editor | MCP | Skills | Hooks (auto-augment) | Support |
|
||||
| -------------------- | --- | ------ | --------------------------------------------------------------------------------------- | ------------ |
|
||||
| **Claude Code** | Yes | Yes | Yes (PreToolUse + PostToolUse) | **Full** |
|
||||
| **Cursor** | Yes | Yes | Yes (postToolUse, [manual install](gitnexus-cursor-integration/README.md#hook-install)) | **Full** |
|
||||
| **Antigravity** (Google) | Yes | Yes | Yes (AfterTool, [Gemini CLI hooks schema](https://geminicli.com/docs/hooks/reference/))[¹](#fn-antigravity-hooks) | **Full** |
|
||||
| **Codex** | Yes | Yes | — | MCP + Skills |
|
||||
| **Windsurf** | Yes | — | — | MCP |
|
||||
| **OpenCode** | Yes | Yes | — | MCP + Skills |
|
||||
| Editor | MCP | Skills | Hooks (auto-augment) | Support |
|
||||
| --------------- | --- | ------ | --------------------------------------------------------------------------------------- | ------------ |
|
||||
| **Claude Code** | Yes | Yes | Yes (PreToolUse + PostToolUse) | **Full** |
|
||||
| **Cursor** | Yes | Yes | Yes (postToolUse, [manual install](gitnexus-cursor-integration/README.md#hook-install)) | **Full** |
|
||||
| **Codex** | Yes | Yes | — | MCP + Skills |
|
||||
| **Windsurf** | Yes | — | — | MCP |
|
||||
| **OpenCode** | Yes | Yes | — | MCP + Skills |
|
||||
|
||||
> **Claude Code** gets the deepest integration: MCP tools + agent skills + PreToolUse hooks that enrich searches with graph context + PostToolUse hooks that detect a stale index after commits and prompt the agent to reindex.
|
||||
|
||||
<a id="fn-antigravity-hooks"></a>
|
||||
> ¹ **Antigravity hooks** follow the [Gemini CLI hooks reference](https://geminicli.com/docs/hooks/reference/) (Antigravity 2.0 is the documented successor to Gemini CLI). Augmentation runs in `AfterTool` because `BeforeTool` has no context-injection channel in the Gemini contract — the agent sees graph context appended to the tool result via `hookSpecificOutput.additionalContext`. Stale-index hints land in the same channel after a successful `git commit/merge/rebase/cherry-pick/pull`. The schema may evolve if Antigravity-specific hook docs diverge from Gemini CLI's; the implementation will track those changes.
|
||||
|
||||
## Community Integrations
|
||||
|
||||
Built by the community — not officially maintained, but worth checking out.
|
||||
@@ -172,21 +168,6 @@ codex mcp add gitnexus -- npx -y gitnexus@latest mcp
|
||||
}
|
||||
```
|
||||
|
||||
**Antigravity** (Google) — `~/.gemini/antigravity/mcp_config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@latest", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> `gitnexus setup` also merges an `AfterTool` entry into `~/.gemini/settings.json` (under the canonical [Gemini CLI hooks schema](https://geminicli.com/docs/hooks/reference/)) and installs skills to `~/.gemini/antigravity/skills/`. Existing user hooks are preserved. The hook adapter's path is rewritten at install time, so run `gitnexus setup` rather than hand-editing.
|
||||
|
||||
**OpenCode** (`~/.config/opencode/config.json`):
|
||||
|
||||
```json
|
||||
@@ -658,7 +639,7 @@ UPSTREAM (what depends on this):
|
||||
authRouter [IMPORTS] -> src/routes/auth.ts
|
||||
```
|
||||
|
||||
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)
|
||||
Options: `maxDepth`, `minConfidence`, `relationTypes` (`CALLS`, `IMPORTS`, `EXTENDS`, `IMPLEMENTS`), `includeTests`
|
||||
|
||||
### Process-Grouped Search
|
||||
|
||||
|
||||
+12
-5
@@ -30,12 +30,19 @@ services:
|
||||
container_name: ${WEB_CONTAINER_NAME:-gitnexus-web}
|
||||
ports:
|
||||
- '${WEB_HOST_PORT:-4173}:4173'
|
||||
# Override the backend URL served to the browser. The default
|
||||
# (http://localhost:4747) works when both containers run locally.
|
||||
# Set GITNEXUS_BACKEND_URL in your .env or shell for remote/custom setups:
|
||||
# Optional: override the backend URL served to the browser.
|
||||
# Required when the gitnexus-server is not reachable at http://localhost:4747
|
||||
# from the user's browser (e.g. remote server deployments).
|
||||
#
|
||||
# Docker Desktop (Mac/Windows):
|
||||
# GITNEXUS_BACKEND_URL=http://host.docker.internal:4747
|
||||
#
|
||||
# Linux / remote server:
|
||||
# GITNEXUS_BACKEND_URL=http://<server-ip>:4747
|
||||
environment:
|
||||
- GITNEXUS_BACKEND_URL=${GITNEXUS_BACKEND_URL:-}
|
||||
# (host.docker.internal requires extra_hosts on Linux Docker Engine)
|
||||
#
|
||||
# environment:
|
||||
# - GITNEXUS_BACKEND_URL=http://host.docker.internal:4747
|
||||
depends_on:
|
||||
gitnexus-server:
|
||||
condition: service_healthy
|
||||
|
||||
+45
-48
@@ -1,4 +1,4 @@
|
||||
import { open } from 'node:fs/promises';
|
||||
import { open, stat } from 'node:fs/promises';
|
||||
import { createServer } from 'node:http';
|
||||
import { extname, isAbsolute, normalize, relative, resolve, sep } from 'node:path';
|
||||
|
||||
@@ -49,22 +49,14 @@ const contentTypes = {
|
||||
|
||||
// Static asset server for the gitnexus-web Docker image.
|
||||
//
|
||||
// TOCTOU prevention: every filesystem interaction uses open() to get a
|
||||
// file handle; subsequent reads use handle.readFile()/createReadStream().
|
||||
// Path-injection containment: each filesystem sink is preceded by the
|
||||
// canonical `path.relative` containment check that CodeQL recognizes as
|
||||
// a sanitizer barrier.
|
||||
//
|
||||
// CodeQL js/file-system-race: the query pairs open() calls when their
|
||||
// path arguments are data-flow aliased. This handler uses exactly two
|
||||
// open() calls whose paths are provably independent:
|
||||
// 1. open(requestedPath) — derived from the URL
|
||||
// 2. open(spaFallback) — the constant root/index.html
|
||||
// Because spaFallback has no data-flow from the request, CodeQL cannot
|
||||
// pair them as a check/use on the same path.
|
||||
//
|
||||
// Path-injection containment: each open() is preceded by a
|
||||
// path.relative() barrier that CodeQL recognizes as a sanitizer.
|
||||
|
||||
const spaFallback = resolve(root, 'index.html');
|
||||
|
||||
// TOCTOU prevention: after the path barrier, the file is opened once via
|
||||
// fs.promises.open() and all subsequent operations (stat, readFile,
|
||||
// createReadStream) use the file handle, eliminating any race between
|
||||
// the existence check and the read.
|
||||
const server = createServer(async (req, res) => {
|
||||
const urlPath = req.url?.split('?')[0] || '/';
|
||||
|
||||
@@ -83,10 +75,11 @@ const server = createServer(async (req, res) => {
|
||||
}
|
||||
|
||||
const cleanPath = normalize(decoded.replace(/^\/+/, ''));
|
||||
const requestedPath = resolve(root, cleanPath);
|
||||
const initialPath = resolve(root, cleanPath);
|
||||
|
||||
const rel = relative(root, requestedPath);
|
||||
if (rel.startsWith('..') || isAbsolute(rel)) {
|
||||
// Sanitizer barrier #1 — guards the first stat() sink.
|
||||
const initialRel = relative(root, initialPath);
|
||||
if (initialRel.startsWith('..') || isAbsolute(initialRel)) {
|
||||
res.writeHead(400);
|
||||
res.end('Bad request');
|
||||
return;
|
||||
@@ -94,43 +87,47 @@ const server = createServer(async (req, res) => {
|
||||
|
||||
let handle;
|
||||
try {
|
||||
let servePath = requestedPath;
|
||||
const initialStat = await stat(initialPath).catch(() => null);
|
||||
|
||||
// Try to open the exact path the client asked for.
|
||||
handle = await open(requestedPath, 'r').catch(() => null);
|
||||
if (handle) {
|
||||
const s = await handle.stat();
|
||||
if (!s.isFile()) {
|
||||
// Directories and other non-files fall through to SPA fallback.
|
||||
await handle.close();
|
||||
handle = null;
|
||||
}
|
||||
// Pick the path we actually serve. Note: any branch reassigns to a
|
||||
// freshly-resolved path; the next sanitizer barrier re-validates.
|
||||
let finalPath;
|
||||
if (initialStat?.isDirectory()) {
|
||||
finalPath = resolve(initialPath, 'index.html');
|
||||
} else if (!initialStat?.isFile()) {
|
||||
finalPath = resolve(root, 'index.html');
|
||||
} else {
|
||||
finalPath = initialPath;
|
||||
}
|
||||
|
||||
// If the requested path wasn't a regular file, serve the SPA entry
|
||||
// point. spaFallback is a module-level constant with no data-flow
|
||||
// from the request, so this open() is independent of the one above.
|
||||
// Sanitizer barrier #2 — guards the open() sink below. No
|
||||
// reassignment of finalPath happens between this guard and the
|
||||
// open(), so the analyzer can prove containment.
|
||||
const finalRel = relative(root, finalPath);
|
||||
if (finalRel.startsWith('..') || isAbsolute(finalRel)) {
|
||||
res.writeHead(400);
|
||||
res.end('Bad request');
|
||||
return;
|
||||
}
|
||||
|
||||
handle = await open(finalPath, 'r').catch(() => null);
|
||||
if (!handle) {
|
||||
servePath = spaFallback;
|
||||
handle = await open(spaFallback, 'r').catch(() => null);
|
||||
if (!handle) {
|
||||
res.writeHead(404);
|
||||
res.end('Not found');
|
||||
return;
|
||||
}
|
||||
const s = await handle.stat();
|
||||
if (!s.isFile()) {
|
||||
res.writeHead(404);
|
||||
res.end('Not found');
|
||||
return;
|
||||
}
|
||||
res.writeHead(404);
|
||||
res.end('Not found');
|
||||
return;
|
||||
}
|
||||
const finalStat = await handle.stat();
|
||||
if (!finalStat.isFile()) {
|
||||
res.writeHead(404);
|
||||
res.end('Not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const isHtml = extname(servePath) === '.html' || !extname(servePath);
|
||||
const cacheControl = servePath.includes(`${sep}assets${sep}`)
|
||||
const isHtml = extname(finalPath) === '.html' || !extname(finalPath);
|
||||
const cacheControl = finalPath.includes(`${sep}assets${sep}`)
|
||||
? 'public, max-age=31536000, immutable'
|
||||
: 'no-cache';
|
||||
const contentType = contentTypes[extname(servePath)] || 'application/octet-stream';
|
||||
const contentType = contentTypes[extname(finalPath)] || 'application/octet-stream';
|
||||
|
||||
if (isHtml && configScript) {
|
||||
const raw = await handle.readFile('utf8');
|
||||
|
||||
@@ -10,7 +10,6 @@ export type PipelinePhase =
|
||||
| 'imports'
|
||||
| 'calls'
|
||||
| 'heritage'
|
||||
| 'scopeResolution'
|
||||
| 'communities'
|
||||
| 'processes'
|
||||
| 'enriching'
|
||||
|
||||
@@ -267,11 +267,8 @@ export interface ScopeLookup {
|
||||
|
||||
/** Call-site description passed to `arityCompatibility`. */
|
||||
export interface Callsite {
|
||||
/** Number of arguments at the call site, if available. */
|
||||
readonly arity?: number;
|
||||
/** Inferred argument types at the call site, one per argument.
|
||||
* An empty string entry means the type was not inferred. */
|
||||
readonly argumentTypes?: readonly string[];
|
||||
/** Number of arguments at the call site. */
|
||||
readonly arity: number;
|
||||
}
|
||||
|
||||
// ─── §2.4 ImportEdge ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -381,7 +381,7 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div ref={selectedViewerRef} className="scrollbar-thin min-h-0 flex-1 overflow-auto">
|
||||
<div ref={selectedViewerRef} className="min-h-0 flex-1 scrollbar-thin overflow-auto">
|
||||
{isLoadingFile ? (
|
||||
<div className="flex items-center justify-center gap-2 py-8 text-text-muted">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
@@ -454,7 +454,7 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
|
||||
{t('graph:codePanel.references', { count: aiReferences.length })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="scrollbar-thin min-h-0 flex-1 space-y-3 overflow-y-auto p-3">
|
||||
<div className="min-h-0 flex-1 scrollbar-thin space-y-3 overflow-y-auto p-3">
|
||||
{refsWithSnippets.map(
|
||||
({ ref, content, start, highlightStart, highlightEnd, totalLines }) => {
|
||||
const nodeColor = ref.label
|
||||
|
||||
@@ -201,10 +201,7 @@ const getNodeTypeIcon = (label: NodeLabel) => {
|
||||
case 'Import':
|
||||
return FileCode;
|
||||
case 'Variable':
|
||||
case 'Property':
|
||||
return Variable;
|
||||
case 'Const':
|
||||
return Target;
|
||||
default:
|
||||
return Variable;
|
||||
}
|
||||
@@ -389,7 +386,7 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
|
||||
</div>
|
||||
|
||||
{/* File tree */}
|
||||
<div className="scrollbar-thin flex-1 overflow-y-auto py-2">
|
||||
<div className="flex-1 scrollbar-thin overflow-y-auto py-2">
|
||||
{fileTree.length === 0 ? (
|
||||
<div className="px-3 py-4 text-center text-xs text-text-muted">
|
||||
{t('graph:fileTree.noFilesLoaded')}
|
||||
@@ -413,7 +410,7 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
|
||||
)}
|
||||
|
||||
{activeTab === 'filters' && (
|
||||
<div className="scrollbar-thin flex-1 overflow-y-auto p-3">
|
||||
<div className="flex-1 scrollbar-thin overflow-y-auto p-3">
|
||||
<div className="mb-3">
|
||||
<h3 className="mb-2 text-xs font-medium tracking-wide text-text-secondary uppercase">
|
||||
{t('graph:fileTree.nodeTypes')}
|
||||
|
||||
@@ -9,16 +9,11 @@ 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,
|
||||
@@ -53,8 +48,6 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
|
||||
clearAICitationHighlights,
|
||||
clearBlastRadius,
|
||||
animatedNodes,
|
||||
graphViewMode,
|
||||
setGraphViewMode,
|
||||
} = useAppState();
|
||||
const [hoveredNodeName, setHoveredNodeName] = useState<string | null>(null);
|
||||
|
||||
@@ -156,22 +149,8 @@ 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,
|
||||
@@ -195,30 +174,25 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
|
||||
useEffect(() => {
|
||||
if (!graph) return;
|
||||
|
||||
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);
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
sigmaGraph = knowledgeGraphToGraphology(graph, communityMemberships);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const sigmaGraph = knowledgeGraphToGraphology(graph, communityMemberships);
|
||||
setSigmaGraph(sigmaGraph);
|
||||
}, [graph, nodeById, setSigmaGraph, graphViewMode]);
|
||||
}, [graph, nodeById, setSigmaGraph]);
|
||||
|
||||
// Update node visibility when filters change
|
||||
useEffect(() => {
|
||||
@@ -231,7 +205,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
|
||||
}, [graph, graphViewMode, visibleLabels, depthFilter, appSelectedNode]);
|
||||
}, [visibleLabels, depthFilter, appSelectedNode]);
|
||||
|
||||
// Sync app selected node with sigma
|
||||
useEffect(() => {
|
||||
@@ -271,53 +245,6 @@ 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}
|
||||
|
||||
@@ -364,7 +364,7 @@ export const ProcessesPanel = () => {
|
||||
</div>
|
||||
|
||||
{/* Process list */}
|
||||
<div className="scrollbar-thin flex-1 overflow-y-auto">
|
||||
<div className="flex-1 scrollbar-thin overflow-y-auto">
|
||||
{/* View All Processes Card */}
|
||||
<div className="px-4 py-3">
|
||||
<button
|
||||
|
||||
@@ -343,7 +343,7 @@ export const QueryFAB = () => {
|
||||
</div>
|
||||
|
||||
{showResults && queryResult.rows.length > 0 && (
|
||||
<div className="scrollbar-thin max-h-48 overflow-auto border-t border-border-subtle">
|
||||
<div className="max-h-48 scrollbar-thin overflow-auto border-t border-border-subtle">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="sticky top-0 bg-surface">
|
||||
<tr>
|
||||
|
||||
@@ -292,7 +292,7 @@ export const RightPanel = () => {
|
||||
)}
|
||||
|
||||
{/* Messages */}
|
||||
<div ref={scrollContainerRef} className="scrollbar-thin flex-1 overflow-y-auto p-4">
|
||||
<div ref={scrollContainerRef} className="flex-1 scrollbar-thin overflow-y-auto p-4">
|
||||
{chatMessages.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center px-4 text-center">
|
||||
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-xl bg-gradient-to-br from-accent to-node-interface text-2xl shadow-glow">
|
||||
@@ -417,7 +417,7 @@ export const RightPanel = () => {
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t('chat:input.placeholder')}
|
||||
rows={1}
|
||||
className="scrollbar-thin min-h-[36px] flex-1 resize-none border-none bg-transparent text-sm text-text-primary outline-none placeholder:text-text-muted"
|
||||
className="min-h-[36px] flex-1 resize-none scrollbar-thin border-none bg-transparent text-sm text-text-primary outline-none placeholder:text-text-muted"
|
||||
style={{ height: '36px', overflowY: 'hidden' }}
|
||||
/>
|
||||
<button
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
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,8 +16,6 @@ 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);
|
||||
@@ -29,7 +27,6 @@ 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) =>
|
||||
@@ -57,18 +54,8 @@ export const GraphStateProvider = ({ children }: { children: ReactNode }) => {
|
||||
setDepthFilter,
|
||||
highlightedNodeIds,
|
||||
setHighlightedNodeIds,
|
||||
graphViewMode,
|
||||
setGraphViewMode,
|
||||
}),
|
||||
[
|
||||
graph,
|
||||
selectedNode,
|
||||
visibleLabels,
|
||||
visibleEdgeTypes,
|
||||
depthFilter,
|
||||
highlightedNodeIds,
|
||||
graphViewMode,
|
||||
],
|
||||
[graph, selectedNode, visibleLabels, visibleEdgeTypes, depthFilter, highlightedNodeIds],
|
||||
);
|
||||
|
||||
return <GraphStateContext.Provider value={value}>{children}</GraphStateContext.Provider>;
|
||||
|
||||
@@ -123,10 +123,6 @@ 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;
|
||||
@@ -236,8 +232,6 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
||||
setDepthFilter,
|
||||
highlightedNodeIds,
|
||||
setHighlightedNodeIds,
|
||||
graphViewMode,
|
||||
setGraphViewMode,
|
||||
} = useGraphState();
|
||||
|
||||
// Right Panel
|
||||
@@ -1272,8 +1266,6 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
||||
toggleEdgeVisibility,
|
||||
depthFilter,
|
||||
setDepthFilter,
|
||||
graphViewMode,
|
||||
setGraphViewMode,
|
||||
highlightedNodeIds,
|
||||
setHighlightedNodeIds,
|
||||
aiCitationHighlightedNodeIds,
|
||||
|
||||
+75
-1003
File diff suppressed because it is too large
Load Diff
@@ -1,307 +0,0 @@
|
||||
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,9 +101,7 @@ export const getCommunityColor = (communityIndex: number): string => {
|
||||
return COMMUNITY_COLORS[communityIndex % COMMUNITY_COLORS.length];
|
||||
};
|
||||
|
||||
// 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.
|
||||
// Labels to show by default (hide imports and variables by default as they clutter)
|
||||
export const DEFAULT_VISIBLE_LABELS: NodeLabel[] = [
|
||||
'Project',
|
||||
'Package',
|
||||
@@ -113,8 +111,6 @@ 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',
|
||||
@@ -131,8 +127,6 @@ export const FILTERABLE_LABELS: NodeLabel[] = [
|
||||
'Function',
|
||||
'Method',
|
||||
'Variable',
|
||||
'Property', // Kotlin/Java field nodes
|
||||
'Const',
|
||||
'Decorator',
|
||||
'Import',
|
||||
];
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
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,9 +1,7 @@
|
||||
import Graph, { MultiGraph } from 'graphology';
|
||||
import Graph from 'graphology';
|
||||
import type { NodeLabel } from 'gitnexus-shared';
|
||||
import type { KnowledgeGraph } from '../core/graph/types';
|
||||
import { EDGE_INFO, NODE_COLORS, NODE_SIZES, getCommunityColor } from './constants';
|
||||
import { calculateTreeLayout } from './tree-layout';
|
||||
import { calculateCirclesLayout } from './circles-layout';
|
||||
import { NODE_COLORS, NODE_SIZES, getCommunityColor } from './constants';
|
||||
|
||||
export interface SigmaNodeAttributes {
|
||||
x: number;
|
||||
@@ -19,13 +17,6 @@ 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
|
||||
}
|
||||
@@ -37,7 +28,6 @@ export interface SigmaEdgeAttributes {
|
||||
type?: string;
|
||||
curvature?: number;
|
||||
zIndex?: number;
|
||||
isHierarchyEdge?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -101,21 +91,18 @@ export const knowledgeGraphToGraphology = (
|
||||
// Build parent-child map from hierarchy relationships
|
||||
// CONTAINS: Folder -> File
|
||||
// DEFINES: File -> Function/Class/Interface/Method
|
||||
// parent -> children (used only for initial spatial seeding before FA2 runs)
|
||||
// IMPORTS: File -> Import
|
||||
// parent -> children
|
||||
const parentToChildren = new Map<string, string[]>();
|
||||
// child -> parent
|
||||
const childToParent = new Map<string, string>();
|
||||
|
||||
// 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']);
|
||||
const hierarchyRelations = new Set(['CONTAINS', 'DEFINES', 'IMPORTS']);
|
||||
|
||||
knowledgeGraph.relationships.forEach((rel) => {
|
||||
// These relationships determine initial node positions (not graph semantics)
|
||||
if (spatialSeedRelations.has(rel.type)) {
|
||||
// source CONTAINS/DEFINES/IMPORTS target → source acts as spatial parent
|
||||
// These relationships represent parent-child hierarchy for positioning
|
||||
if (hierarchyRelations.has(rel.type)) {
|
||||
// source CONTAINS/DEFINES/IMPORTS target, so source is parent
|
||||
if (!parentToChildren.has(rel.sourceId)) {
|
||||
parentToChildren.set(rel.sourceId, []);
|
||||
}
|
||||
@@ -308,202 +295,23 @@ 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 (HIERARCHY_EDGE_STYLES[rel.type] !== undefined) addTreeEdge(rel);
|
||||
});
|
||||
knowledgeGraph.relationships.forEach((rel) => {
|
||||
if (HIERARCHY_EDGE_STYLES[rel.type] === undefined) addTreeEdge(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;
|
||||
|
||||
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);
|
||||
graph.addEdge(rel.sourceId, rel.targetId, {
|
||||
size: edgeBaseSize * style.sizeMultiplier,
|
||||
color: style.color,
|
||||
relationType: rel.type,
|
||||
type: 'curved',
|
||||
curvature: curvature,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return graph;
|
||||
|
||||
@@ -79,7 +79,6 @@ export {
|
||||
Loader2,
|
||||
Maximize2,
|
||||
MousePointerClick,
|
||||
Network,
|
||||
PanelLeft,
|
||||
PanelLeftClose,
|
||||
PanelRightClose,
|
||||
|
||||
@@ -1,217 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,570 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -69,7 +69,6 @@
|
||||
"imports": "Resolving imports",
|
||||
"calls": "Tracing calls",
|
||||
"heritage": "Extracting inheritance",
|
||||
"scopeResolution": "Resolving types",
|
||||
"communities": "Detecting communities",
|
||||
"processes": "Detecting processes",
|
||||
"complete": "Pipeline complete",
|
||||
|
||||
@@ -112,12 +112,6 @@
|
||||
"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",
|
||||
|
||||
@@ -69,7 +69,6 @@
|
||||
"imports": "正在解析导入",
|
||||
"calls": "正在追踪调用",
|
||||
"heritage": "正在提取继承关系",
|
||||
"scopeResolution": "正在解析类型",
|
||||
"communities": "正在检测社区",
|
||||
"processes": "正在检测流程",
|
||||
"complete": "流水线完成",
|
||||
|
||||
@@ -112,12 +112,6 @@
|
||||
"codeNotAvailable": "内存中没有 {{path}} 的代码内容"
|
||||
},
|
||||
"canvas": {
|
||||
"viewModes": {
|
||||
"label": "图形视图模式",
|
||||
"force": "力导向图",
|
||||
"tree": "顺序布局",
|
||||
"circles": "径向布局"
|
||||
},
|
||||
"zoomIn": "放大",
|
||||
"zoomOut": "缩小",
|
||||
"fit": "适应屏幕",
|
||||
|
||||
@@ -28,8 +28,6 @@ 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}', 'src/**/*.test.{ts,tsx}'],
|
||||
include: ['test/**/*.test.{ts,tsx}'],
|
||||
testTimeout: 15000,
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
|
||||
+1
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
**Graph-powered code intelligence for AI agents.** Index any codebase into a knowledge graph, then query it via MCP or CLI.
|
||||
|
||||
Works with **Cursor**, **Claude Code**, **Antigravity** (Google), **Codex**, **Windsurf**, **Cline**, **OpenCode**, and any MCP-compatible tool.
|
||||
Works with **Cursor**, **Claude Code**, **Codex**, **Windsurf**, **Cline**, **OpenCode**, and any MCP-compatible tool.
|
||||
|
||||
[](https://www.npmjs.com/package/gitnexus)
|
||||
[](https://polyformproject.org/licenses/noncommercial/1.0.0/)
|
||||
@@ -34,7 +34,6 @@ To configure MCP for your editor, run `npx gitnexus setup` once — or set it up
|
||||
|--------|-----|--------|---------------------|---------|
|
||||
| **Claude Code** | Yes | Yes | Yes (PreToolUse) | **Full** |
|
||||
| **Cursor** | Yes | Yes | Yes (postToolUse, [manual install](../gitnexus-cursor-integration/README.md#hook-install)) | **Full** |
|
||||
| **Antigravity** (Google) | Yes | Yes | Yes (AfterTool, [Gemini CLI hooks schema](https://geminicli.com/docs/hooks/reference/)) | **Full** |
|
||||
| **Codex** | Yes | Yes | — | MCP + Skills |
|
||||
| **Windsurf** | Yes | — | — | MCP |
|
||||
| **OpenCode** | Yes | Yes | — | MCP + Skills |
|
||||
|
||||
@@ -1,346 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* GitNexus Antigravity / Gemini CLI Hook Adapter
|
||||
*
|
||||
* Bridges the Gemini CLI hooks contract (also used by Antigravity 2.0 — see
|
||||
* https://geminicli.com/docs/hooks/reference/) to the same graph-aware
|
||||
* augmentation / staleness signals the Claude Code hook provides.
|
||||
*
|
||||
* Schema differences from the Claude adapter:
|
||||
* - Events are BeforeTool / AfterTool (not PreToolUse / PostToolUse).
|
||||
* - Tool names are snake_case (run_shell_command, search_file_content, glob).
|
||||
* - BeforeTool cannot inject context — decision: "allow" provides no channel
|
||||
* to surface text to the agent. Augmentation therefore runs in AfterTool,
|
||||
* where `hookSpecificOutput.additionalContext` is appended to the tool
|
||||
* result the agent sees.
|
||||
* - Stale-index hints after git commit/merge/rebase/cherry-pick/pull are
|
||||
* surfaced via the same `additionalContext` channel (so the agent reads
|
||||
* them, not only the user) and mirrored to stderr for terminal users.
|
||||
* - Stdin uses `tool_name`, `tool_input`, and `tool_response`
|
||||
* (with `llmContent`, `returnDisplay`, optional `error`).
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
const { acquireHookSlot } = require('./hook-lock.cjs');
|
||||
const { hasGitNexusDbLockedByGitNexusServer } = require('./hook-db-lock-probe.cjs');
|
||||
|
||||
function readInput() {
|
||||
try {
|
||||
const data = fs.readFileSync(0, 'utf-8');
|
||||
return JSON.parse(data);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function isGlobalRegistryDir(candidate) {
|
||||
if (fs.existsSync(path.join(candidate, 'meta.json'))) return false;
|
||||
return (
|
||||
fs.existsSync(path.join(candidate, 'registry.json')) ||
|
||||
fs.existsSync(path.join(candidate, 'repos'))
|
||||
);
|
||||
}
|
||||
|
||||
function walkForGitNexusDir(startDir) {
|
||||
let dir = startDir;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const candidate = path.join(dir, '.gitnexus');
|
||||
if (fs.existsSync(candidate)) {
|
||||
if (!isGlobalRegistryDir(candidate)) return candidate;
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findCanonicalRepoRoot(cwd) {
|
||||
try {
|
||||
const result = spawnSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 2000,
|
||||
cwd,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
if (result.error || result.status !== 0) return null;
|
||||
const commonDir = (result.stdout || '').trim();
|
||||
if (!commonDir || !path.isAbsolute(commonDir)) return null;
|
||||
return path.dirname(commonDir);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function findGitNexusDir(startDir) {
|
||||
const cwd = startDir || process.cwd();
|
||||
const fromCwd = walkForGitNexusDir(cwd);
|
||||
if (fromCwd) return fromCwd;
|
||||
const canonicalRoot = findCanonicalRepoRoot(cwd);
|
||||
if (canonicalRoot && canonicalRoot !== cwd) {
|
||||
return walkForGitNexusDir(canonicalRoot);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasGitNexusServerOwner(gitNexusDir) {
|
||||
return hasGitNexusDbLockedByGitNexusServer(path.join(gitNexusDir, 'lbug'), process.pid);
|
||||
}
|
||||
|
||||
function extractAugmentContext(stderr) {
|
||||
const output = (stderr || '').trim();
|
||||
const marker = output.indexOf('[GitNexus]');
|
||||
const debug = process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true';
|
||||
if (debug && output.length > 0) {
|
||||
// Emit the FULL discarded prefix (everything before the marker, or all of
|
||||
// it when no marker is present) so suppressed diagnostics — LadybugDB lock
|
||||
// warnings, parser errors, etc. — remain recoverable on the hook's own
|
||||
// stderr. Mirrors the Claude adapter's debug behavior.
|
||||
const discarded = marker === -1 ? output : output.slice(0, marker).trim();
|
||||
if (discarded.length > 0) {
|
||||
process.stderr.write(`[GitNexus hook] augment stderr discarded prefix:\n${discarded}\n`);
|
||||
}
|
||||
}
|
||||
return marker === -1 ? '' : output.slice(marker).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a usable search token from a tool invocation.
|
||||
* - search_file_content / glob: top-level `pattern` (sometimes `query`).
|
||||
* - run_shell_command: parse rg/grep argv, returning the first non-flag
|
||||
* positional ≥ 3 chars.
|
||||
* Returns null when the tool is not a recognized search or the pattern is
|
||||
* too short.
|
||||
*/
|
||||
function extractPattern(toolName, toolInput) {
|
||||
if (toolName === 'search_file_content') {
|
||||
const q = toolInput.pattern || toolInput.query || '';
|
||||
return typeof q === 'string' && q.length >= 3 ? q : null;
|
||||
}
|
||||
|
||||
if (toolName === 'glob') {
|
||||
const raw = toolInput.pattern || '';
|
||||
const match = raw.match(/[*\/]([a-zA-Z][a-zA-Z0-9_-]{2,})/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
if (toolName === 'run_shell_command') {
|
||||
const cmd = toolInput.command || '';
|
||||
if (!/\brg\b|\bgrep\b/.test(cmd)) return null;
|
||||
|
||||
const tokens = cmd.split(/\s+/);
|
||||
let foundCmd = false;
|
||||
let skipNext = false;
|
||||
const flagsWithValues = new Set([
|
||||
'-e',
|
||||
'-f',
|
||||
'-m',
|
||||
'-A',
|
||||
'-B',
|
||||
'-C',
|
||||
'-g',
|
||||
'--glob',
|
||||
'-t',
|
||||
'--type',
|
||||
'--include',
|
||||
'--exclude',
|
||||
]);
|
||||
|
||||
for (const token of tokens) {
|
||||
if (skipNext) {
|
||||
skipNext = false;
|
||||
continue;
|
||||
}
|
||||
if (!foundCmd) {
|
||||
if (/\brg$|\bgrep$/.test(token)) foundCmd = true;
|
||||
continue;
|
||||
}
|
||||
if (token.startsWith('-')) {
|
||||
if (flagsWithValues.has(token)) skipNext = true;
|
||||
continue;
|
||||
}
|
||||
const cleaned = token.replace(/['"]/g, '');
|
||||
return cleaned.length >= 3 ? cleaned : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveCliPath() {
|
||||
const fromEnv = process.env.GITNEXUS_HOOK_CLI_PATH;
|
||||
if (fromEnv !== undefined && String(fromEnv).trim() && fs.existsSync(String(fromEnv))) {
|
||||
return String(fromEnv);
|
||||
}
|
||||
let cliPath = path.resolve(__dirname, '..', '..', 'dist', 'cli', 'index.js');
|
||||
if (!fs.existsSync(cliPath)) {
|
||||
try {
|
||||
cliPath = require.resolve('gitnexus/dist/cli/index.js');
|
||||
} catch {
|
||||
cliPath = '';
|
||||
}
|
||||
}
|
||||
return cliPath;
|
||||
}
|
||||
|
||||
function runGitNexusCli(cliPath, args, cwd, timeout) {
|
||||
const isWin = process.platform === 'win32';
|
||||
if (cliPath) {
|
||||
return spawnSync(process.execPath, [cliPath, ...args], {
|
||||
encoding: 'utf-8',
|
||||
timeout,
|
||||
cwd,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
}
|
||||
return spawnSync(isWin ? 'npx.cmd' : 'npx', ['-y', 'gitnexus', ...args], {
|
||||
encoding: 'utf-8',
|
||||
timeout: timeout + 5000,
|
||||
cwd,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
}
|
||||
|
||||
function writeAdditionalContext(text) {
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'AfterTool',
|
||||
additionalContext: text,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function toolSucceeded(toolResponse) {
|
||||
if (!toolResponse || typeof toolResponse !== 'object') return true;
|
||||
if (toolResponse.error) return false;
|
||||
if (toolResponse.exit_code != null && Number(toolResponse.exit_code) !== 0) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the additionalContext for a tool result, if any.
|
||||
* 1. Graph augment for search-like tools (search_file_content, glob,
|
||||
* run_shell_command-with-rg/grep) that completed successfully.
|
||||
* 2. Stale-index hint after a successful git commit/merge/rebase/cherry-
|
||||
* pick/pull.
|
||||
* Returns null when nothing is to be appended.
|
||||
*/
|
||||
function buildAfterToolContext(input) {
|
||||
const cwd = input.cwd || process.cwd();
|
||||
if (!path.isAbsolute(cwd)) return null;
|
||||
const gitNexusDir = findGitNexusDir(cwd);
|
||||
if (!gitNexusDir) return null;
|
||||
|
||||
const toolName = input.tool_name || '';
|
||||
const toolInput = input.tool_input || {};
|
||||
const toolResponse = input.tool_response || {};
|
||||
const parts = [];
|
||||
|
||||
if (toolSucceeded(toolResponse)) {
|
||||
const pattern = extractPattern(toolName, toolInput);
|
||||
if (pattern) {
|
||||
const augmentText = runAugment(gitNexusDir, cwd, pattern);
|
||||
if (augmentText) parts.push(augmentText);
|
||||
}
|
||||
}
|
||||
|
||||
if (toolName === 'run_shell_command' && toolSucceeded(toolResponse)) {
|
||||
const command = toolInput.command || '';
|
||||
if (/\bgit\s+(commit|merge|rebase|cherry-pick|pull)(\s|$)/.test(command)) {
|
||||
const hint = buildStaleIndexHint(gitNexusDir, cwd);
|
||||
if (hint) {
|
||||
process.stderr.write(`${hint}\n`);
|
||||
parts.push(hint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts.join('\n\n') : null;
|
||||
}
|
||||
|
||||
function runAugment(gitNexusDir, cwd, pattern) {
|
||||
if (hasGitNexusServerOwner(gitNexusDir)) {
|
||||
process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
|
||||
return '';
|
||||
}
|
||||
const release = acquireHookSlot(gitNexusDir);
|
||||
if (!release) return '';
|
||||
const cliPath = resolveCliPath();
|
||||
try {
|
||||
const child = runGitNexusCli(cliPath, ['augment', '--', pattern], cwd, 7000);
|
||||
if (!child.error && child.status === 0) {
|
||||
return extractAugmentContext(child.stderr || '');
|
||||
}
|
||||
} catch {
|
||||
/* graceful failure */
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function buildStaleIndexHint(gitNexusDir, cwd) {
|
||||
let currentHead = '';
|
||||
try {
|
||||
const headResult = spawnSync('git', ['rev-parse', 'HEAD'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 3000,
|
||||
cwd,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
currentHead = (headResult.stdout || '').trim();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
if (!currentHead) return '';
|
||||
|
||||
let lastCommit = '';
|
||||
let hadEmbeddings = false;
|
||||
try {
|
||||
const meta = JSON.parse(fs.readFileSync(path.join(gitNexusDir, 'meta.json'), 'utf-8'));
|
||||
lastCommit = meta.lastCommit || '';
|
||||
hadEmbeddings = meta.stats && meta.stats.embeddings > 0;
|
||||
} catch {
|
||||
/* no meta — treat as stale */
|
||||
}
|
||||
|
||||
if (currentHead === lastCommit) return '';
|
||||
|
||||
const analyzeCmd = `npx gitnexus analyze${hadEmbeddings ? ' --embeddings' : ''}`;
|
||||
return (
|
||||
`[GitNexus] index is stale (last indexed: ${lastCommit ? lastCommit.slice(0, 7) : 'never'}). ` +
|
||||
`Run \`${analyzeCmd}\` to refresh the knowledge graph.`
|
||||
);
|
||||
}
|
||||
|
||||
function handleAfterTool(input) {
|
||||
const context = buildAfterToolContext(input);
|
||||
if (context) writeAdditionalContext(context);
|
||||
}
|
||||
|
||||
const handlers = {
|
||||
AfterTool: handleAfterTool,
|
||||
};
|
||||
|
||||
function main() {
|
||||
try {
|
||||
const input = readInput();
|
||||
const handler = handlers[input.hook_event_name || ''];
|
||||
if (handler) handler(input);
|
||||
} catch (err) {
|
||||
if (process.env.GITNEXUS_DEBUG) {
|
||||
console.error('GitNexus antigravity hook error:', (err.message || '').slice(0, 200));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
Generated
+134
-134
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "gitnexus",
|
||||
"version": "1.6.6-rc.70",
|
||||
"version": "1.6.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "gitnexus",
|
||||
"version": "1.6.6-rc.70",
|
||||
"version": "1.6.5",
|
||||
"hasInstallScript": true,
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"dependencies": {
|
||||
@@ -1307,9 +1307,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.132.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz",
|
||||
"integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==",
|
||||
"version": "0.130.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz",
|
||||
"integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -1387,9 +1387,9 @@
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1404,9 +1404,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1421,9 +1421,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz",
|
||||
"integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1438,9 +1438,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1455,9 +1455,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1472,9 +1472,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1489,9 +1489,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1506,9 +1506,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -1523,9 +1523,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -1540,9 +1540,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1557,9 +1557,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1574,9 +1574,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1591,9 +1591,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||
"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==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz",
|
||||
"integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
@@ -1621,9 +1621,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1638,9 +1638,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"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==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1792,9 +1792,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.0.tgz",
|
||||
"integrity": "sha512-AOQwYUNolgy3VosiRqXrACUXTN8nJUtPl7FJXMqZVyxiiCLhQuG3jXKvCS1ALr+Y2OmZhzzLVlYPEqJaiqkaJQ==",
|
||||
"version": "25.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz",
|
||||
"integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": ">=7.24.0 <7.24.7"
|
||||
@@ -1847,14 +1847,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/coverage-v8": {
|
||||
"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==",
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@bcoe/v8-coverage": "^1.0.2",
|
||||
"@vitest/utils": "4.1.7",
|
||||
"@vitest/utils": "4.1.6",
|
||||
"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.7",
|
||||
"vitest": "4.1.7"
|
||||
"@vitest/browser": "4.1.6",
|
||||
"vitest": "4.1.6"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vitest/browser": {
|
||||
@@ -1878,16 +1878,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"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==",
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz",
|
||||
"integrity": "sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.7",
|
||||
"@vitest/utils": "4.1.7",
|
||||
"@vitest/spy": "4.1.6",
|
||||
"@vitest/utils": "4.1.6",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
@@ -1896,13 +1896,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz",
|
||||
"integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==",
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz",
|
||||
"integrity": "sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.7",
|
||||
"@vitest/spy": "4.1.6",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
@@ -1923,9 +1923,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz",
|
||||
"integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==",
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1936,13 +1936,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz",
|
||||
"integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==",
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.6.tgz",
|
||||
"integrity": "sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.7",
|
||||
"@vitest/utils": "4.1.6",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
@@ -1950,14 +1950,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz",
|
||||
"integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==",
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.6.tgz",
|
||||
"integrity": "sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.7",
|
||||
"@vitest/utils": "4.1.7",
|
||||
"@vitest/pretty-format": "4.1.6",
|
||||
"@vitest/utils": "4.1.6",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
@@ -1966,9 +1966,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz",
|
||||
"integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==",
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.6.tgz",
|
||||
"integrity": "sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -1976,13 +1976,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz",
|
||||
"integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==",
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.6.tgz",
|
||||
"integrity": "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.7",
|
||||
"@vitest/pretty-format": "4.1.6",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
@@ -3594,9 +3594,9 @@
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "11.4.0",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.4.0.tgz",
|
||||
"integrity": "sha512-W+R+kFL4HgVxONq2bhXPi3bGpzGe/yEhVOp233qw9wCRtgncJ15P3bC+e4zZMu4Cq7d+WAJjXGW0uUkifhcatA==",
|
||||
"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==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
@@ -4112,9 +4112,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||
"version": "8.5.14",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
||||
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -4132,7 +4132,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.12",
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
@@ -4291,13 +4291,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz",
|
||||
"integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz",
|
||||
"integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.132.0",
|
||||
"@oxc-project/types": "=0.130.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.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"
|
||||
"@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"
|
||||
}
|
||||
},
|
||||
"node_modules/router": {
|
||||
@@ -5167,16 +5167,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.0.14",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz",
|
||||
"integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==",
|
||||
"version": "8.0.13",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz",
|
||||
"integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.4",
|
||||
"postcss": "^8.5.15",
|
||||
"rolldown": "1.0.2",
|
||||
"postcss": "^8.5.14",
|
||||
"rolldown": "1.0.1",
|
||||
"tinyglobby": "^0.2.16"
|
||||
},
|
||||
"bin": {
|
||||
@@ -5245,19 +5245,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz",
|
||||
"integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==",
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz",
|
||||
"integrity": "sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"@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",
|
||||
"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.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",
|
||||
"@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",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gitnexus",
|
||||
"version": "1.6.6-rc.70",
|
||||
"version": "1.6.5",
|
||||
"description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
|
||||
"author": "Abhigyan Patwari",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
|
||||
@@ -35,22 +35,6 @@ function getBuildTimeoutMs() {
|
||||
|
||||
const BUILD_TIMEOUT_MS = getBuildTimeoutMs();
|
||||
|
||||
// Published-package guard: when installed from the npm registry the
|
||||
// monorepo sibling `gitnexus-shared` does not exist and `dist/` is
|
||||
// already pre-built. Skip the build to avoid a misleading ENOENT
|
||||
// crash (#1795).
|
||||
if (!fs.existsSync(SHARED_ROOT)) {
|
||||
if (fs.existsSync(DIST)) {
|
||||
console.log('[build] skipping — dist/ already present (published package).');
|
||||
process.exit(0);
|
||||
}
|
||||
console.error(
|
||||
`[build] gitnexus-shared not found at ${SHARED_ROOT} and no dist/ exists.\n` +
|
||||
'Are you running from the monorepo checkout? Run `npm install` from the repo root first.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── 1. Build gitnexus-shared ───────────────────────────────────────
|
||||
console.log('[build] compiling gitnexus-shared…');
|
||||
const tscCmd =
|
||||
|
||||
@@ -29,7 +29,6 @@ const PLATFORM_LOGIC = [
|
||||
'test/unit/setup.test.ts',
|
||||
'test/unit/setup-jsonc.test.ts',
|
||||
'test/unit/setup-codex.test.ts',
|
||||
'test/unit/setup-antigravity.test.ts',
|
||||
'test/unit/platform-capabilities.test.ts',
|
||||
'test/unit/worker-pool-windows-quarantine.test.ts',
|
||||
'test/unit/lbug-pool-win-fts-probe.test.ts',
|
||||
@@ -59,7 +58,6 @@ 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',
|
||||
@@ -81,9 +79,6 @@ const SPAWN_CLI = [
|
||||
'test/integration/group/group-cli.test.ts',
|
||||
'test/integration/cli/tool-no-index-stderr.test.ts',
|
||||
'test/integration/setup-skills.test.ts',
|
||||
'test/integration/setup-antigravity.test.ts',
|
||||
'test/integration/antigravity-hook-e2e.test.ts',
|
||||
'test/unit/local-cli-subprocess.test.ts',
|
||||
];
|
||||
|
||||
// Worker threads tests — exercise real worker_threads which have
|
||||
|
||||
@@ -33,7 +33,6 @@ import { warnMissingOptionalGrammars } from './optional-grammars.js';
|
||||
import { glob } from 'glob';
|
||||
import fs from 'fs/promises';
|
||||
import { cliError } from './cli-message.js';
|
||||
import { formatElapsed } from './format-elapsed.js';
|
||||
import { isHfDownloadFailure } from '../core/embeddings/hf-env.js';
|
||||
|
||||
// Capture stderr.write at module load BEFORE anything (LadybugDB native
|
||||
@@ -917,14 +916,14 @@ const analyzeCommandImpl = async (inputPath?: string, options?: AnalyzeOptions):
|
||||
phaseStart = Date.now();
|
||||
}
|
||||
const elapsed = Math.round((Date.now() - phaseStart) / 1000);
|
||||
const display = elapsed >= 3 ? `${phaseLabel} (${formatElapsed(elapsed)})` : phaseLabel;
|
||||
const display = elapsed >= 3 ? `${phaseLabel} (${elapsed}s)` : phaseLabel;
|
||||
bar.update(value, { phase: display });
|
||||
};
|
||||
|
||||
const elapsedTimer = setInterval(() => {
|
||||
const elapsed = Math.round((Date.now() - phaseStart) / 1000);
|
||||
if (elapsed >= 3) {
|
||||
bar.update({ phase: `${lastPhaseLabel} (${formatElapsed(elapsed)})` });
|
||||
bar.update({ phase: `${lastPhaseLabel} (${elapsed}s)` });
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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 {
|
||||
@@ -60,13 +59,6 @@ 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,39 +206,19 @@ export function formatImpactResult(result: any): string {
|
||||
3: 'MAY NEED TESTING (transitive)',
|
||||
};
|
||||
|
||||
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})`);
|
||||
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`);
|
||||
}
|
||||
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();
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
export function formatElapsed(secs: number): string {
|
||||
if (secs < 60) return `${secs}s`;
|
||||
if (secs < 3600) return `${Math.floor(secs / 60)}m ${secs % 60}s`;
|
||||
const h = Math.floor(secs / 3600);
|
||||
const m = Math.floor((secs % 3600) / 60);
|
||||
return `${h}h ${m}m`;
|
||||
}
|
||||
@@ -103,9 +103,6 @@ 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',
|
||||
|
||||
@@ -185,8 +185,7 @@ export const en = {
|
||||
'help.option.clean.all': 'Clean all indexed repos',
|
||||
'help.option.clean.lbugSidecars': 'Clean quarantined LadybugDB missing-shadow WAL sidecars',
|
||||
'help.option.wiki.force': 'Force full regeneration even if up to date',
|
||||
'help.option.wiki.provider':
|
||||
'LLM provider: openai, openrouter, azure, custom, cursor, claude, or codex (default: openai)',
|
||||
'help.option.wiki.provider': 'LLM provider: openai or cursor (default: openai)',
|
||||
'help.option.wiki.model': 'LLM model or Azure deployment name (default: minimax/minimax-m2.5)',
|
||||
'help.option.wiki.baseUrl':
|
||||
'LLM API base URL. Azure v1: https://{resource}.openai.azure.com/openai/v1',
|
||||
@@ -216,9 +215,6 @@ 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':
|
||||
|
||||
@@ -174,8 +174,7 @@ export const zhCN = {
|
||||
'help.option.clean.all': '清理所有已索引仓库',
|
||||
'help.option.clean.lbugSidecars': '清理已隔离的 LadybugDB missing-shadow WAL sidecar',
|
||||
'help.option.wiki.force': '即使已是最新也强制完整重新生成',
|
||||
'help.option.wiki.provider':
|
||||
'LLM 提供商:openai、openrouter、azure、custom、cursor、claude 或 codex(默认:openai)',
|
||||
'help.option.wiki.provider': 'LLM 提供商:openai 或 cursor(默认:openai)',
|
||||
'help.option.wiki.model': 'LLM 模型或 Azure deployment 名称(默认:minimax/minimax-m2.5)',
|
||||
'help.option.wiki.baseUrl':
|
||||
'LLM API base URL。Azure v1:https://{resource}.openai.azure.com/openai/v1',
|
||||
@@ -202,9 +201,6 @@ 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 暴露到所有网卡)',
|
||||
|
||||
+14
-22
@@ -5,7 +5,7 @@
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { createRequire } from 'node:module';
|
||||
import { createLazyAction, createLbugLazyAction } from './lazy-action.js';
|
||||
import { createLazyAction } from './lazy-action.js';
|
||||
import { registerGroupCommands } from './group.js';
|
||||
import { localizeCliHelp } from './help-i18n.js';
|
||||
import { t } from './i18n/index.js';
|
||||
@@ -18,9 +18,7 @@ program.name('gitnexus').description('GitNexus local CLI and MCP server').versio
|
||||
|
||||
program
|
||||
.command('setup')
|
||||
.description(
|
||||
'One-time setup: configure MCP for Cursor, Claude Code, Antigravity, OpenCode, Codex',
|
||||
)
|
||||
.description('One-time setup: configure MCP for Cursor, Claude Code, OpenCode, Codex')
|
||||
.action(createLazyAction(() => import('./setup.js'), 'setupCommand'));
|
||||
|
||||
program
|
||||
@@ -89,7 +87,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(createLbugLazyAction(() => import('./analyze.js'), 'analyzeCommand'));
|
||||
.action(createLazyAction(() => import('./analyze.js'), 'analyzeCommand'));
|
||||
|
||||
program
|
||||
.command('index [path...]')
|
||||
@@ -105,12 +103,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(createLbugLazyAction(() => import('./serve.js'), 'serveCommand'));
|
||||
.action(createLazyAction(() => import('./serve.js'), 'serveCommand'));
|
||||
|
||||
program
|
||||
.command('mcp')
|
||||
.description('Start MCP server (stdio) — serves all indexed repos')
|
||||
.action(createLbugLazyAction(() => import('./mcp.js'), 'mcpCommand'));
|
||||
.action(createLazyAction(() => import('./mcp.js'), 'mcpCommand'));
|
||||
|
||||
program
|
||||
.command('list')
|
||||
@@ -148,10 +146,7 @@ program
|
||||
.command('wiki [path]')
|
||||
.description('Generate repository wiki from knowledge graph')
|
||||
.option('-f, --force', 'Force full regeneration even if up to date')
|
||||
.option(
|
||||
'--provider <provider>',
|
||||
'LLM provider: openai, openrouter, azure, custom, cursor, claude, or codex (default: openai)',
|
||||
)
|
||||
.option('--provider <provider>', 'LLM provider: openai or cursor (default: openai)')
|
||||
.option('--model <model>', 'LLM model or Azure deployment name (default: minimax/minimax-m2.5)')
|
||||
.option(
|
||||
'--base-url <url>',
|
||||
@@ -177,12 +172,12 @@ program
|
||||
'--lang <lang>',
|
||||
'Output language for generated documentation (e.g. english, chinese, spanish, japanese)',
|
||||
)
|
||||
.action(createLbugLazyAction(() => import('./wiki.js'), 'wikiCommand'));
|
||||
.action(createLazyAction(() => import('./wiki.js'), 'wikiCommand'));
|
||||
|
||||
program
|
||||
.command('augment <pattern>')
|
||||
.description('Augment a search pattern with knowledge graph context (used by hooks)')
|
||||
.action(createLbugLazyAction(() => import('./augment.js'), 'augmentCommand'));
|
||||
.action(createLazyAction(() => import('./augment.js'), 'augmentCommand'));
|
||||
|
||||
program
|
||||
.command('publish [path]')
|
||||
@@ -207,7 +202,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(createLbugLazyAction(() => import('./tool.js'), 'queryCommand'));
|
||||
.action(createLazyAction(() => import('./tool.js'), 'queryCommand'));
|
||||
|
||||
program
|
||||
.command('context [name]')
|
||||
@@ -216,7 +211,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(createLbugLazyAction(() => import('./tool.js'), 'contextCommand'));
|
||||
.action(createLazyAction(() => import('./tool.js'), 'contextCommand'));
|
||||
|
||||
program
|
||||
.command('impact <target>')
|
||||
@@ -225,16 +220,13 @@ program
|
||||
.option('-r, --repo <name>', 'Target repository')
|
||||
.option('--depth <n>', 'Max relationship depth (default: 3)')
|
||||
.option('--include-tests', 'Include test files in results')
|
||||
.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'));
|
||||
.action(createLazyAction(() => import('./tool.js'), 'impactCommand'));
|
||||
|
||||
program
|
||||
.command('cypher <query>')
|
||||
.description('Execute raw Cypher query against the knowledge graph')
|
||||
.option('-r, --repo <name>', 'Target repository')
|
||||
.action(createLbugLazyAction(() => import('./tool.js'), 'cypherCommand'));
|
||||
.action(createLazyAction(() => import('./tool.js'), 'cypherCommand'));
|
||||
|
||||
program
|
||||
.command('detect-changes')
|
||||
@@ -243,7 +235,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(createLbugLazyAction(() => import('./tool.js'), 'detectChangesCommand'));
|
||||
.action(createLazyAction(() => import('./tool.js'), 'detectChangesCommand'));
|
||||
|
||||
// ─── Eval Server (persistent daemon for SWE-bench) ─────────────────
|
||||
|
||||
@@ -256,7 +248,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(createLbugLazyAction(() => import('./eval-server.js'), 'evalServerCommand'));
|
||||
.action(createLazyAction(() => import('./eval-server.js'), 'evalServerCommand'));
|
||||
|
||||
registerGroupCommands(program);
|
||||
localizeCliHelp(program);
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
* 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';
|
||||
}
|
||||
@@ -23,23 +21,3 @@ 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);
|
||||
};
|
||||
}
|
||||
|
||||
+8
-218
@@ -13,6 +13,7 @@ import { execFile, execFileSync } from 'child_process';
|
||||
import { createRequire } from 'module';
|
||||
import { promisify } from 'util';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { glob } from 'glob';
|
||||
import { parseTree, modify, applyEdits, ParseError, parse as parseJsonc } from 'jsonc-parser';
|
||||
import { getGlobalDir } from '../storage/repo-manager.js';
|
||||
|
||||
@@ -253,18 +254,14 @@ async function installClaudeCodeSkills(result: SetupResult): Promise<void> {
|
||||
/**
|
||||
* Check whether an event array already contains a gitnexus-hook entry.
|
||||
*/
|
||||
function hasGitnexusHook(
|
||||
hooksObj: any,
|
||||
eventName: string,
|
||||
commandFragment = 'gitnexus-hook',
|
||||
): boolean {
|
||||
function hasGitnexusHook(hooksObj: any, eventName: string): boolean {
|
||||
const entries = hooksObj?.[eventName];
|
||||
if (!Array.isArray(entries)) return false;
|
||||
return entries.some(
|
||||
(h: any) =>
|
||||
Array.isArray(h.hooks) &&
|
||||
h.hooks.some(
|
||||
(hh: any) => typeof hh.command === 'string' && hh.command.includes(commandFragment),
|
||||
(hh: any) => typeof hh.command === 'string' && hh.command.includes('gitnexus-hook'),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -471,192 +468,6 @@ async function installClaudeCodeHooks(result: SetupResult): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Antigravity (Google) ──────────────────────────────────────────
|
||||
//
|
||||
// Antigravity stores its MCP config under ~/.gemini/antigravity/mcp_config.json
|
||||
// and inherits Gemini CLI's hooks contract
|
||||
// (https://geminicli.com/docs/hooks/reference/), which lives at
|
||||
// ~/.gemini/settings.json under the canonical `hooks.<EventName>` array layout.
|
||||
//
|
||||
// We register a single AfterTool entry matching Gemini's built-in search/shell
|
||||
// tools (search_file_content|glob|run_shell_command). BeforeTool is not used:
|
||||
// the Gemini contract provides no documented context-injection channel for it,
|
||||
// so augmentation runs in AfterTool where `hookSpecificOutput.additionalContext`
|
||||
// is appended to the tool result the agent reads. See the antigravity hook
|
||||
// adapter for the stdin/stdout contract details.
|
||||
|
||||
async function setupAntigravity(result: SetupResult): Promise<void> {
|
||||
const antigravityDir = path.join(os.homedir(), '.gemini', 'antigravity');
|
||||
if (!(await dirExists(antigravityDir))) {
|
||||
result.skipped.push('Antigravity (not installed)');
|
||||
return;
|
||||
}
|
||||
|
||||
const mcpPath = path.join(antigravityDir, 'mcp_config.json');
|
||||
try {
|
||||
const ok = await mergeJsoncFile(mcpPath, ['mcpServers', 'gitnexus'], getMcpEntry());
|
||||
if (ok) {
|
||||
result.configured.push('Antigravity');
|
||||
} else {
|
||||
result.errors.push(
|
||||
'Antigravity: mcp_config.json is corrupt — skipping to preserve existing content',
|
||||
);
|
||||
}
|
||||
} catch (err: any) {
|
||||
result.errors.push(`Antigravity: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install GitNexus skills to ~/.gemini/antigravity/skills/ (global scope,
|
||||
* per https://codelabs.developers.google.com/getting-started-with-antigravity-skills).
|
||||
* Each skill is laid out as {skillName}/SKILL.md just like the other editors.
|
||||
*/
|
||||
async function installAntigravitySkills(result: SetupResult): Promise<void> {
|
||||
const antigravityDir = path.join(os.homedir(), '.gemini', 'antigravity');
|
||||
if (!(await dirExists(antigravityDir))) return;
|
||||
|
||||
const skillsDir = path.join(antigravityDir, 'skills');
|
||||
try {
|
||||
const installed = await installSkillsTo(skillsDir);
|
||||
if (installed.length > 0) {
|
||||
result.configured.push(
|
||||
`Antigravity skills (${installed.length} skills → ~/.gemini/antigravity/skills/)`,
|
||||
);
|
||||
}
|
||||
} catch (err: any) {
|
||||
result.errors.push(`Antigravity skills: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the Antigravity/Gemini-CLI hook adapter to
|
||||
* ~/.gemini/config/hooks/gitnexus/ and register an AfterTool entry in
|
||||
* ~/.gemini/settings.json under `hooks.AfterTool`.
|
||||
*
|
||||
* Why AfterTool (and not BeforeTool): the Gemini hooks reference
|
||||
* (https://geminicli.com/docs/hooks/reference/) does not provide a context-
|
||||
* injection channel for BeforeTool. AfterTool's
|
||||
* `hookSpecificOutput.additionalContext` is the only documented way to
|
||||
* append text the agent will read.
|
||||
*/
|
||||
async function installAntigravityHooks(result: SetupResult): Promise<void> {
|
||||
const antigravityDir = path.join(os.homedir(), '.gemini', 'antigravity');
|
||||
if (!(await dirExists(antigravityDir))) return;
|
||||
|
||||
const geminiDir = path.join(os.homedir(), '.gemini');
|
||||
const settingsPath = path.join(geminiDir, 'settings.json');
|
||||
const destHooksDir = path.join(geminiDir, 'config', 'hooks', 'gitnexus');
|
||||
|
||||
// The antigravity adapter shares its lock/probe helpers with the claude
|
||||
// adapter — same DB, same concurrency rules — so we reuse those CJS files
|
||||
// from gitnexus/hooks/claude/ rather than duplicating them.
|
||||
const pluginAntigravityDir = path.join(__dirname, '..', '..', 'hooks', 'antigravity');
|
||||
const pluginClaudeDir = path.join(__dirname, '..', '..', 'hooks', 'claude');
|
||||
|
||||
try {
|
||||
await fs.mkdir(destHooksDir, { recursive: true });
|
||||
|
||||
// Adapter script: rewrite the dist path baked into the file so it resolves
|
||||
// to the installed gitnexus CLI rather than the cwd-relative dev path.
|
||||
const adapterSrc = path.join(pluginAntigravityDir, 'gitnexus-antigravity-hook.cjs');
|
||||
const adapterDest = path.join(destHooksDir, 'gitnexus-antigravity-hook.cjs');
|
||||
try {
|
||||
let content = await fs.readFile(adapterSrc, 'utf-8');
|
||||
const resolvedCli = path.join(__dirname, '..', 'cli', 'index.js');
|
||||
const normalizedCli = path.resolve(resolvedCli).replace(/\\/g, '/');
|
||||
const jsonCli = JSON.stringify(normalizedCli);
|
||||
content = content.replace(
|
||||
"let cliPath = path.resolve(__dirname, '..', '..', 'dist', 'cli', 'index.js');",
|
||||
`let cliPath = ${jsonCli};`,
|
||||
);
|
||||
await fs.writeFile(adapterDest, content, 'utf-8');
|
||||
} catch {
|
||||
// Adapter not found in source — skip
|
||||
}
|
||||
|
||||
// Bail out if the adapter was not written — registering the hook entry
|
||||
// without the script would crash on every tool invocation (top-level
|
||||
// require() of sibling helpers fails with MODULE_NOT_FOUND).
|
||||
try {
|
||||
await fs.access(adapterDest);
|
||||
} catch {
|
||||
result.errors.push(
|
||||
'Antigravity hooks: adapter script was not installed — skipping hook registration',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Shared helpers (copied from hooks/claude/). win-rm-list-json.ps1 is
|
||||
// required by hook-db-lock-probe.cjs on Windows — without it, the MCP
|
||||
// server ownership probe silently fails open and the hook may contend
|
||||
// with the MCP server on the LadybugDB.
|
||||
for (const helper of ['hook-lock.cjs', 'hook-db-lock-probe.cjs', 'win-rm-list-json.ps1']) {
|
||||
try {
|
||||
await fs.copyFile(path.join(pluginClaudeDir, helper), path.join(destHooksDir, helper));
|
||||
} catch {
|
||||
result.errors.push(
|
||||
`Antigravity hooks: failed to copy ${helper} — hook may crash at runtime`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const hookPath = path.join(destHooksDir, 'gitnexus-antigravity-hook.cjs').replace(/\\/g, '/');
|
||||
const escapedHookPath = hookPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
const hookCmd = `node "${escapedHookPath}"`;
|
||||
|
||||
const parsed = await (async () => {
|
||||
try {
|
||||
const r = await fs.readFile(settingsPath, 'utf-8');
|
||||
return parseJsonc(r);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
const hookEntries: Array<{ eventName: string; value: unknown }> = [];
|
||||
|
||||
if (!hasGitnexusHook(parsed?.hooks, 'AfterTool', 'gitnexus-antigravity-hook')) {
|
||||
// Matcher follows the Gemini CLI built-in tool naming (snake_case).
|
||||
// search_file_content / glob cover content + filename search; run_shell_command
|
||||
// catches rg/grep invocations and the git commit family for stale-index hints.
|
||||
hookEntries.push({
|
||||
eventName: 'AfterTool',
|
||||
value: {
|
||||
matcher: 'search_file_content|glob|run_shell_command',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: hookCmd,
|
||||
name: 'gitnexus',
|
||||
// ms — Gemini CLI uses milliseconds (default 60000); Claude Code
|
||||
// uses seconds. 10000 ms = 10 s.
|
||||
timeout: 10000,
|
||||
description: 'GitNexus graph context + stale-index hints',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (hookEntries.length === 0) {
|
||||
result.configured.push('Antigravity hooks (already configured)');
|
||||
return;
|
||||
}
|
||||
|
||||
const ok = await mergeHooksJsonc(settingsPath, hookEntries);
|
||||
if (ok) {
|
||||
result.configured.push('Antigravity hooks (AfterTool)');
|
||||
} else {
|
||||
result.errors.push(
|
||||
'Antigravity hooks: settings.json is corrupt — skipping to preserve existing content',
|
||||
);
|
||||
}
|
||||
} catch (err: any) {
|
||||
result.errors.push(`Antigravity hooks: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function setupOpenCode(result: SetupResult): Promise<void> {
|
||||
const opencodeDir = path.join(os.homedir(), '.config', 'opencode');
|
||||
if (!(await dirExists(opencodeDir))) {
|
||||
@@ -752,33 +563,15 @@ async function setupCodex(result: SetupResult): Promise<void> {
|
||||
*/
|
||||
async function installSkillsTo(targetDir: string): Promise<string[]> {
|
||||
const installed: string[] = [];
|
||||
// GITNEXUS_TEST_SKILLS_ROOT lets tests stage a fixture skills tree without
|
||||
// depending on __dirname resolution under Vitest.
|
||||
const skillsRoot =
|
||||
process.env.GITNEXUS_TEST_SKILLS_ROOT ?? path.join(__dirname, '..', '..', 'skills');
|
||||
const skillsRoot = path.join(__dirname, '..', '..', 'skills');
|
||||
|
||||
// Was glob('*.md') + glob('*/SKILL.md'); replaced with fs.readdir because
|
||||
// glob v13's cwd handling did not match the fixture path on Windows runners
|
||||
// (absolute temp paths containing the 8.3 short-name `RUNNER~1` returned
|
||||
// zero matches). fs.readdir has no such path quirks.
|
||||
let flatFiles: string[] = [];
|
||||
let dirSkillFiles: string[] = [];
|
||||
try {
|
||||
const entries = await fs.readdir(skillsRoot, { withFileTypes: true });
|
||||
flatFiles = entries.filter((e) => e.isFile() && e.name.endsWith('.md')).map((e) => e.name);
|
||||
const subdirSkillFiles = await Promise.all(
|
||||
entries
|
||||
.filter((e) => e.isDirectory())
|
||||
.map(async (e) => {
|
||||
try {
|
||||
await fs.access(path.join(skillsRoot, e.name, 'SKILL.md'));
|
||||
return path.join(e.name, 'SKILL.md');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
dirSkillFiles = subdirSkillFiles.filter((p): p is string => p !== null);
|
||||
[flatFiles, dirSkillFiles] = await Promise.all([
|
||||
glob('*.md', { cwd: skillsRoot }),
|
||||
glob('*/SKILL.md', { cwd: skillsRoot }),
|
||||
]);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
@@ -912,15 +705,12 @@ export const setupCommand = async () => {
|
||||
// Detect and configure each editor's MCP
|
||||
await setupCursor(result);
|
||||
await setupClaudeCode(result);
|
||||
await setupAntigravity(result);
|
||||
await setupOpenCode(result);
|
||||
await setupCodex(result);
|
||||
|
||||
// Install global skills for platforms that support them
|
||||
await installClaudeCodeSkills(result);
|
||||
await installClaudeCodeHooks(result);
|
||||
await installAntigravitySkills(result);
|
||||
await installAntigravityHooks(result);
|
||||
await installCursorSkills(result);
|
||||
await installOpenCodeSkills(result);
|
||||
await installCodexSkills(result);
|
||||
|
||||
@@ -117,9 +117,6 @@ export async function impactCommand(
|
||||
repo?: string;
|
||||
depth?: string;
|
||||
includeTests?: boolean;
|
||||
limit?: string;
|
||||
offset?: string;
|
||||
summaryOnly?: boolean;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (!target?.trim()) {
|
||||
@@ -129,19 +126,12 @@ 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) {
|
||||
|
||||
+19
-62
@@ -19,7 +19,6 @@ import {
|
||||
import { WikiGenerator, type WikiOptions } from '../core/wiki/generator.js';
|
||||
import { resolveLLMConfig, type LLMProvider } from '../core/wiki/llm-client.js';
|
||||
import { detectCursorCLI } from '../core/wiki/cursor-client.js';
|
||||
import { detectLocalCLI } from '../core/wiki/local-cli-client.js';
|
||||
import { logger } from '../core/logger.js';
|
||||
|
||||
export interface WikiCommandOptions {
|
||||
@@ -56,18 +55,6 @@ function parsePositiveIntegerOption(
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function isLocalProvider(
|
||||
provider: LLMProvider | undefined,
|
||||
): provider is 'cursor' | 'claude' | 'codex' {
|
||||
return provider === 'cursor' || provider === 'claude' || provider === 'codex';
|
||||
}
|
||||
|
||||
function localModelConfigKey(provider: 'cursor' | 'claude' | 'codex') {
|
||||
if (provider === 'cursor') return 'cursorModel';
|
||||
if (provider === 'claude') return 'claudeModel';
|
||||
return 'codexModel';
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt the user for input via stdin.
|
||||
*/
|
||||
@@ -204,11 +191,10 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions)
|
||||
if (options.provider) updates.provider = options.provider;
|
||||
if (options.apiVersion) updates.apiVersion = options.apiVersion;
|
||||
if (options.reasoningModel !== undefined) updates.isReasoningModel = options.reasoningModel;
|
||||
// Save model to appropriate field based on provider.
|
||||
// Save model to appropriate field based on provider
|
||||
if (options.model) {
|
||||
const targetProvider = options.provider ?? existing.provider;
|
||||
if (isLocalProvider(targetProvider)) {
|
||||
updates[localModelConfigKey(targetProvider)] = options.model;
|
||||
if (options.provider === 'cursor') {
|
||||
updates.cursorModel = options.model;
|
||||
} else {
|
||||
updates.model = options.model;
|
||||
}
|
||||
@@ -219,7 +205,7 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions)
|
||||
|
||||
const savedConfig = await loadCLIConfig();
|
||||
const hasSavedConfig = !!(
|
||||
isLocalProvider(savedConfig.provider) ||
|
||||
savedConfig.provider === 'cursor' ||
|
||||
(savedConfig.apiKey && savedConfig.baseUrl)
|
||||
);
|
||||
const hasCLIOverrides = !!(
|
||||
@@ -245,10 +231,10 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions)
|
||||
if (!hasSavedConfig && !hasCLIOverrides) {
|
||||
if (!process.stdin.isTTY) {
|
||||
// Non-interactive mode — need either API key or Cursor CLI
|
||||
if (!llmConfig.apiKey && !isLocalProvider(llmConfig.provider)) {
|
||||
if (!llmConfig.apiKey && llmConfig.provider !== 'cursor') {
|
||||
console.log(' Error: No LLM API key found.');
|
||||
console.log(' Set OPENAI_API_KEY or GITNEXUS_API_KEY environment variable,');
|
||||
console.log(' or pass --api-key <key>, or use --provider cursor|claude|codex.\n');
|
||||
console.log(' or pass --api-key <key>, or use --provider cursor.\n');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
@@ -256,51 +242,23 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions)
|
||||
} else {
|
||||
console.log(" No LLM configured. Let's set it up.\n");
|
||||
console.log(
|
||||
' Supports OpenAI, OpenRouter, Azure, any OpenAI-compatible API, Cursor CLI, Claude CLI, or Codex CLI.\n',
|
||||
' Supports OpenAI, OpenRouter, Azure, any OpenAI-compatible API, or Cursor CLI.\n',
|
||||
);
|
||||
|
||||
// Check if local agent CLIs are available.
|
||||
// Check if Cursor CLI is available
|
||||
const hasCursor = detectCursorCLI();
|
||||
const hasClaude = detectLocalCLI('claude');
|
||||
const hasCodex = detectLocalCLI('codex');
|
||||
const localChoices: Array<{
|
||||
choice: string;
|
||||
provider: 'cursor' | 'claude' | 'codex';
|
||||
}> = [];
|
||||
|
||||
// Provider selection
|
||||
console.log(' [1] OpenAI (api.openai.com)');
|
||||
console.log(' [2] OpenRouter (openrouter.ai)');
|
||||
console.log(' [3] Azure OpenAI');
|
||||
console.log(' [4] Custom endpoint');
|
||||
let nextChoice = 5;
|
||||
if (hasCursor) {
|
||||
const choice = String(nextChoice++);
|
||||
localChoices.push({
|
||||
choice,
|
||||
provider: 'cursor',
|
||||
});
|
||||
console.log(` [${choice}] Cursor CLI (local, uses your Cursor subscription)`);
|
||||
}
|
||||
if (hasClaude) {
|
||||
const choice = String(nextChoice++);
|
||||
localChoices.push({
|
||||
choice,
|
||||
provider: 'claude',
|
||||
});
|
||||
console.log(` [${choice}] Claude CLI (local, uses your Claude Code login)`);
|
||||
}
|
||||
if (hasCodex) {
|
||||
const choice = String(nextChoice++);
|
||||
localChoices.push({
|
||||
choice,
|
||||
provider: 'codex',
|
||||
});
|
||||
console.log(` [${choice}] Codex CLI (local, uses your Codex login)`);
|
||||
console.log(' [5] Cursor CLI (local, uses your Cursor subscription)');
|
||||
}
|
||||
console.log('');
|
||||
|
||||
const maxChoice = String(nextChoice - 1);
|
||||
const maxChoice = hasCursor ? '5' : '4';
|
||||
const choice = await prompt(` Select provider (1/${maxChoice}): `);
|
||||
|
||||
let baseUrl: string;
|
||||
@@ -308,21 +266,21 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions)
|
||||
let provider: LLMProvider = 'openai';
|
||||
let key = '';
|
||||
|
||||
const selectedLocal = localChoices.find((item) => item.choice === choice);
|
||||
if (selectedLocal) {
|
||||
// Local CLI selected - model defaults to the CLI's configured default.
|
||||
provider = selectedLocal.provider;
|
||||
if (choice === '5' && hasCursor) {
|
||||
// Cursor CLI selected - model defaults to 'auto' (Cursor's default)
|
||||
provider = 'cursor';
|
||||
baseUrl = '';
|
||||
|
||||
const modelInput = await prompt(' Model (leave empty for CLI default): ');
|
||||
const modelInput = await prompt(' Model (leave empty for auto): ');
|
||||
const model = modelInput || '';
|
||||
|
||||
const localConfig = { ...savedConfig, provider };
|
||||
if (model) (localConfig as Record<string, unknown>)[localModelConfigKey(provider)] = model;
|
||||
await saveCLIConfig(localConfig);
|
||||
// Save config for Cursor
|
||||
const cursorConfig: Record<string, string> = { provider: 'cursor' };
|
||||
if (model) cursorConfig.cursorModel = model;
|
||||
await saveCLIConfig(cursorConfig);
|
||||
console.log(' Config saved to ~/.gitnexus/config.json\n');
|
||||
|
||||
llmConfig = { ...llmConfig, provider, model, apiKey: '', baseUrl: '' };
|
||||
llmConfig = { ...llmConfig, provider: 'cursor', model, apiKey: '', baseUrl: '' };
|
||||
} else if (choice === '3') {
|
||||
// Azure OpenAI guided setup — minimal prompts
|
||||
console.log('\n Azure OpenAI setup.\n');
|
||||
@@ -370,7 +328,6 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions)
|
||||
const azureBaseUrl = `${endpoint}/openai/v1`;
|
||||
|
||||
await saveCLIConfig({
|
||||
...savedConfig,
|
||||
apiKey: azureKey,
|
||||
baseUrl: azureBaseUrl,
|
||||
model: deploymentName,
|
||||
|
||||
@@ -87,7 +87,6 @@ const DEFAULT_IGNORE_LIST = new Set([
|
||||
'.generated',
|
||||
'generated',
|
||||
'auto-generated',
|
||||
'monaco-workers', // Monaco editor web-worker bundles generated for browser runtime
|
||||
'.terraform',
|
||||
'.serverless',
|
||||
|
||||
@@ -324,6 +323,14 @@ 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,10 +25,6 @@ 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;
|
||||
|
||||
@@ -433,7 +429,6 @@ 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,7 +43,6 @@ export interface GroupToolPort {
|
||||
relationTypes?: string[];
|
||||
includeTests?: boolean;
|
||||
minConfidence?: number;
|
||||
limit?: number;
|
||||
},
|
||||
): Promise<unknown>;
|
||||
query(
|
||||
|
||||
@@ -475,9 +475,8 @@ 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, baseQualifier);
|
||||
markCppDependentBase(filePath, className, baseName);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -554,14 +553,10 @@ function* iterBaseClasses(baseClause: SyntaxNode): IterableIterator<SyntaxNode>
|
||||
*/
|
||||
function isBaseDependent(baseNode: SyntaxNode, templateParams: Set<string>): boolean {
|
||||
if (baseNode.type !== 'template_type') {
|
||||
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;
|
||||
}
|
||||
// Bare `type_identifier` or `qualified_identifier` bases — not
|
||||
// dependent (the base name itself doesn't reference a template
|
||||
// parameter at this level).
|
||||
return false;
|
||||
}
|
||||
// Walk all descendants of the template_argument_list looking for any
|
||||
// type_identifier matching a template parameter, or any conservative-
|
||||
@@ -629,27 +624,6 @@ 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
|
||||
|
||||
@@ -27,13 +27,12 @@
|
||||
* declaration transparently.
|
||||
*/
|
||||
|
||||
import type { Callsite, ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared';
|
||||
import type { ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared';
|
||||
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
import {
|
||||
isOverloadAmbiguousAfterNormalization,
|
||||
narrowOverloadCandidates,
|
||||
} from '../../scope-resolution/passes/overload-narrowing.js';
|
||||
import { cppConversionRank } from './conversion-rank.js';
|
||||
|
||||
interface RangeKey {
|
||||
readonly startLine: number;
|
||||
@@ -108,7 +107,6 @@ export function resolveCppQualifiedNamespaceMember(
|
||||
memberName: string,
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
_scopes: ScopeResolutionIndexes,
|
||||
callsite?: Callsite,
|
||||
): SymbolDefinition | 'ambiguous' | undefined {
|
||||
const allHits: SymbolDefinition[] = [];
|
||||
const seenNodeId = new Set<string>();
|
||||
@@ -134,17 +132,19 @@ export function resolveCppQualifiedNamespaceMember(
|
||||
if (allHits.length === 0) return undefined;
|
||||
if (allHits.length === 1) return allHits[0];
|
||||
|
||||
// Multi-candidate: thread call-site arity/argument-types through the
|
||||
// `resolveQualifiedReceiverMember` contract so `narrowOverloadCandidates`
|
||||
// can disambiguate via exact-type match and, when available, conversion-rank
|
||||
// scoring (`cppConversionRank`). Same-signature ambiguity is still detected
|
||||
// by `isOverloadAmbiguousAfterNormalization` below.
|
||||
const narrowed = narrowOverloadCandidates(
|
||||
allHits,
|
||||
callsite?.arity,
|
||||
callsite?.argumentTypes,
|
||||
callsite !== undefined ? { conversionRankFn: cppConversionRank } : undefined,
|
||||
);
|
||||
// Multi-candidate: the `resolveQualifiedReceiverMember` hook has no
|
||||
// access to call-site arity or argument types, so
|
||||
// `narrowOverloadCandidates` cannot actually narrow here — the call
|
||||
// with `(allHits, undefined, undefined)` is effectively a pass-through.
|
||||
// We retain it so that `isOverloadAmbiguousAfterNormalization` can
|
||||
// still detect int/long-style normalization collisions on this path,
|
||||
// but for any multi-hit case where candidates have genuinely distinct
|
||||
// signatures (e.g. `foo(int)` vs `foo(double)` in different inline
|
||||
// children), we conservatively suppress rather than pick arbitrarily.
|
||||
// A future enhancement could thread call-site argument info through
|
||||
// the `resolveQualifiedReceiverMember` contract to enable real
|
||||
// narrowing here.
|
||||
const narrowed = narrowOverloadCandidates(allHits, undefined, undefined);
|
||||
if (narrowed.length === 1) return narrowed[0];
|
||||
if (narrowed.length === 0) return undefined;
|
||||
if (isOverloadAmbiguousAfterNormalization(narrowed, undefined)) return 'ambiguous';
|
||||
|
||||
@@ -275,12 +275,6 @@ export const cppScopeResolver: ScopeResolver = {
|
||||
// descends transitively through inline-namespace children when
|
||||
// searching for the called member. Returns undefined for non-namespace
|
||||
// receivers so receiver-bound-calls Case 2 still gets a chance.
|
||||
resolveQualifiedReceiverMember: (
|
||||
receiverName,
|
||||
memberName,
|
||||
_callerScope,
|
||||
scopes,
|
||||
parsedFiles,
|
||||
callsite,
|
||||
) => resolveCppQualifiedNamespaceMember(receiverName, memberName, parsedFiles, scopes, callsite),
|
||||
resolveQualifiedReceiverMember: (receiverName, memberName, _callerScope, scopes, parsedFiles) =>
|
||||
resolveCppQualifiedNamespaceMember(receiverName, memberName, parsedFiles, scopes),
|
||||
};
|
||||
|
||||
@@ -37,14 +37,12 @@ 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 and their syntactic
|
||||
* qualifiers (e.g., `detail` for `detail::Inner<T>`).
|
||||
* the simple names of its dependent base classes.
|
||||
*
|
||||
* Key: filePath
|
||||
* Value: Map<className, Map<baseName, qualifier>>
|
||||
* qualifier is '' when the base was unqualified.
|
||||
* Value: Map<className, Set<dependentBaseSimpleName>>
|
||||
*/
|
||||
const dependentBasesByFile = new Map<string, Map<string, Map<string, Set<string>>>>();
|
||||
const dependentBasesByFile = new Map<string, Map<string, Set<string>>>();
|
||||
|
||||
/**
|
||||
* Post-`populateOwners` resolution: per-class-nodeId, the set of
|
||||
@@ -57,19 +55,12 @@ 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,
|
||||
qualifier = '',
|
||||
): void {
|
||||
export function markCppDependentBase(filePath: string, className: string, baseName: string): void {
|
||||
let perFile = dependentBasesByFile.get(filePath);
|
||||
if (perFile === undefined) {
|
||||
perFile = new Map();
|
||||
@@ -77,15 +68,10 @@ export function markCppDependentBase(
|
||||
}
|
||||
let bases = perFile.get(className);
|
||||
if (bases === undefined) {
|
||||
bases = new Map();
|
||||
bases = new Set();
|
||||
perFile.set(className, bases);
|
||||
}
|
||||
let quals = bases.get(baseName);
|
||||
if (quals === undefined) {
|
||||
quals = new Set();
|
||||
bases.set(baseName, quals);
|
||||
}
|
||||
quals.add(qualifier);
|
||||
bases.add(baseName);
|
||||
}
|
||||
|
||||
/** Clear two-phase-lookup state. Called from `clearFileLocalNames`. */
|
||||
@@ -103,10 +89,8 @@ 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. 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
|
||||
* 2. Fall back to accepting a unique simple-name match.
|
||||
* 3. Skip when multiple candidates exist and no namespace match is
|
||||
* found (conservative: avoids false associations).
|
||||
*/
|
||||
export function populateCppDependentBases(parsedFiles: readonly ParsedFile[]): void {
|
||||
@@ -115,13 +99,7 @@ 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;
|
||||
@@ -130,17 +108,14 @@ 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) : '';
|
||||
entryByNodeId.set(def.nodeId, { nodeId: def.nodeId, nsPrefix, simple });
|
||||
let entries = classesBySimpleName.get(simple);
|
||||
if (entries === undefined) {
|
||||
entries = [];
|
||||
classesBySimpleName.set(simple, entries);
|
||||
}
|
||||
entries.push({ nodeId: def.nodeId, nsPrefix });
|
||||
}
|
||||
}
|
||||
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>();
|
||||
@@ -164,12 +139,7 @@ export function populateCppDependentBases(parsedFiles: readonly ParsedFile[]): v
|
||||
localClassByName.set(simple, { nodeId: def.nodeId, nsPrefix });
|
||||
}
|
||||
|
||||
// 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) {
|
||||
for (const [className, baseNames] of perFile) {
|
||||
const classEntry = localClassByName.get(className);
|
||||
if (classEntry === undefined) continue;
|
||||
|
||||
@@ -179,77 +149,33 @@ export function populateCppDependentBases(parsedFiles: readonly ParsedFile[]): v
|
||||
dependentBaseNodeIds.set(classEntry.nodeId, bases);
|
||||
}
|
||||
|
||||
for (const [baseName, qualsSet] of baseEntries) {
|
||||
for (const baseQualifier of qualsSet) {
|
||||
const candidates = classesBySimpleName.get(baseName);
|
||||
if (candidates === undefined || candidates.length === 0) continue;
|
||||
for (const baseName of baseNames) {
|
||||
const candidates = classesBySimpleName.get(baseName);
|
||||
if (candidates === undefined || candidates.length === 0) 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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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.
|
||||
if (candidates.length === 1) {
|
||||
// Unique simple-name match — accept regardless of namespace.
|
||||
bases.add(candidates[0].nodeId);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Multiple classes share the same simple name — prefer the one
|
||||
// whose namespace matches the deriving class's namespace.
|
||||
// V1: exact dot-prefix match only. Cross-namespace inheritance
|
||||
// (e.g., `ns::outer::Derived` extending bare `Inner` defined in
|
||||
// `ns::outer::inner`) and inline-namespace cases are deferred to
|
||||
// V2; the conservative skip-on-ambiguity below avoids false
|
||||
// associations in those edge cases.
|
||||
const nsMatch = candidates.find((c) => c.nsPrefix === classEntry.nsPrefix);
|
||||
if (nsMatch !== undefined) {
|
||||
bases.add(nsMatch.nodeId);
|
||||
}
|
||||
// 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?
|
||||
|
||||
@@ -33,15 +33,6 @@ 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:
|
||||
@@ -267,12 +258,4 @@ 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,
|
||||
});
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
/**
|
||||
* 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';
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,619 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
/**
|
||||
* 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';
|
||||
@@ -1,117 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
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;
|
||||
});
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
/**
|
||||
* 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),
|
||||
};
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
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,
|
||||
};
|
||||
@@ -1,63 +0,0 @@
|
||||
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';
|
||||
}
|
||||
@@ -32,15 +32,6 @@ import { rustVariableConfig } from '../variable-extractors/configs/rust.js';
|
||||
import { createCallExtractor } from '../call-extractors/generic.js';
|
||||
import { rustCallConfig } from '../call-extractors/configs/rust.js';
|
||||
import { createHeritageExtractor } from '../heritage-extractors/generic.js';
|
||||
import {
|
||||
emitRustScopeCaptures,
|
||||
rustArityCompatibility,
|
||||
rustBindingScopeFor,
|
||||
rustImportOwningScope,
|
||||
rustReceiverBinding,
|
||||
interpretRustImport,
|
||||
interpretRustTypeBinding,
|
||||
} from './rust/index.js';
|
||||
|
||||
/** Rust impl_item: find the function_item child and extract its name as a Method. */
|
||||
const rustExtractFunctionName = (
|
||||
@@ -182,12 +173,4 @@ export const rustProvider = defineLanguage({
|
||||
classExtractor: createClassExtractor(rustClassConfig),
|
||||
heritageExtractor: createHeritageExtractor(SupportedLanguages.Rust),
|
||||
builtInNames: BUILT_INS,
|
||||
// ── RFC #909 Ring 3: scope-based resolution hooks ──────────
|
||||
emitScopeCaptures: emitRustScopeCaptures,
|
||||
interpretImport: interpretRustImport,
|
||||
interpretTypeBinding: interpretRustTypeBinding,
|
||||
bindingScopeFor: rustBindingScopeFor,
|
||||
importOwningScope: rustImportOwningScope,
|
||||
receiverBinding: rustReceiverBinding,
|
||||
arityCompatibility: rustArityCompatibility,
|
||||
});
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { Callsite, SymbolDefinition } from 'gitnexus-shared';
|
||||
|
||||
export function rustArityCompatibility(
|
||||
def: SymbolDefinition,
|
||||
callsite: Callsite,
|
||||
): 'compatible' | 'unknown' | 'incompatible' {
|
||||
const max = def.parameterCount;
|
||||
const min = def.requiredParameterCount;
|
||||
if (max === undefined && min === undefined) return 'unknown';
|
||||
if (!Number.isFinite(callsite.arity) || callsite.arity < 0) return 'unknown';
|
||||
|
||||
if (min !== undefined && callsite.arity < min) return 'incompatible';
|
||||
if (max !== undefined && callsite.arity > max) return 'incompatible';
|
||||
return 'compatible';
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
let hits = 0;
|
||||
let misses = 0;
|
||||
|
||||
export function recordRustCacheHit(): void {
|
||||
hits++;
|
||||
}
|
||||
export function recordRustCacheMiss(): void {
|
||||
misses++;
|
||||
}
|
||||
|
||||
export function getRustCaptureCacheStats(): { readonly hits: number; readonly misses: number } {
|
||||
return { hits, misses };
|
||||
}
|
||||
|
||||
export function resetRustCaptureCacheStats(): void {
|
||||
hits = 0;
|
||||
misses = 0;
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
import type { Capture, CaptureMatch } from 'gitnexus-shared';
|
||||
import {
|
||||
findNodeAtRange,
|
||||
nodeToCapture,
|
||||
syntheticCapture,
|
||||
type SyntaxNode,
|
||||
} from '../../utils/ast-helpers.js';
|
||||
import { getRustParser, getRustScopeQuery } from './query.js';
|
||||
import { recordRustCacheHit, recordRustCacheMiss } from './cache-stats.js';
|
||||
import { splitRustUseDeclaration } from './import-decomposer.js';
|
||||
import { synthesizeRustReceiverBinding } from './receiver-binding.js';
|
||||
import { getTreeSitterBufferSize } from '../../constants.js';
|
||||
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
|
||||
|
||||
export function emitRustScopeCaptures(
|
||||
sourceText: string,
|
||||
_filePath: string,
|
||||
cachedTree?: unknown,
|
||||
): readonly CaptureMatch[] {
|
||||
let tree = cachedTree as ReturnType<ReturnType<typeof getRustParser>['parse']> | undefined;
|
||||
if (tree === undefined) {
|
||||
tree = parseSourceSafe(getRustParser(), sourceText, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(sourceText),
|
||||
});
|
||||
recordRustCacheMiss();
|
||||
} else {
|
||||
recordRustCacheHit();
|
||||
}
|
||||
|
||||
const rawMatches = getRustScopeQuery().matches(tree.rootNode);
|
||||
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 use declarations into individual import captures
|
||||
if (grouped['@import.statement'] !== undefined) {
|
||||
const anchor = grouped['@import.statement']!;
|
||||
const useNode = findNodeAtRange(tree.rootNode, anchor.range, 'use_declaration');
|
||||
if (useNode !== null) {
|
||||
out.push(...splitRustUseDeclaration(useNode));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Synthesize self receiver bindings for methods inside impl blocks
|
||||
let cachedImplLookup: { fnNode: SyntaxNode; implNode: SyntaxNode | null } | undefined;
|
||||
if (grouped['@scope.function'] !== undefined) {
|
||||
const scopeCap = grouped['@scope.function']!;
|
||||
const fnNode = findNodeAtRange(tree.rootNode, scopeCap.range, 'function_item');
|
||||
if (fnNode !== null) {
|
||||
const implNode = findEnclosingImpl(fnNode);
|
||||
cachedImplLookup = { fnNode, implNode };
|
||||
const receiver = synthesizeRustReceiverBinding(fnNode, implNode);
|
||||
if (receiver !== null) out.push(receiver);
|
||||
}
|
||||
}
|
||||
|
||||
// Attach declaration arity for functions/methods
|
||||
const declAnchor = grouped['@declaration.function'];
|
||||
if (declAnchor !== undefined) {
|
||||
const fnNode = findNodeAtRange(tree.rootNode, declAnchor.range, 'function_item');
|
||||
if (fnNode !== null) {
|
||||
const implNode =
|
||||
cachedImplLookup?.fnNode === fnNode
|
||||
? cachedImplLookup.implNode
|
||||
: findEnclosingImpl(fnNode);
|
||||
const traitNode = implNode === null ? findEnclosingTrait(fnNode) : null;
|
||||
// Reclassify as method if inside an impl block or trait definition
|
||||
if (implNode !== null || traitNode !== 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 = computeRustDeclarationArity(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),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hoist return-type bindings from impl block functions to module level.
|
||||
// The auto-hoist in the scope-extractor places a type binding whose
|
||||
// anchor matches its innermost scope on the parent scope. By using the
|
||||
// impl_item node as the anchor (which matches the impl's Class scope),
|
||||
// the binding lands on the Module scope — making it visible to the
|
||||
// compound receiver's hoistTypeBindingsToModule walk.
|
||||
if (
|
||||
grouped['@type-binding.return'] !== undefined &&
|
||||
grouped['@type-binding.name'] !== undefined
|
||||
) {
|
||||
const tbReturnAnchor = grouped['@type-binding.return']!;
|
||||
const fnNode = findNodeAtRange(tree.rootNode, tbReturnAnchor.range, 'function_item');
|
||||
if (fnNode !== null) {
|
||||
const implNode = findEnclosingImpl(fnNode);
|
||||
if (implNode !== null) {
|
||||
out.push({
|
||||
'@type-binding.name': syntheticCapture(
|
||||
'@type-binding.name',
|
||||
implNode,
|
||||
grouped['@type-binding.name']!.text,
|
||||
),
|
||||
'@type-binding.type': syntheticCapture(
|
||||
'@type-binding.type',
|
||||
implNode,
|
||||
grouped['@type-binding.type']!.text,
|
||||
),
|
||||
'@type-binding.return': syntheticCapture(
|
||||
'@type-binding.return',
|
||||
implNode,
|
||||
tbReturnAnchor.text,
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Attach call arity for call expressions
|
||||
const callAnchor =
|
||||
grouped['@reference.call.free'] ??
|
||||
grouped['@reference.call.member'] ??
|
||||
grouped['@reference.call.constructor'];
|
||||
if (callAnchor !== undefined) {
|
||||
const callNode =
|
||||
findNodeAtRange(tree.rootNode, callAnchor.range, 'call_expression') ??
|
||||
findNodeAtRange(tree.rootNode, callAnchor.range, 'struct_expression');
|
||||
if (callNode !== null) {
|
||||
const arity = computeRustCallArity(callNode);
|
||||
grouped['@reference.arity'] = syntheticCapture('@reference.arity', callNode, String(arity));
|
||||
}
|
||||
}
|
||||
|
||||
out.push(grouped);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function findEnclosingImpl(node: SyntaxNode): SyntaxNode | null {
|
||||
let current: SyntaxNode | null = node.parent;
|
||||
while (current !== null) {
|
||||
if (current.type === 'impl_item') return current;
|
||||
if (current.type === 'source_file' || current.type === 'mod_item') return null;
|
||||
current = current.parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findEnclosingTrait(node: SyntaxNode): SyntaxNode | null {
|
||||
let current: SyntaxNode | null = node.parent;
|
||||
while (current !== null) {
|
||||
if (current.type === 'trait_item') return current;
|
||||
if (current.type === 'source_file' || current.type === 'mod_item') return null;
|
||||
current = current.parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function computeRustDeclarationArity(fnNode: SyntaxNode): {
|
||||
parameterCount?: number;
|
||||
requiredParameterCount?: number;
|
||||
} {
|
||||
const params = fnNode.childForFieldName('parameters');
|
||||
if (params === null) return {};
|
||||
|
||||
let count = 0;
|
||||
for (let i = 0; i < params.namedChildCount; i++) {
|
||||
const child = params.namedChild(i);
|
||||
if (child === null) continue;
|
||||
if (child.type === 'self_parameter') continue;
|
||||
if (child.type === 'parameter') count++;
|
||||
}
|
||||
// Rust has no default parameters or overloading
|
||||
return { parameterCount: count, requiredParameterCount: count };
|
||||
}
|
||||
|
||||
function computeRustCallArity(callNode: SyntaxNode): number {
|
||||
if (callNode.type === 'struct_expression') {
|
||||
const body = callNode.childForFieldName('body');
|
||||
if (body === null) return 0;
|
||||
let count = 0;
|
||||
for (let i = 0; i < body.namedChildCount; i++) {
|
||||
const t = body.namedChild(i)?.type;
|
||||
if (t === 'field_initializer' || t === 'shorthand_field_initializer') count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
const args = callNode.childForFieldName('arguments');
|
||||
if (args === null) return 0;
|
||||
|
||||
let count = 0;
|
||||
for (let i = 0; i < args.namedChildCount; i++) {
|
||||
const child = args.namedChild(i);
|
||||
if (child !== null) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
import type { CaptureMatch } from 'gitnexus-shared';
|
||||
import { syntheticCapture } from '../../utils/ast-helpers.js';
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
|
||||
/**
|
||||
* Decompose a Rust `use_declaration` into individual import captures.
|
||||
* Handles simple paths, grouped imports ({A, B}), wildcards (*),
|
||||
* renames (as), and `pub use` re-exports.
|
||||
*/
|
||||
export function splitRustUseDeclaration(node: SyntaxNode): CaptureMatch[] {
|
||||
if (node.type !== 'use_declaration') return [];
|
||||
|
||||
const isReexport = hasVisibilityModifier(node);
|
||||
const argument = getUseArgument(node);
|
||||
if (argument === null) return [];
|
||||
|
||||
return decomposeUseArgument(argument, '', isReexport, node);
|
||||
}
|
||||
|
||||
function hasVisibilityModifier(node: SyntaxNode): boolean {
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
if (node.child(i)?.type === 'visibility_modifier') return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function getUseArgument(node: SyntaxNode): SyntaxNode | null {
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i);
|
||||
if (child === null) continue;
|
||||
if (
|
||||
child.type === 'scoped_identifier' ||
|
||||
child.type === 'scoped_use_list' ||
|
||||
child.type === 'use_wildcard' ||
|
||||
child.type === 'use_as_clause' ||
|
||||
child.type === 'identifier' ||
|
||||
child.type === 'use_list'
|
||||
) {
|
||||
return child;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function decomposeUseArgument(
|
||||
node: SyntaxNode,
|
||||
prefixPath: string,
|
||||
isReexport: boolean,
|
||||
anchor: SyntaxNode,
|
||||
): CaptureMatch[] {
|
||||
switch (node.type) {
|
||||
case 'scoped_identifier': {
|
||||
const path = buildScopedPath(node);
|
||||
const segments = path.split('::');
|
||||
const name = segments[segments.length - 1];
|
||||
return [
|
||||
makeImportCapture(
|
||||
anchor,
|
||||
isReexport ? 'reexport' : 'named',
|
||||
joinPaths(prefixPath, path),
|
||||
name,
|
||||
undefined,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
case 'scoped_use_list': {
|
||||
const pathNode = node.childForFieldName('path');
|
||||
const listNode = node.childForFieldName('list');
|
||||
const pathStr = pathNode ? buildNodePath(pathNode) : '';
|
||||
const fullPrefix = joinPaths(prefixPath, pathStr);
|
||||
if (listNode === null) return [];
|
||||
return decomposeUseList(listNode, fullPrefix, isReexport, anchor);
|
||||
}
|
||||
|
||||
case 'use_list': {
|
||||
return decomposeUseList(node, prefixPath, isReexport, anchor);
|
||||
}
|
||||
|
||||
case 'use_wildcard': {
|
||||
const wcPath = buildWildcardPath(node);
|
||||
return [makeImportCapture(anchor, 'wildcard', joinPaths(prefixPath, wcPath), '*', undefined)];
|
||||
}
|
||||
|
||||
case 'use_as_clause': {
|
||||
const pathChild = node.childForFieldName('path');
|
||||
const aliasChild = node.childForFieldName('alias');
|
||||
if (pathChild === null || aliasChild === null) return [];
|
||||
const originalName =
|
||||
pathChild.type === 'scoped_identifier' ? buildScopedPath(pathChild) : pathChild.text;
|
||||
const aliasName = aliasChild.text;
|
||||
const segments = originalName.split('::');
|
||||
const importedName = segments[segments.length - 1];
|
||||
return [
|
||||
makeImportCapture(
|
||||
anchor,
|
||||
isReexport ? 'reexport' : 'named',
|
||||
joinPaths(prefixPath, originalName),
|
||||
importedName,
|
||||
aliasName,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
case 'identifier': {
|
||||
return [
|
||||
makeImportCapture(
|
||||
anchor,
|
||||
isReexport ? 'reexport' : 'named',
|
||||
joinPaths(prefixPath, node.text),
|
||||
node.text,
|
||||
undefined,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function decomposeUseList(
|
||||
listNode: SyntaxNode,
|
||||
prefix: string,
|
||||
isReexport: boolean,
|
||||
anchor: SyntaxNode,
|
||||
): CaptureMatch[] {
|
||||
const out: CaptureMatch[] = [];
|
||||
for (let i = 0; i < listNode.namedChildCount; i++) {
|
||||
const child = listNode.namedChild(i);
|
||||
if (child === null) continue;
|
||||
|
||||
if (child.type === 'self') {
|
||||
// `use crate::models::{self}` — imports the module itself
|
||||
const segments = prefix.split('::').filter(Boolean);
|
||||
const name = segments[segments.length - 1] ?? 'self';
|
||||
out.push(makeImportCapture(anchor, 'namespace', prefix, name, undefined));
|
||||
} else {
|
||||
out.push(...decomposeUseArgument(child, prefix, isReexport, anchor));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildScopedPath(node: SyntaxNode): string {
|
||||
if (node.type === 'scoped_identifier') {
|
||||
const parts: string[] = [];
|
||||
collectScopedParts(node, parts);
|
||||
return parts.join('::');
|
||||
}
|
||||
return node.text;
|
||||
}
|
||||
|
||||
function collectScopedParts(node: SyntaxNode, parts: string[]): void {
|
||||
if (node.type === 'scoped_identifier') {
|
||||
const pathNode = node.childForFieldName('path');
|
||||
const nameNode = node.childForFieldName('name');
|
||||
if (pathNode) collectScopedParts(pathNode, parts);
|
||||
if (nameNode) parts.push(nameNode.text);
|
||||
} else {
|
||||
parts.push(node.text);
|
||||
}
|
||||
}
|
||||
|
||||
function buildNodePath(node: SyntaxNode): string {
|
||||
if (node.type === 'scoped_identifier') {
|
||||
return buildScopedPath(node);
|
||||
}
|
||||
return node.text;
|
||||
}
|
||||
|
||||
function buildWildcardPath(node: SyntaxNode): string {
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i);
|
||||
if (child === null) continue;
|
||||
if (child.type === 'scoped_identifier') return buildScopedPath(child);
|
||||
if (child.type === 'identifier') return child.text;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function joinPaths(prefix: string, suffix: string): string {
|
||||
if (!prefix) return suffix;
|
||||
if (!suffix) return prefix;
|
||||
return `${prefix}::${suffix}`;
|
||||
}
|
||||
|
||||
function makeImportCapture(
|
||||
anchor: SyntaxNode,
|
||||
kind: string,
|
||||
source: string,
|
||||
name: string,
|
||||
alias: string | undefined,
|
||||
): CaptureMatch {
|
||||
return {
|
||||
'@import.statement': syntheticCapture('@import.statement', anchor, anchor.text),
|
||||
'@import.kind': syntheticCapture('@import.kind', anchor, kind),
|
||||
'@import.source': syntheticCapture('@import.source', anchor, source),
|
||||
'@import.name': syntheticCapture('@import.name', anchor, alias ?? name),
|
||||
...(alias !== undefined
|
||||
? {
|
||||
'@import.alias': syntheticCapture('@import.alias', anchor, alias),
|
||||
'@import.original-name': syntheticCapture('@import.original-name', anchor, name),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
/**
|
||||
* Resolve a Rust `use` import path to a repo-relative file path.
|
||||
*
|
||||
* Rust module resolution rules:
|
||||
* - `crate::foo::bar` → `src/foo/bar.rs` or `src/foo/bar/mod.rs`
|
||||
* - `super::foo` → parent directory's `foo.rs` or `foo/mod.rs`
|
||||
* - `self::foo` → same directory's `foo.rs` or `foo/mod.rs`
|
||||
* - External crate imports (no `crate::`/`super::`/`self::`) → null
|
||||
*/
|
||||
export function resolveRustImportTarget(
|
||||
targetRaw: string,
|
||||
fromFile: string,
|
||||
allFilePaths: ReadonlySet<string>,
|
||||
_resolutionConfig?: unknown,
|
||||
): string | readonly string[] | null {
|
||||
if (!targetRaw) return null;
|
||||
|
||||
const segments = targetRaw.split('::').filter(Boolean);
|
||||
if (segments.length === 0) return null;
|
||||
|
||||
const fromNormalized = fromFile.replace(/\\/g, '/');
|
||||
const fromDir = fromNormalized.includes('/')
|
||||
? fromNormalized.slice(0, fromNormalized.lastIndexOf('/'))
|
||||
: '';
|
||||
|
||||
if (segments[0] === 'crate') {
|
||||
const cratePath = segments.slice(1);
|
||||
return resolveModulePath(cratePath, findSrcRoot(fromNormalized), allFilePaths);
|
||||
}
|
||||
|
||||
if (segments[0] === 'super') {
|
||||
const parentDir = fromDir.includes('/') ? fromDir.slice(0, fromDir.lastIndexOf('/')) : '';
|
||||
const restPath = segments.slice(1);
|
||||
return resolveModulePath(restPath, parentDir, allFilePaths);
|
||||
}
|
||||
|
||||
if (segments[0] === 'self') {
|
||||
const restPath = segments.slice(1);
|
||||
return resolveModulePath(restPath, fromDir, allFilePaths);
|
||||
}
|
||||
|
||||
// External crate — try workspace-level resolution
|
||||
const workspaceResult = resolveWorkspaceCrate(segments, allFilePaths);
|
||||
if (workspaceResult !== null) return workspaceResult;
|
||||
|
||||
// Fallback: treat as implicit crate-relative (Rust 2015 edition or
|
||||
// when the first segment matches a sibling module name).
|
||||
return resolveModulePath(segments, findSrcRoot(fromNormalized), allFilePaths);
|
||||
}
|
||||
|
||||
function findSrcRoot(filePath: string): string {
|
||||
const normalized = filePath.replace(/\\/g, '/');
|
||||
const srcIdx = normalized.lastIndexOf('/src/');
|
||||
if (srcIdx !== -1) return normalized.slice(0, srcIdx + 4); // includes trailing /src
|
||||
if (normalized.startsWith('src/')) return 'src';
|
||||
return '';
|
||||
}
|
||||
|
||||
function resolveModulePath(
|
||||
pathSegments: string[],
|
||||
baseDir: string,
|
||||
allFilePaths: ReadonlySet<string>,
|
||||
): string | readonly string[] | null {
|
||||
if (pathSegments.length === 0) {
|
||||
const modPath = baseDir ? `${baseDir}/mod.rs` : 'mod.rs';
|
||||
if (allFilePaths.has(modPath)) return modPath;
|
||||
return null;
|
||||
}
|
||||
|
||||
const modulePath = pathSegments.join('/');
|
||||
|
||||
// Try direct file
|
||||
const directFile = baseDir ? `${baseDir}/${modulePath}.rs` : `${modulePath}.rs`;
|
||||
if (allFilePaths.has(directFile)) return directFile;
|
||||
|
||||
// Try mod.rs inside directory
|
||||
const modFile = baseDir ? `${baseDir}/${modulePath}/mod.rs` : `${modulePath}/mod.rs`;
|
||||
if (allFilePaths.has(modFile)) return modFile;
|
||||
|
||||
// Try partial path resolution: for `use crate::models::User` where
|
||||
// User is a type inside models.rs, resolve to `src/models.rs`
|
||||
if (pathSegments.length >= 2) {
|
||||
const parentPath = pathSegments.slice(0, -1).join('/');
|
||||
const parentFile = baseDir ? `${baseDir}/${parentPath}.rs` : `${parentPath}.rs`;
|
||||
if (allFilePaths.has(parentFile)) return parentFile;
|
||||
|
||||
const parentModFile = baseDir ? `${baseDir}/${parentPath}/mod.rs` : `${parentPath}/mod.rs`;
|
||||
if (allFilePaths.has(parentModFile)) return parentModFile;
|
||||
}
|
||||
|
||||
// Fallback: try increasingly shorter path prefixes
|
||||
for (let i = pathSegments.length - 2; i >= 1; i--) {
|
||||
const prefix = pathSegments.slice(0, i).join('/');
|
||||
const prefixFile = baseDir ? `${baseDir}/${prefix}.rs` : `${prefix}.rs`;
|
||||
if (allFilePaths.has(prefixFile)) return prefixFile;
|
||||
const prefixModFile = baseDir ? `${baseDir}/${prefix}/mod.rs` : `${prefix}/mod.rs`;
|
||||
if (allFilePaths.has(prefixModFile)) return prefixModFile;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveWorkspaceCrate(
|
||||
segments: string[],
|
||||
allFilePaths: ReadonlySet<string>,
|
||||
): string | null {
|
||||
const crateName = segments[0];
|
||||
const restSegments = segments.slice(1);
|
||||
|
||||
const candidates = [
|
||||
restSegments.length > 0
|
||||
? `${crateName}/src/${restSegments.join('/')}.rs`
|
||||
: `${crateName}/src/lib.rs`,
|
||||
restSegments.length > 0
|
||||
? `${crateName}/src/${restSegments.join('/')}/mod.rs`
|
||||
: `${crateName}/src/lib.rs`,
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (allFilePaths.has(candidate)) return candidate;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface RustResolveContext {
|
||||
readonly fromFile: string;
|
||||
readonly allFilePaths: ReadonlySet<string>;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
/**
|
||||
* Rust scope-resolution hooks (RFC #909 Ring 3).
|
||||
*/
|
||||
export { emitRustScopeCaptures } from './captures.js';
|
||||
export { getRustCaptureCacheStats, resetRustCaptureCacheStats } from './cache-stats.js';
|
||||
export {
|
||||
interpretRustImport,
|
||||
interpretRustTypeBinding,
|
||||
normalizeRustTypeName,
|
||||
} from './interpret.js';
|
||||
export { splitRustUseDeclaration } from './import-decomposer.js';
|
||||
export { synthesizeRustReceiverBinding } from './receiver-binding.js';
|
||||
export { rustArityCompatibility } from './arity.js';
|
||||
export { rustMergeBindings } from './merge-bindings.js';
|
||||
export { rustBindingScopeFor, rustImportOwningScope, rustReceiverBinding } from './simple-hooks.js';
|
||||
export { resolveRustImportTarget, type RustResolveContext } from './import-target.js';
|
||||
@@ -1,166 +0,0 @@
|
||||
import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'gitnexus-shared';
|
||||
|
||||
const REF_PREFIX_RE = /^&\s*(mut\s+)?/;
|
||||
const PTR_PREFIX_RE = /^\*\s*(const|mut)?\s*/;
|
||||
const ENUM_VARIANT_NAMES = new Set(['Some', 'None', 'Ok', 'Err']);
|
||||
|
||||
// ─── interpretImport ──────────────────────────────────────────────────────
|
||||
|
||||
export function interpretRustImport(captures: CaptureMatch): ParsedImport | null {
|
||||
const kind = captures['@import.kind']?.text;
|
||||
const source = captures['@import.source']?.text;
|
||||
const name = captures['@import.name']?.text;
|
||||
const alias = captures['@import.alias']?.text;
|
||||
if (kind === undefined || source === undefined) return null;
|
||||
|
||||
if (kind === 'wildcard') return { kind: 'wildcard', targetRaw: source };
|
||||
if (kind === 'namespace') {
|
||||
if (name === undefined) return null;
|
||||
return { kind: 'namespace', localName: name, importedName: name, targetRaw: source };
|
||||
}
|
||||
if (kind === 'reexport') {
|
||||
if (name === undefined) return null;
|
||||
const originalName = captures['@import.original-name']?.text;
|
||||
return {
|
||||
kind: 'reexport',
|
||||
localName: alias ?? name,
|
||||
importedName: originalName ?? name,
|
||||
targetRaw: source,
|
||||
};
|
||||
}
|
||||
// kind === 'named'
|
||||
if (name === undefined) return null;
|
||||
const originalName = captures['@import.original-name']?.text;
|
||||
return {
|
||||
kind: 'named',
|
||||
localName: alias ?? name,
|
||||
importedName: originalName ?? name,
|
||||
targetRaw: source,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── interpretTypeBinding ─────────────────────────────────────────────────
|
||||
|
||||
export function interpretRustTypeBinding(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'] = 'annotation';
|
||||
let normalizedType: string;
|
||||
|
||||
if (captures['@type-binding.self'] !== undefined) {
|
||||
source = 'self';
|
||||
normalizedType = normalizeRustTypeName(type);
|
||||
} else if (captures['@type-binding.constructor'] !== undefined) {
|
||||
source = 'constructor-inferred';
|
||||
normalizedType = normalizeRustTypeName(type);
|
||||
} else if (captures['@type-binding.call-return'] !== undefined) {
|
||||
if (ENUM_VARIANT_NAMES.has(type)) return null;
|
||||
source = 'constructor-inferred';
|
||||
normalizedType = normalizeRustCallReturnType(type);
|
||||
} else if (captures['@type-binding.return'] !== undefined) {
|
||||
source = 'return-annotation';
|
||||
normalizedType = normalizeRustReturnType(type);
|
||||
} else if (captures['@type-binding.assignment'] !== undefined) {
|
||||
source = 'assignment-inferred';
|
||||
normalizedType = normalizeRustTypeName(type);
|
||||
} else if (captures['@type-binding.alias'] !== undefined) {
|
||||
source = 'assignment-inferred';
|
||||
normalizedType = normalizeRustTypeName(type);
|
||||
} else if (captures['@type-binding.parameter'] !== undefined) {
|
||||
source = 'parameter-annotation';
|
||||
normalizedType = normalizeRustTypeName(type);
|
||||
} else {
|
||||
normalizedType = normalizeRustTypeName(type);
|
||||
}
|
||||
|
||||
return { boundName: name, rawTypeName: normalizedType, source };
|
||||
}
|
||||
|
||||
export function normalizeRustTypeName(text: string): string {
|
||||
let t = text.trim();
|
||||
// Strip reference prefixes (&, &mut, *const, *mut)
|
||||
while (t.startsWith('&')) t = t.replace(REF_PREFIX_RE, '');
|
||||
while (t.startsWith('*')) t = t.replace(PTR_PREFIX_RE, '');
|
||||
// Unwrap common smart-pointer/container wrappers to their inner type
|
||||
const wrappers = ['Box', 'Option', 'Arc', 'Rc', 'Mutex', 'RwLock', 'RefCell', 'Cell'];
|
||||
for (const w of wrappers) {
|
||||
if (t.startsWith(`${w}<`)) {
|
||||
const inner = extractFirstGenericArg(t);
|
||||
if (inner !== null) {
|
||||
t = inner;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (t.startsWith('Vec<')) {
|
||||
const inner = extractFirstGenericArg(t);
|
||||
if (inner !== null) t = inner;
|
||||
}
|
||||
const bracket = t.indexOf('<');
|
||||
if (bracket !== -1) t = t.slice(0, bracket);
|
||||
// Take last segment of qualified paths (crate::foo::Bar → Bar)
|
||||
const lastColon = t.lastIndexOf('::');
|
||||
if (lastColon !== -1) t = t.slice(lastColon + 2);
|
||||
return t.trim();
|
||||
}
|
||||
|
||||
function extractFirstGenericArg(text: string): string | null {
|
||||
const open = text.indexOf('<');
|
||||
if (open === -1) return null;
|
||||
let depth = 0;
|
||||
for (let i = open; i < text.length; i++) {
|
||||
if (text[i] === '<') depth++;
|
||||
else if (text[i] === '>') {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
const inner = text.slice(open + 1, i).trim();
|
||||
const comma = findTopLevelComma(inner);
|
||||
return comma === -1 ? inner : inner.slice(0, comma).trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findTopLevelComma(text: string): number {
|
||||
let depth = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
if (text[i] === '<') depth++;
|
||||
else if (text[i] === '>') depth--;
|
||||
else if (text[i] === ',' && depth === 0) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function normalizeRustCallReturnType(text: string): string {
|
||||
let t = text.trim();
|
||||
// For scoped calls like `Foo::new()`, extract the type part before `::`
|
||||
const scopeIdx = t.indexOf('::');
|
||||
if (scopeIdx !== -1) {
|
||||
t = t.slice(0, scopeIdx);
|
||||
}
|
||||
return normalizeRustTypeName(t);
|
||||
}
|
||||
|
||||
function normalizeRustReturnType(text: string): string {
|
||||
let t = text.trim();
|
||||
while (t.startsWith('&')) t = t.replace(REF_PREFIX_RE, '');
|
||||
// Unwrap Result<T, E>, Option<T> for return types
|
||||
const wrappers = ['Result', 'Option'];
|
||||
for (const w of wrappers) {
|
||||
if (t.startsWith(`${w}<`)) {
|
||||
const inner = extractFirstGenericArg(t);
|
||||
if (inner !== null) {
|
||||
t = inner;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const bracket = t.indexOf('<');
|
||||
if (bracket !== -1) t = t.slice(0, bracket);
|
||||
const lastColon = t.lastIndexOf('::');
|
||||
if (lastColon !== -1) t = t.slice(lastColon + 2);
|
||||
return t.trim();
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { BindingRef } from 'gitnexus-shared';
|
||||
|
||||
const TIER: Record<BindingRef['origin'], number> = {
|
||||
local: 0,
|
||||
namespace: 1,
|
||||
import: 2,
|
||||
reexport: 3,
|
||||
wildcard: 4,
|
||||
};
|
||||
|
||||
export function rustMergeBindings(
|
||||
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;
|
||||
});
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import type { ParsedFile } from 'gitnexus-shared';
|
||||
import { isClassLike, populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js';
|
||||
|
||||
/**
|
||||
* Populate `ownerId` on Rust method defs.
|
||||
*
|
||||
* Rust methods are declared inside `impl TypeName { ... }` blocks, not
|
||||
* directly inside struct bodies. The tree-sitter query creates Class scopes
|
||||
* for impl blocks, and the generic `populateClassOwnedMembers` handles methods
|
||||
* that are structurally nested inside those Class scopes. But we also need to
|
||||
* bridge the impl block's methods to the actual struct def, since the impl
|
||||
* block is semantically "owned by" the struct.
|
||||
*
|
||||
* Strategy:
|
||||
* 1. Run the generic `populateClassOwnedMembers` (handles property fields in
|
||||
* structs and methods in impl blocks).
|
||||
* 2. For each method in an impl block's Class scope whose ownerId points to
|
||||
* the impl block, re-point ownerId to the struct def (if found in the
|
||||
* same module).
|
||||
*/
|
||||
export function populateRustOwners(parsed: ParsedFile): void {
|
||||
populateClassOwnedMembers(parsed);
|
||||
populateRustImplOwners(parsed);
|
||||
}
|
||||
|
||||
function populateRustImplOwners(parsed: ParsedFile): void {
|
||||
// Build a map of struct name → def nodeId from all scopes.
|
||||
const structByName = new Map<string, string>();
|
||||
for (const scope of parsed.scopes) {
|
||||
for (const def of scope.ownedDefs) {
|
||||
if (isClassLike(def.type) && def.qualifiedName) {
|
||||
structByName.set(def.qualifiedName, def.nodeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (structByName.size === 0) return;
|
||||
|
||||
const structBySuffix = new Map<string, string>();
|
||||
for (const [qname, nodeId] of structByName) {
|
||||
const dot = qname.lastIndexOf('.');
|
||||
const suffix = dot !== -1 ? qname.slice(dot + 1) : qname;
|
||||
structBySuffix.set(suffix, nodeId);
|
||||
}
|
||||
|
||||
for (const scope of parsed.scopes) {
|
||||
if (scope.kind !== 'Function') continue;
|
||||
const methodDefs = scope.ownedDefs.filter(
|
||||
(d) => d.type === 'Method' && d.ownerId === undefined,
|
||||
);
|
||||
if (methodDefs.length === 0) continue;
|
||||
|
||||
let receiverType: string | undefined;
|
||||
for (const [, tb] of scope.typeBindings) {
|
||||
if (tb.source === 'self') {
|
||||
receiverType = tb.rawName;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (receiverType === undefined) continue;
|
||||
|
||||
const ownerId = structByName.get(receiverType) ?? structBySuffix.get(receiverType);
|
||||
if (ownerId !== undefined) {
|
||||
for (const def of methodDefs) {
|
||||
(def as { ownerId?: string }).ownerId = ownerId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
import Parser from 'tree-sitter';
|
||||
import Rust from 'tree-sitter-rust';
|
||||
|
||||
const RUST_SCOPE_QUERY = `
|
||||
;; Scopes
|
||||
(source_file) @scope.module
|
||||
(struct_item) @scope.class
|
||||
(trait_item) @scope.class
|
||||
(impl_item) @scope.class
|
||||
(enum_item) @scope.class
|
||||
(function_item) @scope.function
|
||||
(closure_expression) @scope.function
|
||||
(block) @scope.block
|
||||
(if_expression) @scope.block
|
||||
(match_expression) @scope.block
|
||||
(for_expression) @scope.block
|
||||
(while_expression) @scope.block
|
||||
(loop_expression) @scope.block
|
||||
(mod_item) @scope.namespace
|
||||
|
||||
;; Declarations — struct
|
||||
(struct_item
|
||||
name: (type_identifier) @declaration.name) @declaration.struct
|
||||
|
||||
;; Declarations — trait
|
||||
(trait_item
|
||||
name: (type_identifier) @declaration.name) @declaration.trait
|
||||
|
||||
;; Declarations — enum
|
||||
(enum_item
|
||||
name: (type_identifier) @declaration.name) @declaration.enum
|
||||
|
||||
;; Declarations — function (top-level or inside mod)
|
||||
(function_item
|
||||
name: (identifier) @declaration.name) @declaration.function
|
||||
|
||||
;; Declarations — struct fields
|
||||
(field_declaration
|
||||
name: (field_identifier) @declaration.name
|
||||
type: (_) @declaration.field-type) @declaration.field
|
||||
|
||||
;; Declarations — variables (let bindings)
|
||||
(let_declaration
|
||||
pattern: (identifier) @declaration.name) @declaration.variable
|
||||
|
||||
;; Declarations — const
|
||||
(const_item
|
||||
name: (identifier) @declaration.name) @declaration.const
|
||||
|
||||
;; Declarations — static
|
||||
(static_item
|
||||
name: (identifier) @declaration.name) @declaration.const
|
||||
|
||||
;; Imports
|
||||
(use_declaration) @import.statement
|
||||
|
||||
;; Type bindings — parameter annotations
|
||||
(parameter
|
||||
pattern: (identifier) @type-binding.name
|
||||
type: (_) @type-binding.type) @type-binding.parameter
|
||||
|
||||
;; Type bindings — let with type annotation
|
||||
(let_declaration
|
||||
pattern: (identifier) @type-binding.name
|
||||
type: (_) @type-binding.type) @type-binding.assignment
|
||||
|
||||
;; Type bindings — struct literal constructor inference
|
||||
(let_declaration
|
||||
pattern: (identifier) @type-binding.name
|
||||
value: (struct_expression
|
||||
name: (_) @type-binding.type)) @type-binding.constructor
|
||||
|
||||
;; Type bindings — call-return inference (let x = Foo::new())
|
||||
(let_declaration
|
||||
pattern: (identifier) @type-binding.name
|
||||
value: (call_expression
|
||||
function: (_) @type-binding.type)) @type-binding.call-return
|
||||
|
||||
;; Type bindings — call-return inference through .await (let x = foo().await)
|
||||
(let_declaration
|
||||
pattern: (identifier) @type-binding.name
|
||||
value: (await_expression
|
||||
(call_expression
|
||||
function: (_) @type-binding.type))) @type-binding.call-return
|
||||
|
||||
;; Type bindings — variable alias (let x = y)
|
||||
(let_declaration
|
||||
pattern: (identifier) @type-binding.name
|
||||
value: (identifier) @type-binding.type) @type-binding.alias
|
||||
|
||||
;; Type bindings — return type annotation
|
||||
(function_item
|
||||
name: (identifier) @type-binding.name
|
||||
return_type: (_) @type-binding.type) @type-binding.return
|
||||
|
||||
;; References — free calls
|
||||
(call_expression
|
||||
function: (identifier) @reference.name) @reference.call.free
|
||||
|
||||
;; References — member calls (obj.method())
|
||||
(call_expression
|
||||
function: (field_expression
|
||||
value: (_) @reference.receiver
|
||||
field: (field_identifier) @reference.name)) @reference.call.member
|
||||
|
||||
;; References — scoped calls (Foo::bar())
|
||||
(call_expression
|
||||
function: (scoped_identifier
|
||||
name: (identifier) @reference.name)) @reference.call.free
|
||||
|
||||
;; References — constructor calls (struct literal)
|
||||
(struct_expression
|
||||
name: (_) @reference.name) @reference.call.constructor
|
||||
|
||||
;; References — field reads
|
||||
(field_expression
|
||||
value: (_) @reference.receiver
|
||||
field: (field_identifier) @reference.name) @reference.read
|
||||
|
||||
;; References — field writes (assignment)
|
||||
(assignment_expression
|
||||
left: (field_expression
|
||||
value: (_) @reference.receiver
|
||||
field: (field_identifier) @reference.name)) @reference.write
|
||||
|
||||
;; References — field writes (compound assignment: +=, -=, etc.)
|
||||
(compound_assignment_expr
|
||||
left: (field_expression
|
||||
value: (_) @reference.receiver
|
||||
field: (field_identifier) @reference.name)) @reference.write
|
||||
`;
|
||||
|
||||
let _parser: Parser | null = null;
|
||||
let _query: Parser.Query | null = null;
|
||||
|
||||
export function getRustParser(): Parser {
|
||||
if (_parser === null) {
|
||||
_parser = new Parser();
|
||||
_parser.setLanguage(Rust as Parameters<Parser['setLanguage']>[0]);
|
||||
}
|
||||
return _parser;
|
||||
}
|
||||
|
||||
export function getRustScopeQuery(): Parser.Query {
|
||||
if (_query === null) {
|
||||
_query = new Parser.Query(Rust as Parameters<Parser['setLanguage']>[0], RUST_SCOPE_QUERY);
|
||||
}
|
||||
return _query;
|
||||
}
|
||||
@@ -1,708 +0,0 @@
|
||||
import type { ParsedFile, Scope, ScopeId, TypeRef } from 'gitnexus-shared';
|
||||
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
import { getRustParser } from './query.js';
|
||||
import { getTreeSitterBufferSize } from '../../constants.js';
|
||||
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
|
||||
/**
|
||||
* Populate type bindings for patterns and iterators that the tree-sitter
|
||||
* query can't handle (they need runtime type lookup, not just syntax).
|
||||
*
|
||||
* Covers: for-loop element types, if-let/while-let pattern bindings,
|
||||
* match arm patterns, and struct destructuring.
|
||||
*
|
||||
* Runs in Phase 2 (after propagateImportedReturnTypes) so all cross-file
|
||||
* type bindings are available for lookup.
|
||||
*/
|
||||
export function populateRustRangeBindings(
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
_indexes: ScopeResolutionIndexes,
|
||||
ctx: {
|
||||
readonly fileContents: ReadonlyMap<string, string>;
|
||||
readonly treeCache?: { get(filePath: string): unknown };
|
||||
},
|
||||
): void {
|
||||
const parser = getRustParser();
|
||||
const allReturnTypes = new Map<string, string>();
|
||||
const allFieldTypes = new Map<string, Map<string, string>>();
|
||||
|
||||
for (const parsed of parsedFiles) {
|
||||
const sourceText = ctx.fileContents.get(parsed.filePath);
|
||||
if (sourceText === undefined) continue;
|
||||
|
||||
const cachedTree = ctx.treeCache?.get(parsed.filePath);
|
||||
const tree =
|
||||
(cachedTree as ReturnType<typeof parser.parse> | undefined) ??
|
||||
parseSourceSafe(parser, sourceText, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(sourceText),
|
||||
});
|
||||
|
||||
for (const fn of tree.rootNode.descendantsOfType('function_item')) {
|
||||
const nameNode = fn.childForFieldName('name');
|
||||
const retType = fn.childForFieldName('return_type');
|
||||
if (nameNode !== null && retType !== null) {
|
||||
const name = nameNode.text;
|
||||
if (allReturnTypes.has(name)) {
|
||||
allReturnTypes.delete(name);
|
||||
} else {
|
||||
allReturnTypes.set(name, retType.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const structNode of tree.rootNode.descendantsOfType('struct_item')) {
|
||||
const nameNode = structNode.childForFieldName('name');
|
||||
const body = structNode.childForFieldName('body');
|
||||
if (nameNode === null || body === null) continue;
|
||||
const fields = new Map<string, string>();
|
||||
for (const field of body.descendantsOfType('field_declaration')) {
|
||||
const fieldName = field.childForFieldName('name');
|
||||
const fieldType = field.childForFieldName('type');
|
||||
if (fieldName !== null && fieldType !== null) {
|
||||
fields.set(fieldName.text, normalizeFieldType(fieldType.text));
|
||||
}
|
||||
}
|
||||
if (fields.size > 0) {
|
||||
const name = nameNode.text;
|
||||
if (allFieldTypes.has(name)) {
|
||||
allFieldTypes.delete(name);
|
||||
} else {
|
||||
allFieldTypes.set(name, fields);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const parsed of parsedFiles) {
|
||||
const sourceText = ctx.fileContents.get(parsed.filePath);
|
||||
if (sourceText === undefined) continue;
|
||||
|
||||
const cachedTree = ctx.treeCache?.get(parsed.filePath);
|
||||
const tree =
|
||||
(cachedTree as ReturnType<typeof parser.parse> | undefined) ??
|
||||
parseSourceSafe(parser, sourceText, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(sourceText),
|
||||
});
|
||||
|
||||
const scopeMap = new Map(parsed.scopes.map((s) => [s.id, s]));
|
||||
const moduleScope = parsed.scopes.find((s) => s.kind === 'Module');
|
||||
if (moduleScope === undefined) continue;
|
||||
|
||||
processFieldTypeBindings(tree.rootNode, parsed, scopeMap);
|
||||
processIdentityMethodBindings(parsed);
|
||||
processForLoops(tree.rootNode, parsed, scopeMap, moduleScope, allReturnTypes);
|
||||
processPatternBindings(tree.rootNode, parsed, scopeMap, moduleScope);
|
||||
processStructDestructuring(tree.rootNode, parsed, scopeMap, moduleScope, allFieldTypes);
|
||||
processPendingAssignments(
|
||||
tree.rootNode,
|
||||
parsed,
|
||||
parsedFiles,
|
||||
scopeMap,
|
||||
moduleScope,
|
||||
allReturnTypes,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function processFieldTypeBindings(
|
||||
root: SyntaxNode,
|
||||
parsed: ParsedFile,
|
||||
scopeMap: ReadonlyMap<string, Scope>,
|
||||
): void {
|
||||
for (const structNode of root.descendantsOfType('struct_item')) {
|
||||
const nameNode = structNode.childForFieldName('name');
|
||||
if (nameNode === null) continue;
|
||||
|
||||
const structScope = findScopeForNode(structNode, parsed, scopeMap);
|
||||
if (structScope === null) continue;
|
||||
|
||||
const body = structNode.childForFieldName('body');
|
||||
if (body === null) continue;
|
||||
|
||||
for (const field of body.descendantsOfType('field_declaration')) {
|
||||
const fieldName = field.childForFieldName('name');
|
||||
const fieldType = field.childForFieldName('type');
|
||||
if (fieldName === null || fieldType === null) continue;
|
||||
|
||||
const normalizedType = normalizeFieldType(fieldType.text);
|
||||
injectTypeBinding(structScope, fieldName.text, normalizedType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findScopeForNode(
|
||||
node: SyntaxNode,
|
||||
parsed: ParsedFile,
|
||||
scopeMap: ReadonlyMap<string, Scope>,
|
||||
): Scope | null {
|
||||
for (const scope of parsed.scopes) {
|
||||
if (
|
||||
scope.kind === 'Class' &&
|
||||
scope.range.startLine === node.startPosition.row + 1 &&
|
||||
scope.range.startCol === node.startPosition.column
|
||||
) {
|
||||
return scope;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeFieldType(text: string): string {
|
||||
let t = text.trim();
|
||||
if (t.startsWith('&')) t = t.replace(/^&\s*(mut\s+)?/, '');
|
||||
const bracket = t.indexOf('<');
|
||||
if (bracket !== -1) t = t.slice(0, bracket);
|
||||
const lastColon = t.lastIndexOf('::');
|
||||
if (lastColon !== -1) t = t.slice(lastColon + 2);
|
||||
return t.trim();
|
||||
}
|
||||
|
||||
function processForLoops(
|
||||
root: SyntaxNode,
|
||||
parsed: ParsedFile,
|
||||
scopeMap: ReadonlyMap<string, Scope>,
|
||||
moduleScope: Scope,
|
||||
allReturnTypes: ReadonlyMap<string, string>,
|
||||
): void {
|
||||
for (const forNode of root.descendantsOfType('for_expression')) {
|
||||
const patternNode = forNode.childForFieldName('pattern');
|
||||
const valueNode = forNode.childForFieldName('value');
|
||||
if (patternNode === null || valueNode === null) continue;
|
||||
|
||||
const varName = extractVarName(patternNode);
|
||||
if (varName === null) continue;
|
||||
|
||||
const elementType = resolveIterableElementType(
|
||||
valueNode,
|
||||
parsed,
|
||||
scopeMap,
|
||||
moduleScope,
|
||||
allReturnTypes,
|
||||
);
|
||||
if (elementType === null) continue;
|
||||
|
||||
const targetScope = findEnclosingFunctionScope(forNode, scopeMap) ?? moduleScope;
|
||||
injectTypeBinding(targetScope, varName, elementType);
|
||||
}
|
||||
}
|
||||
|
||||
function processPatternBindings(
|
||||
root: SyntaxNode,
|
||||
parsed: ParsedFile,
|
||||
scopeMap: ReadonlyMap<string, Scope>,
|
||||
moduleScope: Scope,
|
||||
): void {
|
||||
for (const nodeType of ['let_condition', 'match_arm'] as const) {
|
||||
for (const node of root.descendantsOfType(nodeType)) {
|
||||
const patternNode =
|
||||
nodeType === 'let_condition'
|
||||
? node.childForFieldName('pattern')
|
||||
: (node.childForFieldName('pattern')?.firstNamedChild ?? null);
|
||||
if (patternNode === null) continue;
|
||||
|
||||
if (patternNode.type === 'captured_pattern') {
|
||||
processCapturedPattern(patternNode, node, parsed, scopeMap, moduleScope);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (patternNode.type === 'tuple_struct_pattern') {
|
||||
processTupleStructPattern(patternNode, node, parsed, scopeMap, moduleScope);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function processCapturedPattern(
|
||||
patternNode: SyntaxNode,
|
||||
contextNode: SyntaxNode,
|
||||
_parsed: ParsedFile,
|
||||
scopeMap: ReadonlyMap<string, Scope>,
|
||||
moduleScope: Scope,
|
||||
): void {
|
||||
const varNode = patternNode.namedChildren.find((c) => c.type === 'identifier');
|
||||
const structPatternNode = patternNode.namedChildren.find((c) => c.type === 'struct_pattern');
|
||||
if (varNode === undefined || structPatternNode === undefined) return;
|
||||
|
||||
const typeName = structPatternNode.childForFieldName('type')?.text;
|
||||
if (typeName === undefined) return;
|
||||
|
||||
const targetScope = findEnclosingFunctionScope(contextNode, scopeMap) ?? moduleScope;
|
||||
injectTypeBinding(targetScope, varNode.text, typeName);
|
||||
}
|
||||
|
||||
function processTupleStructPattern(
|
||||
patternNode: SyntaxNode,
|
||||
contextNode: SyntaxNode,
|
||||
parsed: ParsedFile,
|
||||
scopeMap: ReadonlyMap<string, Scope>,
|
||||
moduleScope: Scope,
|
||||
): void {
|
||||
const wrapperNode = patternNode.childForFieldName('type');
|
||||
if (wrapperNode === null) return;
|
||||
const wrapper = wrapperNode.text;
|
||||
|
||||
const wrapperIdx = patternNode.namedChildren.indexOf(wrapperNode);
|
||||
const innerIdent = patternNode.namedChildren.find(
|
||||
(c, i) => i > wrapperIdx && c.type === 'identifier',
|
||||
);
|
||||
if (innerIdent === null || innerIdent === undefined) return;
|
||||
const varName = innerIdent.text;
|
||||
|
||||
let sourceVarNode: SyntaxNode | null = null;
|
||||
if (contextNode.type === 'let_condition') {
|
||||
sourceVarNode = contextNode.childForFieldName('value');
|
||||
} else {
|
||||
let matchExpr: SyntaxNode | null = contextNode.parent;
|
||||
while (matchExpr !== null && matchExpr.type !== 'match_expression') {
|
||||
matchExpr = matchExpr.parent;
|
||||
}
|
||||
sourceVarNode = matchExpr?.childForFieldName('value') ?? null;
|
||||
}
|
||||
if (sourceVarNode === null || sourceVarNode === undefined) return;
|
||||
|
||||
const sourceVarName = sourceVarNode.type === 'identifier' ? sourceVarNode.text : null;
|
||||
if (sourceVarName === null) return;
|
||||
|
||||
const sourceType = lookupTypeInScopes(sourceVarName, contextNode, parsed, scopeMap, moduleScope);
|
||||
if (sourceType === null) return;
|
||||
|
||||
let resolvedType: string | null = null;
|
||||
|
||||
if (wrapper === 'Some') {
|
||||
resolvedType = unwrapGeneric(sourceType);
|
||||
} else if (wrapper === 'Ok' || wrapper === 'Err') {
|
||||
const rawType = lookupRawParameterType(sourceVarName, contextNode);
|
||||
if (rawType !== null) {
|
||||
const argIdx = wrapper === 'Ok' ? 0 : 1;
|
||||
resolvedType = extractNthGenericArg(rawType, argIdx);
|
||||
}
|
||||
if (resolvedType === null) {
|
||||
resolvedType = wrapper === 'Ok' ? unwrapGeneric(sourceType) : null;
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedType === null) return;
|
||||
|
||||
const targetScope = findEnclosingFunctionScope(contextNode, scopeMap) ?? moduleScope;
|
||||
injectTypeBinding(targetScope, varName, resolvedType);
|
||||
}
|
||||
|
||||
function processStructDestructuring(
|
||||
root: SyntaxNode,
|
||||
parsed: ParsedFile,
|
||||
scopeMap: ReadonlyMap<string, Scope>,
|
||||
moduleScope: Scope,
|
||||
allFieldTypes?: ReadonlyMap<string, Map<string, string>>,
|
||||
): void {
|
||||
for (const letNode of root.descendantsOfType('let_declaration')) {
|
||||
const patternNode = letNode.childForFieldName('pattern');
|
||||
if (patternNode === null || patternNode.type !== 'struct_pattern') continue;
|
||||
|
||||
const typeName = patternNode.childForFieldName('type')?.text;
|
||||
if (typeName === undefined) continue;
|
||||
|
||||
const valueNode = letNode.childForFieldName('value');
|
||||
if (valueNode === null) continue;
|
||||
|
||||
const targetScope = findEnclosingFunctionScope(letNode, scopeMap) ?? moduleScope;
|
||||
|
||||
for (const fieldNode of patternNode.namedChildren) {
|
||||
let fieldName: string | undefined;
|
||||
if (fieldNode.type === 'field_pattern') {
|
||||
fieldName = fieldNode.childForFieldName('name')?.text;
|
||||
} else if (fieldNode.type === 'shorthand_field_pattern') {
|
||||
fieldName = fieldNode.firstNamedChild?.text;
|
||||
}
|
||||
if (fieldName === undefined) continue;
|
||||
|
||||
let fieldType = lookupFieldType(typeName, fieldName, parsed, scopeMap, moduleScope);
|
||||
if (fieldType === null) {
|
||||
fieldType = allFieldTypes?.get(typeName)?.get(fieldName) ?? null;
|
||||
}
|
||||
if (fieldType !== null) {
|
||||
injectTypeBinding(targetScope, fieldName, fieldType);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const IDENTITY_METHODS = ['unwrap', 'expect', 'clone', 'as_ref', 'as_mut'];
|
||||
|
||||
function processIdentityMethodBindings(parsed: ParsedFile): void {
|
||||
for (const scope of parsed.scopes) {
|
||||
if (scope.kind !== 'Class') continue;
|
||||
const classDef = scope.ownedDefs.find((d) => {
|
||||
const t = d.type;
|
||||
return t === 'Struct' || t === 'Enum' || t === 'Class';
|
||||
});
|
||||
if (classDef === undefined) continue;
|
||||
const name = classDef.qualifiedName?.split('.').pop();
|
||||
if (name === undefined) continue;
|
||||
|
||||
for (const method of IDENTITY_METHODS) {
|
||||
if (!scope.typeBindings.has(method)) {
|
||||
(scope.typeBindings as Map<string, TypeRef>).set(method, {
|
||||
rawName: name,
|
||||
declaredAtScope: scope.id,
|
||||
source: 'return-annotation',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function processPendingAssignments(
|
||||
root: SyntaxNode,
|
||||
parsed: ParsedFile,
|
||||
allParsedFiles: readonly ParsedFile[],
|
||||
scopeMap: ReadonlyMap<string, Scope>,
|
||||
moduleScope: Scope,
|
||||
allReturnTypes: ReadonlyMap<string, string>,
|
||||
): void {
|
||||
for (let pass = 0; pass < 3; pass++) {
|
||||
for (const letNode of root.descendantsOfType('let_declaration')) {
|
||||
const patternNode = letNode.childForFieldName('pattern');
|
||||
if (patternNode === null) continue;
|
||||
const varName = extractVarName(patternNode);
|
||||
if (varName === null) continue;
|
||||
|
||||
const targetScope = findEnclosingFunctionScope(letNode, scopeMap) ?? moduleScope;
|
||||
if (targetScope.typeBindings.has(varName)) continue;
|
||||
|
||||
const valueNode = letNode.childForFieldName('value');
|
||||
if (valueNode === null) continue;
|
||||
|
||||
if (valueNode.type === 'identifier') {
|
||||
const rhsType = lookupTypeInScopes(valueNode.text, letNode, parsed, scopeMap, moduleScope);
|
||||
if (rhsType !== null) {
|
||||
injectTypeBinding(targetScope, varName, rhsType);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (valueNode.type === 'field_expression') {
|
||||
const receiver = valueNode.childForFieldName('value');
|
||||
const field = valueNode.childForFieldName('field');
|
||||
if (receiver !== null && field !== null && receiver.type === 'identifier') {
|
||||
const receiverType = lookupTypeInScopes(
|
||||
receiver.text,
|
||||
letNode,
|
||||
parsed,
|
||||
scopeMap,
|
||||
moduleScope,
|
||||
);
|
||||
if (receiverType !== null) {
|
||||
const fieldType = findFieldTypeAcrossFiles(receiverType, field.text, allParsedFiles);
|
||||
if (fieldType !== null) {
|
||||
injectTypeBinding(targetScope, varName, fieldType);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (valueNode.type === 'call_expression') {
|
||||
const func = valueNode.childForFieldName('function');
|
||||
if (func !== null && func.type === 'field_expression') {
|
||||
const receiver = func.childForFieldName('value');
|
||||
const method = func.childForFieldName('field');
|
||||
if (receiver !== null && method !== null && receiver.type === 'identifier') {
|
||||
const receiverType = lookupTypeInScopes(
|
||||
receiver.text,
|
||||
letNode,
|
||||
parsed,
|
||||
scopeMap,
|
||||
moduleScope,
|
||||
);
|
||||
if (receiverType !== null) {
|
||||
const retType = findMethodReturnTypeAcrossFiles(
|
||||
receiverType,
|
||||
method.text,
|
||||
allParsedFiles,
|
||||
);
|
||||
if (retType !== null) {
|
||||
injectTypeBinding(targetScope, varName, retType);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (func !== null && func.type === 'identifier') {
|
||||
const rawReturn = allReturnTypes.get(func.text);
|
||||
if (rawReturn !== undefined) {
|
||||
injectTypeBinding(targetScope, varName, normalizeFieldType(rawReturn));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveIterableElementType(
|
||||
valueNode: SyntaxNode,
|
||||
parsed: ParsedFile,
|
||||
scopeMap: ReadonlyMap<string, Scope>,
|
||||
moduleScope: Scope,
|
||||
allReturnTypes?: ReadonlyMap<string, string>,
|
||||
): string | null {
|
||||
let iterableNode = valueNode;
|
||||
if (iterableNode.type === 'reference_expression') {
|
||||
iterableNode = iterableNode.firstNamedChild ?? iterableNode;
|
||||
}
|
||||
|
||||
if (iterableNode.type === 'identifier') {
|
||||
const rawType = lookupTypeInScopes(iterableNode.text, valueNode, parsed, scopeMap, moduleScope);
|
||||
if (rawType !== null) return unwrapGeneric(rawType);
|
||||
}
|
||||
|
||||
if (iterableNode.type === 'call_expression') {
|
||||
const func = iterableNode.childForFieldName('function');
|
||||
if (func === null) return null;
|
||||
|
||||
if (func.type === 'field_expression') {
|
||||
const receiver = func.childForFieldName('value');
|
||||
if (receiver !== null && receiver.type === 'identifier') {
|
||||
const rawType = lookupTypeInScopes(receiver.text, valueNode, parsed, scopeMap, moduleScope);
|
||||
if (rawType !== null) return unwrapGeneric(rawType);
|
||||
}
|
||||
}
|
||||
|
||||
if (func.type === 'identifier') {
|
||||
const crossFileReturn = allReturnTypes?.get(func.text);
|
||||
if (crossFileReturn !== undefined) return unwrapGeneric(crossFileReturn);
|
||||
const rawReturn = lookupRawFunctionReturnType(func.text, valueNode);
|
||||
if (rawReturn !== null) return unwrapGeneric(rawReturn);
|
||||
const returnType = lookupReturnTypeInScopes(func.text, parsed, scopeMap, moduleScope);
|
||||
if (returnType !== null) return unwrapGeneric(returnType);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function findFieldTypeAcrossFiles(
|
||||
structName: string,
|
||||
fieldName: string,
|
||||
allParsedFiles: readonly ParsedFile[],
|
||||
): string | null {
|
||||
for (const pf of allParsedFiles) {
|
||||
for (const scope of pf.scopes) {
|
||||
if (scope.kind !== 'Class') continue;
|
||||
const hasDef = scope.ownedDefs.some(
|
||||
(d) => d.qualifiedName === structName || d.qualifiedName?.endsWith('.' + structName),
|
||||
);
|
||||
if (!hasDef) continue;
|
||||
const tb = scope.typeBindings.get(fieldName);
|
||||
if (tb !== undefined) return tb.rawName;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findMethodReturnTypeAcrossFiles(
|
||||
structName: string,
|
||||
methodName: string,
|
||||
allParsedFiles: readonly ParsedFile[],
|
||||
): string | null {
|
||||
for (const pf of allParsedFiles) {
|
||||
for (const scope of pf.scopes) {
|
||||
if (scope.kind !== 'Class') continue;
|
||||
const hasDef = scope.ownedDefs.some(
|
||||
(d) => d.qualifiedName === structName || d.qualifiedName?.endsWith('.' + structName),
|
||||
);
|
||||
if (!hasDef) continue;
|
||||
const tb = scope.typeBindings.get(methodName);
|
||||
if (tb !== undefined && tb.source === 'return-annotation') return tb.rawName;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function lookupRawFunctionReturnType(funcName: string, contextNode: SyntaxNode): string | null {
|
||||
let root: SyntaxNode = contextNode;
|
||||
while (root.parent !== null) root = root.parent;
|
||||
for (const fn of root.descendantsOfType('function_item')) {
|
||||
const nameNode = fn.childForFieldName('name');
|
||||
if (nameNode !== null && nameNode.text === funcName) {
|
||||
const retType = fn.childForFieldName('return_type');
|
||||
if (retType !== null) return retType.text;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function lookupRawParameterType(paramName: string, contextNode: SyntaxNode): string | null {
|
||||
let current: SyntaxNode | null = contextNode;
|
||||
while (current !== null) {
|
||||
if (current.type === 'function_item') {
|
||||
const params = current.childForFieldName('parameters');
|
||||
if (params !== null) {
|
||||
for (let i = 0; i < params.namedChildCount; i++) {
|
||||
const param = params.namedChild(i);
|
||||
if (param === null || param.type !== 'parameter') continue;
|
||||
const pattern = param.childForFieldName('pattern');
|
||||
const typeNode = param.childForFieldName('type');
|
||||
if (pattern !== null && typeNode !== null && pattern.text === paramName) {
|
||||
return typeNode.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
current = current.parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function lookupTypeInScopes(
|
||||
name: string,
|
||||
contextNode: SyntaxNode,
|
||||
parsed: ParsedFile,
|
||||
scopeMap: ReadonlyMap<string, Scope>,
|
||||
moduleScope: Scope,
|
||||
): string | null {
|
||||
const fnScope = findEnclosingFunctionScope(contextNode, scopeMap);
|
||||
if (fnScope !== null) {
|
||||
const tb = fnScope.typeBindings.get(name);
|
||||
if (tb !== undefined) return tb.rawName;
|
||||
}
|
||||
|
||||
const mtb = moduleScope.typeBindings.get(name);
|
||||
if (mtb !== undefined) return mtb.rawName;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function lookupReturnTypeInScopes(
|
||||
funcName: string,
|
||||
parsed: ParsedFile,
|
||||
_scopeMap: ReadonlyMap<string, Scope>,
|
||||
moduleScope: Scope,
|
||||
): string | null {
|
||||
const tb = moduleScope.typeBindings.get(funcName);
|
||||
if (tb !== undefined && tb.source === 'return-annotation') return tb.rawName;
|
||||
|
||||
for (const scope of parsed.scopes) {
|
||||
const stb = scope.typeBindings.get(funcName);
|
||||
if (stb !== undefined && stb.source === 'return-annotation') return stb.rawName;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function lookupFieldType(
|
||||
structName: string,
|
||||
fieldName: string,
|
||||
parsed: ParsedFile,
|
||||
_scopeMap: ReadonlyMap<string, Scope>,
|
||||
_moduleScope: Scope,
|
||||
): string | null {
|
||||
for (const scope of parsed.scopes) {
|
||||
if (scope.kind !== 'Class') continue;
|
||||
const hasDef = scope.ownedDefs.some(
|
||||
(d) => d.qualifiedName === structName || d.qualifiedName?.endsWith('.' + structName),
|
||||
);
|
||||
if (!hasDef) continue;
|
||||
|
||||
const tb = scope.typeBindings.get(fieldName);
|
||||
if (tb !== undefined) return tb.rawName;
|
||||
|
||||
for (const def of scope.ownedDefs) {
|
||||
const defName = def.qualifiedName?.split('.').pop();
|
||||
if (def.type === 'Property' && defName === fieldName) {
|
||||
const tb = scope.typeBindings.get(fieldName);
|
||||
if (tb !== undefined) return tb.rawName;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function unwrapGeneric(rawType: string): string {
|
||||
const match = rawType.match(/^(?:Vec|Option|Arc|Rc|Box|Mutex|RwLock|RefCell|Cell)<(.+)>$/);
|
||||
if (match) {
|
||||
const inner = match[1].trim();
|
||||
const comma = findTopLevelComma(inner);
|
||||
return comma === -1 ? inner : inner.slice(0, comma).trim();
|
||||
}
|
||||
if (rawType.startsWith('&[') && rawType.endsWith(']')) {
|
||||
return rawType.slice(2, -1).trim();
|
||||
}
|
||||
return rawType;
|
||||
}
|
||||
|
||||
function extractNthGenericArg(rawType: string, n: number): string | null {
|
||||
const open = rawType.indexOf('<');
|
||||
if (open === -1) return null;
|
||||
const close = rawType.lastIndexOf('>');
|
||||
if (close === -1) return null;
|
||||
const inner = rawType.slice(open + 1, close).trim();
|
||||
const args = splitTopLevelComma(inner);
|
||||
return n < args.length ? args[n].trim() : null;
|
||||
}
|
||||
|
||||
function splitTopLevelComma(text: string): string[] {
|
||||
const parts: string[] = [];
|
||||
let depth = 0;
|
||||
let start = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
if (text[i] === '<') depth++;
|
||||
else if (text[i] === '>') depth--;
|
||||
else if (text[i] === ',' && depth === 0) {
|
||||
parts.push(text.slice(start, i));
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
parts.push(text.slice(start));
|
||||
return parts;
|
||||
}
|
||||
|
||||
function findTopLevelComma(text: string): number {
|
||||
let depth = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
if (text[i] === '<') depth++;
|
||||
else if (text[i] === '>') depth--;
|
||||
else if (text[i] === ',' && depth === 0) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function extractVarName(node: SyntaxNode): string | null {
|
||||
if (node.type === 'identifier') return node.text;
|
||||
if (node.type === 'mut_pattern' || node.type === 'reference_pattern') {
|
||||
return node.firstNamedChild?.text ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function injectTypeBinding(scope: Scope, name: string, typeName: string): void {
|
||||
if (scope.typeBindings.has(name)) return;
|
||||
(scope.typeBindings as Map<string, TypeRef>).set(name, {
|
||||
rawName: typeName,
|
||||
declaredAtScope: scope.id,
|
||||
source: 'annotation',
|
||||
});
|
||||
}
|
||||
|
||||
function findEnclosingFunctionScope(
|
||||
node: SyntaxNode,
|
||||
scopeMap: ReadonlyMap<ScopeId, Scope>,
|
||||
): Scope | null {
|
||||
let current: SyntaxNode | null = node as SyntaxNode;
|
||||
while (current !== null) {
|
||||
if (current.type === 'function_item') {
|
||||
for (const scope of scopeMap.values()) {
|
||||
if (
|
||||
scope.kind === 'Function' &&
|
||||
scope.range.startLine === current.startPosition.row + 1 &&
|
||||
scope.range.startCol === current.startPosition.column
|
||||
) {
|
||||
return scope;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
current = current.parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
import type { CaptureMatch } from 'gitnexus-shared';
|
||||
import { syntheticCapture } from '../../utils/ast-helpers.js';
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
|
||||
/**
|
||||
* Given a function_item node that is inside an impl_item, synthesize a
|
||||
* self-type-binding capture if the function has a `self_parameter`.
|
||||
*
|
||||
* The impl_item structure:
|
||||
* impl [TraitName for] TypeName { fn method(&self) { ... } }
|
||||
*/
|
||||
export function synthesizeRustReceiverBinding(
|
||||
fnNode: SyntaxNode,
|
||||
implNode: SyntaxNode | null,
|
||||
): CaptureMatch | null {
|
||||
if (fnNode.type !== 'function_item') return null;
|
||||
if (implNode === null) return null;
|
||||
|
||||
const params = fnNode.childForFieldName('parameters');
|
||||
if (params === null) return null;
|
||||
|
||||
let hasSelf = false;
|
||||
for (let i = 0; i < params.namedChildCount; i++) {
|
||||
if (params.namedChild(i)?.type === 'self_parameter') {
|
||||
hasSelf = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!hasSelf) return null;
|
||||
|
||||
const implType = getImplTargetType(implNode);
|
||||
if (implType === 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, implType),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the target type from an impl_item.
|
||||
* `impl TypeName { ... }` → "TypeName"
|
||||
* `impl TraitName for TypeName { ... }` → "TypeName"
|
||||
*/
|
||||
export function getImplTargetType(implNode: SyntaxNode): string | null {
|
||||
if (implNode.type !== 'impl_item') return null;
|
||||
|
||||
// Look for `for` keyword — if present, impl is `impl Trait for Type`
|
||||
let hasFor = false;
|
||||
let typeAfterFor: SyntaxNode | null = null;
|
||||
for (let i = 0; i < implNode.childCount; i++) {
|
||||
const child = implNode.child(i);
|
||||
if (child === null) continue;
|
||||
if (child.type === 'for') {
|
||||
hasFor = true;
|
||||
continue;
|
||||
}
|
||||
if (hasFor && child.type === 'type_identifier') {
|
||||
typeAfterFor = child;
|
||||
break;
|
||||
}
|
||||
if (hasFor && child.type === 'scoped_type_identifier') {
|
||||
typeAfterFor = child;
|
||||
break;
|
||||
}
|
||||
if (hasFor && child.type === 'generic_type') {
|
||||
typeAfterFor = child;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hasFor && typeAfterFor !== null) {
|
||||
return normalizeRustTypeName(typeAfterFor.text);
|
||||
}
|
||||
|
||||
// No `for` keyword: impl TypeName { ... }
|
||||
const typeField = implNode.childForFieldName('type');
|
||||
if (typeField !== null) {
|
||||
return normalizeRustTypeName(typeField.text);
|
||||
}
|
||||
|
||||
// Fallback: find first type_identifier after `impl`
|
||||
let afterImpl = false;
|
||||
for (let i = 0; i < implNode.childCount; i++) {
|
||||
const child = implNode.child(i);
|
||||
if (child === null) continue;
|
||||
if (child.type === 'impl') {
|
||||
afterImpl = true;
|
||||
continue;
|
||||
}
|
||||
if (afterImpl && (child.type === 'type_identifier' || child.type === 'generic_type')) {
|
||||
return normalizeRustTypeName(child.text);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the trait name from an impl_item when it's `impl Trait for Type`.
|
||||
*/
|
||||
export function getImplTraitName(implNode: SyntaxNode): string | null {
|
||||
if (implNode.type !== 'impl_item') return null;
|
||||
|
||||
let afterImpl = false;
|
||||
for (let i = 0; i < implNode.childCount; i++) {
|
||||
const child = implNode.child(i);
|
||||
if (child === null) continue;
|
||||
if (child.type === 'impl') {
|
||||
afterImpl = true;
|
||||
continue;
|
||||
}
|
||||
if (child.type === 'for') {
|
||||
break;
|
||||
}
|
||||
if (
|
||||
afterImpl &&
|
||||
(child.type === 'type_identifier' || child.type === 'scoped_type_identifier')
|
||||
) {
|
||||
for (let j = i + 1; j < implNode.childCount; j++) {
|
||||
const next = implNode.child(j);
|
||||
if (next === null) continue;
|
||||
if (next.type === 'for') {
|
||||
return normalizeRustTypeName(child.text);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeRustTypeName(text: string): string {
|
||||
let t = text.trim();
|
||||
while (t.startsWith('&')) t = t.replace(/^&\s*(mut\s+)?/, '');
|
||||
while (t.startsWith('*')) t = t.slice(1).trim();
|
||||
const bracket = t.indexOf('<');
|
||||
if (bracket !== -1) t = t.slice(0, bracket);
|
||||
return t.trim();
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import type { ParsedFile } 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 { rustProvider } from '../rust.js';
|
||||
import { rustArityCompatibility, rustMergeBindings, resolveRustImportTarget } from './index.js';
|
||||
import { populateRustOwners } from './method-owners.js';
|
||||
import { populateRustRangeBindings } from './range-binding.js';
|
||||
import { isClassLike } from '../../scope-resolution/scope/walkers.js';
|
||||
import { resolveDefGraphId } from '../../scope-resolution/graph-bridge/ids.js';
|
||||
|
||||
function buildRustMro(
|
||||
graph: Parameters<ScopeResolver['buildMro']>[0],
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
nodeLookup: Parameters<ScopeResolver['buildMro']>[2],
|
||||
): Map<string, string[]> {
|
||||
const baseMro = buildMro(graph, parsedFiles, nodeLookup, defaultLinearize);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
const fileByDefId = new Map<string, string>();
|
||||
for (const parsed of parsedFiles) {
|
||||
for (const def of parsed.localDefs) {
|
||||
fileByDefId.set(def.nodeId, parsed.filePath);
|
||||
}
|
||||
}
|
||||
|
||||
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 childFile = fileByDefId.get(childDefId);
|
||||
const parentFile = fileByDefId.get(parentDefId);
|
||||
if (childFile !== parentFile) continue;
|
||||
|
||||
const existing = baseMro.get(childDefId);
|
||||
if (existing !== undefined) {
|
||||
if (!existing.includes(parentDefId)) existing.push(parentDefId);
|
||||
} else {
|
||||
baseMro.set(childDefId, [parentDefId]);
|
||||
}
|
||||
}
|
||||
|
||||
return baseMro;
|
||||
}
|
||||
|
||||
export const rustScopeResolver: ScopeResolver = {
|
||||
language: SupportedLanguages.Rust,
|
||||
languageProvider: rustProvider,
|
||||
importEdgeReason: 'rust-scope: use',
|
||||
|
||||
resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) =>
|
||||
resolveRustImportTarget(targetRaw, fromFile, allFilePaths, resolutionConfig),
|
||||
|
||||
mergeBindings: (existing, incoming, scopeId) => rustMergeBindings(existing, incoming, scopeId),
|
||||
|
||||
arityCompatibility: (callsite, def) => rustArityCompatibility(def, callsite),
|
||||
|
||||
buildMro: (graph, parsedFiles, nodeLookup) => buildRustMro(graph, parsedFiles, nodeLookup),
|
||||
|
||||
populateOwners: (parsed: ParsedFile) => populateRustOwners(parsed),
|
||||
|
||||
isSuperReceiver: () => false,
|
||||
|
||||
populateRangeBindings: populateRustRangeBindings,
|
||||
|
||||
fieldFallbackOnMethodLookup: false,
|
||||
hoistTypeBindingsToModule: true,
|
||||
propagatesReturnTypesAcrossImports: true,
|
||||
allowGlobalFreeCallFallback: true,
|
||||
};
|
||||
@@ -1,44 +0,0 @@
|
||||
import type {
|
||||
CaptureMatch,
|
||||
ParsedImport,
|
||||
Scope,
|
||||
ScopeId,
|
||||
ScopeTree,
|
||||
TypeRef,
|
||||
} from 'gitnexus-shared';
|
||||
|
||||
export function rustBindingScopeFor(
|
||||
decl: CaptureMatch,
|
||||
innermost: Scope,
|
||||
_tree: ScopeTree,
|
||||
): ScopeId | null {
|
||||
// Keep self typeBindings in the method's Function scope so
|
||||
// populateRustOwners can match Method defs to their receiver types.
|
||||
if (decl['@type-binding.self'] !== undefined) {
|
||||
return innermost.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rust `use` statements inside a function body should attach at function scope,
|
||||
* not module scope. If the innermost scope is a Function, attach there.
|
||||
*/
|
||||
export function rustImportOwningScope(
|
||||
_imp: ParsedImport,
|
||||
innermost: Scope,
|
||||
_tree: ScopeTree,
|
||||
): ScopeId | null {
|
||||
if (innermost.kind === 'Function') {
|
||||
return innermost.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function rustReceiverBinding(functionScope: Scope): TypeRef | null {
|
||||
if (functionScope.kind !== 'Function') return null;
|
||||
for (const binding of functionScope.typeBindings.values()) {
|
||||
if (binding.source === 'self') return binding;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -32,13 +32,13 @@ export const communitiesPhase: PipelinePhase<CommunitiesOutput> = {
|
||||
|
||||
ctx.onProgress({
|
||||
phase: 'communities',
|
||||
percent: 98,
|
||||
percent: 84,
|
||||
message: 'Detecting code communities...',
|
||||
stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: ctx.graph.nodeCount },
|
||||
});
|
||||
|
||||
const communityResult = await processCommunities(ctx.graph, (message, progress) => {
|
||||
const communityProgress = 98 + progress * 0.01;
|
||||
const communityProgress = 84 + progress * 0.09;
|
||||
ctx.onProgress({
|
||||
phase: 'communities',
|
||||
percent: Math.round(communityProgress),
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Computes Method Resolution Order (MRO) and creates METHOD_OVERRIDES
|
||||
* and METHOD_IMPLEMENTS edges.
|
||||
*
|
||||
* @deps crossFile, scopeResolution
|
||||
* @deps crossFile
|
||||
* @reads graph (all nodes and relationships)
|
||||
* @writes graph (METHOD_OVERRIDES, METHOD_IMPLEMENTS edges)
|
||||
*/
|
||||
@@ -25,7 +25,7 @@ export interface MROOutput {
|
||||
|
||||
export const mroPhase: PipelinePhase<MROOutput> = {
|
||||
name: 'mro',
|
||||
deps: ['crossFile', 'scopeResolution', 'structure'],
|
||||
deps: ['crossFile', 'structure'],
|
||||
|
||||
async execute(
|
||||
ctx: PipelineContext,
|
||||
@@ -35,7 +35,7 @@ export const mroPhase: PipelinePhase<MROOutput> = {
|
||||
|
||||
ctx.onProgress({
|
||||
phase: 'enriching',
|
||||
percent: 98,
|
||||
percent: 83,
|
||||
message: 'Computing method resolution order...',
|
||||
stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: ctx.graph.nodeCount },
|
||||
});
|
||||
|
||||
@@ -41,7 +41,7 @@ export const processesPhase: PipelinePhase<ProcessesOutput> = {
|
||||
|
||||
ctx.onProgress({
|
||||
phase: 'processes',
|
||||
percent: 99,
|
||||
percent: 94,
|
||||
message: 'Detecting execution flows...',
|
||||
stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: ctx.graph.nodeCount },
|
||||
});
|
||||
@@ -56,7 +56,7 @@ export const processesPhase: PipelinePhase<ProcessesOutput> = {
|
||||
ctx.graph,
|
||||
communityResult.memberships,
|
||||
(message, progress) => {
|
||||
const processProgress = 99 + progress * 0.01;
|
||||
const processProgress = 94 + progress * 0.05;
|
||||
ctx.onProgress({
|
||||
phase: 'processes',
|
||||
percent: Math.round(processProgress),
|
||||
|
||||
@@ -78,8 +78,6 @@ export const MIGRATED_LANGUAGES: ReadonlySet<SupportedLanguages> = new Set<Suppo
|
||||
SupportedLanguages.JavaScript,
|
||||
SupportedLanguages.Kotlin,
|
||||
SupportedLanguages.Java,
|
||||
SupportedLanguages.Rust,
|
||||
SupportedLanguages.Ruby,
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -437,28 +437,6 @@ 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
|
||||
@@ -727,7 +705,6 @@ export interface ScopeResolver {
|
||||
callerScope: ScopeId,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
callsite?: Callsite,
|
||||
) => SymbolDefinition | 'ambiguous' | undefined;
|
||||
|
||||
/**
|
||||
|
||||
@@ -30,14 +30,9 @@ import {
|
||||
} from '../scope/walkers.js';
|
||||
|
||||
/** Max depth for compound-receiver chain resolution (`a().b().c().d()`).
|
||||
* 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;
|
||||
* Practical code rarely exceeds 3-4 hops; the cap prevents
|
||||
* pathological recursion if the receiver text is malformed. */
|
||||
const COMPOUND_RECEIVER_MAX_DEPTH = 4;
|
||||
|
||||
const MAP_TUPLE_SENTINEL_RE = /^__MAP_TUPLE_(\d+)__:(.+)$/;
|
||||
|
||||
@@ -149,23 +144,6 @@ 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);
|
||||
}
|
||||
|
||||
@@ -501,7 +501,6 @@ export function emitReceiverBoundCalls(
|
||||
site.inScope,
|
||||
scopes,
|
||||
parsedFiles,
|
||||
site,
|
||||
);
|
||||
if (memberDef === 'ambiguous') {
|
||||
// Same-name ambiguity across inline-namespace children (#1564):
|
||||
@@ -620,21 +619,15 @@ 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 !== undefined && typeRef.rawName.includes('.') && !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. For rawNames that
|
||||
// already contain `()` (Ruby member-call-return captures),
|
||||
// pass through directly — the compound resolver handles the
|
||||
// full expression including the call syntax.
|
||||
// the last segment as a method invocation.
|
||||
let ownerDef = resolveCompoundReceiverClass(
|
||||
typeRef.rawName,
|
||||
typeRef.declaredAtScope,
|
||||
@@ -642,7 +635,7 @@ export function emitReceiverBoundCalls(
|
||||
index,
|
||||
compoundOpts,
|
||||
);
|
||||
if (ownerDef === undefined && !typeRef.rawName.includes('(')) {
|
||||
if (ownerDef === undefined) {
|
||||
ownerDef = resolveCompoundReceiverClass(
|
||||
typeRef.rawName + '()',
|
||||
typeRef.declaredAtScope,
|
||||
|
||||
@@ -34,7 +34,7 @@ import type { ParseOutput } from '../../pipeline-phases/parse.js';
|
||||
import { isRegistryPrimary } from '../../registry-primary-flag.js';
|
||||
import { SupportedLanguages, getLanguageFromFilename } from 'gitnexus-shared';
|
||||
import { readFileContents } from '../../filesystem-walker.js';
|
||||
import { runScopeResolution, type ScopeResolutionSubPhase } from './run.js';
|
||||
import { runScopeResolution } from './run.js';
|
||||
import { SCOPE_RESOLVERS } from './registry.js';
|
||||
import { isDev, isSemanticModelValidatorEnabled } from '../../utils/env.js';
|
||||
import type { ResolutionOutcome } from '../resolution-outcome.js';
|
||||
@@ -130,31 +130,6 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
|
||||
}
|
||||
>();
|
||||
|
||||
// Pre-count files and languages for progress reporting. This avoids
|
||||
// a frozen progress bar during long scope-resolution runs (#1741).
|
||||
let totalScopeFiles = 0;
|
||||
let totalScopeLangs = 0;
|
||||
for (const [lang] of SCOPE_RESOLVERS) {
|
||||
if (!isRegistryPrimary(lang)) continue;
|
||||
const count = scannedFiles.filter((f) => getLanguageFromFilename(f.path) === lang).length;
|
||||
if (count > 0) {
|
||||
totalScopeLangs++;
|
||||
totalScopeFiles += count;
|
||||
}
|
||||
}
|
||||
const SCOPE_PCT_START = 90;
|
||||
const SCOPE_PCT_RANGE = 8; // 90-98 internal → 54-59% display
|
||||
let processedScopeFiles = 0;
|
||||
let currentLangIdx = 0;
|
||||
|
||||
if (totalScopeFiles > 0) {
|
||||
ctx.onProgress({
|
||||
phase: 'scopeResolution',
|
||||
percent: SCOPE_PCT_START,
|
||||
message: 'Resolving types',
|
||||
});
|
||||
}
|
||||
|
||||
for (const [lang, provider] of SCOPE_RESOLVERS) {
|
||||
if (!isRegistryPrimary(lang)) continue;
|
||||
|
||||
@@ -178,23 +153,6 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
|
||||
? await provider.loadResolutionConfig(ctx.repoPath)
|
||||
: undefined;
|
||||
|
||||
const langFileCount = files.length;
|
||||
const langLabel = lang.charAt(0).toUpperCase() + lang.slice(1);
|
||||
currentLangIdx++;
|
||||
const langTag =
|
||||
totalScopeLangs > 1 ? `${langLabel} [${currentLangIdx}/${totalScopeLangs}]` : langLabel;
|
||||
|
||||
if (totalScopeFiles > 0) {
|
||||
const pct =
|
||||
SCOPE_PCT_START + Math.round((processedScopeFiles / totalScopeFiles) * SCOPE_PCT_RANGE);
|
||||
ctx.onProgress({
|
||||
phase: 'scopeResolution',
|
||||
percent: pct,
|
||||
message: 'Resolving types',
|
||||
detail: `${langTag}, ${langFileCount.toLocaleString()} files`,
|
||||
});
|
||||
}
|
||||
|
||||
const stats = runScopeResolution(
|
||||
{
|
||||
graph: ctx.graph,
|
||||
@@ -211,44 +169,6 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
|
||||
logger.warn(`[scope-resolution:${lang}] ${msg}`);
|
||||
}
|
||||
},
|
||||
onProgress:
|
||||
totalScopeFiles > 0
|
||||
? (subPhase: ScopeResolutionSubPhase, current, total) => {
|
||||
let langRatio: number;
|
||||
switch (subPhase) {
|
||||
case 'extracting':
|
||||
langRatio = total > 0 ? (current / total) * 0.5 : 0;
|
||||
break;
|
||||
case 'analyzing types':
|
||||
langRatio = 0.5;
|
||||
break;
|
||||
case 'resolving references':
|
||||
langRatio = 0.7;
|
||||
break;
|
||||
case 'linking symbols':
|
||||
langRatio = 0.85;
|
||||
break;
|
||||
default: {
|
||||
const _exhaustive: never = subPhase;
|
||||
langRatio = 0.85;
|
||||
}
|
||||
}
|
||||
const overallRatio = Math.min(
|
||||
1,
|
||||
(processedScopeFiles + langRatio * langFileCount) / totalScopeFiles,
|
||||
);
|
||||
const pct = SCOPE_PCT_START + Math.round(overallRatio * SCOPE_PCT_RANGE);
|
||||
ctx.onProgress({
|
||||
phase: 'scopeResolution',
|
||||
percent: pct,
|
||||
message: 'Resolving types',
|
||||
detail:
|
||||
subPhase === 'extracting'
|
||||
? `${langTag} — extracting ${current.toLocaleString()}/${total.toLocaleString()} files`
|
||||
: `${langTag} — ${subPhase}`,
|
||||
});
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
provider,
|
||||
);
|
||||
@@ -263,7 +183,6 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
|
||||
preExtractedByPath.delete(fp);
|
||||
}
|
||||
|
||||
processedScopeFiles += langFileCount;
|
||||
anyRan = true;
|
||||
totalFiles += stats.filesProcessed;
|
||||
totalImports += stats.importsEmitted;
|
||||
@@ -281,15 +200,6 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
|
||||
}
|
||||
}
|
||||
|
||||
if (totalScopeFiles > 0 && anyRan) {
|
||||
ctx.onProgress({
|
||||
phase: 'scopeResolution',
|
||||
percent: SCOPE_PCT_START + SCOPE_PCT_RANGE,
|
||||
message: 'Resolving types',
|
||||
detail: 'complete',
|
||||
});
|
||||
}
|
||||
|
||||
// Dispose the cross-phase Tree cache — scope-resolution is the
|
||||
// only consumer. Holding Trees past this point is pure memory
|
||||
// pressure: downstream phases (mro, community, csv-generator)
|
||||
|
||||
@@ -19,10 +19,8 @@ import { javaScopeResolver } from '../../languages/java/scope-resolver.js';
|
||||
import { cScopeResolver } from '../../languages/c/scope-resolver.js';
|
||||
import { cppScopeResolver } from '../../languages/cpp/scope-resolver.js';
|
||||
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';
|
||||
|
||||
/** Map of `SupportedLanguages` → `ScopeResolver`. The phase iterates
|
||||
* this map intersected with `MIGRATED_LANGUAGES` (the per-language
|
||||
@@ -40,8 +38,6 @@ export const SCOPE_RESOLVERS: ReadonlyMap<SupportedLanguages, ScopeResolver> = n
|
||||
[SupportedLanguages.C, cScopeResolver],
|
||||
[SupportedLanguages.CPlusPlus, cppScopeResolver],
|
||||
[SupportedLanguages.PHP, phpScopeResolver],
|
||||
[SupportedLanguages.Rust, rustScopeResolver],
|
||||
[SupportedLanguages.JavaScript, javascriptScopeResolver],
|
||||
[SupportedLanguages.Kotlin, kotlinScopeResolver],
|
||||
[SupportedLanguages.Ruby, rubyScopeResolver],
|
||||
]);
|
||||
|
||||
@@ -116,12 +116,6 @@ function preEmitInheritanceEdges(
|
||||
return handledSites;
|
||||
}
|
||||
|
||||
export type ScopeResolutionSubPhase =
|
||||
| 'extracting'
|
||||
| 'analyzing types'
|
||||
| 'resolving references'
|
||||
| 'linking symbols';
|
||||
|
||||
interface RunScopeResolutionInput {
|
||||
readonly graph: KnowledgeGraph;
|
||||
/**
|
||||
@@ -173,16 +167,6 @@ interface RunScopeResolutionInput {
|
||||
* intentionally suppress an edge; the graph remains unchanged.
|
||||
*/
|
||||
readonly recordResolutionOutcome?: ResolutionOutcomeRecorder;
|
||||
/**
|
||||
* Optional progress callback for UI updates during long-running scope
|
||||
* resolution. Called periodically during the extract loop and at each
|
||||
* sub-phase boundary (finalize, resolve, emit).
|
||||
*
|
||||
* @param subPhase Current sub-phase name for display
|
||||
* @param current Files processed so far (during extract) or total files (at phase boundaries)
|
||||
* @param total Total files in this language
|
||||
*/
|
||||
readonly onProgress?: (subPhase: ScopeResolutionSubPhase, current: number, total: number) => void;
|
||||
}
|
||||
|
||||
interface RunScopeResolutionStats {
|
||||
@@ -223,10 +207,7 @@ export function runScopeResolution(
|
||||
const treeCache = input.treeCache;
|
||||
const preExtracted = input.preExtractedParsedFiles;
|
||||
let preExtractedHits = 0;
|
||||
const progressInterval = files.length > 0 ? Math.max(1, Math.floor(files.length / 50)) : 1;
|
||||
input.onProgress?.('extracting', 0, files.length);
|
||||
for (let fileIdx = 0; fileIdx < files.length; fileIdx++) {
|
||||
const file = files[fileIdx];
|
||||
for (const file of files) {
|
||||
let parsed: ParsedFile | undefined;
|
||||
// Fast path: a worker (during the parse phase) already produced a
|
||||
// ParsedFile for this file via `extractParsedFile`. Reuse it
|
||||
@@ -251,12 +232,6 @@ export function runScopeResolution(
|
||||
}
|
||||
provider.populateOwners(parsed);
|
||||
parsedFiles.push(parsed);
|
||||
if (
|
||||
input.onProgress &&
|
||||
((fileIdx + 1) % progressInterval === 0 || fileIdx === files.length - 1)
|
||||
) {
|
||||
input.onProgress('extracting', fileIdx + 1, files.length);
|
||||
}
|
||||
}
|
||||
if (PROF && preExtracted !== undefined) {
|
||||
logger.warn(`[scope-resolution prof] pre-extracted hits: ${preExtractedHits}/${files.length}`);
|
||||
@@ -292,7 +267,6 @@ export function runScopeResolution(
|
||||
const tExtract = PROF ? process.hrtime.bigint() : 0n;
|
||||
|
||||
// ── Phase 2: finalize → ScopeResolutionIndexes ─────────────────────────
|
||||
input.onProgress?.('analyzing types', files.length, files.length);
|
||||
const allFilePaths = new Set(parsedFiles.map((f) => f.filePath));
|
||||
const nodeLookup = buildGraphNodeLookup(graph);
|
||||
|
||||
@@ -308,26 +282,8 @@ export function runScopeResolution(
|
||||
},
|
||||
});
|
||||
const preEmittedInheritanceSites = preEmitInheritanceEdges(graph, finalized, 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,
|
||||
);
|
||||
const mroByClassDefId = provider.buildMro(graph, parsedFiles, nodeLookup);
|
||||
const extendsOnlyMroByClassDefId = provider.buildExtendsOnlyMro?.(graph, parsedFiles, nodeLookup);
|
||||
|
||||
// Replace the empty MethodDispatchIndex that finalizeScopeModel
|
||||
// builds by design with the populated one derived from the
|
||||
@@ -394,7 +350,6 @@ export function runScopeResolution(
|
||||
validateBindingsImmutability(indexes, onWarn);
|
||||
|
||||
// ── Phase 3: resolve references via Registry.lookup ────────────────────
|
||||
input.onProgress?.('resolving references', files.length, files.length);
|
||||
const registryProviders: RegistryProviders = {
|
||||
arityCompatibility: provider.arityCompatibility,
|
||||
};
|
||||
@@ -407,13 +362,12 @@ export function runScopeResolution(
|
||||
const tResolve = PROF ? process.hrtime.bigint() : 0n;
|
||||
|
||||
// ── Phase 4: emit graph edges (LOAD-BEARING ORDER — see I1) ────────────
|
||||
input.onProgress?.('linking symbols', files.length, files.length);
|
||||
const handledSites = new Set<string>(preEmittedInheritanceSites);
|
||||
const receiverExtras = emitReceiverBoundCalls(
|
||||
graph,
|
||||
indexes,
|
||||
parsedFiles,
|
||||
postHeritageNodeLookup,
|
||||
nodeLookup,
|
||||
handledSites,
|
||||
provider,
|
||||
workspaceIndex,
|
||||
@@ -428,7 +382,7 @@ export function runScopeResolution(
|
||||
graph,
|
||||
indexes,
|
||||
parsedFiles,
|
||||
postHeritageNodeLookup,
|
||||
nodeLookup,
|
||||
handledSites,
|
||||
readonlyModel,
|
||||
)
|
||||
@@ -437,7 +391,7 @@ export function runScopeResolution(
|
||||
graph,
|
||||
indexes,
|
||||
parsedFiles,
|
||||
postHeritageNodeLookup,
|
||||
nodeLookup,
|
||||
referenceIndex,
|
||||
handledSites,
|
||||
readonlyModel,
|
||||
@@ -456,7 +410,7 @@ export function runScopeResolution(
|
||||
graph,
|
||||
indexes,
|
||||
referenceIndex,
|
||||
postHeritageNodeLookup,
|
||||
nodeLookup,
|
||||
handledSites,
|
||||
);
|
||||
const importsEmitted = emitImportEdges(
|
||||
|
||||
@@ -598,14 +598,6 @@ 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`
|
||||
@@ -633,17 +625,6 @@ 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);
|
||||
@@ -774,88 +755,10 @@ export const createWorkerPool = (
|
||||
onProgress(next);
|
||||
};
|
||||
|
||||
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 replaceWorker = async (workerIndex: number): Promise<boolean> => {
|
||||
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 {
|
||||
@@ -900,7 +803,7 @@ export const createWorkerPool = (
|
||||
const liveWorkers = workers.slice();
|
||||
for (let i = 0; i < workers.length; i++) workers[i] = undefined;
|
||||
activeSlots.clear();
|
||||
void terminateTrackedWorkers(liveWorkers);
|
||||
void Promise.all(liveWorkers.map((worker) => worker?.terminate().catch(() => undefined)));
|
||||
};
|
||||
|
||||
const maybeDone = () => {
|
||||
@@ -990,7 +893,6 @@ export const createWorkerPool = (
|
||||
workerIndex: number,
|
||||
reason: string,
|
||||
excludePaths: readonly string[],
|
||||
removalMode: WorkerRemovalMode = 'terminate',
|
||||
) => {
|
||||
if (stopped) return;
|
||||
consecutiveFailuresPerSlot[workerIndex]++;
|
||||
@@ -1019,7 +921,9 @@ export const createWorkerPool = (
|
||||
},
|
||||
`Worker ${workerIndex} exceeded respawn budget; dropping slot.`,
|
||||
);
|
||||
await removeWorkerFromSlot(workerIndex, removalMode, reason);
|
||||
const dead = workers[workerIndex];
|
||||
await dead?.terminate().catch(() => undefined);
|
||||
workers[workerIndex] = undefined;
|
||||
activeSlots.delete(workerIndex);
|
||||
if (activeSlots.size === 0) {
|
||||
tripBreaker(
|
||||
@@ -1041,7 +945,7 @@ export const createWorkerPool = (
|
||||
},
|
||||
`Worker ${workerIndex} died; respawning slot (attempt ${respawnCount[workerIndex]}/${poolOptions.maxRespawnsPerSlot}).`,
|
||||
);
|
||||
const respawned = await replaceWorker(workerIndex, removalMode, reason);
|
||||
const respawned = await replaceWorker(workerIndex);
|
||||
if (!respawned) {
|
||||
activeSlots.delete(workerIndex);
|
||||
if (activeSlots.size === 0) {
|
||||
@@ -1307,12 +1211,7 @@ export const createWorkerPool = (
|
||||
activeWorkers--;
|
||||
busySlots.delete(workerIndex);
|
||||
requeueRemainder(job, decision.excludePaths);
|
||||
await handleWorkerDeath(
|
||||
workerIndex,
|
||||
decision.reason,
|
||||
decision.excludePaths,
|
||||
'retire',
|
||||
);
|
||||
await handleWorkerDeath(workerIndex, decision.reason, decision.excludePaths);
|
||||
if (stopped) return;
|
||||
if (activeSlots.has(workerIndex)) runWorker(workerIndex);
|
||||
wakeIdleSlots();
|
||||
@@ -1356,11 +1255,9 @@ export const createWorkerPool = (
|
||||
},
|
||||
`Worker ${workerIndex} hit consecutive-failure threshold on idle-timeout retry; tripping circuit breaker.`,
|
||||
);
|
||||
await removeWorkerFromSlot(
|
||||
workerIndex,
|
||||
'retire',
|
||||
'idle-timeout retry consecutive-failure threshold',
|
||||
);
|
||||
const dead = workers[workerIndex];
|
||||
await dead?.terminate().catch(() => undefined);
|
||||
workers[workerIndex] = undefined;
|
||||
activeSlots.delete(workerIndex);
|
||||
tripBreaker(
|
||||
new WorkerPoolDispatchError(
|
||||
@@ -1381,18 +1278,12 @@ export const createWorkerPool = (
|
||||
},
|
||||
`Worker ${workerIndex} exceeded respawn budget during idle-timeout retry; dropping slot.`,
|
||||
);
|
||||
await removeWorkerFromSlot(
|
||||
workerIndex,
|
||||
'retire',
|
||||
'idle-timeout retry respawn budget exhausted',
|
||||
);
|
||||
const dead = workers[workerIndex];
|
||||
await dead?.terminate().catch(() => undefined);
|
||||
workers[workerIndex] = undefined;
|
||||
activeSlots.delete(workerIndex);
|
||||
} else {
|
||||
const respawned = await replaceWorker(
|
||||
workerIndex,
|
||||
'retire',
|
||||
'idle-timeout retry',
|
||||
);
|
||||
const respawned = await replaceWorker(workerIndex);
|
||||
if (!respawned) {
|
||||
activeSlots.delete(workerIndex);
|
||||
}
|
||||
@@ -1591,7 +1482,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 terminateTrackedWorkers(workers);
|
||||
await Promise.all(workers.map((w) => w?.terminate().catch(() => undefined)));
|
||||
workers.length = 0;
|
||||
activeSlots.clear();
|
||||
};
|
||||
|
||||
@@ -4,8 +4,6 @@ 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 {
|
||||
@@ -25,7 +23,6 @@ import {
|
||||
isOpenRetryExhausted,
|
||||
isWalCorruptionError,
|
||||
openLbugConnection,
|
||||
toNativeSafePath,
|
||||
WAL_RECOVERY_SUGGESTION,
|
||||
waitForWindowsHandleRelease,
|
||||
type LbugConnectionHandle,
|
||||
@@ -389,8 +386,7 @@ const runWithSessionLock = async <T>(operation: () => Promise<T>): Promise<T> =>
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeCopyPath = (filePath: string): string =>
|
||||
toNativeSafePath(filePath).replace(/\\/g, '/');
|
||||
const normalizeCopyPath = (filePath: string): string => filePath.replace(/\\/g, '/');
|
||||
|
||||
const closeQueryResult = async (result: lbug.QueryResult): Promise<void> => {
|
||||
try {
|
||||
@@ -860,13 +856,7 @@ export const loadGraphToLbug = async (
|
||||
|
||||
const log = onProgress || (() => {});
|
||||
|
||||
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');
|
||||
}
|
||||
const csvDir = path.join(storagePath, 'csv');
|
||||
|
||||
log('Streaming CSVs to disk...');
|
||||
const csvResult = await streamAllCSVsToDisk(graph, repoPath, csvDir);
|
||||
|
||||
@@ -1,194 +1,9 @@
|
||||
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.
|
||||
*
|
||||
@@ -536,10 +351,12 @@ 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, safePath, options), safePath);
|
||||
db = await openWithLockRetry(
|
||||
() => createLbugDatabase(lbugModule, databasePath, options),
|
||||
databasePath,
|
||||
);
|
||||
return { db, conn: new lbugModule.Connection(db) };
|
||||
} catch (err) {
|
||||
if (db) await db.close().catch(() => {});
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
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,7 +23,6 @@ import { isReadOnlyDbError, loadFTSExtension } from './lbug-adapter.js';
|
||||
import {
|
||||
createLbugDatabase,
|
||||
isWalCorruptionError,
|
||||
toNativeSafePath,
|
||||
WAL_RECOVERY_SUGGESTION,
|
||||
} from './lbug-config.js';
|
||||
import {
|
||||
@@ -391,7 +390,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, toNativeSafePath(dbPath), { throwOnWalReplayFailure: false });
|
||||
db = createLbugDatabase(lbug, dbPath, { throwOnWalReplayFailure: false });
|
||||
await db.init();
|
||||
await probeDatabaseForShadowReplay(db);
|
||||
} catch (err) {
|
||||
@@ -416,7 +415,7 @@ async function openReadOnlyDatabase(dbPath: string): Promise<lbug.Database> {
|
||||
logger: poolSidecarLogger,
|
||||
allowQuarantine: true,
|
||||
});
|
||||
db = createLbugDatabase(lbug, toNativeSafePath(dbPath), {
|
||||
db = createLbugDatabase(lbug, dbPath, {
|
||||
readOnly: true,
|
||||
throwOnWalReplayFailure: false,
|
||||
});
|
||||
@@ -435,7 +434,7 @@ async function openReadOnlyDatabase(dbPath: string): Promise<lbug.Database> {
|
||||
logger: poolSidecarLogger,
|
||||
allowQuarantine: true,
|
||||
});
|
||||
db = createLbugDatabase(lbug, toNativeSafePath(dbPath), {
|
||||
db = createLbugDatabase(lbug, dbPath, {
|
||||
readOnly: true,
|
||||
throwOnWalReplayFailure: false,
|
||||
});
|
||||
@@ -449,7 +448,7 @@ async function openReadOnlyDatabase(dbPath: string): Promise<lbug.Database> {
|
||||
await db.close().catch(() => {});
|
||||
db = undefined;
|
||||
await replayShadowPagesWithWritableOpen(dbPath);
|
||||
db = createLbugDatabase(lbug, toNativeSafePath(dbPath), {
|
||||
db = createLbugDatabase(lbug, dbPath, {
|
||||
readOnly: true,
|
||||
throwOnWalReplayFailure: false,
|
||||
});
|
||||
|
||||
@@ -163,7 +163,6 @@ export const PHASE_LABELS: Record<string, string> = {
|
||||
imports: 'Resolving imports',
|
||||
calls: 'Tracing calls',
|
||||
heritage: 'Extracting inheritance',
|
||||
scopeResolution: 'Resolving types',
|
||||
communities: 'Detecting communities',
|
||||
processes: 'Detecting processes',
|
||||
complete: 'Pipeline complete',
|
||||
|
||||
@@ -39,7 +39,6 @@ import {
|
||||
} from './llm-client.js';
|
||||
|
||||
import { callCursorLLM, resolveCursorConfig } from './cursor-client.js';
|
||||
import { callClaudeLLM, callCodexLLM, resolveLocalCLIConfig } from './local-cli-client.js';
|
||||
|
||||
import {
|
||||
GROUPING_SYSTEM_PROMPT,
|
||||
@@ -99,7 +98,6 @@ export interface WikiRunResult {
|
||||
// ─── Constants ────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_MAX_TOKENS_PER_MODULE = 30_000;
|
||||
const GROUPING_TOKEN_BUDGET = 100_000;
|
||||
const WIKI_DIR = 'wiki';
|
||||
|
||||
// ─── Generator Class ──────────────────────────────────────────────────
|
||||
@@ -205,7 +203,7 @@ export class WikiGenerator {
|
||||
}
|
||||
|
||||
/**
|
||||
* Route LLM call to the appropriate provider.
|
||||
* Route LLM call to the appropriate provider (OpenAI-compatible or Cursor CLI).
|
||||
*/
|
||||
private async invokeLLM(
|
||||
prompt: string,
|
||||
@@ -219,16 +217,6 @@ export class WikiGenerator {
|
||||
});
|
||||
return callCursorLLM(prompt, cursorConfig, systemPrompt, options);
|
||||
}
|
||||
if (this.llmConfig.provider === 'claude' || this.llmConfig.provider === 'codex') {
|
||||
const localConfig = resolveLocalCLIConfig({
|
||||
model: this.llmConfig.model,
|
||||
workingDirectory: this.repoPath,
|
||||
requestTimeoutMs: this.llmConfig.requestTimeoutMs,
|
||||
});
|
||||
return this.llmConfig.provider === 'claude'
|
||||
? callClaudeLLM(prompt, localConfig, systemPrompt, options)
|
||||
: callCodexLLM(prompt, localConfig, systemPrompt, options);
|
||||
}
|
||||
return callLLM(prompt, this.llmConfig, systemPrompt, options);
|
||||
}
|
||||
|
||||
@@ -471,22 +459,15 @@ export class WikiGenerator {
|
||||
DIRECTORY_TREE: dirTree,
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
// 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);
|
||||
|
||||
// Convert to tree nodes
|
||||
const tree: ModuleTreeNode[] = [];
|
||||
@@ -517,202 +498,6 @@ 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.
|
||||
*/
|
||||
|
||||
@@ -9,14 +9,7 @@ import { CircuitOpenError, ResilientFetchExhaustedError, resilientFetch } from '
|
||||
* Config priority: CLI flags > env vars > defaults
|
||||
*/
|
||||
|
||||
export type LLMProvider =
|
||||
| 'openai'
|
||||
| 'openrouter'
|
||||
| 'azure'
|
||||
| 'custom'
|
||||
| 'cursor'
|
||||
| 'claude'
|
||||
| 'codex';
|
||||
export type LLMProvider = 'openai' | 'openrouter' | 'azure' | 'custom' | 'cursor';
|
||||
|
||||
export interface LLMConfig {
|
||||
apiKey: string;
|
||||
@@ -25,7 +18,7 @@ export interface LLMConfig {
|
||||
maxTokens: number;
|
||||
temperature: number;
|
||||
/** Provider type — controls auth header behaviour */
|
||||
provider?: LLMProvider;
|
||||
provider?: 'openai' | 'openrouter' | 'azure' | 'custom' | 'cursor';
|
||||
/** Azure api-version query param (e.g. '2024-10-21'). Appended to URL when set. */
|
||||
apiVersion?: string;
|
||||
/** When true, strips sampling params and uses max_completion_tokens instead of max_tokens */
|
||||
@@ -51,17 +44,6 @@ export interface LLMResponse {
|
||||
export async function resolveLLMConfig(overrides?: Partial<LLMConfig>): Promise<LLMConfig> {
|
||||
const { loadCLIConfig } = await import('../../storage/repo-manager.js');
|
||||
const savedConfig = await loadCLIConfig();
|
||||
const savedProvider = overrides?.provider ?? savedConfig.provider;
|
||||
const savedLocalModel =
|
||||
savedProvider === 'cursor'
|
||||
? savedConfig.cursorModel
|
||||
: savedProvider === 'claude'
|
||||
? savedConfig.claudeModel
|
||||
: savedProvider === 'codex'
|
||||
? savedConfig.codexModel
|
||||
: undefined;
|
||||
const localProvider =
|
||||
savedProvider === 'cursor' || savedProvider === 'claude' || savedProvider === 'codex';
|
||||
|
||||
const apiKey =
|
||||
overrides?.apiKey ||
|
||||
@@ -79,12 +61,13 @@ export async function resolveLLMConfig(overrides?: Partial<LLMConfig>): Promise<
|
||||
'https://openrouter.ai/api/v1',
|
||||
model:
|
||||
overrides?.model ||
|
||||
(localProvider ? undefined : process.env.GITNEXUS_MODEL) ||
|
||||
savedLocalModel ||
|
||||
(localProvider ? '' : savedConfig.model || 'minimax/minimax-m2.5'),
|
||||
process.env.GITNEXUS_MODEL ||
|
||||
(savedConfig.provider === 'cursor' ? savedConfig.cursorModel : undefined) ||
|
||||
savedConfig.model ||
|
||||
'minimax/minimax-m2.5',
|
||||
maxTokens: overrides?.maxTokens ?? 16_384,
|
||||
temperature: overrides?.temperature ?? 0,
|
||||
provider: savedProvider ?? 'openai',
|
||||
provider: overrides?.provider ?? savedConfig.provider ?? 'openai',
|
||||
apiVersion:
|
||||
overrides?.apiVersion || process.env.GITNEXUS_AZURE_API_VERSION || savedConfig.apiVersion,
|
||||
isReasoningModel: overrides?.isReasoningModel ?? savedConfig.isReasoningModel,
|
||||
|
||||
@@ -1,352 +0,0 @@
|
||||
/**
|
||||
* Local agent CLI clients for wiki generation.
|
||||
*
|
||||
* These providers use the user's authenticated local CLI session instead of
|
||||
* an OpenAI-compatible HTTP API.
|
||||
*/
|
||||
|
||||
import fs from 'fs/promises';
|
||||
import { existsSync } from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { execFileSync, spawn } from 'child_process';
|
||||
import { StringDecoder } from 'string_decoder';
|
||||
import type { LLMResponse, CallLLMOptions } from './llm-client.js';
|
||||
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
export type LocalAgentProvider = 'claude' | 'codex';
|
||||
|
||||
export interface LocalCLIConfig {
|
||||
model?: string;
|
||||
workingDirectory?: string;
|
||||
requestTimeoutMs?: number;
|
||||
}
|
||||
|
||||
const COMMANDS: Record<LocalAgentProvider, string> = {
|
||||
claude: 'claude',
|
||||
codex: 'codex',
|
||||
};
|
||||
|
||||
interface LocalCommand {
|
||||
displayName: string;
|
||||
command: string;
|
||||
argsPrefix: string[];
|
||||
}
|
||||
|
||||
function killChildTree(child: import('child_process').ChildProcess): void {
|
||||
if (process.platform === 'win32' && child.pid !== undefined) {
|
||||
try {
|
||||
execFileSync('taskkill', ['/T', '/F', '/PID', String(child.pid)], {
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
// Process may have already exited — fall through to child.kill()
|
||||
}
|
||||
}
|
||||
child.kill();
|
||||
}
|
||||
|
||||
function isVerbose(): boolean {
|
||||
return process.env.GITNEXUS_VERBOSE === '1';
|
||||
}
|
||||
|
||||
function verboseLog(provider: LocalAgentProvider, ...args: unknown[]): void {
|
||||
if (isVerbose()) {
|
||||
logger.info({ provider, args }, '[local-cli]');
|
||||
}
|
||||
}
|
||||
|
||||
const cachedCommands = new Map<LocalAgentProvider, LocalCommand | null>();
|
||||
|
||||
export function detectLocalCLI(provider: LocalAgentProvider): string | null {
|
||||
if (cachedCommands.has(provider)) return cachedCommands.get(provider)?.displayName ?? null;
|
||||
const commandInfo = resolveLocalCommand(provider);
|
||||
try {
|
||||
execFileSync(commandInfo.command, [...commandInfo.argsPrefix, '--version'], {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
cachedCommands.set(provider, commandInfo);
|
||||
} catch (err: unknown) {
|
||||
const isNotFound =
|
||||
err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'ENOENT';
|
||||
if (!isNotFound && err instanceof Error) {
|
||||
logger.warn(
|
||||
`${provider} CLI found but --version failed (exit ${(err as { status?: number }).status ?? '?'}). ` +
|
||||
`Ensure it is authenticated: run \`${COMMANDS[provider]} --version\` manually.`,
|
||||
);
|
||||
}
|
||||
cachedCommands.set(provider, null);
|
||||
}
|
||||
return cachedCommands.get(provider)?.displayName ?? null;
|
||||
}
|
||||
|
||||
export function resolveLocalCLIConfig(overrides?: Partial<LocalCLIConfig>): LocalCLIConfig {
|
||||
return {
|
||||
model: overrides?.model,
|
||||
workingDirectory: overrides?.workingDirectory,
|
||||
requestTimeoutMs: overrides?.requestTimeoutMs,
|
||||
};
|
||||
}
|
||||
|
||||
export async function callClaudeLLM(
|
||||
prompt: string,
|
||||
config: LocalCLIConfig,
|
||||
systemPrompt?: string,
|
||||
options?: CallLLMOptions,
|
||||
): Promise<LLMResponse> {
|
||||
const commandInfo = getDetectedCommand('claude');
|
||||
if (!commandInfo) {
|
||||
throw new Error('Claude CLI not found. Install Claude Code and ensure `claude` is on PATH.');
|
||||
}
|
||||
|
||||
const args = ['-p', '--output-format', 'text', '--no-session-persistence'];
|
||||
if (config.model) {
|
||||
args.push('--model', config.model);
|
||||
}
|
||||
const fullPrompt = systemPrompt ? `${systemPrompt}\n\n---\n\n${prompt}` : prompt;
|
||||
|
||||
const response = await runLocalCLI('claude', commandInfo, args, config, fullPrompt, options);
|
||||
if (!response.content) {
|
||||
throw new Error('claude CLI returned empty output');
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function callCodexLLM(
|
||||
prompt: string,
|
||||
config: LocalCLIConfig,
|
||||
systemPrompt?: string,
|
||||
options?: CallLLMOptions,
|
||||
): Promise<LLMResponse> {
|
||||
const commandInfo = getDetectedCommand('codex');
|
||||
if (!commandInfo) {
|
||||
throw new Error('Codex CLI not found. Install Codex CLI and ensure `codex` is on PATH.');
|
||||
}
|
||||
|
||||
const outputDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-wiki-codex-'));
|
||||
const outputPath = path.join(outputDir, 'last-message.txt');
|
||||
const workingDirectory = config.workingDirectory || process.cwd();
|
||||
const fullPrompt = systemPrompt ? `${systemPrompt}\n\n---\n\n${prompt}` : prompt;
|
||||
const args = [
|
||||
'exec',
|
||||
'--cd',
|
||||
workingDirectory,
|
||||
'--sandbox',
|
||||
'read-only',
|
||||
'-c',
|
||||
'approval_policy="never"',
|
||||
'--color',
|
||||
'never',
|
||||
'--output-last-message',
|
||||
outputPath,
|
||||
];
|
||||
|
||||
if (config.model) {
|
||||
args.push('--model', config.model);
|
||||
}
|
||||
args.push('-');
|
||||
|
||||
try {
|
||||
const response = await runLocalCLI('codex', commandInfo, args, config, fullPrompt, options);
|
||||
const lastMessage = await fs.readFile(outputPath, 'utf-8').catch(() => '');
|
||||
const content = (lastMessage || response.content).trim();
|
||||
if (!content) {
|
||||
throw new Error('codex CLI returned empty output');
|
||||
}
|
||||
return { content };
|
||||
} finally {
|
||||
await fs.rm(outputDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function runLocalCLI(
|
||||
provider: LocalAgentProvider,
|
||||
commandInfo: LocalCommand,
|
||||
args: string[],
|
||||
config: LocalCLIConfig,
|
||||
stdinText?: string,
|
||||
options?: CallLLMOptions,
|
||||
): Promise<LLMResponse> {
|
||||
const finalArgs = [...commandInfo.argsPrefix, ...args];
|
||||
verboseLog(
|
||||
provider,
|
||||
'Spawning:',
|
||||
commandInfo.command,
|
||||
maskPromptArgs(provider, finalArgs).join(' '),
|
||||
);
|
||||
verboseLog(provider, 'Working directory:', config.workingDirectory || process.cwd());
|
||||
if (config.model) {
|
||||
verboseLog(provider, 'Model:', config.model);
|
||||
} else {
|
||||
verboseLog(provider, 'Model: default');
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(commandInfo.command, finalArgs, {
|
||||
cwd: config.workingDirectory || process.cwd(),
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
env: {
|
||||
...process.env,
|
||||
CI: '1',
|
||||
},
|
||||
});
|
||||
|
||||
verboseLog(provider, 'Process spawned with PID:', child.pid);
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
const stdoutDecoder = new StringDecoder('utf8');
|
||||
const stderrDecoder = new StringDecoder('utf8');
|
||||
let stdinError: Error | undefined;
|
||||
let settled = false;
|
||||
let killTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const rejectOnce = (error: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (killTimer !== undefined) clearTimeout(killTimer);
|
||||
reject(error);
|
||||
};
|
||||
|
||||
const resolveOnce = (response: LLMResponse) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (killTimer !== undefined) clearTimeout(killTimer);
|
||||
resolve(response);
|
||||
};
|
||||
|
||||
if (config.requestTimeoutMs !== undefined && config.requestTimeoutMs > 0) {
|
||||
killTimer = setTimeout(() => {
|
||||
killChildTree(child);
|
||||
const duration =
|
||||
config.requestTimeoutMs! >= 60_000
|
||||
? `${Math.round(config.requestTimeoutMs! / 60_000)}m`
|
||||
: `${Math.round(config.requestTimeoutMs! / 1_000)}s`;
|
||||
rejectOnce(
|
||||
new Error(
|
||||
`${provider} CLI timed out after ${duration}. ` +
|
||||
'Increase --timeout or omit it to disable the request timeout.',
|
||||
),
|
||||
);
|
||||
}, config.requestTimeoutMs);
|
||||
}
|
||||
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
const chunkStr = stdoutDecoder.write(chunk);
|
||||
stdout += chunkStr;
|
||||
verboseLog(provider, `[stdout] received ${chunkStr.length} chars, total: ${stdout.length}`);
|
||||
options?.onChunk?.(stdout.length);
|
||||
});
|
||||
|
||||
child.stderr.on('data', (chunk: Buffer) => {
|
||||
const chunkStr = stderrDecoder.write(chunk);
|
||||
stderr += chunkStr;
|
||||
verboseLog(provider, '[stderr]', chunkStr.trim());
|
||||
});
|
||||
|
||||
child.stdin.on('error', (err: Error) => {
|
||||
stdinError = err;
|
||||
verboseLog(provider, 'stdin error:', err.message);
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
stdout += stdoutDecoder.end();
|
||||
stderr += stderrDecoder.end();
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
verboseLog(provider, `Process exited with code ${code} after ${elapsed}s`);
|
||||
|
||||
if (code !== 0) {
|
||||
const details = stderr.trim() || stdinError?.message || stdout.trim();
|
||||
rejectOnce(new Error(`${provider} CLI exited with code ${code}: ${details}`));
|
||||
return;
|
||||
}
|
||||
if (stdinError) {
|
||||
rejectOnce(new Error(`${provider} CLI stdin error: ${stdinError.message}`));
|
||||
return;
|
||||
}
|
||||
resolveOnce({ content: stdout.trim() });
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
rejectOnce(new Error(`Failed to spawn ${provider} CLI: ${err.message}`));
|
||||
});
|
||||
|
||||
child.stdin.end(stdinText);
|
||||
});
|
||||
}
|
||||
|
||||
function maskPromptArgs(provider: LocalAgentProvider, args: string[]): string[] {
|
||||
if (provider === 'codex') {
|
||||
return args.map((arg) => (arg === '-' ? '[stdin prompt]' : arg));
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function getDetectedCommand(provider: LocalAgentProvider): LocalCommand | null {
|
||||
detectLocalCLI(provider);
|
||||
return cachedCommands.get(provider) ?? null;
|
||||
}
|
||||
|
||||
function resolveLocalCommand(provider: LocalAgentProvider): LocalCommand {
|
||||
const displayName = COMMANDS[provider];
|
||||
if (process.platform !== 'win32') {
|
||||
return { displayName, command: displayName, argsPrefix: [] };
|
||||
}
|
||||
|
||||
const npmBin = findWindowsCommand(`${displayName}.cmd`) || findWindowsCommand(displayName);
|
||||
if (npmBin) {
|
||||
const binDir = path.dirname(npmBin);
|
||||
if (provider === 'claude') {
|
||||
const exePath = path.join(
|
||||
binDir,
|
||||
'node_modules',
|
||||
'@anthropic-ai',
|
||||
'claude-code',
|
||||
'bin',
|
||||
'claude.exe',
|
||||
);
|
||||
if (existsSync(exePath)) {
|
||||
return { displayName, command: exePath, argsPrefix: [] };
|
||||
}
|
||||
}
|
||||
|
||||
if (provider === 'codex') {
|
||||
const scriptPath = path.join(binDir, 'node_modules', '@openai', 'codex', 'bin', 'codex.js');
|
||||
if (existsSync(scriptPath)) {
|
||||
return { displayName, command: process.execPath, argsPrefix: [scriptPath] };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Last-resort fallback for non-npm Windows installations that only expose a
|
||||
// .cmd shim. Prompts are passed via stdin, so repo content is not placed on
|
||||
// the command line.
|
||||
return {
|
||||
displayName,
|
||||
command: process.env.ComSpec || 'cmd.exe',
|
||||
argsPrefix: ['/d', '/s', '/c', displayName],
|
||||
};
|
||||
}
|
||||
|
||||
function findWindowsCommand(command: string): string | null {
|
||||
try {
|
||||
const output = execFileSync('where.exe', [command], {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
});
|
||||
return (
|
||||
output
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean) ?? null
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user