mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-03 02:21:44 +00:00
Merge branch 'main' into feat/group-trace-mcp-tool
This commit is contained in:
commit
d216fe9271
157 changed files with 13574 additions and 684 deletions
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
|
|
@ -48,7 +48,7 @@ jobs:
|
|||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
|
||||
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: security-and-quality
|
||||
|
|
@ -69,6 +69,6 @@ jobs:
|
|||
- '**/test/fixtures/**'
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
|
||||
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
with:
|
||||
category: '/language:${{ matrix.language }}'
|
||||
|
|
|
|||
2
.github/workflows/scorecard.yml
vendored
2
.github/workflows/scorecard.yml
vendored
|
|
@ -53,6 +53,6 @@ jobs:
|
|||
retention-days: 5
|
||||
|
||||
- name: Upload to Security tab
|
||||
uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
|
||||
uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
|
|
|||
2
.github/workflows/trivy.yml
vendored
2
.github/workflows/trivy.yml
vendored
|
|
@ -76,7 +76,7 @@ jobs:
|
|||
exit-code: '0'
|
||||
|
||||
- name: Upload to Security tab
|
||||
uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
|
||||
uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
with:
|
||||
sarif_file: trivy-${{ matrix.image.name }}.sarif
|
||||
category: trivy-${{ matrix.image.name }}
|
||||
|
|
|
|||
2
.github/workflows/workflow-lint.yml
vendored
2
.github/workflows/workflow-lint.yml
vendored
|
|
@ -76,7 +76,7 @@ jobs:
|
|||
continue-on-error: true
|
||||
|
||||
- name: Upload SARIF
|
||||
uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
|
||||
uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
with:
|
||||
sarif_file: zizmor.sarif
|
||||
category: zizmor
|
||||
|
|
|
|||
39
README.md
39
README.md
|
|
@ -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, 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, 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.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -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, Codex, Windsurf, OpenCode | Quick exploration, demos, one-off analysis |
|
||||
| **For** | Daily development with Cursor, Claude Code, Antigravity, 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,16 +114,20 @@ 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** |
|
||||
| **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** |
|
||||
| **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 |
|
||||
|
||||
> **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.
|
||||
|
|
@ -168,6 +172,21 @@ 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
|
||||
|
|
@ -639,7 +658,7 @@ UPSTREAM (what depends on this):
|
|||
authRouter [IMPORTS] -> src/routes/auth.ts
|
||||
```
|
||||
|
||||
Options: `maxDepth`, `minConfidence`, `relationTypes` (`CALLS`, `IMPORTS`, `EXTENDS`, `IMPLEMENTS`), `includeTests`
|
||||
Options: `maxDepth`, `minConfidence`, `relationTypes` (`CALLS`, `IMPORTS`, `EXTENDS`, `IMPLEMENTS`), `includeTests`, `limit` (max symbols per depth, default 100), `offset` (pagination start per depth), `summaryOnly` (counts and risk only, omits symbol list)
|
||||
|
||||
### Process-Grouped Search
|
||||
|
||||
|
|
|
|||
|
|
@ -53,6 +53,10 @@ export interface SymbolDefinition {
|
|||
* `ScopeResolver.constraintCompatibility` hook during overload narrowing.
|
||||
* Absent for symbols that have no constraints (the common case). */
|
||||
templateConstraints?: unknown;
|
||||
/** True when the producing language marked this callable as explicit.
|
||||
* Currently used by C++ overload ranking to exclude explicit constructors
|
||||
* from implicit user-defined conversion candidates. */
|
||||
isExplicit?: boolean;
|
||||
/** Links Method/Constructor/Property to owning Class/Struct/Trait nodeId */
|
||||
ownerId?: string;
|
||||
}
|
||||
|
|
|
|||
117
gitnexus-web/e2e/tree-view.spec.ts
Normal file
117
gitnexus-web/e2e/tree-view.spec.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* E2E tests for graph layout mode switching (Sequential / Radial layouts).
|
||||
*
|
||||
* Requires:
|
||||
* - gitnexus serve running on localhost:4747 with at least one indexed repo
|
||||
* - gitnexus-web dev server running on localhost:5173
|
||||
*
|
||||
* Skipped when servers aren't available (CI without services, etc.).
|
||||
* Set E2E=1 to force-run even without the availability check.
|
||||
*/
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL ?? 'http://localhost:4747';
|
||||
const FRONTEND_URL = process.env.FRONTEND_URL ?? 'http://localhost:5173';
|
||||
|
||||
test.beforeAll(async () => {
|
||||
if (process.env.E2E) return;
|
||||
try {
|
||||
const [backendRes, frontendRes] = await Promise.allSettled([
|
||||
fetch(`${BACKEND_URL}/api/repos`),
|
||||
fetch(FRONTEND_URL),
|
||||
]);
|
||||
if (
|
||||
backendRes.status === 'rejected' ||
|
||||
(backendRes.status === 'fulfilled' && !backendRes.value.ok)
|
||||
) {
|
||||
test.skip(true, 'gitnexus serve not available on :4747');
|
||||
return;
|
||||
}
|
||||
if (
|
||||
frontendRes.status === 'rejected' ||
|
||||
(frontendRes.status === 'fulfilled' && !frontendRes.value.ok)
|
||||
) {
|
||||
test.skip(true, 'Vite dev server not available on :5173');
|
||||
return;
|
||||
}
|
||||
if (backendRes.status === 'fulfilled') {
|
||||
const repos = await backendRes.value.json();
|
||||
if (!repos.length) {
|
||||
test.skip(true, 'No indexed repos — run gitnexus analyze first');
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
test.skip(true, 'servers not available');
|
||||
}
|
||||
});
|
||||
|
||||
async function waitForGraphLoaded(page: import('@playwright/test').Page) {
|
||||
await page.goto(`${FRONTEND_URL}?lng=en`);
|
||||
|
||||
// The app starts on the landing/onboarding screen. Pick the first repo card
|
||||
// (preferring a known repo name) and click it to load the graph.
|
||||
const landingCards = page.locator('[data-testid="landing-repo-card"]');
|
||||
const preferredCard = landingCards.filter({ hasText: /GitNexus|local-integration/ }).first();
|
||||
try {
|
||||
await landingCards.first().waitFor({ state: 'visible', timeout: 15_000 });
|
||||
const card = (await preferredCard.count()) > 0 ? preferredCard : landingCards.first();
|
||||
await card.click();
|
||||
} catch {
|
||||
// Landing screen may not appear (e.g. when ?server auto-connects)
|
||||
}
|
||||
|
||||
// Wait until the status bar confirms the graph is ready.
|
||||
const statusBar = page.getByRole('contentinfo');
|
||||
await expect(statusBar.getByText('Ready', { exact: true })).toBeVisible({ timeout: 45_000 });
|
||||
await expect(statusBar).toContainText(/nodes/, { timeout: 20_000 });
|
||||
|
||||
// Finally confirm the sigma canvas is present.
|
||||
await page.waitForSelector('.sigma-container', { timeout: 10_000 });
|
||||
}
|
||||
|
||||
test.describe('Graph Layout Modes', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await waitForGraphLoaded(page);
|
||||
});
|
||||
|
||||
test('should switch between force, sequential, and radial layouts', async ({ page }) => {
|
||||
const forceTab = page.locator('button:has-text("Force Graph")');
|
||||
const sequentialTab = page.locator('button:has-text("Sequential Layout")');
|
||||
const radialTab = page.locator('button:has-text("Radial Layout")');
|
||||
|
||||
// Force Graph is the default active tab
|
||||
await expect(forceTab).toHaveClass(/bg-accent/);
|
||||
await expect(sequentialTab).not.toHaveClass(/bg-accent/);
|
||||
|
||||
// Switch to Sequential Layout
|
||||
await sequentialTab.click();
|
||||
await expect(sequentialTab).toHaveClass(/bg-accent/, { timeout: 5_000 });
|
||||
await expect(forceTab).not.toHaveClass(/bg-accent/);
|
||||
|
||||
// All three layout tabs should be present in the tab bar
|
||||
await expect(radialTab).toBeVisible();
|
||||
|
||||
// Switch back to Force Graph
|
||||
await forceTab.click();
|
||||
await expect(forceTab).toHaveClass(/bg-accent/, { timeout: 5_000 });
|
||||
await expect(sequentialTab).not.toHaveClass(/bg-accent/);
|
||||
});
|
||||
|
||||
test('should interact with nodes in sequential layout', async ({ page }) => {
|
||||
await page.locator('button:has-text("Sequential Layout")').click();
|
||||
|
||||
// Click the first file-tree item in the sidebar (more reliable than a
|
||||
// blind canvas click, which may land on empty space). The FileTreePanel
|
||||
// renders node names as <span class="truncate font-mono text-xs">.
|
||||
// Clicking any of them calls setSelectedNode, which shows the selection
|
||||
// bar with the "Clear" button — the same mechanism used in
|
||||
// server-connect.spec.ts's "Turn Off All Highlights" test.
|
||||
const firstTreeItem = page.locator('span.truncate.font-mono').first();
|
||||
await firstTreeItem.waitFor({ state: 'visible', timeout: 10_000 });
|
||||
await firstTreeItem.click();
|
||||
|
||||
await expect(page.locator('text=Clear')).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
});
|
||||
109
gitnexus-web/package-lock.json
generated
109
gitnexus-web/package-lock.json
generated
|
|
@ -11,12 +11,12 @@
|
|||
"@langchain/anthropic": "^1.3.29",
|
||||
"@langchain/core": "^1.1.44",
|
||||
"@langchain/google-genai": "^2.1.30",
|
||||
"@langchain/langgraph": "^1.2.9",
|
||||
"@langchain/langgraph": "^1.3.2",
|
||||
"@langchain/ollama": "^1.2.6",
|
||||
"@langchain/openai": "^1.4.5",
|
||||
"@sigma/edge-curve": "^3.1.0",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"axios": "^1.16.0",
|
||||
"axios": "^1.16.1",
|
||||
"d3": "^7.9.0",
|
||||
"dompurify": "^3.4.3",
|
||||
"gitnexus-shared": "file:../gitnexus-shared",
|
||||
|
|
@ -53,7 +53,7 @@
|
|||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/dompurify": "^3.2.0",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
|
|
@ -1393,13 +1393,14 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@langchain/langgraph": {
|
||||
"version": "1.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-1.2.9.tgz",
|
||||
"integrity": "sha512-3c7BtGycHC2v9p6w/Hv8L7kEl1YnZYOQTDJtmAp3knk6JOedO7d2bYP3y0SRyhv5orUEGf/KGvx8ZsB/ideP7g==",
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-1.3.2.tgz",
|
||||
"integrity": "sha512-SL7Ktsr681R7da+1b2MVOWEbaCoFJOXEJPTGOjg4JIG4C7quWbTYC8DzxhcCxte6D/8cGp0rYDBnbKLXEpNqlA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@langchain/langgraph-checkpoint": "^1.0.1",
|
||||
"@langchain/langgraph-sdk": "~1.8.9",
|
||||
"@langchain/langgraph-checkpoint": "^1.0.2",
|
||||
"@langchain/langgraph-sdk": "~1.9.4",
|
||||
"@langchain/protocol": "^0.0.15",
|
||||
"@standard-schema/spec": "1.1.0",
|
||||
"uuid": "^10.0.0"
|
||||
},
|
||||
|
|
@ -1407,7 +1408,7 @@
|
|||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@langchain/core": "^1.1.40",
|
||||
"@langchain/core": "^1.1.44",
|
||||
"zod": "^3.25.32 || ^4.2.0",
|
||||
"zod-to-json-schema": "^3.x"
|
||||
},
|
||||
|
|
@ -1418,9 +1419,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@langchain/langgraph-checkpoint": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-1.0.1.tgz",
|
||||
"integrity": "sha512-HM0cJLRpIsSlWBQ/xuDC67l52SqZ62Bh2Y61DX+Xorqwoh5e1KxYvfCD7GnSTbWWhjBOutvnR0vPhu4orFkZfw==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-1.0.2.tgz",
|
||||
"integrity": "sha512-F4E5Tr0nt8FGghgdscJtHw+ABzChOHeI80R7Y1pjIHdiJom6c2ieo76vL+FWiny80JmoGqhrVAEIWrw0cXKPxg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"uuid": "^10.0.0"
|
||||
|
|
@ -1429,7 +1430,7 @@
|
|||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@langchain/core": "^1.0.1"
|
||||
"@langchain/core": "^1.1.44"
|
||||
}
|
||||
},
|
||||
"node_modules/@langchain/langgraph-checkpoint/node_modules/uuid": {
|
||||
|
|
@ -1446,27 +1447,25 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@langchain/langgraph-sdk": {
|
||||
"version": "1.8.10",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.8.10.tgz",
|
||||
"integrity": "sha512-wrB3rkRw5KAmsqezwvKP3midT4qJrV6Hj9XJMYo+cbvXC4HYpSAmyY/VriSyeTFRbLG/OP/pY2Yz+9Z54nSaXQ==",
|
||||
"version": "1.9.9",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.9.9.tgz",
|
||||
"integrity": "sha512-aiWHbmqxWj5sAMwFsaB3eSGQvKpMbUKTlt9zbAC0T7IiFqDYUWi9gJUGsTdvJutAfB3P/NzC4s8ETUtUQEUlYg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@langchain/protocol": "^0.0.15",
|
||||
"@types/json-schema": "^7.0.15",
|
||||
"p-queue": "^9.0.1",
|
||||
"p-retry": "^7.1.1",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@langchain/core": "^1.1.16",
|
||||
"@langchain/core": "^1.1.44",
|
||||
"react": "^18 || ^19",
|
||||
"react-dom": "^18 || ^19",
|
||||
"svelte": "^4.0.0 || ^5.0.0",
|
||||
"vue": "^3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@langchain/core": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
|
|
@ -1488,9 +1487,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@langchain/langgraph-sdk/node_modules/p-queue": {
|
||||
"version": "9.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.2.0.tgz",
|
||||
"integrity": "sha512-dWgLE8AH0HjQ9fe74pUkKkvzzYT18Inp4zra3lKHnnwqGvcfcUBrvF2EAVX+envufDNBOzpPq/IBUONDbI7+3g==",
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.3.0.tgz",
|
||||
"integrity": "sha512-7NED7xhQ74Ngp4JP/2e0VZHp7vSWfJfqeiR92jPgxsz6m0Se4P03YoTKa9dDXyZ3r6P616gUXttrB6nnHYKang==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"eventemitter3": "^5.0.4",
|
||||
|
|
@ -1516,9 +1515,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@langchain/langgraph-sdk/node_modules/uuid": {
|
||||
"version": "13.0.1",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.1.tgz",
|
||||
"integrity": "sha512-9ezox2roIft6ExBVTVqibSd5dc5/47Sw/uY6b4SjQUT2TzQ0tltNquWA46y4xPQmdZYqvnio22SgWd41M86+jw==",
|
||||
"version": "13.0.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz",
|
||||
"integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
|
|
@ -1587,6 +1586,12 @@
|
|||
"@langchain/core": "^1.1.42"
|
||||
}
|
||||
},
|
||||
"node_modules/@langchain/protocol": {
|
||||
"version": "0.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/protocol/-/protocol-0.0.15.tgz",
|
||||
"integrity": "sha512-MllvbpMjqHevUm+v94M422mH7XKN+wGCvJRBVROTWBotEDOATYB4Ktk2UheYP859y9o2LlhtPek5t1T9eyfAbQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-2.0.3.tgz",
|
||||
|
|
@ -2854,13 +2859,13 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
|
||||
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
|
||||
"version": "25.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
|
||||
"integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.19.0"
|
||||
"undici-types": ">=7.24.0 <7.24.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/prismjs": {
|
||||
|
|
@ -3414,16 +3419,42 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz",
|
||||
"integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
|
||||
"version": "1.16.1",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz",
|
||||
"integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.16.0",
|
||||
"form-data": "^4.0.5",
|
||||
"https-proxy-agent": "^5.0.1",
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/axios/node_modules/agent-base": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/axios/node_modules/https-proxy-agent": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "6",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/bail": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz",
|
||||
|
|
@ -5444,9 +5475,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/is-network-error": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.1.tgz",
|
||||
"integrity": "sha512-6QCxa49rQbmUWLfk0nuGqzql9U8uaV2H6279bRErPBHe/109hCzsLUBUHfbEtvLIHBd6hyXbgedBSHevm43Edw==",
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.2.tgz",
|
||||
"integrity": "sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
|
|
@ -8567,9 +8598,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.19.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
|
||||
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
|
||||
"version": "7.24.6",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
|
||||
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -21,12 +21,12 @@
|
|||
"@langchain/anthropic": "^1.3.29",
|
||||
"@langchain/core": "^1.1.44",
|
||||
"@langchain/google-genai": "^2.1.30",
|
||||
"@langchain/langgraph": "^1.2.9",
|
||||
"@langchain/langgraph": "^1.3.2",
|
||||
"@langchain/ollama": "^1.2.6",
|
||||
"@langchain/openai": "^1.4.5",
|
||||
"@sigma/edge-curve": "^3.1.0",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"axios": "^1.16.0",
|
||||
"axios": "^1.16.1",
|
||||
"d3": "^7.9.0",
|
||||
"dompurify": "^3.4.3",
|
||||
"gitnexus-shared": "file:../gitnexus-shared",
|
||||
|
|
@ -63,7 +63,7 @@
|
|||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/dompurify": "^3.2.0",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
|
|
|
|||
|
|
@ -201,7 +201,10 @@ const getNodeTypeIcon = (label: NodeLabel) => {
|
|||
case 'Import':
|
||||
return FileCode;
|
||||
case 'Variable':
|
||||
case 'Property':
|
||||
return Variable;
|
||||
case 'Const':
|
||||
return Target;
|
||||
default:
|
||||
return Variable;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,11 +9,16 @@ import {
|
|||
Pause,
|
||||
Lightbulb,
|
||||
LightbulbOff,
|
||||
Network,
|
||||
GitBranch,
|
||||
Target,
|
||||
} from '@/lib/lucide-icons';
|
||||
import { useSigma } from '../hooks/useSigma';
|
||||
import { useAppState } from '../hooks/useAppState';
|
||||
import {
|
||||
knowledgeGraphToGraphology,
|
||||
knowledgeGraphToTreeGraphology,
|
||||
knowledgeGraphToCirclesGraphology,
|
||||
filterGraphByDepth,
|
||||
SigmaNodeAttributes,
|
||||
SigmaEdgeAttributes,
|
||||
|
|
@ -48,6 +53,8 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
|
|||
clearAICitationHighlights,
|
||||
clearBlastRadius,
|
||||
animatedNodes,
|
||||
graphViewMode,
|
||||
setGraphViewMode,
|
||||
} = useAppState();
|
||||
const [hoveredNodeName, setHoveredNodeName] = useState<string | null>(null);
|
||||
|
||||
|
|
@ -149,8 +156,22 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
|
|||
blastRadiusNodeIds: effectiveBlastRadiusNodeIds,
|
||||
animatedNodes: effectiveAnimatedNodes,
|
||||
visibleEdgeTypes,
|
||||
layoutMode: graphViewMode,
|
||||
});
|
||||
|
||||
const handleViewModeChange = useCallback(
|
||||
(mode: 'force' | 'tree' | 'circles') => {
|
||||
if (mode === graphViewMode) return;
|
||||
setSelectedNode(null);
|
||||
setSigmaSelectedNode(null);
|
||||
setHoveredNodeName(null);
|
||||
setGraphViewMode(mode);
|
||||
// Reset zoom when switching views
|
||||
resetZoom();
|
||||
},
|
||||
[graphViewMode, resetZoom, setGraphViewMode, setSelectedNode, setSigmaSelectedNode],
|
||||
);
|
||||
|
||||
// Expose focusNode to parent via ref
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
|
|
@ -174,25 +195,30 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
|
|||
useEffect(() => {
|
||||
if (!graph) return;
|
||||
|
||||
// Build communityMemberships map from MEMBER_OF relationships
|
||||
// MEMBER_OF edges: nodeId -> communityId (stored as targetId)
|
||||
const communityMemberships = new Map<string, number>();
|
||||
graph.relationships.forEach((rel) => {
|
||||
if (rel.type === 'MEMBER_OF') {
|
||||
// Find the community node to get its index
|
||||
const communityNode = nodeById.get(rel.targetId);
|
||||
if (communityNode && communityNode.label === 'Community') {
|
||||
// Extract community index from id (e.g., "comm_5" -> 5)
|
||||
const numericPart = rel.targetId.replace('comm_', '');
|
||||
const communityIdx = /^\d+$/.test(numericPart) ? parseInt(numericPart, 10) : 0;
|
||||
communityMemberships.set(rel.sourceId, communityIdx);
|
||||
}
|
||||
}
|
||||
});
|
||||
let sigmaGraph: Graph<SigmaNodeAttributes, SigmaEdgeAttributes>;
|
||||
|
||||
if (graphViewMode === 'tree') {
|
||||
sigmaGraph = knowledgeGraphToTreeGraphology(graph);
|
||||
} else if (graphViewMode === 'circles') {
|
||||
sigmaGraph = knowledgeGraphToCirclesGraphology(graph);
|
||||
} else {
|
||||
// Build community memberships map from MEMBER_OF relationships
|
||||
const communityMemberships = new Map<string, number>();
|
||||
graph.relationships.forEach((rel) => {
|
||||
if (rel.type === 'MEMBER_OF') {
|
||||
const communityNode = nodeById.get(rel.targetId);
|
||||
if (communityNode && communityNode.label === 'Community') {
|
||||
const numericPart = rel.targetId.replace('comm_', '');
|
||||
const communityIdx = /^\d+$/.test(numericPart) ? parseInt(numericPart, 10) : 0;
|
||||
communityMemberships.set(rel.sourceId, communityIdx);
|
||||
}
|
||||
}
|
||||
});
|
||||
sigmaGraph = knowledgeGraphToGraphology(graph, communityMemberships);
|
||||
}
|
||||
|
||||
const sigmaGraph = knowledgeGraphToGraphology(graph, communityMemberships);
|
||||
setSigmaGraph(sigmaGraph);
|
||||
}, [graph, nodeById, setSigmaGraph]);
|
||||
}, [graph, nodeById, setSigmaGraph, graphViewMode]);
|
||||
|
||||
// Update node visibility when filters change
|
||||
useEffect(() => {
|
||||
|
|
@ -205,7 +231,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
|
|||
filterGraphByDepth(sigmaGraph, appSelectedNode?.id || null, depthFilter, visibleLabels);
|
||||
sigma.refresh();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- sigmaRef identity never changes
|
||||
}, [visibleLabels, depthFilter, appSelectedNode]);
|
||||
}, [graph, graphViewMode, visibleLabels, depthFilter, appSelectedNode]);
|
||||
|
||||
// Sync app selected node with sigma
|
||||
useEffect(() => {
|
||||
|
|
@ -245,6 +271,53 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* View Mode Tabs */}
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={t('canvas.viewModes.label')}
|
||||
className="absolute top-4 left-1/2 z-20 flex -translate-x-1/2 gap-1 rounded-lg border border-border-subtle bg-elevated/90 p-1 backdrop-blur-sm"
|
||||
>
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={graphViewMode === 'force'}
|
||||
onClick={() => handleViewModeChange('force')}
|
||||
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-all ${
|
||||
graphViewMode === 'force'
|
||||
? 'bg-accent text-white'
|
||||
: 'text-text-secondary hover:bg-hover hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
<Network className="h-3.5 w-3.5" />
|
||||
{t('canvas.viewModes.force')}
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={graphViewMode === 'tree'}
|
||||
onClick={() => handleViewModeChange('tree')}
|
||||
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-all ${
|
||||
graphViewMode === 'tree'
|
||||
? 'bg-accent text-white'
|
||||
: 'text-text-secondary hover:bg-hover hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
<GitBranch className="h-3.5 w-3.5" />
|
||||
{t('canvas.viewModes.tree')}
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={graphViewMode === 'circles'}
|
||||
onClick={() => handleViewModeChange('circles')}
|
||||
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-all ${
|
||||
graphViewMode === 'circles'
|
||||
? 'bg-accent text-white'
|
||||
: 'text-text-secondary hover:bg-hover hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
<Target className="h-3.5 w-3.5" />
|
||||
{t('canvas.viewModes.circles')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sigma container */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
Check,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Square,
|
||||
} from '@/lib/lucide-icons';
|
||||
import type { ToolCallInfo } from '../core/llm/types';
|
||||
import type { TFunction } from 'i18next';
|
||||
|
|
@ -77,6 +78,13 @@ const getStatusDisplay = (status: ToolCallInfo['status']) => {
|
|||
bgColor: 'bg-rose-500/10',
|
||||
borderColor: 'border-rose-500/30',
|
||||
};
|
||||
case 'stopped':
|
||||
return {
|
||||
icon: <Square className="h-3.5 w-3.5 fill-current" />,
|
||||
color: 'text-amber-300',
|
||||
bgColor: 'bg-amber-500/10',
|
||||
borderColor: 'border-amber-500/30',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: <Sparkles className="h-3.5 w-3.5" />,
|
||||
|
|
|
|||
|
|
@ -363,8 +363,17 @@ export type AgentMessage = { role: 'user'; content: string } | AgentHistoryMessa
|
|||
export interface AgentRuntimeOptions {
|
||||
/** Capture assistant/tool messages for providers that require exact transcript replay. */
|
||||
captureHistory?: boolean;
|
||||
/** When aborted (e.g. user clicked Stop), the stream ends with a `cancelled` chunk. */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
const isAbortError = (error: unknown, signal?: AbortSignal): boolean => {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') return true;
|
||||
if (error instanceof Error && error.name === 'AbortError') return true;
|
||||
if (signal?.aborted) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
export const buildLangChainMessages = (messages: AgentMessage[]): BaseMessage[] =>
|
||||
messages.map((message) => {
|
||||
if (message.role === 'user') {
|
||||
|
|
@ -438,6 +447,7 @@ export async function* streamAgentResponse(
|
|||
streamMode: ['values', 'messages'] as any,
|
||||
// Allow longer tool/reasoning loops (more Cursor-like persistence)
|
||||
recursionLimit: 50,
|
||||
signal: options.signal,
|
||||
} as any);
|
||||
|
||||
// Track what we've yielded to avoid duplicates
|
||||
|
|
@ -455,6 +465,10 @@ export async function* streamAgentResponse(
|
|||
let lastStepMessages: any[] | null = null;
|
||||
|
||||
for await (const event of stream) {
|
||||
if (options.signal?.aborted) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Events come as [streamMode, data] tuples when using multiple modes
|
||||
// or just data when using single mode
|
||||
let mode: string;
|
||||
|
|
@ -511,10 +525,11 @@ export async function* streamAgentResponse(
|
|||
// - After all tools are done: treat as final content
|
||||
const isReasoning =
|
||||
!hasSeenToolCallThisTurn || toolCalls.length > 0 || pendingToolCalls > 0;
|
||||
yield {
|
||||
type: isReasoning ? 'reasoning' : 'content',
|
||||
[isReasoning ? 'reasoning' : 'content']: content,
|
||||
};
|
||||
if (isReasoning) {
|
||||
yield { type: 'reasoning', reasoning: content };
|
||||
} else {
|
||||
yield { type: 'content', content };
|
||||
}
|
||||
}
|
||||
|
||||
// Track tool calls from message chunks
|
||||
|
|
@ -627,6 +642,11 @@ export async function* streamAgentResponse(
|
|||
}
|
||||
}
|
||||
|
||||
if (options.signal?.aborted) {
|
||||
yield { type: 'cancelled' };
|
||||
return;
|
||||
}
|
||||
|
||||
// DEBUG: Stream completed normally
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('✅ Stream completed normally, yielding done');
|
||||
|
|
@ -640,6 +660,10 @@ export async function* streamAgentResponse(
|
|||
: undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isAbortError(error, options.signal)) {
|
||||
yield { type: 'cancelled' };
|
||||
return;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
// DEBUG: Stream error
|
||||
if (import.meta.env.DEV) {
|
||||
|
|
|
|||
|
|
@ -254,7 +254,7 @@ export interface ToolCallInfo {
|
|||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
result?: string;
|
||||
status: 'pending' | 'running' | 'completed' | 'error';
|
||||
status: 'pending' | 'running' | 'completed' | 'error' | 'stopped';
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -286,22 +286,17 @@ export type AgentHistoryMessage =
|
|||
};
|
||||
|
||||
/**
|
||||
* Streaming chunk from agent
|
||||
* Now supports step-based streaming where each step is a distinct message
|
||||
* Streaming chunk from agent (discriminated union).
|
||||
* Each variant carries only its relevant fields, enabling exhaustive switch handling.
|
||||
*/
|
||||
export interface AgentStreamChunk {
|
||||
type: 'reasoning' | 'tool_call' | 'tool_result' | 'content' | 'error' | 'done';
|
||||
/** LLM's reasoning/thinking text (shown as a step) */
|
||||
reasoning?: string;
|
||||
/** Final answer content (streamed token by token) */
|
||||
content?: string;
|
||||
/** Hidden raw transcript for reconstructing future agent turns */
|
||||
historyMessages?: AgentHistoryMessage[];
|
||||
/** Tool call information */
|
||||
toolCall?: ToolCallInfo;
|
||||
/** Error message */
|
||||
error?: string;
|
||||
}
|
||||
export type AgentStreamChunk =
|
||||
| { type: 'reasoning'; reasoning: string }
|
||||
| { type: 'tool_call'; toolCall: ToolCallInfo }
|
||||
| { type: 'tool_result'; toolCall: ToolCallInfo }
|
||||
| { type: 'content'; content: string }
|
||||
| { type: 'error'; error: string }
|
||||
| { type: 'done'; historyMessages?: AgentHistoryMessage[] }
|
||||
| { type: 'cancelled' };
|
||||
|
||||
/**
|
||||
* A single step in the agent's execution
|
||||
|
|
|
|||
22
gitnexus-web/src/hooks/app-state/graph.test.tsx
Normal file
22
gitnexus-web/src/hooks/app-state/graph.test.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { GraphStateProvider, useGraphState } from './graph';
|
||||
|
||||
function wrapper({ children }: { children: React.ReactNode }) {
|
||||
return <GraphStateProvider>{children}</GraphStateProvider>;
|
||||
}
|
||||
|
||||
describe('GraphState', () => {
|
||||
it('should have default graphViewMode as "force"', () => {
|
||||
const { result } = renderHook(() => useGraphState(), { wrapper });
|
||||
expect(result.current.graphViewMode).toBe('force');
|
||||
});
|
||||
|
||||
it('should toggle graphViewMode', () => {
|
||||
const { result } = renderHook(() => useGraphState(), { wrapper });
|
||||
act(() => {
|
||||
result.current.setGraphViewMode('tree');
|
||||
});
|
||||
expect(result.current.graphViewMode).toBe('tree');
|
||||
});
|
||||
});
|
||||
|
|
@ -16,6 +16,8 @@ interface GraphStateContextValue {
|
|||
setDepthFilter: (depth: number | null) => void;
|
||||
highlightedNodeIds: Set<string>;
|
||||
setHighlightedNodeIds: (ids: Set<string>) => void;
|
||||
graphViewMode: 'force' | 'tree' | 'circles';
|
||||
setGraphViewMode: (mode: 'force' | 'tree' | 'circles') => void;
|
||||
}
|
||||
|
||||
const GraphStateContext = createContext<GraphStateContextValue | null>(null);
|
||||
|
|
@ -27,6 +29,7 @@ export const GraphStateProvider = ({ children }: { children: ReactNode }) => {
|
|||
const [visibleEdgeTypes, setVisibleEdgeTypes] = useState<EdgeType[]>(DEFAULT_VISIBLE_EDGES);
|
||||
const [depthFilter, setDepthFilter] = useState<number | null>(null);
|
||||
const [highlightedNodeIds, setHighlightedNodeIds] = useState<Set<string>>(new Set());
|
||||
const [graphViewMode, setGraphViewMode] = useState<'force' | 'tree' | 'circles'>('force');
|
||||
|
||||
const toggleLabelVisibility = useCallback((label: NodeLabel) => {
|
||||
setVisibleLabels((prev) =>
|
||||
|
|
@ -54,8 +57,18 @@ export const GraphStateProvider = ({ children }: { children: ReactNode }) => {
|
|||
setDepthFilter,
|
||||
highlightedNodeIds,
|
||||
setHighlightedNodeIds,
|
||||
graphViewMode,
|
||||
setGraphViewMode,
|
||||
}),
|
||||
[graph, selectedNode, visibleLabels, visibleEdgeTypes, depthFilter, highlightedNodeIds],
|
||||
[
|
||||
graph,
|
||||
selectedNode,
|
||||
visibleLabels,
|
||||
visibleEdgeTypes,
|
||||
depthFilter,
|
||||
highlightedNodeIds,
|
||||
graphViewMode,
|
||||
],
|
||||
);
|
||||
|
||||
return <GraphStateContext.Provider value={value}>{children}</GraphStateContext.Provider>;
|
||||
|
|
|
|||
|
|
@ -123,6 +123,10 @@ interface AppState {
|
|||
depthFilter: number | null;
|
||||
setDepthFilter: (depth: number | null) => void;
|
||||
|
||||
// Graph view mode
|
||||
graphViewMode: 'force' | 'tree' | 'circles';
|
||||
setGraphViewMode: (mode: 'force' | 'tree' | 'circles') => void;
|
||||
|
||||
// Query state
|
||||
highlightedNodeIds: Set<string>;
|
||||
setHighlightedNodeIds: (ids: Set<string>) => void;
|
||||
|
|
@ -232,6 +236,8 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
|||
setDepthFilter,
|
||||
highlightedNodeIds,
|
||||
setHighlightedNodeIds,
|
||||
graphViewMode,
|
||||
setGraphViewMode,
|
||||
} = useGraphState();
|
||||
|
||||
// Right Panel
|
||||
|
|
@ -582,6 +588,8 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
|||
|
||||
// Agent state — agent runs on main thread now (I/O-bound, not CPU-bound)
|
||||
const agentRef = useRef<any>(null);
|
||||
const chatAbortRef = useRef<AbortController | null>(null);
|
||||
const chatStateRef = useRef<'idle' | 'streaming' | 'aborting'>('idle');
|
||||
|
||||
const initializeAgent = useCallback(
|
||||
async (overrideProjectName?: string): Promise<void> => {
|
||||
|
|
@ -640,7 +648,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
|||
|
||||
const sendChatMessage = useCallback(
|
||||
async (message: string): Promise<void> => {
|
||||
if (isChatLoading) return;
|
||||
if (chatStateRef.current !== 'idle') return;
|
||||
|
||||
// Refresh Code panel for the new question: keep user-pinned refs, clear old AI citations
|
||||
clearAICodeReferences();
|
||||
|
|
@ -679,8 +687,13 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
|||
}
|
||||
|
||||
setIsChatLoading(true);
|
||||
chatStateRef.current = 'streaming';
|
||||
setCurrentToolCalls([]);
|
||||
|
||||
chatAbortRef.current?.abort();
|
||||
const chatAbortController = new AbortController();
|
||||
chatAbortRef.current = chatAbortController;
|
||||
|
||||
const providerCapabilities = getProviderCapabilities(llmSettings.activeProvider);
|
||||
|
||||
// Prepare message history for agent (convert our format to AgentMessage format)
|
||||
|
|
@ -736,11 +749,13 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
|||
});
|
||||
};
|
||||
let pendingUpdate = false;
|
||||
let rafHandle: number | null = null;
|
||||
const scheduleMessageUpdate = () => {
|
||||
if (pendingUpdate) return;
|
||||
pendingUpdate = true;
|
||||
requestAnimationFrame(() => {
|
||||
rafHandle = requestAnimationFrame(() => {
|
||||
pendingUpdate = false;
|
||||
rafHandle = null;
|
||||
updateMessage();
|
||||
});
|
||||
};
|
||||
|
|
@ -887,7 +902,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
|||
if (idx < 0) {
|
||||
idx = toolCallsForMessage.findIndex((t) => t.name === tc.name && !t.result);
|
||||
}
|
||||
if (idx >= 0) {
|
||||
if (idx >= 0 && toolCallsForMessage[idx].status !== 'stopped') {
|
||||
toolCallsForMessage[idx] = {
|
||||
...toolCallsForMessage[idx],
|
||||
result: tc.result,
|
||||
|
|
@ -903,7 +918,11 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
|||
(s.toolCall.id === tc.id ||
|
||||
(s.toolCall.name === tc.name && s.toolCall.status === 'running')),
|
||||
);
|
||||
if (stepIdx >= 0 && stepsForMessage[stepIdx].toolCall) {
|
||||
if (
|
||||
stepIdx >= 0 &&
|
||||
stepsForMessage[stepIdx].toolCall &&
|
||||
stepsForMessage[stepIdx].toolCall!.status !== 'stopped'
|
||||
) {
|
||||
stepsForMessage[stepIdx] = {
|
||||
...stepsForMessage[stepIdx],
|
||||
toolCall: {
|
||||
|
|
@ -924,6 +943,8 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
|||
targetIdx = prev.findIndex((t) => t.name === tc.name && !t.result);
|
||||
}
|
||||
if (targetIdx >= 0) {
|
||||
const target = prev[targetIdx];
|
||||
if (target.status === 'stopped') return prev;
|
||||
return prev.map((t, i) =>
|
||||
i === targetIdx ? { ...t, result: tc.result, status: 'completed' } : t,
|
||||
);
|
||||
|
|
@ -1022,13 +1043,24 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
|||
const { streamAgentResponse } = await import('../core/llm/agent');
|
||||
for await (const chunk of streamAgentResponse(agent, history, {
|
||||
captureHistory: providerCapabilities.preserveAssistantTranscript,
|
||||
signal: chatAbortController.signal,
|
||||
})) {
|
||||
if (chunk.type === 'cancelled') {
|
||||
break;
|
||||
}
|
||||
onChunk(chunk);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setAgentError(message);
|
||||
if (!chatAbortController.signal.aborted) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setAgentError(message);
|
||||
}
|
||||
} finally {
|
||||
if (rafHandle != null) {
|
||||
cancelAnimationFrame(rafHandle);
|
||||
rafHandle = null;
|
||||
}
|
||||
chatStateRef.current = 'idle';
|
||||
setIsChatLoading(false);
|
||||
setCurrentToolCalls([]);
|
||||
}
|
||||
|
|
@ -1044,22 +1076,56 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
|||
clearAIToolHighlights,
|
||||
graph,
|
||||
embeddingStatus,
|
||||
isChatLoading,
|
||||
],
|
||||
);
|
||||
|
||||
const stopChatResponse = useCallback(() => {
|
||||
if (isChatLoading) {
|
||||
// Agent streaming will be interrupted by the AbortController in sendChatMessage
|
||||
setIsChatLoading(false);
|
||||
setCurrentToolCalls([]);
|
||||
}
|
||||
}, [isChatLoading]);
|
||||
if (!chatAbortRef.current) return;
|
||||
|
||||
chatStateRef.current = 'aborting';
|
||||
chatAbortRef.current.abort();
|
||||
chatAbortRef.current = null;
|
||||
|
||||
const stoppedLabel = i18n.t('chat:stopped');
|
||||
const markStoppedToolCall = (tc: ToolCallInfo): ToolCallInfo =>
|
||||
tc.status === 'running' || tc.status === 'pending'
|
||||
? { ...tc, status: 'stopped', result: stoppedLabel }
|
||||
: tc;
|
||||
|
||||
setCurrentToolCalls((prev) => prev.map(markStoppedToolCall));
|
||||
|
||||
setChatMessages((prev) => {
|
||||
const lastAssistantIdx = [...prev]
|
||||
.map((m, i) => (m.role === 'assistant' ? i : -1))
|
||||
.filter((i) => i >= 0)
|
||||
.pop();
|
||||
if (lastAssistantIdx === undefined) return prev;
|
||||
|
||||
const message = prev[lastAssistantIdx];
|
||||
|
||||
const updated: ChatMessage = {
|
||||
...message,
|
||||
toolCalls: message.toolCalls?.map(markStoppedToolCall),
|
||||
steps: message.steps?.map((step) =>
|
||||
step.type === 'tool_call' && step.toolCall
|
||||
? { ...step, toolCall: markStoppedToolCall(step.toolCall) }
|
||||
: step,
|
||||
),
|
||||
};
|
||||
return prev.map((m, i) => (i === lastAssistantIdx ? updated : m));
|
||||
});
|
||||
|
||||
setIsChatLoading(false);
|
||||
}, []);
|
||||
|
||||
const clearChat = useCallback(() => {
|
||||
chatAbortRef.current?.abort();
|
||||
chatAbortRef.current = null;
|
||||
chatStateRef.current = 'idle';
|
||||
setChatMessages([]);
|
||||
setCurrentToolCalls([]);
|
||||
setAgentError(null);
|
||||
setIsChatLoading(false);
|
||||
}, []);
|
||||
|
||||
// Switch to a different repo on the connected server
|
||||
|
|
@ -1266,6 +1332,8 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
|||
toggleEdgeVisibility,
|
||||
depthFilter,
|
||||
setDepthFilter,
|
||||
graphViewMode,
|
||||
setGraphViewMode,
|
||||
highlightedNodeIds,
|
||||
setHighlightedNodeIds,
|
||||
aiCitationHighlightedNodeIds,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
307
gitnexus-web/src/lib/circles-layout.ts
Normal file
307
gitnexus-web/src/lib/circles-layout.ts
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
import type { KnowledgeGraph } from '../core/graph/types';
|
||||
import type { GraphNode, NodeLabel } from 'gitnexus-shared';
|
||||
import { NODE_SIZES } from './constants';
|
||||
|
||||
export interface CirclesNodePosition {
|
||||
x: number;
|
||||
y: number;
|
||||
size: number;
|
||||
/** Logical ring index 0 (innermost) … RING_COUNT-1 (outermost) */
|
||||
ring: number;
|
||||
/** Angle in radians, stored so the physics can use it as an anchor */
|
||||
angle: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Configurable constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Target radius (px) for each ring. Ring 0 is innermost. */
|
||||
export const CIRCLES_RING_RADII = [90, 240, 420, 620] as const;
|
||||
|
||||
/**
|
||||
* Half-width of the allowed radial band around each ring centre.
|
||||
* Keep this small enough that adjacent rings never overlap.
|
||||
* Current ring gaps: 150 / 180 / 200 px → band = 45 leaves 60-110 px of clear air.
|
||||
*/
|
||||
export const CIRCLES_BAND_HALF = 45;
|
||||
|
||||
/** Number of rings (= number of layers). */
|
||||
export const RING_COUNT = CIRCLES_RING_RADII.length; // 4
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Layer assignment — identical to tree-layout so the same node types
|
||||
// end up in the same conceptual layer.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TYPE_TO_RING: Record<string, number> = {
|
||||
// Ring 0 – innermost: structural containers
|
||||
Project: 0,
|
||||
Package: 0,
|
||||
Module: 0,
|
||||
Folder: 0,
|
||||
Namespace: 0,
|
||||
|
||||
// Ring 1 – files
|
||||
File: 1,
|
||||
Section: 1,
|
||||
Import: 1,
|
||||
Route: 1,
|
||||
Tool: 1,
|
||||
|
||||
// Ring 2 – type definitions
|
||||
Class: 2,
|
||||
Interface: 2,
|
||||
Enum: 2,
|
||||
Type: 2,
|
||||
Struct: 2,
|
||||
Trait: 2,
|
||||
Union: 2,
|
||||
Record: 2,
|
||||
Typedef: 2,
|
||||
Template: 2,
|
||||
TypeAlias: 2,
|
||||
|
||||
// Ring 3 – outermost: functions / methods / variables
|
||||
Function: 3,
|
||||
Method: 3,
|
||||
Impl: 3,
|
||||
Delegate: 3,
|
||||
Constructor: 3,
|
||||
Variable: 3,
|
||||
Const: 3,
|
||||
Static: 3,
|
||||
Property: 3,
|
||||
Decorator: 3,
|
||||
Annotation: 3,
|
||||
Macro: 3,
|
||||
CodeElement: 3,
|
||||
};
|
||||
|
||||
const DEFAULT_RING = 1;
|
||||
|
||||
/** Hierarchy edges used for angular-allocation grouping. */
|
||||
export const CIRCLES_HIERARCHY_RELATIONS = new Set([
|
||||
'CONTAINS',
|
||||
'DEFINES',
|
||||
'HAS_METHOD',
|
||||
'HAS_PROPERTY',
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getNodeRing(node: GraphNode): number {
|
||||
return TYPE_TO_RING[node.label] ?? DEFAULT_RING;
|
||||
}
|
||||
|
||||
function calculateNodeSize(ring: number, nodeType: NodeLabel): number {
|
||||
const baseSize = NODE_SIZES[nodeType] || 6;
|
||||
const ringMultiplier = Math.max(0.6, 1 - ring * 0.12);
|
||||
return baseSize * ringMultiplier;
|
||||
}
|
||||
|
||||
function deterministicHash(str: string): number {
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = (hash << 5) + hash + str.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
return (Math.abs(hash) % 10000) / 10000;
|
||||
}
|
||||
|
||||
function buildHierarchyMaps(graph: KnowledgeGraph) {
|
||||
const childrenByParent = new Map<string, string[]>();
|
||||
const parentsByChild = new Map<string, string[]>();
|
||||
|
||||
for (const rel of graph.relationships) {
|
||||
if (!CIRCLES_HIERARCHY_RELATIONS.has(rel.type)) continue;
|
||||
|
||||
if (!childrenByParent.has(rel.sourceId)) childrenByParent.set(rel.sourceId, []);
|
||||
childrenByParent.get(rel.sourceId)!.push(rel.targetId);
|
||||
|
||||
if (!parentsByChild.has(rel.targetId)) parentsByChild.set(rel.targetId, []);
|
||||
parentsByChild.get(rel.targetId)!.push(rel.sourceId);
|
||||
}
|
||||
|
||||
return { childrenByParent, parentsByChild };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parent-centred angular allocation
|
||||
//
|
||||
// Each parent's children are placed in an arc CENTRED on the parent's own
|
||||
// angle, with arc size proportional to child count. This prevents the
|
||||
// sequential-concatenation bias (where the largest group's arc centre drifts
|
||||
// to 90° / 270° regardless of where the parent sits) that caused top-bottom
|
||||
// crowding in the previous sequential allocation.
|
||||
//
|
||||
// Overlapping initial arcs are fine — the physics simulation's angular spread
|
||||
// force resolves them during the simulation.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function initParentCentredAngles(
|
||||
graph: KnowledgeGraph,
|
||||
parentsByChild: Map<string, string[]>,
|
||||
): Map<string, CirclesNodePosition> {
|
||||
const positions = new Map<string, CirclesNodePosition>();
|
||||
|
||||
// Group nodes by ring
|
||||
const nodesByRing: GraphNode[][] = Array.from({ length: RING_COUNT }, () => []);
|
||||
const nodeRingMap = new Map<string, number>();
|
||||
|
||||
for (const node of graph.nodes) {
|
||||
const ring = getNodeRing(node);
|
||||
if (ring >= 0 && ring < RING_COUNT) {
|
||||
nodesByRing[ring].push(node);
|
||||
nodeRingMap.set(node.id, ring);
|
||||
}
|
||||
}
|
||||
|
||||
const TWO_PI = Math.PI * 2;
|
||||
|
||||
// --- Ring 0: sorted alphabetically, evenly spaced around full circle ---
|
||||
const ring0Nodes = [...nodesByRing[0]].sort((a, b) =>
|
||||
a.properties.name.localeCompare(b.properties.name),
|
||||
);
|
||||
|
||||
if (ring0Nodes.length > 0) {
|
||||
const count = ring0Nodes.length;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const node = ring0Nodes[i];
|
||||
const angle = (i / count) * TWO_PI;
|
||||
const r = CIRCLES_RING_RADII[0];
|
||||
positions.set(node.id, {
|
||||
x: r * Math.cos(angle),
|
||||
y: r * Math.sin(angle),
|
||||
size: calculateNodeSize(0, node.label),
|
||||
ring: 0,
|
||||
angle,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Rings 1-3: parent-centred arc placement ---
|
||||
for (let ring = 1; ring < RING_COUNT; ring++) {
|
||||
const ringNodes = nodesByRing[ring];
|
||||
if (ringNodes.length === 0) continue;
|
||||
|
||||
const r = CIRCLES_RING_RADII[ring];
|
||||
|
||||
// Find each node's primary parent: placed ancestor with highest ring index
|
||||
// (so a Method prefers its Class over a distant Package).
|
||||
const assignedParent = new Map<string, string>();
|
||||
for (const node of ringNodes) {
|
||||
const parents = parentsByChild.get(node.id) ?? [];
|
||||
let bestParent: string | null = null;
|
||||
let bestParentRing = -1;
|
||||
for (const p of parents) {
|
||||
if (!positions.has(p)) continue;
|
||||
const pRing = nodeRingMap.get(p) ?? -1;
|
||||
if (pRing > bestParentRing) {
|
||||
bestParentRing = pRing;
|
||||
bestParent = p;
|
||||
}
|
||||
}
|
||||
if (bestParent) assignedParent.set(node.id, bestParent);
|
||||
}
|
||||
|
||||
// Bucket into parent groups and orphans
|
||||
const childrenOfParent = new Map<string, GraphNode[]>();
|
||||
const orphans: GraphNode[] = [];
|
||||
|
||||
for (const node of ringNodes) {
|
||||
const p = assignedParent.get(node.id);
|
||||
if (!p) {
|
||||
orphans.push(node);
|
||||
} else {
|
||||
if (!childrenOfParent.has(p)) childrenOfParent.set(p, []);
|
||||
childrenOfParent.get(p)!.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
for (const children of childrenOfParent.values()) {
|
||||
children.sort((a, b) => a.properties.name.localeCompare(b.properties.name));
|
||||
}
|
||||
orphans.sort((a, b) => a.properties.name.localeCompare(b.properties.name));
|
||||
|
||||
const totalParented = ringNodes.length - orphans.length;
|
||||
const parentedFraction = totalParented > 0 ? totalParented / ringNodes.length : 0;
|
||||
|
||||
// Place each parent's children in an arc centred on the parent's angle.
|
||||
// Arc size ∝ child count relative to all parented nodes.
|
||||
for (const [parentId, children] of childrenOfParent) {
|
||||
if (children.length === 0) continue;
|
||||
|
||||
const parentAngle = positions.get(parentId)?.angle ?? 0;
|
||||
const slotArc = (children.length / totalParented) * parentedFraction * TWO_PI;
|
||||
const startAngle = parentAngle - slotArc / 2;
|
||||
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const angle = startAngle + (i + 0.5) * (slotArc / children.length);
|
||||
positions.set(children[i].id, {
|
||||
x: r * Math.cos(angle),
|
||||
y: r * Math.sin(angle),
|
||||
size: calculateNodeSize(ring, children[i].label),
|
||||
ring,
|
||||
angle,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Orphans: spread evenly in their proportional arc, centred at angle = π
|
||||
// (left side), away from the 0° / ±π boundary to avoid wrapping artefacts.
|
||||
if (orphans.length > 0) {
|
||||
const orphanFraction = orphans.length / ringNodes.length;
|
||||
const orphanArc = orphanFraction * TWO_PI;
|
||||
// Centre orphan arc at π so it doesn't overlap with the typical 0° cluster
|
||||
const orphanStart = Math.PI - orphanArc / 2;
|
||||
for (let i = 0; i < orphans.length; i++) {
|
||||
const angle = orphanStart + (i + 0.5) * (orphanArc / orphans.length);
|
||||
positions.set(orphans[i].id, {
|
||||
x: r * Math.cos(angle),
|
||||
y: r * Math.sin(angle),
|
||||
size: calculateNodeSize(ring, orphans[i].label),
|
||||
ring,
|
||||
angle,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return positions;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Circles view layout: concentric rings with parent-centred angular allocation.
|
||||
*
|
||||
* Ring 0 (innermost) = Folders/Packages
|
||||
* Ring 1 = Files
|
||||
* Ring 2 = Classes/Interfaces
|
||||
* Ring 3 (outermost) = Functions/Methods/Variables
|
||||
*
|
||||
* Returns initial positions; the physics simulation in useSigma.ts refines
|
||||
* them using radial gravity + hard band clamping, angular spread, and 2D
|
||||
* repulsion — identical in structure to the tree-view physics.
|
||||
*/
|
||||
export function calculateCirclesLayout(graph: KnowledgeGraph): Map<string, CirclesNodePosition> {
|
||||
const { parentsByChild } = buildHierarchyMaps(graph);
|
||||
|
||||
// 1. Parent-centred angular allocation — no top/bottom bias
|
||||
const positions = initParentCentredAngles(graph, parentsByChild);
|
||||
|
||||
// 2. Subtle radial jitter only — angular jitter would fight the centred placement
|
||||
for (const [nodeId, pos] of positions) {
|
||||
const jitter = (deterministicHash(nodeId + 'r') - 0.5) * 10; // ±10 px
|
||||
const r = CIRCLES_RING_RADII[pos.ring] + jitter;
|
||||
pos.x = r * Math.cos(pos.angle);
|
||||
pos.y = r * Math.sin(pos.angle);
|
||||
}
|
||||
|
||||
return positions;
|
||||
}
|
||||
|
|
@ -101,7 +101,9 @@ export const getCommunityColor = (communityIndex: number): string => {
|
|||
return COMMUNITY_COLORS[communityIndex % COMMUNITY_COLORS.length];
|
||||
};
|
||||
|
||||
// Labels to show by default (hide imports and variables by default as they clutter)
|
||||
// Labels to show by default (hide imports by default as they clutter).
|
||||
// Property/Const are the Kotlin/Java equivalents of Variable — include them so
|
||||
// Kotlin repos don't appear to have no leaf nodes.
|
||||
export const DEFAULT_VISIBLE_LABELS: NodeLabel[] = [
|
||||
'Project',
|
||||
'Package',
|
||||
|
|
@ -111,6 +113,8 @@ export const DEFAULT_VISIBLE_LABELS: NodeLabel[] = [
|
|||
'Class',
|
||||
'Function',
|
||||
'Method',
|
||||
'Property', // Kotlin/Java fields (HAS_PROPERTY + DEFINES File→Property)
|
||||
'Const', // Top-level constants
|
||||
'Interface',
|
||||
'Enum',
|
||||
'Type',
|
||||
|
|
@ -127,6 +131,8 @@ export const FILTERABLE_LABELS: NodeLabel[] = [
|
|||
'Function',
|
||||
'Method',
|
||||
'Variable',
|
||||
'Property', // Kotlin/Java field nodes
|
||||
'Const',
|
||||
'Decorator',
|
||||
'Import',
|
||||
];
|
||||
|
|
|
|||
208
gitnexus-web/src/lib/graph-adapter.test.ts
Normal file
208
gitnexus-web/src/lib/graph-adapter.test.ts
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { knowledgeGraphToTreeGraphology, knowledgeGraphToCirclesGraphology } from './graph-adapter';
|
||||
import type { KnowledgeGraph } from '../core/graph/types';
|
||||
import type { GraphNode } from 'gitnexus-shared';
|
||||
import { EDGE_INFO } from './constants';
|
||||
|
||||
function makeNode(id: string, label: string, name: string): GraphNode {
|
||||
return {
|
||||
id,
|
||||
label: label as any,
|
||||
properties: { name, filePath: '', startLine: 1, endLine: 1 },
|
||||
};
|
||||
}
|
||||
|
||||
describe('knowledgeGraphToTreeGraphology', () => {
|
||||
it('should create a graph with tree layout', () => {
|
||||
const graph: KnowledgeGraph = {
|
||||
nodes: [
|
||||
makeNode('root', 'Project', 'MyProject'),
|
||||
makeNode('folder', 'Folder', 'src'),
|
||||
makeNode('file', 'File', 'main.ts'),
|
||||
],
|
||||
relationships: [
|
||||
{ id: 'r1', type: 'CONTAINS', sourceId: 'root', targetId: 'folder' },
|
||||
{ id: 'r2', type: 'CONTAINS', sourceId: 'folder', targetId: 'file' },
|
||||
{ id: 'r3', type: 'CALLS', sourceId: 'file', targetId: 'root' },
|
||||
],
|
||||
};
|
||||
|
||||
const sigmaGraph = knowledgeGraphToTreeGraphology(graph);
|
||||
|
||||
expect(sigmaGraph.hasNode('root')).toBe(true);
|
||||
expect(sigmaGraph.hasNode('folder')).toBe(true);
|
||||
expect(sigmaGraph.hasNode('file')).toBe(true);
|
||||
|
||||
const rootAttrs = sigmaGraph.getNodeAttributes('root');
|
||||
const folderAttrs = sigmaGraph.getNodeAttributes('folder');
|
||||
const fileAttrs = sigmaGraph.getNodeAttributes('file');
|
||||
|
||||
// Tree view is inverted vertically, so files sit above containers.
|
||||
expect(fileAttrs.y).toBeLessThan(rootAttrs.y);
|
||||
expect(fileAttrs.y).toBeLessThan(folderAttrs.y);
|
||||
|
||||
// Nodes should have reasonable sizes
|
||||
expect(rootAttrs.size).toBeGreaterThan(2);
|
||||
expect(folderAttrs.size).toBeGreaterThan(2);
|
||||
expect(fileAttrs.size).toBeGreaterThan(2);
|
||||
|
||||
expect(rootAttrs.treeAnchorX).toBe(rootAttrs.x);
|
||||
expect(rootAttrs.treeAnchorY).toBe(rootAttrs.y);
|
||||
expect(rootAttrs.treeLayer).toBe(0);
|
||||
expect(fileAttrs.treeLayer).toBe(1);
|
||||
});
|
||||
|
||||
it('should style hierarchy edges differently from cross-cutting edges', () => {
|
||||
const graph: KnowledgeGraph = {
|
||||
nodes: [makeNode('a', 'Function', 'fnA'), makeNode('b', 'Function', 'fnB')],
|
||||
relationships: [
|
||||
{ id: 'r1', type: 'CONTAINS', sourceId: 'a', targetId: 'b' },
|
||||
{ id: 'r2', type: 'CALLS', sourceId: 'a', targetId: 'b' },
|
||||
],
|
||||
};
|
||||
|
||||
const sigmaGraph = knowledgeGraphToTreeGraphology(graph);
|
||||
|
||||
// MultiGraph allows multiple edges per pair — both CONTAINS and CALLS must survive.
|
||||
expect(sigmaGraph.size).toBe(2);
|
||||
|
||||
const attrsByType = new Map<string, { isHierarchyEdge?: boolean; color: string }>();
|
||||
sigmaGraph.forEachEdge((_edge, attrs) => {
|
||||
attrsByType.set(attrs.relationType, attrs);
|
||||
});
|
||||
|
||||
const containsAttrs = attrsByType.get('CONTAINS');
|
||||
expect(containsAttrs).toBeDefined();
|
||||
expect(containsAttrs!.isHierarchyEdge).toBe(true);
|
||||
expect(containsAttrs!.color).toBe(EDGE_INFO.CONTAINS.color);
|
||||
|
||||
const callsAttrs = attrsByType.get('CALLS');
|
||||
expect(callsAttrs).toBeDefined();
|
||||
expect(callsAttrs!.isHierarchyEdge).toBe(false);
|
||||
expect(callsAttrs!.color).toBe(EDGE_INFO.CALLS.color);
|
||||
});
|
||||
|
||||
it('should treat imports as cross-cutting edges in tree view', () => {
|
||||
const graph: KnowledgeGraph = {
|
||||
nodes: [makeNode('a', 'File', 'a.ts'), makeNode('b', 'File', 'b.ts')],
|
||||
relationships: [{ id: 'r1', type: 'IMPORTS', sourceId: 'a', targetId: 'b' }],
|
||||
};
|
||||
|
||||
const sigmaGraph = knowledgeGraphToTreeGraphology(graph);
|
||||
|
||||
sigmaGraph.forEachEdge((edge, attrs) => {
|
||||
if (attrs.relationType === 'IMPORTS') {
|
||||
expect(attrs.isHierarchyEdge).toBe(false);
|
||||
expect(attrs.color).toBe(EDGE_INFO.IMPORTS.color);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle a medium-sized graph without dropping nodes or edges', () => {
|
||||
// 2000 nodes + 4000 edges — exercises the adaptive spring iteration path (14 iters).
|
||||
// Structural assertion only: wall-clock timing is too variable across CI machines.
|
||||
const nodes: GraphNode[] = Array.from({ length: 2000 }, (_, i) =>
|
||||
makeNode(`n${i}`, i % 4 === 0 ? 'Folder' : i % 4 === 1 ? 'File' : 'Function', `node${i}`),
|
||||
);
|
||||
const relationships = Array.from({ length: 4000 }, (_, i) => ({
|
||||
id: `r${i}`,
|
||||
type: i % 3 === 0 ? 'CONTAINS' : 'CALLS',
|
||||
sourceId: `n${i % 2000}`,
|
||||
targetId: `n${(i + 7) % 2000}`,
|
||||
}));
|
||||
const graph: KnowledgeGraph = { nodes, relationships };
|
||||
|
||||
const sigmaGraph = knowledgeGraphToTreeGraphology(graph);
|
||||
|
||||
// All nodes that have a tree-layout position must be present in the output.
|
||||
expect(sigmaGraph.order).toBe(2000);
|
||||
// Every relationship whose source and target both exist should produce an edge.
|
||||
// Self-loops (sourceId === targetId) are excluded — the adapter skips them.
|
||||
const selfLoops = relationships.filter((r) => r.sourceId === r.targetId).length;
|
||||
expect(sigmaGraph.size).toBe(relationships.length - selfLoops);
|
||||
});
|
||||
});
|
||||
|
||||
describe('knowledgeGraphToCirclesGraphology', () => {
|
||||
it('should place nodes into ring positions based on their type', () => {
|
||||
const graph: KnowledgeGraph = {
|
||||
nodes: [
|
||||
makeNode('folder', 'Folder', 'src'),
|
||||
makeNode('file', 'File', 'main.ts'),
|
||||
makeNode('fn', 'Function', 'doSomething'),
|
||||
],
|
||||
relationships: [
|
||||
{ id: 'r1', type: 'CONTAINS', sourceId: 'folder', targetId: 'file' },
|
||||
{ id: 'r2', type: 'CALLS', sourceId: 'file', targetId: 'fn' },
|
||||
],
|
||||
};
|
||||
|
||||
const sigmaGraph = knowledgeGraphToCirclesGraphology(graph);
|
||||
|
||||
expect(sigmaGraph.hasNode('folder')).toBe(true);
|
||||
expect(sigmaGraph.hasNode('file')).toBe(true);
|
||||
expect(sigmaGraph.hasNode('fn')).toBe(true);
|
||||
|
||||
// Each node carries its ring index and anchor coordinates
|
||||
const folderAttrs = sigmaGraph.getNodeAttributes('folder');
|
||||
const fileAttrs = sigmaGraph.getNodeAttributes('file');
|
||||
const fnAttrs = sigmaGraph.getNodeAttributes('fn');
|
||||
|
||||
expect(typeof folderAttrs.circlesRing).toBe('number');
|
||||
expect(typeof folderAttrs.circlesAnchorX).toBe('number');
|
||||
expect(typeof folderAttrs.circlesAnchorY).toBe('number');
|
||||
|
||||
// Folders/Packages live in ring 0 (innermost); Files in ring 1; Functions in ring 3.
|
||||
expect(folderAttrs.circlesRing).toBe(0);
|
||||
expect(fileAttrs.circlesRing).toBe(1);
|
||||
expect(fnAttrs.circlesRing).toBe(3);
|
||||
|
||||
// Tree anchor attributes must NOT be set in circles mode
|
||||
expect(folderAttrs.treeAnchorX).toBeUndefined();
|
||||
expect(folderAttrs.treeAnchorY).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should style hierarchy edges differently from cross-cutting edges', () => {
|
||||
const graph: KnowledgeGraph = {
|
||||
nodes: [makeNode('a', 'File', 'a.ts'), makeNode('b', 'Function', 'fn')],
|
||||
relationships: [
|
||||
{ id: 'r1', type: 'CONTAINS', sourceId: 'a', targetId: 'b' },
|
||||
{ id: 'r2', type: 'CALLS', sourceId: 'a', targetId: 'b' },
|
||||
],
|
||||
};
|
||||
|
||||
// MultiGraph allows multiple edges per pair — both CONTAINS and CALLS must survive.
|
||||
const sigmaGraph = knowledgeGraphToCirclesGraphology(graph);
|
||||
|
||||
expect(sigmaGraph.size).toBe(2);
|
||||
|
||||
const attrsByType = new Map<string, { isHierarchyEdge?: boolean; color: string }>();
|
||||
sigmaGraph.forEachEdge((_, attrs) => {
|
||||
attrsByType.set(attrs.relationType, attrs);
|
||||
});
|
||||
|
||||
const containsAttrs = attrsByType.get('CONTAINS');
|
||||
expect(containsAttrs).toBeDefined();
|
||||
expect(containsAttrs!.isHierarchyEdge).toBe(true);
|
||||
expect(containsAttrs!.color).toBe(EDGE_INFO.CONTAINS.color);
|
||||
|
||||
const callsAttrs = attrsByType.get('CALLS');
|
||||
expect(callsAttrs).toBeDefined();
|
||||
expect(callsAttrs!.isHierarchyEdge).toBe(false);
|
||||
expect(callsAttrs!.color).toBe(EDGE_INFO.CALLS.color);
|
||||
});
|
||||
|
||||
it('should treat CALLS as a cross-cutting edge in circles view', () => {
|
||||
const graph: KnowledgeGraph = {
|
||||
nodes: [makeNode('a', 'Function', 'fnA'), makeNode('b', 'Function', 'fnB')],
|
||||
relationships: [{ id: 'r1', type: 'CALLS', sourceId: 'a', targetId: 'b' }],
|
||||
};
|
||||
|
||||
const sigmaGraph = knowledgeGraphToCirclesGraphology(graph);
|
||||
|
||||
sigmaGraph.forEachEdge((_, attrs) => {
|
||||
expect(attrs.isHierarchyEdge).toBe(false);
|
||||
expect(attrs.color).toBe(EDGE_INFO.CALLS.color);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
import Graph from 'graphology';
|
||||
import Graph, { MultiGraph } from 'graphology';
|
||||
import type { NodeLabel } from 'gitnexus-shared';
|
||||
import type { KnowledgeGraph } from '../core/graph/types';
|
||||
import { NODE_COLORS, NODE_SIZES, getCommunityColor } from './constants';
|
||||
import { EDGE_INFO, NODE_COLORS, NODE_SIZES, getCommunityColor } from './constants';
|
||||
import { calculateTreeLayout } from './tree-layout';
|
||||
import { calculateCirclesLayout } from './circles-layout';
|
||||
|
||||
export interface SigmaNodeAttributes {
|
||||
x: number;
|
||||
|
|
@ -17,6 +19,13 @@ export interface SigmaNodeAttributes {
|
|||
zIndex?: number;
|
||||
highlighted?: boolean;
|
||||
mass?: number; // ForceAtlas2 mass - higher = more repulsion
|
||||
treeAnchorX?: number;
|
||||
treeAnchorY?: number;
|
||||
treeLayer?: number;
|
||||
circlesAnchorX?: number;
|
||||
circlesAnchorY?: number;
|
||||
circlesRing?: number;
|
||||
circlesAnchorAngle?: number;
|
||||
community?: number; // Community index from Leiden algorithm
|
||||
communityColor?: string; // Color assigned by community
|
||||
}
|
||||
|
|
@ -28,6 +37,7 @@ export interface SigmaEdgeAttributes {
|
|||
type?: string;
|
||||
curvature?: number;
|
||||
zIndex?: number;
|
||||
isHierarchyEdge?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -91,18 +101,21 @@ export const knowledgeGraphToGraphology = (
|
|||
// Build parent-child map from hierarchy relationships
|
||||
// CONTAINS: Folder -> File
|
||||
// DEFINES: File -> Function/Class/Interface/Method
|
||||
// IMPORTS: File -> Import
|
||||
// parent -> children
|
||||
// parent -> children (used only for initial spatial seeding before FA2 runs)
|
||||
const parentToChildren = new Map<string, string[]>();
|
||||
// child -> parent
|
||||
const childToParent = new Map<string, string>();
|
||||
|
||||
const hierarchyRelations = new Set(['CONTAINS', 'DEFINES', 'IMPORTS']);
|
||||
// IMPORTS is not a true structural hierarchy, but treating it as a spatial
|
||||
// seed helps FA2 converge for import-heavy codebases: files that import each
|
||||
// other start near each other, so the simulation doesn't have to close many
|
||||
// long cross-package springs from scratch.
|
||||
const spatialSeedRelations = new Set(['CONTAINS', 'DEFINES', 'IMPORTS']);
|
||||
|
||||
knowledgeGraph.relationships.forEach((rel) => {
|
||||
// These relationships represent parent-child hierarchy for positioning
|
||||
if (hierarchyRelations.has(rel.type)) {
|
||||
// source CONTAINS/DEFINES/IMPORTS target, so source is parent
|
||||
// These relationships determine initial node positions (not graph semantics)
|
||||
if (spatialSeedRelations.has(rel.type)) {
|
||||
// source CONTAINS/DEFINES/IMPORTS target → source acts as spatial parent
|
||||
if (!parentToChildren.has(rel.sourceId)) {
|
||||
parentToChildren.set(rel.sourceId, []);
|
||||
}
|
||||
|
|
@ -295,23 +308,202 @@ export const knowledgeGraphToGraphology = (
|
|||
// TYPE RELATIONSHIPS - Warm colors (OOP)
|
||||
EXTENDS: { color: '#c2410c', sizeMultiplier: 1.0 }, // Orange - extension
|
||||
IMPLEMENTS: { color: '#be185d', sizeMultiplier: 0.9 }, // Pink - interface implementation
|
||||
|
||||
// KOTLIN/JAVA HIERARCHY — same hues as their logical equivalents so force
|
||||
// mode renders these consistently with tree/circles view.
|
||||
HAS_METHOD: { color: EDGE_INFO.DEFINES.color, sizeMultiplier: 0.4 }, // Class→Method (≈ DEFINES)
|
||||
HAS_PROPERTY: { color: EDGE_INFO.CONTAINS.color, sizeMultiplier: 0.35 }, // Class→Property (≈ CONTAINS)
|
||||
};
|
||||
|
||||
// Two-pass insertion so hierarchy/DEFINES edges are drawn first (behind)
|
||||
// and cross-edges (CALLS, IMPORTS, EXTENDS) are drawn on top.
|
||||
const BACKGROUND_EDGE_TYPES = new Set(['CONTAINS', 'DEFINES', 'HAS_METHOD', 'HAS_PROPERTY']);
|
||||
|
||||
const addEdge = (rel: (typeof knowledgeGraph.relationships)[number]) => {
|
||||
if (!graph.hasNode(rel.sourceId) || !graph.hasNode(rel.targetId)) return;
|
||||
if (graph.hasEdge(rel.sourceId, rel.targetId)) return;
|
||||
const style = EDGE_STYLES[rel.type] || { color: '#4a4a5a', sizeMultiplier: 0.5 };
|
||||
const curvature = 0.12 + Math.random() * 0.08;
|
||||
graph.addEdge(rel.sourceId, rel.targetId, {
|
||||
size: edgeBaseSize * style.sizeMultiplier,
|
||||
color: style.color,
|
||||
relationType: rel.type,
|
||||
type: 'curved',
|
||||
curvature,
|
||||
});
|
||||
};
|
||||
|
||||
// Pass 1: background (hierarchy) edges — rendered behind
|
||||
knowledgeGraph.relationships.forEach((rel) => {
|
||||
if (BACKGROUND_EDGE_TYPES.has(rel.type)) addEdge(rel);
|
||||
});
|
||||
// Pass 2: foreground (cross) edges — rendered on top
|
||||
knowledgeGraph.relationships.forEach((rel) => {
|
||||
if (!BACKGROUND_EDGE_TYPES.has(rel.type)) addEdge(rel);
|
||||
});
|
||||
|
||||
return graph;
|
||||
};
|
||||
|
||||
export const knowledgeGraphToTreeGraphology = (
|
||||
knowledgeGraph: KnowledgeGraph,
|
||||
): Graph<SigmaNodeAttributes, SigmaEdgeAttributes> => {
|
||||
const graph = new MultiGraph<SigmaNodeAttributes, SigmaEdgeAttributes>();
|
||||
const nodeCount = knowledgeGraph.nodes.length;
|
||||
const positions = calculateTreeLayout(knowledgeGraph);
|
||||
|
||||
// Add nodes with tree positions
|
||||
for (const node of knowledgeGraph.nodes) {
|
||||
const pos = positions.get(node.id);
|
||||
if (!pos) continue;
|
||||
|
||||
const baseSize = NODE_SIZES[node.label] || 8;
|
||||
const scaledSize = getScaledNodeSize(baseSize, nodeCount);
|
||||
const finalSize = Math.max(2, pos.size * (scaledSize / baseSize));
|
||||
|
||||
graph.addNode(node.id, {
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
size: finalSize,
|
||||
color: NODE_COLORS[node.label] || '#9ca3af',
|
||||
label: node.properties.name,
|
||||
nodeType: node.label,
|
||||
filePath: node.properties.filePath,
|
||||
startLine: node.properties.startLine,
|
||||
endLine: node.properties.endLine,
|
||||
hidden: false,
|
||||
mass: 1, // No force layout in tree view
|
||||
treeAnchorX: pos.x,
|
||||
treeAnchorY: pos.y,
|
||||
treeLayer: pos.depth,
|
||||
});
|
||||
}
|
||||
|
||||
// Add edges with tree-specific styling
|
||||
const edgeBaseSize = nodeCount > 20000 ? 0.4 : nodeCount > 5000 ? 0.6 : 1.0;
|
||||
|
||||
const HIERARCHY_EDGE_STYLES: Record<string, { color: string; sizeMultiplier: number }> = {
|
||||
CONTAINS: { color: EDGE_INFO.CONTAINS.color, sizeMultiplier: 0.3 },
|
||||
DEFINES: { color: EDGE_INFO.DEFINES.color, sizeMultiplier: 0.3 },
|
||||
HAS_METHOD: { color: EDGE_INFO.DEFINES.color, sizeMultiplier: 0.3 }, // Kotlin Class→Method hierarchy
|
||||
HAS_PROPERTY: { color: EDGE_INFO.CONTAINS.color, sizeMultiplier: 0.25 }, // Kotlin Class→Property hierarchy
|
||||
};
|
||||
|
||||
const CROSS_EDGE_STYLES: Record<string, { color: string; sizeMultiplier: number }> = {
|
||||
IMPORTS: { color: EDGE_INFO.IMPORTS.color, sizeMultiplier: 0.6 },
|
||||
CALLS: { color: EDGE_INFO.CALLS.color, sizeMultiplier: 0.8 },
|
||||
EXTENDS: { color: EDGE_INFO.EXTENDS.color, sizeMultiplier: 1.0 },
|
||||
IMPLEMENTS: { color: EDGE_INFO.IMPLEMENTS.color, sizeMultiplier: 0.9 },
|
||||
};
|
||||
|
||||
// Two-pass insertion: hierarchy edges first (rendered behind), cross-edges on top.
|
||||
// Dedup by relationship ID so CONTAINS + CALLS between the same pair both survive.
|
||||
const addedTreeRelIds = new Set<string>();
|
||||
const addTreeEdge = (rel: (typeof knowledgeGraph.relationships)[number]) => {
|
||||
if (!graph.hasNode(rel.sourceId) || !graph.hasNode(rel.targetId)) return;
|
||||
if (addedTreeRelIds.has(rel.id)) return;
|
||||
addedTreeRelIds.add(rel.id);
|
||||
const isHierarchy = HIERARCHY_EDGE_STYLES[rel.type] !== undefined;
|
||||
const style = isHierarchy
|
||||
? HIERARCHY_EDGE_STYLES[rel.type]
|
||||
: CROSS_EDGE_STYLES[rel.type] || { color: '#4a4a5a', sizeMultiplier: 0.5 };
|
||||
graph.addEdge(rel.sourceId, rel.targetId, {
|
||||
size: edgeBaseSize * style.sizeMultiplier,
|
||||
color: style.color,
|
||||
relationType: rel.type,
|
||||
type: 'curved',
|
||||
curvature: 0.1 + Math.random() * 0.1,
|
||||
isHierarchyEdge: isHierarchy,
|
||||
});
|
||||
};
|
||||
|
||||
knowledgeGraph.relationships.forEach((rel) => {
|
||||
if (graph.hasNode(rel.sourceId) && graph.hasNode(rel.targetId)) {
|
||||
if (!graph.hasEdge(rel.sourceId, rel.targetId)) {
|
||||
const style = EDGE_STYLES[rel.type] || { color: '#4a4a5a', sizeMultiplier: 0.5 };
|
||||
const curvature = 0.12 + Math.random() * 0.08;
|
||||
if (HIERARCHY_EDGE_STYLES[rel.type] !== undefined) addTreeEdge(rel);
|
||||
});
|
||||
knowledgeGraph.relationships.forEach((rel) => {
|
||||
if (HIERARCHY_EDGE_STYLES[rel.type] === undefined) addTreeEdge(rel);
|
||||
});
|
||||
|
||||
graph.addEdge(rel.sourceId, rel.targetId, {
|
||||
size: edgeBaseSize * style.sizeMultiplier,
|
||||
color: style.color,
|
||||
relationType: rel.type,
|
||||
type: 'curved',
|
||||
curvature: curvature,
|
||||
});
|
||||
}
|
||||
}
|
||||
return graph;
|
||||
};
|
||||
|
||||
export const knowledgeGraphToCirclesGraphology = (
|
||||
knowledgeGraph: KnowledgeGraph,
|
||||
): Graph<SigmaNodeAttributes, SigmaEdgeAttributes> => {
|
||||
const graph = new MultiGraph<SigmaNodeAttributes, SigmaEdgeAttributes>();
|
||||
const nodeCount = knowledgeGraph.nodes.length;
|
||||
const positions = calculateCirclesLayout(knowledgeGraph);
|
||||
|
||||
for (const node of knowledgeGraph.nodes) {
|
||||
const pos = positions.get(node.id);
|
||||
if (!pos) continue;
|
||||
|
||||
const baseSize = NODE_SIZES[node.label] || 8;
|
||||
const scaledSize = getScaledNodeSize(baseSize, nodeCount);
|
||||
const finalSize = Math.max(2, pos.size * (scaledSize / baseSize));
|
||||
|
||||
graph.addNode(node.id, {
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
size: finalSize,
|
||||
color: NODE_COLORS[node.label] || '#9ca3af',
|
||||
label: node.properties.name,
|
||||
nodeType: node.label,
|
||||
filePath: node.properties.filePath,
|
||||
startLine: node.properties.startLine,
|
||||
endLine: node.properties.endLine,
|
||||
hidden: false,
|
||||
mass: 1,
|
||||
circlesAnchorX: pos.x,
|
||||
circlesAnchorY: pos.y,
|
||||
circlesRing: pos.ring,
|
||||
circlesAnchorAngle: pos.angle,
|
||||
});
|
||||
}
|
||||
|
||||
const edgeBaseSize = nodeCount > 20000 ? 0.4 : nodeCount > 5000 ? 0.6 : 1.0;
|
||||
|
||||
// Reuse the same edge style maps as tree view
|
||||
const HIERARCHY_EDGE_STYLES: Record<string, { color: string; sizeMultiplier: number }> = {
|
||||
CONTAINS: { color: EDGE_INFO.CONTAINS.color, sizeMultiplier: 0.3 },
|
||||
DEFINES: { color: EDGE_INFO.DEFINES.color, sizeMultiplier: 0.3 },
|
||||
HAS_METHOD: { color: EDGE_INFO.DEFINES.color, sizeMultiplier: 0.3 },
|
||||
HAS_PROPERTY: { color: EDGE_INFO.CONTAINS.color, sizeMultiplier: 0.25 },
|
||||
};
|
||||
|
||||
const CROSS_EDGE_STYLES: Record<string, { color: string; sizeMultiplier: number }> = {
|
||||
IMPORTS: { color: EDGE_INFO.IMPORTS.color, sizeMultiplier: 0.6 },
|
||||
CALLS: { color: EDGE_INFO.CALLS.color, sizeMultiplier: 0.8 },
|
||||
EXTENDS: { color: EDGE_INFO.EXTENDS.color, sizeMultiplier: 1.0 },
|
||||
IMPLEMENTS: { color: EDGE_INFO.IMPLEMENTS.color, sizeMultiplier: 0.9 },
|
||||
};
|
||||
|
||||
// Two-pass insertion: hierarchy edges first (rendered behind), cross-edges on top.
|
||||
// Dedup by relationship ID so CONTAINS + CALLS between the same pair both survive.
|
||||
const addedCirclesRelIds = new Set<string>();
|
||||
const addCirclesEdge = (rel: (typeof knowledgeGraph.relationships)[number]) => {
|
||||
if (!graph.hasNode(rel.sourceId) || !graph.hasNode(rel.targetId)) return;
|
||||
if (addedCirclesRelIds.has(rel.id)) return;
|
||||
addedCirclesRelIds.add(rel.id);
|
||||
const isHierarchy = HIERARCHY_EDGE_STYLES[rel.type] !== undefined;
|
||||
const style = isHierarchy
|
||||
? HIERARCHY_EDGE_STYLES[rel.type]
|
||||
: CROSS_EDGE_STYLES[rel.type] || { color: '#4a4a5a', sizeMultiplier: 0.5 };
|
||||
graph.addEdge(rel.sourceId, rel.targetId, {
|
||||
size: edgeBaseSize * style.sizeMultiplier,
|
||||
color: style.color,
|
||||
relationType: rel.type,
|
||||
type: 'curved',
|
||||
curvature: 0.1 + Math.random() * 0.1,
|
||||
isHierarchyEdge: isHierarchy,
|
||||
});
|
||||
};
|
||||
|
||||
knowledgeGraph.relationships.forEach((rel) => {
|
||||
if (HIERARCHY_EDGE_STYLES[rel.type] !== undefined) addCirclesEdge(rel);
|
||||
});
|
||||
knowledgeGraph.relationships.forEach((rel) => {
|
||||
if (HIERARCHY_EDGE_STYLES[rel.type] === undefined) addCirclesEdge(rel);
|
||||
});
|
||||
|
||||
return graph;
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ export {
|
|||
Loader2,
|
||||
Maximize2,
|
||||
MousePointerClick,
|
||||
Network,
|
||||
PanelLeft,
|
||||
PanelLeftClose,
|
||||
PanelRightClose,
|
||||
|
|
|
|||
217
gitnexus-web/src/lib/tree-layout.test.ts
Normal file
217
gitnexus-web/src/lib/tree-layout.test.ts
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { calculateTreeLayout } from './tree-layout';
|
||||
import type { KnowledgeGraph } from '../core/graph/types';
|
||||
import type { GraphNode } from 'gitnexus-shared';
|
||||
|
||||
function makeNode(id: string, label: string, name: string): GraphNode {
|
||||
return {
|
||||
id,
|
||||
label: label as any,
|
||||
properties: { name, filePath: '', startLine: 1, endLine: 1 },
|
||||
};
|
||||
}
|
||||
|
||||
describe('calculateTreeLayout', () => {
|
||||
it('should place different types in correct layers', () => {
|
||||
const graph: KnowledgeGraph = {
|
||||
nodes: [
|
||||
makeNode('f1', 'Folder', 'src'),
|
||||
makeNode('file1', 'File', 'main.ts'),
|
||||
makeNode('cls1', 'Class', 'MyClass'),
|
||||
makeNode('fn1', 'Function', 'myFunc'),
|
||||
],
|
||||
relationships: [],
|
||||
};
|
||||
|
||||
const positions = calculateTreeLayout(graph);
|
||||
|
||||
const folderY = positions.get('f1')!.y;
|
||||
const fileY = positions.get('file1')!.y;
|
||||
const classY = positions.get('cls1')!.y;
|
||||
const funcY = positions.get('fn1')!.y;
|
||||
|
||||
// Layer ordering is visually inverted in tree view:
|
||||
// Function < Class < File < Folder
|
||||
expect(funcY).toBeLessThan(classY);
|
||||
expect(classY).toBeLessThan(fileY);
|
||||
expect(fileY).toBeLessThan(folderY);
|
||||
});
|
||||
|
||||
it('should arrange many same-type nodes in a grid within a layer', () => {
|
||||
const nodes: GraphNode[] = [];
|
||||
for (let i = 0; i < 40; i++) {
|
||||
nodes.push(makeNode(`fn${i}`, 'Function', `func${i}`));
|
||||
}
|
||||
|
||||
const graph: KnowledgeGraph = { nodes, relationships: [] };
|
||||
const positions = calculateTreeLayout(graph);
|
||||
|
||||
const xValues = nodes.map((n) => positions.get(n.id)!.x);
|
||||
const yValues = nodes.map((n) => positions.get(n.id)!.y);
|
||||
|
||||
// Should have multiple columns (spread horizontally)
|
||||
const uniqueX = [...new Set(xValues)].sort((a, b) => a - b);
|
||||
expect(uniqueX.length).toBeGreaterThan(3);
|
||||
|
||||
// Should have multiple rows (spread vertically within layer)
|
||||
const uniqueY = [...new Set(yValues)].sort((a, b) => a - b);
|
||||
expect(uniqueY.length).toBeGreaterThan(1);
|
||||
|
||||
// Overall width should be significant
|
||||
const minX = Math.min(...xValues);
|
||||
const maxX = Math.max(...xValues);
|
||||
expect(maxX - minX).toBeGreaterThan(500);
|
||||
|
||||
// Height spread within layer should be moderate (not a single line)
|
||||
const minY = Math.min(...yValues);
|
||||
const maxY = Math.max(...yValues);
|
||||
expect(maxY - minY).toBeGreaterThan(50);
|
||||
expect(maxY - minY).toBeLessThan(250); // But not too tall
|
||||
});
|
||||
|
||||
it('should sort nodes alphabetically within layers', () => {
|
||||
const graph: KnowledgeGraph = {
|
||||
nodes: [
|
||||
makeNode('z', 'Function', 'zFn'),
|
||||
makeNode('a', 'Function', 'aFn'),
|
||||
makeNode('m', 'Function', 'mFn'),
|
||||
],
|
||||
relationships: [],
|
||||
};
|
||||
|
||||
const positions = calculateTreeLayout(graph);
|
||||
|
||||
// In grid layout, 'a' should appear before 'm' and 'z' in reading order
|
||||
// (left-to-right, top-to-bottom)
|
||||
const aPos = positions.get('a')!;
|
||||
const mPos = positions.get('m')!;
|
||||
const zPos = positions.get('z')!;
|
||||
|
||||
// Reading order: a comes before m, which comes before z
|
||||
const aIndex = aPos.y * 10000 + aPos.x;
|
||||
const mIndex = mPos.y * 10000 + mPos.x;
|
||||
const zIndex = zPos.y * 10000 + zPos.x;
|
||||
|
||||
expect(aIndex).toBeLessThan(mIndex);
|
||||
expect(mIndex).toBeLessThan(zIndex);
|
||||
});
|
||||
|
||||
it('should place multiple node types in correct layers', () => {
|
||||
const graph: KnowledgeGraph = {
|
||||
nodes: [
|
||||
makeNode('folder', 'Folder', 'src'),
|
||||
makeNode('file', 'File', 'main.ts'),
|
||||
makeNode('iface', 'Interface', 'MyInterface'),
|
||||
makeNode('enum', 'Enum', 'MyEnum'),
|
||||
makeNode('method', 'Method', 'myMethod'),
|
||||
],
|
||||
relationships: [],
|
||||
};
|
||||
|
||||
const positions = calculateTreeLayout(graph);
|
||||
|
||||
// Folder now appears below files/types/methods in the inverted tree view
|
||||
expect(positions.get('file')!.y).toBeLessThan(positions.get('folder')!.y);
|
||||
|
||||
// File (layer 1) should be below Class/Interface/Enum (layer 2)
|
||||
expect(positions.get('iface')!.y).toBeLessThan(positions.get('file')!.y);
|
||||
expect(positions.get('enum')!.y).toBeLessThan(positions.get('file')!.y);
|
||||
|
||||
// Interface/Enum (layer 2) should be below Method (layer 3)
|
||||
expect(positions.get('method')!.y).toBeLessThan(positions.get('iface')!.y);
|
||||
expect(positions.get('method')!.y).toBeLessThan(positions.get('enum')!.y);
|
||||
});
|
||||
|
||||
it('should keep node sizes reasonable', () => {
|
||||
const graph: KnowledgeGraph = {
|
||||
nodes: [
|
||||
makeNode('folder', 'Folder', 'src'),
|
||||
makeNode('file', 'File', 'main.ts'),
|
||||
makeNode('fn', 'Function', 'myFunc'),
|
||||
],
|
||||
relationships: [],
|
||||
};
|
||||
|
||||
const positions = calculateTreeLayout(graph);
|
||||
|
||||
for (const id of ['folder', 'file', 'fn']) {
|
||||
expect(positions.get(id)!.size).toBeGreaterThan(2);
|
||||
expect(positions.get(id)!.size).toBeLessThan(25);
|
||||
}
|
||||
});
|
||||
|
||||
it('should spread sibling branches under their structural parent in auto mode', () => {
|
||||
const graph: KnowledgeGraph = {
|
||||
nodes: [
|
||||
makeNode('folder', 'Folder', 'apps'),
|
||||
makeNode('fileA', 'File', 'a.ts'),
|
||||
makeNode('fileB', 'File', 'b.ts'),
|
||||
makeNode('fileC', 'File', 'c.ts'),
|
||||
makeNode('fnA', 'Function', 'fnA'),
|
||||
makeNode('fnB', 'Function', 'fnB'),
|
||||
makeNode('fnC', 'Function', 'fnC'),
|
||||
],
|
||||
relationships: [
|
||||
{ id: 'r1', type: 'CONTAINS', sourceId: 'folder', targetId: 'fileA' },
|
||||
{ id: 'r2', type: 'CONTAINS', sourceId: 'folder', targetId: 'fileB' },
|
||||
{ id: 'r3', type: 'CONTAINS', sourceId: 'folder', targetId: 'fileC' },
|
||||
{ id: 'r4', type: 'DEFINES', sourceId: 'fileA', targetId: 'fnA' },
|
||||
{ id: 'r5', type: 'DEFINES', sourceId: 'fileB', targetId: 'fnB' },
|
||||
{ id: 'r6', type: 'DEFINES', sourceId: 'fileC', targetId: 'fnC' },
|
||||
],
|
||||
};
|
||||
|
||||
const positions = calculateTreeLayout(graph);
|
||||
const fileXs = ['fileA', 'fileB', 'fileC'].map((id) => positions.get(id)!.x);
|
||||
const fnXs = ['fnA', 'fnB', 'fnC'].map((id) => positions.get(id)!.x);
|
||||
|
||||
expect(Math.max(...fileXs) - Math.min(...fileXs)).toBeGreaterThan(120);
|
||||
expect(Math.max(...fnXs) - Math.min(...fnXs)).toBeGreaterThan(120);
|
||||
expect(Math.abs(positions.get('fileA')!.x - positions.get('fnA')!.x)).toBeLessThan(120);
|
||||
expect(Math.abs(positions.get('fileB')!.x - positions.get('fnB')!.x)).toBeLessThan(120);
|
||||
expect(Math.abs(positions.get('fileC')!.x - positions.get('fnC')!.x)).toBeLessThan(120);
|
||||
});
|
||||
|
||||
it('should let long edges pull connected nodes closer without breaking their layer', () => {
|
||||
const nodes = Array.from({ length: 10 }, (_, i) => makeNode(`fn${i}`, 'Function', `fn${i}`));
|
||||
|
||||
const baseline = calculateTreeLayout({ nodes, relationships: [] });
|
||||
const relaxed = calculateTreeLayout({
|
||||
nodes,
|
||||
relationships: [
|
||||
{ id: 'r1', type: 'CALLS', sourceId: 'fn0', targetId: 'fn9' },
|
||||
{ id: 'r2', type: 'CALLS', sourceId: 'fn1', targetId: 'fn8' },
|
||||
],
|
||||
});
|
||||
|
||||
const baselineDistance = Math.abs(baseline.get('fn0')!.x - baseline.get('fn9')!.x);
|
||||
const relaxedDistance = Math.abs(relaxed.get('fn0')!.x - relaxed.get('fn9')!.x);
|
||||
expect(relaxedDistance).toBeLessThan(baselineDistance);
|
||||
|
||||
const relaxedYValues = nodes.map((node) => relaxed.get(node.id)!.y);
|
||||
const minY = Math.min(...relaxedYValues);
|
||||
const maxY = Math.max(...relaxedYValues);
|
||||
expect(maxY - minY).toBeGreaterThan(50);
|
||||
expect(maxY - minY).toBeLessThan(250);
|
||||
});
|
||||
|
||||
it('should preserve layer spread under heavy edge attraction', () => {
|
||||
const nodes: GraphNode[] = [makeNode('file', 'File', 'hub.ts')];
|
||||
for (let i = 0; i < 18; i++) {
|
||||
nodes.push(makeNode(`fn${i}`, 'Function', `fn${i}`));
|
||||
}
|
||||
|
||||
const relationships = Array.from({ length: 18 }, (_, i) => ({
|
||||
id: `r${i}`,
|
||||
type: 'CALLS',
|
||||
sourceId: `fn${i}`,
|
||||
targetId: 'file',
|
||||
}));
|
||||
|
||||
const positions = calculateTreeLayout({ nodes, relationships });
|
||||
const functionXs = Array.from({ length: 18 }, (_, i) => positions.get(`fn${i}`)!.x);
|
||||
|
||||
expect(Math.max(...functionXs) - Math.min(...functionXs)).toBeGreaterThan(280);
|
||||
expect(positions.get('file')!.y).toBeGreaterThan(positions.get('fn0')!.y);
|
||||
});
|
||||
});
|
||||
570
gitnexus-web/src/lib/tree-layout.ts
Normal file
570
gitnexus-web/src/lib/tree-layout.ts
Normal file
|
|
@ -0,0 +1,570 @@
|
|||
import type { KnowledgeGraph } from '../core/graph/types';
|
||||
import type { GraphNode, NodeLabel } from 'gitnexus-shared';
|
||||
import { NODE_SIZES } from './constants';
|
||||
|
||||
export interface TreeNodePosition {
|
||||
x: number;
|
||||
y: number;
|
||||
size: number;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps node types to display layers in the tree view.
|
||||
* Layer 0 = top (containers), Layer 3 = bottom (functions/methods).
|
||||
*/
|
||||
const TYPE_TO_LAYER: Record<string, number> = {
|
||||
// Layer 0: Structural containers
|
||||
Project: 0,
|
||||
Package: 0,
|
||||
Module: 0,
|
||||
Folder: 0,
|
||||
Namespace: 0,
|
||||
|
||||
// Layer 1: Files
|
||||
File: 1,
|
||||
Section: 1,
|
||||
Import: 1,
|
||||
Route: 1,
|
||||
Tool: 1,
|
||||
|
||||
// Layer 2: Type definitions
|
||||
Class: 2,
|
||||
Interface: 2,
|
||||
Enum: 2,
|
||||
Type: 2,
|
||||
Struct: 2,
|
||||
Trait: 2,
|
||||
Union: 2,
|
||||
Record: 2,
|
||||
Typedef: 2,
|
||||
Template: 2,
|
||||
TypeAlias: 2,
|
||||
|
||||
// Layer 3: Functions / Methods
|
||||
Function: 3,
|
||||
Method: 3,
|
||||
Impl: 3,
|
||||
Delegate: 3,
|
||||
Constructor: 3,
|
||||
Variable: 3,
|
||||
Const: 3,
|
||||
Static: 3,
|
||||
Property: 3,
|
||||
Decorator: 3,
|
||||
Annotation: 3,
|
||||
Macro: 3,
|
||||
CodeElement: 3,
|
||||
};
|
||||
|
||||
/** Fallback layer for unmapped types. */
|
||||
const DEFAULT_LAYER = 1;
|
||||
|
||||
/** Virtual canvas size for layout calculation. */
|
||||
const CANVAS_WIDTH = 1200;
|
||||
const CANVAS_HEIGHT = 800;
|
||||
const LAYER_COUNT = 4;
|
||||
const LAYER_HEIGHT = CANVAS_HEIGHT / LAYER_COUNT; // 200
|
||||
const PADDING_X = 60;
|
||||
const PADDING_Y = 15;
|
||||
const MIN_NODE_GAP = 45;
|
||||
const MAX_LAYER_ROW_SPREAD = 132;
|
||||
// HAS_METHOD and HAS_PROPERTY are Kotlin/Java-style hierarchy edges
|
||||
// (Class→Method, Class→Property). Treat them like DEFINES for layout purposes
|
||||
// so Methods/Properties cluster beneath their parent Class horizontally.
|
||||
const HIERARCHY_RELATIONS = new Set(['CONTAINS', 'DEFINES', 'HAS_METHOD', 'HAS_PROPERTY']);
|
||||
const MAX_X = (CANVAS_WIDTH - PADDING_X * 2) / 2;
|
||||
|
||||
const RELATION_SPRING_WEIGHTS: Record<string, number> = {
|
||||
CONTAINS: 0.12,
|
||||
DEFINES: 0.16,
|
||||
HAS_METHOD: 0.16, // Same as DEFINES — keeps methods near their class
|
||||
HAS_PROPERTY: 0.14, // Slightly weaker — properties can spread more
|
||||
IMPORTS: 0.2,
|
||||
CALLS: 0.24,
|
||||
EXTENDS: 0.18,
|
||||
IMPLEMENTS: 0.18,
|
||||
};
|
||||
|
||||
function calculateNodeSize(layer: number, nodeType: NodeLabel): number {
|
||||
const baseSize = NODE_SIZES[nodeType] || 6;
|
||||
const layerMultiplier = Math.max(0.6, 1 - layer * 0.12);
|
||||
return baseSize * layerMultiplier;
|
||||
}
|
||||
|
||||
function deterministicHash(str: string): number {
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = (hash << 5) + hash + str.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
return (Math.abs(hash) % 10000) / 10000;
|
||||
}
|
||||
|
||||
function getNodeLayer(node: GraphNode): number {
|
||||
return TYPE_TO_LAYER[node.label] ?? DEFAULT_LAYER;
|
||||
}
|
||||
|
||||
function buildHierarchyMaps(graph: KnowledgeGraph) {
|
||||
const childrenByParent = new Map<string, string[]>();
|
||||
const parentsByChild = new Map<string, string[]>();
|
||||
|
||||
for (const rel of graph.relationships) {
|
||||
if (!HIERARCHY_RELATIONS.has(rel.type)) continue;
|
||||
|
||||
if (!childrenByParent.has(rel.sourceId)) {
|
||||
childrenByParent.set(rel.sourceId, []);
|
||||
}
|
||||
childrenByParent.get(rel.sourceId)!.push(rel.targetId);
|
||||
|
||||
if (!parentsByChild.has(rel.targetId)) {
|
||||
parentsByChild.set(rel.targetId, []);
|
||||
}
|
||||
parentsByChild.get(rel.targetId)!.push(rel.sourceId);
|
||||
}
|
||||
|
||||
return { childrenByParent, parentsByChild };
|
||||
}
|
||||
|
||||
function buildLayerNodeIds(graph: KnowledgeGraph): string[][] {
|
||||
const nodeIdsByLayer: string[][] = Array.from({ length: LAYER_COUNT }, () => []);
|
||||
|
||||
for (const node of graph.nodes) {
|
||||
const layer = getNodeLayer(node);
|
||||
if (layer >= 0 && layer < LAYER_COUNT) {
|
||||
nodeIdsByLayer[layer].push(node.id);
|
||||
}
|
||||
}
|
||||
|
||||
return nodeIdsByLayer;
|
||||
}
|
||||
|
||||
function getRestEdgeLength(
|
||||
relationType: string,
|
||||
source: TreeNodePosition,
|
||||
target: TreeNodePosition,
|
||||
) {
|
||||
const depthGap = Math.abs(source.depth - target.depth);
|
||||
const baseLength = HIERARCHY_RELATIONS.has(relationType) ? 60 : 85;
|
||||
return baseLength + depthGap * 40;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function getLayerRowOffsets(nodeCount: number): number[] {
|
||||
if (nodeCount <= 4) return [0];
|
||||
|
||||
const rowCount = nodeCount <= 16 ? 2 : 3;
|
||||
const totalSpread = rowCount === 2 ? 72 : MAX_LAYER_ROW_SPREAD;
|
||||
const rowGap = totalSpread / (rowCount - 1);
|
||||
|
||||
return Array.from({ length: rowCount }, (_, rowIndex) => -totalSpread / 2 + rowIndex * rowGap);
|
||||
}
|
||||
|
||||
function placeNodesInSlice(
|
||||
positions: Map<string, TreeNodePosition>,
|
||||
nodes: GraphNode[],
|
||||
startX: number,
|
||||
slotWidth: number,
|
||||
layerY: number,
|
||||
layer: number,
|
||||
) {
|
||||
const rowOffsets = getLayerRowOffsets(nodes.length);
|
||||
const rowCount = rowOffsets.length;
|
||||
const baseNodesPerRow = Math.floor(nodes.length / rowCount);
|
||||
const remainder = nodes.length % rowCount;
|
||||
|
||||
let cursor = 0;
|
||||
|
||||
for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {
|
||||
const nodesInRow = baseNodesPerRow + (rowIndex < remainder ? 1 : 0);
|
||||
if (nodesInRow === 0) continue;
|
||||
|
||||
const rowSpacing = slotWidth / nodesInRow;
|
||||
for (let i = 0; i < nodesInRow; i++) {
|
||||
const node = nodes[cursor++];
|
||||
positions.set(node.id, {
|
||||
x: startX + (i + 0.5) * rowSpacing,
|
||||
y: layerY + rowOffsets[rowIndex],
|
||||
size: calculateNodeSize(layer, node.label),
|
||||
depth: layer,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function enforceLayerSpacing(
|
||||
layerNodeIds: string[],
|
||||
positions: Map<string, TreeNodePosition>,
|
||||
anchorXByNode: Map<string, number>,
|
||||
) {
|
||||
if (layerNodeIds.length < 2) return;
|
||||
|
||||
const sortedIds = [...layerNodeIds].sort((a, b) => positions.get(a)!.x - positions.get(b)!.x);
|
||||
|
||||
for (let pass = 0; pass < 2; pass++) {
|
||||
for (let i = 1; i < sortedIds.length; i++) {
|
||||
const prev = positions.get(sortedIds[i - 1])!;
|
||||
const curr = positions.get(sortedIds[i])!;
|
||||
const minGap = Math.max(MIN_NODE_GAP * 0.65, (prev.size + curr.size) * 1.7);
|
||||
const gap = curr.x - prev.x;
|
||||
|
||||
if (gap < minGap) {
|
||||
const push = (minGap - gap) / 2;
|
||||
prev.x -= push;
|
||||
curr.x += push;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = sortedIds.length - 2; i >= 0; i--) {
|
||||
const curr = positions.get(sortedIds[i])!;
|
||||
const next = positions.get(sortedIds[i + 1])!;
|
||||
const minGap = Math.max(MIN_NODE_GAP * 0.65, (curr.size + next.size) * 1.7);
|
||||
const gap = next.x - curr.x;
|
||||
|
||||
if (gap < minGap) {
|
||||
const push = (minGap - gap) / 2;
|
||||
curr.x -= push;
|
||||
next.x += push;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const anchorCenter =
|
||||
sortedIds.reduce((sum, nodeId) => sum + (anchorXByNode.get(nodeId) ?? 0), 0) / sortedIds.length;
|
||||
const currentCenter =
|
||||
sortedIds.reduce((sum, nodeId) => sum + positions.get(nodeId)!.x, 0) / sortedIds.length;
|
||||
const recenterDelta = currentCenter - anchorCenter;
|
||||
|
||||
for (const nodeId of sortedIds) {
|
||||
const pos = positions.get(nodeId)!;
|
||||
pos.x = clamp(pos.x - recenterDelta, -MAX_X, MAX_X);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize positions using proportional X allocation.
|
||||
*
|
||||
* Each parent in layer N is allocated a horizontal slice proportional to how
|
||||
* many direct hierarchy children it has in layer N+1. Children are then placed
|
||||
* evenly within their parent's slice. Orphan nodes (no placed hierarchy parent)
|
||||
* fill a proportional slice at the far right.
|
||||
*
|
||||
* Why this is better than uniform distribution:
|
||||
* 1. Dense parents (many children) get more canvas space → no artificial
|
||||
* crowding in the centre even before the physics simulation runs.
|
||||
* 2. Each child starts within its parent's X slice → parent-child edges are
|
||||
* short by construction, so the spring system converges quickly.
|
||||
* 3. Orphan nodes land at the right end; their spring connections pull them
|
||||
* toward better positions at runtime without fighting a spread force.
|
||||
*/
|
||||
function initProportionalPositions(
|
||||
graph: KnowledgeGraph,
|
||||
parentsByChild: Map<string, string[]>,
|
||||
): Map<string, TreeNodePosition> {
|
||||
const positions = new Map<string, TreeNodePosition>();
|
||||
|
||||
// Group nodes by layer and build a fast layer-lookup map.
|
||||
const nodesByLayer: GraphNode[][] = Array.from({ length: LAYER_COUNT }, () => []);
|
||||
const nodeLayerMap = new Map<string, number>();
|
||||
for (const node of graph.nodes) {
|
||||
const layer = getNodeLayer(node);
|
||||
if (layer >= 0 && layer < LAYER_COUNT) {
|
||||
nodesByLayer[layer].push(node);
|
||||
nodeLayerMap.set(node.id, layer);
|
||||
}
|
||||
}
|
||||
|
||||
const availableWidth = CANVAS_WIDTH - PADDING_X * 2;
|
||||
const halfWidth = availableWidth / 2;
|
||||
const availableHeight = LAYER_HEIGHT - PADDING_Y * 2;
|
||||
|
||||
// Y centre for a given logical layer (layer 0 = top).
|
||||
const getLayerY = (layer: number): number => {
|
||||
const visualLayer = LAYER_COUNT - 1 - layer;
|
||||
return visualLayer * LAYER_HEIGHT + PADDING_Y + availableHeight / 2;
|
||||
};
|
||||
|
||||
// --- Layer 0: sorted alphabetically, evenly spaced ---
|
||||
const layer0Nodes = [...nodesByLayer[0]].sort((a, b) =>
|
||||
a.properties.name.localeCompare(b.properties.name),
|
||||
);
|
||||
if (layer0Nodes.length > 0) {
|
||||
const spacing = availableWidth / layer0Nodes.length;
|
||||
for (let i = 0; i < layer0Nodes.length; i++) {
|
||||
const node = layer0Nodes[i];
|
||||
positions.set(node.id, {
|
||||
x: -halfWidth + (i + 0.5) * spacing,
|
||||
y: getLayerY(0),
|
||||
size: calculateNodeSize(0, node.label),
|
||||
depth: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Layers 1-3: proportional allocation from their parents ---
|
||||
for (let layer = 1; layer < LAYER_COUNT; layer++) {
|
||||
const layerNodes = nodesByLayer[layer];
|
||||
if (layerNodes.length === 0) continue;
|
||||
|
||||
const layerY = getLayerY(layer);
|
||||
|
||||
// For each node, find its "primary parent": the already-placed hierarchy
|
||||
// parent with the highest layer index (= closest ancestor in the tree).
|
||||
// Walking all parents and picking the deepest-placed one means a Method
|
||||
// prefers its Class over a distant Package, for example.
|
||||
const assignedParent = new Map<string, string>();
|
||||
for (const node of layerNodes) {
|
||||
const parents = parentsByChild.get(node.id) ?? [];
|
||||
let bestParent: string | null = null;
|
||||
let bestParentLayer = -1;
|
||||
for (const p of parents) {
|
||||
if (!positions.has(p)) continue; // not yet placed
|
||||
const pLayer = nodeLayerMap.get(p) ?? -1;
|
||||
if (pLayer > bestParentLayer) {
|
||||
bestParentLayer = pLayer;
|
||||
bestParent = p;
|
||||
}
|
||||
}
|
||||
if (bestParent) assignedParent.set(node.id, bestParent);
|
||||
}
|
||||
|
||||
// Bucket nodes into parent groups or orphans.
|
||||
const childrenOfParent = new Map<string, GraphNode[]>();
|
||||
const orphans: GraphNode[] = [];
|
||||
for (const node of layerNodes) {
|
||||
const p = assignedParent.get(node.id);
|
||||
if (!p) {
|
||||
orphans.push(node);
|
||||
} else {
|
||||
if (!childrenOfParent.has(p)) childrenOfParent.set(p, []);
|
||||
childrenOfParent.get(p)!.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort within each parent's group and orphans alphabetically.
|
||||
for (const children of childrenOfParent.values()) {
|
||||
children.sort((a, b) => a.properties.name.localeCompare(b.properties.name));
|
||||
}
|
||||
orphans.sort((a, b) => a.properties.name.localeCompare(b.properties.name));
|
||||
|
||||
// Sort active parents left-to-right by their placed X position.
|
||||
const activeParents = [...childrenOfParent.keys()].sort(
|
||||
(a, b) => (positions.get(a)?.x ?? 0) - (positions.get(b)?.x ?? 0),
|
||||
);
|
||||
|
||||
const totalParented = layerNodes.length - orphans.length;
|
||||
|
||||
// Divide the full canvas width:
|
||||
// • parented children → (totalParented / total) fraction of width
|
||||
// • orphans → remaining fraction at the right
|
||||
const parentedWidth =
|
||||
totalParented > 0 ? availableWidth * (totalParented / layerNodes.length) : 0;
|
||||
const orphanWidth = availableWidth - parentedWidth;
|
||||
|
||||
let curX = -halfWidth;
|
||||
|
||||
// Place each parent's children in a sub-slice proportional to child count.
|
||||
for (const parentId of activeParents) {
|
||||
const children = childrenOfParent.get(parentId) ?? [];
|
||||
if (children.length === 0) continue;
|
||||
|
||||
const slotWidth = (children.length / totalParented) * parentedWidth;
|
||||
placeNodesInSlice(positions, children, curX, slotWidth, layerY, layer);
|
||||
curX += slotWidth;
|
||||
}
|
||||
|
||||
// Orphans fill the rightmost slice.
|
||||
if (orphans.length > 0 && orphanWidth > 0) {
|
||||
placeNodesInSlice(positions, orphans, curX, orphanWidth, layerY, layer);
|
||||
}
|
||||
}
|
||||
|
||||
// Shift Y so the layout is centred at y = 0.
|
||||
const centerY = CANVAS_HEIGHT / 2;
|
||||
for (const pos of positions.values()) {
|
||||
pos.y -= centerY;
|
||||
}
|
||||
|
||||
return positions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tree view layout: type-layered grid with organic jitter and
|
||||
* structure-aware horizontal branch shaping.
|
||||
*/
|
||||
export function calculateTreeLayout(graph: KnowledgeGraph): Map<string, TreeNodePosition> {
|
||||
// Build hierarchy maps before initial placement so initProportionalPositions
|
||||
// can assign each node to its closest placed ancestor's X slice.
|
||||
const nodeIdsByLayer = buildLayerNodeIds(graph);
|
||||
const { childrenByParent, parentsByChild } = buildHierarchyMaps(graph);
|
||||
|
||||
// 1. Start with proportional X allocation: each parent gets a canvas slice
|
||||
// proportional to its child count, so dense subtrees never crowd the centre.
|
||||
const positions = initProportionalPositions(graph, parentsByChild);
|
||||
|
||||
// 2. Add subtle Y jitter only — X jitter would scramble the hierarchy ordering
|
||||
// that initProportionalPositions established (especially bad when node spacing < jitter).
|
||||
for (const [nodeId, pos] of positions) {
|
||||
pos.y += (deterministicHash(nodeId + 'y') - 0.5) * 20;
|
||||
}
|
||||
|
||||
// 3. Use structural edges to create a tree-like horizontal ordering while
|
||||
// preserving the type-based vertical layers.
|
||||
const STRUCTURE_ITERATIONS = 6;
|
||||
for (let iter = 0; iter < STRUCTURE_ITERATIONS; iter++) {
|
||||
const childTargets = new Map<string, { sum: number; count: number }>();
|
||||
|
||||
for (const [parentId, children] of childrenByParent) {
|
||||
const parentPos = positions.get(parentId);
|
||||
if (!parentPos || children.length === 0) continue;
|
||||
|
||||
const childPositions = children
|
||||
.map((childId) => ({ childId, pos: positions.get(childId) }))
|
||||
.filter(
|
||||
(entry): entry is { childId: string; pos: TreeNodePosition } => entry.pos !== undefined,
|
||||
)
|
||||
.sort((a, b) => a.pos.x - b.pos.x);
|
||||
|
||||
if (childPositions.length === 0) continue;
|
||||
|
||||
const currentCenter =
|
||||
childPositions.reduce((sum, entry) => sum + entry.pos.x, 0) / childPositions.length;
|
||||
const shift = parentPos.x - currentCenter;
|
||||
|
||||
for (const entry of childPositions) {
|
||||
const existing = childTargets.get(entry.childId) || { sum: 0, count: 0 };
|
||||
existing.sum += entry.pos.x + shift;
|
||||
existing.count += 1;
|
||||
childTargets.set(entry.childId, existing);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [nodeId, target] of childTargets) {
|
||||
const pos = positions.get(nodeId);
|
||||
if (!pos) continue;
|
||||
const avgTargetX = target.sum / target.count;
|
||||
pos.x = pos.x * 0.45 + avgTargetX * 0.55;
|
||||
}
|
||||
|
||||
const parentTargets = new Map<string, { sum: number; count: number }>();
|
||||
for (const [parentId, children] of childrenByParent) {
|
||||
const parentPos = positions.get(parentId);
|
||||
if (!parentPos || children.length === 0) continue;
|
||||
|
||||
const childXs = children
|
||||
.map((childId) => positions.get(childId)?.x)
|
||||
.filter((value): value is number => value !== undefined);
|
||||
|
||||
if (childXs.length === 0) continue;
|
||||
|
||||
const avgChildX = childXs.reduce((sum, value) => sum + value, 0) / childXs.length;
|
||||
const existing = parentTargets.get(parentId) || { sum: 0, count: 0 };
|
||||
existing.sum += avgChildX;
|
||||
existing.count += 1;
|
||||
parentTargets.set(parentId, existing);
|
||||
}
|
||||
|
||||
for (const [nodeId, target] of parentTargets) {
|
||||
const pos = positions.get(nodeId);
|
||||
if (!pos) continue;
|
||||
const avgTargetX = target.sum / target.count;
|
||||
pos.x = pos.x * 0.65 + avgTargetX * 0.35;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Pull childless nodes slightly toward their hierarchy parents when the
|
||||
// graph has enough structure information to form branches.
|
||||
for (const [nodeId, parents] of parentsByChild) {
|
||||
if (childrenByParent.has(nodeId)) continue;
|
||||
const pos = positions.get(nodeId);
|
||||
if (!pos || parents.length === 0) continue;
|
||||
|
||||
const parentXs = parents
|
||||
.map((parentId) => positions.get(parentId)?.x)
|
||||
.filter((value): value is number => value !== undefined);
|
||||
|
||||
if (parentXs.length === 0) continue;
|
||||
|
||||
const avgParentX = parentXs.reduce((sum, value) => sum + value, 0) / parentXs.length;
|
||||
pos.x = pos.x * 0.7 + avgParentX * 0.3;
|
||||
}
|
||||
|
||||
// 5. Keep a per-node horizontal anchor so long edges can pull nodes closer
|
||||
// without destroying each layer's original spread.
|
||||
const anchorXByNode = new Map<string, number>();
|
||||
for (const [nodeId, pos] of positions) {
|
||||
anchorXByNode.set(nodeId, pos.x);
|
||||
}
|
||||
|
||||
// 6. Relax the graph like a constrained spring system. Only X is allowed
|
||||
// to move, so node types stay on their original Y layers.
|
||||
// For large graphs the spring phase is O(N×E×iterations) and would freeze
|
||||
// the main thread — scale it down proportionally so the initial proportional
|
||||
// layout (already good at large N) is kept without expensive refinement.
|
||||
const nodeCount = graph.nodes.length;
|
||||
const SPRING_ITERATIONS = nodeCount > 10000 ? 0 : nodeCount > 3000 ? 4 : 14;
|
||||
for (let iter = 0; iter < SPRING_ITERATIONS; iter++) {
|
||||
const deltaXByNode = new Map<string, number>();
|
||||
|
||||
for (const [nodeId, pos] of positions) {
|
||||
const anchorX = anchorXByNode.get(nodeId) ?? pos.x;
|
||||
const normalizedDistance = Math.min(1, Math.abs(pos.x) / MAX_X);
|
||||
const anchorStrength = 0.05 + normalizedDistance * normalizedDistance * 0.1;
|
||||
deltaXByNode.set(nodeId, (anchorX - pos.x) * anchorStrength);
|
||||
}
|
||||
|
||||
for (const rel of graph.relationships) {
|
||||
const sourcePos = positions.get(rel.sourceId);
|
||||
const targetPos = positions.get(rel.targetId);
|
||||
if (!sourcePos || !targetPos) continue;
|
||||
|
||||
const dx = targetPos.x - sourcePos.x;
|
||||
const dy = targetPos.y - sourcePos.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||
const restLength = getRestEdgeLength(rel.type, sourcePos, targetPos);
|
||||
const stretch = distance - restLength;
|
||||
|
||||
if (stretch <= 0) continue;
|
||||
|
||||
const springWeight = RELATION_SPRING_WEIGHTS[rel.type] ?? 0.14;
|
||||
const pull = stretch * springWeight * 0.08;
|
||||
const forceX = (dx / distance) * pull;
|
||||
|
||||
deltaXByNode.set(rel.sourceId, (deltaXByNode.get(rel.sourceId) ?? 0) + forceX);
|
||||
deltaXByNode.set(rel.targetId, (deltaXByNode.get(rel.targetId) ?? 0) - forceX);
|
||||
}
|
||||
|
||||
for (const [nodeId, pos] of positions) {
|
||||
const deltaX = deltaXByNode.get(nodeId) ?? 0;
|
||||
const normalizedDistance = Math.min(1, Math.abs(pos.x) / MAX_X);
|
||||
const edgeResistance = 1 + normalizedDistance * normalizedDistance * 4.5;
|
||||
const maxStep = 18 - normalizedDistance * 6;
|
||||
const step = clamp(deltaX / edgeResistance, -maxStep, maxStep);
|
||||
pos.x = clamp(pos.x + step, -MAX_X, MAX_X);
|
||||
}
|
||||
|
||||
for (const layerNodeIds of nodeIdsByLayer) {
|
||||
enforceLayerSpacing(layerNodeIds, positions, anchorXByNode);
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Recenter and softly clamp X so the layout keeps its breadth without
|
||||
// drifting too far off-canvas.
|
||||
const xValues = Array.from(positions.values()).map((pos) => pos.x);
|
||||
if (xValues.length > 0) {
|
||||
const minX = Math.min(...xValues);
|
||||
const maxX = Math.max(...xValues);
|
||||
const centerX = (minX + maxX) / 2;
|
||||
const halfSpan = Math.max(1, (maxX - minX) / 2);
|
||||
const scale = halfSpan > MAX_X ? MAX_X / halfSpan : 1;
|
||||
|
||||
for (const pos of positions.values()) {
|
||||
pos.x = (pos.x - centerX) * scale;
|
||||
}
|
||||
}
|
||||
|
||||
return positions;
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@
|
|||
"clearChat": "Clear chat",
|
||||
"stopResponse": "Stop response"
|
||||
},
|
||||
"stopped": "Stopped by user",
|
||||
"badges": {
|
||||
"configureAI": "Configure AI",
|
||||
"connecting": "Connecting"
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@
|
|||
"status": {
|
||||
"running": "running",
|
||||
"completed": "completed",
|
||||
"error": "error"
|
||||
"error": "error",
|
||||
"stopped": "stopped"
|
||||
},
|
||||
"tools": {
|
||||
"search": "🔍 Search Code",
|
||||
|
|
@ -112,6 +113,12 @@
|
|||
"codeNotAvailable": "Code not available in memory for {{path}}"
|
||||
},
|
||||
"canvas": {
|
||||
"viewModes": {
|
||||
"label": "Graph view mode",
|
||||
"force": "Force Graph",
|
||||
"tree": "Sequential Layout",
|
||||
"circles": "Radial Layout"
|
||||
},
|
||||
"zoomIn": "Zoom In",
|
||||
"zoomOut": "Zoom Out",
|
||||
"fit": "Fit to Screen",
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@
|
|||
"clearChat": "清空聊天",
|
||||
"stopResponse": "停止响应"
|
||||
},
|
||||
"stopped": "用户已停止",
|
||||
"badges": {
|
||||
"configureAI": "配置 AI",
|
||||
"connecting": "连接中"
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@
|
|||
"status": {
|
||||
"running": "运行中",
|
||||
"completed": "已完成",
|
||||
"error": "错误"
|
||||
"error": "错误",
|
||||
"stopped": "已停止"
|
||||
},
|
||||
"tools": {
|
||||
"search": "🔍 搜索代码",
|
||||
|
|
@ -112,6 +113,12 @@
|
|||
"codeNotAvailable": "内存中没有 {{path}} 的代码内容"
|
||||
},
|
||||
"canvas": {
|
||||
"viewModes": {
|
||||
"label": "图形视图模式",
|
||||
"force": "力导向图",
|
||||
"tree": "顺序布局",
|
||||
"circles": "径向布局"
|
||||
},
|
||||
"zoomIn": "放大",
|
||||
"zoomOut": "缩小",
|
||||
"fit": "适应屏幕",
|
||||
|
|
|
|||
97
gitnexus-web/test/unit/agent-abort.test.ts
Normal file
97
gitnexus-web/test/unit/agent-abort.test.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { streamAgentResponse, type AgentMessage } from '../../src/core/llm/agent';
|
||||
|
||||
describe('streamAgentResponse abort', () => {
|
||||
const userMessage: AgentMessage[] = [{ role: 'user', content: 'hello' }];
|
||||
|
||||
it('yields cancelled when the LangGraph stream throws AbortError', async () => {
|
||||
const agent = {
|
||||
stream: async () => {
|
||||
throw new DOMException('The operation was aborted', 'AbortError');
|
||||
},
|
||||
};
|
||||
|
||||
const chunks = [];
|
||||
for await (const chunk of streamAgentResponse(agent as any, userMessage, {
|
||||
signal: new AbortController().signal,
|
||||
})) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
expect(chunks).toEqual([{ type: 'cancelled' }]);
|
||||
});
|
||||
|
||||
it('yields cancelled when the abort signal is set mid-stream', async () => {
|
||||
const controller = new AbortController();
|
||||
const agent = {
|
||||
stream: async function* () {
|
||||
yield ['values', { messages: [] }];
|
||||
controller.abort();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
yield ['messages', [{ _getType: () => 'ai', content: 'still going' }]];
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const chunks = [];
|
||||
for await (const chunk of streamAgentResponse(agent as any, userMessage, {
|
||||
signal: controller.signal,
|
||||
})) {
|
||||
chunks.push(chunk);
|
||||
if (chunk.type === 'cancelled') break;
|
||||
}
|
||||
|
||||
expect(chunks[chunks.length - 1]).toEqual({ type: 'cancelled' });
|
||||
expect(chunks.filter((c) => c.type === 'error')).toEqual([]);
|
||||
});
|
||||
|
||||
it('passes AbortSignal to agent.stream config', async () => {
|
||||
const controller = new AbortController();
|
||||
let capturedConfig: Record<string, unknown> | undefined;
|
||||
|
||||
const agent = {
|
||||
stream: async (_input: unknown, config: Record<string, unknown>) => {
|
||||
capturedConfig = config;
|
||||
throw new DOMException('aborted', 'AbortError');
|
||||
},
|
||||
};
|
||||
|
||||
for await (const _chunk of streamAgentResponse(agent as any, userMessage, {
|
||||
signal: controller.signal,
|
||||
})) {
|
||||
// drain
|
||||
}
|
||||
|
||||
expect(capturedConfig?.signal).toBe(controller.signal);
|
||||
});
|
||||
|
||||
it('yields cancelled for a plain Error with name AbortError', async () => {
|
||||
const agent = {
|
||||
stream: async () => {
|
||||
throw Object.assign(new Error('aborted'), { name: 'AbortError' });
|
||||
},
|
||||
};
|
||||
|
||||
const chunks = [];
|
||||
for await (const chunk of streamAgentResponse(agent as any, userMessage)) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
expect(chunks).toEqual([{ type: 'cancelled' }]);
|
||||
});
|
||||
|
||||
it('does not treat unrelated errors mentioning abort as cancellation', async () => {
|
||||
const agent = {
|
||||
stream: async () => {
|
||||
throw new Error('Cannot abort the current transaction');
|
||||
},
|
||||
};
|
||||
|
||||
const chunks = [];
|
||||
for await (const chunk of streamAgentResponse(agent as any, userMessage)) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
expect(chunks).toEqual([{ type: 'error', error: 'Cannot abort the current transaction' }]);
|
||||
});
|
||||
});
|
||||
|
|
@ -28,6 +28,8 @@ const ICON_MAP: Record<string, string> = {
|
|||
Decorator: 'AtSign',
|
||||
Import: 'FileCode',
|
||||
Variable: 'Variable',
|
||||
Property: 'Variable',
|
||||
Const: 'Target',
|
||||
};
|
||||
|
||||
describe('filter panel icon mappings', () => {
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ export default defineConfig({
|
|||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./test/setup.ts'],
|
||||
include: ['test/**/*.test.{ts,tsx}'],
|
||||
include: ['test/**/*.test.{ts,tsx}', 'src/**/*.test.{ts,tsx}'],
|
||||
testTimeout: 15000,
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
|
|
|
|||
|
|
@ -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**, **Codex**, **Windsurf**, **Cline**, **OpenCode**, and any MCP-compatible tool.
|
||||
Works with **Cursor**, **Claude Code**, **Antigravity** (Google), **Codex**, **Windsurf**, **Cline**, **OpenCode**, and any MCP-compatible tool.
|
||||
|
||||
[](https://www.npmjs.com/package/gitnexus)
|
||||
[](https://polyformproject.org/licenses/noncommercial/1.0.0/)
|
||||
|
|
@ -34,6 +34,7 @@ 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 |
|
||||
|
|
|
|||
346
gitnexus/hooks/antigravity/gitnexus-antigravity-hook.cjs
Executable file
346
gitnexus/hooks/antigravity/gitnexus-antigravity-hook.cjs
Executable file
|
|
@ -0,0 +1,346 @@
|
|||
#!/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();
|
||||
264
gitnexus/package-lock.json
generated
264
gitnexus/package-lock.json
generated
|
|
@ -1307,9 +1307,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.130.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz",
|
||||
"integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==",
|
||||
"version": "0.132.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz",
|
||||
"integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
|
|
@ -1387,9 +1387,9 @@
|
|||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz",
|
||||
"integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz",
|
||||
"integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1404,9 +1404,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz",
|
||||
"integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz",
|
||||
"integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1421,9 +1421,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz",
|
||||
"integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz",
|
||||
"integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1438,9 +1438,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz",
|
||||
"integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz",
|
||||
"integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1455,9 +1455,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz",
|
||||
"integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz",
|
||||
"integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
|
|
@ -1472,9 +1472,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz",
|
||||
"integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz",
|
||||
"integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1489,9 +1489,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz",
|
||||
"integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz",
|
||||
"integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1506,9 +1506,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz",
|
||||
"integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz",
|
||||
"integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
|
|
@ -1523,9 +1523,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz",
|
||||
"integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz",
|
||||
"integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
|
|
@ -1540,9 +1540,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz",
|
||||
"integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz",
|
||||
"integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1557,9 +1557,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz",
|
||||
"integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz",
|
||||
"integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1574,9 +1574,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz",
|
||||
"integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz",
|
||||
"integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1591,9 +1591,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz",
|
||||
"integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz",
|
||||
"integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
|
|
@ -1621,9 +1621,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz",
|
||||
"integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz",
|
||||
"integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1638,9 +1638,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz",
|
||||
"integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz",
|
||||
"integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1792,9 +1792,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz",
|
||||
"integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==",
|
||||
"version": "25.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
|
||||
"integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": ">=7.24.0 <7.24.7"
|
||||
|
|
@ -1847,14 +1847,14 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/coverage-v8": {
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.6.tgz",
|
||||
"integrity": "sha512-36l628fQ/9a/8ihy97eOtEnvWQEdqULQOJtcaxtoNq0G1w3Mxd4szSahOaMM9/NGyZ+hyKcMtIW/WIxq0XQViQ==",
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.7.tgz",
|
||||
"integrity": "sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@bcoe/v8-coverage": "^1.0.2",
|
||||
"@vitest/utils": "4.1.6",
|
||||
"@vitest/utils": "4.1.7",
|
||||
"ast-v8-to-istanbul": "^1.0.0",
|
||||
"istanbul-lib-coverage": "^3.2.2",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
|
|
@ -1868,8 +1868,8 @@
|
|||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vitest/browser": "4.1.6",
|
||||
"vitest": "4.1.6"
|
||||
"@vitest/browser": "4.1.7",
|
||||
"vitest": "4.1.7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vitest/browser": {
|
||||
|
|
@ -1878,16 +1878,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz",
|
||||
"integrity": "sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==",
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz",
|
||||
"integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.6",
|
||||
"@vitest/utils": "4.1.6",
|
||||
"@vitest/spy": "4.1.7",
|
||||
"@vitest/utils": "4.1.7",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
|
|
@ -1896,13 +1896,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz",
|
||||
"integrity": "sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==",
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz",
|
||||
"integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.6",
|
||||
"@vitest/spy": "4.1.7",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
|
|
@ -1923,9 +1923,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.6.tgz",
|
||||
"integrity": "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==",
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz",
|
||||
"integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -1936,13 +1936,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.6.tgz",
|
||||
"integrity": "sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==",
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz",
|
||||
"integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.6",
|
||||
"@vitest/utils": "4.1.7",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
|
|
@ -1950,14 +1950,14 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.6.tgz",
|
||||
"integrity": "sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==",
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz",
|
||||
"integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.6",
|
||||
"@vitest/utils": "4.1.6",
|
||||
"@vitest/pretty-format": "4.1.7",
|
||||
"@vitest/utils": "4.1.7",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
|
|
@ -1966,9 +1966,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.6.tgz",
|
||||
"integrity": "sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==",
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz",
|
||||
"integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
|
|
@ -1976,13 +1976,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.6.tgz",
|
||||
"integrity": "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==",
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz",
|
||||
"integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.6",
|
||||
"@vitest/pretty-format": "4.1.7",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
|
|
@ -3594,9 +3594,9 @@
|
|||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "11.3.6",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz",
|
||||
"integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==",
|
||||
"version": "11.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz",
|
||||
"integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
|
|
@ -4112,9 +4112,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.14",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
||||
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -4132,7 +4132,7 @@
|
|||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"nanoid": "^3.3.12",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
|
|
@ -4291,13 +4291,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz",
|
||||
"integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz",
|
||||
"integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.130.0",
|
||||
"@oxc-project/types": "=0.132.0",
|
||||
"@rolldown/pluginutils": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
|
|
@ -4307,21 +4307,21 @@
|
|||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm64": "1.0.1",
|
||||
"@rolldown/binding-darwin-arm64": "1.0.1",
|
||||
"@rolldown/binding-darwin-x64": "1.0.1",
|
||||
"@rolldown/binding-freebsd-x64": "1.0.1",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.1",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.0.1",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.0.1",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.1",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.0.1",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.0.1",
|
||||
"@rolldown/binding-linux-x64-musl": "1.0.1",
|
||||
"@rolldown/binding-openharmony-arm64": "1.0.1",
|
||||
"@rolldown/binding-wasm32-wasi": "1.0.1",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.0.1",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.1"
|
||||
"@rolldown/binding-android-arm64": "1.0.2",
|
||||
"@rolldown/binding-darwin-arm64": "1.0.2",
|
||||
"@rolldown/binding-darwin-x64": "1.0.2",
|
||||
"@rolldown/binding-freebsd-x64": "1.0.2",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.2",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.0.2",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.0.2",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.2",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.0.2",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.0.2",
|
||||
"@rolldown/binding-linux-x64-musl": "1.0.2",
|
||||
"@rolldown/binding-openharmony-arm64": "1.0.2",
|
||||
"@rolldown/binding-wasm32-wasi": "1.0.2",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.0.2",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/router": {
|
||||
|
|
@ -5167,16 +5167,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.0.13",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz",
|
||||
"integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==",
|
||||
"version": "8.0.14",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz",
|
||||
"integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.4",
|
||||
"postcss": "^8.5.14",
|
||||
"rolldown": "1.0.1",
|
||||
"postcss": "^8.5.15",
|
||||
"rolldown": "1.0.2",
|
||||
"tinyglobby": "^0.2.16"
|
||||
},
|
||||
"bin": {
|
||||
|
|
@ -5245,19 +5245,19 @@
|
|||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz",
|
||||
"integrity": "sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==",
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz",
|
||||
"integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.6",
|
||||
"@vitest/mocker": "4.1.6",
|
||||
"@vitest/pretty-format": "4.1.6",
|
||||
"@vitest/runner": "4.1.6",
|
||||
"@vitest/snapshot": "4.1.6",
|
||||
"@vitest/spy": "4.1.6",
|
||||
"@vitest/utils": "4.1.6",
|
||||
"@vitest/expect": "4.1.7",
|
||||
"@vitest/mocker": "4.1.7",
|
||||
"@vitest/pretty-format": "4.1.7",
|
||||
"@vitest/runner": "4.1.7",
|
||||
"@vitest/snapshot": "4.1.7",
|
||||
"@vitest/spy": "4.1.7",
|
||||
"@vitest/utils": "4.1.7",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
|
|
@ -5285,12 +5285,12 @@
|
|||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.1.6",
|
||||
"@vitest/browser-preview": "4.1.6",
|
||||
"@vitest/browser-webdriverio": "4.1.6",
|
||||
"@vitest/coverage-istanbul": "4.1.6",
|
||||
"@vitest/coverage-v8": "4.1.6",
|
||||
"@vitest/ui": "4.1.6",
|
||||
"@vitest/browser-playwright": "4.1.7",
|
||||
"@vitest/browser-preview": "4.1.7",
|
||||
"@vitest/browser-webdriverio": "4.1.7",
|
||||
"@vitest/coverage-istanbul": "4.1.7",
|
||||
"@vitest/coverage-v8": "4.1.7",
|
||||
"@vitest/ui": "4.1.7",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
|
|
|
|||
|
|
@ -35,6 +35,22 @@ 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,6 +29,7 @@ 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',
|
||||
|
|
@ -58,6 +59,7 @@ const LBUG_NATIVE = [
|
|||
'test/integration/lbug-close-handle-release.test.ts',
|
||||
'test/integration/lbug-orphan-sidecar-recovery.test.ts',
|
||||
'test/integration/lbug-readonly-init.test.ts',
|
||||
'test/integration/lbug-non-ascii-path.test.ts',
|
||||
'test/integration/local-backend.test.ts',
|
||||
'test/integration/local-backend-calltool.test.ts',
|
||||
'test/integration/search-core.test.ts',
|
||||
|
|
@ -79,6 +81,8 @@ 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',
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { getRuntimeCapabilities, getRuntimeFingerprint } from '../core/platform/capabilities.js';
|
||||
import { resolveEmbeddingConfig } from '../core/embeddings/config.js';
|
||||
import { isHttpMode } from '../core/embeddings/http-client.js';
|
||||
import { checkLbugNative } from '../core/lbug/native-check.js';
|
||||
import { t } from './i18n/index.js';
|
||||
|
||||
function isCombiningMark(codePoint: number): boolean {
|
||||
|
|
@ -59,6 +60,13 @@ export const doctorCommand = async () => {
|
|||
console.log(` ${label('doctor.labels.node', 10)}${fingerprint.node}`);
|
||||
console.log(` ${label('doctor.labels.gitnexus', 10)}${fingerprint.gitnexus}`);
|
||||
console.log(` ${label('doctor.labels.ladybugdb', 10)}${fingerprint.ladybugdb ?? 'unknown'}`);
|
||||
const nativeCheck = checkLbugNative();
|
||||
if (nativeCheck.ok) {
|
||||
console.log(` ${padDisplayEnd('native', 10)}✓ lbugjs.node loaded`);
|
||||
} else {
|
||||
console.log(` ${padDisplayEnd('native', 10)}✗ lbugjs.node missing`);
|
||||
process.stderr.write(`\n${nativeCheck.message?.replace(/^/gm, ' ')}\n\n`);
|
||||
}
|
||||
console.log(` ${label('doctor.labels.onnx', 10)}${fingerprint.onnxruntime ?? 'unknown'}`);
|
||||
console.log('');
|
||||
console.log(t('doctor.capabilities'));
|
||||
|
|
|
|||
|
|
@ -206,19 +206,39 @@ export function formatImpactResult(result: any): string {
|
|||
3: 'MAY NEED TESTING (transitive)',
|
||||
};
|
||||
|
||||
for (const depth of [1, 2, 3]) {
|
||||
const items = byDepth[depth];
|
||||
if (!items || items.length === 0) continue;
|
||||
|
||||
lines.push(`d=${depth}: ${depthLabels[depth] || ''} (${items.length})`);
|
||||
for (const item of items.slice(0, 12)) {
|
||||
const conf = item.confidence < 1 ? ` (conf: ${item.confidence})` : '';
|
||||
lines.push(` ${item.type} ${item.name} → ${item.filePath} [${item.relationType}]${conf}`);
|
||||
}
|
||||
if (items.length > 12) {
|
||||
lines.push(` ... and ${items.length - 12} more`);
|
||||
if (!result.byDepth && result.byDepthCounts) {
|
||||
lines.push('(summary only — use summaryOnly: false to see symbol lists)');
|
||||
const depthCounts = result.byDepthCounts;
|
||||
for (const depth of [1, 2, 3]) {
|
||||
const count = depthCounts[depth] ?? 0;
|
||||
if (count === 0) continue;
|
||||
lines.push(`d=${depth}: ${depthLabels[depth] || ''} (${count})`);
|
||||
}
|
||||
lines.push('');
|
||||
} else {
|
||||
const depthCounts = result.byDepthCounts || {};
|
||||
for (const depth of [1, 2, 3]) {
|
||||
const items = byDepth[depth] || [];
|
||||
const trueCount = depthCounts[depth] ?? items.length;
|
||||
if (trueCount === 0) continue;
|
||||
|
||||
lines.push(`d=${depth}: ${depthLabels[depth] || ''} (${trueCount})`);
|
||||
if (items.length === 0) {
|
||||
lines.push(` (0 items on this page — adjust offset)`);
|
||||
} else {
|
||||
const shown = Math.min(items.length, 12);
|
||||
for (const item of items.slice(0, shown)) {
|
||||
const conf = item.confidence < 1 ? ` (conf: ${item.confidence})` : '';
|
||||
lines.push(
|
||||
` ${item.type} ${item.name} → ${item.filePath} [${item.relationType}]${conf}`,
|
||||
);
|
||||
}
|
||||
if (trueCount > shown) {
|
||||
lines.push(` ... and ${trueCount - shown} more`);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n').trim();
|
||||
|
|
|
|||
|
|
@ -103,6 +103,9 @@ const OPTION_DESCRIPTION_KEYS = {
|
|||
'impact|-r, --repo <name>': 'help.option.repo.target',
|
||||
'impact|--depth <n>': 'help.option.impact.depth',
|
||||
'impact|--include-tests': 'help.option.impact.includeTests',
|
||||
'impact|--limit <n>': 'help.option.impact.limit',
|
||||
'impact|--offset <n>': 'help.option.impact.offset',
|
||||
'impact|--summary-only': 'help.option.impact.summaryOnly',
|
||||
'cypher|-r, --repo <name>': 'help.option.repo.target',
|
||||
'detect-changes|-s, --scope <scope>': 'help.option.detectChanges.scope',
|
||||
'detect-changes|-b, --base-ref <ref>': 'help.option.detectChanges.baseRef',
|
||||
|
|
|
|||
|
|
@ -216,6 +216,9 @@ export const en = {
|
|||
'help.option.impact.direction': 'upstream (dependants) or downstream (dependencies)',
|
||||
'help.option.impact.depth': 'Max relationship depth (default: 3)',
|
||||
'help.option.impact.includeTests': 'Include test files in results',
|
||||
'help.option.impact.limit': 'Max symbols per depth level (default: 100)',
|
||||
'help.option.impact.offset': 'Skip N symbols per depth level for pagination',
|
||||
'help.option.impact.summaryOnly': 'Return counts and risk only, omit symbol list',
|
||||
'help.option.detectChanges.scope': 'What to analyze: unstaged, staged, all, or compare',
|
||||
'help.option.detectChanges.baseRef': 'Branch/commit for compare scope (e.g. main)',
|
||||
'help.option.evalServer.host':
|
||||
|
|
|
|||
|
|
@ -202,6 +202,9 @@ export const zhCN = {
|
|||
'help.option.impact.direction': 'upstream(依赖它的项)或 downstream(它依赖的项)',
|
||||
'help.option.impact.depth': '最大关系遍历深度(默认:3)',
|
||||
'help.option.impact.includeTests': '在结果中包含测试文件',
|
||||
'help.option.impact.limit': '每层深度最大符号数(默认:100)',
|
||||
'help.option.impact.offset': '每层深度跳过 N 个符号(分页用)',
|
||||
'help.option.impact.summaryOnly': '仅返回计数和风险等级,省略符号列表',
|
||||
'help.option.detectChanges.scope': '分析范围:unstaged、staged、all 或 compare',
|
||||
'help.option.detectChanges.baseRef': 'compare 范围的分支/提交(例如 main)',
|
||||
'help.option.evalServer.host': '绑定地址(默认:127.0.0.1;用 0.0.0.0 暴露到所有网卡)',
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
import { Command } from 'commander';
|
||||
import { createRequire } from 'node:module';
|
||||
import { createLazyAction } from './lazy-action.js';
|
||||
import { createLazyAction, createLbugLazyAction } from './lazy-action.js';
|
||||
import { registerGroupCommands } from './group.js';
|
||||
import { localizeCliHelp } from './help-i18n.js';
|
||||
import { t } from './i18n/index.js';
|
||||
|
|
@ -18,7 +18,9 @@ program.name('gitnexus').description('GitNexus local CLI and MCP server').versio
|
|||
|
||||
program
|
||||
.command('setup')
|
||||
.description('One-time setup: configure MCP for Cursor, Claude Code, OpenCode, Codex')
|
||||
.description(
|
||||
'One-time setup: configure MCP for Cursor, Claude Code, Antigravity, OpenCode, Codex',
|
||||
)
|
||||
.action(createLazyAction(() => import('./setup.js'), 'setupCommand'));
|
||||
|
||||
program
|
||||
|
|
@ -87,7 +89,7 @@ program
|
|||
.option('--embedding-sub-batch-size <n>', 'Number of chunks per embedding model call')
|
||||
.option('--embedding-device <device>', 'Embedding device: auto, cpu, dml, cuda, or wasm')
|
||||
.addHelpText('after', () => t('help.analyze.environment'))
|
||||
.action(createLazyAction(() => import('./analyze.js'), 'analyzeCommand'));
|
||||
.action(createLbugLazyAction(() => import('./analyze.js'), 'analyzeCommand'));
|
||||
|
||||
program
|
||||
.command('index [path...]')
|
||||
|
|
@ -103,12 +105,12 @@ program
|
|||
.description('Start local HTTP server for web UI connection')
|
||||
.option('-p, --port <port>', 'Port number', '4747')
|
||||
.option('--host <host>', 'Bind address (default: 127.0.0.1, use 0.0.0.0 for remote access)')
|
||||
.action(createLazyAction(() => import('./serve.js'), 'serveCommand'));
|
||||
.action(createLbugLazyAction(() => import('./serve.js'), 'serveCommand'));
|
||||
|
||||
program
|
||||
.command('mcp')
|
||||
.description('Start MCP server (stdio) — serves all indexed repos')
|
||||
.action(createLazyAction(() => import('./mcp.js'), 'mcpCommand'));
|
||||
.action(createLbugLazyAction(() => import('./mcp.js'), 'mcpCommand'));
|
||||
|
||||
program
|
||||
.command('list')
|
||||
|
|
@ -175,12 +177,12 @@ program
|
|||
'--lang <lang>',
|
||||
'Output language for generated documentation (e.g. english, chinese, spanish, japanese)',
|
||||
)
|
||||
.action(createLazyAction(() => import('./wiki.js'), 'wikiCommand'));
|
||||
.action(createLbugLazyAction(() => import('./wiki.js'), 'wikiCommand'));
|
||||
|
||||
program
|
||||
.command('augment <pattern>')
|
||||
.description('Augment a search pattern with knowledge graph context (used by hooks)')
|
||||
.action(createLazyAction(() => import('./augment.js'), 'augmentCommand'));
|
||||
.action(createLbugLazyAction(() => import('./augment.js'), 'augmentCommand'));
|
||||
|
||||
program
|
||||
.command('publish [path]')
|
||||
|
|
@ -205,7 +207,7 @@ program
|
|||
.option('-g, --goal <text>', 'What you want to find')
|
||||
.option('-l, --limit <n>', 'Max processes to return (default: 5)')
|
||||
.option('--content', 'Include full symbol source code')
|
||||
.action(createLazyAction(() => import('./tool.js'), 'queryCommand'));
|
||||
.action(createLbugLazyAction(() => import('./tool.js'), 'queryCommand'));
|
||||
|
||||
program
|
||||
.command('context [name]')
|
||||
|
|
@ -214,7 +216,7 @@ program
|
|||
.option('-u, --uid <uid>', 'Direct symbol UID (zero-ambiguity lookup)')
|
||||
.option('-f, --file <path>', 'File path to disambiguate common names')
|
||||
.option('--content', 'Include full symbol source code')
|
||||
.action(createLazyAction(() => import('./tool.js'), 'contextCommand'));
|
||||
.action(createLbugLazyAction(() => import('./tool.js'), 'contextCommand'));
|
||||
|
||||
program
|
||||
.command('impact <target>')
|
||||
|
|
@ -223,13 +225,16 @@ program
|
|||
.option('-r, --repo <name>', 'Target repository')
|
||||
.option('--depth <n>', 'Max relationship depth (default: 3)')
|
||||
.option('--include-tests', 'Include test files in results')
|
||||
.action(createLazyAction(() => import('./tool.js'), 'impactCommand'));
|
||||
.option('--limit <n>', 'Max symbols per depth level (default: 100)')
|
||||
.option('--offset <n>', 'Skip N symbols per depth level for pagination')
|
||||
.option('--summary-only', 'Return counts and risk only, omit symbol list')
|
||||
.action(createLbugLazyAction(() => import('./tool.js'), 'impactCommand'));
|
||||
|
||||
program
|
||||
.command('cypher <query>')
|
||||
.description('Execute raw Cypher query against the knowledge graph')
|
||||
.option('-r, --repo <name>', 'Target repository')
|
||||
.action(createLazyAction(() => import('./tool.js'), 'cypherCommand'));
|
||||
.action(createLbugLazyAction(() => import('./tool.js'), 'cypherCommand'));
|
||||
|
||||
program
|
||||
.command('detect-changes')
|
||||
|
|
@ -238,7 +243,7 @@ program
|
|||
.option('-s, --scope <scope>', 'What to analyze: unstaged, staged, all, or compare', 'unstaged')
|
||||
.option('-b, --base-ref <ref>', 'Branch/commit for compare scope (e.g. main)')
|
||||
.option('-r, --repo <name>', 'Target repository')
|
||||
.action(createLazyAction(() => import('./tool.js'), 'detectChangesCommand'));
|
||||
.action(createLbugLazyAction(() => import('./tool.js'), 'detectChangesCommand'));
|
||||
|
||||
// ─── Eval Server (persistent daemon for SWE-bench) ─────────────────
|
||||
|
||||
|
|
@ -251,7 +256,7 @@ program
|
|||
'Bind address (default: 127.0.0.1, use 0.0.0.0 to expose to all interfaces)',
|
||||
)
|
||||
.option('--idle-timeout <seconds>', 'Auto-shutdown after N seconds idle (0 = disabled)', '0')
|
||||
.action(createLazyAction(() => import('./eval-server.js'), 'evalServerCommand'));
|
||||
.action(createLbugLazyAction(() => import('./eval-server.js'), 'evalServerCommand'));
|
||||
|
||||
registerGroupCommands(program);
|
||||
localizeCliHelp(program);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
* at compile time — catching typos when used with concrete module imports.
|
||||
*/
|
||||
|
||||
import { checkLbugNative } from '../core/lbug/native-check.js';
|
||||
|
||||
function isCallable(value: unknown): value is (...args: unknown[]) => unknown {
|
||||
return typeof value === 'function';
|
||||
}
|
||||
|
|
@ -21,3 +23,23 @@ export function createLazyAction<
|
|||
await action(...args);
|
||||
};
|
||||
}
|
||||
|
||||
export function createLbugLazyAction<
|
||||
TModule extends Record<string, unknown>,
|
||||
TKey extends string & keyof TModule,
|
||||
>(loader: () => Promise<TModule>, exportName: TKey): (...args: unknown[]) => Promise<void> {
|
||||
return async (...args: unknown[]): Promise<void> => {
|
||||
const check = checkLbugNative();
|
||||
if (!check.ok) {
|
||||
process.stderr.write(`\n ${check.message?.replace(/\n/g, '\n ')}\n\n`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const module = await loader();
|
||||
const action = module[exportName];
|
||||
if (!isCallable(action)) {
|
||||
throw new Error(`Lazy action export not found: ${exportName}`);
|
||||
}
|
||||
await action(...args);
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ 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';
|
||||
|
||||
|
|
@ -254,14 +253,18 @@ async function installClaudeCodeSkills(result: SetupResult): Promise<void> {
|
|||
/**
|
||||
* Check whether an event array already contains a gitnexus-hook entry.
|
||||
*/
|
||||
function hasGitnexusHook(hooksObj: any, eventName: string): boolean {
|
||||
function hasGitnexusHook(
|
||||
hooksObj: any,
|
||||
eventName: string,
|
||||
commandFragment = 'gitnexus-hook',
|
||||
): 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('gitnexus-hook'),
|
||||
(hh: any) => typeof hh.command === 'string' && hh.command.includes(commandFragment),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -468,6 +471,192 @@ 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))) {
|
||||
|
|
@ -563,15 +752,33 @@ async function setupCodex(result: SetupResult): Promise<void> {
|
|||
*/
|
||||
async function installSkillsTo(targetDir: string): Promise<string[]> {
|
||||
const installed: string[] = [];
|
||||
const skillsRoot = path.join(__dirname, '..', '..', 'skills');
|
||||
// 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');
|
||||
|
||||
// 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 {
|
||||
[flatFiles, dirSkillFiles] = await Promise.all([
|
||||
glob('*.md', { cwd: skillsRoot }),
|
||||
glob('*/SKILL.md', { cwd: skillsRoot }),
|
||||
]);
|
||||
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);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
|
@ -705,12 +912,15 @@ 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,6 +117,9 @@ export async function impactCommand(
|
|||
repo?: string;
|
||||
depth?: string;
|
||||
includeTests?: boolean;
|
||||
limit?: string;
|
||||
offset?: string;
|
||||
summaryOnly?: boolean;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (!target?.trim()) {
|
||||
|
|
@ -126,12 +129,19 @@ export async function impactCommand(
|
|||
|
||||
try {
|
||||
const backend = await getBackend();
|
||||
const rawLimit = parseInt(options?.limit ?? '', 10);
|
||||
const rawOffset = parseInt(options?.offset ?? '', 10);
|
||||
const parsedLimit = Number.isFinite(rawLimit) ? rawLimit : undefined;
|
||||
const parsedOffset = Number.isFinite(rawOffset) ? rawOffset : undefined;
|
||||
const result = await backend.callTool('impact', {
|
||||
target,
|
||||
direction: options?.direction || 'upstream',
|
||||
maxDepth: options?.depth ? parseInt(options.depth, 10) : undefined,
|
||||
includeTests: options?.includeTests ?? false,
|
||||
repo: options?.repo,
|
||||
limit: parsedLimit,
|
||||
offset: parsedOffset,
|
||||
summaryOnly: options?.summaryOnly ?? undefined,
|
||||
});
|
||||
output(result);
|
||||
} catch (err: unknown) {
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ const DEFAULT_IGNORE_LIST = new Set([
|
|||
'.generated',
|
||||
'generated',
|
||||
'auto-generated',
|
||||
'monaco-workers', // Monaco editor web-worker bundles generated for browser runtime
|
||||
'.terraform',
|
||||
'.serverless',
|
||||
|
||||
|
|
@ -323,14 +324,6 @@ export const shouldIgnorePath = (filePath: string): boolean => {
|
|||
}
|
||||
}
|
||||
|
||||
// Ignore hidden files (starting with .)
|
||||
if (fileName.startsWith('.') && fileName !== '.') {
|
||||
// But allow some important config files
|
||||
const allowedDotFiles = ['.env', '.gitignore']; // Already in IGNORED_FILES, so this is redundant
|
||||
// Actually, let's NOT ignore all dot files - many are important configs
|
||||
// Just rely on the explicit lists above
|
||||
}
|
||||
|
||||
// Ignore files that look like generated/bundled code
|
||||
if (
|
||||
fileNameLower.includes('.bundle.') ||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,10 @@ import { getGroupDir } from './storage.js';
|
|||
import { closeBridgeDb, openBridgeDbReadOnly, queryBridge, readBridgeMeta } from './bridge-db.js';
|
||||
import { BRIDGE_SCHEMA_VERSION } from './bridge-schema.js';
|
||||
|
||||
// High limit for the local phase of group impact so collectImpactSymbolUids
|
||||
// sees (nearly) all symbols. Bypasses the MCP-facing default of 100.
|
||||
const GROUP_LOCAL_PHASE_LIMIT = 10000;
|
||||
|
||||
/** Cross-boundary hops beyond this value are clamped (multi-hop reserved for future work). */
|
||||
export const MAX_SUPPORTED_CROSS_DEPTH = 1;
|
||||
|
||||
|
|
@ -429,6 +433,7 @@ export async function runGroupImpact(
|
|||
relationTypes: relationTypes && relationTypes.length > 0 ? relationTypes : undefined,
|
||||
includeTests,
|
||||
minConfidence,
|
||||
limit: GROUP_LOCAL_PHASE_LIMIT,
|
||||
};
|
||||
|
||||
const deadline = Date.now() + Math.max(0, timeoutMs);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import * as path from 'node:path';
|
|||
import { isBladeTemplateFilename } from 'gitnexus-shared';
|
||||
import type { HttpLanguagePlugin } from './types.js';
|
||||
import { JAVA_HTTP_PLUGIN } from './java.js';
|
||||
import { KOTLIN_HTTP_PLUGIN } from './kotlin.js';
|
||||
import { GO_HTTP_PLUGIN } from './go.js';
|
||||
import { PYTHON_HTTP_PLUGIN } from './python.js';
|
||||
import { PHP_HTTP_PLUGIN } from './php.js';
|
||||
|
|
@ -18,6 +19,11 @@ export type { HttpDetection, HttpLanguagePlugin, HttpRole } from './types.js';
|
|||
* new language, drop a `http-patterns/<lang>.ts` that exports a
|
||||
* `HttpLanguagePlugin`, import it here and register the extension(s).
|
||||
* No edits to `http-route-extractor.ts` are required.
|
||||
*
|
||||
* Optional grammar plugins (e.g. `kotlin.ts`, which depends on the
|
||||
* optionalDependency `tree-sitter-kotlin`) export `null` when the
|
||||
* native binding is unavailable; we skip registration in that case so
|
||||
* a missing optional grammar never crashes the orchestrator.
|
||||
*/
|
||||
const REGISTRY: Record<string, HttpLanguagePlugin> = {
|
||||
'.java': JAVA_HTTP_PLUGIN,
|
||||
|
|
@ -30,16 +36,26 @@ const REGISTRY: Record<string, HttpLanguagePlugin> = {
|
|||
'.tsx': TSX_HTTP_PLUGIN,
|
||||
};
|
||||
|
||||
if (KOTLIN_HTTP_PLUGIN) {
|
||||
REGISTRY['.kt'] = KOTLIN_HTTP_PLUGIN;
|
||||
REGISTRY['.kts'] = KOTLIN_HTTP_PLUGIN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Glob for files worth scanning for HTTP routes. Kept alongside the
|
||||
* registry so adding a new language widens the glob in one edit.
|
||||
*
|
||||
* `.kt`/`.kts` are always present in the glob even when the optional
|
||||
* `tree-sitter-kotlin` grammar isn't installed — `getPluginForFile`
|
||||
* will return `undefined` for those files in that case, so the
|
||||
* orchestrator simply skips them at scan time without erroring.
|
||||
*
|
||||
* `.vue` / `.svelte` files are intentionally omitted for the source-scan
|
||||
* path — they need their own grammar-aware extraction and the existing
|
||||
* regex fallback for them was never very accurate. The graph-assisted
|
||||
* Strategy A still handles them via the ingestion pipeline.
|
||||
*/
|
||||
export const HTTP_SCAN_GLOB = '**/*.{ts,tsx,js,jsx,java,go,py,php}';
|
||||
export const HTTP_SCAN_GLOB = '**/*.{ts,tsx,js,jsx,java,kt,kts,go,py,php}';
|
||||
|
||||
/**
|
||||
* Return the HTTP plugin registered for the given file's extension,
|
||||
|
|
|
|||
|
|
@ -30,6 +30,19 @@ const METHOD_ANNOTATION_TO_HTTP: Record<string, string> = {
|
|||
};
|
||||
|
||||
// ─── Provider: Spring class-level @RequestMapping prefix ──────────────
|
||||
// Two patterns are needed because the AST shape differs depending on
|
||||
// whether the annotation uses a positional argument or a named one:
|
||||
// @RequestMapping("/api") → (annotation_argument_list (string_literal))
|
||||
// @RequestMapping(path = "/api") → (annotation_argument_list (element_value_pair key:(identifier) value:(string_literal)))
|
||||
// @RequestMapping(value = "/api") → same as above
|
||||
//
|
||||
// The named-argument pattern MUST constrain the `key` field to the route
|
||||
// member names (`path`/`value`); without it, the query also captures
|
||||
// non-route attributes such as `produces`, `consumes`, `headers`, `name`,
|
||||
// `params` (their right-hand string literals would be mis-extracted as
|
||||
// route prefixes — e.g. `produces = "application/json"` would corrupt
|
||||
// every method route under that controller). The sibling
|
||||
// `topic-patterns/java.ts` uses the same `key:` constraint approach.
|
||||
const SPRING_CLASS_PREFIX_PATTERNS = compilePatterns({
|
||||
name: 'java-spring-class-prefix',
|
||||
language: Java,
|
||||
|
|
@ -44,10 +57,27 @@ const SPRING_CLASS_PREFIX_PATTERNS = compilePatterns({
|
|||
arguments: (annotation_argument_list (string_literal) @prefix)))) @class
|
||||
`,
|
||||
},
|
||||
{
|
||||
meta: {},
|
||||
query: `
|
||||
(class_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
name: (identifier) @ann (#eq? @ann "RequestMapping")
|
||||
arguments: (annotation_argument_list
|
||||
(element_value_pair
|
||||
key: (identifier) @key (#match? @key "^(path|value)$")
|
||||
value: (string_literal) @prefix))))) @class
|
||||
`,
|
||||
},
|
||||
],
|
||||
} satisfies LanguagePatterns<Record<string, never>>);
|
||||
|
||||
// ─── Provider: Spring @(Get|Post|...)Mapping method annotations ───────
|
||||
// Same dual-pattern approach: positional vs named argument. The named
|
||||
// pattern restricts the annotation member name to `path`/`value` to
|
||||
// avoid capturing unrelated string-valued attributes
|
||||
// (`produces`, `consumes`, `headers`, `name`, `params`, ...).
|
||||
const SPRING_METHOD_ROUTE_PATTERNS = compilePatterns({
|
||||
name: 'java-spring-method-route',
|
||||
language: Java,
|
||||
|
|
@ -63,6 +93,20 @@ const SPRING_METHOD_ROUTE_PATTERNS = compilePatterns({
|
|||
name: (identifier) @method_name) @method
|
||||
`,
|
||||
},
|
||||
{
|
||||
meta: {},
|
||||
query: `
|
||||
(method_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
name: (identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Mapping$")
|
||||
arguments: (annotation_argument_list
|
||||
(element_value_pair
|
||||
key: (identifier) @key (#match? @key "^(path|value)$")
|
||||
value: (string_literal) @path))))
|
||||
name: (identifier) @method_name) @method
|
||||
`,
|
||||
},
|
||||
],
|
||||
} satisfies LanguagePatterns<Record<string, never>>);
|
||||
|
||||
|
|
|
|||
469
gitnexus/src/core/group/extractors/http-patterns/kotlin.ts
Normal file
469
gitnexus/src/core/group/extractors/http-patterns/kotlin.ts
Normal file
|
|
@ -0,0 +1,469 @@
|
|||
import Parser from 'tree-sitter';
|
||||
import { createRequire } from 'node:module';
|
||||
import {
|
||||
compilePatterns,
|
||||
runCompiledPatterns,
|
||||
unquoteLiteral,
|
||||
type LanguagePatterns,
|
||||
} from '../tree-sitter-scanner.js';
|
||||
import type { HttpDetection, HttpLanguagePlugin } from './types.js';
|
||||
|
||||
/**
|
||||
* Kotlin HTTP plugin (Spring providers + consumers).
|
||||
*
|
||||
* **Providers** (#1849) — Spring `@RequestMapping` class prefixes and
|
||||
* `@(Get|Post|...)Mapping` method annotations on Kotlin Spring Boot
|
||||
* controllers. Both positional shorthand (`@GetMapping("/x")`) and
|
||||
* named annotation arguments (`@GetMapping(value = "/x")` and
|
||||
* `@GetMapping(path = "/x")`) are supported.
|
||||
*
|
||||
* **Consumers** (this PR) — three call-site patterns common in Kotlin
|
||||
* Spring projects:
|
||||
*
|
||||
* 1. `restTemplate.getForObject("/x", ...)` and friends
|
||||
* 2. `webClient.get().uri("/x")` (short form, 1 verb hop + 1 uri hop)
|
||||
* 3. `Request.Builder().url("/x")` (OkHttp)
|
||||
*
|
||||
* The long-form `webClient.method(HttpMethod.X).uri("/y")` chain is
|
||||
* intentionally deferred to a follow-up: it requires walk-up logic
|
||||
* to recover the verb from a sibling `call_expression`, and we can
|
||||
* land 80% of real-world Kotlin Spring consumer coverage with the
|
||||
* three simpler patterns above.
|
||||
*
|
||||
* tree-sitter-kotlin (fwcd) AST shapes used here:
|
||||
* class_declaration
|
||||
* modifiers
|
||||
* annotation
|
||||
* constructor_invocation
|
||||
* user_type → type_identifier ← annotation name
|
||||
* value_arguments
|
||||
* value_argument
|
||||
* (simple_identifier "=")? ← absent for positional, present for named
|
||||
* string_literal
|
||||
* type_identifier ← class name
|
||||
*
|
||||
* Consumer call shape (Kotlin chains everything via `navigation_expression`):
|
||||
* call_expression ← outer `.uri("/x")` or `.url("/x")`
|
||||
* navigation_expression
|
||||
* call_expression ← inner `.get()` / `Request.Builder()` / `restTemplate.x`
|
||||
* navigation_expression
|
||||
* simple_identifier ← receiver: `webClient` / `Request` / `restTemplate`
|
||||
* navigation_suffix ← `.method` / `.Builder` / `.getForObject`
|
||||
* call_suffix (value_arguments)
|
||||
* navigation_suffix ← `.uri` / `.url`
|
||||
* call_suffix
|
||||
* value_arguments
|
||||
* value_argument
|
||||
* string_literal ← the path
|
||||
*
|
||||
* tree-sitter-kotlin is an optional npm dependency — when its native
|
||||
* binding is unavailable the plugin gracefully exports `null` and
|
||||
* `http-patterns/index.ts` skips registration for `.kt`/`.kts` files.
|
||||
*/
|
||||
|
||||
const _require = createRequire(import.meta.url);
|
||||
|
||||
/** Loaded lazily; null when the grammar binding isn't installed. */
|
||||
let Kotlin: unknown | null = null;
|
||||
try {
|
||||
Kotlin = _require('tree-sitter-kotlin');
|
||||
} catch {
|
||||
Kotlin = null;
|
||||
}
|
||||
|
||||
const METHOD_ANNOTATION_TO_HTTP: Record<string, string> = {
|
||||
GetMapping: 'GET',
|
||||
PostMapping: 'POST',
|
||||
PutMapping: 'PUT',
|
||||
DeleteMapping: 'DELETE',
|
||||
PatchMapping: 'PATCH',
|
||||
};
|
||||
|
||||
/**
|
||||
* RestTemplate method-name → HTTP verb. Mirrors the Java plugin's
|
||||
* `REST_TEMPLATE_TO_HTTP` (java.ts) so a polyglot repo emits the
|
||||
* same contract IDs from .java and .kt sources.
|
||||
*/
|
||||
const REST_TEMPLATE_TO_HTTP: Record<string, string> = {
|
||||
getForObject: 'GET',
|
||||
getForEntity: 'GET',
|
||||
postForObject: 'POST',
|
||||
postForEntity: 'POST',
|
||||
put: 'PUT',
|
||||
delete: 'DELETE',
|
||||
patchForObject: 'PATCH',
|
||||
};
|
||||
|
||||
/**
|
||||
* WebClient short-form verb → HTTP verb. The reactive WebClient API
|
||||
* exposes `.get()`, `.post()`, `.put()`, `.delete()`, `.patch()` as
|
||||
* one-liners that return a `RequestHeadersUriSpec` whose `.uri(...)`
|
||||
* carries the path. We capture both pieces in a single query (see
|
||||
* `WEB_CLIENT_SHORT_PATTERNS` below) and translate the verb here.
|
||||
*/
|
||||
const WEB_CLIENT_SHORT_TO_HTTP: Record<string, string> = {
|
||||
get: 'GET',
|
||||
post: 'POST',
|
||||
put: 'PUT',
|
||||
delete: 'DELETE',
|
||||
patch: 'PATCH',
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the plugin only if the Kotlin grammar is available. Compiling
|
||||
* the queries against a null grammar would throw at module load time
|
||||
* and abort the whole http-route-extractor module.
|
||||
*/
|
||||
function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
|
||||
// ─── Provider: Spring class-level @RequestMapping prefix ──────────────
|
||||
// Two patterns mirror the Java plugin's positional vs named split:
|
||||
// @RequestMapping("/api") → value_argument has string_literal as its first named child
|
||||
// @RequestMapping(path = "/api") → value_argument has [simple_identifier @key, string_literal]
|
||||
// @RequestMapping(value = "/api") → same as above, with key="value"
|
||||
//
|
||||
// Tree-sitter-kotlin grammar (fwcd 0.3.8) does NOT have a separate
|
||||
// node for named arguments — both positional and named forms share
|
||||
// `value_argument`. The positional pattern uses the immediate-child
|
||||
// anchor `.` so it only matches when the string_literal is the FIRST
|
||||
// named child (i.e. no preceding simple_identifier "=" prefix). The
|
||||
// named pattern explicitly captures the simple_identifier and uses
|
||||
// `#match?` to restrict it to `path`/`value`, matching the same
|
||||
// safety bar that the Java plugin enforces (see java.ts and the
|
||||
// sibling topic-patterns/java.ts for the analogous constraint).
|
||||
//
|
||||
// Without the `key:` constraint the named query would also capture
|
||||
// unrelated attributes like `produces`, `consumes`, `headers`,
|
||||
// `name`, `params` — emitting bogus route contracts (a regression
|
||||
// identical to the one Claude flagged on PR #1834 for Java).
|
||||
const SPRING_CLASS_PREFIX_PATTERNS = compilePatterns({
|
||||
name: 'kotlin-spring-class-prefix',
|
||||
language,
|
||||
patterns: [
|
||||
{
|
||||
meta: {},
|
||||
query: `
|
||||
(class_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
(constructor_invocation
|
||||
(user_type (type_identifier) @ann (#eq? @ann "RequestMapping"))
|
||||
(value_arguments
|
||||
(value_argument . (string_literal) @prefix)))))
|
||||
(type_identifier) @cls) @class
|
||||
`,
|
||||
},
|
||||
{
|
||||
meta: {},
|
||||
query: `
|
||||
(class_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
(constructor_invocation
|
||||
(user_type (type_identifier) @ann (#eq? @ann "RequestMapping"))
|
||||
(value_arguments
|
||||
(value_argument
|
||||
(simple_identifier) @key (#match? @key "^(path|value)$")
|
||||
(string_literal) @prefix)))))
|
||||
(type_identifier) @cls) @class
|
||||
`,
|
||||
},
|
||||
],
|
||||
} satisfies LanguagePatterns<Record<string, never>>);
|
||||
|
||||
// ─── Provider: Spring @(Get|Post|...)Mapping method annotations ───────
|
||||
// Same dual-pattern positional/named approach. The Kotlin AST puts the
|
||||
// function name (`simple_identifier`) outside the `modifiers` subtree,
|
||||
// so we capture it from `function_declaration` directly.
|
||||
const SPRING_METHOD_ROUTE_PATTERNS = compilePatterns({
|
||||
name: 'kotlin-spring-method-route',
|
||||
language,
|
||||
patterns: [
|
||||
{
|
||||
meta: {},
|
||||
query: `
|
||||
(function_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
(constructor_invocation
|
||||
(user_type (type_identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Mapping$"))
|
||||
(value_arguments
|
||||
(value_argument . (string_literal) @path)))))
|
||||
(simple_identifier) @method_name) @method
|
||||
`,
|
||||
},
|
||||
{
|
||||
meta: {},
|
||||
query: `
|
||||
(function_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
(constructor_invocation
|
||||
(user_type (type_identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Mapping$"))
|
||||
(value_arguments
|
||||
(value_argument
|
||||
(simple_identifier) @key (#match? @key "^(path|value)$")
|
||||
(string_literal) @path)))))
|
||||
(simple_identifier) @method_name) @method
|
||||
`,
|
||||
},
|
||||
],
|
||||
} satisfies LanguagePatterns<Record<string, never>>);
|
||||
|
||||
// ─── Consumer: Spring RestTemplate ────────────────────────────────────
|
||||
// Kotlin call-site shape mirrors the Java plugin's
|
||||
// `REST_TEMPLATE_PATTERNS`, but goes through tree-sitter-kotlin's
|
||||
// `navigation_expression` instead of Java's `method_invocation`:
|
||||
//
|
||||
// restTemplate.getForObject("/x", User::class.java)
|
||||
//
|
||||
// becomes
|
||||
//
|
||||
// call_expression
|
||||
// navigation_expression
|
||||
// simple_identifier "restTemplate"
|
||||
// navigation_suffix → simple_identifier "getForObject"
|
||||
// call_suffix
|
||||
// value_arguments
|
||||
// value_argument . string_literal "/x" ← captured
|
||||
// value_argument User::class.java
|
||||
//
|
||||
// The receiver name is constrained to `restTemplate` (#eq? @obj),
|
||||
// matching the Java plugin's heuristic. This means a non-conventional
|
||||
// field name (e.g. `userServiceTemplate`) will not be picked up;
|
||||
// that's the same trade-off already accepted on the Java side.
|
||||
const REST_TEMPLATE_PATTERNS = compilePatterns({
|
||||
name: 'kotlin-rest-template',
|
||||
language,
|
||||
patterns: [
|
||||
{
|
||||
meta: {},
|
||||
query: `
|
||||
(call_expression
|
||||
(navigation_expression
|
||||
(simple_identifier) @obj (#eq? @obj "restTemplate")
|
||||
(navigation_suffix (simple_identifier) @method))
|
||||
(call_suffix
|
||||
(value_arguments . (value_argument . (string_literal) @path))))
|
||||
`,
|
||||
},
|
||||
],
|
||||
} satisfies LanguagePatterns<Record<string, never>>);
|
||||
|
||||
// ─── Consumer: Spring WebClient (short form) ──────────────────────────
|
||||
// Reactive WebClient exposes one-liner verb helpers:
|
||||
//
|
||||
// webClient.get().uri("/x").retrieve().awaitBody<T>()
|
||||
// webClient.post().uri("/x")...
|
||||
//
|
||||
// The chain `webClient.get().uri("/x")` parses as two nested
|
||||
// `call_expression` nodes — the OUTER call is `.uri("/x")` and the
|
||||
// INNER call is `webClient.get()`. We anchor on the outer call and
|
||||
// require:
|
||||
// - inner receiver is `webClient`
|
||||
// - inner suffix is one of the HTTP verbs (#match?)
|
||||
// - outer suffix is exactly `uri`
|
||||
// - outer call's first value_argument is a string literal
|
||||
//
|
||||
// The long-form `webClient.method(HttpMethod.GET).uri("/x")` chain
|
||||
// uses an extra navigation hop and an enum field access — it's
|
||||
// intentionally out of scope here (see file header).
|
||||
const WEB_CLIENT_SHORT_PATTERNS = compilePatterns({
|
||||
name: 'kotlin-web-client-short',
|
||||
language,
|
||||
patterns: [
|
||||
{
|
||||
meta: {},
|
||||
query: `
|
||||
(call_expression
|
||||
(navigation_expression
|
||||
(call_expression
|
||||
(navigation_expression
|
||||
(simple_identifier) @obj (#eq? @obj "webClient")
|
||||
(navigation_suffix
|
||||
(simple_identifier) @verb (#match? @verb "^(get|post|put|delete|patch)$")))
|
||||
(call_suffix (value_arguments)))
|
||||
(navigation_suffix (simple_identifier) @uri (#eq? @uri "uri")))
|
||||
(call_suffix
|
||||
(value_arguments . (value_argument . (string_literal) @path))))
|
||||
`,
|
||||
},
|
||||
],
|
||||
} satisfies LanguagePatterns<Record<string, never>>);
|
||||
|
||||
// ─── Consumer: OkHttp Request.Builder().url("/x") ─────────────────────
|
||||
// Kotlin parses `Request.Builder()` as a `call_expression` whose
|
||||
// callee is a `navigation_expression` (Request → .Builder), NOT as
|
||||
// Java's `object_creation_expression`. The chain `.url("/x")` then
|
||||
// wraps that in another `call_expression`. The query mirrors Java's
|
||||
// `OK_HTTP_PATTERNS` (java.ts) but adapts the node types.
|
||||
//
|
||||
// Receiver `Request` is constrained by name (#eq? @cls); a project
|
||||
// that imports OkHttp's `Request` under an alias (`import okhttp3.Request as OkRequest`)
|
||||
// would not be picked up — this matches the Java plugin's heuristic.
|
||||
//
|
||||
// **Known limitation — verb defaults to GET.** OkHttp encodes the
|
||||
// verb on a *sibling* call further down the builder chain (e.g.
|
||||
// `.post(body)` / `.get()` / `.delete()`), not on `.url(...)` itself.
|
||||
// This query intentionally does not walk the chain to recover the
|
||||
// verb — it emits `method: 'GET'` for every match, mirroring
|
||||
// `java.ts:OK_HTTP_PATTERNS`. So a `Request.Builder().url("/x").post(body).build()`
|
||||
// call becomes `http::GET::/x`, not `http::POST::/x`. This is the
|
||||
// same trade-off Java has accepted; pinned by an anti-overreach
|
||||
// test in `http-route-extractor.test.ts` so a future verb-walk
|
||||
// implementation has to update this comment in lockstep.
|
||||
const OK_HTTP_PATTERNS = compilePatterns({
|
||||
name: 'kotlin-okhttp',
|
||||
language,
|
||||
patterns: [
|
||||
{
|
||||
meta: {},
|
||||
query: `
|
||||
(call_expression
|
||||
(navigation_expression
|
||||
(call_expression
|
||||
(navigation_expression
|
||||
(simple_identifier) @cls (#eq? @cls "Request")
|
||||
(navigation_suffix (simple_identifier) @builder (#eq? @builder "Builder")))
|
||||
(call_suffix (value_arguments)))
|
||||
(navigation_suffix (simple_identifier) @method (#eq? @method "url")))
|
||||
(call_suffix
|
||||
(value_arguments . (value_argument . (string_literal) @path))))
|
||||
`,
|
||||
},
|
||||
],
|
||||
} satisfies LanguagePatterns<Record<string, never>>);
|
||||
|
||||
/**
|
||||
* Find the nearest enclosing class_declaration ancestor for a node, or
|
||||
* null if the node is top-level. Mirrors the Java plugin's helper.
|
||||
*/
|
||||
function findEnclosingClass(node: Parser.SyntaxNode): Parser.SyntaxNode | null {
|
||||
let cur: Parser.SyntaxNode | null = node.parent;
|
||||
while (cur) {
|
||||
if (cur.type === 'class_declaration') return cur;
|
||||
cur = cur.parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Join a class-level prefix and a method-level path. Identical
|
||||
* semantics to the Java plugin: strip leading/trailing slashes on
|
||||
* the prefix, strip leading slashes on the method path, ensure a
|
||||
* single slash between them.
|
||||
*/
|
||||
function joinPath(prefix: string, methodPath: string): string {
|
||||
const cleanPrefix = prefix.replace(/^\/+/, '').replace(/\/+$/, '');
|
||||
const cleanSub = methodPath.replace(/^\/+/, '');
|
||||
if (!cleanPrefix) return `/${cleanSub}`;
|
||||
return `/${cleanPrefix}/${cleanSub}`;
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'kotlin-http',
|
||||
language,
|
||||
scan(tree) {
|
||||
const out: HttpDetection[] = [];
|
||||
|
||||
// ─── Class prefixes ─────────────────────────────────────────────
|
||||
const prefixByClassId = new Map<number, string>();
|
||||
for (const match of runCompiledPatterns(SPRING_CLASS_PREFIX_PATTERNS, tree)) {
|
||||
const prefixNode = match.captures.prefix;
|
||||
const classNode = match.captures.class;
|
||||
if (!prefixNode || !classNode) continue;
|
||||
const prefix = unquoteLiteral(prefixNode.text);
|
||||
if (prefix !== null) prefixByClassId.set(classNode.id, prefix);
|
||||
}
|
||||
|
||||
// ─── Method routes ──────────────────────────────────────────────
|
||||
for (const match of runCompiledPatterns(SPRING_METHOD_ROUTE_PATTERNS, tree)) {
|
||||
const annNode = match.captures.ann;
|
||||
const pathNode = match.captures.path;
|
||||
const nameNode = match.captures.method_name;
|
||||
const methodNode = match.captures.method;
|
||||
if (!annNode || !pathNode || !methodNode) continue;
|
||||
const httpMethod = METHOD_ANNOTATION_TO_HTTP[annNode.text];
|
||||
if (!httpMethod) continue;
|
||||
const rawPath = unquoteLiteral(pathNode.text);
|
||||
if (rawPath === null) continue;
|
||||
const enclosingClass = findEnclosingClass(methodNode);
|
||||
const prefix = enclosingClass ? (prefixByClassId.get(enclosingClass.id) ?? '') : '';
|
||||
const fullPath = joinPath(prefix, rawPath);
|
||||
out.push({
|
||||
role: 'provider',
|
||||
framework: 'spring',
|
||||
method: httpMethod,
|
||||
path: fullPath,
|
||||
name: nameNode?.text ?? null,
|
||||
confidence: 0.8,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Consumers: RestTemplate ────────────────────────────────────
|
||||
for (const match of runCompiledPatterns(REST_TEMPLATE_PATTERNS, tree)) {
|
||||
const methodNode = match.captures.method;
|
||||
const pathNode = match.captures.path;
|
||||
if (!methodNode || !pathNode) continue;
|
||||
const httpMethod = REST_TEMPLATE_TO_HTTP[methodNode.text];
|
||||
if (!httpMethod) continue;
|
||||
const path = unquoteLiteral(pathNode.text);
|
||||
if (path === null) continue;
|
||||
out.push({
|
||||
role: 'consumer',
|
||||
framework: 'spring-rest-template',
|
||||
method: httpMethod,
|
||||
path,
|
||||
name: null,
|
||||
confidence: 0.7,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Consumers: WebClient short form (.get()/.post()/etc → .uri) ─
|
||||
for (const match of runCompiledPatterns(WEB_CLIENT_SHORT_PATTERNS, tree)) {
|
||||
const verbNode = match.captures.verb;
|
||||
const pathNode = match.captures.path;
|
||||
if (!verbNode || !pathNode) continue;
|
||||
const httpMethod = WEB_CLIENT_SHORT_TO_HTTP[verbNode.text];
|
||||
if (!httpMethod) continue;
|
||||
const path = unquoteLiteral(pathNode.text);
|
||||
if (path === null) continue;
|
||||
out.push({
|
||||
role: 'consumer',
|
||||
framework: 'spring-web-client',
|
||||
method: httpMethod,
|
||||
path,
|
||||
name: null,
|
||||
confidence: 0.7,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Consumers: OkHttp Request.Builder().url("path") ────────────
|
||||
for (const match of runCompiledPatterns(OK_HTTP_PATTERNS, tree)) {
|
||||
const pathNode = match.captures.path;
|
||||
if (!pathNode) continue;
|
||||
const path = unquoteLiteral(pathNode.text);
|
||||
if (path === null) continue;
|
||||
out.push({
|
||||
role: 'consumer',
|
||||
framework: 'okhttp',
|
||||
method: 'GET',
|
||||
path,
|
||||
name: null,
|
||||
confidence: 0.7,
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The exported plugin is `null` when tree-sitter-kotlin's native
|
||||
* binding is unavailable. `http-patterns/index.ts` checks for null
|
||||
* before registering `.kt`/`.kts` so missing optional grammars never
|
||||
* crash the orchestrator.
|
||||
*/
|
||||
export const KOTLIN_HTTP_PLUGIN: HttpLanguagePlugin | null = Kotlin
|
||||
? buildKotlinPlugin(Kotlin)
|
||||
: null;
|
||||
|
|
@ -43,6 +43,7 @@ export interface GroupToolPort {
|
|||
relationTypes?: string[];
|
||||
includeTests?: boolean;
|
||||
minConfidence?: number;
|
||||
limit?: number;
|
||||
},
|
||||
): Promise<unknown>;
|
||||
query(
|
||||
|
|
|
|||
|
|
@ -6,11 +6,18 @@
|
|||
* processed by cobol-processor.ts in pipeline Phase 2.6, not by the
|
||||
* tree-sitter pipeline.
|
||||
*
|
||||
* This provider exists to satisfy the SupportedLanguages exhaustiveness
|
||||
* checks and to declare parseStrategy: 'standalone'.
|
||||
* This provider supports scope-based resolution (RFC #909 Ring 3) via
|
||||
* `emitScopeCaptures` which wraps the regex tagger. COPY statements are
|
||||
* interpreted as imports; there is no type system and no implicit receiver.
|
||||
*/
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { defineLanguage } from '../language-provider.js';
|
||||
import {
|
||||
emitCobolScopeCaptures,
|
||||
interpretCobolImport,
|
||||
cobolImportOwningScope,
|
||||
cobolReceiverBinding,
|
||||
} from './cobol/index.js';
|
||||
|
||||
export const cobolProvider = defineLanguage({
|
||||
id: SupportedLanguages.Cobol,
|
||||
|
|
@ -26,4 +33,10 @@ export const cobolProvider = defineLanguage({
|
|||
},
|
||||
exportChecker: () => false,
|
||||
importResolver: () => null,
|
||||
|
||||
// ── Scope-resolution hooks ───────────────────────────────────────
|
||||
emitScopeCaptures: emitCobolScopeCaptures,
|
||||
interpretImport: interpretCobolImport,
|
||||
importOwningScope: cobolImportOwningScope,
|
||||
receiverBinding: cobolReceiverBinding,
|
||||
});
|
||||
|
|
|
|||
291
gitnexus/src/core/ingestion/languages/cobol/captures.ts
Normal file
291
gitnexus/src/core/ingestion/languages/cobol/captures.ts
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
/**
|
||||
* `emitScopeCaptures` for COBOL.
|
||||
*
|
||||
* Wraps the existing regex tagger (`extractCobolSymbolsWithRegex`) and
|
||||
* produces parser-agnostic `CaptureMatch[]` matching the RFC §5.1
|
||||
* vocabulary. The central `ScopeExtractor` consumes these captures
|
||||
* without knowing whether they came from tree-sitter or regex.
|
||||
*
|
||||
* Pure given the input source text. No I/O, no globals consulted.
|
||||
* The regex tagger is synchronous — no async needed.
|
||||
*/
|
||||
|
||||
import type { Capture, CaptureMatch, Range } from 'gitnexus-shared';
|
||||
import {
|
||||
extractCobolSymbolsWithRegex,
|
||||
preprocessCobolSource,
|
||||
} from '../../cobol/cobol-preprocessor.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Capture building helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function capture(name: string, range: Range, text: string): Capture {
|
||||
return { name, range, text };
|
||||
}
|
||||
|
||||
function rangeOf(startLine: number, startCol: number, endLine: number, endCol: number): Range {
|
||||
return { startLine, startCol, endLine, endCol };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a single CaptureMatch from a record of captures.
|
||||
* Returns null if the record is empty.
|
||||
*/
|
||||
function matchFrom(grouped: Record<string, Capture>): CaptureMatch | null {
|
||||
if (Object.keys(grouped).length === 0) return null;
|
||||
return Object.freeze(grouped) as CaptureMatch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute end column for a single-line capture from the source lines array.
|
||||
*/
|
||||
function endColFrom(line: string): number {
|
||||
return line.length > 0 ? line.length - 1 : 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function emitCobolScopeCaptures(
|
||||
sourceText: string,
|
||||
_filePath: string,
|
||||
_cachedTree?: unknown,
|
||||
): readonly CaptureMatch[] {
|
||||
const lines = sourceText.split(/\r?\n/);
|
||||
// Preprocess: strip patch markers from columns 1-6
|
||||
const cleaned = preprocessCobolSource(sourceText);
|
||||
// Run the regex tagger on the preprocessed source
|
||||
const extracted = extractCobolSymbolsWithRegex(cleaned, _filePath);
|
||||
|
||||
const out: CaptureMatch[] = [];
|
||||
|
||||
// ── 1. PROGRAM-ID → @scope.module ───────────────────────────────────
|
||||
// The primary program name (first PROGRAM-ID encountered)
|
||||
if (extracted.programName) {
|
||||
const name = extracted.programName;
|
||||
const lastLine = lines.length;
|
||||
|
||||
const progDef = extracted.programs.find((p) => p.name.toUpperCase() === name.toUpperCase());
|
||||
const startLine = progDef?.startLine ?? 1;
|
||||
const endLine = progDef?.endLine ?? lastLine;
|
||||
const startCol = 0;
|
||||
const endCol = endColFrom(lines[Math.min(endLine, lines.length) - 1] ?? '');
|
||||
|
||||
const progIdLine = findProgramIdLine(cleaned, name);
|
||||
const nameRange =
|
||||
progIdLine !== -1
|
||||
? rangeOf(progIdLine, 7, progIdLine, lines[progIdLine - 1]?.length ?? endCol)
|
||||
: rangeOf(startLine, startCol, endLine, endCol);
|
||||
|
||||
const grouped: Record<string, Capture> = {
|
||||
'@scope.module': capture('@scope.module', nameRange, name),
|
||||
'@declaration.program': capture(
|
||||
'@declaration.program',
|
||||
rangeOf(startLine, startCol, endLine, endCol),
|
||||
name,
|
||||
),
|
||||
'@declaration.name': capture('@declaration.name', nameRange, name),
|
||||
};
|
||||
|
||||
if (progDef?.procedureUsing && progDef.procedureUsing.length > 0) {
|
||||
grouped['@declaration.parameter-count'] = capture(
|
||||
'@declaration.parameter-count',
|
||||
nameRange,
|
||||
String(progDef.procedureUsing.length),
|
||||
);
|
||||
}
|
||||
|
||||
const m = matchFrom(grouped);
|
||||
if (m !== null) out.push(m);
|
||||
}
|
||||
|
||||
// ── 2. Nested / additional programs → @scope.module ──────────────
|
||||
for (const prog of extracted.programs) {
|
||||
if (extracted.programName && prog.name.toUpperCase() === extracted.programName.toUpperCase())
|
||||
continue;
|
||||
|
||||
const startLine = prog.startLine;
|
||||
const endLine = prog.endLine;
|
||||
const startCol = 0;
|
||||
const endCol = endColFrom(lines[Math.min(endLine, lines.length) - 1] ?? '');
|
||||
|
||||
const progIdLine = findProgramIdLine(cleaned, prog.name);
|
||||
const nameRange =
|
||||
progIdLine !== -1
|
||||
? rangeOf(progIdLine, 7, progIdLine, lines[progIdLine - 1]?.length ?? endCol)
|
||||
: rangeOf(startLine, startCol, endLine, endCol);
|
||||
|
||||
const grouped: Record<string, Capture> = {
|
||||
'@scope.module': capture('@scope.module', nameRange, prog.name),
|
||||
'@declaration.program': capture(
|
||||
'@declaration.program',
|
||||
rangeOf(startLine, startCol, endLine, endCol),
|
||||
prog.name,
|
||||
),
|
||||
'@declaration.name': capture('@declaration.name', nameRange, prog.name),
|
||||
};
|
||||
|
||||
if (prog.procedureUsing && prog.procedureUsing.length > 0) {
|
||||
grouped['@declaration.parameter-count'] = capture(
|
||||
'@declaration.parameter-count',
|
||||
nameRange,
|
||||
String(prog.procedureUsing.length),
|
||||
);
|
||||
}
|
||||
|
||||
const m = matchFrom(grouped);
|
||||
if (m !== null) out.push(m);
|
||||
}
|
||||
|
||||
// ── 3. PROCEDURE DIVISION sections → @scope.function ─────────────
|
||||
for (const section of extracted.sections) {
|
||||
const lineIdx = section.line - 1;
|
||||
if (lineIdx < 0 || lineIdx >= lines.length) continue;
|
||||
|
||||
const sectionLine = lines[lineIdx];
|
||||
const startCol = 0;
|
||||
const endCol = endColFrom(sectionLine);
|
||||
const nameRange = rangeOf(section.line, startCol, section.line, endCol);
|
||||
|
||||
const grouped: Record<string, Capture> = {
|
||||
'@scope.function': capture('@scope.function', nameRange, section.name),
|
||||
'@declaration.function': capture('@declaration.function', nameRange, section.name),
|
||||
'@declaration.name': capture('@declaration.name', nameRange, section.name),
|
||||
};
|
||||
|
||||
const m = matchFrom(grouped);
|
||||
if (m !== null) out.push(m);
|
||||
}
|
||||
|
||||
// ── 4. Paragraphs → @scope.function ──────────────────────────────
|
||||
for (const para of extracted.paragraphs) {
|
||||
const lineIdx = para.line - 1;
|
||||
if (lineIdx < 0 || lineIdx >= lines.length) continue;
|
||||
|
||||
const paraLine = lines[lineIdx];
|
||||
const startCol = 0;
|
||||
const endCol = endColFrom(paraLine);
|
||||
const nameRange = rangeOf(para.line, startCol, para.line, endCol);
|
||||
|
||||
const grouped: Record<string, Capture> = {
|
||||
'@scope.function': capture('@scope.function', nameRange, para.name),
|
||||
'@declaration.function': capture('@declaration.function', nameRange, para.name),
|
||||
'@declaration.name': capture('@declaration.name', nameRange, para.name),
|
||||
};
|
||||
|
||||
const m = matchFrom(grouped);
|
||||
if (m !== null) out.push(m);
|
||||
}
|
||||
|
||||
// ── 5. COPY → @import.statement ──────────────────────────────────
|
||||
for (const copy of extracted.copies) {
|
||||
const lineIdx = copy.line - 1;
|
||||
if (lineIdx < 0 || lineIdx >= lines.length) continue;
|
||||
|
||||
const copyLine = lines[lineIdx];
|
||||
const startCol = 0;
|
||||
const endCol = endColFrom(copyLine);
|
||||
const stmtRange = rangeOf(copy.line, startCol, copy.line, endCol);
|
||||
|
||||
const grouped: Record<string, Capture> = {
|
||||
'@import.statement': capture('@import.statement', stmtRange, copy.target),
|
||||
'@import.name': capture('@import.name', stmtRange, copy.target),
|
||||
};
|
||||
|
||||
const m = matchFrom(grouped);
|
||||
if (m !== null) out.push(m);
|
||||
}
|
||||
|
||||
// ── 6. CALL (quoted/referenced) → @reference.call ────────────────
|
||||
for (const call of extracted.calls) {
|
||||
const lineIdx = call.line - 1;
|
||||
if (lineIdx < 0 || lineIdx >= lines.length) continue;
|
||||
|
||||
const callLine = lines[lineIdx];
|
||||
const startCol = 0;
|
||||
const endCol = endColFrom(callLine);
|
||||
const stmtRange = rangeOf(call.line, startCol, call.line, endCol);
|
||||
|
||||
const grouped: Record<string, Capture> = {
|
||||
'@reference.call': capture('@reference.call', stmtRange, call.target),
|
||||
'@reference.name': capture('@reference.name', stmtRange, call.target),
|
||||
};
|
||||
|
||||
// Arity from CALL USING parameters
|
||||
if (call.parameters && call.parameters.length > 0) {
|
||||
grouped['@reference.arity'] = capture(
|
||||
'@reference.arity',
|
||||
stmtRange,
|
||||
String(call.parameters.length),
|
||||
);
|
||||
}
|
||||
|
||||
const m = matchFrom(grouped);
|
||||
if (m !== null) out.push(m);
|
||||
}
|
||||
|
||||
// ── 7. PERFORM → @reference.call ─────────────────────────────────
|
||||
for (const perf of extracted.performs) {
|
||||
const lineIdx = perf.line - 1;
|
||||
if (lineIdx < 0 || lineIdx >= lines.length) continue;
|
||||
|
||||
const perfLine = lines[lineIdx];
|
||||
const startCol = 0;
|
||||
const endCol = endColFrom(perfLine);
|
||||
const stmtRange = rangeOf(perf.line, startCol, perf.line, endCol);
|
||||
|
||||
const grouped: Record<string, Capture> = {
|
||||
'@reference.call': capture('@reference.call', stmtRange, perf.target),
|
||||
'@reference.name': capture('@reference.name', stmtRange, perf.target),
|
||||
};
|
||||
|
||||
const m = matchFrom(grouped);
|
||||
if (m !== null) out.push(m);
|
||||
}
|
||||
|
||||
// ── 8. GO TO → @reference.call ───────────────────────────────────
|
||||
for (const gt of extracted.gotos) {
|
||||
const lineIdx = gt.line - 1;
|
||||
if (lineIdx < 0 || lineIdx >= lines.length) continue;
|
||||
|
||||
const gtLine = lines[lineIdx];
|
||||
const startCol = 0;
|
||||
const endCol = endColFrom(gtLine);
|
||||
const stmtRange = rangeOf(gt.line, startCol, gt.line, endCol);
|
||||
|
||||
const grouped: Record<string, Capture> = {
|
||||
'@reference.call': capture('@reference.call', stmtRange, gt.target),
|
||||
'@reference.name': capture('@reference.name', stmtRange, gt.target),
|
||||
};
|
||||
|
||||
const m = matchFrom(grouped);
|
||||
if (m !== null) out.push(m);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Find the PROGRAM-ID. line for a given program name in the cleaned source.
|
||||
* Returns 1-based line number, or -1 if not found.
|
||||
*/
|
||||
function findProgramIdLine(cleanedSource: string, programName: string): number {
|
||||
const lines = cleanedSource.split(/\r?\n/);
|
||||
const upper = programName.toUpperCase();
|
||||
const re = new RegExp(`\\bPROGRAM-ID\\.\\s*${escapeRegex(upper)}\\b`, 'i');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (re.test(lines[i])) return i + 1; // 1-based
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** Simple regex escape for special chars. */
|
||||
function escapeRegex(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
20
gitnexus/src/core/ingestion/languages/cobol/index.ts
Normal file
20
gitnexus/src/core/ingestion/languages/cobol/index.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
/**
|
||||
* COBOL scope-resolution public API barrel.
|
||||
*
|
||||
* Consumers should import from this file rather than the individual
|
||||
* modules — that keeps the per-hook organization an implementation
|
||||
* detail we can refactor without touching the provider wiring.
|
||||
*
|
||||
* Module layout:
|
||||
*
|
||||
* - `captures.ts` — `emitCobolScopeCaptures` (wraps the regex tagger)
|
||||
* - `interpret.ts` — import/type-binding/receiver hooks
|
||||
*/
|
||||
|
||||
export { emitCobolScopeCaptures } from './captures.js';
|
||||
export {
|
||||
interpretCobolImport,
|
||||
interpretCobolTypeBinding,
|
||||
cobolImportOwningScope,
|
||||
cobolReceiverBinding,
|
||||
} from './interpret.js';
|
||||
99
gitnexus/src/core/ingestion/languages/cobol/interpret.ts
Normal file
99
gitnexus/src/core/ingestion/languages/cobol/interpret.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
/**
|
||||
* COBOL scope-resolution interpret hooks.
|
||||
*
|
||||
* Interprets raw `@import.statement` capture matches (from COPY statements)
|
||||
* into `ParsedImport` for the central finalize algorithm.
|
||||
*
|
||||
* COBOL's import semantic is simple: `COPY bookname` means the copybook's
|
||||
* content is inlined at compile time. There is no module-system equivalent
|
||||
* of `export` — everything is text-inclusion. The scope-resolution pipeline
|
||||
* models this as a `'named'` import where the imported name is the copybook
|
||||
* name and the target is the copybook file path.
|
||||
*/
|
||||
|
||||
import type {
|
||||
CaptureMatch,
|
||||
ParsedImport,
|
||||
ParsedTypeBinding,
|
||||
ScopeId,
|
||||
ScopeTree,
|
||||
Scope,
|
||||
TypeRef,
|
||||
} from 'gitnexus-shared';
|
||||
|
||||
// ─── interpretImport ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Interpret a COPY statement as a `ParsedImport`.
|
||||
*
|
||||
* The `@import.name` capture contains the copybook target name (e.g.,
|
||||
* `CPSESP` from `COPY CPSESP.`). Returns a `'named'` import with the
|
||||
* copybook name as both `localName` and `importedName`.
|
||||
*
|
||||
* Returns `null` for any match that doesn't carry an `@import.name` (e.g.,
|
||||
* malformed COPY statements the regex tagger might emit).
|
||||
*/
|
||||
export function interpretCobolImport(match: CaptureMatch): ParsedImport | null {
|
||||
const nameCap = match['@import.name'];
|
||||
if (nameCap === undefined) return null;
|
||||
|
||||
const name = nameCap.text;
|
||||
if (name === '') return null;
|
||||
|
||||
return {
|
||||
kind: 'named',
|
||||
localName: name,
|
||||
importedName: name,
|
||||
targetRaw: name,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── interpretTypeBinding ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* COBOL has no type system — no type bindings to interpret.
|
||||
* Always returns `null`.
|
||||
*/
|
||||
export function interpretCobolTypeBinding(_match: CaptureMatch): ParsedTypeBinding | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── importOwningScope ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* COPY statements in COBOL are module-level — they expand inline at
|
||||
* compile time and their bindings belong to the enclosing PROGRAM-ID
|
||||
* (Module) scope. Walk up from the innermost scope through ancestors
|
||||
* to find the enclosing Module scope.
|
||||
*
|
||||
* For the edge case of a COPY inside a paragraph (unusual but possible
|
||||
* with some vendors), we walk the scope tree to ensure the import is
|
||||
* attached to the program scope, not the paragraph Function scope.
|
||||
*/
|
||||
export function cobolImportOwningScope(
|
||||
_imp: ParsedImport,
|
||||
innermost: Scope,
|
||||
tree: ScopeTree,
|
||||
): ScopeId | null {
|
||||
// If already in a Module scope, use it directly.
|
||||
if (innermost.kind === 'Module') return innermost.id;
|
||||
// Walk through ancestors to find the enclosing Module.
|
||||
const ancestors = tree.getAncestors(innermost.id);
|
||||
for (const ancId of ancestors) {
|
||||
const anc = tree.getScope(ancId);
|
||||
if (anc !== undefined && anc.kind === 'Module') return ancId;
|
||||
}
|
||||
// Fallback: delegate to central default.
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── receiverBinding ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* COBOL has no implicit receiver (no `self`, `this`, or equivalent).
|
||||
* All function calls are explicit CALL statements or PERFORM/GO TO
|
||||
* control flow. Always returns `null`.
|
||||
*/
|
||||
export function cobolReceiverBinding(_functionScope: Scope): TypeRef | null {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
/**
|
||||
* COBOL `ScopeResolver` registered in `SCOPE_RESOLVERS` and consumed
|
||||
* by the generic `runScopeResolution` orchestrator.
|
||||
*
|
||||
* The provider is a thin wiring object — COBOL's simple scope model
|
||||
* (Module + Function only, no inheritance, no type system) plugs into
|
||||
* `runScopeResolution` with minimal configuration.
|
||||
*
|
||||
* Reference: `languages/python/scope-resolver.ts`.
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import type { ParsedFile } from 'gitnexus-shared';
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js';
|
||||
import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
|
||||
import { cobolProvider } from '../cobol.js';
|
||||
|
||||
// Copybook file extensions for COPY name resolution
|
||||
const COPYBOOK_EXTENSIONS = new Set(['.cpy', '.copybook']);
|
||||
|
||||
const cobolScopeResolver: ScopeResolver = {
|
||||
language: SupportedLanguages.Cobol,
|
||||
languageProvider: cobolProvider,
|
||||
importEdgeReason: 'cobol-scope: copy',
|
||||
|
||||
// ── Resolve COPY bookname to file path ─────────────────────────────
|
||||
resolveImportTarget: (targetRaw, _fromFile, allFilePaths) => {
|
||||
const upper = targetRaw.toUpperCase();
|
||||
// Check copybook files first
|
||||
for (const fp of allFilePaths) {
|
||||
const ext = path.extname(fp).toLowerCase();
|
||||
if (!COPYBOOK_EXTENSIONS.has(ext)) continue;
|
||||
const basename = path.basename(fp, ext).toUpperCase();
|
||||
if (basename === upper) return fp;
|
||||
}
|
||||
// Also search COBOL source files (.cbl, .cob, .cobol)
|
||||
const COBOL_SOURCE_EXTS = new Set(['.cbl', '.cob', '.cobol']);
|
||||
for (const fp of allFilePaths) {
|
||||
const ext = path.extname(fp).toLowerCase();
|
||||
if (!COBOL_SOURCE_EXTS.has(ext)) continue;
|
||||
const basename = path.basename(fp, ext).toUpperCase();
|
||||
if (basename === upper) return fp;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
// COBOL has no binding-merge rules beyond the default (local-first-then-imports).
|
||||
mergeBindings: (existing) => [...existing],
|
||||
|
||||
// COBOL arity: compare CALL USING param count against def's parameterCount.
|
||||
// COBOL requires exact arity match for CALL USING.
|
||||
arityCompatibility: (callsite, def) => {
|
||||
if (callsite.arity === undefined) return 'unknown';
|
||||
const defParamCount = def.parameterCount;
|
||||
if (defParamCount === undefined) return 'unknown';
|
||||
if (callsite.arity === defParamCount) return 'compatible';
|
||||
return 'incompatible';
|
||||
},
|
||||
|
||||
// No inheritance in COBOL — empty MRO map.
|
||||
buildMro: () => new Map(),
|
||||
|
||||
// Everything lives under the PROGRAM-ID Module scope.
|
||||
populateOwners: (parsed: ParsedFile) => populateClassOwnedMembers(parsed),
|
||||
|
||||
// COBOL has no super calls.
|
||||
isSuperReceiver: () => false,
|
||||
|
||||
// ── Optional toggles ─────────────────────────────────────────────
|
||||
fieldFallbackOnMethodLookup: false,
|
||||
propagatesReturnTypesAcrossImports: false,
|
||||
};
|
||||
|
||||
export { cobolScopeResolver };
|
||||
|
|
@ -126,6 +126,13 @@ export function emitCppScopeCaptures(
|
|||
JSON.stringify(arity.parameterTypeClasses),
|
||||
);
|
||||
}
|
||||
if (hasExplicitSpecifier(fnNode)) {
|
||||
grouped['@declaration.is-explicit'] = syntheticCapture(
|
||||
'@declaration.is-explicit',
|
||||
fnNode,
|
||||
'true',
|
||||
);
|
||||
}
|
||||
|
||||
// Detect static storage class (file-local linkage)
|
||||
if (hasStaticStorageClass(fnNode)) {
|
||||
|
|
@ -475,8 +482,9 @@ function detectCppDependentBases(root: SyntaxNode, filePath: string): void {
|
|||
for (const base of iterBaseClasses(baseClause)) {
|
||||
if (isBaseDependent(base, params)) {
|
||||
const baseName = extractBaseLookupName(base);
|
||||
const baseQualifier = extractBaseLookupQualifier(base);
|
||||
if (baseName !== '') {
|
||||
markCppDependentBase(filePath, className, baseName);
|
||||
markCppDependentBase(filePath, className, baseName, baseQualifier);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -553,10 +561,14 @@ function* iterBaseClasses(baseClause: SyntaxNode): IterableIterator<SyntaxNode>
|
|||
*/
|
||||
function isBaseDependent(baseNode: SyntaxNode, templateParams: Set<string>): boolean {
|
||||
if (baseNode.type !== 'template_type') {
|
||||
// Bare `type_identifier` or `qualified_identifier` bases — not
|
||||
// dependent (the base name itself doesn't reference a template
|
||||
// parameter at this level).
|
||||
return false;
|
||||
if (baseNode.type === 'qualified_identifier') {
|
||||
// Qualified identifier bases (e.g. `detail::Inner<T>`) may contain
|
||||
// template_type children — descend into them for template param check.
|
||||
// Fall through to the stack walk below.
|
||||
} else {
|
||||
// Bare `type_identifier` bases — not dependent.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Walk all descendants of the template_argument_list looking for any
|
||||
// type_identifier matching a template parameter, or any conservative-
|
||||
|
|
@ -624,6 +636,27 @@ function extractBaseLookupName(baseNode: SyntaxNode): string {
|
|||
return '';
|
||||
}
|
||||
|
||||
/** Extract the syntactic namespace qualifier from a base class node.
|
||||
* For `detail::Inner<T>`, returns `'detail'`.
|
||||
* For unqualified bases (`Inner<T>`, `Base<int>`), returns `''`.
|
||||
* Nested qualifiers (`a::b::Inner<T>`) return the full scope text.
|
||||
*/
|
||||
function extractBaseLookupQualifier(baseNode: SyntaxNode): string {
|
||||
if (baseNode.type === 'qualified_identifier') {
|
||||
const scopeNode = baseNode.childForFieldName('scope');
|
||||
if (scopeNode !== null) return scopeNode.text;
|
||||
}
|
||||
// template_type nodes may have a qualified_identifier as their name child
|
||||
if (baseNode.type === 'template_type') {
|
||||
const nameNode = baseNode.childForFieldName('name');
|
||||
if (nameNode !== null && nameNode.type === 'qualified_identifier') {
|
||||
const scopeNode = nameNode.childForFieldName('scope');
|
||||
if (scopeNode !== null) return scopeNode.text;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk parent chain from a function_definition / declaration / field_declaration
|
||||
* to find the enclosing `template_declaration`. Returns null when the function
|
||||
|
|
@ -1516,6 +1549,20 @@ function extractDeclaratorLeafName(node: SyntaxNode): string | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a C++ declaration has an `explicit` specifier. Tree-sitter-cpp
|
||||
* exposes `explicit` as a direct keyword child on constructor declarations in
|
||||
* current grammar builds; the bounded text prefix keeps this resilient across
|
||||
* small grammar shape differences without scanning whole function bodies.
|
||||
*/
|
||||
function hasExplicitSpecifier(node: SyntaxNode): boolean {
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i);
|
||||
if (child !== null && child.text === 'explicit') return true;
|
||||
}
|
||||
return /\bexplicit\b/.test(node.text.slice(0, 128));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a C++ function_definition or declaration has `static` storage class.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@
|
|||
* - rank 2: standard conversion (arithmetic, nullptr -> T*, T* -> bool,
|
||||
* T* -> void*)
|
||||
* - rank 3: nullptr -> bool (kept worse than nullptr -> T*)
|
||||
* - rank 4: ellipsis conversion (worst viable)
|
||||
* - rank 4: user-defined conversion (one-step, conservative)
|
||||
* - rank 5: ellipsis conversion (worst viable)
|
||||
* - Infinity: mismatch (string -> int, user types, unsupported shapes)
|
||||
*
|
||||
* This function is intentionally C++-specific. Other languages may define
|
||||
|
|
@ -20,6 +21,7 @@
|
|||
*/
|
||||
|
||||
import type { ParameterTypeClass } from 'gitnexus-shared';
|
||||
import { hasCppUserDefinedConversion } from './user-defined-conversions.js';
|
||||
|
||||
/** Set of normalized arithmetic types that support implicit conversion. */
|
||||
const ARITHMETIC = new Set(['int', 'double', 'char', 'bool']);
|
||||
|
|
@ -34,7 +36,8 @@ const INTEGRAL_PROMOTION = new Map([
|
|||
* Return the conversion rank from `argType` to `paramType`.
|
||||
*
|
||||
* @returns 0 for exact match, 1 for integral promotion, 2 for standard
|
||||
* conversion, 3 for nullptr -> bool, 4 for ellipsis, Infinity
|
||||
* conversion, 3 for nullptr -> bool, 4 for user-defined conversion,
|
||||
* 5 for ellipsis, Infinity
|
||||
* for mismatch.
|
||||
*/
|
||||
export function cppConversionRank(
|
||||
|
|
@ -46,13 +49,14 @@ export function cppConversionRank(
|
|||
if (argType === paramType) {
|
||||
return exactShapeCompatible(argTypeClass, paramTypeClass) ? 0 : Infinity;
|
||||
}
|
||||
if (paramType === '...') return 4;
|
||||
if (paramType === '...') return 5;
|
||||
if (INTEGRAL_PROMOTION.get(argType) === paramType) return 1;
|
||||
if (ARITHMETIC.has(argType) && ARITHMETIC.has(paramType)) return 2;
|
||||
if (argType === 'null' && isPointer(paramTypeClass)) return 2;
|
||||
if (argType === 'null' && paramType === 'bool') return 3;
|
||||
if (isPointer(argTypeClass) && paramType === 'bool') return 2;
|
||||
if (isPointer(argTypeClass) && isPointer(paramTypeClass) && paramType === 'void') return 2;
|
||||
if (hasCppUserDefinedConversion(argType, paramType)) return 4;
|
||||
return Infinity;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -225,6 +225,12 @@ const CPP_SCOPE_QUERY = `
|
|||
declarator: (function_declarator
|
||||
declarator: (field_identifier) @declaration.name))) @declaration.method
|
||||
|
||||
;; Constructor prototype in class body: User(int id);
|
||||
(field_declaration_list
|
||||
(declaration
|
||||
declarator: (function_declarator
|
||||
declarator: (identifier) @declaration.name)) @declaration.method)
|
||||
|
||||
;; Method prototype with reference return: User& getRef();
|
||||
(field_declaration
|
||||
declarator: (reference_declarator
|
||||
|
|
|
|||
|
|
@ -34,6 +34,10 @@ import {
|
|||
} from './inline-namespaces.js';
|
||||
import { populateCppRangeBindings } from './range-bindings.js';
|
||||
import { cppConstraintCompatibility } from './constraint-filter.js';
|
||||
import {
|
||||
clearCppUserDefinedConversions,
|
||||
populateCppUserDefinedConversions,
|
||||
} from './user-defined-conversions.js';
|
||||
|
||||
/**
|
||||
* C++ `ScopeResolver` registered in `SCOPE_RESOLVERS` and consumed by
|
||||
|
|
@ -61,6 +65,7 @@ export const cppScopeResolver: ScopeResolver = {
|
|||
clearCppDependentBases();
|
||||
clearCppAdlState();
|
||||
clearCppInlineNamespaces();
|
||||
clearCppUserDefinedConversions();
|
||||
return scanCppHeaderFiles(repoPath);
|
||||
},
|
||||
|
||||
|
|
@ -110,6 +115,10 @@ export const cppScopeResolver: ScopeResolver = {
|
|||
// by ADL (U2 of plan 2026-05-13-001) to identify each argument type's
|
||||
// associated namespace for Koenig lookup.
|
||||
populateCppAssociatedNamespaces(parsed);
|
||||
// Build conservative one-step user-defined conversion facts for
|
||||
// overload ranking (#1631): implicit converting constructors only,
|
||||
// with no chaining or conversion-operator handling.
|
||||
populateCppUserDefinedConversions(parsed);
|
||||
},
|
||||
|
||||
// Resolve recorded template-class → dependent-base simple names to
|
||||
|
|
|
|||
|
|
@ -37,12 +37,14 @@ import { findEnclosingClassDef } from '../../scope-resolution/scope/walkers.js';
|
|||
|
||||
/**
|
||||
* Capture-time record: for each template class declaration in a file,
|
||||
* the simple names of its dependent base classes.
|
||||
* the simple names of its dependent base classes and their syntactic
|
||||
* qualifiers (e.g., `detail` for `detail::Inner<T>`).
|
||||
*
|
||||
* Key: filePath
|
||||
* Value: Map<className, Set<dependentBaseSimpleName>>
|
||||
* Value: Map<className, Map<baseName, qualifier>>
|
||||
* qualifier is '' when the base was unqualified.
|
||||
*/
|
||||
const dependentBasesByFile = new Map<string, Map<string, Set<string>>>();
|
||||
const dependentBasesByFile = new Map<string, Map<string, Map<string, Set<string>>>>();
|
||||
|
||||
/**
|
||||
* Post-`populateOwners` resolution: per-class-nodeId, the set of
|
||||
|
|
@ -55,12 +57,19 @@ const dependentBaseNodeIds = new Map<string, Set<string>>();
|
|||
* Record a dependent-base relationship discovered during scope-capture
|
||||
* emission. `className` is the simple name of the template class;
|
||||
* `baseName` is the simple name of the dependent base class.
|
||||
* `qualifier` is the syntactic namespace qualifier (e.g. `detail` for
|
||||
* `detail::Inner<T>`), or '' for unqualified bases.
|
||||
*
|
||||
* The capture-time recorder uses simple names because the registry
|
||||
* resolution that maps names → nodeIds runs later (in
|
||||
* `populateCppDependentBases`).
|
||||
*/
|
||||
export function markCppDependentBase(filePath: string, className: string, baseName: string): void {
|
||||
export function markCppDependentBase(
|
||||
filePath: string,
|
||||
className: string,
|
||||
baseName: string,
|
||||
qualifier = '',
|
||||
): void {
|
||||
let perFile = dependentBasesByFile.get(filePath);
|
||||
if (perFile === undefined) {
|
||||
perFile = new Map();
|
||||
|
|
@ -68,10 +77,15 @@ export function markCppDependentBase(filePath: string, className: string, baseNa
|
|||
}
|
||||
let bases = perFile.get(className);
|
||||
if (bases === undefined) {
|
||||
bases = new Set();
|
||||
bases = new Map();
|
||||
perFile.set(className, bases);
|
||||
}
|
||||
bases.add(baseName);
|
||||
let quals = bases.get(baseName);
|
||||
if (quals === undefined) {
|
||||
quals = new Set();
|
||||
bases.set(baseName, quals);
|
||||
}
|
||||
quals.add(qualifier);
|
||||
}
|
||||
|
||||
/** Clear two-phase-lookup state. Called from `clearFileLocalNames`. */
|
||||
|
|
@ -89,8 +103,10 @@ export function clearCppDependentBases(): void {
|
|||
* Disambiguation strategy (multiple classes sharing a simple name):
|
||||
* 1. Prefer the candidate whose qualified-name namespace prefix matches
|
||||
* the deriving class's namespace prefix (same-namespace bias).
|
||||
* 2. Fall back to accepting a unique simple-name match.
|
||||
* 3. Skip when multiple candidates exist and no namespace match is
|
||||
* 2. When a syntactic qualifier is available (`detail` in
|
||||
* `detail::Inner<T>`), target the exact namespace derived from it.
|
||||
* 3. Fall back to accepting a unique simple-name match.
|
||||
* 4. Skip when multiple candidates exist and no namespace match is
|
||||
* found (conservative: avoids false associations).
|
||||
*/
|
||||
export function populateCppDependentBases(parsedFiles: readonly ParsedFile[]): void {
|
||||
|
|
@ -99,7 +115,13 @@ export function populateCppDependentBases(parsedFiles: readonly ParsedFile[]): v
|
|||
// Build workspace-wide index: simpleName → {nodeId, nsPrefix}[]
|
||||
// nsPrefix is the dot-joined namespace path (qualifiedName without the
|
||||
// last segment). Classes at global scope have nsPrefix = ''.
|
||||
// Dedup by nodeId, keeping the LAST occurrence: parsed.localDefs may
|
||||
// list the same class def multiple times — the scope-extractor creates
|
||||
// a def with simple-name qualifiedName first, then the class extractor
|
||||
// replaces it with the correct fully-qualified qualifiedName. Keeping
|
||||
// the later entry ensures we capture the full namespace path.
|
||||
const classesBySimpleName = new Map<string, { nodeId: string; nsPrefix: string }[]>();
|
||||
const entryByNodeId = new Map<string, { nodeId: string; nsPrefix: string; simple: string }>();
|
||||
for (const parsed of parsedFiles) {
|
||||
for (const def of parsed.localDefs) {
|
||||
if (def.type !== 'Class' && def.type !== 'Struct' && def.type !== 'Interface') continue;
|
||||
|
|
@ -108,14 +130,17 @@ export function populateCppDependentBases(parsedFiles: readonly ParsedFile[]): v
|
|||
const simple = lastDot >= 0 ? qn.slice(lastDot + 1) : qn;
|
||||
if (simple === '') continue;
|
||||
const nsPrefix = lastDot >= 0 ? qn.slice(0, lastDot) : '';
|
||||
let entries = classesBySimpleName.get(simple);
|
||||
if (entries === undefined) {
|
||||
entries = [];
|
||||
classesBySimpleName.set(simple, entries);
|
||||
}
|
||||
entries.push({ nodeId: def.nodeId, nsPrefix });
|
||||
entryByNodeId.set(def.nodeId, { nodeId: def.nodeId, nsPrefix, simple });
|
||||
}
|
||||
}
|
||||
for (const entry of entryByNodeId.values()) {
|
||||
let entries = classesBySimpleName.get(entry.simple);
|
||||
if (entries === undefined) {
|
||||
entries = [];
|
||||
classesBySimpleName.set(entry.simple, entries);
|
||||
}
|
||||
entries.push({ nodeId: entry.nodeId, nsPrefix: entry.nsPrefix });
|
||||
}
|
||||
|
||||
// Build a filePath → ParsedFile lookup for fast per-file access.
|
||||
const parsedByFile = new Map<string, ParsedFile>();
|
||||
|
|
@ -139,7 +164,12 @@ export function populateCppDependentBases(parsedFiles: readonly ParsedFile[]): v
|
|||
localClassByName.set(simple, { nodeId: def.nodeId, nsPrefix });
|
||||
}
|
||||
|
||||
for (const [className, baseNames] of perFile) {
|
||||
// V3: qualifier-based exact targeting. When the base specifier carries
|
||||
// a syntactic qualifier (e.g., `detail` in `detail::Inner<T>`), compute
|
||||
// the expected namespace prefix and use exact (===) match. Falls back to
|
||||
// the V2 prefix-contains heuristic when the qualifier isn't available or
|
||||
// the exact match fails (absolute qualifier edge cases like `::std`).
|
||||
for (const [className, baseEntries] of perFile) {
|
||||
const classEntry = localClassByName.get(className);
|
||||
if (classEntry === undefined) continue;
|
||||
|
||||
|
|
@ -149,54 +179,77 @@ export function populateCppDependentBases(parsedFiles: readonly ParsedFile[]): v
|
|||
dependentBaseNodeIds.set(classEntry.nodeId, bases);
|
||||
}
|
||||
|
||||
for (const baseName of baseNames) {
|
||||
const candidates = classesBySimpleName.get(baseName);
|
||||
if (candidates === undefined || candidates.length === 0) continue;
|
||||
for (const [baseName, qualsSet] of baseEntries) {
|
||||
for (const baseQualifier of qualsSet) {
|
||||
const candidates = classesBySimpleName.get(baseName);
|
||||
if (candidates === undefined || candidates.length === 0) continue;
|
||||
|
||||
if (candidates.length === 1) {
|
||||
// Unique simple-name match — accept regardless of namespace.
|
||||
bases.add(candidates[0].nodeId);
|
||||
continue;
|
||||
}
|
||||
// Compute the expected namespace prefix from the qualifier.
|
||||
// Relative qualifier (e.g. `inner`): prepend deriving class's prefix.
|
||||
// Absolute qualifiers (`::std`, `ns::other`) will fail the relative
|
||||
// lookup and fall through to the prefix-heuristic below.
|
||||
const normalizedQualifier = baseQualifier.replace(/::/g, '.');
|
||||
const expectedNs =
|
||||
baseQualifier && classEntry.nsPrefix
|
||||
? classEntry.nsPrefix + '.' + normalizedQualifier
|
||||
: normalizedQualifier;
|
||||
|
||||
// Multiple classes share the same simple name — prefer the one
|
||||
// whose namespace matches the deriving class's namespace.
|
||||
// V2: filter by prefix-match capped at one level deeper, then
|
||||
// accept only if exactly one candidate survives. This lets
|
||||
// Derived<T> in ns::outer find Inner<T> in ns::outer::inner
|
||||
// (or ns::v1 for inline-namespace variants) while rejecting
|
||||
// sibling collisions (e.g. detail::Inner vs public_api::Inner).
|
||||
//
|
||||
// The one-segment cap limits walk depth: ns → ns.a ✓, ns → ns.a.b ✗.
|
||||
// Global-scope deriving classes match any single-segment namespace.
|
||||
//
|
||||
// LIMITATION: True ISO behavior would use the base specifier's
|
||||
// syntactic qualifier (available at captures.ts:611 as
|
||||
// qualified_identifier scope) to navigate from the current scope,
|
||||
// which would resolve `detail::Inner` vs `public_api::Inner`
|
||||
// unambiguously. Threading the qualifier is tracked in #1815.
|
||||
// Until then, sibling collisions correctly suppress.
|
||||
const nsMatches = candidates.filter((c) => {
|
||||
if (c.nsPrefix === classEntry.nsPrefix) return true;
|
||||
if (classEntry.nsPrefix === '') {
|
||||
return c.nsPrefix !== '' && !c.nsPrefix.includes('.');
|
||||
if (candidates.length === 1) {
|
||||
// Unqualified base: accept unique match (pre-existing behavior).
|
||||
if (!baseQualifier) {
|
||||
bases.add(candidates[0].nodeId);
|
||||
continue;
|
||||
}
|
||||
// Qualified base: verify namespace before accepting.
|
||||
if (
|
||||
candidates[0].nsPrefix === expectedNs ||
|
||||
candidates[0].nsPrefix === normalizedQualifier
|
||||
) {
|
||||
bases.add(candidates[0].nodeId);
|
||||
}
|
||||
// else: suppress — qualifier doesn't match. #1564 policy.
|
||||
continue;
|
||||
}
|
||||
if (c.nsPrefix.startsWith(classEntry.nsPrefix + '.')) {
|
||||
const suffix = c.nsPrefix.slice(classEntry.nsPrefix.length + 1);
|
||||
return !suffix.includes('.');
|
||||
|
||||
// V3: qualifier-based exact targeting. When the base specifier
|
||||
// carries a syntactic qualifier, compute the expected namespace
|
||||
// prefix and attempt an exact (===) match using the deduplicated
|
||||
// nsPrefix. Dedup by nodeId removes broken entries from the
|
||||
// classesBySimpleName index, making the surviving nsPrefix reliable.
|
||||
if (baseQualifier) {
|
||||
const qualifierMatch = candidates.find(
|
||||
(c) => c.nsPrefix === expectedNs || c.nsPrefix === normalizedQualifier,
|
||||
);
|
||||
if (qualifierMatch !== undefined) {
|
||||
bases.add(qualifierMatch.nodeId);
|
||||
continue;
|
||||
}
|
||||
continue; // qualifier was explicit but no match — suppress, don't fall through to V2
|
||||
}
|
||||
return false;
|
||||
});
|
||||
const nsMatch = nsMatches.length === 1 ? nsMatches[0] : undefined;
|
||||
if (nsMatch !== undefined) {
|
||||
bases.add(nsMatch.nodeId);
|
||||
|
||||
// V2 fallback: filter by prefix-match capped at one level deeper,
|
||||
// then accept only if exactly one candidate survives.
|
||||
const nsMatches = candidates.filter((c) => {
|
||||
if (c.nsPrefix === classEntry.nsPrefix) return true;
|
||||
if (classEntry.nsPrefix === '') {
|
||||
return c.nsPrefix !== '' && !c.nsPrefix.includes('.');
|
||||
}
|
||||
if (c.nsPrefix.startsWith(classEntry.nsPrefix + '.')) {
|
||||
const suffix = c.nsPrefix.slice(classEntry.nsPrefix.length + 1);
|
||||
return !suffix.includes('.');
|
||||
}
|
||||
return false;
|
||||
});
|
||||
const nsMatch = nsMatches.length === 1 ? nsMatches[0] : undefined;
|
||||
if (nsMatch !== undefined) {
|
||||
bases.add(nsMatch.nodeId);
|
||||
}
|
||||
// else: ambiguous (multiple candidates, no namespace match) → skip.
|
||||
}
|
||||
// else: ambiguous (multiple candidates, no namespace match) → skip.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-phase lookup predicate: is the candidate def a member of a
|
||||
* dependent base of the caller's enclosing template class?
|
||||
|
|
|
|||
|
|
@ -0,0 +1,140 @@
|
|||
import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared';
|
||||
import type { ScopeId } from 'gitnexus-shared';
|
||||
import { normalizeCppParamType } from './arity-metadata.js';
|
||||
|
||||
const userDefinedConversions = new Set<string>();
|
||||
const pendingUserDefinedConversions: PendingUserDefinedConversion[] = [];
|
||||
const classIdentitiesBySimpleName = new Map<string, Set<string>>();
|
||||
|
||||
interface PendingUserDefinedConversion {
|
||||
readonly argType: string;
|
||||
readonly paramType: string;
|
||||
readonly ownerClassName: string;
|
||||
}
|
||||
|
||||
export function clearCppUserDefinedConversions(): void {
|
||||
userDefinedConversions.clear();
|
||||
pendingUserDefinedConversions.length = 0;
|
||||
classIdentitiesBySimpleName.clear();
|
||||
}
|
||||
|
||||
export function hasCppUserDefinedConversion(argType: string, paramType: string): boolean {
|
||||
return userDefinedConversions.has(conversionKey(argType, paramType));
|
||||
}
|
||||
|
||||
export function populateCppUserDefinedConversions(parsed: ParsedFile): void {
|
||||
const scopesById = new Map<ScopeId, (typeof parsed.scopes)[number]>();
|
||||
for (const scope of parsed.scopes) scopesById.set(scope.id, scope);
|
||||
|
||||
for (const classScope of parsed.scopes) {
|
||||
if (classScope.kind !== 'Class') continue;
|
||||
const classDef = classScope.ownedDefs.find(isClassLike);
|
||||
if (classDef !== undefined) recordClassIdentity(classDef);
|
||||
}
|
||||
|
||||
for (const classScope of parsed.scopes) {
|
||||
if (classScope.kind !== 'Class') continue;
|
||||
const classDef = classScope.ownedDefs.find(isClassLike);
|
||||
if (classDef === undefined) continue;
|
||||
const className = normalizedSimpleName(classDef);
|
||||
if (className === '') continue;
|
||||
|
||||
const methodDefs = collectClassMethodDefs(classScope.id, parsed, scopesById);
|
||||
for (const def of methodDefs) {
|
||||
const simpleName = simpleNameOf(def);
|
||||
if (simpleName === className && def.parameterTypes?.length === 1) {
|
||||
if (def.isExplicit === true) continue;
|
||||
registerPendingCppUserDefinedConversion(def.parameterTypes[0], className, className);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rebuildCppUserDefinedConversions();
|
||||
}
|
||||
|
||||
export function registerCppUserDefinedConversion(argType: string, paramType: string): void {
|
||||
if (argType === '' || paramType === '') return;
|
||||
if (argType === paramType) return;
|
||||
userDefinedConversions.add(conversionKey(argType, paramType));
|
||||
}
|
||||
|
||||
function collectClassMethodDefs(
|
||||
classScopeId: ScopeId,
|
||||
parsed: ParsedFile,
|
||||
scopesById: ReadonlyMap<ScopeId, (typeof parsed.scopes)[number]>,
|
||||
): SymbolDefinition[] {
|
||||
const methods: SymbolDefinition[] = [];
|
||||
const classScope = scopesById.get(classScopeId);
|
||||
if (classScope === undefined) return methods;
|
||||
|
||||
for (const def of classScope.ownedDefs) {
|
||||
if (isCallableMember(def)) methods.push(def);
|
||||
}
|
||||
for (const scope of parsed.scopes) {
|
||||
if (scope.parent !== classScopeId) continue;
|
||||
if (scope.kind === 'Class') continue;
|
||||
for (const def of scope.ownedDefs) {
|
||||
if (isCallableMember(def)) methods.push(def);
|
||||
}
|
||||
}
|
||||
return methods;
|
||||
}
|
||||
|
||||
function conversionKey(argType: string, paramType: string): string {
|
||||
return `${argType}\0${paramType}`;
|
||||
}
|
||||
|
||||
function registerPendingCppUserDefinedConversion(
|
||||
argType: string,
|
||||
paramType: string,
|
||||
ownerClassName: string,
|
||||
): void {
|
||||
if (argType === '' || paramType === '') return;
|
||||
if (argType === paramType) return;
|
||||
pendingUserDefinedConversions.push({ argType, paramType, ownerClassName });
|
||||
}
|
||||
|
||||
function rebuildCppUserDefinedConversions(): void {
|
||||
userDefinedConversions.clear();
|
||||
for (const conversion of pendingUserDefinedConversions) {
|
||||
if (isAmbiguousClassName(conversion.ownerClassName)) continue;
|
||||
userDefinedConversions.add(conversionKey(conversion.argType, conversion.paramType));
|
||||
}
|
||||
}
|
||||
|
||||
function recordClassIdentity(def: SymbolDefinition): void {
|
||||
const simpleName = normalizedSimpleName(def);
|
||||
if (simpleName === '') return;
|
||||
const identities = classIdentitiesBySimpleName.get(simpleName) ?? new Set<string>();
|
||||
identities.add(normalizedQualifiedClassName(def));
|
||||
classIdentitiesBySimpleName.set(simpleName, identities);
|
||||
}
|
||||
|
||||
function isAmbiguousClassName(simpleName: string): boolean {
|
||||
return (classIdentitiesBySimpleName.get(simpleName)?.size ?? 0) > 1;
|
||||
}
|
||||
|
||||
function normalizedQualifiedClassName(def: SymbolDefinition): string {
|
||||
const qualifiedName = def.qualifiedName ?? simpleNameOf(def);
|
||||
if (qualifiedName === '' || !qualifiedName.includes('.')) return `${def.filePath}:${def.nodeId}`;
|
||||
return qualifiedName
|
||||
.split('.')
|
||||
.map((part) => normalizeCppParamType(part))
|
||||
.join('.');
|
||||
}
|
||||
|
||||
function normalizedSimpleName(def: SymbolDefinition): string {
|
||||
return normalizeCppParamType(simpleNameOf(def));
|
||||
}
|
||||
|
||||
function simpleNameOf(def: SymbolDefinition): string {
|
||||
return def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? '';
|
||||
}
|
||||
|
||||
function isClassLike(def: SymbolDefinition): boolean {
|
||||
return def.type === 'Class' || def.type === 'Struct' || def.type === 'Interface';
|
||||
}
|
||||
|
||||
function isCallableMember(def: SymbolDefinition): boolean {
|
||||
return def.type === 'Method' || def.type === 'Constructor';
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@ import { findNodeAtRange, nodeToCapture, syntheticCapture } from '../../utils/as
|
|||
import { splitImportStatement } from './import-decomposer.js';
|
||||
import { getPythonParser, getPythonScopeQuery } from './query.js';
|
||||
import { synthesizeReceiverTypeBinding } from './receiver-binding.js';
|
||||
import { synthesizeDependsReferences } from './depends-references.js';
|
||||
import { computePythonArityMetadata } from './arity-metadata.js';
|
||||
import { recordCacheHit, recordCacheMiss } from './cache-stats.js';
|
||||
import { getTreeSitterBufferSize } from '../../constants.js';
|
||||
|
|
@ -98,6 +99,7 @@ export function emitPythonScopeCaptures(
|
|||
if (fnNode !== null) {
|
||||
const synth = synthesizeReceiverTypeBinding(fnNode);
|
||||
if (synth !== null) out.push(synth);
|
||||
for (const depRef of synthesizeDependsReferences(fnNode)) out.push(depRef);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
/**
|
||||
* Synthesize `@reference.call.free` captures for FastAPI `Depends(callable)`
|
||||
* parameter defaults.
|
||||
*
|
||||
* `Depends(get_db)` passes `get_db` as a callable that the DI framework
|
||||
* calls on every request. The route handler is functionally a caller of
|
||||
* the dependency — impact analysis needs that edge.
|
||||
*
|
||||
* Tree-sitter can't express "the first argument of a call named Depends
|
||||
* inside a parameter default" in a single static query, so we synthesize
|
||||
* reference captures in code, mirroring the receiver-binding pattern.
|
||||
*/
|
||||
|
||||
import type { CaptureMatch } from 'gitnexus-shared';
|
||||
import { nodeToCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
|
||||
/**
|
||||
* Inspect a `function_definition` node's parameters for `Depends(callable)`
|
||||
* defaults. Returns one `@reference.call.free` CaptureMatch per dependency.
|
||||
*/
|
||||
export function synthesizeDependsReferences(fnNode: SyntaxNode): readonly CaptureMatch[] {
|
||||
const params = fnNode.childForFieldName('parameters');
|
||||
if (params === null) return [];
|
||||
|
||||
const results: CaptureMatch[] = [];
|
||||
|
||||
for (let i = 0; i < params.namedChildCount; i++) {
|
||||
const param = params.namedChild(i);
|
||||
if (param === null) continue;
|
||||
|
||||
if (param.type !== 'typed_default_parameter' && param.type !== 'default_parameter') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const defaultValue = param.childForFieldName('value') ?? param.childForFieldName('default');
|
||||
if (defaultValue === null) continue;
|
||||
|
||||
const callNode = defaultValue.type === 'call' ? defaultValue : null;
|
||||
if (callNode === null) continue;
|
||||
|
||||
const fnIdent = callNode.childForFieldName('function');
|
||||
if (fnIdent === null || fnIdent.type !== 'identifier' || fnIdent.text !== 'Depends') continue;
|
||||
|
||||
const args = callNode.childForFieldName('arguments');
|
||||
if (args === null || args.namedChildCount === 0) continue;
|
||||
|
||||
const firstArg = args.namedChild(0);
|
||||
if (firstArg === null) continue;
|
||||
|
||||
if (firstArg.type === 'identifier') {
|
||||
results.push({
|
||||
'@reference.call.free': nodeToCapture('@reference.call.free', firstArg),
|
||||
'@reference.name': nodeToCapture('@reference.name', firstArg),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (firstArg.type === 'attribute') {
|
||||
const attrName = firstArg.childForFieldName('attribute');
|
||||
const obj = firstArg.childForFieldName('object');
|
||||
if (attrName !== null && obj !== null) {
|
||||
results.push({
|
||||
'@reference.call.member': nodeToCapture('@reference.call.member', attrName),
|
||||
'@reference.name': nodeToCapture('@reference.name', attrName),
|
||||
'@reference.receiver': nodeToCapture('@reference.receiver', obj),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
|
@ -33,6 +33,15 @@ import { rubyHeritageConfig } from '../heritage-extractors/configs/ruby.js';
|
|||
import { maybeRewriteRubyBareCallToSelf } from '../utils/ruby-self-call.js';
|
||||
import { findEnclosingClassInfo } from '../utils/ast-helpers.js';
|
||||
import type { DispatchDecision, ImplicitReceiverOverride } from '../call-types.js';
|
||||
import {
|
||||
emitRubyScopeCaptures,
|
||||
rubyArityCompatibility,
|
||||
rubyBindingScopeFor,
|
||||
rubyImportOwningScope,
|
||||
rubyReceiverBinding,
|
||||
interpretRubyImport,
|
||||
interpretRubyTypeBinding,
|
||||
} from './ruby/index.js';
|
||||
|
||||
/**
|
||||
* Ruby label override. Applied to:
|
||||
|
|
@ -258,4 +267,12 @@ export const rubyProvider = defineLanguage({
|
|||
},
|
||||
|
||||
builtInNames: BUILT_INS,
|
||||
// ── RFC #909 Ring 3: scope-based resolution hooks ──────────
|
||||
emitScopeCaptures: emitRubyScopeCaptures,
|
||||
interpretImport: interpretRubyImport,
|
||||
interpretTypeBinding: interpretRubyTypeBinding,
|
||||
bindingScopeFor: rubyBindingScopeFor,
|
||||
importOwningScope: rubyImportOwningScope,
|
||||
receiverBinding: rubyReceiverBinding,
|
||||
arityCompatibility: rubyArityCompatibility,
|
||||
});
|
||||
|
|
|
|||
45
gitnexus/src/core/ingestion/languages/ruby/arity.ts
Normal file
45
gitnexus/src/core/ingestion/languages/ruby/arity.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
/**
|
||||
* Ruby arity check, accommodating `*args`, `**kwargs`, and defaults.
|
||||
*
|
||||
* The `def` metadata we care about (set by the existing Ruby method/
|
||||
* function extractor):
|
||||
* - `parameterCount` — total positional + keyword params
|
||||
* - `requiredParameterCount` — min required (excludes defaults / `*args` / `**kwargs`)
|
||||
* - `parameterTypes` — present when types are known; we also use it
|
||||
* as a "we have varargs" hint (`'*args'`,
|
||||
* `'**kwargs'` literals appear in the array).
|
||||
*
|
||||
* Verdicts:
|
||||
* - `'compatible'` — `requiredParameterCount <= argCount <= parameterCount`,
|
||||
* OR the def takes `*args` (then any `argCount >= required` ok).
|
||||
* - `'incompatible'` — argCount is below required, OR above max with no `*args`.
|
||||
* - `'unknown'` — def metadata is absent / incomplete.
|
||||
*
|
||||
* `'incompatible'` is a soft signal in `Registry.lookup` (penalized but
|
||||
* still considered when no compatible candidate exists), per RFC 4.
|
||||
*/
|
||||
|
||||
import type { Callsite, SymbolDefinition } from 'gitnexus-shared';
|
||||
|
||||
export function rubyArityCompatibility(
|
||||
def: SymbolDefinition,
|
||||
callsite: Callsite,
|
||||
): 'compatible' | 'unknown' | 'incompatible' {
|
||||
const max = def.parameterCount;
|
||||
const min = def.requiredParameterCount;
|
||||
if (max === undefined && min === undefined) return 'unknown';
|
||||
|
||||
const argCount = callsite.arity;
|
||||
if (!Number.isFinite(argCount) || argCount < 0) return 'unknown';
|
||||
|
||||
// Detect varargs/kwargs from parameterTypes if present (the Ruby
|
||||
// method extractor stores `'*args'`/`'**kwargs'` in this list).
|
||||
const hasVarArgs =
|
||||
def.parameterTypes !== undefined &&
|
||||
def.parameterTypes.some((t) => t === '*args' || t === '**kwargs' || t.startsWith('*'));
|
||||
|
||||
if (min !== undefined && argCount < min) return 'incompatible';
|
||||
if (max !== undefined && argCount > max && !hasVarArgs) return 'incompatible';
|
||||
|
||||
return 'compatible';
|
||||
}
|
||||
18
gitnexus/src/core/ingestion/languages/ruby/cache-stats.ts
Normal file
18
gitnexus/src/core/ingestion/languages/ruby/cache-stats.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
let hits = 0;
|
||||
let misses = 0;
|
||||
|
||||
export function recordRubyCacheHit(): void {
|
||||
hits++;
|
||||
}
|
||||
export function recordRubyCacheMiss(): void {
|
||||
misses++;
|
||||
}
|
||||
|
||||
export function getRubyCaptureCacheStats(): { readonly hits: number; readonly misses: number } {
|
||||
return { hits, misses };
|
||||
}
|
||||
|
||||
export function resetRubyCaptureCacheStats(): void {
|
||||
hits = 0;
|
||||
misses = 0;
|
||||
}
|
||||
619
gitnexus/src/core/ingestion/languages/ruby/captures.ts
Normal file
619
gitnexus/src/core/ingestion/languages/ruby/captures.ts
Normal file
|
|
@ -0,0 +1,619 @@
|
|||
import type { Capture, CaptureMatch } from 'gitnexus-shared';
|
||||
import {
|
||||
findNodeAtRange,
|
||||
nodeToCapture,
|
||||
syntheticCapture,
|
||||
type SyntaxNode,
|
||||
} from '../../utils/ast-helpers.js';
|
||||
import { getRubyParser, getRubyScopeQuery } from './query.js';
|
||||
import { recordRubyCacheHit, recordRubyCacheMiss } from './cache-stats.js';
|
||||
import { synthesizeRubyReceiverBinding, findEnclosingClassOrModule } from './receiver-binding.js';
|
||||
import { getTreeSitterBufferSize } from '../../constants.js';
|
||||
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
|
||||
|
||||
const FUNCTION_NODE_TYPES = ['method', 'singleton_method'] as const;
|
||||
const HERITAGE_CALL_NAMES: ReadonlySet<string> = new Set(['include', 'extend', 'prepend']);
|
||||
const ATTR_CALL_NAMES: ReadonlySet<string> = new Set([
|
||||
'attr_accessor',
|
||||
'attr_reader',
|
||||
'attr_writer',
|
||||
]);
|
||||
|
||||
export function emitRubyScopeCaptures(
|
||||
sourceText: string,
|
||||
_filePath: string,
|
||||
cachedTree?: unknown,
|
||||
): readonly CaptureMatch[] {
|
||||
let tree = cachedTree as ReturnType<ReturnType<typeof getRubyParser>['parse']> | undefined;
|
||||
if (tree === undefined) {
|
||||
try {
|
||||
tree = parseSourceSafe(getRubyParser(), sourceText, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(sourceText),
|
||||
});
|
||||
} catch (err) {
|
||||
throw scopeExtractionError('parse', _filePath, err);
|
||||
}
|
||||
recordRubyCacheMiss();
|
||||
} else {
|
||||
recordRubyCacheHit();
|
||||
}
|
||||
|
||||
let rawMatches: ReturnType<ReturnType<typeof getRubyScopeQuery>['matches']>;
|
||||
try {
|
||||
rawMatches = getRubyScopeQuery().matches(tree.rootNode);
|
||||
} catch (err) {
|
||||
throw scopeExtractionError('scope query', _filePath, err);
|
||||
}
|
||||
|
||||
const out: CaptureMatch[] = [];
|
||||
|
||||
for (const m of rawMatches) {
|
||||
const grouped: Record<string, Capture> = {};
|
||||
for (const c of m.captures) {
|
||||
const tag = '@' + c.name;
|
||||
if (tag.startsWith('@_')) continue;
|
||||
grouped[tag] = nodeToCapture(tag, c.node);
|
||||
}
|
||||
if (Object.keys(grouped).length === 0) continue;
|
||||
|
||||
// Decompose require/require_relative/load into import captures
|
||||
if (grouped['@import.statement'] !== undefined) {
|
||||
const anchor = grouped['@import.statement']!;
|
||||
const callNode = findNodeAtRange(tree.rootNode, anchor.range, 'call');
|
||||
if (callNode !== null) {
|
||||
const decomposed = decomposeRubyImport(callNode, anchor);
|
||||
if (decomposed !== null) {
|
||||
out.push(decomposed);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(grouped);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Synthesize self receiver bindings for methods inside class/module
|
||||
if (grouped['@scope.function'] !== undefined) {
|
||||
const scopeCap = grouped['@scope.function']!;
|
||||
const fnNode = findFunctionNode(tree.rootNode, scopeCap.range);
|
||||
if (fnNode !== null) {
|
||||
const enclosingNode = findEnclosingClassOrModule(fnNode);
|
||||
const receiver = synthesizeRubyReceiverBinding(fnNode, enclosingNode);
|
||||
if (receiver !== null) out.push(receiver);
|
||||
}
|
||||
out.push(grouped);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Reclassify declaration.function as declaration.method + attach arity
|
||||
if (grouped['@declaration.function'] !== undefined) {
|
||||
const anchorCap = grouped['@declaration.function']!;
|
||||
const fnNode = findFunctionNode(tree.rootNode, anchorCap.range);
|
||||
if (fnNode !== null) {
|
||||
const enclosingNode = findEnclosingClassOrModule(fnNode);
|
||||
if (enclosingNode !== null) {
|
||||
const nameCap = grouped['@declaration.name'];
|
||||
delete (grouped as Record<string, Capture | undefined>)['@declaration.function'];
|
||||
grouped['@declaration.method'] = syntheticCapture(
|
||||
'@declaration.method',
|
||||
fnNode,
|
||||
fnNode.text,
|
||||
);
|
||||
if (nameCap !== undefined) {
|
||||
grouped['@declaration.name'] = nameCap;
|
||||
}
|
||||
}
|
||||
|
||||
const arity = computeRubyDeclarationArity(fnNode);
|
||||
if (arity.parameterCount !== undefined) {
|
||||
grouped['@declaration.parameter-count'] = syntheticCapture(
|
||||
'@declaration.parameter-count',
|
||||
fnNode,
|
||||
String(arity.parameterCount),
|
||||
);
|
||||
}
|
||||
if (arity.requiredParameterCount !== undefined) {
|
||||
grouped['@declaration.required-parameter-count'] = syntheticCapture(
|
||||
'@declaration.required-parameter-count',
|
||||
fnNode,
|
||||
String(arity.requiredParameterCount),
|
||||
);
|
||||
}
|
||||
if (arity.parameterTypes !== undefined) {
|
||||
grouped['@declaration.parameter-types'] = syntheticCapture(
|
||||
'@declaration.parameter-types',
|
||||
fnNode,
|
||||
JSON.stringify(arity.parameterTypes),
|
||||
);
|
||||
}
|
||||
}
|
||||
out.push(grouped);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Intercept heritage calls (include/extend/prepend) — encode as
|
||||
// special imports so emitHeritageEdges can emit IMPLEMENTS edges.
|
||||
if (grouped['@reference.call.free'] !== undefined && grouped['@reference.name'] !== undefined) {
|
||||
const callName = grouped['@reference.name']!.text;
|
||||
if (HERITAGE_CALL_NAMES.has(callName)) {
|
||||
const callNode = findNodeAtRange(
|
||||
tree.rootNode,
|
||||
grouped['@reference.call.free']!.range,
|
||||
'call',
|
||||
);
|
||||
if (callNode !== null) {
|
||||
const enclosing = findEnclosingClassOrModule(callNode);
|
||||
const ownerName = enclosing?.childForFieldName('name')?.text;
|
||||
if (ownerName) {
|
||||
const argList = callNode.childForFieldName('arguments');
|
||||
if (argList !== null) {
|
||||
for (let ai = 0; ai < argList.namedChildCount; ai++) {
|
||||
const arg = argList.namedChild(ai);
|
||||
if (arg !== null && (arg.type === 'constant' || arg.type === 'scope_resolution')) {
|
||||
out.push({
|
||||
'@import.statement': grouped['@reference.call.free']!,
|
||||
'@import.kind': syntheticCapture('@import.kind', callNode, 'namespace'),
|
||||
'@import.source': syntheticCapture(
|
||||
'@import.source',
|
||||
callNode,
|
||||
`__heritage__:${callName}:${arg.text}:${ownerName}`,
|
||||
),
|
||||
'@import.name': syntheticCapture('@import.name', callNode, arg.text),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Intercept attr_accessor/attr_reader/attr_writer — encode as special
|
||||
// imports so emitHeritageEdges can create Property nodes + HAS_PROPERTY.
|
||||
// Also emit @declaration.property captures so each property ends up in
|
||||
// localDefs and gets reconciled into model.fields, enabling write-access
|
||||
// resolution via receiver-bound-calls (Case 4 → findOwnedMember).
|
||||
if (ATTR_CALL_NAMES.has(callName)) {
|
||||
const callNode = findNodeAtRange(
|
||||
tree.rootNode,
|
||||
grouped['@reference.call.free']!.range,
|
||||
'call',
|
||||
);
|
||||
if (callNode !== null) {
|
||||
const enclosing = findEnclosingClassOrModule(callNode);
|
||||
const ownerName = enclosing?.childForFieldName('name')?.text;
|
||||
if (ownerName) {
|
||||
const argList = callNode.childForFieldName('arguments');
|
||||
if (argList !== null) {
|
||||
for (let ai = 0; ai < argList.namedChildCount; ai++) {
|
||||
const arg = argList.namedChild(ai);
|
||||
if (arg !== null && (arg.type === 'simple_symbol' || arg.type === 'symbol')) {
|
||||
const propName = arg.text.replace(/^:/, '');
|
||||
out.push({
|
||||
'@import.statement': grouped['@reference.call.free']!,
|
||||
'@import.kind': syntheticCapture('@import.kind', callNode, 'namespace'),
|
||||
'@import.source': syntheticCapture(
|
||||
'@import.source',
|
||||
callNode,
|
||||
`__property__:${callName}:${propName}:${ownerName}`,
|
||||
),
|
||||
'@import.name': syntheticCapture('@import.name', callNode, propName),
|
||||
});
|
||||
// Emit a property declaration so the property flows into
|
||||
// localDefs → model.fields for receiver-bound write access.
|
||||
out.push({
|
||||
'@declaration.property': syntheticCapture(
|
||||
'@declaration.property',
|
||||
arg,
|
||||
propName,
|
||||
),
|
||||
'@declaration.name': syntheticCapture('@declaration.name', arg, propName),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Attach call arity for call expressions
|
||||
const callTag = (['@reference.call.free', '@reference.call.member'] as const).find(
|
||||
(t) => grouped[t] !== undefined,
|
||||
);
|
||||
if (callTag !== undefined && grouped['@reference.arity'] === undefined) {
|
||||
const anchor = grouped[callTag]!;
|
||||
const callNode = findNodeAtRange(tree.rootNode, anchor.range, 'call');
|
||||
if (callNode !== null) {
|
||||
const arity = computeRubyCallArity(callNode);
|
||||
grouped['@reference.arity'] = syntheticCapture('@reference.arity', callNode, String(arity));
|
||||
}
|
||||
}
|
||||
|
||||
out.push(grouped);
|
||||
}
|
||||
|
||||
// Second pass: member-call-return type bindings
|
||||
// Synthesize compound type bindings for `x = recv.method(...)` assignments.
|
||||
// The query-level @type-binding.call-return pattern only captures free calls
|
||||
// (!receiver). For member calls we need `x → recv.method()` so the compound
|
||||
// receiver resolver can chain-follow through the receiver's class scope.
|
||||
for (const assignNode of tree.rootNode.descendantsOfType('assignment')) {
|
||||
const left = assignNode.childForFieldName('left');
|
||||
const right = assignNode.childForFieldName('right');
|
||||
if (left === null || right === null) continue;
|
||||
if (left.type !== 'identifier' && left.type !== 'constant') continue;
|
||||
if (right.type !== 'call') continue;
|
||||
const recvNode = right.childForFieldName('receiver');
|
||||
const methodNode = right.childForFieldName('method');
|
||||
if (recvNode === null || methodNode === null) continue;
|
||||
// Skip .new calls — already handled by the constructor-inference query patterns
|
||||
if (methodNode.text === 'new') continue;
|
||||
const compoundName = `${recvNode.text}.${methodNode.text}()`;
|
||||
out.push({
|
||||
'@type-binding.call-return': syntheticCapture(
|
||||
'@type-binding.call-return',
|
||||
assignNode,
|
||||
assignNode.text,
|
||||
),
|
||||
'@type-binding.name': syntheticCapture('@type-binding.name', assignNode, left.text),
|
||||
'@type-binding.type': syntheticCapture('@type-binding.type', assignNode, compoundName),
|
||||
});
|
||||
}
|
||||
|
||||
// Third pass: YARD comment annotations (@param, @return, @type)
|
||||
for (const comment of tree.rootNode.descendantsOfType('comment')) {
|
||||
const text = comment.text;
|
||||
|
||||
// @param name [Type]
|
||||
const paramMatch = text.match(/@param\s+(\w+)\s+\[([^\]]+)\]/);
|
||||
if (paramMatch) {
|
||||
const [, paramName, typeName] = paramMatch;
|
||||
const methodNode = findFollowingMethod(comment);
|
||||
if (methodNode !== null && paramName && typeName) {
|
||||
out.push({
|
||||
'@type-binding.parameter': syntheticCapture('@type-binding.parameter', methodNode, text),
|
||||
'@type-binding.name': syntheticCapture('@type-binding.name', methodNode, paramName),
|
||||
'@type-binding.type': syntheticCapture(
|
||||
'@type-binding.type',
|
||||
methodNode,
|
||||
normalizeYardType(typeName),
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// @param [Type] name (alternate YARD order)
|
||||
const paramAltMatch = text.match(/@param\s+\[([^\]]+)\]\s+(\w+)/);
|
||||
if (!paramMatch && paramAltMatch) {
|
||||
const [, typeName, paramName] = paramAltMatch;
|
||||
const methodNode = findFollowingMethod(comment);
|
||||
if (methodNode !== null && paramName && typeName) {
|
||||
out.push({
|
||||
'@type-binding.parameter': syntheticCapture('@type-binding.parameter', methodNode, text),
|
||||
'@type-binding.name': syntheticCapture('@type-binding.name', methodNode, paramName),
|
||||
'@type-binding.type': syntheticCapture(
|
||||
'@type-binding.type',
|
||||
methodNode,
|
||||
normalizeYardType(typeName),
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// @return [Type]
|
||||
const returnMatch = text.match(/@return\s+\[([^\]]+)\]/);
|
||||
if (returnMatch) {
|
||||
const [, typeName] = returnMatch;
|
||||
const methodNode = findFollowingMethod(comment);
|
||||
if (methodNode !== null && typeName) {
|
||||
const methodName = methodNode.childForFieldName('name')?.text;
|
||||
if (methodName) {
|
||||
out.push({
|
||||
'@type-binding.return': syntheticCapture('@type-binding.return', methodNode, text),
|
||||
'@type-binding.name': syntheticCapture('@type-binding.name', methodNode, methodName),
|
||||
'@type-binding.type': syntheticCapture(
|
||||
'@type-binding.type',
|
||||
methodNode,
|
||||
normalizeYardType(typeName),
|
||||
),
|
||||
});
|
||||
}
|
||||
} else if (typeName) {
|
||||
// YARD @return before attr_accessor/attr_reader/attr_writer: the
|
||||
// comment precedes a `call` node (not a method). Extract the
|
||||
// property name from the attr call's arguments and bind it to
|
||||
// the annotated return type. This enables field-type chains
|
||||
// like `user.address.save → Address#save`.
|
||||
const attrNode = findFollowingAttrCall(comment);
|
||||
if (attrNode !== null) {
|
||||
const argList = attrNode.childForFieldName('arguments');
|
||||
if (argList !== null) {
|
||||
for (let ai = 0; ai < argList.namedChildCount; ai++) {
|
||||
const arg = argList.namedChild(ai);
|
||||
if (arg !== null && (arg.type === 'simple_symbol' || arg.type === 'symbol')) {
|
||||
const propName = arg.text.replace(/^:/, '');
|
||||
out.push({
|
||||
'@type-binding.return': syntheticCapture('@type-binding.return', attrNode, text),
|
||||
'@type-binding.name': syntheticCapture('@type-binding.name', attrNode, propName),
|
||||
'@type-binding.type': syntheticCapture(
|
||||
'@type-binding.type',
|
||||
attrNode,
|
||||
normalizeYardType(typeName),
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// @type [Type]
|
||||
const typeMatch = text.match(/@type\s+\[([^\]]+)\]/);
|
||||
if (typeMatch) {
|
||||
const [, typeName] = typeMatch;
|
||||
const methodNode = findFollowingMethod(comment);
|
||||
if (methodNode !== null && typeName) {
|
||||
out.push({
|
||||
'@type-binding.parameter': syntheticCapture('@type-binding.parameter', methodNode, text),
|
||||
'@type-binding.name': syntheticCapture('@type-binding.name', methodNode, ''),
|
||||
'@type-binding.type': syntheticCapture(
|
||||
'@type-binding.type',
|
||||
methodNode,
|
||||
normalizeYardType(typeName),
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fourth pass: constructor-return inference for methods.
|
||||
// When a method's body ends with `ClassName.new(...)`, synthesize a
|
||||
// return-type binding `methodName → ClassName` on the method node.
|
||||
// This enables cross-file return-type propagation for factory methods
|
||||
// like `def self.get_user; User.new; end` → `get_user → User`.
|
||||
for (const methodNode of [
|
||||
...tree.rootNode.descendantsOfType('method'),
|
||||
...tree.rootNode.descendantsOfType('singleton_method'),
|
||||
]) {
|
||||
const methodName = methodNode.childForFieldName('name')?.text;
|
||||
if (methodName === undefined) continue;
|
||||
// Skip if a YARD @return already created a return binding for this method
|
||||
if (
|
||||
out.some(
|
||||
(m) =>
|
||||
m['@type-binding.return'] !== undefined &&
|
||||
m['@type-binding.name']?.text === methodName &&
|
||||
m['@type-binding.return']?.range.startLine === methodNode.startPosition.row,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const body = methodNode.childForFieldName('body');
|
||||
if (body === null) continue;
|
||||
// Find the last expression in the method body
|
||||
const lastChild = body.namedChildCount > 0 ? body.namedChild(body.namedChildCount - 1) : null;
|
||||
if (lastChild === null) continue;
|
||||
// Check if the last expression is a `ClassName.new(...)` call
|
||||
if (lastChild.type === 'call') {
|
||||
const recv = lastChild.childForFieldName('receiver');
|
||||
const meth = lastChild.childForFieldName('method');
|
||||
if (
|
||||
recv !== null &&
|
||||
meth !== null &&
|
||||
meth.text === 'new' &&
|
||||
(recv.type === 'constant' || recv.type === 'scope_resolution')
|
||||
) {
|
||||
out.push({
|
||||
'@type-binding.return': syntheticCapture(
|
||||
'@type-binding.return',
|
||||
methodNode,
|
||||
`constructor-return: ${recv.text}.new`,
|
||||
),
|
||||
'@type-binding.name': syntheticCapture('@type-binding.name', methodNode, methodName),
|
||||
'@type-binding.type': syntheticCapture('@type-binding.type', methodNode, recv.text),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function decomposeRubyImport(callNode: SyntaxNode, anchor: Capture): CaptureMatch | null {
|
||||
const methodNode = callNode.childForFieldName('method');
|
||||
if (methodNode === null) return null;
|
||||
const methodName = methodNode.text;
|
||||
if (methodName !== 'require' && methodName !== 'require_relative' && methodName !== 'load') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const argsNode = callNode.childForFieldName('arguments');
|
||||
const argNode = argsNode !== null ? argsNode.namedChild(0) : callNode.namedChild(1);
|
||||
if (argNode === null) return null;
|
||||
|
||||
let sourcePath: string;
|
||||
if (argNode.type === 'string') {
|
||||
const contentChild = argNode.namedChild(0);
|
||||
sourcePath =
|
||||
contentChild !== null && contentChild.type === 'string_content'
|
||||
? contentChild.text
|
||||
: argNode.text.replace(/^['"]|['"]$/g, '');
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (sourcePath === '') return null;
|
||||
|
||||
const segments = sourcePath.replace(/\\/g, '/').split('/');
|
||||
const lastSegment = segments[segments.length - 1]!;
|
||||
const moduleName = lastSegment.replace(/\.rb$/, '');
|
||||
|
||||
return {
|
||||
'@import.statement': anchor,
|
||||
'@import.kind': syntheticCapture('@import.kind', callNode, 'wildcard'),
|
||||
'@import.source': syntheticCapture('@import.source', callNode, sourcePath),
|
||||
'@import.name': syntheticCapture('@import.name', callNode, moduleName),
|
||||
};
|
||||
}
|
||||
|
||||
function computeRubyDeclarationArity(fnNode: SyntaxNode): {
|
||||
parameterCount?: number;
|
||||
requiredParameterCount?: number;
|
||||
parameterTypes?: string[];
|
||||
} {
|
||||
const params = fnNode.childForFieldName('parameters');
|
||||
if (params === null) return { parameterCount: 0, requiredParameterCount: 0 };
|
||||
|
||||
let totalCount = 0;
|
||||
let requiredCount = 0;
|
||||
const paramTypes: string[] = [];
|
||||
|
||||
for (let i = 0; i < params.namedChildCount; i++) {
|
||||
const child = params.namedChild(i);
|
||||
if (child === null) continue;
|
||||
|
||||
switch (child.type) {
|
||||
case 'identifier':
|
||||
totalCount++;
|
||||
requiredCount++;
|
||||
paramTypes.push('');
|
||||
break;
|
||||
case 'optional_parameter':
|
||||
totalCount++;
|
||||
paramTypes.push('');
|
||||
break;
|
||||
case 'splat_parameter':
|
||||
totalCount++;
|
||||
paramTypes.push('*args');
|
||||
break;
|
||||
case 'hash_splat_parameter':
|
||||
totalCount++;
|
||||
paramTypes.push('**kwargs');
|
||||
break;
|
||||
case 'block_parameter':
|
||||
// &block not counted in arity
|
||||
break;
|
||||
case 'keyword_parameter': {
|
||||
totalCount++;
|
||||
const hasDefault = child.childForFieldName('value') !== null;
|
||||
if (!hasDefault) requiredCount++;
|
||||
paramTypes.push('');
|
||||
break;
|
||||
}
|
||||
default:
|
||||
totalCount++;
|
||||
requiredCount++;
|
||||
paramTypes.push('');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
parameterCount: totalCount,
|
||||
requiredParameterCount: requiredCount,
|
||||
parameterTypes: paramTypes.length > 0 ? paramTypes : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function computeRubyCallArity(callNode: SyntaxNode): number {
|
||||
const argList = callNode.childForFieldName('arguments');
|
||||
if (argList === null) return 0;
|
||||
|
||||
let count = 0;
|
||||
for (let i = 0; i < argList.namedChildCount; i++) {
|
||||
const child = argList.namedChild(i);
|
||||
if (child !== null && child.type !== 'block') count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function findFunctionNode(rootNode: SyntaxNode, range: Capture['range']): SyntaxNode | null {
|
||||
for (const nodeType of FUNCTION_NODE_TYPES) {
|
||||
const n = findNodeAtRange(rootNode, range, nodeType);
|
||||
if (n !== null) return n;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function scopeExtractionError(stage: string, filePath: string, err: unknown): Error {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
return new Error(
|
||||
`[ruby] tree-sitter ${stage} failed for ${filePath}: ${reason}; skipping scope extraction for this file`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk forward from a comment node, skipping consecutive comments,
|
||||
* and return the next `method` or `singleton_method` node (if any).
|
||||
*/
|
||||
function findFollowingMethod(commentNode: SyntaxNode): SyntaxNode | null {
|
||||
let sibling = commentNode.nextNamedSibling;
|
||||
while (sibling !== null && sibling.type === 'comment') {
|
||||
sibling = sibling.nextNamedSibling;
|
||||
}
|
||||
if (sibling === null) return null;
|
||||
if (sibling.type === 'method' || sibling.type === 'singleton_method') return sibling;
|
||||
// In tree-sitter-ruby, YARD comments before a method inside a class body
|
||||
// are children of `class`, while the method is inside `body_statement`.
|
||||
// Walk into body_statement to find the method.
|
||||
if (sibling.type === 'body_statement') {
|
||||
const first = sibling.firstNamedChild;
|
||||
if (first !== null && (first.type === 'method' || first.type === 'singleton_method')) {
|
||||
return first;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk forward from a comment node, skipping consecutive comments,
|
||||
* and return the next `call` node whose method is attr_accessor /
|
||||
* attr_reader / attr_writer (if any). Used to attach YARD `@return`
|
||||
* annotations to property declarations.
|
||||
*/
|
||||
function findFollowingAttrCall(commentNode: SyntaxNode): SyntaxNode | null {
|
||||
let sibling = commentNode.nextNamedSibling;
|
||||
while (sibling !== null && sibling.type === 'comment') {
|
||||
sibling = sibling.nextNamedSibling;
|
||||
}
|
||||
if (sibling === null) return null;
|
||||
if (sibling.type === 'call') {
|
||||
const methodNode = sibling.childForFieldName('method');
|
||||
if (methodNode !== null && ATTR_CALL_NAMES.has(methodNode.text)) {
|
||||
return sibling;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a YARD type string: for single-parameter generics like
|
||||
* `Array<User>` or `Array[User]` keep the inner type; for multi-param
|
||||
* generics like `Hash<Symbol, User>` strip the generic and return the
|
||||
* outer type name only.
|
||||
*/
|
||||
function normalizeYardType(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
|
||||
// Check for angle-bracket generics: Type<Inner> or Type<A, B>
|
||||
const angleMatch = trimmed.match(/^(\w+)<(.+)>$/);
|
||||
if (angleMatch) {
|
||||
const inner = angleMatch[2]!;
|
||||
// Single-param generic → return the inner type
|
||||
if (!inner.includes(',')) return inner.trim();
|
||||
// Multi-param generic → return the outer type
|
||||
return angleMatch[1]!;
|
||||
}
|
||||
|
||||
// Check for bracket generics: Type[Inner] (YARD sometimes uses this)
|
||||
const bracketMatch = trimmed.match(/^(\w+)\[(.+)\]$/);
|
||||
if (bracketMatch) {
|
||||
const inner = bracketMatch[2]!;
|
||||
if (!inner.includes(',')) return inner.trim();
|
||||
return bracketMatch[1]!;
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
107
gitnexus/src/core/ingestion/languages/ruby/import-target.ts
Normal file
107
gitnexus/src/core/ingestion/languages/ruby/import-target.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
/**
|
||||
* Resolve a Ruby require/require_relative import path to a repo-relative file.
|
||||
*
|
||||
* Ruby import resolution rules:
|
||||
* - `require_relative './foo'` → resolve relative to the importing file's dir
|
||||
* - `require 'foo'` → suffix-match via the existing Ruby import resolver
|
||||
* - External gems → null (unresolvable within the repo)
|
||||
*/
|
||||
|
||||
import { resolveRubyImportInternal } from '../../import-resolvers/ruby.js';
|
||||
import { buildSuffixIndex } from '../../import-resolvers/utils.js';
|
||||
|
||||
export interface RubyResolveContext {
|
||||
readonly fromFile: string;
|
||||
readonly allFilePaths: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
// ─── resolveRubyImportTarget ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* ScopeResolver-shaped adapter:
|
||||
* `(targetRaw, fromFile, allFilePaths, resolutionConfig?) → string | string[] | null`
|
||||
*
|
||||
* For relative paths (`./` or `../` — require_relative semantics), resolves
|
||||
* against the importing file's directory, trying `.rb` and `/index.rb`
|
||||
* suffixes.
|
||||
*
|
||||
* For bare requires (gem-style like `'json'`, `'serializable'`), delegates
|
||||
* to the existing `resolveRubyImportInternal` which uses suffix matching.
|
||||
*
|
||||
* Returns `null` for external gems that have no matching file in the repo.
|
||||
*/
|
||||
export function resolveRubyImportTarget(
|
||||
targetRaw: string,
|
||||
fromFile: string,
|
||||
allFilePaths: ReadonlySet<string>,
|
||||
_resolutionConfig?: unknown,
|
||||
): string | readonly string[] | null {
|
||||
if (!targetRaw) return null;
|
||||
if (targetRaw.startsWith('__heritage__:') || targetRaw.startsWith('__property__:')) return null;
|
||||
|
||||
const fromNormalized = fromFile.replace(/\\/g, '/');
|
||||
const fromDir = fromNormalized.includes('/')
|
||||
? fromNormalized.slice(0, fromNormalized.lastIndexOf('/'))
|
||||
: '';
|
||||
|
||||
// ── require_relative: relative path resolution ──────────────────────
|
||||
if (targetRaw.startsWith('./') || targetRaw.startsWith('../')) {
|
||||
const resolved = resolveRelative(targetRaw, fromDir, allFilePaths);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// ── require: bare/gem-style suffix matching ─────────────────────────
|
||||
return resolveBare(targetRaw, allFilePaths);
|
||||
}
|
||||
|
||||
// ─── internal helpers ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve a relative require path (`./foo`, `../bar`) against `fromDir`.
|
||||
* Tries `${resolved}.rb` then `${resolved}/index.rb`.
|
||||
*/
|
||||
function resolveRelative(
|
||||
targetRaw: string,
|
||||
fromDir: string,
|
||||
allFilePaths: ReadonlySet<string>,
|
||||
): string | null {
|
||||
// Resolve `./` and `../` segments manually against fromDir
|
||||
const segments = (fromDir ? fromDir + '/' + targetRaw : targetRaw).split('/');
|
||||
const resolved: string[] = [];
|
||||
|
||||
for (const seg of segments) {
|
||||
if (seg === '' || seg === '.') continue;
|
||||
if (seg === '..') {
|
||||
resolved.pop();
|
||||
} else {
|
||||
resolved.push(seg);
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedPath = resolved.join('/');
|
||||
|
||||
// Try direct .rb file
|
||||
const rbFile = `${resolvedPath}.rb`;
|
||||
if (allFilePaths.has(rbFile)) return rbFile;
|
||||
|
||||
// Try index.rb inside directory
|
||||
const indexFile = `${resolvedPath}/index.rb`;
|
||||
if (allFilePaths.has(indexFile)) return indexFile;
|
||||
|
||||
// The path might already include .rb extension
|
||||
if (resolvedPath.endsWith('.rb') && allFilePaths.has(resolvedPath)) return resolvedPath;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a bare require path (`'serializable'`, `'json'`, `'net/http'`)
|
||||
* via suffix matching using the existing Ruby import resolver.
|
||||
*/
|
||||
function resolveBare(targetRaw: string, allFilePaths: ReadonlySet<string>): string | null {
|
||||
const normalizedFileList = [...allFilePaths].map((f) => f.replace(/\\/g, '/'));
|
||||
const allFileList = [...allFilePaths];
|
||||
const index = buildSuffixIndex(normalizedFileList, allFileList);
|
||||
|
||||
return resolveRubyImportInternal(targetRaw, normalizedFileList, allFileList, index);
|
||||
}
|
||||
20
gitnexus/src/core/ingestion/languages/ruby/index.ts
Normal file
20
gitnexus/src/core/ingestion/languages/ruby/index.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
/**
|
||||
* Ruby scope-resolution hooks (RFC #909 Ring 3).
|
||||
*/
|
||||
export { emitRubyScopeCaptures } from './captures.js';
|
||||
export { getRubyCaptureCacheStats, resetRubyCaptureCacheStats } from './cache-stats.js';
|
||||
export {
|
||||
interpretRubyImport,
|
||||
interpretRubyTypeBinding,
|
||||
normalizeRubyTypeName,
|
||||
} from './interpret.js';
|
||||
export { rubyArityCompatibility } from './arity.js';
|
||||
export { rubyMergeBindings } from './merge-bindings.js';
|
||||
export { synthesizeRubyReceiverBinding, findEnclosingClassOrModule } from './receiver-binding.js';
|
||||
export {
|
||||
rubyBindingScopeFor,
|
||||
rubyImportOwningScope,
|
||||
rubyReceiverBinding,
|
||||
rubyFunctionDefinitionLabel,
|
||||
} from './simple-hooks.js';
|
||||
export { resolveRubyImportTarget, type RubyResolveContext } from './import-target.js';
|
||||
117
gitnexus/src/core/ingestion/languages/ruby/interpret.ts
Normal file
117
gitnexus/src/core/ingestion/languages/ruby/interpret.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'gitnexus-shared';
|
||||
|
||||
// ─── interpretImport ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Interpret a pre-decomposed Ruby import capture into a `ParsedImport`.
|
||||
*
|
||||
* Ruby `require` / `require_relative` / `load` bring everything from the
|
||||
* target file into scope (wildcard semantics). The captures layer (U5)
|
||||
* pre-decomposes the raw tree-sitter match so that this function receives:
|
||||
* - `@import.kind` — always `'wildcard'` for Ruby
|
||||
* - `@import.source` — the string argument (e.g. `'./user'`, `'serializable'`)
|
||||
* - `@import.name` — derived module name (informational)
|
||||
*/
|
||||
export function interpretRubyImport(captures: CaptureMatch): ParsedImport | null {
|
||||
const kind = captures['@import.kind']?.text;
|
||||
if (kind === undefined) return null;
|
||||
|
||||
const source = captures['@import.source']?.text;
|
||||
if (source === undefined) return null;
|
||||
|
||||
// Heritage-encoded imports (__heritage__:include:Serializable:User)
|
||||
// are stored as namespace imports so emitHeritageEdges can read them.
|
||||
if (source.startsWith('__heritage__:') || source.startsWith('__property__:')) {
|
||||
const name = captures['@import.name']?.text ?? source;
|
||||
return { kind: 'namespace', localName: name, importedName: name, targetRaw: source };
|
||||
}
|
||||
|
||||
// Ruby imports are always wildcard — everything in the required file
|
||||
// becomes visible in the importing scope.
|
||||
return { kind: 'wildcard', targetRaw: source };
|
||||
}
|
||||
|
||||
// ─── interpretTypeBinding ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Interpret a Ruby type-binding capture into a `ParsedTypeBinding`.
|
||||
*
|
||||
* Type information in Ruby comes from YARD/RBS annotations, `.new` calls,
|
||||
* and assignment inference. The captures layer tags each match with one of
|
||||
* several sub-captures (`@type-binding.self`, `@type-binding.constructor`,
|
||||
* etc.) so this function can determine the `source`.
|
||||
*/
|
||||
export function interpretRubyTypeBinding(captures: CaptureMatch): ParsedTypeBinding | null {
|
||||
const name = captures['@type-binding.name']?.text;
|
||||
const type = captures['@type-binding.type']?.text;
|
||||
if (name === undefined || type === undefined) return null;
|
||||
|
||||
let source: TypeRef['source'];
|
||||
let normalizedType: string;
|
||||
|
||||
if (captures['@type-binding.self'] !== undefined) {
|
||||
source = 'self';
|
||||
normalizedType = normalizeRubyTypeName(type);
|
||||
} else if (captures['@type-binding.constructor'] !== undefined) {
|
||||
source = 'constructor-inferred';
|
||||
normalizedType = normalizeRubyConstructorType(type);
|
||||
} else if (captures['@type-binding.call-return'] !== undefined) {
|
||||
source = 'constructor-inferred';
|
||||
normalizedType = normalizeRubyTypeName(type);
|
||||
} else if (captures['@type-binding.return'] !== undefined) {
|
||||
source = 'return-annotation';
|
||||
normalizedType = normalizeRubyTypeName(type);
|
||||
} else if (captures['@type-binding.parameter'] !== undefined) {
|
||||
source = 'parameter-annotation';
|
||||
normalizedType = normalizeRubyTypeName(type);
|
||||
} else if (captures['@type-binding.alias'] !== undefined) {
|
||||
source = 'assignment-inferred';
|
||||
normalizedType = normalizeRubyTypeName(type);
|
||||
} else {
|
||||
source = 'annotation';
|
||||
normalizedType = normalizeRubyTypeName(type);
|
||||
}
|
||||
|
||||
return { boundName: name, rawTypeName: normalizedType, source };
|
||||
}
|
||||
|
||||
// ─── normalizeRubyTypeName ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Normalize a Ruby type name to its simple form:
|
||||
* 1. Strip leading `::` (root-qualified)
|
||||
* 2. Take last segment of qualified paths (`Foo::Bar::Baz` → `Baz`)
|
||||
* 3. Strip generic angle brackets for consistency (`Array<User>` → `Array`)
|
||||
* 4. Trim whitespace
|
||||
*/
|
||||
export function normalizeRubyTypeName(text: string): string {
|
||||
let t = text.trim();
|
||||
|
||||
// Strip leading root-qualifier
|
||||
if (t.startsWith('::')) t = t.slice(2);
|
||||
|
||||
// Strip generic angle brackets (e.g. `Array<User>` → `Array`)
|
||||
const angleBracket = t.indexOf('<');
|
||||
if (angleBracket !== -1) t = t.slice(0, angleBracket);
|
||||
|
||||
// Take last segment of qualified paths (Foo::Bar::Baz → Baz)
|
||||
const lastColon = t.lastIndexOf('::');
|
||||
if (lastColon !== -1) t = t.slice(lastColon + 2);
|
||||
|
||||
return t.trim();
|
||||
}
|
||||
|
||||
// ─── internal helpers ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Normalize a constructor-inferred type from a `.new` call.
|
||||
* Handles `Foo::Bar.new` → `Bar` and plain `Foo.new` → `Foo`.
|
||||
*/
|
||||
function normalizeRubyConstructorType(text: string): string {
|
||||
let t = text.trim();
|
||||
|
||||
// Strip `.new` suffix if present (e.g. `Foo::Bar.new` → `Foo::Bar`)
|
||||
if (t.endsWith('.new')) t = t.slice(0, -4);
|
||||
|
||||
return normalizeRubyTypeName(t);
|
||||
}
|
||||
27
gitnexus/src/core/ingestion/languages/ruby/merge-bindings.ts
Normal file
27
gitnexus/src/core/ingestion/languages/ruby/merge-bindings.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import type { BindingRef } from 'gitnexus-shared';
|
||||
|
||||
const TIER: Record<BindingRef['origin'], number> = {
|
||||
local: 0,
|
||||
namespace: 1,
|
||||
import: 2,
|
||||
reexport: 3,
|
||||
wildcard: 4,
|
||||
};
|
||||
|
||||
export function rubyMergeBindings(
|
||||
existing: readonly BindingRef[],
|
||||
incoming: readonly BindingRef[],
|
||||
_scopeId: string,
|
||||
): BindingRef[] {
|
||||
const seen = new Set<string>();
|
||||
return [...existing, ...incoming]
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(TIER[a.origin] ?? 99) - (TIER[b.origin] ?? 99) || a.def.nodeId.localeCompare(b.def.nodeId),
|
||||
)
|
||||
.filter((binding) => {
|
||||
if (seen.has(binding.def.nodeId)) return false;
|
||||
seen.add(binding.def.nodeId);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
221
gitnexus/src/core/ingestion/languages/ruby/query.ts
Normal file
221
gitnexus/src/core/ingestion/languages/ruby/query.ts
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
/**
|
||||
* Tree-sitter query for Ruby scope captures (U1 scope-resolution migration).
|
||||
*
|
||||
* Captures the structural skeleton the generic scope-resolution pipeline
|
||||
* consumes: scopes (program/class/module/method/block), declarations
|
||||
* (class, module, method, singleton_method, variable), imports
|
||||
* (require/require_relative/load), type bindings (constructor-inferred
|
||||
* locals via `.new`), and references (free calls, member calls).
|
||||
*
|
||||
* Ruby specifics that shape this query:
|
||||
*
|
||||
* - Ruby modules are class-like scopes (they hold methods and can be
|
||||
* mixed in via include/extend/prepend).
|
||||
*
|
||||
* - `singleton_method` (`def self.foo`) is a class-level method
|
||||
* declaration, captured as @scope.function + @declaration.function.
|
||||
*
|
||||
* - Ruby has no static type annotations. Constructor inference via
|
||||
* `x = User.new` is handled here; YARD `@param`/`@return` comments
|
||||
* are handled programmatically in captures.ts.
|
||||
*
|
||||
* - In Ruby, field access IS a method call (attr_reader generates
|
||||
* methods). Member calls cover both method calls and field reads.
|
||||
*
|
||||
* - `require`, `require_relative`, and `load` are plain method calls
|
||||
* in the grammar — matched by name via `#match?`.
|
||||
*
|
||||
* - `do_block` and `block` (`{ }`) are both block scopes that can
|
||||
* introduce closures.
|
||||
*
|
||||
* Exposes lazy `Parser` and `Query` singletons so callers don't pay
|
||||
* tree-sitter init cost per file.
|
||||
*/
|
||||
|
||||
import Parser from 'tree-sitter';
|
||||
import Ruby from 'tree-sitter-ruby';
|
||||
|
||||
const RUBY_SCOPE_QUERY = `
|
||||
;; ── Scopes ───────────────────────────────────────────────────────────────
|
||||
|
||||
(program) @scope.module
|
||||
|
||||
(class) @scope.class
|
||||
(module) @scope.class
|
||||
|
||||
(method) @scope.function
|
||||
(singleton_method) @scope.function
|
||||
|
||||
(do_block) @scope.block
|
||||
(block) @scope.block
|
||||
|
||||
;; ── Declarations — class ─────────────────────────────────────────────────
|
||||
|
||||
(class
|
||||
name: (constant) @declaration.name) @declaration.class
|
||||
|
||||
;; ── Declarations — module (labeled Trait for class-like registry lookup) ─
|
||||
|
||||
(module
|
||||
name: (constant) @declaration.name) @declaration.trait
|
||||
|
||||
;; ── Declarations — method (instance) ─────────────────────────────────────
|
||||
|
||||
(method
|
||||
name: (identifier) @declaration.name) @declaration.function
|
||||
|
||||
;; ── Declarations — singleton method (class-level: def self.foo) ──────────
|
||||
|
||||
(singleton_method
|
||||
name: (identifier) @declaration.name) @declaration.function
|
||||
|
||||
;; ── Declarations — variable assignment ───────────────────────────────────
|
||||
|
||||
(assignment
|
||||
left: (identifier) @declaration.name) @declaration.variable
|
||||
|
||||
;; ── Imports — require / require_relative / load ──────────────────────────
|
||||
;;
|
||||
;; All three are plain \`call\` nodes in tree-sitter-ruby with no receiver.
|
||||
;; The import-decomposer in captures.ts fans out the argument to a path.
|
||||
|
||||
(call
|
||||
method: (identifier) @_method
|
||||
(#match? @_method "^(require|require_relative|load)$")) @import.statement
|
||||
|
||||
;; ── Type bindings — constructor inference: x = User.new ──────────────────
|
||||
;;
|
||||
;; tree-sitter-ruby parses \`x = User.new\` as:
|
||||
;; (assignment
|
||||
;; left: (identifier) ;; "x"
|
||||
;; right: (call
|
||||
;; receiver: (constant) ;; "User"
|
||||
;; method: (identifier))) ;; "new"
|
||||
;;
|
||||
;; Captures the receiver constant as the type.
|
||||
|
||||
(assignment
|
||||
left: (identifier) @type-binding.name
|
||||
right: (call
|
||||
receiver: (constant) @type-binding.type
|
||||
method: (identifier) @_new_method
|
||||
(#eq? @_new_method "new"))) @type-binding.constructor
|
||||
|
||||
;; Qualified constructor: x = Foo::Bar.new (scope_resolution receiver)
|
||||
|
||||
(assignment
|
||||
left: (identifier) @type-binding.name
|
||||
right: (call
|
||||
receiver: (scope_resolution) @type-binding.type
|
||||
method: (identifier) @_new_method2
|
||||
(#eq? @_new_method2 "new"))) @type-binding.constructor
|
||||
|
||||
;; Constant constructor: SERVICE = UserService.new (left is constant, not identifier)
|
||||
|
||||
(assignment
|
||||
left: (constant) @type-binding.name
|
||||
right: (call
|
||||
receiver: (constant) @type-binding.type
|
||||
method: (identifier) @_new_method3
|
||||
(#eq? @_new_method3 "new"))) @type-binding.constructor
|
||||
|
||||
(assignment
|
||||
left: (constant) @type-binding.name
|
||||
right: (call
|
||||
receiver: (scope_resolution) @type-binding.type
|
||||
method: (identifier) @_new_method4
|
||||
(#eq? @_new_method4 "new"))) @type-binding.constructor
|
||||
|
||||
;; Call-return inference: x = build_service() (factory pattern)
|
||||
|
||||
(assignment
|
||||
left: (identifier) @type-binding.name
|
||||
right: (call
|
||||
!receiver
|
||||
method: (identifier) @type-binding.type)) @type-binding.call-return
|
||||
|
||||
;; Constant call-return: SERVICE = build_service()
|
||||
|
||||
(assignment
|
||||
left: (constant) @type-binding.name
|
||||
right: (call
|
||||
!receiver
|
||||
method: (identifier) @type-binding.type)) @type-binding.call-return
|
||||
|
||||
;; ── Type bindings — for-in loop: for x in collection ─────────────────────
|
||||
;;
|
||||
;; The loop variable \`x\` gets the element type of the collection.
|
||||
;; We bind \`x → collection\` as an alias; the chain-follow pass
|
||||
;; resolves \`collection → ElementType\` via YARD \`@param\` annotations.
|
||||
;; tree-sitter-ruby wraps the collection in an \`in\` node:
|
||||
;; (for pattern: (identifier) value: (in (identifier)))
|
||||
|
||||
(for
|
||||
pattern: (identifier) @type-binding.name
|
||||
value: (in
|
||||
(identifier) @type-binding.type)) @type-binding.alias
|
||||
|
||||
;; ── Type bindings — variable alias: x = y ────────────────────────────────
|
||||
|
||||
(assignment
|
||||
left: (identifier) @type-binding.name
|
||||
right: (identifier) @type-binding.type) @type-binding.alias
|
||||
|
||||
;; ── References — free calls (no receiver) ────────────────────────────────
|
||||
|
||||
(call
|
||||
!receiver
|
||||
method: (identifier) @reference.name) @reference.call.free
|
||||
|
||||
;; ── References — bare calls (zero-arity calls without parentheses) ──────
|
||||
;;
|
||||
;; Ruby allows calling methods without parentheses. When no arguments are
|
||||
;; passed, tree-sitter-ruby parses them as plain \`identifier\` nodes inside
|
||||
;; \`body_statement\`. This mirrors the legacy query pattern. The scope-
|
||||
;; resolution pipeline filters false positives via builtInNames and
|
||||
;; arity-based overload narrowing.
|
||||
|
||||
(body_statement
|
||||
(identifier) @reference.name) @reference.call.free
|
||||
|
||||
;; ── References — member calls (with receiver): obj.method() ──────────────
|
||||
|
||||
(call
|
||||
receiver: (_) @reference.receiver
|
||||
method: (identifier) @reference.name) @reference.call.member
|
||||
|
||||
;; ── References — field writes: obj.field = value ─────────────────────────
|
||||
;;
|
||||
;; Ruby setter syntax: \`obj.name = x\` is an assignment whose left-hand
|
||||
;; side is a \`call\` node with a receiver.
|
||||
|
||||
(assignment
|
||||
left: (call
|
||||
receiver: (_) @reference.receiver
|
||||
method: (identifier) @reference.name)) @reference.write
|
||||
|
||||
;; ── References — field writes (compound assignment: obj.field += value) ──
|
||||
|
||||
(operator_assignment
|
||||
left: (call
|
||||
receiver: (_) @reference.receiver
|
||||
method: (identifier) @reference.name)) @reference.write
|
||||
`;
|
||||
|
||||
let _parser: Parser | null = null;
|
||||
let _query: Parser.Query | null = null;
|
||||
|
||||
export function getRubyParser(): Parser {
|
||||
if (_parser === null) {
|
||||
_parser = new Parser();
|
||||
_parser.setLanguage(Ruby as Parameters<Parser['setLanguage']>[0]);
|
||||
}
|
||||
return _parser;
|
||||
}
|
||||
|
||||
export function getRubyScopeQuery(): Parser.Query {
|
||||
if (_query === null) {
|
||||
_query = new Parser.Query(Ruby as Parameters<Parser['setLanguage']>[0], RUBY_SCOPE_QUERY);
|
||||
}
|
||||
return _query;
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
/**
|
||||
* Synthesize `@type-binding.self` captures for Ruby methods.
|
||||
*
|
||||
* Every instance method and singleton method inside a class or module body
|
||||
* gets `self` bound to the enclosing class/module name. Ruby has no
|
||||
* `@staticmethod` concept — even `def self.foo` dispatches on the class.
|
||||
*
|
||||
* For `class << self` (singleton_class) blocks, we walk past the
|
||||
* singleton_class to the real owning class/module, matching the
|
||||
* `rubyResolveEnclosingOwner` logic from the legacy provider.
|
||||
*/
|
||||
|
||||
import type { CaptureMatch } from 'gitnexus-shared';
|
||||
import { syntheticCapture } from '../../utils/ast-helpers.js';
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
|
||||
/**
|
||||
* Walk up the parent chain from `node` to find the first enclosing
|
||||
* `class` or `module` ancestor.
|
||||
*
|
||||
* - `singleton_class` (`class << self`) is not itself a real type, so we
|
||||
* skip past it and continue walking to find the true owner.
|
||||
* - Stops at `program` (never walks past the file root).
|
||||
* - Returns `null` when no enclosing class/module is found.
|
||||
*/
|
||||
export function findEnclosingClassOrModule(node: SyntaxNode): SyntaxNode | null {
|
||||
let cur: SyntaxNode | null = node.parent;
|
||||
while (cur !== null) {
|
||||
if (cur.type === 'program') return null;
|
||||
if (cur.type === 'class' || cur.type === 'module') return cur;
|
||||
// singleton_class (`class << self`) is not the real owner —
|
||||
// keep walking to find the enclosing class/module.
|
||||
cur = cur.parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the class/module name from the `name` field of a `class` or
|
||||
* `module` node. The field holds a `constant` node whose text is the
|
||||
* simple name (e.g. "MyClass").
|
||||
*/
|
||||
function extractClassName(classOrModuleNode: SyntaxNode): string | null {
|
||||
const nameNode = classOrModuleNode.childForFieldName('name');
|
||||
if (nameNode === null) return null;
|
||||
return nameNode.text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a method node (`method` or `singleton_method`) and its enclosing
|
||||
* `class`/`module` node (or null for top-level defs), synthesize a
|
||||
* `@type-binding.self` capture that binds `self` to the class/module name.
|
||||
*
|
||||
* Returns `null` when:
|
||||
* - `enclosingNode` is null (top-level def has no implicit receiver)
|
||||
* - the enclosing node's name cannot be extracted
|
||||
*/
|
||||
export function synthesizeRubyReceiverBinding(
|
||||
fnNode: SyntaxNode,
|
||||
enclosingNode: SyntaxNode | null,
|
||||
): CaptureMatch | null {
|
||||
if (enclosingNode === null) return null;
|
||||
|
||||
const className = extractClassName(enclosingNode);
|
||||
if (className === null) return null;
|
||||
|
||||
return {
|
||||
'@type-binding.self': syntheticCapture('@type-binding.self', fnNode, 'self'),
|
||||
'@type-binding.name': syntheticCapture('@type-binding.name', fnNode, 'self'),
|
||||
'@type-binding.type': syntheticCapture('@type-binding.type', fnNode, className),
|
||||
};
|
||||
}
|
||||
235
gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts
Normal file
235
gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
import type { ParsedFile, ScopeId } from 'gitnexus-shared';
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js';
|
||||
import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
|
||||
import { rubyProvider } from '../ruby.js';
|
||||
import { rubyArityCompatibility, rubyMergeBindings, resolveRubyImportTarget } from './index.js';
|
||||
import { populateClassOwnedMembers, isClassLike } from '../../scope-resolution/scope/walkers.js';
|
||||
import { resolveDefGraphId } from '../../scope-resolution/graph-bridge/ids.js';
|
||||
import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js';
|
||||
import type { KnowledgeGraph } from '../../../graph/types.js';
|
||||
import { generateId } from '../../../../lib/utils.js';
|
||||
|
||||
const HERITAGE_PREFIX = '__heritage__:';
|
||||
|
||||
function emitRubyMixinEdges(
|
||||
graph: KnowledgeGraph,
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
nodeLookup: GraphNodeLookup,
|
||||
): void {
|
||||
const graphIdByName = new Map<string, string>();
|
||||
for (const parsed of parsedFiles) {
|
||||
for (const def of parsed.localDefs) {
|
||||
if (!isClassLike(def.type)) continue;
|
||||
const graphId = resolveDefGraphId(parsed.filePath, def, nodeLookup);
|
||||
if (graphId !== undefined) {
|
||||
const simpleName = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? '';
|
||||
graphIdByName.set(simpleName, graphId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const emitted = new Set<string>();
|
||||
// Pre-seed with existing IMPLEMENTS edges to avoid duplicates when the
|
||||
// parse-worker path already produced heritage (worker path for repos
|
||||
// with >= 15 files).
|
||||
for (const rel of graph.iterRelationshipsByType('IMPLEMENTS')) {
|
||||
emitted.add(`${rel.sourceId}->${rel.targetId}:${rel.reason}`);
|
||||
}
|
||||
|
||||
for (const parsed of parsedFiles) {
|
||||
for (const imp of parsed.parsedImports) {
|
||||
if (!imp.targetRaw.startsWith(HERITAGE_PREFIX)) continue;
|
||||
const parts = imp.targetRaw.slice(HERITAGE_PREFIX.length).split(':');
|
||||
if (parts.length < 3) continue;
|
||||
const [kind, mixinName, className] = parts;
|
||||
const classGraphId = graphIdByName.get(className!);
|
||||
const mixinGraphId = graphIdByName.get(mixinName!);
|
||||
if (classGraphId === undefined || mixinGraphId === undefined) continue;
|
||||
const edgeKey = `${classGraphId}->${mixinGraphId}:${kind}`;
|
||||
if (emitted.has(edgeKey)) continue;
|
||||
emitted.add(edgeKey);
|
||||
graph.addRelationship({
|
||||
id: generateId('IMPLEMENTS', edgeKey),
|
||||
sourceId: classGraphId,
|
||||
targetId: mixinGraphId,
|
||||
type: 'IMPLEMENTS',
|
||||
confidence: 0.85,
|
||||
reason: kind!,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Emit Property nodes + HAS_PROPERTY edges from __property__:... imports.
|
||||
// Skip if the parse-worker already created the property (worker path merges
|
||||
// Property nodes into the graph before scope-resolution runs).
|
||||
const existingProps = new Set<string>();
|
||||
for (const rel of graph.iterRelationshipsByType('HAS_PROPERTY')) {
|
||||
const targetNode = graph.getNode(rel.targetId);
|
||||
if (targetNode !== undefined) {
|
||||
existingProps.add(`${rel.sourceId}->prop:${targetNode.properties.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
const PROPERTY_PREFIX = '__property__:';
|
||||
for (const parsed of parsedFiles) {
|
||||
for (const imp of parsed.parsedImports) {
|
||||
if (!imp.targetRaw.startsWith(PROPERTY_PREFIX)) continue;
|
||||
const parts = imp.targetRaw.slice(PROPERTY_PREFIX.length).split(':');
|
||||
if (parts.length < 3) continue;
|
||||
const [_attrKind, propName, className] = parts;
|
||||
const classGraphId = graphIdByName.get(className!);
|
||||
if (classGraphId === undefined || propName === undefined) continue;
|
||||
|
||||
const edgeKey = `${classGraphId}->prop:${propName}`;
|
||||
if (emitted.has(edgeKey) || existingProps.has(edgeKey)) continue;
|
||||
emitted.add(edgeKey);
|
||||
|
||||
const propId = generateId('Property', `${parsed.filePath}:${className}.${propName}`);
|
||||
graph.addNode({
|
||||
id: propId,
|
||||
label: 'Property',
|
||||
properties: { name: propName, filePath: parsed.filePath },
|
||||
});
|
||||
graph.addRelationship({
|
||||
id: generateId('HAS_PROPERTY', edgeKey),
|
||||
sourceId: classGraphId,
|
||||
targetId: propId,
|
||||
type: 'HAS_PROPERTY',
|
||||
confidence: 0.9,
|
||||
reason: 'attr',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildRubyMro(
|
||||
graph: Parameters<ScopeResolver['buildMro']>[0],
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
nodeLookup: Parameters<ScopeResolver['buildMro']>[2],
|
||||
): Map<string, string[]> {
|
||||
// Step 1: EXTENDS chain via the generic MRO builder (direct class inheritance).
|
||||
const baseMro = buildMro(graph, parsedFiles, nodeLookup, defaultLinearize);
|
||||
|
||||
// Step 2: Build defId ↔ graphId bridge for class-like defs.
|
||||
const defIdByGraphId = new Map<string, string>();
|
||||
for (const parsed of parsedFiles) {
|
||||
for (const def of parsed.localDefs) {
|
||||
if (!isClassLike(def.type)) continue;
|
||||
const graphId = resolveDefGraphId(parsed.filePath, def, nodeLookup);
|
||||
if (graphId !== undefined) defIdByGraphId.set(graphId, def.nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Collect IMPLEMENTS edges, partitioned by reason.
|
||||
const prependByChild = new Map<string, string[]>();
|
||||
const includeByChild = new Map<string, string[]>();
|
||||
|
||||
for (const rel of graph.iterRelationshipsByType('IMPLEMENTS')) {
|
||||
const childDefId = defIdByGraphId.get(rel.sourceId);
|
||||
const parentDefId = defIdByGraphId.get(rel.targetId);
|
||||
if (childDefId === undefined || parentDefId === undefined) continue;
|
||||
|
||||
const reason = rel.reason;
|
||||
if (reason === 'prepend') {
|
||||
let list = prependByChild.get(childDefId);
|
||||
if (list === undefined) {
|
||||
list = [];
|
||||
prependByChild.set(childDefId, list);
|
||||
}
|
||||
list.push(parentDefId);
|
||||
} else if (reason === 'include') {
|
||||
let list = includeByChild.get(childDefId);
|
||||
if (list === undefined) {
|
||||
list = [];
|
||||
includeByChild.set(childDefId, list);
|
||||
}
|
||||
list.push(parentDefId);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Reorder MRO per Ruby semantics.
|
||||
// Order: prepend (reversed) → direct extends chain → include (reversed).
|
||||
// `extend` is excluded — it belongs to singleton dispatch only (legacy
|
||||
// `getInstanceAncestry` in heritage-map.ts explicitly drops extend entries).
|
||||
// Reversed because Ruby declaration order means last-declared wins
|
||||
// (prepend B; prepend A → B checked before A).
|
||||
for (const defId of defIdByGraphId.values()) {
|
||||
const extendsChain = baseMro.get(defId) ?? [];
|
||||
const prepends = prependByChild.get(defId);
|
||||
const includes = includeByChild.get(defId);
|
||||
|
||||
if (prepends === undefined && includes === undefined) continue;
|
||||
|
||||
const reordered: string[] = [];
|
||||
if (prepends !== undefined) {
|
||||
for (let i = prepends.length - 1; i >= 0; i--) reordered.push(prepends[i]);
|
||||
}
|
||||
reordered.push(...extendsChain);
|
||||
if (includes !== undefined) {
|
||||
for (let i = includes.length - 1; i >= 0; i--) reordered.push(includes[i]);
|
||||
}
|
||||
baseMro.set(defId, reordered);
|
||||
}
|
||||
|
||||
return baseMro;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate all names exported from a target module scope's file.
|
||||
* Ruby's `require` / `require_relative` are wildcard imports — they bring
|
||||
* every top-level def (class, module, method, constant) from the target
|
||||
* file into the importer's scope. Without this hook the finalize pass
|
||||
* cannot materialize individual bindings from wildcard imports, which
|
||||
* blocks `propagateImportedReturnTypes` from mirroring return-type
|
||||
* typeBindings across files.
|
||||
*/
|
||||
function expandRubyWildcardNames(
|
||||
targetModuleScope: ScopeId,
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
): readonly string[] {
|
||||
const target = parsedFiles.find((p) => p.moduleScope === targetModuleScope);
|
||||
if (target === undefined) return [];
|
||||
|
||||
const seen = new Set<string>();
|
||||
const names: string[] = [];
|
||||
for (const def of target.localDefs) {
|
||||
const qn = def.qualifiedName;
|
||||
if (qn === undefined || qn.length === 0) continue;
|
||||
const name = qn.split('.').pop() ?? qn;
|
||||
if (name === '') continue;
|
||||
if (seen.has(name)) continue;
|
||||
seen.add(name);
|
||||
names.push(name);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
export const rubyScopeResolver: ScopeResolver = {
|
||||
language: SupportedLanguages.Ruby,
|
||||
languageProvider: rubyProvider,
|
||||
importEdgeReason: 'ruby-scope: import',
|
||||
|
||||
resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) =>
|
||||
resolveRubyImportTarget(targetRaw, fromFile, allFilePaths, resolutionConfig),
|
||||
|
||||
expandsWildcardTo: (targetModuleScope, parsedFiles) =>
|
||||
expandRubyWildcardNames(targetModuleScope, parsedFiles),
|
||||
|
||||
mergeBindings: (existing, incoming, scopeId) => rubyMergeBindings(existing, incoming, scopeId),
|
||||
|
||||
arityCompatibility: (callsite, def) => rubyArityCompatibility(def, callsite),
|
||||
|
||||
buildMro: (graph, parsedFiles, nodeLookup) => buildRubyMro(graph, parsedFiles, nodeLookup),
|
||||
|
||||
populateOwners: (parsed) => populateClassOwnedMembers(parsed),
|
||||
|
||||
isSuperReceiver: (text) => text.trim() === 'super',
|
||||
|
||||
emitHeritageEdges: (graph, parsedFiles, nodeLookup) =>
|
||||
emitRubyMixinEdges(graph, parsedFiles, nodeLookup),
|
||||
|
||||
fieldFallbackOnMethodLookup: true,
|
||||
propagatesReturnTypesAcrossImports: true,
|
||||
allowGlobalFreeCallFallback: true,
|
||||
};
|
||||
63
gitnexus/src/core/ingestion/languages/ruby/simple-hooks.ts
Normal file
63
gitnexus/src/core/ingestion/languages/ruby/simple-hooks.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import type {
|
||||
CaptureMatch,
|
||||
ParsedImport,
|
||||
Scope,
|
||||
ScopeId,
|
||||
ScopeTree,
|
||||
TypeRef,
|
||||
NodeLabel,
|
||||
} from 'gitnexus-shared';
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
|
||||
export function rubyBindingScopeFor(
|
||||
decl: CaptureMatch,
|
||||
innermost: Scope,
|
||||
_tree: ScopeTree,
|
||||
): ScopeId | null {
|
||||
// Keep self typeBindings in the method's Function scope so
|
||||
// populateClassOwnedMembers can match Method defs to their receiver types.
|
||||
if (decl['@type-binding.self'] !== undefined) {
|
||||
return innermost.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruby `require` / `include` inside a function or class body should attach
|
||||
* at that scope, not module scope.
|
||||
*/
|
||||
export function rubyImportOwningScope(
|
||||
_imp: ParsedImport,
|
||||
innermost: Scope,
|
||||
_tree: ScopeTree,
|
||||
): ScopeId | null {
|
||||
if (innermost.kind === 'Function' || innermost.kind === 'Class') {
|
||||
return innermost.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function rubyReceiverBinding(functionScope: Scope): TypeRef | null {
|
||||
if (functionScope.kind !== 'Function') return null;
|
||||
return functionScope.typeBindings.get('self') ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reclassify top-level `def` as `'Method'` when it appears inside a
|
||||
* `class` or `module` body. Stand-alone defs remain `'Function'`.
|
||||
*/
|
||||
export function rubyFunctionDefinitionLabel(
|
||||
functionNode: SyntaxNode,
|
||||
defaultLabel: NodeLabel,
|
||||
): NodeLabel {
|
||||
if (defaultLabel !== 'Function') return defaultLabel;
|
||||
let ancestor: SyntaxNode | null = functionNode.parent;
|
||||
while (ancestor) {
|
||||
if (ancestor.type === 'program') break;
|
||||
if (ancestor.type === 'class' || ancestor.type === 'module') {
|
||||
return 'Method';
|
||||
}
|
||||
ancestor = ancestor.parent;
|
||||
}
|
||||
return 'Function';
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@
|
|||
import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared';
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { resolveImportPath } from '../../import-resolvers/standard.js';
|
||||
import type { SuffixIndex } from '../../import-resolvers/utils.js';
|
||||
import type { TsconfigPaths } from '../../language-config.js';
|
||||
|
||||
export interface TsResolveContext {
|
||||
|
|
@ -31,6 +32,8 @@ export interface TsResolveContext {
|
|||
readonly normalizedFileList?: readonly string[];
|
||||
/** Per-call resolution cache to dedupe repeated lookups. */
|
||||
readonly resolveCache?: Map<string, string | null>;
|
||||
/** Prebuilt suffix index for O(1)-style package/absolute import matching. */
|
||||
readonly index?: SuffixIndex;
|
||||
/** Parsed tsconfig path-aliases. `null` = no aliases configured. */
|
||||
readonly tsconfigPaths?: TsconfigPaths | null;
|
||||
/** JavaScript vs TypeScript switch — affects the extensions the
|
||||
|
|
@ -84,6 +87,7 @@ export function resolveTsTarget(targetRaw: string, ctx: TsResolveContext): strin
|
|||
resolveCache,
|
||||
language,
|
||||
ctx.tsconfigPaths ?? null,
|
||||
ctx.index,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.
|
|||
import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
|
||||
import { typescriptProvider } from '../typescript.js';
|
||||
import { loadTsconfigPaths, type TsconfigPaths } from '../../language-config.js';
|
||||
import { buildSuffixIndex, type SuffixIndex } from '../../import-resolvers/utils.js';
|
||||
import {
|
||||
typescriptArityCompatibility,
|
||||
typescriptMergeBindings,
|
||||
|
|
@ -50,6 +51,7 @@ function makeTsResolveImportTarget(): ScopeResolver['resolveImportTarget'] {
|
|||
readonly allFilePaths: Set<string>;
|
||||
readonly allFileList: readonly string[];
|
||||
readonly normalizedFileList: readonly string[];
|
||||
readonly index: SuffixIndex;
|
||||
readonly resolveCache: Map<string, string | null>;
|
||||
}
|
||||
let cached: PassCache | null = null;
|
||||
|
|
@ -57,11 +59,13 @@ function makeTsResolveImportTarget(): ScopeResolver['resolveImportTarget'] {
|
|||
return (targetRaw, fromFile, allFilePaths, resolutionConfig) => {
|
||||
if (cached === null || cached.key !== allFilePaths) {
|
||||
const allFileList = Array.from(allFilePaths);
|
||||
const normalizedFileList = allFileList.map((f) => f.toLowerCase());
|
||||
cached = {
|
||||
key: allFilePaths,
|
||||
allFilePaths: new Set(allFilePaths),
|
||||
allFileList,
|
||||
normalizedFileList: allFileList.map((f) => f.toLowerCase()),
|
||||
normalizedFileList,
|
||||
index: buildSuffixIndex(normalizedFileList, allFileList),
|
||||
resolveCache: new Map(),
|
||||
};
|
||||
}
|
||||
|
|
@ -72,6 +76,7 @@ function makeTsResolveImportTarget(): ScopeResolver['resolveImportTarget'] {
|
|||
allFilePaths: cached.allFilePaths,
|
||||
allFileList: cached.allFileList,
|
||||
normalizedFileList: cached.normalizedFileList,
|
||||
index: cached.index,
|
||||
resolveCache: cached.resolveCache,
|
||||
tsconfigPaths: cfg?.tsconfigPaths ?? null,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ import type {
|
|||
FileConstructorBindings,
|
||||
FileScopeBindings,
|
||||
ExtractedORMQuery,
|
||||
FetchWrapperDef,
|
||||
} from './workers/parse-worker.js';
|
||||
import {
|
||||
getTreeSitterBufferSize,
|
||||
|
|
@ -69,6 +70,7 @@ export interface WorkerExtractedData {
|
|||
heritage: ExtractedHeritage[];
|
||||
routes: ExtractedRoute[];
|
||||
fetchCalls: ExtractedFetchCall[];
|
||||
fetchWrapperDefs: FetchWrapperDef[];
|
||||
decoratorRoutes: ExtractedDecoratorRoute[];
|
||||
toolDefs: ExtractedToolDef[];
|
||||
ormQueries: ExtractedORMQuery[];
|
||||
|
|
@ -110,6 +112,7 @@ export const mergeChunkResults = (
|
|||
const allHeritage: ExtractedHeritage[] = [];
|
||||
const allRoutes: ExtractedRoute[] = [];
|
||||
const allFetchCalls: ExtractedFetchCall[] = [];
|
||||
const allFetchWrapperDefs: FetchWrapperDef[] = [];
|
||||
const allDecoratorRoutes: ExtractedDecoratorRoute[] = [];
|
||||
const allToolDefs: ExtractedToolDef[] = [];
|
||||
const allORMQueries: ExtractedORMQuery[] = [];
|
||||
|
|
@ -147,6 +150,7 @@ export const mergeChunkResults = (
|
|||
for (const item of result.heritage) allHeritage.push(item);
|
||||
for (const item of result.routes) allRoutes.push(item);
|
||||
for (const item of result.fetchCalls) allFetchCalls.push(item);
|
||||
for (const item of result.fetchWrapperDefs ?? []) allFetchWrapperDefs.push(item);
|
||||
for (const item of result.decoratorRoutes) allDecoratorRoutes.push(item);
|
||||
for (const item of result.toolDefs) allToolDefs.push(item);
|
||||
if (result.ormQueries) for (const item of result.ormQueries) allORMQueries.push(item);
|
||||
|
|
@ -163,6 +167,7 @@ export const mergeChunkResults = (
|
|||
heritage: allHeritage,
|
||||
routes: allRoutes,
|
||||
fetchCalls: allFetchCalls,
|
||||
fetchWrapperDefs: allFetchWrapperDefs,
|
||||
decoratorRoutes: allDecoratorRoutes,
|
||||
toolDefs: allToolDefs,
|
||||
ormQueries: allORMQueries,
|
||||
|
|
@ -203,6 +208,7 @@ const processParsingWithWorkers = async (
|
|||
heritage: [],
|
||||
routes: [],
|
||||
fetchCalls: [],
|
||||
fetchWrapperDefs: [],
|
||||
decoratorRoutes: [],
|
||||
toolDefs: [],
|
||||
ormQueries: [],
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ import type {
|
|||
ExtractedRoute,
|
||||
ExtractedToolDef,
|
||||
FileConstructorBindings,
|
||||
FetchWrapperDef,
|
||||
} from '../workers/parse-worker.js';
|
||||
import type { ExtractedHeritage } from '../model/heritage-map.js';
|
||||
import type { KnowledgeGraph } from '../../graph/types.js';
|
||||
|
|
@ -141,6 +142,7 @@ export async function runChunkedParseAndResolve(
|
|||
): Promise<{
|
||||
exportedTypeMap: ExportedTypeMap;
|
||||
allFetchCalls: ExtractedFetchCall[];
|
||||
allFetchWrapperDefs: FetchWrapperDef[];
|
||||
allExtractedRoutes: ExtractedRoute[];
|
||||
allDecoratorRoutes: ExtractedDecoratorRoute[];
|
||||
allToolDefs: ExtractedToolDef[];
|
||||
|
|
@ -352,6 +354,7 @@ export async function runChunkedParseAndResolve(
|
|||
// it, and later wildcard chunks re-run it themselves.
|
||||
let hasSynthesized = false;
|
||||
const allFetchCalls: ExtractedFetchCall[] = [];
|
||||
const allFetchWrapperDefs: FetchWrapperDef[] = [];
|
||||
const allExtractedRoutes: ExtractedRoute[] = [];
|
||||
const allDecoratorRoutes: ExtractedDecoratorRoute[] = [];
|
||||
const allToolDefs: ExtractedToolDef[] = [];
|
||||
|
|
@ -663,6 +666,9 @@ export async function runChunkedParseAndResolve(
|
|||
if (chunkWorkerData.fetchCalls?.length) {
|
||||
for (const item of chunkWorkerData.fetchCalls) allFetchCalls.push(item);
|
||||
}
|
||||
if (chunkWorkerData.fetchWrapperDefs?.length) {
|
||||
for (const item of chunkWorkerData.fetchWrapperDefs) allFetchWrapperDefs.push(item);
|
||||
}
|
||||
if (chunkWorkerData.routes?.length) {
|
||||
for (const item of chunkWorkerData.routes) allExtractedRoutes.push(item);
|
||||
}
|
||||
|
|
@ -1082,6 +1088,7 @@ export async function runChunkedParseAndResolve(
|
|||
return {
|
||||
exportedTypeMap,
|
||||
allFetchCalls,
|
||||
allFetchWrapperDefs,
|
||||
allExtractedRoutes,
|
||||
allDecoratorRoutes,
|
||||
allToolDefs,
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import type {
|
|||
ExtractedDecoratorRoute,
|
||||
ExtractedToolDef,
|
||||
ExtractedORMQuery,
|
||||
FetchWrapperDef,
|
||||
} from '../workers/parse-worker.js';
|
||||
import type { createResolutionContext } from '../model/resolution-context.js';
|
||||
import { runChunkedParseAndResolve } from './parse-impl.js';
|
||||
|
|
@ -45,6 +46,7 @@ export interface ParseOutput {
|
|||
*/
|
||||
readonly exportedTypeMap: ReadonlyMap<string, ReadonlyMap<string, string>>;
|
||||
readonly allFetchCalls: readonly ExtractedFetchCall[];
|
||||
readonly allFetchWrapperDefs: readonly FetchWrapperDef[];
|
||||
readonly allExtractedRoutes: readonly ExtractedRoute[];
|
||||
readonly allDecoratorRoutes: readonly ExtractedDecoratorRoute[];
|
||||
readonly allToolDefs: readonly ExtractedToolDef[];
|
||||
|
|
|
|||
|
|
@ -131,6 +131,10 @@ export function normalizeExtractedRoutePath(routePath: string, prefix: string |
|
|||
return joined.replace(/\/+/g, '/') || '/';
|
||||
}
|
||||
|
||||
function escapeRegex(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
export const routesPhase: PipelinePhase<RoutesOutput> = {
|
||||
name: 'routes',
|
||||
deps: ['parse'],
|
||||
|
|
@ -142,6 +146,7 @@ export const routesPhase: PipelinePhase<RoutesOutput> = {
|
|||
const {
|
||||
allPaths,
|
||||
allFetchCalls: parseFetchCalls,
|
||||
allFetchWrapperDefs,
|
||||
allExtractedRoutes,
|
||||
allDecoratorRoutes,
|
||||
} = getPhaseOutput<ParseOutput>(deps, 'parse');
|
||||
|
|
@ -357,6 +362,35 @@ export const routesPhase: PipelinePhase<RoutesOutput> = {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Cross-file fetch wrapper consumer extraction ──
|
||||
// When the parse phase discovered functions that internally call fetch(),
|
||||
// scan JS/TS consumer files for calls to those wrapper functions with
|
||||
// URL-like string arguments and add them to allFetchCalls so
|
||||
// processNextjsFetchRoutes can create FETCHES edges.
|
||||
if (allFetchWrapperDefs && allFetchWrapperDefs.length > 0 && routeRegistry.size > 0) {
|
||||
const wrapperNames = new Set(allFetchWrapperDefs.map((d) => d.functionName));
|
||||
const jsFiles = allPaths.filter((p) => /\.[jt]sx?$/.test(p));
|
||||
if (jsFiles.length > 0 && wrapperNames.size > 0) {
|
||||
const jsContents = await readFileContents(ctx.repoPath, jsFiles);
|
||||
for (const [filePath, content] of jsContents) {
|
||||
for (const name of wrapperNames) {
|
||||
const regex = new RegExp(
|
||||
`\\b${escapeRegex(name)}\\s*\\(\\s*['"\`](/[^'"\`\\s)]+)['"\`]`,
|
||||
'g',
|
||||
);
|
||||
let match;
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
allFetchCalls.push({
|
||||
filePath,
|
||||
fetchURL: match[1],
|
||||
lineNumber: content.substring(0, match.index).split('\n').length,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (routeRegistry.size > 0 && allFetchCalls.length > 0) {
|
||||
const routeURLToFile = new Map<string, string>();
|
||||
for (const [url, entry] of routeRegistry) routeURLToFile.set(url, entry.filePath);
|
||||
|
|
|
|||
|
|
@ -11,8 +11,9 @@
|
|||
* ## Contract
|
||||
*
|
||||
* - Env-var name per language: `REGISTRY_PRIMARY_<UPPER(enum-value)>`.
|
||||
* Example: `SupportedLanguages.Python` → `REGISTRY_PRIMARY_PYTHON`;
|
||||
* `SupportedLanguages.CPlusPlus` (value `'cpp'`) → `REGISTRY_PRIMARY_CPP`.
|
||||
* Example: `SupportedLanguages.Python` → `REGISTRY_PRIMARY_PYTHON`;
|
||||
* `SupportedLanguages.CPlusPlus` (value `'cpp'`) → `REGISTRY_PRIMARY_CPP`.
|
||||
* `SupportedLanguages.Cobol` (value `'cobol'`) → `REGISTRY_PRIMARY_COBOL`.
|
||||
* - Truthy values: `'true'`, `'1'`, `'yes'` (case-insensitive,
|
||||
* whitespace-trimmed). Anything else — including `undefined`, empty
|
||||
* string, or unknown tokens — is `false`.
|
||||
|
|
@ -79,6 +80,7 @@ export const MIGRATED_LANGUAGES: ReadonlySet<SupportedLanguages> = new Set<Suppo
|
|||
SupportedLanguages.Kotlin,
|
||||
SupportedLanguages.Java,
|
||||
SupportedLanguages.Rust,
|
||||
SupportedLanguages.Ruby,
|
||||
]);
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -574,6 +574,7 @@ function buildDefFromDeclarationMatch(
|
|||
const declaredType = match['@declaration.field-type']?.text;
|
||||
const returnType = match['@declaration.return-type']?.text;
|
||||
const templateConstraints = parseJsonCapture(match['@declaration.template-constraints']);
|
||||
const isExplicit = parseBooleanCapture(match['@declaration.is-explicit']);
|
||||
|
||||
return {
|
||||
nodeId: makeDefId(filePath, anchor.range, type, nameCap.text),
|
||||
|
|
@ -588,6 +589,7 @@ function buildDefFromDeclarationMatch(
|
|||
...(returnType !== undefined ? { returnType } : {}),
|
||||
...(templateArguments !== undefined ? { templateArguments } : {}),
|
||||
...(templateConstraints !== undefined ? { templateConstraints } : {}),
|
||||
...(isExplicit === true ? { isExplicit: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -610,6 +612,13 @@ function parseIntCapture(cap: { readonly text: string } | undefined): number | u
|
|||
return Number.isFinite(n) ? n : undefined;
|
||||
}
|
||||
|
||||
function parseBooleanCapture(cap: { readonly text: string } | undefined): boolean | undefined {
|
||||
if (cap === undefined) return undefined;
|
||||
if (cap.text === 'true') return true;
|
||||
if (cap.text === 'false') return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseJsonParameterTypeClassesCapture(
|
||||
cap: { readonly text: string } | undefined,
|
||||
): ParameterTypeClass[] | undefined {
|
||||
|
|
@ -1079,6 +1088,7 @@ const KNOWN_SUB_TAGS: ReadonlySet<string> = new Set<string>([
|
|||
'@declaration.parameter-types',
|
||||
'@declaration.parameter-type-classes',
|
||||
'@declaration.template-constraints',
|
||||
'@declaration.is-explicit',
|
||||
]);
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -437,6 +437,28 @@ export interface ScopeResolver {
|
|||
nodeLookup: GraphNodeLookup,
|
||||
) => Map<string /* DefId */, string[] /* ancestor DefIds */>;
|
||||
|
||||
/**
|
||||
* Optional pre-MRO hook to emit heritage edges (IMPLEMENTS) that the
|
||||
* generic `preEmitInheritanceEdges` pass cannot produce. Runs AFTER
|
||||
* `preEmitInheritanceEdges` (which emits EXTENDS from `@reference.inherits`
|
||||
* sites) and BEFORE `buildMro` (which reads the graph for EXTENDS +
|
||||
* IMPLEMENTS). Languages whose heritage declarations are syntactic method
|
||||
* calls rather than grammar-level heritage clauses (e.g., Ruby
|
||||
* `include`/`extend`/`prepend`) use this hook to emit IMPLEMENTS edges
|
||||
* from parsed import or reference data.
|
||||
*
|
||||
* Receives the graph (writable), parsedFiles, and nodeLookup — same
|
||||
* surface as `buildMro`. Must be idempotent (the orchestrator may call
|
||||
* it more than once during re-resolution).
|
||||
*
|
||||
* Default: undefined (no extra heritage edges needed).
|
||||
*/
|
||||
readonly emitHeritageEdges?: (
|
||||
graph: KnowledgeGraph,
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
nodeLookup: GraphNodeLookup,
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* Mutate `parsed.localDefs[i].ownerId` to point at the structural
|
||||
* owner. Python's rule: methods (Function defs whose parent scope
|
||||
|
|
|
|||
|
|
@ -30,9 +30,14 @@ import {
|
|||
} from '../scope/walkers.js';
|
||||
|
||||
/** Max depth for compound-receiver chain resolution (`a().b().c().d()`).
|
||||
* Practical code rarely exceeds 3-4 hops; the cap prevents
|
||||
* pathological recursion if the receiver text is malformed. */
|
||||
const COMPOUND_RECEIVER_MAX_DEPTH = 4;
|
||||
* Practical code rarely exceeds 3-4 _syntactic_ hops, but languages
|
||||
* with type-binding-mediated chains (Ruby's `x = obj.method()` binds
|
||||
* `x → obj.method()` and recurses through the compound resolver) can
|
||||
* triple the depth count because each intermediate step contributes
|
||||
* two recursions (bare-ident → compound rawName → call-expr parse).
|
||||
* 8 covers 3-level chains with headroom while still capping
|
||||
* pathological recursion. */
|
||||
const COMPOUND_RECEIVER_MAX_DEPTH = 8;
|
||||
|
||||
const MAP_TUPLE_SENTINEL_RE = /^__MAP_TUPLE_(\d+)__:(.+)$/;
|
||||
|
||||
|
|
@ -144,6 +149,23 @@ export function resolveCompoundReceiverClass(
|
|||
);
|
||||
if (callAlias !== undefined) return callAlias;
|
||||
}
|
||||
|
||||
// Compound member-call alias: rawName has both `.` and `()`
|
||||
// (`user = Factory.get_user()` → rawName `Factory.get_user()`).
|
||||
// Recurse into the compound resolver with the raw compound
|
||||
// expression so the mixed-chain parser can split at top-level
|
||||
// `.` and resolve the receiver + method return type.
|
||||
if (tb.rawName.includes('.') && tb.rawName.includes('(')) {
|
||||
const compound = resolveCompoundReceiverClass(
|
||||
tb.rawName,
|
||||
inScope,
|
||||
scopes,
|
||||
index,
|
||||
options,
|
||||
depth + 1,
|
||||
);
|
||||
if (compound !== undefined) return compound;
|
||||
}
|
||||
}
|
||||
return findClassBindingInScope(inScope, text, scopes);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -620,15 +620,21 @@ export function emitReceiverBoundCalls(
|
|||
}
|
||||
|
||||
// ── Case 3b: chain-typebinding (`city → user.get_city`) ──────
|
||||
// Also handles compound member-call rawNames (`city → addr.get_city()`)
|
||||
// where the rawName includes both `.` and `()` — Ruby's
|
||||
// member-call-return captures produce this shape.
|
||||
const chainHead =
|
||||
typeRef !== undefined && typeRef.rawName.includes('.') && !typeRef.rawName.includes('(')
|
||||
typeRef !== undefined && typeRef.rawName.includes('.')
|
||||
? (typeRef.rawName.split('.', 1)[0] ?? '')
|
||||
: undefined;
|
||||
if (typeRef !== undefined && chainHead !== undefined && !namespaceTargets.has(chainHead)) {
|
||||
// Try the plain dotted-field walk first — covers property /
|
||||
// collection-accessor shapes (`.Values`, Kotlin `.size`) and
|
||||
// field chains. Fall back to call-form (`x()`) which treats
|
||||
// the last segment as a method invocation.
|
||||
// the last segment as a method invocation. For rawNames that
|
||||
// already contain `()` (Ruby member-call-return captures),
|
||||
// pass through directly — the compound resolver handles the
|
||||
// full expression including the call syntax.
|
||||
let ownerDef = resolveCompoundReceiverClass(
|
||||
typeRef.rawName,
|
||||
typeRef.declaredAtScope,
|
||||
|
|
@ -636,7 +642,7 @@ export function emitReceiverBoundCalls(
|
|||
index,
|
||||
compoundOpts,
|
||||
);
|
||||
if (ownerDef === undefined) {
|
||||
if (ownerDef === undefined && !typeRef.rawName.includes('(')) {
|
||||
ownerDef = resolveCompoundReceiverClass(
|
||||
typeRef.rawName + '()',
|
||||
typeRef.declaredAtScope,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ import { phpScopeResolver } from '../../languages/php/scope-resolver.js';
|
|||
import { rustScopeResolver } from '../../languages/rust/scope-resolver.js';
|
||||
import { javascriptScopeResolver } from '../../languages/javascript/scope-resolver.js';
|
||||
import { kotlinScopeResolver } from '../../languages/kotlin/scope-resolver.js';
|
||||
import { rubyScopeResolver } from '../../languages/ruby/scope-resolver.js';
|
||||
import { cobolScopeResolver } from '../../languages/cobol/scope-resolver.js';
|
||||
|
||||
/** Map of `SupportedLanguages` → `ScopeResolver`. The phase iterates
|
||||
* this map intersected with `MIGRATED_LANGUAGES` (the per-language
|
||||
|
|
@ -42,4 +44,6 @@ export const SCOPE_RESOLVERS: ReadonlyMap<SupportedLanguages, ScopeResolver> = n
|
|||
[SupportedLanguages.Rust, rustScopeResolver],
|
||||
[SupportedLanguages.JavaScript, javascriptScopeResolver],
|
||||
[SupportedLanguages.Kotlin, kotlinScopeResolver],
|
||||
[SupportedLanguages.Ruby, rubyScopeResolver],
|
||||
[SupportedLanguages.Cobol, cobolScopeResolver],
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -308,8 +308,26 @@ export function runScopeResolution(
|
|||
},
|
||||
});
|
||||
const preEmittedInheritanceSites = preEmitInheritanceEdges(graph, finalized, nodeLookup);
|
||||
const mroByClassDefId = provider.buildMro(graph, parsedFiles, nodeLookup);
|
||||
const extendsOnlyMroByClassDefId = provider.buildExtendsOnlyMro?.(graph, parsedFiles, nodeLookup);
|
||||
// Call-based heritage hook (e.g., Ruby include/extend/prepend) — emits
|
||||
// IMPLEMENTS edges that `preEmitInheritanceEdges` cannot produce because
|
||||
// the heritage declarations are syntactic method calls, not grammar-level
|
||||
// heritage clauses. Must run BEFORE `buildMro` so MRO construction sees
|
||||
// the freshly-emitted IMPLEMENTS edges.
|
||||
provider.emitHeritageEdges?.(graph, parsedFiles, nodeLookup);
|
||||
// Rebuild the node lookup after heritage-edge emission. Languages like
|
||||
// Ruby create Property graph nodes inside `emitHeritageEdges`; those
|
||||
// nodes must be visible to downstream passes (`emitReceiverBoundCalls`
|
||||
// resolves write-access targets via `resolveDefGraphId` which consults
|
||||
// `nodeLookup`). Without this rebuild, Property nodes added by the
|
||||
// heritage hook are invisible and ACCESSES edges silently fail to emit.
|
||||
const postHeritageNodeLookup =
|
||||
provider.emitHeritageEdges !== undefined ? buildGraphNodeLookup(graph) : nodeLookup;
|
||||
const mroByClassDefId = provider.buildMro(graph, parsedFiles, postHeritageNodeLookup);
|
||||
const extendsOnlyMroByClassDefId = provider.buildExtendsOnlyMro?.(
|
||||
graph,
|
||||
parsedFiles,
|
||||
postHeritageNodeLookup,
|
||||
);
|
||||
|
||||
// Replace the empty MethodDispatchIndex that finalizeScopeModel
|
||||
// builds by design with the populated one derived from the
|
||||
|
|
@ -395,7 +413,7 @@ export function runScopeResolution(
|
|||
graph,
|
||||
indexes,
|
||||
parsedFiles,
|
||||
nodeLookup,
|
||||
postHeritageNodeLookup,
|
||||
handledSites,
|
||||
provider,
|
||||
workspaceIndex,
|
||||
|
|
@ -410,7 +428,7 @@ export function runScopeResolution(
|
|||
graph,
|
||||
indexes,
|
||||
parsedFiles,
|
||||
nodeLookup,
|
||||
postHeritageNodeLookup,
|
||||
handledSites,
|
||||
readonlyModel,
|
||||
)
|
||||
|
|
@ -419,7 +437,7 @@ export function runScopeResolution(
|
|||
graph,
|
||||
indexes,
|
||||
parsedFiles,
|
||||
nodeLookup,
|
||||
postHeritageNodeLookup,
|
||||
referenceIndex,
|
||||
handledSites,
|
||||
readonlyModel,
|
||||
|
|
@ -438,7 +456,7 @@ export function runScopeResolution(
|
|||
graph,
|
||||
indexes,
|
||||
referenceIndex,
|
||||
nodeLookup,
|
||||
postHeritageNodeLookup,
|
||||
handledSites,
|
||||
);
|
||||
const importsEmitted = emitImportEdges(
|
||||
|
|
|
|||
|
|
@ -241,6 +241,12 @@ export const TYPESCRIPT_QUERIES = `
|
|||
[(string (string_fragment) @route.url)
|
||||
(template_string) @route.template_url])) @route.fetch
|
||||
|
||||
; Custom fetch wrappers: apiFetch('/path'), fetchJSON('/api/data'), httpGet('/users'), etc.
|
||||
(call_expression
|
||||
function: (identifier) @_wrapper_fn (#match? @_wrapper_fn "^(api(Fetch|Get|Post|Put|Delete|Patch|Request)|fetch(API|JSON|Data|Endpoint|Resource|Url)|http(Fetch|Get|Post|Put|Delete|Patch|Request))$")
|
||||
arguments: (arguments
|
||||
(string (string_fragment) @route.url))) @route.fetch
|
||||
|
||||
; axios.get/post/put/delete/patch('/path'), $.get/post/ajax({url:'/path'})
|
||||
(call_expression
|
||||
function: (member_expression
|
||||
|
|
@ -434,6 +440,12 @@ export const JAVASCRIPT_QUERIES = `
|
|||
[(string (string_fragment) @route.url)
|
||||
(template_string) @route.template_url])) @route.fetch
|
||||
|
||||
; Custom fetch wrappers: apiFetch('/path'), fetchJSON('/api/data'), httpGet('/users'), etc.
|
||||
(call_expression
|
||||
function: (identifier) @_wrapper_fn (#match? @_wrapper_fn "^(api(Fetch|Get|Post|Put|Delete|Patch|Request)|fetch(API|JSON|Data|Endpoint|Resource|Url)|http(Fetch|Get|Post|Put|Delete|Patch|Request))$")
|
||||
arguments: (arguments
|
||||
(string (string_fragment) @route.url))) @route.fetch
|
||||
|
||||
; axios.get/post, $.get/post/ajax
|
||||
(call_expression
|
||||
function: (member_expression
|
||||
|
|
|
|||
|
|
@ -198,6 +198,11 @@ export interface ExtractedFetchCall {
|
|||
lineNumber: number;
|
||||
}
|
||||
|
||||
export interface FetchWrapperDef {
|
||||
filePath: string;
|
||||
functionName: string;
|
||||
}
|
||||
|
||||
export interface ExtractedDecoratorRoute {
|
||||
filePath: string;
|
||||
routePath: string;
|
||||
|
|
@ -268,6 +273,7 @@ export interface ParseWorkerResult {
|
|||
heritage: ExtractedHeritage[];
|
||||
routes: ExtractedRoute[];
|
||||
fetchCalls: ExtractedFetchCall[];
|
||||
fetchWrapperDefs: FetchWrapperDef[];
|
||||
decoratorRoutes: ExtractedDecoratorRoute[];
|
||||
toolDefs: ExtractedToolDef[];
|
||||
ormQueries: ExtractedORMQuery[];
|
||||
|
|
@ -732,6 +738,7 @@ const processBatch = (
|
|||
heritage: [],
|
||||
routes: [],
|
||||
fetchCalls: [],
|
||||
fetchWrapperDefs: [],
|
||||
decoratorRoutes: [],
|
||||
toolDefs: [],
|
||||
ormQueries: [],
|
||||
|
|
@ -842,6 +849,23 @@ const EXPRESS_ROUTE_METHODS = new Set([
|
|||
'route',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Walk a tree-sitter AST subtree looking for a call to the global `fetch()` function.
|
||||
* Returns `true` if found within `maxDepth` levels of nesting — keeps the check
|
||||
* lightweight so it doesn't slow down parse-worker on large function bodies.
|
||||
*/
|
||||
const checkForFetchCall = (node: SyntaxNode, depth = 0, maxDepth = 5): boolean => {
|
||||
if (depth > maxDepth) return false;
|
||||
if (node.type === 'call_expression') {
|
||||
const fn = node.childForFieldName('function');
|
||||
if (fn?.type === 'identifier' && fn.text === 'fetch') return true;
|
||||
}
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
if (checkForFetchCall(node.child(i)!, depth + 1, maxDepth)) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// HTTP client methods that are ONLY used by clients, not Express route registration.
|
||||
// Methods like get/post/put/delete/patch overlap with Express — those are captured by
|
||||
// the express_route handler as route definitions, not consumers. The fetch() global
|
||||
|
|
@ -1944,6 +1968,21 @@ const processFileGroup = (
|
|||
: '',
|
||||
});
|
||||
}
|
||||
|
||||
// ── Fetch wrapper detection: record functions that call fetch() internally ──
|
||||
if (
|
||||
nodeLabel === 'Function' &&
|
||||
definitionNode &&
|
||||
nameNode &&
|
||||
(language === SupportedLanguages.TypeScript || language === SupportedLanguages.JavaScript)
|
||||
) {
|
||||
if (checkForFetchCall(definitionNode)) {
|
||||
result.fetchWrapperDefs.push({
|
||||
filePath: file.path,
|
||||
functionName: nameNode.text,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract framework routes via provider detection (e.g., Laravel routes.php)
|
||||
|
|
@ -1985,6 +2024,7 @@ let accumulated: ParseWorkerResult = {
|
|||
heritage: [],
|
||||
routes: [],
|
||||
fetchCalls: [],
|
||||
fetchWrapperDefs: [],
|
||||
decoratorRoutes: [],
|
||||
toolDefs: [],
|
||||
ormQueries: [],
|
||||
|
|
@ -2013,6 +2053,7 @@ const mergeResult = (target: ParseWorkerResult, src: ParseWorkerResult) => {
|
|||
appendAll(target.heritage, src.heritage);
|
||||
appendAll(target.routes, src.routes);
|
||||
appendAll(target.fetchCalls, src.fetchCalls);
|
||||
appendAll(target.fetchWrapperDefs, src.fetchWrapperDefs);
|
||||
appendAll(target.decoratorRoutes, src.decoratorRoutes);
|
||||
appendAll(target.toolDefs, src.toolDefs);
|
||||
appendAll(target.ormQueries, src.ormQueries);
|
||||
|
|
@ -2104,6 +2145,7 @@ parentPort!.on('message', (msg: WorkerIncomingMessage) => {
|
|||
heritage: [],
|
||||
routes: [],
|
||||
fetchCalls: [],
|
||||
fetchWrapperDefs: [],
|
||||
decoratorRoutes: [],
|
||||
toolDefs: [],
|
||||
ormQueries: [],
|
||||
|
|
|
|||
|
|
@ -598,6 +598,14 @@ export const createWorkerPool = (
|
|||
const poolOptions = resolveWorkerPoolOptions(options, size);
|
||||
const spawnWorker = options?.workerFactory ?? ((url: URL) => new Worker(url));
|
||||
const workers: (Worker | undefined)[] = new Array(size);
|
||||
type RetiredWorkerRecord = {
|
||||
worker: Worker;
|
||||
workerIndex: number;
|
||||
reason: string;
|
||||
cleanup: () => void;
|
||||
terminate: () => Promise<void>;
|
||||
};
|
||||
const retiredWorkers = new Set<RetiredWorkerRecord>();
|
||||
const respawnCount: number[] = new Array(size).fill(0);
|
||||
const activeSlots: Set<number> = new Set();
|
||||
// Layer 3 (quarantine): tracked via the dedicated `quarantine.ts`
|
||||
|
|
@ -625,6 +633,17 @@ export const createWorkerPool = (
|
|||
let poolBroken = false;
|
||||
let poolFailure: Error | undefined;
|
||||
|
||||
const terminateTrackedWorkers = async (
|
||||
liveWorkers: readonly (Worker | undefined)[],
|
||||
): Promise<void> => {
|
||||
const retired = Array.from(retiredWorkers);
|
||||
await Promise.all([
|
||||
...liveWorkers.map((worker) => worker?.terminate().catch(() => undefined)),
|
||||
...retired.map((record) => record.terminate()),
|
||||
]);
|
||||
retiredWorkers.clear();
|
||||
};
|
||||
|
||||
for (let i = 0; i < size; i++) {
|
||||
workers[i] = spawnWorker(workerUrl);
|
||||
activeSlots.add(i);
|
||||
|
|
@ -755,10 +774,88 @@ export const createWorkerPool = (
|
|||
onProgress(next);
|
||||
};
|
||||
|
||||
const replaceWorker = async (workerIndex: number): Promise<boolean> => {
|
||||
type WorkerRemovalMode = 'terminate' | 'retire';
|
||||
|
||||
const retireWorkerAfterTimeout = (
|
||||
worker: Worker,
|
||||
workerIndex: number,
|
||||
reason: string,
|
||||
): void => {
|
||||
let cleaned = false;
|
||||
let terminateStarted = false;
|
||||
|
||||
function cleanupRetired() {
|
||||
if (cleaned) return;
|
||||
cleaned = true;
|
||||
worker.removeListener('message', onRetiredMessage);
|
||||
worker.removeListener('error', onRetiredError);
|
||||
worker.removeListener('exit', onRetiredExit);
|
||||
worker.removeListener('messageerror', onRetiredMessageError);
|
||||
retiredWorkers.delete(record);
|
||||
}
|
||||
|
||||
async function terminateRetired() {
|
||||
if (terminateStarted) return;
|
||||
terminateStarted = true;
|
||||
cleanupRetired();
|
||||
await worker.terminate().catch(() => undefined);
|
||||
}
|
||||
|
||||
function terminateWhenBackInJs() {
|
||||
void terminateRetired();
|
||||
}
|
||||
|
||||
function onRetiredMessage(raw: unknown) {
|
||||
if (raw === null || typeof raw !== 'object') return;
|
||||
const type = (raw as { type?: unknown }).type;
|
||||
if (type === 'sub-batch-done' || type === 'result' || type === 'error') {
|
||||
terminateWhenBackInJs();
|
||||
}
|
||||
}
|
||||
|
||||
const onRetiredError = () => cleanupRetired();
|
||||
const onRetiredExit = () => cleanupRetired();
|
||||
const onRetiredMessageError = () => terminateWhenBackInJs();
|
||||
const record: RetiredWorkerRecord = {
|
||||
worker,
|
||||
workerIndex,
|
||||
reason,
|
||||
cleanup: cleanupRetired,
|
||||
terminate: terminateRetired,
|
||||
};
|
||||
retiredWorkers.add(record);
|
||||
worker.on('message', onRetiredMessage);
|
||||
worker.once('error', onRetiredError);
|
||||
worker.once('exit', onRetiredExit);
|
||||
worker.once('messageerror', onRetiredMessageError);
|
||||
(worker as Worker & { unref?: () => void }).unref?.();
|
||||
logger.warn(
|
||||
{ workerIndex, reason },
|
||||
`Worker ${workerIndex} timed out; retiring without immediate terminate to avoid aborting native parser state.`,
|
||||
);
|
||||
};
|
||||
|
||||
const removeWorkerFromSlot = async (
|
||||
workerIndex: number,
|
||||
mode: WorkerRemovalMode,
|
||||
reason: string,
|
||||
): Promise<void> => {
|
||||
const existing = workers[workerIndex];
|
||||
await existing?.terminate().catch(() => undefined);
|
||||
workers[workerIndex] = undefined;
|
||||
if (!existing) return;
|
||||
if (mode === 'retire') {
|
||||
retireWorkerAfterTimeout(existing, workerIndex, reason);
|
||||
return;
|
||||
}
|
||||
await existing.terminate().catch(() => undefined);
|
||||
};
|
||||
|
||||
const replaceWorker = async (
|
||||
workerIndex: number,
|
||||
mode: WorkerRemovalMode = 'terminate',
|
||||
reason = 'replacing worker',
|
||||
): Promise<boolean> => {
|
||||
await removeWorkerFromSlot(workerIndex, mode, reason);
|
||||
if (stopped) return false;
|
||||
const replacement = spawnWorker(workerUrl);
|
||||
try {
|
||||
|
|
@ -803,7 +900,7 @@ export const createWorkerPool = (
|
|||
const liveWorkers = workers.slice();
|
||||
for (let i = 0; i < workers.length; i++) workers[i] = undefined;
|
||||
activeSlots.clear();
|
||||
void Promise.all(liveWorkers.map((worker) => worker?.terminate().catch(() => undefined)));
|
||||
void terminateTrackedWorkers(liveWorkers);
|
||||
};
|
||||
|
||||
const maybeDone = () => {
|
||||
|
|
@ -893,6 +990,7 @@ export const createWorkerPool = (
|
|||
workerIndex: number,
|
||||
reason: string,
|
||||
excludePaths: readonly string[],
|
||||
removalMode: WorkerRemovalMode = 'terminate',
|
||||
) => {
|
||||
if (stopped) return;
|
||||
consecutiveFailuresPerSlot[workerIndex]++;
|
||||
|
|
@ -921,9 +1019,7 @@ export const createWorkerPool = (
|
|||
},
|
||||
`Worker ${workerIndex} exceeded respawn budget; dropping slot.`,
|
||||
);
|
||||
const dead = workers[workerIndex];
|
||||
await dead?.terminate().catch(() => undefined);
|
||||
workers[workerIndex] = undefined;
|
||||
await removeWorkerFromSlot(workerIndex, removalMode, reason);
|
||||
activeSlots.delete(workerIndex);
|
||||
if (activeSlots.size === 0) {
|
||||
tripBreaker(
|
||||
|
|
@ -945,7 +1041,7 @@ export const createWorkerPool = (
|
|||
},
|
||||
`Worker ${workerIndex} died; respawning slot (attempt ${respawnCount[workerIndex]}/${poolOptions.maxRespawnsPerSlot}).`,
|
||||
);
|
||||
const respawned = await replaceWorker(workerIndex);
|
||||
const respawned = await replaceWorker(workerIndex, removalMode, reason);
|
||||
if (!respawned) {
|
||||
activeSlots.delete(workerIndex);
|
||||
if (activeSlots.size === 0) {
|
||||
|
|
@ -1211,7 +1307,12 @@ export const createWorkerPool = (
|
|||
activeWorkers--;
|
||||
busySlots.delete(workerIndex);
|
||||
requeueRemainder(job, decision.excludePaths);
|
||||
await handleWorkerDeath(workerIndex, decision.reason, decision.excludePaths);
|
||||
await handleWorkerDeath(
|
||||
workerIndex,
|
||||
decision.reason,
|
||||
decision.excludePaths,
|
||||
'retire',
|
||||
);
|
||||
if (stopped) return;
|
||||
if (activeSlots.has(workerIndex)) runWorker(workerIndex);
|
||||
wakeIdleSlots();
|
||||
|
|
@ -1255,9 +1356,11 @@ export const createWorkerPool = (
|
|||
},
|
||||
`Worker ${workerIndex} hit consecutive-failure threshold on idle-timeout retry; tripping circuit breaker.`,
|
||||
);
|
||||
const dead = workers[workerIndex];
|
||||
await dead?.terminate().catch(() => undefined);
|
||||
workers[workerIndex] = undefined;
|
||||
await removeWorkerFromSlot(
|
||||
workerIndex,
|
||||
'retire',
|
||||
'idle-timeout retry consecutive-failure threshold',
|
||||
);
|
||||
activeSlots.delete(workerIndex);
|
||||
tripBreaker(
|
||||
new WorkerPoolDispatchError(
|
||||
|
|
@ -1278,12 +1381,18 @@ export const createWorkerPool = (
|
|||
},
|
||||
`Worker ${workerIndex} exceeded respawn budget during idle-timeout retry; dropping slot.`,
|
||||
);
|
||||
const dead = workers[workerIndex];
|
||||
await dead?.terminate().catch(() => undefined);
|
||||
workers[workerIndex] = undefined;
|
||||
await removeWorkerFromSlot(
|
||||
workerIndex,
|
||||
'retire',
|
||||
'idle-timeout retry respawn budget exhausted',
|
||||
);
|
||||
activeSlots.delete(workerIndex);
|
||||
} else {
|
||||
const respawned = await replaceWorker(workerIndex);
|
||||
const respawned = await replaceWorker(
|
||||
workerIndex,
|
||||
'retire',
|
||||
'idle-timeout retry',
|
||||
);
|
||||
if (!respawned) {
|
||||
activeSlots.delete(workerIndex);
|
||||
}
|
||||
|
|
@ -1482,7 +1591,7 @@ export const createWorkerPool = (
|
|||
// exception when this is called from `runChunkedParseAndResolve`'s
|
||||
// finally block — masking the real failure and leaving `workers[]`
|
||||
// populated with dead references because the lines below never run.
|
||||
await Promise.all(workers.map((w) => w?.terminate().catch(() => undefined)));
|
||||
await terminateTrackedWorkers(workers);
|
||||
workers.length = 0;
|
||||
activeSlots.clear();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { createInterface } from 'readline';
|
|||
import { once } from 'events';
|
||||
import { finished } from 'stream/promises';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import crypto from 'crypto';
|
||||
import lbug from '@ladybugdb/core';
|
||||
import { KnowledgeGraph } from '../graph/types.js';
|
||||
import {
|
||||
|
|
@ -23,6 +25,7 @@ import {
|
|||
isOpenRetryExhausted,
|
||||
isWalCorruptionError,
|
||||
openLbugConnection,
|
||||
toNativeSafePath,
|
||||
WAL_RECOVERY_SUGGESTION,
|
||||
waitForWindowsHandleRelease,
|
||||
type LbugConnectionHandle,
|
||||
|
|
@ -386,7 +389,8 @@ const runWithSessionLock = async <T>(operation: () => Promise<T>): Promise<T> =>
|
|||
}
|
||||
};
|
||||
|
||||
const normalizeCopyPath = (filePath: string): string => filePath.replace(/\\/g, '/');
|
||||
const normalizeCopyPath = (filePath: string): string =>
|
||||
toNativeSafePath(filePath).replace(/\\/g, '/');
|
||||
|
||||
const closeQueryResult = async (result: lbug.QueryResult): Promise<void> => {
|
||||
try {
|
||||
|
|
@ -856,7 +860,13 @@ export const loadGraphToLbug = async (
|
|||
|
||||
const log = onProgress || (() => {});
|
||||
|
||||
const csvDir = path.join(storagePath, 'csv');
|
||||
let csvDir: string;
|
||||
if (process.platform === 'win32' && /[^\x00-\x7F]/.test(storagePath)) {
|
||||
const hash = crypto.createHash('sha256').update(storagePath).digest('hex').slice(0, 16);
|
||||
csvDir = toNativeSafePath(path.join(os.tmpdir(), `gitnexus-csv-${hash}`));
|
||||
} else {
|
||||
csvDir = path.join(storagePath, 'csv');
|
||||
}
|
||||
|
||||
log('Streaming CSVs to disk...');
|
||||
const csvResult = await streamAllCSVsToDisk(graph, repoPath, csvDir);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,194 @@
|
|||
import fs from 'fs/promises';
|
||||
import fsSync from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import crypto from 'crypto';
|
||||
import { execFileSync } from 'child_process';
|
||||
import { isMainThread } from 'worker_threads';
|
||||
import type lbug from '@ladybugdb/core';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
// ─── Windows non-ASCII path workaround (#1811) ───────────────────────────────
|
||||
//
|
||||
// KuzuDB's native C++ layer on Windows uses CreateFileA (ANSI), not
|
||||
// CreateFileW. Non-ASCII path bytes from Node.js (UTF-8) are
|
||||
// misinterpreted via the system's Active Code Page (e.g. GBK), producing
|
||||
// a garbled path — "Error 3: The system cannot find the path."
|
||||
//
|
||||
// Layered workaround:
|
||||
// 1. Try 8.3 short-name form (fast, no persistent state)
|
||||
// 2. Fall back to an NTFS junction from an ASCII temp path
|
||||
// 3. If both fail, log a diagnostic and return the original path
|
||||
|
||||
const NON_ASCII_RE = /[^\x00-\x7F]/;
|
||||
const JUNCTION_PREFIX = 'gitnexus-junction-';
|
||||
|
||||
const activeJunctions = new Set<string>();
|
||||
let cleanupRegistered = false;
|
||||
let orphanScanDone = false;
|
||||
|
||||
function junctionHash(targetDir: string): string {
|
||||
return crypto.createHash('sha256').update(targetDir).digest('hex').slice(0, 16);
|
||||
}
|
||||
|
||||
function tryShortPath(p: string): string | null {
|
||||
try {
|
||||
// Pass the path via environment variable so the command string is
|
||||
// static — avoids CodeQL command-injection taint (the path never
|
||||
// appears in the shell command text).
|
||||
const result = execFileSync('cmd.exe', ['/c', 'for %I in ("%GITNEXUS_SP%") do @echo %~sI'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
windowsHide: true,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env, GITNEXUS_SP: p },
|
||||
});
|
||||
const shortPath = result.trim();
|
||||
if (
|
||||
shortPath &&
|
||||
!NON_ASCII_RE.test(shortPath) &&
|
||||
(!shortPath.includes('?') || p.includes('?'))
|
||||
) {
|
||||
return shortPath;
|
||||
}
|
||||
} catch {
|
||||
// 8.3 unavailable or cmd failed
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function tryJunction(targetDir: string, leaf: string): string | null {
|
||||
const hash = junctionHash(targetDir);
|
||||
const junctionLink = path.join(os.tmpdir(), `${JUNCTION_PREFIX}${hash}`);
|
||||
|
||||
if (fsSync.existsSync(junctionLink)) {
|
||||
try {
|
||||
const existing = fsSync.readlinkSync(junctionLink);
|
||||
if (path.resolve(existing) === path.resolve(targetDir)) {
|
||||
activeJunctions.add(junctionLink);
|
||||
return path.join(junctionLink, leaf);
|
||||
}
|
||||
fsSync.rmSync(junctionLink, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Stale or broken junction — remove and recreate
|
||||
try {
|
||||
fsSync.rmSync(junctionLink, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
fsSync.symlinkSync(targetDir, junctionLink, 'junction');
|
||||
activeJunctions.add(junctionLink);
|
||||
return path.join(junctionLink, leaf);
|
||||
} catch (err: any) {
|
||||
if (err.code === 'EEXIST') {
|
||||
try {
|
||||
const existing = fsSync.readlinkSync(junctionLink);
|
||||
if (path.resolve(existing) === path.resolve(targetDir)) {
|
||||
activeJunctions.add(junctionLink);
|
||||
return path.join(junctionLink, leaf);
|
||||
}
|
||||
} catch {
|
||||
/* cannot verify — fall through */
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function registerCleanupHandlers(): void {
|
||||
if (cleanupRegistered) return;
|
||||
cleanupRegistered = true;
|
||||
|
||||
process.on('exit', () => cleanupNativePathJunctions());
|
||||
|
||||
for (const signal of ['SIGTERM', 'SIGINT'] as const) {
|
||||
process.on(signal, () => {
|
||||
cleanupNativePathJunctions();
|
||||
if (process.platform === 'win32') {
|
||||
process.exit(signal === 'SIGINT' ? 130 : 143);
|
||||
} else {
|
||||
process.kill(process.pid, signal);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function scanOrphanedJunctions(): void {
|
||||
if (orphanScanDone) return;
|
||||
orphanScanDone = true;
|
||||
try {
|
||||
const tmpdir = os.tmpdir();
|
||||
const entries = fsSync.readdirSync(tmpdir);
|
||||
for (const entry of entries) {
|
||||
if (!entry.startsWith(JUNCTION_PREFIX)) continue;
|
||||
const junctionPath = path.join(tmpdir, entry);
|
||||
try {
|
||||
const target = fsSync.readlinkSync(junctionPath);
|
||||
try {
|
||||
fsSync.lstatSync(target);
|
||||
} catch {
|
||||
fsSync.rmSync(junctionPath, { recursive: true, force: true });
|
||||
}
|
||||
} catch {
|
||||
// Not a symlink/junction or unreadable — leave it
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// tmpdir unreadable — skip scan
|
||||
}
|
||||
}
|
||||
|
||||
export function cleanupNativePathJunctions(): void {
|
||||
for (const junctionPath of activeJunctions) {
|
||||
try {
|
||||
fsSync.rmSync(junctionPath, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Best effort — EPERM on Windows is common during exit
|
||||
}
|
||||
}
|
||||
activeJunctions.clear();
|
||||
}
|
||||
|
||||
export function toNativeSafePath(p: string): string {
|
||||
if (process.platform !== 'win32') return p;
|
||||
if (!NON_ASCII_RE.test(p)) return p;
|
||||
|
||||
if (isMainThread) {
|
||||
scanOrphanedJunctions();
|
||||
registerCleanupHandlers();
|
||||
}
|
||||
|
||||
const shortPath = tryShortPath(p);
|
||||
if (shortPath) return shortPath;
|
||||
|
||||
if (!isMainThread) {
|
||||
logger.warn(
|
||||
`GitNexus: non-ASCII path in worker thread — junction fallback skipped. ` +
|
||||
`Path: "${p}". 8.3 short names may need to be enabled on this volume.`,
|
||||
);
|
||||
return p;
|
||||
}
|
||||
|
||||
const targetDir = path.dirname(p);
|
||||
const leaf = path.basename(p);
|
||||
if (fsSync.existsSync(targetDir)) {
|
||||
const junctionResult = tryJunction(targetDir, leaf);
|
||||
if (junctionResult) return junctionResult;
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
`GitNexus: non-ASCII path "${p}" could not be converted to an ASCII-safe form. ` +
|
||||
'LadybugDB may fail with "Cannot open file." To fix: move the repo to a path ' +
|
||||
'without CJK/Unicode characters, or enable 8.3 short names on this volume ' +
|
||||
'(fsutil 8dot3name set 0).',
|
||||
);
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared configuration for `@ladybugdb/core` `Database` construction.
|
||||
*
|
||||
|
|
@ -351,12 +536,10 @@ export async function openLbugConnection(
|
|||
databasePath: string,
|
||||
options: LbugDatabaseOptions = {},
|
||||
): Promise<LbugConnectionHandle> {
|
||||
const safePath = toNativeSafePath(databasePath);
|
||||
let db: lbug.Database | undefined;
|
||||
try {
|
||||
db = await openWithLockRetry(
|
||||
() => createLbugDatabase(lbugModule, databasePath, options),
|
||||
databasePath,
|
||||
);
|
||||
db = await openWithLockRetry(() => createLbugDatabase(lbugModule, safePath, options), safePath);
|
||||
return { db, conn: new lbugModule.Connection(db) };
|
||||
} catch (err) {
|
||||
if (db) await db.close().catch(() => {});
|
||||
|
|
|
|||
79
gitnexus/src/core/lbug/native-check.ts
Normal file
79
gitnexus/src/core/lbug/native-check.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
export interface NativeCheckResult {
|
||||
ok: boolean;
|
||||
binaryPath?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export function checkLbugNative(overridePkgDir?: string): NativeCheckResult {
|
||||
let pkgDir: string;
|
||||
|
||||
if (overridePkgDir) {
|
||||
pkgDir = overridePkgDir;
|
||||
} else {
|
||||
try {
|
||||
const _require = createRequire(import.meta.url);
|
||||
const mainEntry = _require.resolve('@ladybugdb/core');
|
||||
pkgDir = path.dirname(mainEntry);
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
message: [
|
||||
'LadybugDB package (@ladybugdb/core) is not installed.',
|
||||
'',
|
||||
'Run: npm install',
|
||||
].join('\n'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const binaryPath = path.join(pkgDir, 'lbugjs.node');
|
||||
if (!fs.existsSync(binaryPath)) {
|
||||
return {
|
||||
ok: false,
|
||||
binaryPath,
|
||||
message: [
|
||||
'LadybugDB native binary (lbugjs.node) is missing.',
|
||||
'',
|
||||
'This usually happens when the install lifecycle script was skipped.',
|
||||
'',
|
||||
'To repair:',
|
||||
` node ${path.join(pkgDir, 'install.js')}`,
|
||||
'',
|
||||
'If using bun, add to package.json and reinstall:',
|
||||
' "trustedDependencies": ["@ladybugdb/core"]',
|
||||
'',
|
||||
'Also check that npm is not configured with ignore-scripts=true',
|
||||
'(in .npmrc or via --ignore-scripts).',
|
||||
].join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const _require = createRequire(import.meta.url);
|
||||
_require(binaryPath);
|
||||
} catch (err: unknown) {
|
||||
const nativeError = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
ok: false,
|
||||
binaryPath,
|
||||
message: [
|
||||
'LadybugDB native binary (lbugjs.node) exists but failed to load:',
|
||||
` ${nativeError}`,
|
||||
'',
|
||||
'This can happen with a truncated file, ABI mismatch, or wrong-platform binary.',
|
||||
'',
|
||||
'To repair:',
|
||||
` node ${path.join(pkgDir, 'install.js')}`,
|
||||
'',
|
||||
'If using bun, add to package.json and reinstall:',
|
||||
' "trustedDependencies": ["@ladybugdb/core"]',
|
||||
].join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, binaryPath };
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ import { isReadOnlyDbError, loadFTSExtension } from './lbug-adapter.js';
|
|||
import {
|
||||
createLbugDatabase,
|
||||
isWalCorruptionError,
|
||||
toNativeSafePath,
|
||||
WAL_RECOVERY_SUGGESTION,
|
||||
} from './lbug-config.js';
|
||||
import {
|
||||
|
|
@ -390,7 +391,7 @@ async function probeDatabaseForShadowReplay(db: lbug.Database): Promise<void> {
|
|||
async function replayShadowPagesWithWritableOpen(dbPath: string): Promise<void> {
|
||||
let db: lbug.Database | undefined;
|
||||
try {
|
||||
db = createLbugDatabase(lbug, dbPath, { throwOnWalReplayFailure: false });
|
||||
db = createLbugDatabase(lbug, toNativeSafePath(dbPath), { throwOnWalReplayFailure: false });
|
||||
await db.init();
|
||||
await probeDatabaseForShadowReplay(db);
|
||||
} catch (err) {
|
||||
|
|
@ -415,7 +416,7 @@ async function openReadOnlyDatabase(dbPath: string): Promise<lbug.Database> {
|
|||
logger: poolSidecarLogger,
|
||||
allowQuarantine: true,
|
||||
});
|
||||
db = createLbugDatabase(lbug, dbPath, {
|
||||
db = createLbugDatabase(lbug, toNativeSafePath(dbPath), {
|
||||
readOnly: true,
|
||||
throwOnWalReplayFailure: false,
|
||||
});
|
||||
|
|
@ -434,7 +435,7 @@ async function openReadOnlyDatabase(dbPath: string): Promise<lbug.Database> {
|
|||
logger: poolSidecarLogger,
|
||||
allowQuarantine: true,
|
||||
});
|
||||
db = createLbugDatabase(lbug, dbPath, {
|
||||
db = createLbugDatabase(lbug, toNativeSafePath(dbPath), {
|
||||
readOnly: true,
|
||||
throwOnWalReplayFailure: false,
|
||||
});
|
||||
|
|
@ -448,7 +449,7 @@ async function openReadOnlyDatabase(dbPath: string): Promise<lbug.Database> {
|
|||
await db.close().catch(() => {});
|
||||
db = undefined;
|
||||
await replayShadowPagesWithWritableOpen(dbPath);
|
||||
db = createLbugDatabase(lbug, dbPath, {
|
||||
db = createLbugDatabase(lbug, toNativeSafePath(dbPath), {
|
||||
readOnly: true,
|
||||
throwOnWalReplayFailure: false,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ export interface WikiRunResult {
|
|||
// ─── Constants ────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_MAX_TOKENS_PER_MODULE = 30_000;
|
||||
const GROUPING_TOKEN_BUDGET = 100_000;
|
||||
const WIKI_DIR = 'wiki';
|
||||
|
||||
// ─── Generator Class ──────────────────────────────────────────────────
|
||||
|
|
@ -470,15 +471,22 @@ export class WikiGenerator {
|
|||
DIRECTORY_TREE: dirTree,
|
||||
});
|
||||
|
||||
// Grouping is a structured-data phase (JSON output), not documentation.
|
||||
// Do NOT apply buildSystemPrompt here — a language instruction would risk
|
||||
// translating module-name keys, breaking slug stability and JSON parsing.
|
||||
const response = await this.invokeLLM(
|
||||
prompt,
|
||||
GROUPING_SYSTEM_PROMPT,
|
||||
this.streamOpts('Grouping files', 15, 13),
|
||||
);
|
||||
const grouping = this.parseGroupingResponse(response.content, files);
|
||||
const promptTokens = estimateTokens(prompt);
|
||||
let grouping: Record<string, string[]>;
|
||||
|
||||
if (promptTokens <= GROUPING_TOKEN_BUDGET) {
|
||||
// Grouping is a structured-data phase (JSON output), not documentation.
|
||||
// Do NOT apply buildSystemPrompt here — a language instruction would risk
|
||||
// translating module-name keys, breaking slug stability and JSON parsing.
|
||||
const response = await this.invokeLLM(
|
||||
prompt,
|
||||
GROUPING_SYSTEM_PROMPT,
|
||||
this.streamOpts('Grouping files', 15, 13),
|
||||
);
|
||||
grouping = this.parseGroupingResponse(response.content, files);
|
||||
} else {
|
||||
grouping = await this.batchedGrouping(files);
|
||||
}
|
||||
|
||||
// Convert to tree nodes
|
||||
const tree: ModuleTreeNode[] = [];
|
||||
|
|
@ -509,6 +517,202 @@ export class WikiGenerator {
|
|||
return tree;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run grouping in batches when the full file list exceeds GROUPING_TOKEN_BUDGET.
|
||||
*/
|
||||
private async batchedGrouping(files: FileWithExports[]): Promise<Record<string, string[]>> {
|
||||
const batches = this.batchFilesForGrouping(files);
|
||||
const partials: Record<string, string[]>[] = [];
|
||||
|
||||
for (let i = 0; i < batches.length; i++) {
|
||||
const batch = batches[i];
|
||||
this.onProgress(
|
||||
'grouping',
|
||||
15 + Math.round(((i + 1) / batches.length) * 13),
|
||||
`Grouping batch ${i + 1}/${batches.length} (LLM)...`,
|
||||
);
|
||||
|
||||
const batchFileList = formatFileListForGrouping(batch);
|
||||
const batchDirTree = formatDirectoryTree(batch.map((f) => f.filePath));
|
||||
const batchPrompt = fillTemplate(GROUPING_USER_PROMPT, {
|
||||
FILE_LIST: batchFileList,
|
||||
DIRECTORY_TREE: batchDirTree,
|
||||
});
|
||||
|
||||
try {
|
||||
const batchStart = 15 + Math.round((i / batches.length) * 13);
|
||||
const batchRange = Math.max(1, Math.round(13 / batches.length));
|
||||
const response = await this.invokeLLM(
|
||||
batchPrompt,
|
||||
GROUPING_SYSTEM_PROMPT,
|
||||
this.streamOpts(`Grouping batch ${i + 1}/${batches.length}`, batchStart, batchRange),
|
||||
);
|
||||
partials.push(this.parseGroupingResponse(response.content, batch));
|
||||
} catch {
|
||||
this.onProgress(
|
||||
'grouping',
|
||||
15,
|
||||
`Batch ${i + 1} failed, falling back to directory grouping`,
|
||||
);
|
||||
return this.fallbackGrouping(files);
|
||||
}
|
||||
}
|
||||
|
||||
const merged = this.mergeGroupings(partials);
|
||||
|
||||
const assignedFiles = new Set(Object.values(merged).flat());
|
||||
const unassigned = files.map((f) => f.filePath).filter((fp) => !assignedFiles.has(fp));
|
||||
if (unassigned.length > 0) {
|
||||
merged['Other'] = [...(merged['Other'] ?? []), ...unassigned];
|
||||
}
|
||||
|
||||
return Object.keys(merged).length > 0 ? merged : this.fallbackGrouping(files);
|
||||
}
|
||||
|
||||
/**
|
||||
* Partition files into batches that fit within GROUPING_TOKEN_BUDGET.
|
||||
* Groups by top-level directory for semantic coherence.
|
||||
*/
|
||||
private batchFilesForGrouping(files: FileWithExports[]): FileWithExports[][] {
|
||||
if (files.length === 0) return [];
|
||||
|
||||
const dirGroups = new Map<string, FileWithExports[]>();
|
||||
for (const f of files) {
|
||||
const parts = f.filePath.replace(/\\/g, '/').split('/');
|
||||
const topDir = parts.length > 1 ? parts[0] : 'Root';
|
||||
let group = dirGroups.get(topDir);
|
||||
if (!group) {
|
||||
group = [];
|
||||
dirGroups.set(topDir, group);
|
||||
}
|
||||
group.push(f);
|
||||
}
|
||||
|
||||
const batches: FileWithExports[][] = [];
|
||||
let currentBatch: FileWithExports[] = [];
|
||||
|
||||
for (const dirFiles of dirGroups.values()) {
|
||||
const dirPromptSize = this.estimateGroupingPromptTokens(dirFiles);
|
||||
|
||||
if (dirPromptSize > GROUPING_TOKEN_BUDGET) {
|
||||
if (currentBatch.length > 0) {
|
||||
batches.push(currentBatch);
|
||||
currentBatch = [];
|
||||
}
|
||||
// Sub-batch this large directory by fixed chunks
|
||||
for (let i = 0; i < dirFiles.length; ) {
|
||||
const subBatch: FileWithExports[] = [];
|
||||
while (i < dirFiles.length) {
|
||||
subBatch.push(dirFiles[i]);
|
||||
i++;
|
||||
if (
|
||||
this.estimateGroupingPromptTokens(subBatch) > GROUPING_TOKEN_BUDGET &&
|
||||
subBatch.length > 1
|
||||
) {
|
||||
subBatch.pop();
|
||||
i--;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (
|
||||
subBatch.length === 1 &&
|
||||
this.estimateGroupingPromptTokens(subBatch) > GROUPING_TOKEN_BUDGET
|
||||
) {
|
||||
subBatch[0] = this.trimSymbolsToFit(subBatch[0]);
|
||||
}
|
||||
batches.push(subBatch);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidateBatch = [...currentBatch, ...dirFiles];
|
||||
if (this.estimateGroupingPromptTokens(candidateBatch) > GROUPING_TOKEN_BUDGET) {
|
||||
if (currentBatch.length > 0) {
|
||||
batches.push(currentBatch);
|
||||
}
|
||||
currentBatch = dirFiles;
|
||||
} else {
|
||||
currentBatch = candidateBatch;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentBatch.length > 0) {
|
||||
batches.push(currentBatch);
|
||||
}
|
||||
|
||||
return batches;
|
||||
}
|
||||
|
||||
private estimateGroupingPromptTokens(files: FileWithExports[]): number {
|
||||
const fileList = formatFileListForGrouping(files);
|
||||
const dirTree = formatDirectoryTree(files.map((f) => f.filePath));
|
||||
const prompt = fillTemplate(GROUPING_USER_PROMPT, {
|
||||
FILE_LIST: fileList,
|
||||
DIRECTORY_TREE: dirTree,
|
||||
});
|
||||
return estimateTokens(prompt);
|
||||
}
|
||||
|
||||
private trimSymbolsToFit(file: FileWithExports): FileWithExports {
|
||||
const symbols = file.symbols;
|
||||
let lo = 0;
|
||||
let hi = symbols.length;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi + 1) >>> 1;
|
||||
const candidate: FileWithExports = {
|
||||
filePath: file.filePath,
|
||||
symbols: [
|
||||
...symbols.slice(0, mid),
|
||||
{ name: `... and ${symbols.length - mid} more`, type: 'truncated' },
|
||||
],
|
||||
};
|
||||
if (this.estimateGroupingPromptTokens([candidate]) <= GROUPING_TOKEN_BUDGET) {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
if (lo >= symbols.length) return file;
|
||||
return {
|
||||
filePath: file.filePath,
|
||||
symbols:
|
||||
lo > 0
|
||||
? [
|
||||
...symbols.slice(0, lo),
|
||||
{ name: `... and ${symbols.length - lo} more`, type: 'truncated' },
|
||||
]
|
||||
: [{ name: 'no exports (truncated)', type: 'truncated' }],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge partial groupings from multiple batches. Same module name across
|
||||
* batches gets file lists concatenated. Deduplicates (first-seen wins).
|
||||
*/
|
||||
private mergeGroupings(partials: Record<string, string[]>[]): Record<string, string[]> {
|
||||
const merged: Record<string, string[]> = {};
|
||||
const seen = new Set<string>();
|
||||
const slugToCanonical = new Map<string, string>();
|
||||
|
||||
for (const partial of partials) {
|
||||
for (const [mod, paths] of Object.entries(partial)) {
|
||||
const slug = this.slugify(mod);
|
||||
const canonical = slugToCanonical.get(slug) ?? mod;
|
||||
if (!slugToCanonical.has(slug)) slugToCanonical.set(slug, mod);
|
||||
|
||||
for (const fp of paths) {
|
||||
if (!seen.has(fp)) {
|
||||
seen.add(fp);
|
||||
if (!merged[canonical]) merged[canonical] = [];
|
||||
merged[canonical].push(fp);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse LLM grouping response. Validates all files are assigned.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -304,6 +304,21 @@ export function resolveWorktreeCwd(repoPath: string, launchCwd: string): string
|
|||
*/
|
||||
export const REPO_ID_HASH_LENGTH = 6;
|
||||
|
||||
interface ImpactParams {
|
||||
target: string;
|
||||
target_uid?: string;
|
||||
file_path?: string;
|
||||
kind?: string;
|
||||
direction: 'upstream' | 'downstream';
|
||||
maxDepth?: number;
|
||||
relationTypes?: string[];
|
||||
includeTests?: boolean;
|
||||
minConfidence?: number;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
summaryOnly?: boolean;
|
||||
}
|
||||
|
||||
export class LocalBackend {
|
||||
private repos: Map<string, RepoHandle> = new Map();
|
||||
private contextCache: Map<string, CodebaseContext> = new Map();
|
||||
|
|
@ -2750,20 +2765,7 @@ export class LocalBackend {
|
|||
};
|
||||
}
|
||||
|
||||
private async impact(
|
||||
repo: RepoHandle,
|
||||
params: {
|
||||
target: string;
|
||||
target_uid?: string;
|
||||
file_path?: string;
|
||||
kind?: string;
|
||||
direction: 'upstream' | 'downstream';
|
||||
maxDepth?: number;
|
||||
relationTypes?: string[];
|
||||
includeTests?: boolean;
|
||||
minConfidence?: number;
|
||||
},
|
||||
): Promise<any> {
|
||||
private async impact(repo: RepoHandle, params: ImpactParams): Promise<any> {
|
||||
try {
|
||||
return await this._impactImpl(repo, params);
|
||||
} catch (err: any) {
|
||||
|
|
@ -2780,20 +2782,7 @@ export class LocalBackend {
|
|||
}
|
||||
}
|
||||
|
||||
private async _impactImpl(
|
||||
repo: RepoHandle,
|
||||
params: {
|
||||
target: string;
|
||||
target_uid?: string;
|
||||
file_path?: string;
|
||||
kind?: string;
|
||||
direction: 'upstream' | 'downstream';
|
||||
maxDepth?: number;
|
||||
relationTypes?: string[];
|
||||
includeTests?: boolean;
|
||||
minConfidence?: number;
|
||||
},
|
||||
): Promise<any> {
|
||||
private async _impactImpl(repo: RepoHandle, params: ImpactParams): Promise<any> {
|
||||
await this.ensureInitialized(repo.id);
|
||||
|
||||
const { target, direction } = params;
|
||||
|
|
@ -2896,6 +2885,9 @@ export class LocalBackend {
|
|||
relationTypes: effectiveRelationTypes,
|
||||
includeTests,
|
||||
minConfidence,
|
||||
limit: Number.isFinite(params.limit) ? params.limit : 100,
|
||||
offset: Number.isFinite(params.offset) ? params.offset : 0,
|
||||
summaryOnly: params.summaryOnly,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -2912,9 +2904,20 @@ export class LocalBackend {
|
|||
relationTypes: string[];
|
||||
includeTests: boolean;
|
||||
minConfidence: number;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
summaryOnly?: boolean;
|
||||
},
|
||||
): Promise<any> {
|
||||
const { maxDepth, relationTypes, includeTests, minConfidence } = opts;
|
||||
const hasExplicitLimit = typeof opts.limit === 'number' && Number.isFinite(opts.limit);
|
||||
const paginationLimit = hasExplicitLimit
|
||||
? Math.max(1, Math.min(Math.trunc(opts.limit!), 10000))
|
||||
: Infinity;
|
||||
const rawOffset =
|
||||
typeof opts.offset === 'number' && Number.isFinite(opts.offset) ? opts.offset : 0;
|
||||
const paginationOffset = Math.max(0, Math.trunc(rawOffset));
|
||||
const summaryOnly = opts.summaryOnly ?? false;
|
||||
const relTypeFilter = relationTypes.map((t) => `'${t}'`).join(', ');
|
||||
const confidenceFilter = minConfidence > 0 ? ` AND r.confidence >= ${minConfidence}` : '';
|
||||
|
||||
|
|
@ -3326,7 +3329,13 @@ export class LocalBackend {
|
|||
risk = 'MEDIUM';
|
||||
}
|
||||
|
||||
return {
|
||||
// Build per-depth counts (always included, even in summaryOnly mode)
|
||||
const byDepthCounts: Record<number, number> = {};
|
||||
for (const [depth, items] of Object.entries(grouped)) {
|
||||
byDepthCounts[Number(depth)] = items.length;
|
||||
}
|
||||
|
||||
const base = {
|
||||
target: {
|
||||
id: symId,
|
||||
name: sym.name || sym[1],
|
||||
|
|
@ -3342,9 +3351,37 @@ export class LocalBackend {
|
|||
processes_affected: processCount,
|
||||
modules_affected: moduleCount,
|
||||
},
|
||||
byDepthCounts,
|
||||
affected_processes: affectedProcesses,
|
||||
affected_modules: affectedModules,
|
||||
byDepth: grouped,
|
||||
};
|
||||
|
||||
if (summaryOnly) {
|
||||
return base;
|
||||
}
|
||||
|
||||
// Apply limit/offset pagination per depth level
|
||||
const paginatedGrouped: Record<number, any[]> = {};
|
||||
let anyTruncated = false;
|
||||
for (const [depth, items] of Object.entries(grouped)) {
|
||||
const total = items.length;
|
||||
const sliced = items.slice(paginationOffset, paginationOffset + paginationLimit);
|
||||
paginatedGrouped[Number(depth)] = sliced;
|
||||
if (paginationOffset > 0 || paginationOffset + paginationLimit < total) {
|
||||
anyTruncated = true;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
...(anyTruncated && {
|
||||
pagination: {
|
||||
...(Number.isFinite(paginationLimit) && { limit: paginationLimit }),
|
||||
offset: paginationOffset,
|
||||
truncated: true,
|
||||
},
|
||||
}),
|
||||
byDepth: paginatedGrouped,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -3500,6 +3537,9 @@ export class LocalBackend {
|
|||
if (typeof params.subgroup === 'string') impactArgs.subgroup = params.subgroup;
|
||||
if (params.timeoutMs !== undefined) impactArgs.timeoutMs = params.timeoutMs;
|
||||
if (params.timeout !== undefined) impactArgs.timeout = params.timeout;
|
||||
// limit/offset/summaryOnly are not forwarded to group-mode impact:
|
||||
// runGroupImpact uses GROUP_LOCAL_PHASE_LIMIT internally for UID
|
||||
// collection and does not re-paginate the local result yet.
|
||||
return svc.groupImpact(impactArgs);
|
||||
}
|
||||
if (method === 'query') {
|
||||
|
|
|
|||
|
|
@ -336,13 +336,15 @@ Output includes:
|
|||
- summary: direct callers, processes affected, modules affected
|
||||
- affected_processes: which execution flows break and at which step
|
||||
- affected_modules: which functional areas are hit (direct vs indirect)
|
||||
- byDepth: all affected symbols grouped by traversal depth
|
||||
- byDepth: affected symbols grouped by traversal depth (paginated by limit/offset; omitted when summaryOnly:true — use byDepthCounts for totals per depth, pagination object when truncated)
|
||||
|
||||
Depth groups:
|
||||
- d=1: WILL BREAK (direct callers/importers)
|
||||
- d=2: LIKELY AFFECTED (indirect)
|
||||
- d=3: MAY NEED TESTING (transitive)
|
||||
|
||||
TIP: For hub symbols (base error classes, shared utilities) with many direct callers, use summaryOnly: true first to see counts and risk, then drill into specific depths with limit/offset. maxDepth alone does not bound output size when most dependents are at depth 1. limit and offset apply independently to each depth level, not to the total result set — use byDepthCounts to see totals per depth.
|
||||
|
||||
TIP: Default traversal uses CALLS/IMPORTS/EXTENDS/IMPLEMENTS. For class members, include HAS_METHOD and HAS_PROPERTY in relationTypes. For field access analysis, include ACCESSES in relationTypes.
|
||||
|
||||
Handles disambiguation: when multiple symbols share the target name, returns ranked candidates (each with a relevance score) instead of silently picking one. Use target_uid for zero-ambiguity lookup, or narrow with file_path and/or kind hints.
|
||||
|
|
@ -422,6 +424,27 @@ SERVICE: optional monorepo path prefix (case-sensitive path segments). When "rep
|
|||
description:
|
||||
'Optional group subgroup prefix (member repo paths) limiting which repos participate in cross fan-out.',
|
||||
},
|
||||
limit: {
|
||||
type: 'integer',
|
||||
description:
|
||||
'Max symbols returned in byDepth per depth level (default: 100). Single-repo only; ignored in group mode (@groupName). Use small values for hub symbols to avoid output truncation.',
|
||||
default: 100,
|
||||
minimum: 1,
|
||||
maximum: 10000,
|
||||
},
|
||||
offset: {
|
||||
type: 'integer',
|
||||
description:
|
||||
'Skip this many symbols per depth level before applying limit. Single-repo only; ignored in group mode (@groupName). Use with limit for pagination.',
|
||||
default: 0,
|
||||
minimum: 0,
|
||||
},
|
||||
summaryOnly: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'When true, returns target, summary, risk, byDepthCounts, affected_processes, and affected_modules — omits byDepth. Single-repo only; ignored in group mode (@groupName). Use for hub symbols to get actionable signal without output explosion.',
|
||||
default: false,
|
||||
},
|
||||
timeoutMs: {
|
||||
type: 'number',
|
||||
description:
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue