mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-29 01:41:42 +00:00
Merge branch 'main' into fix/web-stop-nexus-ai-1615
This commit is contained in:
commit
2a24d10094
89 changed files with 7720 additions and 174 deletions
37
README.md
37
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
|
||||
|
|
|
|||
|
|
@ -30,6 +30,12 @@ services:
|
|||
container_name: ${WEB_CONTAINER_NAME:-gitnexus-web}
|
||||
ports:
|
||||
- '${WEB_HOST_PORT:-4173}:4173'
|
||||
# Override the backend URL served to the browser. The default
|
||||
# (http://localhost:4747) works when both containers run locally.
|
||||
# Set GITNEXUS_BACKEND_URL in your .env or shell for remote/custom setups:
|
||||
# GITNEXUS_BACKEND_URL=http://<server-ip>:4747
|
||||
environment:
|
||||
- GITNEXUS_BACKEND_URL=${GITNEXUS_BACKEND_URL:-}
|
||||
depends_on:
|
||||
gitnexus-server:
|
||||
condition: service_healthy
|
||||
|
|
|
|||
|
|
@ -1,12 +1,39 @@
|
|||
import { createReadStream } from 'node:fs';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { open } from 'node:fs/promises';
|
||||
import { createServer } from 'node:http';
|
||||
import { extname, isAbsolute, normalize, relative, resolve } from 'node:path';
|
||||
import { extname, isAbsolute, normalize, relative, resolve, sep } from 'node:path';
|
||||
|
||||
const host = '0.0.0.0';
|
||||
const port = Number(process.env.PORT || '4173');
|
||||
const root = resolve(process.cwd(), 'dist');
|
||||
|
||||
function isValidUrl(value) {
|
||||
try {
|
||||
const u = new URL(value);
|
||||
return u.protocol === 'http:' || u.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function jsonForScriptTag(obj) {
|
||||
return JSON.stringify(obj)
|
||||
.replace(/</g, '\\u003c')
|
||||
.replace(/>/g, '\\u003e')
|
||||
.replace(/&/g, '\\u0026');
|
||||
}
|
||||
|
||||
const rawBackendUrl = process.env.GITNEXUS_BACKEND_URL ?? null;
|
||||
if (rawBackendUrl && !isValidUrl(rawBackendUrl)) {
|
||||
const safeRaw = rawBackendUrl.replace(/[\x00-\x1f\x7f]/g, ' ').slice(0, 200);
|
||||
console.warn(
|
||||
`[gitnexus-web] GITNEXUS_BACKEND_URL "${safeRaw}" is not a valid http/https URL -- ignoring.`,
|
||||
);
|
||||
}
|
||||
const backendUrl = rawBackendUrl && isValidUrl(rawBackendUrl) ? rawBackendUrl : null;
|
||||
const configScript = backendUrl
|
||||
? `<script>window.__GITNEXUS_CONFIG__=${jsonForScriptTag({ backendUrl })};</script>`
|
||||
: '';
|
||||
|
||||
const contentTypes = {
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
|
|
@ -22,22 +49,22 @@ const contentTypes = {
|
|||
|
||||
// Static asset server for the gitnexus-web Docker image.
|
||||
//
|
||||
// Path-injection containment: the request handler is intentionally a single
|
||||
// inline pipeline with no helper functions on the path-data flow. Each
|
||||
// filesystem sink (stat, createReadStream) is immediately preceded by the
|
||||
// canonical `path.relative` containment check that CodeQL's
|
||||
// `js/path-injection` query recognizes as a sanitizer barrier:
|
||||
// TOCTOU prevention: every filesystem interaction uses open() to get a
|
||||
// file handle; subsequent reads use handle.readFile()/createReadStream().
|
||||
//
|
||||
// const rel = relative(root, candidate);
|
||||
// if (rel.startsWith('..') || isAbsolute(rel)) reject;
|
||||
// // candidate is now proven inside `root`
|
||||
// CodeQL js/file-system-race: the query pairs open() calls when their
|
||||
// path arguments are data-flow aliased. This handler uses exactly two
|
||||
// open() calls whose paths are provably independent:
|
||||
// 1. open(requestedPath) — derived from the URL
|
||||
// 2. open(spaFallback) — the constant root/index.html
|
||||
// Because spaFallback has no data-flow from the request, CodeQL cannot
|
||||
// pair them as a check/use on the same path.
|
||||
//
|
||||
// Earlier iterations of this file used a helper (`resolveWithinRoot`) and a
|
||||
// `startsWith(root + sep)` check. Both were semantically correct but neither
|
||||
// was recognized by CodeQL: `startsWith(root + sep)` is not in the analyzer's
|
||||
// barrier-pattern set, and helper-based sanitization is not followed across
|
||||
// the request handler's reassignment paths in vanilla JS. The inline-at-sink
|
||||
// shape below is the documented analyzer-friendly idiom.
|
||||
// Path-injection containment: each open() is preceded by a
|
||||
// path.relative() barrier that CodeQL recognizes as a sanitizer.
|
||||
|
||||
const spaFallback = resolve(root, 'index.html');
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
const urlPath = req.url?.split('?')[0] || '/';
|
||||
|
||||
|
|
@ -56,62 +83,90 @@ const server = createServer(async (req, res) => {
|
|||
}
|
||||
|
||||
const cleanPath = normalize(decoded.replace(/^\/+/, ''));
|
||||
const initialPath = resolve(root, cleanPath);
|
||||
const requestedPath = resolve(root, cleanPath);
|
||||
|
||||
// Sanitizer barrier #1 — guards the first stat() sink.
|
||||
const initialRel = relative(root, initialPath);
|
||||
if (initialRel.startsWith('..') || isAbsolute(initialRel)) {
|
||||
const rel = relative(root, requestedPath);
|
||||
if (rel.startsWith('..') || isAbsolute(rel)) {
|
||||
res.writeHead(400);
|
||||
res.end('Bad request');
|
||||
return;
|
||||
}
|
||||
|
||||
let handle;
|
||||
try {
|
||||
const initialStat = await stat(initialPath).catch(() => null);
|
||||
let servePath = requestedPath;
|
||||
|
||||
// Pick the path we actually serve. Note: any branch reassigns to a
|
||||
// freshly-resolved path; the next sanitizer barrier re-validates.
|
||||
let finalPath;
|
||||
if (initialStat?.isDirectory()) {
|
||||
finalPath = resolve(initialPath, 'index.html');
|
||||
} else if (!initialStat?.isFile()) {
|
||||
finalPath = resolve(root, 'index.html');
|
||||
// Try to open the exact path the client asked for.
|
||||
handle = await open(requestedPath, 'r').catch(() => null);
|
||||
if (handle) {
|
||||
const s = await handle.stat();
|
||||
if (!s.isFile()) {
|
||||
// Directories and other non-files fall through to SPA fallback.
|
||||
await handle.close();
|
||||
handle = null;
|
||||
}
|
||||
}
|
||||
|
||||
// If the requested path wasn't a regular file, serve the SPA entry
|
||||
// point. spaFallback is a module-level constant with no data-flow
|
||||
// from the request, so this open() is independent of the one above.
|
||||
if (!handle) {
|
||||
servePath = spaFallback;
|
||||
handle = await open(spaFallback, 'r').catch(() => null);
|
||||
if (!handle) {
|
||||
res.writeHead(404);
|
||||
res.end('Not found');
|
||||
return;
|
||||
}
|
||||
const s = await handle.stat();
|
||||
if (!s.isFile()) {
|
||||
res.writeHead(404);
|
||||
res.end('Not found');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const isHtml = extname(servePath) === '.html' || !extname(servePath);
|
||||
const cacheControl = servePath.includes(`${sep}assets${sep}`)
|
||||
? 'public, max-age=31536000, immutable'
|
||||
: 'no-cache';
|
||||
const contentType = contentTypes[extname(servePath)] || 'application/octet-stream';
|
||||
|
||||
if (isHtml && configScript) {
|
||||
const raw = await handle.readFile('utf8');
|
||||
await handle.close();
|
||||
handle = null;
|
||||
if (!raw.includes('</head>')) {
|
||||
console.warn('[gitnexus-web] Could not inject config: no </head> tag found in HTML');
|
||||
}
|
||||
const html = raw.includes('</head>') ? raw.replace('</head>', `${configScript}</head>`) : raw;
|
||||
const buf = Buffer.from(html, 'utf8');
|
||||
res.writeHead(200, {
|
||||
'Cache-Control': cacheControl,
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Content-Length': buf.length,
|
||||
'Cross-Origin-Opener-Policy': 'same-origin',
|
||||
'Cross-Origin-Embedder-Policy': 'require-corp',
|
||||
});
|
||||
res.end(buf);
|
||||
} else {
|
||||
finalPath = initialPath;
|
||||
res.writeHead(200, {
|
||||
'Cache-Control': cacheControl,
|
||||
'Content-Type': contentType,
|
||||
'Cross-Origin-Opener-Policy': 'same-origin',
|
||||
'Cross-Origin-Embedder-Policy': 'require-corp',
|
||||
});
|
||||
const stream = handle.createReadStream();
|
||||
handle = null;
|
||||
stream.on('error', () => res.destroy());
|
||||
stream.pipe(res);
|
||||
}
|
||||
|
||||
// Sanitizer barrier #2 — guards both the second stat() and the
|
||||
// createReadStream() sinks. No reassignment of finalPath happens
|
||||
// between this guard and either sink, so the analyzer can prove
|
||||
// containment for both.
|
||||
const finalRel = relative(root, finalPath);
|
||||
if (finalRel.startsWith('..') || isAbsolute(finalRel)) {
|
||||
res.writeHead(400);
|
||||
res.end('Bad request');
|
||||
return;
|
||||
}
|
||||
|
||||
const finalStat = await stat(finalPath).catch(() => null);
|
||||
if (!finalStat?.isFile()) {
|
||||
res.writeHead(404);
|
||||
res.end('Not found');
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200, {
|
||||
'Cache-Control': finalPath.includes('/assets/')
|
||||
? 'public, max-age=31536000, immutable'
|
||||
: 'no-cache',
|
||||
'Content-Type': contentTypes[extname(finalPath)] || 'application/octet-stream',
|
||||
'Cross-Origin-Opener-Policy': 'same-origin',
|
||||
'Cross-Origin-Embedder-Policy': 'require-corp',
|
||||
});
|
||||
const stream = createReadStream(finalPath);
|
||||
stream.on('error', () => res.destroy());
|
||||
stream.pipe(res);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.writeHead(500);
|
||||
res.end(error instanceof Error ? error.message : 'Internal server error');
|
||||
res.end('Internal server error');
|
||||
} finally {
|
||||
if (handle) await handle.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -70,8 +70,20 @@ before(async () => {
|
|||
await waitForServer(serverPort);
|
||||
});
|
||||
|
||||
function killAndWait(proc) {
|
||||
return new Promise((resolve) => {
|
||||
if (!proc || proc.exitCode !== null) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
proc.once('exit', resolve);
|
||||
proc.kill();
|
||||
if (proc.exitCode !== null) resolve();
|
||||
});
|
||||
}
|
||||
|
||||
after(async () => {
|
||||
child?.kill();
|
||||
await killAndWait(child);
|
||||
if (tmpDir) await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
|
|
@ -122,3 +134,132 @@ it('returns 404 when dist/index.html is missing', async () => {
|
|||
const res = await rawGet(serverPort, '/nonexistent-page');
|
||||
assert.equal(res.status, 404);
|
||||
});
|
||||
|
||||
// -- Config injection: server-level integration tests ---
|
||||
|
||||
function spawnServerWithEnv(cwd, port, env) {
|
||||
const proc = spawn(process.execPath, [serverScript], {
|
||||
cwd,
|
||||
env: { ...process.env, PORT: String(port), ...env },
|
||||
stdio: 'pipe',
|
||||
});
|
||||
proc.on('error', (err) => {
|
||||
throw err;
|
||||
});
|
||||
return proc;
|
||||
}
|
||||
|
||||
async function withInjectionServer(envOverrides, fn) {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'gitnexus-inject-'));
|
||||
const distDir = join(dir, 'dist');
|
||||
const assetsDir = join(distDir, 'assets');
|
||||
await mkdir(assetsDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(distDir, 'index.html'),
|
||||
'<!doctype html><html><head><meta charset="utf-8"></head><body>app</body></html>',
|
||||
);
|
||||
await writeFile(join(assetsDir, 'style.abc.css'), 'body{}');
|
||||
|
||||
const port = await getFreePort();
|
||||
const proc = spawnServerWithEnv(dir, port, envOverrides);
|
||||
try {
|
||||
await waitForServer(port);
|
||||
await fn(port);
|
||||
} finally {
|
||||
await killAndWait(proc);
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
it('injects __GITNEXUS_CONFIG__ into / when GITNEXUS_BACKEND_URL is valid', async () => {
|
||||
await withInjectionServer({ GITNEXUS_BACKEND_URL: 'http://10.0.0.1:4747' }, async (port) => {
|
||||
const res = await rawGet(port, '/');
|
||||
assert.equal(res.status, 200);
|
||||
assert.ok(
|
||||
res.body.includes('window.__GITNEXUS_CONFIG__'),
|
||||
'Expected __GITNEXUS_CONFIG__ in response body',
|
||||
);
|
||||
assert.ok(res.body.includes('http://10.0.0.1:4747'), 'Expected backend URL in response body');
|
||||
});
|
||||
});
|
||||
|
||||
it('injects __GITNEXUS_CONFIG__ into SPA fallback routes', async () => {
|
||||
await withInjectionServer({ GITNEXUS_BACKEND_URL: 'http://10.0.0.1:4747' }, async (port) => {
|
||||
const res = await rawGet(port, '/some/deep/link');
|
||||
assert.equal(res.status, 200);
|
||||
assert.ok(
|
||||
res.body.includes('window.__GITNEXUS_CONFIG__'),
|
||||
'Expected __GITNEXUS_CONFIG__ in SPA fallback response',
|
||||
);
|
||||
assert.ok(
|
||||
res.body.includes('http://10.0.0.1:4747'),
|
||||
'Expected backend URL in SPA fallback response',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not inject when GITNEXUS_BACKEND_URL is not set', async () => {
|
||||
await withInjectionServer({}, async (port) => {
|
||||
const res = await rawGet(port, '/');
|
||||
assert.equal(res.status, 200);
|
||||
assert.ok(
|
||||
!res.body.includes('__GITNEXUS_CONFIG__'),
|
||||
'Expected no __GITNEXUS_CONFIG__ when env var is unset',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not inject when GITNEXUS_BACKEND_URL is invalid', async () => {
|
||||
await withInjectionServer({ GITNEXUS_BACKEND_URL: 'not-a-url' }, async (port) => {
|
||||
const res = await rawGet(port, '/');
|
||||
assert.equal(res.status, 200);
|
||||
assert.ok(
|
||||
!res.body.includes('__GITNEXUS_CONFIG__'),
|
||||
'Expected no __GITNEXUS_CONFIG__ for invalid URL',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not inject when GITNEXUS_BACKEND_URL uses a non-http protocol', async () => {
|
||||
await withInjectionServer({ GITNEXUS_BACKEND_URL: 'ftp://somehost:21' }, async (port) => {
|
||||
const res = await rawGet(port, '/');
|
||||
assert.equal(res.status, 200);
|
||||
assert.ok(
|
||||
!res.body.includes('__GITNEXUS_CONFIG__'),
|
||||
'Expected no __GITNEXUS_CONFIG__ for non-http protocol',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('escapes </script> in GITNEXUS_BACKEND_URL to prevent XSS', async () => {
|
||||
const xssUrl = 'http://example.com/?x=</script><script>alert(1)</script>';
|
||||
await withInjectionServer({ GITNEXUS_BACKEND_URL: xssUrl }, async (port) => {
|
||||
const res = await rawGet(port, '/');
|
||||
assert.equal(res.status, 200);
|
||||
|
||||
const scriptMatches = res.body.match(/<script>/gi) || [];
|
||||
assert.equal(
|
||||
scriptMatches.length,
|
||||
1,
|
||||
`Expected exactly 1 <script> tag but found ${scriptMatches.length}: XSS breakout detected`,
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
!res.body.includes('</script><script>'),
|
||||
'</script> must not appear unescaped -- would allow script breakout',
|
||||
);
|
||||
assert.ok(res.body.includes('\\u003c'), 'Angle brackets must be escaped as \\u003c');
|
||||
});
|
||||
});
|
||||
|
||||
it('does not inject config into static assets', async () => {
|
||||
await withInjectionServer({ GITNEXUS_BACKEND_URL: 'http://10.0.0.1:4747' }, async (port) => {
|
||||
const res = await rawGet(port, '/assets/style.abc.css');
|
||||
assert.equal(res.status, 200);
|
||||
assert.ok(
|
||||
!res.body.includes('__GITNEXUS_CONFIG__'),
|
||||
'Static assets must not contain injected config',
|
||||
);
|
||||
assert.equal(res.body, 'body{}');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export type PipelinePhase =
|
|||
| 'imports'
|
||||
| 'calls'
|
||||
| 'heritage'
|
||||
| 'scopeResolution'
|
||||
| 'communities'
|
||||
| 'processes'
|
||||
| 'enriching'
|
||||
|
|
|
|||
|
|
@ -267,8 +267,11 @@ export interface ScopeLookup {
|
|||
|
||||
/** Call-site description passed to `arityCompatibility`. */
|
||||
export interface Callsite {
|
||||
/** Number of arguments at the call site. */
|
||||
readonly arity: number;
|
||||
/** Number of arguments at the call site, if available. */
|
||||
readonly arity?: number;
|
||||
/** Inferred argument types at the call site, one per argument.
|
||||
* An empty string entry means the type was not inferred. */
|
||||
readonly argumentTypes?: readonly string[];
|
||||
}
|
||||
|
||||
// ─── §2.4 ImportEdge ────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@
|
|||
export const ERROR_RESET_DELAY_MS = 3000;
|
||||
export const BACKEND_URL_DEBOUNCE_MS = 500;
|
||||
|
||||
export const DEFAULT_BACKEND_URL = 'http://localhost:4747';
|
||||
export const DEFAULT_BACKEND_URL =
|
||||
(typeof window !== 'undefined' && window.__GITNEXUS_CONFIG__?.backendUrl) ||
|
||||
'http://localhost:4747';
|
||||
export const DEFAULT_OLLAMA_BASE_URL = 'http://localhost:11434';
|
||||
export const DEFAULT_OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';
|
||||
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@
|
|||
"imports": "Resolving imports",
|
||||
"calls": "Tracing calls",
|
||||
"heritage": "Extracting inheritance",
|
||||
"scopeResolution": "Resolving types",
|
||||
"communities": "Detecting communities",
|
||||
"processes": "Detecting processes",
|
||||
"complete": "Pipeline complete",
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@
|
|||
"imports": "正在解析导入",
|
||||
"calls": "正在追踪调用",
|
||||
"heritage": "正在提取继承关系",
|
||||
"scopeResolution": "正在解析类型",
|
||||
"communities": "正在检测社区",
|
||||
"processes": "正在检测流程",
|
||||
"complete": "流水线完成",
|
||||
|
|
|
|||
6
gitnexus-web/src/vite-env.d.ts
vendored
6
gitnexus-web/src/vite-env.d.ts
vendored
|
|
@ -1 +1,7 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
||||
interface Window {
|
||||
__GITNEXUS_CONFIG__?: {
|
||||
backendUrl?: string;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -172,6 +172,37 @@ describe('fetchGraph', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('DEFAULT_BACKEND_URL resolution', () => {
|
||||
afterEach(() => {
|
||||
delete window.__GITNEXUS_CONFIG__;
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('falls back to localhost:4747 when no config is injected', async () => {
|
||||
delete window.__GITNEXUS_CONFIG__;
|
||||
const { DEFAULT_BACKEND_URL } = await import('../../src/config/ui-constants');
|
||||
expect(DEFAULT_BACKEND_URL).toBe('http://localhost:4747');
|
||||
});
|
||||
|
||||
it('uses window.__GITNEXUS_CONFIG__.backendUrl when set', async () => {
|
||||
window.__GITNEXUS_CONFIG__ = { backendUrl: 'http://10.0.0.1:4747' };
|
||||
const { DEFAULT_BACKEND_URL } = await import('../../src/config/ui-constants');
|
||||
expect(DEFAULT_BACKEND_URL).toBe('http://10.0.0.1:4747');
|
||||
});
|
||||
|
||||
it('falls back to localhost:4747 when config object has no backendUrl', async () => {
|
||||
window.__GITNEXUS_CONFIG__ = {};
|
||||
const { DEFAULT_BACKEND_URL } = await import('../../src/config/ui-constants');
|
||||
expect(DEFAULT_BACKEND_URL).toBe('http://localhost:4747');
|
||||
});
|
||||
|
||||
it('falls back to localhost:4747 when backendUrl is an empty string', async () => {
|
||||
window.__GITNEXUS_CONFIG__ = { backendUrl: '' };
|
||||
const { DEFAULT_BACKEND_URL } = await import('../../src/config/ui-constants');
|
||||
expect(DEFAULT_BACKEND_URL).toBe('http://localhost:4747');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateBackendUrl', () => {
|
||||
it('allows http:// URLs', () => {
|
||||
expect(() => validateBackendUrl('http://localhost:4747')).not.toThrow();
|
||||
|
|
@ -225,7 +256,6 @@ describe('setBackendUrl', () => {
|
|||
it('does not mutate _backendUrl when validation fails', () => {
|
||||
setBackendUrl('http://localhost:4747');
|
||||
expect(() => setBackendUrl('javascript:alert(1)')).toThrow();
|
||||
// State must be preserved — validation must happen before the assignment
|
||||
expect(getBackendUrl()).toBe('http://localhost:4747');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -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,9 @@ const SPAWN_CLI = [
|
|||
'test/integration/group/group-cli.test.ts',
|
||||
'test/integration/cli/tool-no-index-stderr.test.ts',
|
||||
'test/integration/setup-skills.test.ts',
|
||||
'test/integration/setup-antigravity.test.ts',
|
||||
'test/integration/antigravity-hook-e2e.test.ts',
|
||||
'test/unit/local-cli-subprocess.test.ts',
|
||||
];
|
||||
|
||||
// Worker threads tests — exercise real worker_threads which have
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import { warnMissingOptionalGrammars } from './optional-grammars.js';
|
|||
import { glob } from 'glob';
|
||||
import fs from 'fs/promises';
|
||||
import { cliError } from './cli-message.js';
|
||||
import { formatElapsed } from './format-elapsed.js';
|
||||
import { isHfDownloadFailure } from '../core/embeddings/hf-env.js';
|
||||
|
||||
// Capture stderr.write at module load BEFORE anything (LadybugDB native
|
||||
|
|
@ -916,14 +917,14 @@ const analyzeCommandImpl = async (inputPath?: string, options?: AnalyzeOptions):
|
|||
phaseStart = Date.now();
|
||||
}
|
||||
const elapsed = Math.round((Date.now() - phaseStart) / 1000);
|
||||
const display = elapsed >= 3 ? `${phaseLabel} (${elapsed}s)` : phaseLabel;
|
||||
const display = elapsed >= 3 ? `${phaseLabel} (${formatElapsed(elapsed)})` : phaseLabel;
|
||||
bar.update(value, { phase: display });
|
||||
};
|
||||
|
||||
const elapsedTimer = setInterval(() => {
|
||||
const elapsed = Math.round((Date.now() - phaseStart) / 1000);
|
||||
if (elapsed >= 3) {
|
||||
bar.update({ phase: `${lastPhaseLabel} (${elapsed}s)` });
|
||||
bar.update({ phase: `${lastPhaseLabel} (${formatElapsed(elapsed)})` });
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
|
|
|
|||
7
gitnexus/src/cli/format-elapsed.ts
Normal file
7
gitnexus/src/cli/format-elapsed.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export function formatElapsed(secs: number): string {
|
||||
if (secs < 60) return `${secs}s`;
|
||||
if (secs < 3600) return `${Math.floor(secs / 60)}m ${secs % 60}s`;
|
||||
const h = Math.floor(secs / 3600);
|
||||
const m = Math.floor((secs % 3600) / 60);
|
||||
return `${h}h ${m}m`;
|
||||
}
|
||||
|
|
@ -185,7 +185,8 @@ export const en = {
|
|||
'help.option.clean.all': 'Clean all indexed repos',
|
||||
'help.option.clean.lbugSidecars': 'Clean quarantined LadybugDB missing-shadow WAL sidecars',
|
||||
'help.option.wiki.force': 'Force full regeneration even if up to date',
|
||||
'help.option.wiki.provider': 'LLM provider: openai or cursor (default: openai)',
|
||||
'help.option.wiki.provider':
|
||||
'LLM provider: openai, openrouter, azure, custom, cursor, claude, or codex (default: openai)',
|
||||
'help.option.wiki.model': 'LLM model or Azure deployment name (default: minimax/minimax-m2.5)',
|
||||
'help.option.wiki.baseUrl':
|
||||
'LLM API base URL. Azure v1: https://{resource}.openai.azure.com/openai/v1',
|
||||
|
|
|
|||
|
|
@ -174,7 +174,8 @@ export const zhCN = {
|
|||
'help.option.clean.all': '清理所有已索引仓库',
|
||||
'help.option.clean.lbugSidecars': '清理已隔离的 LadybugDB missing-shadow WAL sidecar',
|
||||
'help.option.wiki.force': '即使已是最新也强制完整重新生成',
|
||||
'help.option.wiki.provider': 'LLM 提供商:openai 或 cursor(默认:openai)',
|
||||
'help.option.wiki.provider':
|
||||
'LLM 提供商:openai、openrouter、azure、custom、cursor、claude 或 codex(默认:openai)',
|
||||
'help.option.wiki.model': 'LLM 模型或 Azure deployment 名称(默认:minimax/minimax-m2.5)',
|
||||
'help.option.wiki.baseUrl':
|
||||
'LLM API base URL。Azure v1:https://{resource}.openai.azure.com/openai/v1',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -146,7 +148,10 @@ program
|
|||
.command('wiki [path]')
|
||||
.description('Generate repository wiki from knowledge graph')
|
||||
.option('-f, --force', 'Force full regeneration even if up to date')
|
||||
.option('--provider <provider>', 'LLM provider: openai or cursor (default: openai)')
|
||||
.option(
|
||||
'--provider <provider>',
|
||||
'LLM provider: openai, openrouter, azure, custom, cursor, claude, or codex (default: openai)',
|
||||
)
|
||||
.option('--model <model>', 'LLM model or Azure deployment name (default: minimax/minimax-m2.5)')
|
||||
.option(
|
||||
'--base-url <url>',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
import { WikiGenerator, type WikiOptions } from '../core/wiki/generator.js';
|
||||
import { resolveLLMConfig, type LLMProvider } from '../core/wiki/llm-client.js';
|
||||
import { detectCursorCLI } from '../core/wiki/cursor-client.js';
|
||||
import { detectLocalCLI } from '../core/wiki/local-cli-client.js';
|
||||
import { logger } from '../core/logger.js';
|
||||
|
||||
export interface WikiCommandOptions {
|
||||
|
|
@ -55,6 +56,18 @@ function parsePositiveIntegerOption(
|
|||
return parsed;
|
||||
}
|
||||
|
||||
function isLocalProvider(
|
||||
provider: LLMProvider | undefined,
|
||||
): provider is 'cursor' | 'claude' | 'codex' {
|
||||
return provider === 'cursor' || provider === 'claude' || provider === 'codex';
|
||||
}
|
||||
|
||||
function localModelConfigKey(provider: 'cursor' | 'claude' | 'codex') {
|
||||
if (provider === 'cursor') return 'cursorModel';
|
||||
if (provider === 'claude') return 'claudeModel';
|
||||
return 'codexModel';
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt the user for input via stdin.
|
||||
*/
|
||||
|
|
@ -191,10 +204,11 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions)
|
|||
if (options.provider) updates.provider = options.provider;
|
||||
if (options.apiVersion) updates.apiVersion = options.apiVersion;
|
||||
if (options.reasoningModel !== undefined) updates.isReasoningModel = options.reasoningModel;
|
||||
// Save model to appropriate field based on provider
|
||||
// Save model to appropriate field based on provider.
|
||||
if (options.model) {
|
||||
if (options.provider === 'cursor') {
|
||||
updates.cursorModel = options.model;
|
||||
const targetProvider = options.provider ?? existing.provider;
|
||||
if (isLocalProvider(targetProvider)) {
|
||||
updates[localModelConfigKey(targetProvider)] = options.model;
|
||||
} else {
|
||||
updates.model = options.model;
|
||||
}
|
||||
|
|
@ -205,7 +219,7 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions)
|
|||
|
||||
const savedConfig = await loadCLIConfig();
|
||||
const hasSavedConfig = !!(
|
||||
savedConfig.provider === 'cursor' ||
|
||||
isLocalProvider(savedConfig.provider) ||
|
||||
(savedConfig.apiKey && savedConfig.baseUrl)
|
||||
);
|
||||
const hasCLIOverrides = !!(
|
||||
|
|
@ -231,10 +245,10 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions)
|
|||
if (!hasSavedConfig && !hasCLIOverrides) {
|
||||
if (!process.stdin.isTTY) {
|
||||
// Non-interactive mode — need either API key or Cursor CLI
|
||||
if (!llmConfig.apiKey && llmConfig.provider !== 'cursor') {
|
||||
if (!llmConfig.apiKey && !isLocalProvider(llmConfig.provider)) {
|
||||
console.log(' Error: No LLM API key found.');
|
||||
console.log(' Set OPENAI_API_KEY or GITNEXUS_API_KEY environment variable,');
|
||||
console.log(' or pass --api-key <key>, or use --provider cursor.\n');
|
||||
console.log(' or pass --api-key <key>, or use --provider cursor|claude|codex.\n');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
|
@ -242,23 +256,51 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions)
|
|||
} else {
|
||||
console.log(" No LLM configured. Let's set it up.\n");
|
||||
console.log(
|
||||
' Supports OpenAI, OpenRouter, Azure, any OpenAI-compatible API, or Cursor CLI.\n',
|
||||
' Supports OpenAI, OpenRouter, Azure, any OpenAI-compatible API, Cursor CLI, Claude CLI, or Codex CLI.\n',
|
||||
);
|
||||
|
||||
// Check if Cursor CLI is available
|
||||
// Check if local agent CLIs are available.
|
||||
const hasCursor = detectCursorCLI();
|
||||
const hasClaude = detectLocalCLI('claude');
|
||||
const hasCodex = detectLocalCLI('codex');
|
||||
const localChoices: Array<{
|
||||
choice: string;
|
||||
provider: 'cursor' | 'claude' | 'codex';
|
||||
}> = [];
|
||||
|
||||
// Provider selection
|
||||
console.log(' [1] OpenAI (api.openai.com)');
|
||||
console.log(' [2] OpenRouter (openrouter.ai)');
|
||||
console.log(' [3] Azure OpenAI');
|
||||
console.log(' [4] Custom endpoint');
|
||||
let nextChoice = 5;
|
||||
if (hasCursor) {
|
||||
console.log(' [5] Cursor CLI (local, uses your Cursor subscription)');
|
||||
const choice = String(nextChoice++);
|
||||
localChoices.push({
|
||||
choice,
|
||||
provider: 'cursor',
|
||||
});
|
||||
console.log(` [${choice}] Cursor CLI (local, uses your Cursor subscription)`);
|
||||
}
|
||||
if (hasClaude) {
|
||||
const choice = String(nextChoice++);
|
||||
localChoices.push({
|
||||
choice,
|
||||
provider: 'claude',
|
||||
});
|
||||
console.log(` [${choice}] Claude CLI (local, uses your Claude Code login)`);
|
||||
}
|
||||
if (hasCodex) {
|
||||
const choice = String(nextChoice++);
|
||||
localChoices.push({
|
||||
choice,
|
||||
provider: 'codex',
|
||||
});
|
||||
console.log(` [${choice}] Codex CLI (local, uses your Codex login)`);
|
||||
}
|
||||
console.log('');
|
||||
|
||||
const maxChoice = hasCursor ? '5' : '4';
|
||||
const maxChoice = String(nextChoice - 1);
|
||||
const choice = await prompt(` Select provider (1/${maxChoice}): `);
|
||||
|
||||
let baseUrl: string;
|
||||
|
|
@ -266,21 +308,21 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions)
|
|||
let provider: LLMProvider = 'openai';
|
||||
let key = '';
|
||||
|
||||
if (choice === '5' && hasCursor) {
|
||||
// Cursor CLI selected - model defaults to 'auto' (Cursor's default)
|
||||
provider = 'cursor';
|
||||
const selectedLocal = localChoices.find((item) => item.choice === choice);
|
||||
if (selectedLocal) {
|
||||
// Local CLI selected - model defaults to the CLI's configured default.
|
||||
provider = selectedLocal.provider;
|
||||
baseUrl = '';
|
||||
|
||||
const modelInput = await prompt(' Model (leave empty for auto): ');
|
||||
const modelInput = await prompt(' Model (leave empty for CLI default): ');
|
||||
const model = modelInput || '';
|
||||
|
||||
// Save config for Cursor
|
||||
const cursorConfig: Record<string, string> = { provider: 'cursor' };
|
||||
if (model) cursorConfig.cursorModel = model;
|
||||
await saveCLIConfig(cursorConfig);
|
||||
const localConfig = { ...savedConfig, provider };
|
||||
if (model) (localConfig as Record<string, unknown>)[localModelConfigKey(provider)] = model;
|
||||
await saveCLIConfig(localConfig);
|
||||
console.log(' Config saved to ~/.gitnexus/config.json\n');
|
||||
|
||||
llmConfig = { ...llmConfig, provider: 'cursor', model, apiKey: '', baseUrl: '' };
|
||||
llmConfig = { ...llmConfig, provider, model, apiKey: '', baseUrl: '' };
|
||||
} else if (choice === '3') {
|
||||
// Azure OpenAI guided setup — minimal prompts
|
||||
console.log('\n Azure OpenAI setup.\n');
|
||||
|
|
@ -328,6 +370,7 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions)
|
|||
const azureBaseUrl = `${endpoint}/openai/v1`;
|
||||
|
||||
await saveCLIConfig({
|
||||
...savedConfig,
|
||||
apiKey: azureKey,
|
||||
baseUrl: azureBaseUrl,
|
||||
model: deploymentName,
|
||||
|
|
|
|||
|
|
@ -27,12 +27,13 @@
|
|||
* declaration transparently.
|
||||
*/
|
||||
|
||||
import type { ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared';
|
||||
import type { Callsite, ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared';
|
||||
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
import {
|
||||
isOverloadAmbiguousAfterNormalization,
|
||||
narrowOverloadCandidates,
|
||||
} from '../../scope-resolution/passes/overload-narrowing.js';
|
||||
import { cppConversionRank } from './conversion-rank.js';
|
||||
|
||||
interface RangeKey {
|
||||
readonly startLine: number;
|
||||
|
|
@ -107,6 +108,7 @@ export function resolveCppQualifiedNamespaceMember(
|
|||
memberName: string,
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
_scopes: ScopeResolutionIndexes,
|
||||
callsite?: Callsite,
|
||||
): SymbolDefinition | 'ambiguous' | undefined {
|
||||
const allHits: SymbolDefinition[] = [];
|
||||
const seenNodeId = new Set<string>();
|
||||
|
|
@ -132,19 +134,17 @@ export function resolveCppQualifiedNamespaceMember(
|
|||
if (allHits.length === 0) return undefined;
|
||||
if (allHits.length === 1) return allHits[0];
|
||||
|
||||
// Multi-candidate: the `resolveQualifiedReceiverMember` hook has no
|
||||
// access to call-site arity or argument types, so
|
||||
// `narrowOverloadCandidates` cannot actually narrow here — the call
|
||||
// with `(allHits, undefined, undefined)` is effectively a pass-through.
|
||||
// We retain it so that `isOverloadAmbiguousAfterNormalization` can
|
||||
// still detect int/long-style normalization collisions on this path,
|
||||
// but for any multi-hit case where candidates have genuinely distinct
|
||||
// signatures (e.g. `foo(int)` vs `foo(double)` in different inline
|
||||
// children), we conservatively suppress rather than pick arbitrarily.
|
||||
// A future enhancement could thread call-site argument info through
|
||||
// the `resolveQualifiedReceiverMember` contract to enable real
|
||||
// narrowing here.
|
||||
const narrowed = narrowOverloadCandidates(allHits, undefined, undefined);
|
||||
// Multi-candidate: thread call-site arity/argument-types through the
|
||||
// `resolveQualifiedReceiverMember` contract so `narrowOverloadCandidates`
|
||||
// can disambiguate via exact-type match and, when available, conversion-rank
|
||||
// scoring (`cppConversionRank`). Same-signature ambiguity is still detected
|
||||
// by `isOverloadAmbiguousAfterNormalization` below.
|
||||
const narrowed = narrowOverloadCandidates(
|
||||
allHits,
|
||||
callsite?.arity,
|
||||
callsite?.argumentTypes,
|
||||
callsite !== undefined ? { conversionRankFn: cppConversionRank } : undefined,
|
||||
);
|
||||
if (narrowed.length === 1) return narrowed[0];
|
||||
if (narrowed.length === 0) return undefined;
|
||||
if (isOverloadAmbiguousAfterNormalization(narrowed, undefined)) return 'ambiguous';
|
||||
|
|
|
|||
|
|
@ -275,6 +275,12 @@ export const cppScopeResolver: ScopeResolver = {
|
|||
// descends transitively through inline-namespace children when
|
||||
// searching for the called member. Returns undefined for non-namespace
|
||||
// receivers so receiver-bound-calls Case 2 still gets a chance.
|
||||
resolveQualifiedReceiverMember: (receiverName, memberName, _callerScope, scopes, parsedFiles) =>
|
||||
resolveCppQualifiedNamespaceMember(receiverName, memberName, parsedFiles, scopes),
|
||||
resolveQualifiedReceiverMember: (
|
||||
receiverName,
|
||||
memberName,
|
||||
_callerScope,
|
||||
scopes,
|
||||
parsedFiles,
|
||||
callsite,
|
||||
) => resolveCppQualifiedNamespaceMember(receiverName, memberName, parsedFiles, scopes, callsite),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -161,12 +161,33 @@ export function populateCppDependentBases(parsedFiles: readonly ParsedFile[]): v
|
|||
|
||||
// Multiple classes share the same simple name — prefer the one
|
||||
// whose namespace matches the deriving class's namespace.
|
||||
// V1: exact dot-prefix match only. Cross-namespace inheritance
|
||||
// (e.g., `ns::outer::Derived` extending bare `Inner` defined in
|
||||
// `ns::outer::inner`) and inline-namespace cases are deferred to
|
||||
// V2; the conservative skip-on-ambiguity below avoids false
|
||||
// associations in those edge cases.
|
||||
const nsMatch = candidates.find((c) => c.nsPrefix === classEntry.nsPrefix);
|
||||
// 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 (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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,15 @@ import { rustVariableConfig } from '../variable-extractors/configs/rust.js';
|
|||
import { createCallExtractor } from '../call-extractors/generic.js';
|
||||
import { rustCallConfig } from '../call-extractors/configs/rust.js';
|
||||
import { createHeritageExtractor } from '../heritage-extractors/generic.js';
|
||||
import {
|
||||
emitRustScopeCaptures,
|
||||
rustArityCompatibility,
|
||||
rustBindingScopeFor,
|
||||
rustImportOwningScope,
|
||||
rustReceiverBinding,
|
||||
interpretRustImport,
|
||||
interpretRustTypeBinding,
|
||||
} from './rust/index.js';
|
||||
|
||||
/** Rust impl_item: find the function_item child and extract its name as a Method. */
|
||||
const rustExtractFunctionName = (
|
||||
|
|
@ -173,4 +182,12 @@ export const rustProvider = defineLanguage({
|
|||
classExtractor: createClassExtractor(rustClassConfig),
|
||||
heritageExtractor: createHeritageExtractor(SupportedLanguages.Rust),
|
||||
builtInNames: BUILT_INS,
|
||||
// ── RFC #909 Ring 3: scope-based resolution hooks ──────────
|
||||
emitScopeCaptures: emitRustScopeCaptures,
|
||||
interpretImport: interpretRustImport,
|
||||
interpretTypeBinding: interpretRustTypeBinding,
|
||||
bindingScopeFor: rustBindingScopeFor,
|
||||
importOwningScope: rustImportOwningScope,
|
||||
receiverBinding: rustReceiverBinding,
|
||||
arityCompatibility: rustArityCompatibility,
|
||||
});
|
||||
|
|
|
|||
15
gitnexus/src/core/ingestion/languages/rust/arity.ts
Normal file
15
gitnexus/src/core/ingestion/languages/rust/arity.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import type { Callsite, SymbolDefinition } from 'gitnexus-shared';
|
||||
|
||||
export function rustArityCompatibility(
|
||||
def: SymbolDefinition,
|
||||
callsite: Callsite,
|
||||
): 'compatible' | 'unknown' | 'incompatible' {
|
||||
const max = def.parameterCount;
|
||||
const min = def.requiredParameterCount;
|
||||
if (max === undefined && min === undefined) return 'unknown';
|
||||
if (!Number.isFinite(callsite.arity) || callsite.arity < 0) return 'unknown';
|
||||
|
||||
if (min !== undefined && callsite.arity < min) return 'incompatible';
|
||||
if (max !== undefined && callsite.arity > max) return 'incompatible';
|
||||
return 'compatible';
|
||||
}
|
||||
18
gitnexus/src/core/ingestion/languages/rust/cache-stats.ts
Normal file
18
gitnexus/src/core/ingestion/languages/rust/cache-stats.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
let hits = 0;
|
||||
let misses = 0;
|
||||
|
||||
export function recordRustCacheHit(): void {
|
||||
hits++;
|
||||
}
|
||||
export function recordRustCacheMiss(): void {
|
||||
misses++;
|
||||
}
|
||||
|
||||
export function getRustCaptureCacheStats(): { readonly hits: number; readonly misses: number } {
|
||||
return { hits, misses };
|
||||
}
|
||||
|
||||
export function resetRustCaptureCacheStats(): void {
|
||||
hits = 0;
|
||||
misses = 0;
|
||||
}
|
||||
223
gitnexus/src/core/ingestion/languages/rust/captures.ts
Normal file
223
gitnexus/src/core/ingestion/languages/rust/captures.ts
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
import type { Capture, CaptureMatch } from 'gitnexus-shared';
|
||||
import {
|
||||
findNodeAtRange,
|
||||
nodeToCapture,
|
||||
syntheticCapture,
|
||||
type SyntaxNode,
|
||||
} from '../../utils/ast-helpers.js';
|
||||
import { getRustParser, getRustScopeQuery } from './query.js';
|
||||
import { recordRustCacheHit, recordRustCacheMiss } from './cache-stats.js';
|
||||
import { splitRustUseDeclaration } from './import-decomposer.js';
|
||||
import { synthesizeRustReceiverBinding } from './receiver-binding.js';
|
||||
import { getTreeSitterBufferSize } from '../../constants.js';
|
||||
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
|
||||
|
||||
export function emitRustScopeCaptures(
|
||||
sourceText: string,
|
||||
_filePath: string,
|
||||
cachedTree?: unknown,
|
||||
): readonly CaptureMatch[] {
|
||||
let tree = cachedTree as ReturnType<ReturnType<typeof getRustParser>['parse']> | undefined;
|
||||
if (tree === undefined) {
|
||||
tree = parseSourceSafe(getRustParser(), sourceText, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(sourceText),
|
||||
});
|
||||
recordRustCacheMiss();
|
||||
} else {
|
||||
recordRustCacheHit();
|
||||
}
|
||||
|
||||
const rawMatches = getRustScopeQuery().matches(tree.rootNode);
|
||||
const out: CaptureMatch[] = [];
|
||||
|
||||
for (const m of rawMatches) {
|
||||
const grouped: Record<string, Capture> = {};
|
||||
for (const c of m.captures) {
|
||||
const tag = '@' + c.name;
|
||||
if (tag.startsWith('@_')) continue;
|
||||
grouped[tag] = nodeToCapture(tag, c.node);
|
||||
}
|
||||
if (Object.keys(grouped).length === 0) continue;
|
||||
|
||||
// Decompose use declarations into individual import captures
|
||||
if (grouped['@import.statement'] !== undefined) {
|
||||
const anchor = grouped['@import.statement']!;
|
||||
const useNode = findNodeAtRange(tree.rootNode, anchor.range, 'use_declaration');
|
||||
if (useNode !== null) {
|
||||
out.push(...splitRustUseDeclaration(useNode));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Synthesize self receiver bindings for methods inside impl blocks
|
||||
let cachedImplLookup: { fnNode: SyntaxNode; implNode: SyntaxNode | null } | undefined;
|
||||
if (grouped['@scope.function'] !== undefined) {
|
||||
const scopeCap = grouped['@scope.function']!;
|
||||
const fnNode = findNodeAtRange(tree.rootNode, scopeCap.range, 'function_item');
|
||||
if (fnNode !== null) {
|
||||
const implNode = findEnclosingImpl(fnNode);
|
||||
cachedImplLookup = { fnNode, implNode };
|
||||
const receiver = synthesizeRustReceiverBinding(fnNode, implNode);
|
||||
if (receiver !== null) out.push(receiver);
|
||||
}
|
||||
}
|
||||
|
||||
// Attach declaration arity for functions/methods
|
||||
const declAnchor = grouped['@declaration.function'];
|
||||
if (declAnchor !== undefined) {
|
||||
const fnNode = findNodeAtRange(tree.rootNode, declAnchor.range, 'function_item');
|
||||
if (fnNode !== null) {
|
||||
const implNode =
|
||||
cachedImplLookup?.fnNode === fnNode
|
||||
? cachedImplLookup.implNode
|
||||
: findEnclosingImpl(fnNode);
|
||||
const traitNode = implNode === null ? findEnclosingTrait(fnNode) : null;
|
||||
// Reclassify as method if inside an impl block or trait definition
|
||||
if (implNode !== null || traitNode !== null) {
|
||||
const nameCap = grouped['@declaration.name'];
|
||||
delete (grouped as Record<string, Capture | undefined>)['@declaration.function'];
|
||||
grouped['@declaration.method'] = syntheticCapture(
|
||||
'@declaration.method',
|
||||
fnNode,
|
||||
fnNode.text,
|
||||
);
|
||||
if (nameCap !== undefined) {
|
||||
grouped['@declaration.name'] = nameCap;
|
||||
}
|
||||
}
|
||||
|
||||
const arity = computeRustDeclarationArity(fnNode);
|
||||
if (arity.parameterCount !== undefined) {
|
||||
grouped['@declaration.parameter-count'] = syntheticCapture(
|
||||
'@declaration.parameter-count',
|
||||
fnNode,
|
||||
String(arity.parameterCount),
|
||||
);
|
||||
}
|
||||
if (arity.requiredParameterCount !== undefined) {
|
||||
grouped['@declaration.required-parameter-count'] = syntheticCapture(
|
||||
'@declaration.required-parameter-count',
|
||||
fnNode,
|
||||
String(arity.requiredParameterCount),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hoist return-type bindings from impl block functions to module level.
|
||||
// The auto-hoist in the scope-extractor places a type binding whose
|
||||
// anchor matches its innermost scope on the parent scope. By using the
|
||||
// impl_item node as the anchor (which matches the impl's Class scope),
|
||||
// the binding lands on the Module scope — making it visible to the
|
||||
// compound receiver's hoistTypeBindingsToModule walk.
|
||||
if (
|
||||
grouped['@type-binding.return'] !== undefined &&
|
||||
grouped['@type-binding.name'] !== undefined
|
||||
) {
|
||||
const tbReturnAnchor = grouped['@type-binding.return']!;
|
||||
const fnNode = findNodeAtRange(tree.rootNode, tbReturnAnchor.range, 'function_item');
|
||||
if (fnNode !== null) {
|
||||
const implNode = findEnclosingImpl(fnNode);
|
||||
if (implNode !== null) {
|
||||
out.push({
|
||||
'@type-binding.name': syntheticCapture(
|
||||
'@type-binding.name',
|
||||
implNode,
|
||||
grouped['@type-binding.name']!.text,
|
||||
),
|
||||
'@type-binding.type': syntheticCapture(
|
||||
'@type-binding.type',
|
||||
implNode,
|
||||
grouped['@type-binding.type']!.text,
|
||||
),
|
||||
'@type-binding.return': syntheticCapture(
|
||||
'@type-binding.return',
|
||||
implNode,
|
||||
tbReturnAnchor.text,
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Attach call arity for call expressions
|
||||
const callAnchor =
|
||||
grouped['@reference.call.free'] ??
|
||||
grouped['@reference.call.member'] ??
|
||||
grouped['@reference.call.constructor'];
|
||||
if (callAnchor !== undefined) {
|
||||
const callNode =
|
||||
findNodeAtRange(tree.rootNode, callAnchor.range, 'call_expression') ??
|
||||
findNodeAtRange(tree.rootNode, callAnchor.range, 'struct_expression');
|
||||
if (callNode !== null) {
|
||||
const arity = computeRustCallArity(callNode);
|
||||
grouped['@reference.arity'] = syntheticCapture('@reference.arity', callNode, String(arity));
|
||||
}
|
||||
}
|
||||
|
||||
out.push(grouped);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function findEnclosingImpl(node: SyntaxNode): SyntaxNode | null {
|
||||
let current: SyntaxNode | null = node.parent;
|
||||
while (current !== null) {
|
||||
if (current.type === 'impl_item') return current;
|
||||
if (current.type === 'source_file' || current.type === 'mod_item') return null;
|
||||
current = current.parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findEnclosingTrait(node: SyntaxNode): SyntaxNode | null {
|
||||
let current: SyntaxNode | null = node.parent;
|
||||
while (current !== null) {
|
||||
if (current.type === 'trait_item') return current;
|
||||
if (current.type === 'source_file' || current.type === 'mod_item') return null;
|
||||
current = current.parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function computeRustDeclarationArity(fnNode: SyntaxNode): {
|
||||
parameterCount?: number;
|
||||
requiredParameterCount?: number;
|
||||
} {
|
||||
const params = fnNode.childForFieldName('parameters');
|
||||
if (params === null) return {};
|
||||
|
||||
let count = 0;
|
||||
for (let i = 0; i < params.namedChildCount; i++) {
|
||||
const child = params.namedChild(i);
|
||||
if (child === null) continue;
|
||||
if (child.type === 'self_parameter') continue;
|
||||
if (child.type === 'parameter') count++;
|
||||
}
|
||||
// Rust has no default parameters or overloading
|
||||
return { parameterCount: count, requiredParameterCount: count };
|
||||
}
|
||||
|
||||
function computeRustCallArity(callNode: SyntaxNode): number {
|
||||
if (callNode.type === 'struct_expression') {
|
||||
const body = callNode.childForFieldName('body');
|
||||
if (body === null) return 0;
|
||||
let count = 0;
|
||||
for (let i = 0; i < body.namedChildCount; i++) {
|
||||
const t = body.namedChild(i)?.type;
|
||||
if (t === 'field_initializer' || t === 'shorthand_field_initializer') count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
const args = callNode.childForFieldName('arguments');
|
||||
if (args === null) return 0;
|
||||
|
||||
let count = 0;
|
||||
for (let i = 0; i < args.namedChildCount; i++) {
|
||||
const child = args.namedChild(i);
|
||||
if (child !== null) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
207
gitnexus/src/core/ingestion/languages/rust/import-decomposer.ts
Normal file
207
gitnexus/src/core/ingestion/languages/rust/import-decomposer.ts
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
import type { CaptureMatch } from 'gitnexus-shared';
|
||||
import { syntheticCapture } from '../../utils/ast-helpers.js';
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
|
||||
/**
|
||||
* Decompose a Rust `use_declaration` into individual import captures.
|
||||
* Handles simple paths, grouped imports ({A, B}), wildcards (*),
|
||||
* renames (as), and `pub use` re-exports.
|
||||
*/
|
||||
export function splitRustUseDeclaration(node: SyntaxNode): CaptureMatch[] {
|
||||
if (node.type !== 'use_declaration') return [];
|
||||
|
||||
const isReexport = hasVisibilityModifier(node);
|
||||
const argument = getUseArgument(node);
|
||||
if (argument === null) return [];
|
||||
|
||||
return decomposeUseArgument(argument, '', isReexport, node);
|
||||
}
|
||||
|
||||
function hasVisibilityModifier(node: SyntaxNode): boolean {
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
if (node.child(i)?.type === 'visibility_modifier') return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function getUseArgument(node: SyntaxNode): SyntaxNode | null {
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i);
|
||||
if (child === null) continue;
|
||||
if (
|
||||
child.type === 'scoped_identifier' ||
|
||||
child.type === 'scoped_use_list' ||
|
||||
child.type === 'use_wildcard' ||
|
||||
child.type === 'use_as_clause' ||
|
||||
child.type === 'identifier' ||
|
||||
child.type === 'use_list'
|
||||
) {
|
||||
return child;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function decomposeUseArgument(
|
||||
node: SyntaxNode,
|
||||
prefixPath: string,
|
||||
isReexport: boolean,
|
||||
anchor: SyntaxNode,
|
||||
): CaptureMatch[] {
|
||||
switch (node.type) {
|
||||
case 'scoped_identifier': {
|
||||
const path = buildScopedPath(node);
|
||||
const segments = path.split('::');
|
||||
const name = segments[segments.length - 1];
|
||||
return [
|
||||
makeImportCapture(
|
||||
anchor,
|
||||
isReexport ? 'reexport' : 'named',
|
||||
joinPaths(prefixPath, path),
|
||||
name,
|
||||
undefined,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
case 'scoped_use_list': {
|
||||
const pathNode = node.childForFieldName('path');
|
||||
const listNode = node.childForFieldName('list');
|
||||
const pathStr = pathNode ? buildNodePath(pathNode) : '';
|
||||
const fullPrefix = joinPaths(prefixPath, pathStr);
|
||||
if (listNode === null) return [];
|
||||
return decomposeUseList(listNode, fullPrefix, isReexport, anchor);
|
||||
}
|
||||
|
||||
case 'use_list': {
|
||||
return decomposeUseList(node, prefixPath, isReexport, anchor);
|
||||
}
|
||||
|
||||
case 'use_wildcard': {
|
||||
const wcPath = buildWildcardPath(node);
|
||||
return [makeImportCapture(anchor, 'wildcard', joinPaths(prefixPath, wcPath), '*', undefined)];
|
||||
}
|
||||
|
||||
case 'use_as_clause': {
|
||||
const pathChild = node.childForFieldName('path');
|
||||
const aliasChild = node.childForFieldName('alias');
|
||||
if (pathChild === null || aliasChild === null) return [];
|
||||
const originalName =
|
||||
pathChild.type === 'scoped_identifier' ? buildScopedPath(pathChild) : pathChild.text;
|
||||
const aliasName = aliasChild.text;
|
||||
const segments = originalName.split('::');
|
||||
const importedName = segments[segments.length - 1];
|
||||
return [
|
||||
makeImportCapture(
|
||||
anchor,
|
||||
isReexport ? 'reexport' : 'named',
|
||||
joinPaths(prefixPath, originalName),
|
||||
importedName,
|
||||
aliasName,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
case 'identifier': {
|
||||
return [
|
||||
makeImportCapture(
|
||||
anchor,
|
||||
isReexport ? 'reexport' : 'named',
|
||||
joinPaths(prefixPath, node.text),
|
||||
node.text,
|
||||
undefined,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function decomposeUseList(
|
||||
listNode: SyntaxNode,
|
||||
prefix: string,
|
||||
isReexport: boolean,
|
||||
anchor: SyntaxNode,
|
||||
): CaptureMatch[] {
|
||||
const out: CaptureMatch[] = [];
|
||||
for (let i = 0; i < listNode.namedChildCount; i++) {
|
||||
const child = listNode.namedChild(i);
|
||||
if (child === null) continue;
|
||||
|
||||
if (child.type === 'self') {
|
||||
// `use crate::models::{self}` — imports the module itself
|
||||
const segments = prefix.split('::').filter(Boolean);
|
||||
const name = segments[segments.length - 1] ?? 'self';
|
||||
out.push(makeImportCapture(anchor, 'namespace', prefix, name, undefined));
|
||||
} else {
|
||||
out.push(...decomposeUseArgument(child, prefix, isReexport, anchor));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildScopedPath(node: SyntaxNode): string {
|
||||
if (node.type === 'scoped_identifier') {
|
||||
const parts: string[] = [];
|
||||
collectScopedParts(node, parts);
|
||||
return parts.join('::');
|
||||
}
|
||||
return node.text;
|
||||
}
|
||||
|
||||
function collectScopedParts(node: SyntaxNode, parts: string[]): void {
|
||||
if (node.type === 'scoped_identifier') {
|
||||
const pathNode = node.childForFieldName('path');
|
||||
const nameNode = node.childForFieldName('name');
|
||||
if (pathNode) collectScopedParts(pathNode, parts);
|
||||
if (nameNode) parts.push(nameNode.text);
|
||||
} else {
|
||||
parts.push(node.text);
|
||||
}
|
||||
}
|
||||
|
||||
function buildNodePath(node: SyntaxNode): string {
|
||||
if (node.type === 'scoped_identifier') {
|
||||
return buildScopedPath(node);
|
||||
}
|
||||
return node.text;
|
||||
}
|
||||
|
||||
function buildWildcardPath(node: SyntaxNode): string {
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i);
|
||||
if (child === null) continue;
|
||||
if (child.type === 'scoped_identifier') return buildScopedPath(child);
|
||||
if (child.type === 'identifier') return child.text;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function joinPaths(prefix: string, suffix: string): string {
|
||||
if (!prefix) return suffix;
|
||||
if (!suffix) return prefix;
|
||||
return `${prefix}::${suffix}`;
|
||||
}
|
||||
|
||||
function makeImportCapture(
|
||||
anchor: SyntaxNode,
|
||||
kind: string,
|
||||
source: string,
|
||||
name: string,
|
||||
alias: string | undefined,
|
||||
): CaptureMatch {
|
||||
return {
|
||||
'@import.statement': syntheticCapture('@import.statement', anchor, anchor.text),
|
||||
'@import.kind': syntheticCapture('@import.kind', anchor, kind),
|
||||
'@import.source': syntheticCapture('@import.source', anchor, source),
|
||||
'@import.name': syntheticCapture('@import.name', anchor, alias ?? name),
|
||||
...(alias !== undefined
|
||||
? {
|
||||
'@import.alias': syntheticCapture('@import.alias', anchor, alias),
|
||||
'@import.original-name': syntheticCapture('@import.original-name', anchor, name),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
129
gitnexus/src/core/ingestion/languages/rust/import-target.ts
Normal file
129
gitnexus/src/core/ingestion/languages/rust/import-target.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
/**
|
||||
* Resolve a Rust `use` import path to a repo-relative file path.
|
||||
*
|
||||
* Rust module resolution rules:
|
||||
* - `crate::foo::bar` → `src/foo/bar.rs` or `src/foo/bar/mod.rs`
|
||||
* - `super::foo` → parent directory's `foo.rs` or `foo/mod.rs`
|
||||
* - `self::foo` → same directory's `foo.rs` or `foo/mod.rs`
|
||||
* - External crate imports (no `crate::`/`super::`/`self::`) → null
|
||||
*/
|
||||
export function resolveRustImportTarget(
|
||||
targetRaw: string,
|
||||
fromFile: string,
|
||||
allFilePaths: ReadonlySet<string>,
|
||||
_resolutionConfig?: unknown,
|
||||
): string | readonly string[] | null {
|
||||
if (!targetRaw) return null;
|
||||
|
||||
const segments = targetRaw.split('::').filter(Boolean);
|
||||
if (segments.length === 0) return null;
|
||||
|
||||
const fromNormalized = fromFile.replace(/\\/g, '/');
|
||||
const fromDir = fromNormalized.includes('/')
|
||||
? fromNormalized.slice(0, fromNormalized.lastIndexOf('/'))
|
||||
: '';
|
||||
|
||||
if (segments[0] === 'crate') {
|
||||
const cratePath = segments.slice(1);
|
||||
return resolveModulePath(cratePath, findSrcRoot(fromNormalized), allFilePaths);
|
||||
}
|
||||
|
||||
if (segments[0] === 'super') {
|
||||
const parentDir = fromDir.includes('/') ? fromDir.slice(0, fromDir.lastIndexOf('/')) : '';
|
||||
const restPath = segments.slice(1);
|
||||
return resolveModulePath(restPath, parentDir, allFilePaths);
|
||||
}
|
||||
|
||||
if (segments[0] === 'self') {
|
||||
const restPath = segments.slice(1);
|
||||
return resolveModulePath(restPath, fromDir, allFilePaths);
|
||||
}
|
||||
|
||||
// External crate — try workspace-level resolution
|
||||
const workspaceResult = resolveWorkspaceCrate(segments, allFilePaths);
|
||||
if (workspaceResult !== null) return workspaceResult;
|
||||
|
||||
// Fallback: treat as implicit crate-relative (Rust 2015 edition or
|
||||
// when the first segment matches a sibling module name).
|
||||
return resolveModulePath(segments, findSrcRoot(fromNormalized), allFilePaths);
|
||||
}
|
||||
|
||||
function findSrcRoot(filePath: string): string {
|
||||
const normalized = filePath.replace(/\\/g, '/');
|
||||
const srcIdx = normalized.lastIndexOf('/src/');
|
||||
if (srcIdx !== -1) return normalized.slice(0, srcIdx + 4); // includes trailing /src
|
||||
if (normalized.startsWith('src/')) return 'src';
|
||||
return '';
|
||||
}
|
||||
|
||||
function resolveModulePath(
|
||||
pathSegments: string[],
|
||||
baseDir: string,
|
||||
allFilePaths: ReadonlySet<string>,
|
||||
): string | readonly string[] | null {
|
||||
if (pathSegments.length === 0) {
|
||||
const modPath = baseDir ? `${baseDir}/mod.rs` : 'mod.rs';
|
||||
if (allFilePaths.has(modPath)) return modPath;
|
||||
return null;
|
||||
}
|
||||
|
||||
const modulePath = pathSegments.join('/');
|
||||
|
||||
// Try direct file
|
||||
const directFile = baseDir ? `${baseDir}/${modulePath}.rs` : `${modulePath}.rs`;
|
||||
if (allFilePaths.has(directFile)) return directFile;
|
||||
|
||||
// Try mod.rs inside directory
|
||||
const modFile = baseDir ? `${baseDir}/${modulePath}/mod.rs` : `${modulePath}/mod.rs`;
|
||||
if (allFilePaths.has(modFile)) return modFile;
|
||||
|
||||
// Try partial path resolution: for `use crate::models::User` where
|
||||
// User is a type inside models.rs, resolve to `src/models.rs`
|
||||
if (pathSegments.length >= 2) {
|
||||
const parentPath = pathSegments.slice(0, -1).join('/');
|
||||
const parentFile = baseDir ? `${baseDir}/${parentPath}.rs` : `${parentPath}.rs`;
|
||||
if (allFilePaths.has(parentFile)) return parentFile;
|
||||
|
||||
const parentModFile = baseDir ? `${baseDir}/${parentPath}/mod.rs` : `${parentPath}/mod.rs`;
|
||||
if (allFilePaths.has(parentModFile)) return parentModFile;
|
||||
}
|
||||
|
||||
// Fallback: try increasingly shorter path prefixes
|
||||
for (let i = pathSegments.length - 2; i >= 1; i--) {
|
||||
const prefix = pathSegments.slice(0, i).join('/');
|
||||
const prefixFile = baseDir ? `${baseDir}/${prefix}.rs` : `${prefix}.rs`;
|
||||
if (allFilePaths.has(prefixFile)) return prefixFile;
|
||||
const prefixModFile = baseDir ? `${baseDir}/${prefix}/mod.rs` : `${prefix}/mod.rs`;
|
||||
if (allFilePaths.has(prefixModFile)) return prefixModFile;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveWorkspaceCrate(
|
||||
segments: string[],
|
||||
allFilePaths: ReadonlySet<string>,
|
||||
): string | null {
|
||||
const crateName = segments[0];
|
||||
const restSegments = segments.slice(1);
|
||||
|
||||
const candidates = [
|
||||
restSegments.length > 0
|
||||
? `${crateName}/src/${restSegments.join('/')}.rs`
|
||||
: `${crateName}/src/lib.rs`,
|
||||
restSegments.length > 0
|
||||
? `${crateName}/src/${restSegments.join('/')}/mod.rs`
|
||||
: `${crateName}/src/lib.rs`,
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (allFilePaths.has(candidate)) return candidate;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface RustResolveContext {
|
||||
readonly fromFile: string;
|
||||
readonly allFilePaths: ReadonlySet<string>;
|
||||
}
|
||||
16
gitnexus/src/core/ingestion/languages/rust/index.ts
Normal file
16
gitnexus/src/core/ingestion/languages/rust/index.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
/**
|
||||
* Rust scope-resolution hooks (RFC #909 Ring 3).
|
||||
*/
|
||||
export { emitRustScopeCaptures } from './captures.js';
|
||||
export { getRustCaptureCacheStats, resetRustCaptureCacheStats } from './cache-stats.js';
|
||||
export {
|
||||
interpretRustImport,
|
||||
interpretRustTypeBinding,
|
||||
normalizeRustTypeName,
|
||||
} from './interpret.js';
|
||||
export { splitRustUseDeclaration } from './import-decomposer.js';
|
||||
export { synthesizeRustReceiverBinding } from './receiver-binding.js';
|
||||
export { rustArityCompatibility } from './arity.js';
|
||||
export { rustMergeBindings } from './merge-bindings.js';
|
||||
export { rustBindingScopeFor, rustImportOwningScope, rustReceiverBinding } from './simple-hooks.js';
|
||||
export { resolveRustImportTarget, type RustResolveContext } from './import-target.js';
|
||||
166
gitnexus/src/core/ingestion/languages/rust/interpret.ts
Normal file
166
gitnexus/src/core/ingestion/languages/rust/interpret.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'gitnexus-shared';
|
||||
|
||||
const REF_PREFIX_RE = /^&\s*(mut\s+)?/;
|
||||
const PTR_PREFIX_RE = /^\*\s*(const|mut)?\s*/;
|
||||
const ENUM_VARIANT_NAMES = new Set(['Some', 'None', 'Ok', 'Err']);
|
||||
|
||||
// ─── interpretImport ──────────────────────────────────────────────────────
|
||||
|
||||
export function interpretRustImport(captures: CaptureMatch): ParsedImport | null {
|
||||
const kind = captures['@import.kind']?.text;
|
||||
const source = captures['@import.source']?.text;
|
||||
const name = captures['@import.name']?.text;
|
||||
const alias = captures['@import.alias']?.text;
|
||||
if (kind === undefined || source === undefined) return null;
|
||||
|
||||
if (kind === 'wildcard') return { kind: 'wildcard', targetRaw: source };
|
||||
if (kind === 'namespace') {
|
||||
if (name === undefined) return null;
|
||||
return { kind: 'namespace', localName: name, importedName: name, targetRaw: source };
|
||||
}
|
||||
if (kind === 'reexport') {
|
||||
if (name === undefined) return null;
|
||||
const originalName = captures['@import.original-name']?.text;
|
||||
return {
|
||||
kind: 'reexport',
|
||||
localName: alias ?? name,
|
||||
importedName: originalName ?? name,
|
||||
targetRaw: source,
|
||||
};
|
||||
}
|
||||
// kind === 'named'
|
||||
if (name === undefined) return null;
|
||||
const originalName = captures['@import.original-name']?.text;
|
||||
return {
|
||||
kind: 'named',
|
||||
localName: alias ?? name,
|
||||
importedName: originalName ?? name,
|
||||
targetRaw: source,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── interpretTypeBinding ─────────────────────────────────────────────────
|
||||
|
||||
export function interpretRustTypeBinding(captures: CaptureMatch): ParsedTypeBinding | null {
|
||||
const name = captures['@type-binding.name']?.text;
|
||||
const type = captures['@type-binding.type']?.text;
|
||||
if (name === undefined || type === undefined) return null;
|
||||
|
||||
let source: TypeRef['source'] = 'annotation';
|
||||
let normalizedType: string;
|
||||
|
||||
if (captures['@type-binding.self'] !== undefined) {
|
||||
source = 'self';
|
||||
normalizedType = normalizeRustTypeName(type);
|
||||
} else if (captures['@type-binding.constructor'] !== undefined) {
|
||||
source = 'constructor-inferred';
|
||||
normalizedType = normalizeRustTypeName(type);
|
||||
} else if (captures['@type-binding.call-return'] !== undefined) {
|
||||
if (ENUM_VARIANT_NAMES.has(type)) return null;
|
||||
source = 'constructor-inferred';
|
||||
normalizedType = normalizeRustCallReturnType(type);
|
||||
} else if (captures['@type-binding.return'] !== undefined) {
|
||||
source = 'return-annotation';
|
||||
normalizedType = normalizeRustReturnType(type);
|
||||
} else if (captures['@type-binding.assignment'] !== undefined) {
|
||||
source = 'assignment-inferred';
|
||||
normalizedType = normalizeRustTypeName(type);
|
||||
} else if (captures['@type-binding.alias'] !== undefined) {
|
||||
source = 'assignment-inferred';
|
||||
normalizedType = normalizeRustTypeName(type);
|
||||
} else if (captures['@type-binding.parameter'] !== undefined) {
|
||||
source = 'parameter-annotation';
|
||||
normalizedType = normalizeRustTypeName(type);
|
||||
} else {
|
||||
normalizedType = normalizeRustTypeName(type);
|
||||
}
|
||||
|
||||
return { boundName: name, rawTypeName: normalizedType, source };
|
||||
}
|
||||
|
||||
export function normalizeRustTypeName(text: string): string {
|
||||
let t = text.trim();
|
||||
// Strip reference prefixes (&, &mut, *const, *mut)
|
||||
while (t.startsWith('&')) t = t.replace(REF_PREFIX_RE, '');
|
||||
while (t.startsWith('*')) t = t.replace(PTR_PREFIX_RE, '');
|
||||
// Unwrap common smart-pointer/container wrappers to their inner type
|
||||
const wrappers = ['Box', 'Option', 'Arc', 'Rc', 'Mutex', 'RwLock', 'RefCell', 'Cell'];
|
||||
for (const w of wrappers) {
|
||||
if (t.startsWith(`${w}<`)) {
|
||||
const inner = extractFirstGenericArg(t);
|
||||
if (inner !== null) {
|
||||
t = inner;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (t.startsWith('Vec<')) {
|
||||
const inner = extractFirstGenericArg(t);
|
||||
if (inner !== null) t = inner;
|
||||
}
|
||||
const bracket = t.indexOf('<');
|
||||
if (bracket !== -1) t = t.slice(0, bracket);
|
||||
// Take last segment of qualified paths (crate::foo::Bar → Bar)
|
||||
const lastColon = t.lastIndexOf('::');
|
||||
if (lastColon !== -1) t = t.slice(lastColon + 2);
|
||||
return t.trim();
|
||||
}
|
||||
|
||||
function extractFirstGenericArg(text: string): string | null {
|
||||
const open = text.indexOf('<');
|
||||
if (open === -1) return null;
|
||||
let depth = 0;
|
||||
for (let i = open; i < text.length; i++) {
|
||||
if (text[i] === '<') depth++;
|
||||
else if (text[i] === '>') {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
const inner = text.slice(open + 1, i).trim();
|
||||
const comma = findTopLevelComma(inner);
|
||||
return comma === -1 ? inner : inner.slice(0, comma).trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findTopLevelComma(text: string): number {
|
||||
let depth = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
if (text[i] === '<') depth++;
|
||||
else if (text[i] === '>') depth--;
|
||||
else if (text[i] === ',' && depth === 0) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function normalizeRustCallReturnType(text: string): string {
|
||||
let t = text.trim();
|
||||
// For scoped calls like `Foo::new()`, extract the type part before `::`
|
||||
const scopeIdx = t.indexOf('::');
|
||||
if (scopeIdx !== -1) {
|
||||
t = t.slice(0, scopeIdx);
|
||||
}
|
||||
return normalizeRustTypeName(t);
|
||||
}
|
||||
|
||||
function normalizeRustReturnType(text: string): string {
|
||||
let t = text.trim();
|
||||
while (t.startsWith('&')) t = t.replace(REF_PREFIX_RE, '');
|
||||
// Unwrap Result<T, E>, Option<T> for return types
|
||||
const wrappers = ['Result', 'Option'];
|
||||
for (const w of wrappers) {
|
||||
if (t.startsWith(`${w}<`)) {
|
||||
const inner = extractFirstGenericArg(t);
|
||||
if (inner !== null) {
|
||||
t = inner;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const bracket = t.indexOf('<');
|
||||
if (bracket !== -1) t = t.slice(0, bracket);
|
||||
const lastColon = t.lastIndexOf('::');
|
||||
if (lastColon !== -1) t = t.slice(lastColon + 2);
|
||||
return t.trim();
|
||||
}
|
||||
27
gitnexus/src/core/ingestion/languages/rust/merge-bindings.ts
Normal file
27
gitnexus/src/core/ingestion/languages/rust/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 rustMergeBindings(
|
||||
existing: readonly BindingRef[],
|
||||
incoming: readonly BindingRef[],
|
||||
_scopeId: string,
|
||||
): BindingRef[] {
|
||||
const seen = new Set<string>();
|
||||
return [...existing, ...incoming]
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(TIER[a.origin] ?? 99) - (TIER[b.origin] ?? 99) || a.def.nodeId.localeCompare(b.def.nodeId),
|
||||
)
|
||||
.filter((binding) => {
|
||||
if (seen.has(binding.def.nodeId)) return false;
|
||||
seen.add(binding.def.nodeId);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
68
gitnexus/src/core/ingestion/languages/rust/method-owners.ts
Normal file
68
gitnexus/src/core/ingestion/languages/rust/method-owners.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import type { ParsedFile } from 'gitnexus-shared';
|
||||
import { isClassLike, populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js';
|
||||
|
||||
/**
|
||||
* Populate `ownerId` on Rust method defs.
|
||||
*
|
||||
* Rust methods are declared inside `impl TypeName { ... }` blocks, not
|
||||
* directly inside struct bodies. The tree-sitter query creates Class scopes
|
||||
* for impl blocks, and the generic `populateClassOwnedMembers` handles methods
|
||||
* that are structurally nested inside those Class scopes. But we also need to
|
||||
* bridge the impl block's methods to the actual struct def, since the impl
|
||||
* block is semantically "owned by" the struct.
|
||||
*
|
||||
* Strategy:
|
||||
* 1. Run the generic `populateClassOwnedMembers` (handles property fields in
|
||||
* structs and methods in impl blocks).
|
||||
* 2. For each method in an impl block's Class scope whose ownerId points to
|
||||
* the impl block, re-point ownerId to the struct def (if found in the
|
||||
* same module).
|
||||
*/
|
||||
export function populateRustOwners(parsed: ParsedFile): void {
|
||||
populateClassOwnedMembers(parsed);
|
||||
populateRustImplOwners(parsed);
|
||||
}
|
||||
|
||||
function populateRustImplOwners(parsed: ParsedFile): void {
|
||||
// Build a map of struct name → def nodeId from all scopes.
|
||||
const structByName = new Map<string, string>();
|
||||
for (const scope of parsed.scopes) {
|
||||
for (const def of scope.ownedDefs) {
|
||||
if (isClassLike(def.type) && def.qualifiedName) {
|
||||
structByName.set(def.qualifiedName, def.nodeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (structByName.size === 0) return;
|
||||
|
||||
const structBySuffix = new Map<string, string>();
|
||||
for (const [qname, nodeId] of structByName) {
|
||||
const dot = qname.lastIndexOf('.');
|
||||
const suffix = dot !== -1 ? qname.slice(dot + 1) : qname;
|
||||
structBySuffix.set(suffix, nodeId);
|
||||
}
|
||||
|
||||
for (const scope of parsed.scopes) {
|
||||
if (scope.kind !== 'Function') continue;
|
||||
const methodDefs = scope.ownedDefs.filter(
|
||||
(d) => d.type === 'Method' && d.ownerId === undefined,
|
||||
);
|
||||
if (methodDefs.length === 0) continue;
|
||||
|
||||
let receiverType: string | undefined;
|
||||
for (const [, tb] of scope.typeBindings) {
|
||||
if (tb.source === 'self') {
|
||||
receiverType = tb.rawName;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (receiverType === undefined) continue;
|
||||
|
||||
const ownerId = structByName.get(receiverType) ?? structBySuffix.get(receiverType);
|
||||
if (ownerId !== undefined) {
|
||||
for (const def of methodDefs) {
|
||||
(def as { ownerId?: string }).ownerId = ownerId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
149
gitnexus/src/core/ingestion/languages/rust/query.ts
Normal file
149
gitnexus/src/core/ingestion/languages/rust/query.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import Parser from 'tree-sitter';
|
||||
import Rust from 'tree-sitter-rust';
|
||||
|
||||
const RUST_SCOPE_QUERY = `
|
||||
;; Scopes
|
||||
(source_file) @scope.module
|
||||
(struct_item) @scope.class
|
||||
(trait_item) @scope.class
|
||||
(impl_item) @scope.class
|
||||
(enum_item) @scope.class
|
||||
(function_item) @scope.function
|
||||
(closure_expression) @scope.function
|
||||
(block) @scope.block
|
||||
(if_expression) @scope.block
|
||||
(match_expression) @scope.block
|
||||
(for_expression) @scope.block
|
||||
(while_expression) @scope.block
|
||||
(loop_expression) @scope.block
|
||||
(mod_item) @scope.namespace
|
||||
|
||||
;; Declarations — struct
|
||||
(struct_item
|
||||
name: (type_identifier) @declaration.name) @declaration.struct
|
||||
|
||||
;; Declarations — trait
|
||||
(trait_item
|
||||
name: (type_identifier) @declaration.name) @declaration.trait
|
||||
|
||||
;; Declarations — enum
|
||||
(enum_item
|
||||
name: (type_identifier) @declaration.name) @declaration.enum
|
||||
|
||||
;; Declarations — function (top-level or inside mod)
|
||||
(function_item
|
||||
name: (identifier) @declaration.name) @declaration.function
|
||||
|
||||
;; Declarations — struct fields
|
||||
(field_declaration
|
||||
name: (field_identifier) @declaration.name
|
||||
type: (_) @declaration.field-type) @declaration.field
|
||||
|
||||
;; Declarations — variables (let bindings)
|
||||
(let_declaration
|
||||
pattern: (identifier) @declaration.name) @declaration.variable
|
||||
|
||||
;; Declarations — const
|
||||
(const_item
|
||||
name: (identifier) @declaration.name) @declaration.const
|
||||
|
||||
;; Declarations — static
|
||||
(static_item
|
||||
name: (identifier) @declaration.name) @declaration.const
|
||||
|
||||
;; Imports
|
||||
(use_declaration) @import.statement
|
||||
|
||||
;; Type bindings — parameter annotations
|
||||
(parameter
|
||||
pattern: (identifier) @type-binding.name
|
||||
type: (_) @type-binding.type) @type-binding.parameter
|
||||
|
||||
;; Type bindings — let with type annotation
|
||||
(let_declaration
|
||||
pattern: (identifier) @type-binding.name
|
||||
type: (_) @type-binding.type) @type-binding.assignment
|
||||
|
||||
;; Type bindings — struct literal constructor inference
|
||||
(let_declaration
|
||||
pattern: (identifier) @type-binding.name
|
||||
value: (struct_expression
|
||||
name: (_) @type-binding.type)) @type-binding.constructor
|
||||
|
||||
;; Type bindings — call-return inference (let x = Foo::new())
|
||||
(let_declaration
|
||||
pattern: (identifier) @type-binding.name
|
||||
value: (call_expression
|
||||
function: (_) @type-binding.type)) @type-binding.call-return
|
||||
|
||||
;; Type bindings — call-return inference through .await (let x = foo().await)
|
||||
(let_declaration
|
||||
pattern: (identifier) @type-binding.name
|
||||
value: (await_expression
|
||||
(call_expression
|
||||
function: (_) @type-binding.type))) @type-binding.call-return
|
||||
|
||||
;; Type bindings — variable alias (let x = y)
|
||||
(let_declaration
|
||||
pattern: (identifier) @type-binding.name
|
||||
value: (identifier) @type-binding.type) @type-binding.alias
|
||||
|
||||
;; Type bindings — return type annotation
|
||||
(function_item
|
||||
name: (identifier) @type-binding.name
|
||||
return_type: (_) @type-binding.type) @type-binding.return
|
||||
|
||||
;; References — free calls
|
||||
(call_expression
|
||||
function: (identifier) @reference.name) @reference.call.free
|
||||
|
||||
;; References — member calls (obj.method())
|
||||
(call_expression
|
||||
function: (field_expression
|
||||
value: (_) @reference.receiver
|
||||
field: (field_identifier) @reference.name)) @reference.call.member
|
||||
|
||||
;; References — scoped calls (Foo::bar())
|
||||
(call_expression
|
||||
function: (scoped_identifier
|
||||
name: (identifier) @reference.name)) @reference.call.free
|
||||
|
||||
;; References — constructor calls (struct literal)
|
||||
(struct_expression
|
||||
name: (_) @reference.name) @reference.call.constructor
|
||||
|
||||
;; References — field reads
|
||||
(field_expression
|
||||
value: (_) @reference.receiver
|
||||
field: (field_identifier) @reference.name) @reference.read
|
||||
|
||||
;; References — field writes (assignment)
|
||||
(assignment_expression
|
||||
left: (field_expression
|
||||
value: (_) @reference.receiver
|
||||
field: (field_identifier) @reference.name)) @reference.write
|
||||
|
||||
;; References — field writes (compound assignment: +=, -=, etc.)
|
||||
(compound_assignment_expr
|
||||
left: (field_expression
|
||||
value: (_) @reference.receiver
|
||||
field: (field_identifier) @reference.name)) @reference.write
|
||||
`;
|
||||
|
||||
let _parser: Parser | null = null;
|
||||
let _query: Parser.Query | null = null;
|
||||
|
||||
export function getRustParser(): Parser {
|
||||
if (_parser === null) {
|
||||
_parser = new Parser();
|
||||
_parser.setLanguage(Rust as Parameters<Parser['setLanguage']>[0]);
|
||||
}
|
||||
return _parser;
|
||||
}
|
||||
|
||||
export function getRustScopeQuery(): Parser.Query {
|
||||
if (_query === null) {
|
||||
_query = new Parser.Query(Rust as Parameters<Parser['setLanguage']>[0], RUST_SCOPE_QUERY);
|
||||
}
|
||||
return _query;
|
||||
}
|
||||
708
gitnexus/src/core/ingestion/languages/rust/range-binding.ts
Normal file
708
gitnexus/src/core/ingestion/languages/rust/range-binding.ts
Normal file
|
|
@ -0,0 +1,708 @@
|
|||
import type { ParsedFile, Scope, ScopeId, TypeRef } from 'gitnexus-shared';
|
||||
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
import { getRustParser } from './query.js';
|
||||
import { getTreeSitterBufferSize } from '../../constants.js';
|
||||
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
|
||||
/**
|
||||
* Populate type bindings for patterns and iterators that the tree-sitter
|
||||
* query can't handle (they need runtime type lookup, not just syntax).
|
||||
*
|
||||
* Covers: for-loop element types, if-let/while-let pattern bindings,
|
||||
* match arm patterns, and struct destructuring.
|
||||
*
|
||||
* Runs in Phase 2 (after propagateImportedReturnTypes) so all cross-file
|
||||
* type bindings are available for lookup.
|
||||
*/
|
||||
export function populateRustRangeBindings(
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
_indexes: ScopeResolutionIndexes,
|
||||
ctx: {
|
||||
readonly fileContents: ReadonlyMap<string, string>;
|
||||
readonly treeCache?: { get(filePath: string): unknown };
|
||||
},
|
||||
): void {
|
||||
const parser = getRustParser();
|
||||
const allReturnTypes = new Map<string, string>();
|
||||
const allFieldTypes = new Map<string, Map<string, string>>();
|
||||
|
||||
for (const parsed of parsedFiles) {
|
||||
const sourceText = ctx.fileContents.get(parsed.filePath);
|
||||
if (sourceText === undefined) continue;
|
||||
|
||||
const cachedTree = ctx.treeCache?.get(parsed.filePath);
|
||||
const tree =
|
||||
(cachedTree as ReturnType<typeof parser.parse> | undefined) ??
|
||||
parseSourceSafe(parser, sourceText, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(sourceText),
|
||||
});
|
||||
|
||||
for (const fn of tree.rootNode.descendantsOfType('function_item')) {
|
||||
const nameNode = fn.childForFieldName('name');
|
||||
const retType = fn.childForFieldName('return_type');
|
||||
if (nameNode !== null && retType !== null) {
|
||||
const name = nameNode.text;
|
||||
if (allReturnTypes.has(name)) {
|
||||
allReturnTypes.delete(name);
|
||||
} else {
|
||||
allReturnTypes.set(name, retType.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const structNode of tree.rootNode.descendantsOfType('struct_item')) {
|
||||
const nameNode = structNode.childForFieldName('name');
|
||||
const body = structNode.childForFieldName('body');
|
||||
if (nameNode === null || body === null) continue;
|
||||
const fields = new Map<string, string>();
|
||||
for (const field of body.descendantsOfType('field_declaration')) {
|
||||
const fieldName = field.childForFieldName('name');
|
||||
const fieldType = field.childForFieldName('type');
|
||||
if (fieldName !== null && fieldType !== null) {
|
||||
fields.set(fieldName.text, normalizeFieldType(fieldType.text));
|
||||
}
|
||||
}
|
||||
if (fields.size > 0) {
|
||||
const name = nameNode.text;
|
||||
if (allFieldTypes.has(name)) {
|
||||
allFieldTypes.delete(name);
|
||||
} else {
|
||||
allFieldTypes.set(name, fields);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const parsed of parsedFiles) {
|
||||
const sourceText = ctx.fileContents.get(parsed.filePath);
|
||||
if (sourceText === undefined) continue;
|
||||
|
||||
const cachedTree = ctx.treeCache?.get(parsed.filePath);
|
||||
const tree =
|
||||
(cachedTree as ReturnType<typeof parser.parse> | undefined) ??
|
||||
parseSourceSafe(parser, sourceText, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(sourceText),
|
||||
});
|
||||
|
||||
const scopeMap = new Map(parsed.scopes.map((s) => [s.id, s]));
|
||||
const moduleScope = parsed.scopes.find((s) => s.kind === 'Module');
|
||||
if (moduleScope === undefined) continue;
|
||||
|
||||
processFieldTypeBindings(tree.rootNode, parsed, scopeMap);
|
||||
processIdentityMethodBindings(parsed);
|
||||
processForLoops(tree.rootNode, parsed, scopeMap, moduleScope, allReturnTypes);
|
||||
processPatternBindings(tree.rootNode, parsed, scopeMap, moduleScope);
|
||||
processStructDestructuring(tree.rootNode, parsed, scopeMap, moduleScope, allFieldTypes);
|
||||
processPendingAssignments(
|
||||
tree.rootNode,
|
||||
parsed,
|
||||
parsedFiles,
|
||||
scopeMap,
|
||||
moduleScope,
|
||||
allReturnTypes,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function processFieldTypeBindings(
|
||||
root: SyntaxNode,
|
||||
parsed: ParsedFile,
|
||||
scopeMap: ReadonlyMap<string, Scope>,
|
||||
): void {
|
||||
for (const structNode of root.descendantsOfType('struct_item')) {
|
||||
const nameNode = structNode.childForFieldName('name');
|
||||
if (nameNode === null) continue;
|
||||
|
||||
const structScope = findScopeForNode(structNode, parsed, scopeMap);
|
||||
if (structScope === null) continue;
|
||||
|
||||
const body = structNode.childForFieldName('body');
|
||||
if (body === null) continue;
|
||||
|
||||
for (const field of body.descendantsOfType('field_declaration')) {
|
||||
const fieldName = field.childForFieldName('name');
|
||||
const fieldType = field.childForFieldName('type');
|
||||
if (fieldName === null || fieldType === null) continue;
|
||||
|
||||
const normalizedType = normalizeFieldType(fieldType.text);
|
||||
injectTypeBinding(structScope, fieldName.text, normalizedType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findScopeForNode(
|
||||
node: SyntaxNode,
|
||||
parsed: ParsedFile,
|
||||
scopeMap: ReadonlyMap<string, Scope>,
|
||||
): Scope | null {
|
||||
for (const scope of parsed.scopes) {
|
||||
if (
|
||||
scope.kind === 'Class' &&
|
||||
scope.range.startLine === node.startPosition.row + 1 &&
|
||||
scope.range.startCol === node.startPosition.column
|
||||
) {
|
||||
return scope;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeFieldType(text: string): string {
|
||||
let t = text.trim();
|
||||
if (t.startsWith('&')) t = t.replace(/^&\s*(mut\s+)?/, '');
|
||||
const bracket = t.indexOf('<');
|
||||
if (bracket !== -1) t = t.slice(0, bracket);
|
||||
const lastColon = t.lastIndexOf('::');
|
||||
if (lastColon !== -1) t = t.slice(lastColon + 2);
|
||||
return t.trim();
|
||||
}
|
||||
|
||||
function processForLoops(
|
||||
root: SyntaxNode,
|
||||
parsed: ParsedFile,
|
||||
scopeMap: ReadonlyMap<string, Scope>,
|
||||
moduleScope: Scope,
|
||||
allReturnTypes: ReadonlyMap<string, string>,
|
||||
): void {
|
||||
for (const forNode of root.descendantsOfType('for_expression')) {
|
||||
const patternNode = forNode.childForFieldName('pattern');
|
||||
const valueNode = forNode.childForFieldName('value');
|
||||
if (patternNode === null || valueNode === null) continue;
|
||||
|
||||
const varName = extractVarName(patternNode);
|
||||
if (varName === null) continue;
|
||||
|
||||
const elementType = resolveIterableElementType(
|
||||
valueNode,
|
||||
parsed,
|
||||
scopeMap,
|
||||
moduleScope,
|
||||
allReturnTypes,
|
||||
);
|
||||
if (elementType === null) continue;
|
||||
|
||||
const targetScope = findEnclosingFunctionScope(forNode, scopeMap) ?? moduleScope;
|
||||
injectTypeBinding(targetScope, varName, elementType);
|
||||
}
|
||||
}
|
||||
|
||||
function processPatternBindings(
|
||||
root: SyntaxNode,
|
||||
parsed: ParsedFile,
|
||||
scopeMap: ReadonlyMap<string, Scope>,
|
||||
moduleScope: Scope,
|
||||
): void {
|
||||
for (const nodeType of ['let_condition', 'match_arm'] as const) {
|
||||
for (const node of root.descendantsOfType(nodeType)) {
|
||||
const patternNode =
|
||||
nodeType === 'let_condition'
|
||||
? node.childForFieldName('pattern')
|
||||
: (node.childForFieldName('pattern')?.firstNamedChild ?? null);
|
||||
if (patternNode === null) continue;
|
||||
|
||||
if (patternNode.type === 'captured_pattern') {
|
||||
processCapturedPattern(patternNode, node, parsed, scopeMap, moduleScope);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (patternNode.type === 'tuple_struct_pattern') {
|
||||
processTupleStructPattern(patternNode, node, parsed, scopeMap, moduleScope);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function processCapturedPattern(
|
||||
patternNode: SyntaxNode,
|
||||
contextNode: SyntaxNode,
|
||||
_parsed: ParsedFile,
|
||||
scopeMap: ReadonlyMap<string, Scope>,
|
||||
moduleScope: Scope,
|
||||
): void {
|
||||
const varNode = patternNode.namedChildren.find((c) => c.type === 'identifier');
|
||||
const structPatternNode = patternNode.namedChildren.find((c) => c.type === 'struct_pattern');
|
||||
if (varNode === undefined || structPatternNode === undefined) return;
|
||||
|
||||
const typeName = structPatternNode.childForFieldName('type')?.text;
|
||||
if (typeName === undefined) return;
|
||||
|
||||
const targetScope = findEnclosingFunctionScope(contextNode, scopeMap) ?? moduleScope;
|
||||
injectTypeBinding(targetScope, varNode.text, typeName);
|
||||
}
|
||||
|
||||
function processTupleStructPattern(
|
||||
patternNode: SyntaxNode,
|
||||
contextNode: SyntaxNode,
|
||||
parsed: ParsedFile,
|
||||
scopeMap: ReadonlyMap<string, Scope>,
|
||||
moduleScope: Scope,
|
||||
): void {
|
||||
const wrapperNode = patternNode.childForFieldName('type');
|
||||
if (wrapperNode === null) return;
|
||||
const wrapper = wrapperNode.text;
|
||||
|
||||
const wrapperIdx = patternNode.namedChildren.indexOf(wrapperNode);
|
||||
const innerIdent = patternNode.namedChildren.find(
|
||||
(c, i) => i > wrapperIdx && c.type === 'identifier',
|
||||
);
|
||||
if (innerIdent === null || innerIdent === undefined) return;
|
||||
const varName = innerIdent.text;
|
||||
|
||||
let sourceVarNode: SyntaxNode | null = null;
|
||||
if (contextNode.type === 'let_condition') {
|
||||
sourceVarNode = contextNode.childForFieldName('value');
|
||||
} else {
|
||||
let matchExpr: SyntaxNode | null = contextNode.parent;
|
||||
while (matchExpr !== null && matchExpr.type !== 'match_expression') {
|
||||
matchExpr = matchExpr.parent;
|
||||
}
|
||||
sourceVarNode = matchExpr?.childForFieldName('value') ?? null;
|
||||
}
|
||||
if (sourceVarNode === null || sourceVarNode === undefined) return;
|
||||
|
||||
const sourceVarName = sourceVarNode.type === 'identifier' ? sourceVarNode.text : null;
|
||||
if (sourceVarName === null) return;
|
||||
|
||||
const sourceType = lookupTypeInScopes(sourceVarName, contextNode, parsed, scopeMap, moduleScope);
|
||||
if (sourceType === null) return;
|
||||
|
||||
let resolvedType: string | null = null;
|
||||
|
||||
if (wrapper === 'Some') {
|
||||
resolvedType = unwrapGeneric(sourceType);
|
||||
} else if (wrapper === 'Ok' || wrapper === 'Err') {
|
||||
const rawType = lookupRawParameterType(sourceVarName, contextNode);
|
||||
if (rawType !== null) {
|
||||
const argIdx = wrapper === 'Ok' ? 0 : 1;
|
||||
resolvedType = extractNthGenericArg(rawType, argIdx);
|
||||
}
|
||||
if (resolvedType === null) {
|
||||
resolvedType = wrapper === 'Ok' ? unwrapGeneric(sourceType) : null;
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedType === null) return;
|
||||
|
||||
const targetScope = findEnclosingFunctionScope(contextNode, scopeMap) ?? moduleScope;
|
||||
injectTypeBinding(targetScope, varName, resolvedType);
|
||||
}
|
||||
|
||||
function processStructDestructuring(
|
||||
root: SyntaxNode,
|
||||
parsed: ParsedFile,
|
||||
scopeMap: ReadonlyMap<string, Scope>,
|
||||
moduleScope: Scope,
|
||||
allFieldTypes?: ReadonlyMap<string, Map<string, string>>,
|
||||
): void {
|
||||
for (const letNode of root.descendantsOfType('let_declaration')) {
|
||||
const patternNode = letNode.childForFieldName('pattern');
|
||||
if (patternNode === null || patternNode.type !== 'struct_pattern') continue;
|
||||
|
||||
const typeName = patternNode.childForFieldName('type')?.text;
|
||||
if (typeName === undefined) continue;
|
||||
|
||||
const valueNode = letNode.childForFieldName('value');
|
||||
if (valueNode === null) continue;
|
||||
|
||||
const targetScope = findEnclosingFunctionScope(letNode, scopeMap) ?? moduleScope;
|
||||
|
||||
for (const fieldNode of patternNode.namedChildren) {
|
||||
let fieldName: string | undefined;
|
||||
if (fieldNode.type === 'field_pattern') {
|
||||
fieldName = fieldNode.childForFieldName('name')?.text;
|
||||
} else if (fieldNode.type === 'shorthand_field_pattern') {
|
||||
fieldName = fieldNode.firstNamedChild?.text;
|
||||
}
|
||||
if (fieldName === undefined) continue;
|
||||
|
||||
let fieldType = lookupFieldType(typeName, fieldName, parsed, scopeMap, moduleScope);
|
||||
if (fieldType === null) {
|
||||
fieldType = allFieldTypes?.get(typeName)?.get(fieldName) ?? null;
|
||||
}
|
||||
if (fieldType !== null) {
|
||||
injectTypeBinding(targetScope, fieldName, fieldType);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const IDENTITY_METHODS = ['unwrap', 'expect', 'clone', 'as_ref', 'as_mut'];
|
||||
|
||||
function processIdentityMethodBindings(parsed: ParsedFile): void {
|
||||
for (const scope of parsed.scopes) {
|
||||
if (scope.kind !== 'Class') continue;
|
||||
const classDef = scope.ownedDefs.find((d) => {
|
||||
const t = d.type;
|
||||
return t === 'Struct' || t === 'Enum' || t === 'Class';
|
||||
});
|
||||
if (classDef === undefined) continue;
|
||||
const name = classDef.qualifiedName?.split('.').pop();
|
||||
if (name === undefined) continue;
|
||||
|
||||
for (const method of IDENTITY_METHODS) {
|
||||
if (!scope.typeBindings.has(method)) {
|
||||
(scope.typeBindings as Map<string, TypeRef>).set(method, {
|
||||
rawName: name,
|
||||
declaredAtScope: scope.id,
|
||||
source: 'return-annotation',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function processPendingAssignments(
|
||||
root: SyntaxNode,
|
||||
parsed: ParsedFile,
|
||||
allParsedFiles: readonly ParsedFile[],
|
||||
scopeMap: ReadonlyMap<string, Scope>,
|
||||
moduleScope: Scope,
|
||||
allReturnTypes: ReadonlyMap<string, string>,
|
||||
): void {
|
||||
for (let pass = 0; pass < 3; pass++) {
|
||||
for (const letNode of root.descendantsOfType('let_declaration')) {
|
||||
const patternNode = letNode.childForFieldName('pattern');
|
||||
if (patternNode === null) continue;
|
||||
const varName = extractVarName(patternNode);
|
||||
if (varName === null) continue;
|
||||
|
||||
const targetScope = findEnclosingFunctionScope(letNode, scopeMap) ?? moduleScope;
|
||||
if (targetScope.typeBindings.has(varName)) continue;
|
||||
|
||||
const valueNode = letNode.childForFieldName('value');
|
||||
if (valueNode === null) continue;
|
||||
|
||||
if (valueNode.type === 'identifier') {
|
||||
const rhsType = lookupTypeInScopes(valueNode.text, letNode, parsed, scopeMap, moduleScope);
|
||||
if (rhsType !== null) {
|
||||
injectTypeBinding(targetScope, varName, rhsType);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (valueNode.type === 'field_expression') {
|
||||
const receiver = valueNode.childForFieldName('value');
|
||||
const field = valueNode.childForFieldName('field');
|
||||
if (receiver !== null && field !== null && receiver.type === 'identifier') {
|
||||
const receiverType = lookupTypeInScopes(
|
||||
receiver.text,
|
||||
letNode,
|
||||
parsed,
|
||||
scopeMap,
|
||||
moduleScope,
|
||||
);
|
||||
if (receiverType !== null) {
|
||||
const fieldType = findFieldTypeAcrossFiles(receiverType, field.text, allParsedFiles);
|
||||
if (fieldType !== null) {
|
||||
injectTypeBinding(targetScope, varName, fieldType);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (valueNode.type === 'call_expression') {
|
||||
const func = valueNode.childForFieldName('function');
|
||||
if (func !== null && func.type === 'field_expression') {
|
||||
const receiver = func.childForFieldName('value');
|
||||
const method = func.childForFieldName('field');
|
||||
if (receiver !== null && method !== null && receiver.type === 'identifier') {
|
||||
const receiverType = lookupTypeInScopes(
|
||||
receiver.text,
|
||||
letNode,
|
||||
parsed,
|
||||
scopeMap,
|
||||
moduleScope,
|
||||
);
|
||||
if (receiverType !== null) {
|
||||
const retType = findMethodReturnTypeAcrossFiles(
|
||||
receiverType,
|
||||
method.text,
|
||||
allParsedFiles,
|
||||
);
|
||||
if (retType !== null) {
|
||||
injectTypeBinding(targetScope, varName, retType);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (func !== null && func.type === 'identifier') {
|
||||
const rawReturn = allReturnTypes.get(func.text);
|
||||
if (rawReturn !== undefined) {
|
||||
injectTypeBinding(targetScope, varName, normalizeFieldType(rawReturn));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveIterableElementType(
|
||||
valueNode: SyntaxNode,
|
||||
parsed: ParsedFile,
|
||||
scopeMap: ReadonlyMap<string, Scope>,
|
||||
moduleScope: Scope,
|
||||
allReturnTypes?: ReadonlyMap<string, string>,
|
||||
): string | null {
|
||||
let iterableNode = valueNode;
|
||||
if (iterableNode.type === 'reference_expression') {
|
||||
iterableNode = iterableNode.firstNamedChild ?? iterableNode;
|
||||
}
|
||||
|
||||
if (iterableNode.type === 'identifier') {
|
||||
const rawType = lookupTypeInScopes(iterableNode.text, valueNode, parsed, scopeMap, moduleScope);
|
||||
if (rawType !== null) return unwrapGeneric(rawType);
|
||||
}
|
||||
|
||||
if (iterableNode.type === 'call_expression') {
|
||||
const func = iterableNode.childForFieldName('function');
|
||||
if (func === null) return null;
|
||||
|
||||
if (func.type === 'field_expression') {
|
||||
const receiver = func.childForFieldName('value');
|
||||
if (receiver !== null && receiver.type === 'identifier') {
|
||||
const rawType = lookupTypeInScopes(receiver.text, valueNode, parsed, scopeMap, moduleScope);
|
||||
if (rawType !== null) return unwrapGeneric(rawType);
|
||||
}
|
||||
}
|
||||
|
||||
if (func.type === 'identifier') {
|
||||
const crossFileReturn = allReturnTypes?.get(func.text);
|
||||
if (crossFileReturn !== undefined) return unwrapGeneric(crossFileReturn);
|
||||
const rawReturn = lookupRawFunctionReturnType(func.text, valueNode);
|
||||
if (rawReturn !== null) return unwrapGeneric(rawReturn);
|
||||
const returnType = lookupReturnTypeInScopes(func.text, parsed, scopeMap, moduleScope);
|
||||
if (returnType !== null) return unwrapGeneric(returnType);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function findFieldTypeAcrossFiles(
|
||||
structName: string,
|
||||
fieldName: string,
|
||||
allParsedFiles: readonly ParsedFile[],
|
||||
): string | null {
|
||||
for (const pf of allParsedFiles) {
|
||||
for (const scope of pf.scopes) {
|
||||
if (scope.kind !== 'Class') continue;
|
||||
const hasDef = scope.ownedDefs.some(
|
||||
(d) => d.qualifiedName === structName || d.qualifiedName?.endsWith('.' + structName),
|
||||
);
|
||||
if (!hasDef) continue;
|
||||
const tb = scope.typeBindings.get(fieldName);
|
||||
if (tb !== undefined) return tb.rawName;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findMethodReturnTypeAcrossFiles(
|
||||
structName: string,
|
||||
methodName: string,
|
||||
allParsedFiles: readonly ParsedFile[],
|
||||
): string | null {
|
||||
for (const pf of allParsedFiles) {
|
||||
for (const scope of pf.scopes) {
|
||||
if (scope.kind !== 'Class') continue;
|
||||
const hasDef = scope.ownedDefs.some(
|
||||
(d) => d.qualifiedName === structName || d.qualifiedName?.endsWith('.' + structName),
|
||||
);
|
||||
if (!hasDef) continue;
|
||||
const tb = scope.typeBindings.get(methodName);
|
||||
if (tb !== undefined && tb.source === 'return-annotation') return tb.rawName;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function lookupRawFunctionReturnType(funcName: string, contextNode: SyntaxNode): string | null {
|
||||
let root: SyntaxNode = contextNode;
|
||||
while (root.parent !== null) root = root.parent;
|
||||
for (const fn of root.descendantsOfType('function_item')) {
|
||||
const nameNode = fn.childForFieldName('name');
|
||||
if (nameNode !== null && nameNode.text === funcName) {
|
||||
const retType = fn.childForFieldName('return_type');
|
||||
if (retType !== null) return retType.text;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function lookupRawParameterType(paramName: string, contextNode: SyntaxNode): string | null {
|
||||
let current: SyntaxNode | null = contextNode;
|
||||
while (current !== null) {
|
||||
if (current.type === 'function_item') {
|
||||
const params = current.childForFieldName('parameters');
|
||||
if (params !== null) {
|
||||
for (let i = 0; i < params.namedChildCount; i++) {
|
||||
const param = params.namedChild(i);
|
||||
if (param === null || param.type !== 'parameter') continue;
|
||||
const pattern = param.childForFieldName('pattern');
|
||||
const typeNode = param.childForFieldName('type');
|
||||
if (pattern !== null && typeNode !== null && pattern.text === paramName) {
|
||||
return typeNode.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
current = current.parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function lookupTypeInScopes(
|
||||
name: string,
|
||||
contextNode: SyntaxNode,
|
||||
parsed: ParsedFile,
|
||||
scopeMap: ReadonlyMap<string, Scope>,
|
||||
moduleScope: Scope,
|
||||
): string | null {
|
||||
const fnScope = findEnclosingFunctionScope(contextNode, scopeMap);
|
||||
if (fnScope !== null) {
|
||||
const tb = fnScope.typeBindings.get(name);
|
||||
if (tb !== undefined) return tb.rawName;
|
||||
}
|
||||
|
||||
const mtb = moduleScope.typeBindings.get(name);
|
||||
if (mtb !== undefined) return mtb.rawName;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function lookupReturnTypeInScopes(
|
||||
funcName: string,
|
||||
parsed: ParsedFile,
|
||||
_scopeMap: ReadonlyMap<string, Scope>,
|
||||
moduleScope: Scope,
|
||||
): string | null {
|
||||
const tb = moduleScope.typeBindings.get(funcName);
|
||||
if (tb !== undefined && tb.source === 'return-annotation') return tb.rawName;
|
||||
|
||||
for (const scope of parsed.scopes) {
|
||||
const stb = scope.typeBindings.get(funcName);
|
||||
if (stb !== undefined && stb.source === 'return-annotation') return stb.rawName;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function lookupFieldType(
|
||||
structName: string,
|
||||
fieldName: string,
|
||||
parsed: ParsedFile,
|
||||
_scopeMap: ReadonlyMap<string, Scope>,
|
||||
_moduleScope: Scope,
|
||||
): string | null {
|
||||
for (const scope of parsed.scopes) {
|
||||
if (scope.kind !== 'Class') continue;
|
||||
const hasDef = scope.ownedDefs.some(
|
||||
(d) => d.qualifiedName === structName || d.qualifiedName?.endsWith('.' + structName),
|
||||
);
|
||||
if (!hasDef) continue;
|
||||
|
||||
const tb = scope.typeBindings.get(fieldName);
|
||||
if (tb !== undefined) return tb.rawName;
|
||||
|
||||
for (const def of scope.ownedDefs) {
|
||||
const defName = def.qualifiedName?.split('.').pop();
|
||||
if (def.type === 'Property' && defName === fieldName) {
|
||||
const tb = scope.typeBindings.get(fieldName);
|
||||
if (tb !== undefined) return tb.rawName;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function unwrapGeneric(rawType: string): string {
|
||||
const match = rawType.match(/^(?:Vec|Option|Arc|Rc|Box|Mutex|RwLock|RefCell|Cell)<(.+)>$/);
|
||||
if (match) {
|
||||
const inner = match[1].trim();
|
||||
const comma = findTopLevelComma(inner);
|
||||
return comma === -1 ? inner : inner.slice(0, comma).trim();
|
||||
}
|
||||
if (rawType.startsWith('&[') && rawType.endsWith(']')) {
|
||||
return rawType.slice(2, -1).trim();
|
||||
}
|
||||
return rawType;
|
||||
}
|
||||
|
||||
function extractNthGenericArg(rawType: string, n: number): string | null {
|
||||
const open = rawType.indexOf('<');
|
||||
if (open === -1) return null;
|
||||
const close = rawType.lastIndexOf('>');
|
||||
if (close === -1) return null;
|
||||
const inner = rawType.slice(open + 1, close).trim();
|
||||
const args = splitTopLevelComma(inner);
|
||||
return n < args.length ? args[n].trim() : null;
|
||||
}
|
||||
|
||||
function splitTopLevelComma(text: string): string[] {
|
||||
const parts: string[] = [];
|
||||
let depth = 0;
|
||||
let start = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
if (text[i] === '<') depth++;
|
||||
else if (text[i] === '>') depth--;
|
||||
else if (text[i] === ',' && depth === 0) {
|
||||
parts.push(text.slice(start, i));
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
parts.push(text.slice(start));
|
||||
return parts;
|
||||
}
|
||||
|
||||
function findTopLevelComma(text: string): number {
|
||||
let depth = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
if (text[i] === '<') depth++;
|
||||
else if (text[i] === '>') depth--;
|
||||
else if (text[i] === ',' && depth === 0) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function extractVarName(node: SyntaxNode): string | null {
|
||||
if (node.type === 'identifier') return node.text;
|
||||
if (node.type === 'mut_pattern' || node.type === 'reference_pattern') {
|
||||
return node.firstNamedChild?.text ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function injectTypeBinding(scope: Scope, name: string, typeName: string): void {
|
||||
if (scope.typeBindings.has(name)) return;
|
||||
(scope.typeBindings as Map<string, TypeRef>).set(name, {
|
||||
rawName: typeName,
|
||||
declaredAtScope: scope.id,
|
||||
source: 'annotation',
|
||||
});
|
||||
}
|
||||
|
||||
function findEnclosingFunctionScope(
|
||||
node: SyntaxNode,
|
||||
scopeMap: ReadonlyMap<ScopeId, Scope>,
|
||||
): Scope | null {
|
||||
let current: SyntaxNode | null = node as SyntaxNode;
|
||||
while (current !== null) {
|
||||
if (current.type === 'function_item') {
|
||||
for (const scope of scopeMap.values()) {
|
||||
if (
|
||||
scope.kind === 'Function' &&
|
||||
scope.range.startLine === current.startPosition.row + 1 &&
|
||||
scope.range.startCol === current.startPosition.column
|
||||
) {
|
||||
return scope;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
current = current.parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
139
gitnexus/src/core/ingestion/languages/rust/receiver-binding.ts
Normal file
139
gitnexus/src/core/ingestion/languages/rust/receiver-binding.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import type { CaptureMatch } from 'gitnexus-shared';
|
||||
import { syntheticCapture } from '../../utils/ast-helpers.js';
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
|
||||
/**
|
||||
* Given a function_item node that is inside an impl_item, synthesize a
|
||||
* self-type-binding capture if the function has a `self_parameter`.
|
||||
*
|
||||
* The impl_item structure:
|
||||
* impl [TraitName for] TypeName { fn method(&self) { ... } }
|
||||
*/
|
||||
export function synthesizeRustReceiverBinding(
|
||||
fnNode: SyntaxNode,
|
||||
implNode: SyntaxNode | null,
|
||||
): CaptureMatch | null {
|
||||
if (fnNode.type !== 'function_item') return null;
|
||||
if (implNode === null) return null;
|
||||
|
||||
const params = fnNode.childForFieldName('parameters');
|
||||
if (params === null) return null;
|
||||
|
||||
let hasSelf = false;
|
||||
for (let i = 0; i < params.namedChildCount; i++) {
|
||||
if (params.namedChild(i)?.type === 'self_parameter') {
|
||||
hasSelf = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!hasSelf) return null;
|
||||
|
||||
const implType = getImplTargetType(implNode);
|
||||
if (implType === null) return null;
|
||||
|
||||
return {
|
||||
'@type-binding.self': syntheticCapture('@type-binding.self', fnNode, 'self'),
|
||||
'@type-binding.name': syntheticCapture('@type-binding.name', fnNode, 'self'),
|
||||
'@type-binding.type': syntheticCapture('@type-binding.type', fnNode, implType),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the target type from an impl_item.
|
||||
* `impl TypeName { ... }` → "TypeName"
|
||||
* `impl TraitName for TypeName { ... }` → "TypeName"
|
||||
*/
|
||||
export function getImplTargetType(implNode: SyntaxNode): string | null {
|
||||
if (implNode.type !== 'impl_item') return null;
|
||||
|
||||
// Look for `for` keyword — if present, impl is `impl Trait for Type`
|
||||
let hasFor = false;
|
||||
let typeAfterFor: SyntaxNode | null = null;
|
||||
for (let i = 0; i < implNode.childCount; i++) {
|
||||
const child = implNode.child(i);
|
||||
if (child === null) continue;
|
||||
if (child.type === 'for') {
|
||||
hasFor = true;
|
||||
continue;
|
||||
}
|
||||
if (hasFor && child.type === 'type_identifier') {
|
||||
typeAfterFor = child;
|
||||
break;
|
||||
}
|
||||
if (hasFor && child.type === 'scoped_type_identifier') {
|
||||
typeAfterFor = child;
|
||||
break;
|
||||
}
|
||||
if (hasFor && child.type === 'generic_type') {
|
||||
typeAfterFor = child;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hasFor && typeAfterFor !== null) {
|
||||
return normalizeRustTypeName(typeAfterFor.text);
|
||||
}
|
||||
|
||||
// No `for` keyword: impl TypeName { ... }
|
||||
const typeField = implNode.childForFieldName('type');
|
||||
if (typeField !== null) {
|
||||
return normalizeRustTypeName(typeField.text);
|
||||
}
|
||||
|
||||
// Fallback: find first type_identifier after `impl`
|
||||
let afterImpl = false;
|
||||
for (let i = 0; i < implNode.childCount; i++) {
|
||||
const child = implNode.child(i);
|
||||
if (child === null) continue;
|
||||
if (child.type === 'impl') {
|
||||
afterImpl = true;
|
||||
continue;
|
||||
}
|
||||
if (afterImpl && (child.type === 'type_identifier' || child.type === 'generic_type')) {
|
||||
return normalizeRustTypeName(child.text);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the trait name from an impl_item when it's `impl Trait for Type`.
|
||||
*/
|
||||
export function getImplTraitName(implNode: SyntaxNode): string | null {
|
||||
if (implNode.type !== 'impl_item') return null;
|
||||
|
||||
let afterImpl = false;
|
||||
for (let i = 0; i < implNode.childCount; i++) {
|
||||
const child = implNode.child(i);
|
||||
if (child === null) continue;
|
||||
if (child.type === 'impl') {
|
||||
afterImpl = true;
|
||||
continue;
|
||||
}
|
||||
if (child.type === 'for') {
|
||||
break;
|
||||
}
|
||||
if (
|
||||
afterImpl &&
|
||||
(child.type === 'type_identifier' || child.type === 'scoped_type_identifier')
|
||||
) {
|
||||
for (let j = i + 1; j < implNode.childCount; j++) {
|
||||
const next = implNode.child(j);
|
||||
if (next === null) continue;
|
||||
if (next.type === 'for') {
|
||||
return normalizeRustTypeName(child.text);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeRustTypeName(text: string): string {
|
||||
let t = text.trim();
|
||||
while (t.startsWith('&')) t = t.replace(/^&\s*(mut\s+)?/, '');
|
||||
while (t.startsWith('*')) t = t.slice(1).trim();
|
||||
const bracket = t.indexOf('<');
|
||||
if (bracket !== -1) t = t.slice(0, bracket);
|
||||
return t.trim();
|
||||
}
|
||||
79
gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts
Normal file
79
gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import type { ParsedFile } from 'gitnexus-shared';
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js';
|
||||
import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
|
||||
import { rustProvider } from '../rust.js';
|
||||
import { rustArityCompatibility, rustMergeBindings, resolveRustImportTarget } from './index.js';
|
||||
import { populateRustOwners } from './method-owners.js';
|
||||
import { populateRustRangeBindings } from './range-binding.js';
|
||||
import { isClassLike } from '../../scope-resolution/scope/walkers.js';
|
||||
import { resolveDefGraphId } from '../../scope-resolution/graph-bridge/ids.js';
|
||||
|
||||
function buildRustMro(
|
||||
graph: Parameters<ScopeResolver['buildMro']>[0],
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
nodeLookup: Parameters<ScopeResolver['buildMro']>[2],
|
||||
): Map<string, string[]> {
|
||||
const baseMro = buildMro(graph, parsedFiles, nodeLookup, defaultLinearize);
|
||||
|
||||
const defIdByGraphId = new Map<string, string>();
|
||||
for (const parsed of parsedFiles) {
|
||||
for (const def of parsed.localDefs) {
|
||||
if (!isClassLike(def.type)) continue;
|
||||
const graphId = resolveDefGraphId(parsed.filePath, def, nodeLookup);
|
||||
if (graphId !== undefined) defIdByGraphId.set(graphId, def.nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
const fileByDefId = new Map<string, string>();
|
||||
for (const parsed of parsedFiles) {
|
||||
for (const def of parsed.localDefs) {
|
||||
fileByDefId.set(def.nodeId, parsed.filePath);
|
||||
}
|
||||
}
|
||||
|
||||
for (const rel of graph.iterRelationshipsByType('IMPLEMENTS')) {
|
||||
const childDefId = defIdByGraphId.get(rel.sourceId);
|
||||
const parentDefId = defIdByGraphId.get(rel.targetId);
|
||||
if (childDefId === undefined || parentDefId === undefined) continue;
|
||||
|
||||
const childFile = fileByDefId.get(childDefId);
|
||||
const parentFile = fileByDefId.get(parentDefId);
|
||||
if (childFile !== parentFile) continue;
|
||||
|
||||
const existing = baseMro.get(childDefId);
|
||||
if (existing !== undefined) {
|
||||
if (!existing.includes(parentDefId)) existing.push(parentDefId);
|
||||
} else {
|
||||
baseMro.set(childDefId, [parentDefId]);
|
||||
}
|
||||
}
|
||||
|
||||
return baseMro;
|
||||
}
|
||||
|
||||
export const rustScopeResolver: ScopeResolver = {
|
||||
language: SupportedLanguages.Rust,
|
||||
languageProvider: rustProvider,
|
||||
importEdgeReason: 'rust-scope: use',
|
||||
|
||||
resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) =>
|
||||
resolveRustImportTarget(targetRaw, fromFile, allFilePaths, resolutionConfig),
|
||||
|
||||
mergeBindings: (existing, incoming, scopeId) => rustMergeBindings(existing, incoming, scopeId),
|
||||
|
||||
arityCompatibility: (callsite, def) => rustArityCompatibility(def, callsite),
|
||||
|
||||
buildMro: (graph, parsedFiles, nodeLookup) => buildRustMro(graph, parsedFiles, nodeLookup),
|
||||
|
||||
populateOwners: (parsed: ParsedFile) => populateRustOwners(parsed),
|
||||
|
||||
isSuperReceiver: () => false,
|
||||
|
||||
populateRangeBindings: populateRustRangeBindings,
|
||||
|
||||
fieldFallbackOnMethodLookup: false,
|
||||
hoistTypeBindingsToModule: true,
|
||||
propagatesReturnTypesAcrossImports: true,
|
||||
allowGlobalFreeCallFallback: true,
|
||||
};
|
||||
44
gitnexus/src/core/ingestion/languages/rust/simple-hooks.ts
Normal file
44
gitnexus/src/core/ingestion/languages/rust/simple-hooks.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import type {
|
||||
CaptureMatch,
|
||||
ParsedImport,
|
||||
Scope,
|
||||
ScopeId,
|
||||
ScopeTree,
|
||||
TypeRef,
|
||||
} from 'gitnexus-shared';
|
||||
|
||||
export function rustBindingScopeFor(
|
||||
decl: CaptureMatch,
|
||||
innermost: Scope,
|
||||
_tree: ScopeTree,
|
||||
): ScopeId | null {
|
||||
// Keep self typeBindings in the method's Function scope so
|
||||
// populateRustOwners can match Method defs to their receiver types.
|
||||
if (decl['@type-binding.self'] !== undefined) {
|
||||
return innermost.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rust `use` statements inside a function body should attach at function scope,
|
||||
* not module scope. If the innermost scope is a Function, attach there.
|
||||
*/
|
||||
export function rustImportOwningScope(
|
||||
_imp: ParsedImport,
|
||||
innermost: Scope,
|
||||
_tree: ScopeTree,
|
||||
): ScopeId | null {
|
||||
if (innermost.kind === 'Function') {
|
||||
return innermost.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function rustReceiverBinding(functionScope: Scope): TypeRef | null {
|
||||
if (functionScope.kind !== 'Function') return null;
|
||||
for (const binding of functionScope.typeBindings.values()) {
|
||||
if (binding.source === 'self') return binding;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
@ -32,13 +32,13 @@ export const communitiesPhase: PipelinePhase<CommunitiesOutput> = {
|
|||
|
||||
ctx.onProgress({
|
||||
phase: 'communities',
|
||||
percent: 84,
|
||||
percent: 98,
|
||||
message: 'Detecting code communities...',
|
||||
stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: ctx.graph.nodeCount },
|
||||
});
|
||||
|
||||
const communityResult = await processCommunities(ctx.graph, (message, progress) => {
|
||||
const communityProgress = 84 + progress * 0.09;
|
||||
const communityProgress = 98 + progress * 0.01;
|
||||
ctx.onProgress({
|
||||
phase: 'communities',
|
||||
percent: Math.round(communityProgress),
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Computes Method Resolution Order (MRO) and creates METHOD_OVERRIDES
|
||||
* and METHOD_IMPLEMENTS edges.
|
||||
*
|
||||
* @deps crossFile
|
||||
* @deps crossFile, scopeResolution
|
||||
* @reads graph (all nodes and relationships)
|
||||
* @writes graph (METHOD_OVERRIDES, METHOD_IMPLEMENTS edges)
|
||||
*/
|
||||
|
|
@ -25,7 +25,7 @@ export interface MROOutput {
|
|||
|
||||
export const mroPhase: PipelinePhase<MROOutput> = {
|
||||
name: 'mro',
|
||||
deps: ['crossFile', 'structure'],
|
||||
deps: ['crossFile', 'scopeResolution', 'structure'],
|
||||
|
||||
async execute(
|
||||
ctx: PipelineContext,
|
||||
|
|
@ -35,7 +35,7 @@ export const mroPhase: PipelinePhase<MROOutput> = {
|
|||
|
||||
ctx.onProgress({
|
||||
phase: 'enriching',
|
||||
percent: 83,
|
||||
percent: 98,
|
||||
message: 'Computing method resolution order...',
|
||||
stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: ctx.graph.nodeCount },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ export const processesPhase: PipelinePhase<ProcessesOutput> = {
|
|||
|
||||
ctx.onProgress({
|
||||
phase: 'processes',
|
||||
percent: 94,
|
||||
percent: 99,
|
||||
message: 'Detecting execution flows...',
|
||||
stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: ctx.graph.nodeCount },
|
||||
});
|
||||
|
|
@ -56,7 +56,7 @@ export const processesPhase: PipelinePhase<ProcessesOutput> = {
|
|||
ctx.graph,
|
||||
communityResult.memberships,
|
||||
(message, progress) => {
|
||||
const processProgress = 94 + progress * 0.05;
|
||||
const processProgress = 99 + progress * 0.01;
|
||||
ctx.onProgress({
|
||||
phase: 'processes',
|
||||
percent: Math.round(processProgress),
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ export const MIGRATED_LANGUAGES: ReadonlySet<SupportedLanguages> = new Set<Suppo
|
|||
SupportedLanguages.JavaScript,
|
||||
SupportedLanguages.Kotlin,
|
||||
SupportedLanguages.Java,
|
||||
SupportedLanguages.Rust,
|
||||
]);
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -705,6 +705,7 @@ export interface ScopeResolver {
|
|||
callerScope: ScopeId,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
callsite?: Callsite,
|
||||
) => SymbolDefinition | 'ambiguous' | undefined;
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -501,6 +501,7 @@ export function emitReceiverBoundCalls(
|
|||
site.inScope,
|
||||
scopes,
|
||||
parsedFiles,
|
||||
site,
|
||||
);
|
||||
if (memberDef === 'ambiguous') {
|
||||
// Same-name ambiguity across inline-namespace children (#1564):
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ import type { ParseOutput } from '../../pipeline-phases/parse.js';
|
|||
import { isRegistryPrimary } from '../../registry-primary-flag.js';
|
||||
import { SupportedLanguages, getLanguageFromFilename } from 'gitnexus-shared';
|
||||
import { readFileContents } from '../../filesystem-walker.js';
|
||||
import { runScopeResolution } from './run.js';
|
||||
import { runScopeResolution, type ScopeResolutionSubPhase } from './run.js';
|
||||
import { SCOPE_RESOLVERS } from './registry.js';
|
||||
import { isDev, isSemanticModelValidatorEnabled } from '../../utils/env.js';
|
||||
import type { ResolutionOutcome } from '../resolution-outcome.js';
|
||||
|
|
@ -130,6 +130,31 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
|
|||
}
|
||||
>();
|
||||
|
||||
// Pre-count files and languages for progress reporting. This avoids
|
||||
// a frozen progress bar during long scope-resolution runs (#1741).
|
||||
let totalScopeFiles = 0;
|
||||
let totalScopeLangs = 0;
|
||||
for (const [lang] of SCOPE_RESOLVERS) {
|
||||
if (!isRegistryPrimary(lang)) continue;
|
||||
const count = scannedFiles.filter((f) => getLanguageFromFilename(f.path) === lang).length;
|
||||
if (count > 0) {
|
||||
totalScopeLangs++;
|
||||
totalScopeFiles += count;
|
||||
}
|
||||
}
|
||||
const SCOPE_PCT_START = 90;
|
||||
const SCOPE_PCT_RANGE = 8; // 90-98 internal → 54-59% display
|
||||
let processedScopeFiles = 0;
|
||||
let currentLangIdx = 0;
|
||||
|
||||
if (totalScopeFiles > 0) {
|
||||
ctx.onProgress({
|
||||
phase: 'scopeResolution',
|
||||
percent: SCOPE_PCT_START,
|
||||
message: 'Resolving types',
|
||||
});
|
||||
}
|
||||
|
||||
for (const [lang, provider] of SCOPE_RESOLVERS) {
|
||||
if (!isRegistryPrimary(lang)) continue;
|
||||
|
||||
|
|
@ -153,6 +178,23 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
|
|||
? await provider.loadResolutionConfig(ctx.repoPath)
|
||||
: undefined;
|
||||
|
||||
const langFileCount = files.length;
|
||||
const langLabel = lang.charAt(0).toUpperCase() + lang.slice(1);
|
||||
currentLangIdx++;
|
||||
const langTag =
|
||||
totalScopeLangs > 1 ? `${langLabel} [${currentLangIdx}/${totalScopeLangs}]` : langLabel;
|
||||
|
||||
if (totalScopeFiles > 0) {
|
||||
const pct =
|
||||
SCOPE_PCT_START + Math.round((processedScopeFiles / totalScopeFiles) * SCOPE_PCT_RANGE);
|
||||
ctx.onProgress({
|
||||
phase: 'scopeResolution',
|
||||
percent: pct,
|
||||
message: 'Resolving types',
|
||||
detail: `${langTag}, ${langFileCount.toLocaleString()} files`,
|
||||
});
|
||||
}
|
||||
|
||||
const stats = runScopeResolution(
|
||||
{
|
||||
graph: ctx.graph,
|
||||
|
|
@ -169,6 +211,44 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
|
|||
logger.warn(`[scope-resolution:${lang}] ${msg}`);
|
||||
}
|
||||
},
|
||||
onProgress:
|
||||
totalScopeFiles > 0
|
||||
? (subPhase: ScopeResolutionSubPhase, current, total) => {
|
||||
let langRatio: number;
|
||||
switch (subPhase) {
|
||||
case 'extracting':
|
||||
langRatio = total > 0 ? (current / total) * 0.5 : 0;
|
||||
break;
|
||||
case 'analyzing types':
|
||||
langRatio = 0.5;
|
||||
break;
|
||||
case 'resolving references':
|
||||
langRatio = 0.7;
|
||||
break;
|
||||
case 'linking symbols':
|
||||
langRatio = 0.85;
|
||||
break;
|
||||
default: {
|
||||
const _exhaustive: never = subPhase;
|
||||
langRatio = 0.85;
|
||||
}
|
||||
}
|
||||
const overallRatio = Math.min(
|
||||
1,
|
||||
(processedScopeFiles + langRatio * langFileCount) / totalScopeFiles,
|
||||
);
|
||||
const pct = SCOPE_PCT_START + Math.round(overallRatio * SCOPE_PCT_RANGE);
|
||||
ctx.onProgress({
|
||||
phase: 'scopeResolution',
|
||||
percent: pct,
|
||||
message: 'Resolving types',
|
||||
detail:
|
||||
subPhase === 'extracting'
|
||||
? `${langTag} — extracting ${current.toLocaleString()}/${total.toLocaleString()} files`
|
||||
: `${langTag} — ${subPhase}`,
|
||||
});
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
provider,
|
||||
);
|
||||
|
|
@ -183,6 +263,7 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
|
|||
preExtractedByPath.delete(fp);
|
||||
}
|
||||
|
||||
processedScopeFiles += langFileCount;
|
||||
anyRan = true;
|
||||
totalFiles += stats.filesProcessed;
|
||||
totalImports += stats.importsEmitted;
|
||||
|
|
@ -200,6 +281,15 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
|
|||
}
|
||||
}
|
||||
|
||||
if (totalScopeFiles > 0 && anyRan) {
|
||||
ctx.onProgress({
|
||||
phase: 'scopeResolution',
|
||||
percent: SCOPE_PCT_START + SCOPE_PCT_RANGE,
|
||||
message: 'Resolving types',
|
||||
detail: 'complete',
|
||||
});
|
||||
}
|
||||
|
||||
// Dispose the cross-phase Tree cache — scope-resolution is the
|
||||
// only consumer. Holding Trees past this point is pure memory
|
||||
// pressure: downstream phases (mro, community, csv-generator)
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { javaScopeResolver } from '../../languages/java/scope-resolver.js';
|
|||
import { cScopeResolver } from '../../languages/c/scope-resolver.js';
|
||||
import { cppScopeResolver } from '../../languages/cpp/scope-resolver.js';
|
||||
import { phpScopeResolver } from '../../languages/php/scope-resolver.js';
|
||||
import { rustScopeResolver } from '../../languages/rust/scope-resolver.js';
|
||||
import { javascriptScopeResolver } from '../../languages/javascript/scope-resolver.js';
|
||||
import { kotlinScopeResolver } from '../../languages/kotlin/scope-resolver.js';
|
||||
|
||||
|
|
@ -38,6 +39,7 @@ export const SCOPE_RESOLVERS: ReadonlyMap<SupportedLanguages, ScopeResolver> = n
|
|||
[SupportedLanguages.C, cScopeResolver],
|
||||
[SupportedLanguages.CPlusPlus, cppScopeResolver],
|
||||
[SupportedLanguages.PHP, phpScopeResolver],
|
||||
[SupportedLanguages.Rust, rustScopeResolver],
|
||||
[SupportedLanguages.JavaScript, javascriptScopeResolver],
|
||||
[SupportedLanguages.Kotlin, kotlinScopeResolver],
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -116,6 +116,12 @@ function preEmitInheritanceEdges(
|
|||
return handledSites;
|
||||
}
|
||||
|
||||
export type ScopeResolutionSubPhase =
|
||||
| 'extracting'
|
||||
| 'analyzing types'
|
||||
| 'resolving references'
|
||||
| 'linking symbols';
|
||||
|
||||
interface RunScopeResolutionInput {
|
||||
readonly graph: KnowledgeGraph;
|
||||
/**
|
||||
|
|
@ -167,6 +173,16 @@ interface RunScopeResolutionInput {
|
|||
* intentionally suppress an edge; the graph remains unchanged.
|
||||
*/
|
||||
readonly recordResolutionOutcome?: ResolutionOutcomeRecorder;
|
||||
/**
|
||||
* Optional progress callback for UI updates during long-running scope
|
||||
* resolution. Called periodically during the extract loop and at each
|
||||
* sub-phase boundary (finalize, resolve, emit).
|
||||
*
|
||||
* @param subPhase Current sub-phase name for display
|
||||
* @param current Files processed so far (during extract) or total files (at phase boundaries)
|
||||
* @param total Total files in this language
|
||||
*/
|
||||
readonly onProgress?: (subPhase: ScopeResolutionSubPhase, current: number, total: number) => void;
|
||||
}
|
||||
|
||||
interface RunScopeResolutionStats {
|
||||
|
|
@ -207,7 +223,10 @@ export function runScopeResolution(
|
|||
const treeCache = input.treeCache;
|
||||
const preExtracted = input.preExtractedParsedFiles;
|
||||
let preExtractedHits = 0;
|
||||
for (const file of files) {
|
||||
const progressInterval = files.length > 0 ? Math.max(1, Math.floor(files.length / 50)) : 1;
|
||||
input.onProgress?.('extracting', 0, files.length);
|
||||
for (let fileIdx = 0; fileIdx < files.length; fileIdx++) {
|
||||
const file = files[fileIdx];
|
||||
let parsed: ParsedFile | undefined;
|
||||
// Fast path: a worker (during the parse phase) already produced a
|
||||
// ParsedFile for this file via `extractParsedFile`. Reuse it
|
||||
|
|
@ -232,6 +251,12 @@ export function runScopeResolution(
|
|||
}
|
||||
provider.populateOwners(parsed);
|
||||
parsedFiles.push(parsed);
|
||||
if (
|
||||
input.onProgress &&
|
||||
((fileIdx + 1) % progressInterval === 0 || fileIdx === files.length - 1)
|
||||
) {
|
||||
input.onProgress('extracting', fileIdx + 1, files.length);
|
||||
}
|
||||
}
|
||||
if (PROF && preExtracted !== undefined) {
|
||||
logger.warn(`[scope-resolution prof] pre-extracted hits: ${preExtractedHits}/${files.length}`);
|
||||
|
|
@ -267,6 +292,7 @@ export function runScopeResolution(
|
|||
const tExtract = PROF ? process.hrtime.bigint() : 0n;
|
||||
|
||||
// ── Phase 2: finalize → ScopeResolutionIndexes ─────────────────────────
|
||||
input.onProgress?.('analyzing types', files.length, files.length);
|
||||
const allFilePaths = new Set(parsedFiles.map((f) => f.filePath));
|
||||
const nodeLookup = buildGraphNodeLookup(graph);
|
||||
|
||||
|
|
@ -350,6 +376,7 @@ export function runScopeResolution(
|
|||
validateBindingsImmutability(indexes, onWarn);
|
||||
|
||||
// ── Phase 3: resolve references via Registry.lookup ────────────────────
|
||||
input.onProgress?.('resolving references', files.length, files.length);
|
||||
const registryProviders: RegistryProviders = {
|
||||
arityCompatibility: provider.arityCompatibility,
|
||||
};
|
||||
|
|
@ -362,6 +389,7 @@ export function runScopeResolution(
|
|||
const tResolve = PROF ? process.hrtime.bigint() : 0n;
|
||||
|
||||
// ── Phase 4: emit graph edges (LOAD-BEARING ORDER — see I1) ────────────
|
||||
input.onProgress?.('linking symbols', files.length, files.length);
|
||||
const handledSites = new Set<string>(preEmittedInheritanceSites);
|
||||
const receiverExtras = emitReceiverBoundCalls(
|
||||
graph,
|
||||
|
|
|
|||
|
|
@ -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(() => {});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -163,6 +163,7 @@ export const PHASE_LABELS: Record<string, string> = {
|
|||
imports: 'Resolving imports',
|
||||
calls: 'Tracing calls',
|
||||
heritage: 'Extracting inheritance',
|
||||
scopeResolution: 'Resolving types',
|
||||
communities: 'Detecting communities',
|
||||
processes: 'Detecting processes',
|
||||
complete: 'Pipeline complete',
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import {
|
|||
} from './llm-client.js';
|
||||
|
||||
import { callCursorLLM, resolveCursorConfig } from './cursor-client.js';
|
||||
import { callClaudeLLM, callCodexLLM, resolveLocalCLIConfig } from './local-cli-client.js';
|
||||
|
||||
import {
|
||||
GROUPING_SYSTEM_PROMPT,
|
||||
|
|
@ -203,7 +204,7 @@ export class WikiGenerator {
|
|||
}
|
||||
|
||||
/**
|
||||
* Route LLM call to the appropriate provider (OpenAI-compatible or Cursor CLI).
|
||||
* Route LLM call to the appropriate provider.
|
||||
*/
|
||||
private async invokeLLM(
|
||||
prompt: string,
|
||||
|
|
@ -217,6 +218,16 @@ export class WikiGenerator {
|
|||
});
|
||||
return callCursorLLM(prompt, cursorConfig, systemPrompt, options);
|
||||
}
|
||||
if (this.llmConfig.provider === 'claude' || this.llmConfig.provider === 'codex') {
|
||||
const localConfig = resolveLocalCLIConfig({
|
||||
model: this.llmConfig.model,
|
||||
workingDirectory: this.repoPath,
|
||||
requestTimeoutMs: this.llmConfig.requestTimeoutMs,
|
||||
});
|
||||
return this.llmConfig.provider === 'claude'
|
||||
? callClaudeLLM(prompt, localConfig, systemPrompt, options)
|
||||
: callCodexLLM(prompt, localConfig, systemPrompt, options);
|
||||
}
|
||||
return callLLM(prompt, this.llmConfig, systemPrompt, options);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,14 @@ import { CircuitOpenError, ResilientFetchExhaustedError, resilientFetch } from '
|
|||
* Config priority: CLI flags > env vars > defaults
|
||||
*/
|
||||
|
||||
export type LLMProvider = 'openai' | 'openrouter' | 'azure' | 'custom' | 'cursor';
|
||||
export type LLMProvider =
|
||||
| 'openai'
|
||||
| 'openrouter'
|
||||
| 'azure'
|
||||
| 'custom'
|
||||
| 'cursor'
|
||||
| 'claude'
|
||||
| 'codex';
|
||||
|
||||
export interface LLMConfig {
|
||||
apiKey: string;
|
||||
|
|
@ -18,7 +25,7 @@ export interface LLMConfig {
|
|||
maxTokens: number;
|
||||
temperature: number;
|
||||
/** Provider type — controls auth header behaviour */
|
||||
provider?: 'openai' | 'openrouter' | 'azure' | 'custom' | 'cursor';
|
||||
provider?: LLMProvider;
|
||||
/** Azure api-version query param (e.g. '2024-10-21'). Appended to URL when set. */
|
||||
apiVersion?: string;
|
||||
/** When true, strips sampling params and uses max_completion_tokens instead of max_tokens */
|
||||
|
|
@ -44,6 +51,17 @@ export interface LLMResponse {
|
|||
export async function resolveLLMConfig(overrides?: Partial<LLMConfig>): Promise<LLMConfig> {
|
||||
const { loadCLIConfig } = await import('../../storage/repo-manager.js');
|
||||
const savedConfig = await loadCLIConfig();
|
||||
const savedProvider = overrides?.provider ?? savedConfig.provider;
|
||||
const savedLocalModel =
|
||||
savedProvider === 'cursor'
|
||||
? savedConfig.cursorModel
|
||||
: savedProvider === 'claude'
|
||||
? savedConfig.claudeModel
|
||||
: savedProvider === 'codex'
|
||||
? savedConfig.codexModel
|
||||
: undefined;
|
||||
const localProvider =
|
||||
savedProvider === 'cursor' || savedProvider === 'claude' || savedProvider === 'codex';
|
||||
|
||||
const apiKey =
|
||||
overrides?.apiKey ||
|
||||
|
|
@ -61,13 +79,12 @@ export async function resolveLLMConfig(overrides?: Partial<LLMConfig>): Promise<
|
|||
'https://openrouter.ai/api/v1',
|
||||
model:
|
||||
overrides?.model ||
|
||||
process.env.GITNEXUS_MODEL ||
|
||||
(savedConfig.provider === 'cursor' ? savedConfig.cursorModel : undefined) ||
|
||||
savedConfig.model ||
|
||||
'minimax/minimax-m2.5',
|
||||
(localProvider ? undefined : process.env.GITNEXUS_MODEL) ||
|
||||
savedLocalModel ||
|
||||
(localProvider ? '' : savedConfig.model || 'minimax/minimax-m2.5'),
|
||||
maxTokens: overrides?.maxTokens ?? 16_384,
|
||||
temperature: overrides?.temperature ?? 0,
|
||||
provider: overrides?.provider ?? savedConfig.provider ?? 'openai',
|
||||
provider: savedProvider ?? 'openai',
|
||||
apiVersion:
|
||||
overrides?.apiVersion || process.env.GITNEXUS_AZURE_API_VERSION || savedConfig.apiVersion,
|
||||
isReasoningModel: overrides?.isReasoningModel ?? savedConfig.isReasoningModel,
|
||||
|
|
|
|||
352
gitnexus/src/core/wiki/local-cli-client.ts
Normal file
352
gitnexus/src/core/wiki/local-cli-client.ts
Normal file
|
|
@ -0,0 +1,352 @@
|
|||
/**
|
||||
* Local agent CLI clients for wiki generation.
|
||||
*
|
||||
* These providers use the user's authenticated local CLI session instead of
|
||||
* an OpenAI-compatible HTTP API.
|
||||
*/
|
||||
|
||||
import fs from 'fs/promises';
|
||||
import { existsSync } from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { execFileSync, spawn } from 'child_process';
|
||||
import { StringDecoder } from 'string_decoder';
|
||||
import type { LLMResponse, CallLLMOptions } from './llm-client.js';
|
||||
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
export type LocalAgentProvider = 'claude' | 'codex';
|
||||
|
||||
export interface LocalCLIConfig {
|
||||
model?: string;
|
||||
workingDirectory?: string;
|
||||
requestTimeoutMs?: number;
|
||||
}
|
||||
|
||||
const COMMANDS: Record<LocalAgentProvider, string> = {
|
||||
claude: 'claude',
|
||||
codex: 'codex',
|
||||
};
|
||||
|
||||
interface LocalCommand {
|
||||
displayName: string;
|
||||
command: string;
|
||||
argsPrefix: string[];
|
||||
}
|
||||
|
||||
function killChildTree(child: import('child_process').ChildProcess): void {
|
||||
if (process.platform === 'win32' && child.pid !== undefined) {
|
||||
try {
|
||||
execFileSync('taskkill', ['/T', '/F', '/PID', String(child.pid)], {
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
// Process may have already exited — fall through to child.kill()
|
||||
}
|
||||
}
|
||||
child.kill();
|
||||
}
|
||||
|
||||
function isVerbose(): boolean {
|
||||
return process.env.GITNEXUS_VERBOSE === '1';
|
||||
}
|
||||
|
||||
function verboseLog(provider: LocalAgentProvider, ...args: unknown[]): void {
|
||||
if (isVerbose()) {
|
||||
logger.info({ provider, args }, '[local-cli]');
|
||||
}
|
||||
}
|
||||
|
||||
const cachedCommands = new Map<LocalAgentProvider, LocalCommand | null>();
|
||||
|
||||
export function detectLocalCLI(provider: LocalAgentProvider): string | null {
|
||||
if (cachedCommands.has(provider)) return cachedCommands.get(provider)?.displayName ?? null;
|
||||
const commandInfo = resolveLocalCommand(provider);
|
||||
try {
|
||||
execFileSync(commandInfo.command, [...commandInfo.argsPrefix, '--version'], {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
cachedCommands.set(provider, commandInfo);
|
||||
} catch (err: unknown) {
|
||||
const isNotFound =
|
||||
err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'ENOENT';
|
||||
if (!isNotFound && err instanceof Error) {
|
||||
logger.warn(
|
||||
`${provider} CLI found but --version failed (exit ${(err as { status?: number }).status ?? '?'}). ` +
|
||||
`Ensure it is authenticated: run \`${COMMANDS[provider]} --version\` manually.`,
|
||||
);
|
||||
}
|
||||
cachedCommands.set(provider, null);
|
||||
}
|
||||
return cachedCommands.get(provider)?.displayName ?? null;
|
||||
}
|
||||
|
||||
export function resolveLocalCLIConfig(overrides?: Partial<LocalCLIConfig>): LocalCLIConfig {
|
||||
return {
|
||||
model: overrides?.model,
|
||||
workingDirectory: overrides?.workingDirectory,
|
||||
requestTimeoutMs: overrides?.requestTimeoutMs,
|
||||
};
|
||||
}
|
||||
|
||||
export async function callClaudeLLM(
|
||||
prompt: string,
|
||||
config: LocalCLIConfig,
|
||||
systemPrompt?: string,
|
||||
options?: CallLLMOptions,
|
||||
): Promise<LLMResponse> {
|
||||
const commandInfo = getDetectedCommand('claude');
|
||||
if (!commandInfo) {
|
||||
throw new Error('Claude CLI not found. Install Claude Code and ensure `claude` is on PATH.');
|
||||
}
|
||||
|
||||
const args = ['-p', '--output-format', 'text', '--no-session-persistence'];
|
||||
if (config.model) {
|
||||
args.push('--model', config.model);
|
||||
}
|
||||
const fullPrompt = systemPrompt ? `${systemPrompt}\n\n---\n\n${prompt}` : prompt;
|
||||
|
||||
const response = await runLocalCLI('claude', commandInfo, args, config, fullPrompt, options);
|
||||
if (!response.content) {
|
||||
throw new Error('claude CLI returned empty output');
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function callCodexLLM(
|
||||
prompt: string,
|
||||
config: LocalCLIConfig,
|
||||
systemPrompt?: string,
|
||||
options?: CallLLMOptions,
|
||||
): Promise<LLMResponse> {
|
||||
const commandInfo = getDetectedCommand('codex');
|
||||
if (!commandInfo) {
|
||||
throw new Error('Codex CLI not found. Install Codex CLI and ensure `codex` is on PATH.');
|
||||
}
|
||||
|
||||
const outputDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-wiki-codex-'));
|
||||
const outputPath = path.join(outputDir, 'last-message.txt');
|
||||
const workingDirectory = config.workingDirectory || process.cwd();
|
||||
const fullPrompt = systemPrompt ? `${systemPrompt}\n\n---\n\n${prompt}` : prompt;
|
||||
const args = [
|
||||
'exec',
|
||||
'--cd',
|
||||
workingDirectory,
|
||||
'--sandbox',
|
||||
'read-only',
|
||||
'-c',
|
||||
'approval_policy="never"',
|
||||
'--color',
|
||||
'never',
|
||||
'--output-last-message',
|
||||
outputPath,
|
||||
];
|
||||
|
||||
if (config.model) {
|
||||
args.push('--model', config.model);
|
||||
}
|
||||
args.push('-');
|
||||
|
||||
try {
|
||||
const response = await runLocalCLI('codex', commandInfo, args, config, fullPrompt, options);
|
||||
const lastMessage = await fs.readFile(outputPath, 'utf-8').catch(() => '');
|
||||
const content = (lastMessage || response.content).trim();
|
||||
if (!content) {
|
||||
throw new Error('codex CLI returned empty output');
|
||||
}
|
||||
return { content };
|
||||
} finally {
|
||||
await fs.rm(outputDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function runLocalCLI(
|
||||
provider: LocalAgentProvider,
|
||||
commandInfo: LocalCommand,
|
||||
args: string[],
|
||||
config: LocalCLIConfig,
|
||||
stdinText?: string,
|
||||
options?: CallLLMOptions,
|
||||
): Promise<LLMResponse> {
|
||||
const finalArgs = [...commandInfo.argsPrefix, ...args];
|
||||
verboseLog(
|
||||
provider,
|
||||
'Spawning:',
|
||||
commandInfo.command,
|
||||
maskPromptArgs(provider, finalArgs).join(' '),
|
||||
);
|
||||
verboseLog(provider, 'Working directory:', config.workingDirectory || process.cwd());
|
||||
if (config.model) {
|
||||
verboseLog(provider, 'Model:', config.model);
|
||||
} else {
|
||||
verboseLog(provider, 'Model: default');
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(commandInfo.command, finalArgs, {
|
||||
cwd: config.workingDirectory || process.cwd(),
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
env: {
|
||||
...process.env,
|
||||
CI: '1',
|
||||
},
|
||||
});
|
||||
|
||||
verboseLog(provider, 'Process spawned with PID:', child.pid);
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
const stdoutDecoder = new StringDecoder('utf8');
|
||||
const stderrDecoder = new StringDecoder('utf8');
|
||||
let stdinError: Error | undefined;
|
||||
let settled = false;
|
||||
let killTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const rejectOnce = (error: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (killTimer !== undefined) clearTimeout(killTimer);
|
||||
reject(error);
|
||||
};
|
||||
|
||||
const resolveOnce = (response: LLMResponse) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (killTimer !== undefined) clearTimeout(killTimer);
|
||||
resolve(response);
|
||||
};
|
||||
|
||||
if (config.requestTimeoutMs !== undefined && config.requestTimeoutMs > 0) {
|
||||
killTimer = setTimeout(() => {
|
||||
killChildTree(child);
|
||||
const duration =
|
||||
config.requestTimeoutMs! >= 60_000
|
||||
? `${Math.round(config.requestTimeoutMs! / 60_000)}m`
|
||||
: `${Math.round(config.requestTimeoutMs! / 1_000)}s`;
|
||||
rejectOnce(
|
||||
new Error(
|
||||
`${provider} CLI timed out after ${duration}. ` +
|
||||
'Increase --timeout or omit it to disable the request timeout.',
|
||||
),
|
||||
);
|
||||
}, config.requestTimeoutMs);
|
||||
}
|
||||
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
const chunkStr = stdoutDecoder.write(chunk);
|
||||
stdout += chunkStr;
|
||||
verboseLog(provider, `[stdout] received ${chunkStr.length} chars, total: ${stdout.length}`);
|
||||
options?.onChunk?.(stdout.length);
|
||||
});
|
||||
|
||||
child.stderr.on('data', (chunk: Buffer) => {
|
||||
const chunkStr = stderrDecoder.write(chunk);
|
||||
stderr += chunkStr;
|
||||
verboseLog(provider, '[stderr]', chunkStr.trim());
|
||||
});
|
||||
|
||||
child.stdin.on('error', (err: Error) => {
|
||||
stdinError = err;
|
||||
verboseLog(provider, 'stdin error:', err.message);
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
stdout += stdoutDecoder.end();
|
||||
stderr += stderrDecoder.end();
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
verboseLog(provider, `Process exited with code ${code} after ${elapsed}s`);
|
||||
|
||||
if (code !== 0) {
|
||||
const details = stderr.trim() || stdinError?.message || stdout.trim();
|
||||
rejectOnce(new Error(`${provider} CLI exited with code ${code}: ${details}`));
|
||||
return;
|
||||
}
|
||||
if (stdinError) {
|
||||
rejectOnce(new Error(`${provider} CLI stdin error: ${stdinError.message}`));
|
||||
return;
|
||||
}
|
||||
resolveOnce({ content: stdout.trim() });
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
rejectOnce(new Error(`Failed to spawn ${provider} CLI: ${err.message}`));
|
||||
});
|
||||
|
||||
child.stdin.end(stdinText);
|
||||
});
|
||||
}
|
||||
|
||||
function maskPromptArgs(provider: LocalAgentProvider, args: string[]): string[] {
|
||||
if (provider === 'codex') {
|
||||
return args.map((arg) => (arg === '-' ? '[stdin prompt]' : arg));
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function getDetectedCommand(provider: LocalAgentProvider): LocalCommand | null {
|
||||
detectLocalCLI(provider);
|
||||
return cachedCommands.get(provider) ?? null;
|
||||
}
|
||||
|
||||
function resolveLocalCommand(provider: LocalAgentProvider): LocalCommand {
|
||||
const displayName = COMMANDS[provider];
|
||||
if (process.platform !== 'win32') {
|
||||
return { displayName, command: displayName, argsPrefix: [] };
|
||||
}
|
||||
|
||||
const npmBin = findWindowsCommand(`${displayName}.cmd`) || findWindowsCommand(displayName);
|
||||
if (npmBin) {
|
||||
const binDir = path.dirname(npmBin);
|
||||
if (provider === 'claude') {
|
||||
const exePath = path.join(
|
||||
binDir,
|
||||
'node_modules',
|
||||
'@anthropic-ai',
|
||||
'claude-code',
|
||||
'bin',
|
||||
'claude.exe',
|
||||
);
|
||||
if (existsSync(exePath)) {
|
||||
return { displayName, command: exePath, argsPrefix: [] };
|
||||
}
|
||||
}
|
||||
|
||||
if (provider === 'codex') {
|
||||
const scriptPath = path.join(binDir, 'node_modules', '@openai', 'codex', 'bin', 'codex.js');
|
||||
if (existsSync(scriptPath)) {
|
||||
return { displayName, command: process.execPath, argsPrefix: [scriptPath] };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Last-resort fallback for non-npm Windows installations that only expose a
|
||||
// .cmd shim. Prompts are passed via stdin, so repo content is not placed on
|
||||
// the command line.
|
||||
return {
|
||||
displayName,
|
||||
command: process.env.ComSpec || 'cmd.exe',
|
||||
argsPrefix: ['/d', '/s', '/c', displayName],
|
||||
};
|
||||
}
|
||||
|
||||
function findWindowsCommand(command: string): string | null {
|
||||
try {
|
||||
const output = execFileSync('where.exe', [command], {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
});
|
||||
return (
|
||||
output
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean) ?? null
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -933,8 +933,10 @@ export interface CLIConfig {
|
|||
apiKey?: string;
|
||||
model?: string;
|
||||
baseUrl?: string;
|
||||
provider?: 'openai' | 'openrouter' | 'azure' | 'custom' | 'cursor';
|
||||
provider?: 'openai' | 'openrouter' | 'azure' | 'custom' | 'cursor' | 'claude' | 'codex';
|
||||
cursorModel?: string;
|
||||
claudeModel?: string;
|
||||
codexModel?: string;
|
||||
/** Azure api-version query param (e.g. '2024-10-21'). Only used when provider is 'azure'. */
|
||||
apiVersion?: string;
|
||||
/** Set true when the deployment is a reasoning model (o1, o3, o4-mini). Auto-detected for OpenAI; must be set for Azure deployments. */
|
||||
|
|
|
|||
5
gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-ambiguous-normalized/caller.cpp
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-ambiguous-normalized/caller.cpp
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
#include "lib.h"
|
||||
|
||||
void run() {
|
||||
outer::foo(42);
|
||||
}
|
||||
10
gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-ambiguous-normalized/lib.h
vendored
Normal file
10
gitnexus/test/fixtures/lang-resolution/cpp-inline-namespace-ambiguous-normalized/lib.h
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
#pragma once
|
||||
|
||||
namespace outer {
|
||||
inline namespace v1 {
|
||||
void foo(int x);
|
||||
}
|
||||
inline namespace v2 {
|
||||
void foo(long y);
|
||||
}
|
||||
}
|
||||
6
gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base-cross-ns-deep/caller.cpp
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base-cross-ns-deep/caller.cpp
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
#include "lib.h"
|
||||
|
||||
void run() {
|
||||
Derived<int> d;
|
||||
d.g();
|
||||
}
|
||||
24
gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base-cross-ns-deep/lib.h
vendored
Normal file
24
gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base-cross-ns-deep/lib.h
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
#pragma once
|
||||
|
||||
namespace ns {
|
||||
namespace a { namespace b {
|
||||
template<class T>
|
||||
struct Inner {
|
||||
void f();
|
||||
};
|
||||
} }
|
||||
|
||||
template<class T>
|
||||
struct Derived : a::b::Inner<T> {
|
||||
void g() {
|
||||
this->f();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Second Inner class at global scope forces multi-candidate path
|
||||
// in populateCppDependentBases, exercising the namespace filter.
|
||||
template<class T>
|
||||
struct Inner {
|
||||
void g2();
|
||||
};
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
#include "lib.h"
|
||||
|
||||
void run() {
|
||||
Derived<int> d;
|
||||
d.g();
|
||||
}
|
||||
17
gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base-cross-ns-inline/lib.h
vendored
Normal file
17
gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base-cross-ns-inline/lib.h
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
#pragma once
|
||||
|
||||
namespace ns {
|
||||
inline namespace v1 {
|
||||
template<class T>
|
||||
struct Base {
|
||||
void f();
|
||||
};
|
||||
}
|
||||
|
||||
template<class T>
|
||||
struct Derived : Base<T> {
|
||||
void g() {
|
||||
this->f();
|
||||
}
|
||||
};
|
||||
}
|
||||
6
gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base-cross-ns-neg/caller.cpp
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base-cross-ns-neg/caller.cpp
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
#include "lib.h"
|
||||
|
||||
void run() {
|
||||
Derived<int> d;
|
||||
d.g();
|
||||
}
|
||||
14
gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base-cross-ns-neg/lib.h
vendored
Normal file
14
gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base-cross-ns-neg/lib.h
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
#pragma once
|
||||
|
||||
namespace ns::outer {
|
||||
namespace inner {
|
||||
// No Inner<T> declared here
|
||||
}
|
||||
|
||||
template<class T>
|
||||
struct Derived : inner::Inner<T> {
|
||||
void g() {
|
||||
this->f();
|
||||
}
|
||||
};
|
||||
}
|
||||
6
gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base-cross-ns-pos/caller.cpp
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base-cross-ns-pos/caller.cpp
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
#include "lib.h"
|
||||
|
||||
void run() {
|
||||
Derived<int> d;
|
||||
d.g();
|
||||
}
|
||||
17
gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base-cross-ns-pos/lib.h
vendored
Normal file
17
gitnexus/test/fixtures/lang-resolution/cpp-two-phase-dependent-base-cross-ns-pos/lib.h
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
#pragma once
|
||||
|
||||
namespace ns::outer {
|
||||
namespace inner {
|
||||
template<class T>
|
||||
struct Inner {
|
||||
void f();
|
||||
};
|
||||
}
|
||||
|
||||
template<class T>
|
||||
struct Derived : inner::Inner<T> {
|
||||
void g() {
|
||||
this->f();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
#include "lib.h"
|
||||
|
||||
void run() {
|
||||
Derived<int> d;
|
||||
d.g();
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
#pragma once
|
||||
|
||||
namespace mylib {
|
||||
namespace detail {
|
||||
template<class T>
|
||||
struct Inner {
|
||||
void f_a();
|
||||
};
|
||||
}
|
||||
namespace public_api {
|
||||
template<class T>
|
||||
struct Inner {
|
||||
void f_b();
|
||||
};
|
||||
}
|
||||
|
||||
template<class T>
|
||||
struct Derived : detail::Inner<T> {
|
||||
void g() {
|
||||
this->f_a();
|
||||
}
|
||||
};
|
||||
}
|
||||
430
gitnexus/test/integration/antigravity-hook-e2e.test.ts
Normal file
430
gitnexus/test/integration/antigravity-hook-e2e.test.ts
Normal file
|
|
@ -0,0 +1,430 @@
|
|||
/**
|
||||
* Integration Tests: Antigravity hook adapter end-to-end
|
||||
*
|
||||
* Runs the FULL install + execute flow: invokes the real `setupCommand()`
|
||||
* to lay down the Antigravity hook adapter + helpers + win-rm-list-json.ps1
|
||||
* into a temp HOME, then spawns the installed adapter as a real child
|
||||
* process against a temp git repo + .gitnexus/ directory.
|
||||
*
|
||||
* Why install-then-spawn rather than spawning the source adapter directly:
|
||||
* the source `hooks/antigravity/gitnexus-antigravity-hook.cjs` requires
|
||||
* sibling .cjs helpers (`./hook-lock.cjs`, `./hook-db-lock-probe.cjs`)
|
||||
* that only live in `hooks/claude/`. The adapter is designed to be COPIED
|
||||
* to its install location alongside those helpers — running it from its
|
||||
* source dir fails with MODULE_NOT_FOUND. Exercising the install pipeline
|
||||
* verifies the complete contract documented at
|
||||
* https://geminicli.com/docs/hooks/reference/.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { spawnSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import fsp from 'fs/promises';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { runHook, parseHookOutput } from '../utils/hook-test-helpers.js';
|
||||
import { setupCommand } from '../../src/cli/setup.js';
|
||||
|
||||
let tempHome: string;
|
||||
let installedHook: string;
|
||||
let tmpDir: string;
|
||||
let gitNexusDir: string;
|
||||
const originalHome = process.env.HOME;
|
||||
const originalUserProfile = process.env.USERPROFILE;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Stage a temp HOME with the Antigravity marker dir present so
|
||||
// setupCommand installs the adapter + helpers.
|
||||
tempHome = await fsp.mkdtemp(path.join(os.tmpdir(), 'antigravity-hook-e2e-home-'));
|
||||
process.env.HOME = tempHome;
|
||||
process.env.USERPROFILE = tempHome;
|
||||
await fsp.mkdir(path.join(tempHome, '.gemini', 'antigravity'), { recursive: true });
|
||||
|
||||
// Suppress setupCommand's console.log so test output stays readable.
|
||||
const origLog = console.log;
|
||||
console.log = () => {};
|
||||
try {
|
||||
await setupCommand();
|
||||
} finally {
|
||||
console.log = origLog;
|
||||
}
|
||||
|
||||
installedHook = path.join(
|
||||
tempHome,
|
||||
'.gemini',
|
||||
'config',
|
||||
'hooks',
|
||||
'gitnexus',
|
||||
'gitnexus-antigravity-hook.cjs',
|
||||
);
|
||||
|
||||
// Sanity-check the install. If this fails every downstream test would
|
||||
// produce noisy MODULE_NOT_FOUND output that obscures the real cause.
|
||||
if (!fs.existsSync(installedHook)) {
|
||||
throw new Error(`Antigravity adapter was not installed at ${installedHook}`);
|
||||
}
|
||||
for (const helper of ['hook-lock.cjs', 'hook-db-lock-probe.cjs', 'win-rm-list-json.ps1']) {
|
||||
const helperPath = path.join(path.dirname(installedHook), helper);
|
||||
if (!fs.existsSync(helperPath)) {
|
||||
throw new Error(`Helper not installed: ${helperPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Set up a temp git repo with .gitnexus/ for staleness tests.
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'antigravity-hook-e2e-repo-'));
|
||||
gitNexusDir = path.join(tmpDir, '.gitnexus');
|
||||
fs.mkdirSync(gitNexusDir, { recursive: true });
|
||||
spawnSync('git', ['init'], { cwd: tmpDir, stdio: 'pipe' });
|
||||
spawnSync('git', ['config', 'user.email', 'test@test.com'], { cwd: tmpDir, stdio: 'pipe' });
|
||||
spawnSync('git', ['config', 'user.name', 'Test'], { cwd: tmpDir, stdio: 'pipe' });
|
||||
fs.writeFileSync(path.join(tmpDir, 'hello.txt'), 'hello');
|
||||
spawnSync('git', ['add', '.'], { cwd: tmpDir, stdio: 'pipe' });
|
||||
spawnSync('git', ['commit', '-m', 'init'], { cwd: tmpDir, stdio: 'pipe' });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
process.env.HOME = originalHome;
|
||||
process.env.USERPROFILE = originalUserProfile;
|
||||
if (tempHome) await fsp.rm(tempHome, { recursive: true, force: true });
|
||||
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('antigravity hook adapter e2e', () => {
|
||||
describe('AfterTool — stale-index hint after git mutations', () => {
|
||||
it('emits the hint via both additionalContext and stderr after a successful git commit', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(gitNexusDir, 'meta.json'),
|
||||
JSON.stringify({ lastCommit: 'a'.repeat(40), stats: {} }),
|
||||
);
|
||||
|
||||
const result = runHook(installedHook, {
|
||||
hook_event_name: 'AfterTool',
|
||||
tool_name: 'run_shell_command',
|
||||
tool_input: { command: 'git commit -m "test"' },
|
||||
tool_response: { llmContent: '[committed]' },
|
||||
cwd: tmpDir,
|
||||
});
|
||||
|
||||
const output = parseHookOutput(result.stdout);
|
||||
expect(output).not.toBeNull();
|
||||
expect(output!.hookEventName).toBe('AfterTool');
|
||||
expect(output!.additionalContext).toContain('index is stale');
|
||||
expect(output!.additionalContext).toContain('npx gitnexus analyze');
|
||||
|
||||
// Mirror to stderr so terminal users see the hint even when the agent
|
||||
// discards additionalContext
|
||||
expect(result.stderr).toContain('[GitNexus] index is stale');
|
||||
});
|
||||
|
||||
it('stays silent when meta.json lastCommit matches HEAD', () => {
|
||||
const head = spawnSync('git', ['rev-parse', 'HEAD'], {
|
||||
cwd: tmpDir,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).stdout.trim();
|
||||
fs.writeFileSync(
|
||||
path.join(gitNexusDir, 'meta.json'),
|
||||
JSON.stringify({ lastCommit: head, stats: {} }),
|
||||
);
|
||||
|
||||
const result = runHook(installedHook, {
|
||||
hook_event_name: 'AfterTool',
|
||||
tool_name: 'run_shell_command',
|
||||
tool_input: { command: 'git commit -m "test"' },
|
||||
tool_response: { llmContent: '[committed]' },
|
||||
cwd: tmpDir,
|
||||
});
|
||||
|
||||
expect(parseHookOutput(result.stdout)).toBeNull();
|
||||
expect(result.stderr).not.toContain('[GitNexus] index is stale');
|
||||
});
|
||||
|
||||
it('includes --embeddings flag when the previous index had embeddings', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(gitNexusDir, 'meta.json'),
|
||||
JSON.stringify({
|
||||
lastCommit: 'b'.repeat(40),
|
||||
stats: { embeddings: 42 },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = runHook(installedHook, {
|
||||
hook_event_name: 'AfterTool',
|
||||
tool_name: 'run_shell_command',
|
||||
tool_input: { command: 'git commit -m "x"' },
|
||||
tool_response: { llmContent: '[ok]' },
|
||||
cwd: tmpDir,
|
||||
});
|
||||
|
||||
const output = parseHookOutput(result.stdout);
|
||||
expect(output).not.toBeNull();
|
||||
expect(output!.additionalContext).toContain('--embeddings');
|
||||
});
|
||||
|
||||
it('treats missing meta.json as stale', () => {
|
||||
const metaPath = path.join(gitNexusDir, 'meta.json');
|
||||
if (fs.existsSync(metaPath)) fs.unlinkSync(metaPath);
|
||||
|
||||
const result = runHook(installedHook, {
|
||||
hook_event_name: 'AfterTool',
|
||||
tool_name: 'run_shell_command',
|
||||
tool_input: { command: 'git commit -m "x"' },
|
||||
tool_response: { llmContent: '[ok]' },
|
||||
cwd: tmpDir,
|
||||
});
|
||||
|
||||
const output = parseHookOutput(result.stdout);
|
||||
expect(output).not.toBeNull();
|
||||
expect(output!.additionalContext).toContain('stale');
|
||||
});
|
||||
|
||||
it('skips augment + hint when tool_response carries an error', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(gitNexusDir, 'meta.json'),
|
||||
JSON.stringify({ lastCommit: 'c'.repeat(40), stats: {} }),
|
||||
);
|
||||
|
||||
const result = runHook(installedHook, {
|
||||
hook_event_name: 'AfterTool',
|
||||
tool_name: 'run_shell_command',
|
||||
tool_input: { command: 'git commit -m "x"' },
|
||||
tool_response: { error: 'boom' },
|
||||
cwd: tmpDir,
|
||||
});
|
||||
|
||||
expect(parseHookOutput(result.stdout)).toBeNull();
|
||||
});
|
||||
|
||||
it('skips augment + hint when tool_response.exit_code !== 0', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(gitNexusDir, 'meta.json'),
|
||||
JSON.stringify({ lastCommit: 'd'.repeat(40), stats: {} }),
|
||||
);
|
||||
|
||||
const result = runHook(installedHook, {
|
||||
hook_event_name: 'AfterTool',
|
||||
tool_name: 'run_shell_command',
|
||||
tool_input: { command: 'git commit -m "x"' },
|
||||
tool_response: { llmContent: '...', exit_code: 1 },
|
||||
cwd: tmpDir,
|
||||
});
|
||||
|
||||
expect(parseHookOutput(result.stdout)).toBeNull();
|
||||
});
|
||||
|
||||
it('detects all five documented git mutation types', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(gitNexusDir, 'meta.json'),
|
||||
JSON.stringify({ lastCommit: 'e'.repeat(40), stats: {} }),
|
||||
);
|
||||
|
||||
const mutations = [
|
||||
'git commit -m "x"',
|
||||
'git merge feature',
|
||||
'git rebase main',
|
||||
'git cherry-pick abc123',
|
||||
'git pull origin main',
|
||||
];
|
||||
for (const cmd of mutations) {
|
||||
const result = runHook(installedHook, {
|
||||
hook_event_name: 'AfterTool',
|
||||
tool_name: 'run_shell_command',
|
||||
tool_input: { command: cmd },
|
||||
tool_response: { llmContent: '[ok]' },
|
||||
cwd: tmpDir,
|
||||
});
|
||||
const output = parseHookOutput(result.stdout);
|
||||
expect(output, `mutation: ${cmd}`).not.toBeNull();
|
||||
expect(output!.additionalContext).toContain('stale');
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores non-mutation git commands', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(gitNexusDir, 'meta.json'),
|
||||
JSON.stringify({ lastCommit: 'f'.repeat(40), stats: {} }),
|
||||
);
|
||||
|
||||
const nonMutations = ['git status', 'git log', 'git diff', 'git branch', 'git stash'];
|
||||
for (const cmd of nonMutations) {
|
||||
const result = runHook(installedHook, {
|
||||
hook_event_name: 'AfterTool',
|
||||
tool_name: 'run_shell_command',
|
||||
tool_input: { command: cmd },
|
||||
tool_response: { llmContent: '...' },
|
||||
cwd: tmpDir,
|
||||
});
|
||||
expect(parseHookOutput(result.stdout), `cmd: ${cmd}`).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('AfterTool — augment branch (silent without gitnexus CLI)', () => {
|
||||
it('does not crash on search_file_content with a real pattern', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(gitNexusDir, 'meta.json'),
|
||||
JSON.stringify({ lastCommit: '1'.repeat(40), stats: {} }),
|
||||
);
|
||||
|
||||
const result = runHook(installedHook, {
|
||||
hook_event_name: 'AfterTool',
|
||||
tool_name: 'search_file_content',
|
||||
tool_input: { pattern: 'handleRequest' },
|
||||
tool_response: { llmContent: '...' },
|
||||
cwd: tmpDir,
|
||||
});
|
||||
|
||||
// Either exits cleanly (no augment found) or gets killed by the 10s
|
||||
// hook timeout when spawned gitnexus CLI hangs in CI.
|
||||
expect(result.status === 0 || result.status === null).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores patterns shorter than 3 chars', () => {
|
||||
const result = runHook(installedHook, {
|
||||
hook_event_name: 'AfterTool',
|
||||
tool_name: 'search_file_content',
|
||||
tool_input: { pattern: 'ab' },
|
||||
tool_response: { llmContent: '...' },
|
||||
cwd: tmpDir,
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(parseHookOutput(result.stdout)).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores tool names not in the registered matcher', () => {
|
||||
const result = runHook(installedHook, {
|
||||
hook_event_name: 'AfterTool',
|
||||
tool_name: 'read_file',
|
||||
tool_input: { path: '/some/file.ts' },
|
||||
tool_response: { llmContent: '...' },
|
||||
cwd: tmpDir,
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(parseHookOutput(result.stdout)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cwd validation', () => {
|
||||
it('rejects relative cwd silently', () => {
|
||||
const result = runHook(installedHook, {
|
||||
hook_event_name: 'AfterTool',
|
||||
tool_name: 'run_shell_command',
|
||||
tool_input: { command: 'git commit -m "x"' },
|
||||
tool_response: { llmContent: '[ok]' },
|
||||
cwd: 'relative/path',
|
||||
});
|
||||
|
||||
expect(parseHookOutput(result.stdout)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('unhappy paths', () => {
|
||||
it('handles corrupted meta.json without crashing', () => {
|
||||
fs.writeFileSync(path.join(gitNexusDir, 'meta.json'), 'THIS IS NOT JSON {{{');
|
||||
|
||||
const result = runHook(installedHook, {
|
||||
hook_event_name: 'AfterTool',
|
||||
tool_name: 'run_shell_command',
|
||||
tool_input: { command: 'git commit -m "x"' },
|
||||
tool_response: { llmContent: '[ok]' },
|
||||
cwd: tmpDir,
|
||||
});
|
||||
|
||||
expect(result.status === 0 || result.status === null).toBe(true);
|
||||
});
|
||||
|
||||
it('treats meta.json without lastCommit as stale', () => {
|
||||
fs.writeFileSync(path.join(gitNexusDir, 'meta.json'), JSON.stringify({ stats: {} }));
|
||||
|
||||
const result = runHook(installedHook, {
|
||||
hook_event_name: 'AfterTool',
|
||||
tool_name: 'run_shell_command',
|
||||
tool_input: { command: 'git commit -m "x"' },
|
||||
tool_response: { llmContent: '[ok]' },
|
||||
cwd: tmpDir,
|
||||
});
|
||||
|
||||
const output = parseHookOutput(result.stdout);
|
||||
expect(output).not.toBeNull();
|
||||
expect(output!.additionalContext).toContain('stale');
|
||||
});
|
||||
|
||||
it('ignores unknown hook_event_name', () => {
|
||||
// PreToolUse is the Claude hook event; the Antigravity adapter has no
|
||||
// handler for it and should exit silently.
|
||||
const result = runHook(installedHook, {
|
||||
hook_event_name: 'PreToolUse',
|
||||
tool_name: 'run_shell_command',
|
||||
tool_input: { command: 'git commit -m "x"' },
|
||||
tool_response: { llmContent: '[ok]' },
|
||||
cwd: tmpDir,
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(parseHookOutput(result.stdout)).toBeNull();
|
||||
});
|
||||
|
||||
it('does not crash on empty stdin', () => {
|
||||
const result = spawnSync(process.execPath, [installedHook], {
|
||||
input: '',
|
||||
encoding: 'utf-8',
|
||||
timeout: 10000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
expect(result.status).toBe(0);
|
||||
});
|
||||
|
||||
it('does not crash on missing hook_event_name', () => {
|
||||
const result = runHook(installedHook, {
|
||||
tool_name: 'run_shell_command',
|
||||
tool_input: { command: 'git commit -m "x"' },
|
||||
tool_response: { llmContent: '[ok]' },
|
||||
cwd: tmpDir,
|
||||
});
|
||||
expect(result.status).toBe(0);
|
||||
expect(parseHookOutput(result.stdout)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('directory without .gitnexus', () => {
|
||||
// Nest the test repo deeply at the filesystem root so parent traversal
|
||||
// (5 levels) cannot accidentally pick up a .gitnexus from an ancestor.
|
||||
let noGitNexusDir: string;
|
||||
let cleanupRoot: string;
|
||||
|
||||
beforeAll(() => {
|
||||
const root = os.platform() === 'win32' ? 'C:\\' : '/tmp';
|
||||
cleanupRoot = path.join(root, `no-gitnexus-antigravity-${Date.now()}-${process.pid}`);
|
||||
noGitNexusDir = path.join(cleanupRoot, 'a', 'b', 'c', 'd', 'e', 'f');
|
||||
fs.mkdirSync(noGitNexusDir, { recursive: true });
|
||||
spawnSync('git', ['init'], { cwd: noGitNexusDir, stdio: 'pipe' });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(cleanupRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('ignores AfterTool when no .gitnexus exists in cwd or any ancestor', () => {
|
||||
const result = runHook(installedHook, {
|
||||
hook_event_name: 'AfterTool',
|
||||
tool_name: 'run_shell_command',
|
||||
tool_input: { command: 'git commit -m "x"' },
|
||||
tool_response: { llmContent: '[ok]' },
|
||||
cwd: noGitNexusDir,
|
||||
});
|
||||
expect(parseHookOutput(result.stdout)).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores AfterTool search_file_content when no .gitnexus exists', () => {
|
||||
const result = runHook(installedHook, {
|
||||
hook_event_name: 'AfterTool',
|
||||
tool_name: 'search_file_content',
|
||||
tool_input: { pattern: 'handleRequest' },
|
||||
tool_response: { llmContent: '...' },
|
||||
cwd: noGitNexusDir,
|
||||
});
|
||||
expect(parseHookOutput(result.stdout)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1130,6 +1130,8 @@ describe('CLI end-to-end', () => {
|
|||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain('--provider <provider>');
|
||||
expect(result.stdout).toContain('claude');
|
||||
expect(result.stdout).toContain('codex');
|
||||
expect(result.stdout).toContain('--review');
|
||||
expect(result.stdout).toContain('-v, --verbose');
|
||||
expect(result.stdout).toContain('--model <model>');
|
||||
|
|
@ -1198,6 +1200,22 @@ describe('CLI end-to-end', () => {
|
|||
expect(combined).not.toMatch(/API key:/);
|
||||
});
|
||||
|
||||
it('wiki --provider claude without API key does not prompt for key in non-TTY', () => {
|
||||
const result = runCliRaw(['wiki', MINI_REPO, '--provider', 'claude'], repoRoot, 15000);
|
||||
if (result.status === null) return;
|
||||
|
||||
const combined = result.stdout + result.stderr;
|
||||
expect(combined).not.toMatch(/API key:/);
|
||||
});
|
||||
|
||||
it('wiki --provider codex without API key does not prompt for key in non-TTY', () => {
|
||||
const result = runCliRaw(['wiki', MINI_REPO, '--provider', 'codex'], repoRoot, 15000);
|
||||
if (result.status === null) return;
|
||||
|
||||
const combined = result.stdout + result.stderr;
|
||||
expect(combined).not.toMatch(/API key:/);
|
||||
});
|
||||
|
||||
it('wiki --help includes --verbose flag description', () => {
|
||||
const result = runCliRaw(['wiki', '--help'], repoRoot);
|
||||
if (result.status === null) return;
|
||||
|
|
|
|||
80
gitnexus/test/integration/lbug-non-ascii-path.test.ts
Normal file
80
gitnexus/test/integration/lbug-non-ascii-path.test.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
/**
|
||||
* Integration Tests: Non-ASCII path handling (#1811)
|
||||
*
|
||||
* Verifies that LadybugDB can open a database and run COPY commands when
|
||||
* the storage path contains CJK (or other non-ASCII) characters.
|
||||
*
|
||||
* The primary failure mode is on Windows, where KuzuDB's native layer
|
||||
* uses ANSI file APIs and the Active Code Page mangles UTF-8 bytes.
|
||||
* The fix converts paths to 8.3 short-name form on Windows. On
|
||||
* Linux/macOS the conversion is a no-op since POSIX APIs handle UTF-8
|
||||
* natively — but locale misconfiguration or filesystem encoding
|
||||
* mismatches could still surface, so the test runs on all platforms.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { createMinimalTestGraph } from '../helpers/test-graph.js';
|
||||
|
||||
let tmpBase: string;
|
||||
let storagePath: string;
|
||||
let dbPath: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Create a temp directory with CJK characters in the name.
|
||||
// This reproduces the user's scenario: repo at C:\Project\中文\code
|
||||
tmpBase = path.join(os.tmpdir(), `gitnexus-lbug-非ASCII路径-${Date.now()}-${process.pid}`);
|
||||
storagePath = path.join(tmpBase, '.gitnexus');
|
||||
dbPath = path.join(storagePath, 'lbug');
|
||||
await fs.mkdir(dbPath, { recursive: true });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Close the adapter before cleanup to release native file handles.
|
||||
try {
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
await adapter.closeLbug();
|
||||
} catch {
|
||||
// May not have been opened
|
||||
}
|
||||
|
||||
if (tmpBase) {
|
||||
// Retry cleanup — LadybugDB on Windows holds handles briefly after close.
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
try {
|
||||
await fs.rm(tmpBase, { recursive: true, force: true });
|
||||
return;
|
||||
} catch {
|
||||
if (attempt < 4) await new Promise((r) => setTimeout(r, 200 * (attempt + 1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe('LadybugDB with non-ASCII storage path (#1811)', () => {
|
||||
it('initLbug succeeds with CJK characters in the database path', async () => {
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
await expect(adapter.initLbug(dbPath)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('loadGraphToLbug COPY succeeds with CJK characters in CSV paths', async () => {
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
const graph = createMinimalTestGraph();
|
||||
|
||||
await expect(adapter.loadGraphToLbug(graph, tmpBase, storagePath)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('data is queryable after loading through non-ASCII paths', async () => {
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
|
||||
const files = await adapter.executeQuery('MATCH (n:File) RETURN n.id AS id');
|
||||
expect(files).toHaveLength(2);
|
||||
|
||||
const functions = await adapter.executeQuery('MATCH (n:Function) RETURN n.id AS id');
|
||||
expect(functions).toHaveLength(2);
|
||||
|
||||
const rels = await adapter.executeQuery('MATCH ()-[r:CodeRelation]->() RETURN count(r) AS cnt');
|
||||
expect(rels[0].cnt).toBe(4);
|
||||
});
|
||||
});
|
||||
|
|
@ -2288,6 +2288,93 @@ describe('C++ two-phase template lookup — this-> name-hiding arity mismatch',
|
|||
});
|
||||
});
|
||||
|
||||
describe('C++ two-phase template lookup — dependent-base cross-namespace (nested ns)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(FIXTURES, 'cpp-two-phase-dependent-base-cross-ns-pos'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it('Derived<T>::g() -> this->f() resolves to inner::Inner<T>::f when Inner is in a nested namespace (1 edge)', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const resolved = calls.filter((c) => c.source === 'g' && c.target === 'f');
|
||||
expect(resolved.length).toBe(1);
|
||||
expect(resolved[0].targetFilePath).toContain('lib.h');
|
||||
});
|
||||
});
|
||||
|
||||
describe('C++ two-phase template lookup — dependent-base cross-namespace (negative)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(FIXTURES, 'cpp-two-phase-dependent-base-cross-ns-neg'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it('Derived<T>::g() -> this->f() emits zero CALLS when no Inner<T> exists in the nested namespace', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const leaks = calls.filter((c) => c.source === 'g' && c.target === 'f');
|
||||
expect(leaks.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('C++ two-phase template lookup — dependent-base inline-namespace variant', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(FIXTURES, 'cpp-two-phase-dependent-base-cross-ns-inline'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it('Derived<T>::g() -> this->f() resolves to v1::Base<T>::f when Base is in an inline namespace (1 edge)', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const resolved = calls.filter((c) => c.source === 'g' && c.target === 'f');
|
||||
expect(resolved.length).toBe(1);
|
||||
expect(resolved[0].targetFilePath).toContain('lib.h');
|
||||
});
|
||||
});
|
||||
|
||||
describe('C++ two-phase template lookup — dependent-base deep nesting suppression', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(FIXTURES, 'cpp-two-phase-dependent-base-cross-ns-deep'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it('Derived<T>::g() -> this->f() emits zero CALLS when Inner is two levels deep (ns.a.b) — one-level cap enforced', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const leaks = calls.filter((c) => c.source === 'g' && c.target === 'f');
|
||||
expect(leaks.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('C++ two-phase template lookup — dependent-base sibling-namespace suppression', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(FIXTURES, 'cpp-two-phase-dependent-base-cross-ns-sibling-suppress'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it('Derived<T>::g() -> this->f_a() emits zero CALLS when detail::Inner and public_api::Inner are sibling namespaces (ambiguity suppressed)', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const suppressed = calls.filter((c) => c.source === 'g' && c.target === 'f_a');
|
||||
expect(suppressed.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// U3 cross-file namespace variant: Base lives in a different file AND
|
||||
// inside a namespace. The fixture also contains a free function with the
|
||||
|
|
@ -3044,7 +3131,7 @@ describe('C++ inline namespace — ambiguous same-name across inline children (#
|
|||
});
|
||||
});
|
||||
|
||||
describe('C++ inline namespace — ambiguous distinct signatures (conservative suppress)', () => {
|
||||
describe('C++ inline namespace — distinct signatures resolved via call-site types', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
|
|
@ -3054,14 +3141,35 @@ describe('C++ inline namespace — ambiguous distinct signatures (conservative s
|
|||
);
|
||||
}, 60000);
|
||||
|
||||
it('outer::foo(42) emits zero CALLS edges when v1 declares foo(int) and v2 declares foo(double)', () => {
|
||||
it('outer::foo(42) emits exactly 1 CALLS edge to v1::foo(int) when v1 declares foo(int) and v2 declares foo(double)', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const fooCalls = calls.filter((c) => c.source === 'run' && c.target === 'foo');
|
||||
// Even though the two overloads have distinct signatures and a compiler
|
||||
// could disambiguate via argument types, the `resolveQualifiedReceiverMember`
|
||||
// hook lacks call-site arity/argument-type information, so multi-hit cases
|
||||
// are conservatively suppressed. Documents the limitation noted in
|
||||
// inline-namespaces.ts (Finding 1 of Claude review on #1600).
|
||||
// Call-site arity and argument types are now threaded through the
|
||||
// resolveQualifiedReceiverMember contract (#1632). narrowOverloadCandidates
|
||||
// matches the exact type 'int' against v1::foo(int), producing exactly 1 edge.
|
||||
expect(fooCalls).toHaveLength(1);
|
||||
// Verify it resolved to v1::foo(int) at line 4 (0-indexed), not v2::foo(double) at line 7
|
||||
const targetNode = result.graph.getNode(fooCalls[0].rel.targetId);
|
||||
expect(targetNode?.properties.startLine).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('C++ inline namespace — ambiguous normalized signatures', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(FIXTURES, 'cpp-inline-namespace-ambiguous-normalized'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it('outer::foo(42) emits zero CALLS edges when v1 declares foo(int) and v2 declares foo(long) — both normalize to int', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const fooCalls = calls.filter((c) => c.source === 'run' && c.target === 'foo');
|
||||
// int and long both normalize to 'int' via normalizeCppParamType, making
|
||||
// the two candidates indistinguishable after normalization. The resolver
|
||||
// must suppress rather than pick arbitrarily (isOverloadAmbiguousAfterNormalization).
|
||||
expect(fooCalls.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -332,11 +332,14 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly<Record<string, Readonly
|
|||
'outer::foo() emits zero CALLS edges when v1 and v2 both declare foo',
|
||||
'records a structured suppression reason for inline namespace ambiguity',
|
||||
// Distinct-signature inline-namespace ambiguity: `foo(int)` in v1 and
|
||||
// `foo(double)` in v2. The scope-resolver conservatively suppresses
|
||||
// because `resolveQualifiedReceiverMember` lacks call-site argument
|
||||
// types. Legacy DAG has no inline-namespace resolver. Scope-resolver-
|
||||
// only correctness win (#1600 / Claude review Finding 1).
|
||||
'outer::foo(42) emits zero CALLS edges when v1 declares foo(int) and v2 declares foo(double)',
|
||||
// `foo(double)` in v2. PR #1810 threads call-site types through the
|
||||
// resolveQualifiedReceiverMember contract — resolved in both mode paths.
|
||||
// Legacy DAG emits an edge via the global callable fallback; the test
|
||||
// now expects 1 edge, so the old expected-failure entry is removed.
|
||||
// Normalized-signature ambiguity: `foo(int)` vs `foo(long)` both map to
|
||||
// `int` via normalizeCppParamType. Scope-resolver suppresses via
|
||||
// isOverloadAmbiguousAfterNormalization; legacy path picks arbitrarily.
|
||||
'outer::foo(42) emits zero CALLS edges when v1 declares foo(int) and v2 declares foo(long) — both normalize to int',
|
||||
// PR #1598: ADL free-function reference arg negative fixtures rely on
|
||||
// scope-resolver-only correctness. The legacy DAG falls back to
|
||||
// `pickUniqueGlobalCallable` which resolves the callee by simple-name
|
||||
|
|
@ -355,6 +358,13 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly<Record<string, Readonly
|
|||
'records a structured suppression reason for ADL blocker lookup',
|
||||
'swap(a,b) resolves to data::swap when inner scope has callable swap and outer has variable',
|
||||
'record(e) emits zero CALLS when a block-scope function declaration exists',
|
||||
// PR #1634: sibling-namespace dependent-base suppression. The scope-resolver
|
||||
// correctly suppresses when detail::Inner and public_api::Inner share the
|
||||
// same simple name. The legacy DAG picks an arbitrary match.
|
||||
'Derived<T>::g() -> this->f_a() emits zero CALLS when detail::Inner and public_api::Inner are sibling namespaces (ambiguity suppressed)',
|
||||
// PR #1634: deep-nesting suppression. The scope-resolver enforces a
|
||||
// one-level cap on namespace walking. The legacy DAG picks arbitrarily.
|
||||
'Derived<T>::g() -> this->f() emits zero CALLS when Inner is two levels deep (ns.a.b) — one-level cap enforced',
|
||||
]),
|
||||
};
|
||||
|
||||
|
|
|
|||
1023
gitnexus/test/integration/resolvers/rust-scope.test.ts
Normal file
1023
gitnexus/test/integration/resolvers/rust-scope.test.ts
Normal file
File diff suppressed because it is too large
Load diff
234
gitnexus/test/integration/rust-pipeline-benchmark.test.ts
Normal file
234
gitnexus/test/integration/rust-pipeline-benchmark.test.ts
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
/**
|
||||
* Rust ingestion pipeline benchmark.
|
||||
*
|
||||
* Generates synthetic Rust codebases at increasing scales and measures
|
||||
* wall-clock time and peak heap through the full pipeline — parsing,
|
||||
* scope extraction, impl/trait resolution, use decomposition, and
|
||||
* call resolution.
|
||||
*
|
||||
* Run: GITNEXUS_BENCH=1 npx vitest run test/integration/rust-pipeline-benchmark.test.ts
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
|
||||
|
||||
const BENCH_ENABLED = process.env.GITNEXUS_BENCH === '1';
|
||||
|
||||
interface BenchResult {
|
||||
fileCount: number;
|
||||
structCount: number;
|
||||
moduleCount: number;
|
||||
elapsedMs: number;
|
||||
peakHeapMB: number;
|
||||
nodeCount: number;
|
||||
edgeCount: number;
|
||||
}
|
||||
|
||||
function generateRustFixture(
|
||||
fileCount: number,
|
||||
modulesPerLevel: number,
|
||||
): { dir: string; structCount: number; moduleCount: number } {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `rust-bench-${fileCount}-`));
|
||||
const srcDir = path.join(dir, 'src');
|
||||
fs.mkdirSync(srcDir, { recursive: true });
|
||||
|
||||
const modules: string[] = [];
|
||||
for (let i = 0; i < modulesPerLevel; i++) {
|
||||
for (let j = 0; j < modulesPerLevel; j++) {
|
||||
modules.push(`mod${i}_sub${j}`);
|
||||
}
|
||||
}
|
||||
|
||||
const moduleCount = modules.length;
|
||||
const structCount = fileCount;
|
||||
|
||||
const modDeclarations: string[] = [];
|
||||
const createdModules = new Set<string>();
|
||||
|
||||
for (let f = 0; f < fileCount; f++) {
|
||||
const modName = modules[f % modules.length];
|
||||
const structName = `Item${f}`;
|
||||
const traitName = `Process${f}`;
|
||||
|
||||
if (!createdModules.has(modName)) {
|
||||
const modDir = path.join(srcDir, modName);
|
||||
fs.mkdirSync(modDir, { recursive: true });
|
||||
createdModules.add(modName);
|
||||
modDeclarations.push(`pub mod ${modName};`);
|
||||
}
|
||||
|
||||
const siblingIdx = (f + 1) % fileCount;
|
||||
const siblingStruct = `Item${siblingIdx}`;
|
||||
const siblingMod = modules[siblingIdx % modules.length];
|
||||
|
||||
const crossIdx = (f + Math.floor(fileCount / 3)) % fileCount;
|
||||
const crossStruct = `Item${crossIdx}`;
|
||||
const crossMod = modules[crossIdx % modules.length];
|
||||
|
||||
const needsCrossImport = crossMod !== modName;
|
||||
|
||||
const content = [
|
||||
needsCrossImport ? `use crate::${crossMod}::${crossStruct};` : '',
|
||||
modName !== siblingMod ? `use crate::${siblingMod}::${siblingStruct};` : '',
|
||||
'',
|
||||
`pub trait ${traitName} {`,
|
||||
` fn process(&self) -> String;`,
|
||||
` fn default_method(&self) -> bool { true }`,
|
||||
`}`,
|
||||
'',
|
||||
`pub struct ${structName} {`,
|
||||
` pub id: u64,`,
|
||||
` pub name: String,`,
|
||||
` pub value: f64,`,
|
||||
`}`,
|
||||
'',
|
||||
`impl ${structName} {`,
|
||||
` pub fn new(id: u64, name: String) -> Self {`,
|
||||
` Self { id, name, value: 0.0 }`,
|
||||
` }`,
|
||||
'',
|
||||
` pub fn get_id(&self) -> u64 {`,
|
||||
` self.id`,
|
||||
` }`,
|
||||
'',
|
||||
` pub fn set_value(&mut self, v: f64) {`,
|
||||
` self.value = v;`,
|
||||
` }`,
|
||||
'',
|
||||
` pub fn compute(&self) -> f64 {`,
|
||||
` self.value * self.id as f64`,
|
||||
` }`,
|
||||
`}`,
|
||||
'',
|
||||
`impl ${traitName} for ${structName} {`,
|
||||
` fn process(&self) -> String {`,
|
||||
` format!("{}: {}", self.name, self.compute())`,
|
||||
` }`,
|
||||
`}`,
|
||||
'',
|
||||
`pub fn create_${structName.toLowerCase()}(id: u64) -> ${structName} {`,
|
||||
` let mut item = ${structName}::new(id, String::from("test"));`,
|
||||
` item.set_value(42.0);`,
|
||||
` let _result = item.compute();`,
|
||||
` let _processed = item.process();`,
|
||||
` item`,
|
||||
`}`,
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const modDir = path.join(srcDir, modName);
|
||||
const existingMod = path.join(modDir, 'mod.rs');
|
||||
const fileBaseName = structName.toLowerCase();
|
||||
|
||||
fs.writeFileSync(path.join(modDir, `${fileBaseName}.rs`), content);
|
||||
|
||||
const modEntry = `pub mod ${fileBaseName};\npub use ${fileBaseName}::*;\n`;
|
||||
fs.appendFileSync(existingMod, modEntry);
|
||||
}
|
||||
|
||||
const libContent = modDeclarations.join('\n') + '\n';
|
||||
fs.writeFileSync(path.join(srcDir, 'lib.rs'), libContent);
|
||||
|
||||
const cargoToml = [
|
||||
'[package]',
|
||||
'name = "bench-rust-pipeline"',
|
||||
'version = "0.1.0"',
|
||||
'edition = "2021"',
|
||||
'',
|
||||
].join('\n');
|
||||
fs.writeFileSync(path.join(dir, 'Cargo.toml'), cargoToml);
|
||||
|
||||
return { dir, structCount, moduleCount };
|
||||
}
|
||||
|
||||
async function runBenchmark(
|
||||
fileCount: number,
|
||||
modLevels: number,
|
||||
budgetMs: number,
|
||||
): Promise<BenchResult> {
|
||||
const { dir, structCount, moduleCount } = generateRustFixture(fileCount, modLevels);
|
||||
|
||||
let peakHeapMB = 0;
|
||||
const heapSampler = setInterval(() => {
|
||||
const heap = process.memoryUsage().heapUsed / 1024 / 1024;
|
||||
if (heap > peakHeapMB) peakHeapMB = heap;
|
||||
}, 50);
|
||||
|
||||
try {
|
||||
const start = Date.now();
|
||||
const result = await Promise.race([
|
||||
runPipelineFromRepo(dir, () => {}, { skipGraphPhases: true }),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error(`Pipeline exceeded ${budgetMs}ms at ${fileCount} files`)),
|
||||
budgetMs,
|
||||
),
|
||||
),
|
||||
]);
|
||||
const elapsedMs = Date.now() - start;
|
||||
|
||||
return {
|
||||
fileCount,
|
||||
structCount,
|
||||
moduleCount,
|
||||
elapsedMs,
|
||||
peakHeapMB: Math.round(peakHeapMB),
|
||||
nodeCount: result.graph.nodeCount,
|
||||
edgeCount: result.graph.relationshipCount,
|
||||
};
|
||||
} finally {
|
||||
clearInterval(heapSampler);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function printResults(label: string, results: BenchResult[]) {
|
||||
console.log(`\n${label}`);
|
||||
console.log('┌──────────┬─────────┬──────────┬───────────┬──────────┬───────┬───────┐');
|
||||
console.log('│ Files │ Structs │ Modules │ Time (ms) │ Heap MB │ Nodes │ Edges │');
|
||||
console.log('├──────────┼─────────┼──────────┼───────────┼──────────┼───────┼───────┤');
|
||||
for (const r of results) {
|
||||
console.log(
|
||||
`│ ${String(r.fileCount).padStart(8)} │ ${String(r.structCount).padStart(7)} │ ${String(r.moduleCount).padStart(8)} │ ${String(r.elapsedMs).padStart(9)} │ ${String(r.peakHeapMB).padStart(8)} │ ${String(r.nodeCount).padStart(5)} │ ${String(r.edgeCount).padStart(5)} │`,
|
||||
);
|
||||
}
|
||||
console.log('└──────────┴─────────┴──────────┴───────────┴──────────┴───────┴───────┘');
|
||||
|
||||
if (results.length >= 2) {
|
||||
console.log('\nScaling ratios (time_ratio / file_ratio):');
|
||||
for (let i = 1; i < results.length; i++) {
|
||||
const fileRatio = results[i].fileCount / results[i - 1].fileCount;
|
||||
const timeRatio = results[i].elapsedMs / results[i - 1].elapsedMs;
|
||||
const scaling = timeRatio / fileRatio;
|
||||
console.log(
|
||||
` ${results[i - 1].fileCount} → ${results[i].fileCount}: ${scaling.toFixed(2)}x (${scaling < 1.5 ? 'linear' : scaling < 3 ? 'superlinear' : 'WARNING: quadratic'})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe.skipIf(!BENCH_ENABLED)('Rust pipeline benchmark', () => {
|
||||
it('scales with file count (workers enabled)', async () => {
|
||||
const scales = [100, 250, 500];
|
||||
const results: BenchResult[] = [];
|
||||
|
||||
for (const fileCount of scales) {
|
||||
const modLevels = Math.max(2, Math.ceil(Math.sqrt(fileCount / 4)));
|
||||
const result = await runBenchmark(fileCount, modLevels, 180_000);
|
||||
results.push(result);
|
||||
console.log(
|
||||
` ${fileCount} files: ${result.elapsedMs}ms, ${result.peakHeapMB}MB heap, ${result.nodeCount} nodes, ${result.edgeCount} edges`,
|
||||
);
|
||||
}
|
||||
|
||||
printResults('Rust Pipeline — Workers Enabled', results);
|
||||
|
||||
for (let i = 1; i < results.length; i++) {
|
||||
const fileRatio = results[i].fileCount / results[i - 1].fileCount;
|
||||
const timeRatio = results[i].elapsedMs / results[i - 1].elapsedMs;
|
||||
expect(timeRatio / fileRatio).toBeLessThan(3);
|
||||
}
|
||||
}, 300_000);
|
||||
});
|
||||
262
gitnexus/test/integration/setup-antigravity.test.ts
Normal file
262
gitnexus/test/integration/setup-antigravity.test.ts
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
/**
|
||||
* Integration Tests: setupCommand — Antigravity end-to-end
|
||||
*
|
||||
* Exercises the real `setupCommand()` (no mocks) against a temp HOME with
|
||||
* `~/.gemini/antigravity/` present and verifies the on-disk artifacts: MCP
|
||||
* config, ~/.gemini/settings.json hooks entry, hook adapter + helpers
|
||||
* (including win-rm-list-json.ps1), and installed skills.
|
||||
*
|
||||
* Complements the unit-level setup-antigravity test by running the actual
|
||||
* setup pipeline end-to-end with real filesystem state rather than mocked
|
||||
* spawn/spawnSync.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { setupCommand } from '../../src/cli/setup.js';
|
||||
|
||||
const testDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const packageRoot = path.resolve(testDir, '..', '..');
|
||||
const adapterSource = path.join(
|
||||
packageRoot,
|
||||
'hooks',
|
||||
'antigravity',
|
||||
'gitnexus-antigravity-hook.cjs',
|
||||
);
|
||||
|
||||
describe('setupCommand Antigravity integration', () => {
|
||||
let tempHome: string;
|
||||
const originalHome = process.env.HOME;
|
||||
const originalUserProfile = process.env.USERPROFILE;
|
||||
let antigravityDir: string;
|
||||
let geminiDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-antigravity-int-'));
|
||||
process.env.HOME = tempHome;
|
||||
// os.homedir() honors USERPROFILE on Windows
|
||||
process.env.USERPROFILE = tempHome;
|
||||
geminiDir = path.join(tempHome, '.gemini');
|
||||
antigravityDir = path.join(geminiDir, 'antigravity');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
process.env.HOME = originalHome;
|
||||
process.env.USERPROFILE = originalUserProfile;
|
||||
await fs.rm(tempHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Reset ~/.gemini between tests so each starts from a clean slate but
|
||||
// keeps the antigravity/ marker dir present (so setupAntigravity runs).
|
||||
// Tests that need to verify the "not installed" skip path remove the
|
||||
// marker themselves and restore it at the end.
|
||||
await fs.rm(geminiDir, { recursive: true, force: true });
|
||||
await fs.mkdir(antigravityDir, { recursive: true });
|
||||
});
|
||||
|
||||
it('writes mcp_config.json with a valid mcpServers.gitnexus entry', async () => {
|
||||
await setupCommand();
|
||||
|
||||
const raw = await fs.readFile(path.join(antigravityDir, 'mcp_config.json'), 'utf-8');
|
||||
const config = JSON.parse(raw);
|
||||
|
||||
expect(config.mcpServers).toBeDefined();
|
||||
expect(config.mcpServers.gitnexus).toBeDefined();
|
||||
expect(typeof config.mcpServers.gitnexus.command).toBe('string');
|
||||
expect(Array.isArray(config.mcpServers.gitnexus.args)).toBe(true);
|
||||
// mcp is always the final positional regardless of which command shape
|
||||
// (global binary, npx, or cmd /c npx wrapper) is chosen
|
||||
expect(config.mcpServers.gitnexus.args).toContain('mcp');
|
||||
});
|
||||
|
||||
it('registers an AfterTool entry in ~/.gemini/settings.json with the canonical matcher', async () => {
|
||||
await setupCommand();
|
||||
|
||||
const settingsPath = path.join(geminiDir, 'settings.json');
|
||||
const config = JSON.parse(await fs.readFile(settingsPath, 'utf-8'));
|
||||
|
||||
expect(config.hooks).toBeDefined();
|
||||
expect(config.hooks.AfterTool).toBeInstanceOf(Array);
|
||||
expect(config.hooks.AfterTool).toHaveLength(1);
|
||||
|
||||
const entry = config.hooks.AfterTool[0];
|
||||
expect(entry.matcher).toBe('search_file_content|glob|run_shell_command');
|
||||
expect(Array.isArray(entry.hooks)).toBe(true);
|
||||
expect(entry.hooks).toHaveLength(1);
|
||||
|
||||
const hook = entry.hooks[0];
|
||||
expect(hook.type).toBe('command');
|
||||
expect(hook.name).toBe('gitnexus');
|
||||
expect(hook.command).toMatch(/gitnexus-antigravity-hook\.cjs/);
|
||||
// ms — Gemini CLI uses milliseconds; 10000 ms = 10 s
|
||||
expect(hook.timeout).toBe(10000);
|
||||
});
|
||||
|
||||
it('copies the adapter and all required helpers (including win-rm-list-json.ps1) to ~/.gemini/config/hooks/gitnexus/', async () => {
|
||||
await setupCommand();
|
||||
|
||||
const hooksDir = path.join(geminiDir, 'config', 'hooks', 'gitnexus');
|
||||
for (const file of [
|
||||
'gitnexus-antigravity-hook.cjs',
|
||||
'hook-lock.cjs',
|
||||
'hook-db-lock-probe.cjs',
|
||||
// Required by hook-db-lock-probe.cjs on Windows; without it the MCP
|
||||
// server ownership probe silently fails open and the adapter can race
|
||||
// the MCP server on the LadybugDB.
|
||||
'win-rm-list-json.ps1',
|
||||
]) {
|
||||
await expect(
|
||||
fs.access(path.join(hooksDir, file)),
|
||||
`expected ${file} to be installed`,
|
||||
).resolves.toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('rewrites the adapter cliPath to an absolute resolved path at install time', async () => {
|
||||
await setupCommand();
|
||||
|
||||
const installed = await fs.readFile(
|
||||
path.join(geminiDir, 'config', 'hooks', 'gitnexus', 'gitnexus-antigravity-hook.cjs'),
|
||||
'utf-8',
|
||||
);
|
||||
const source = await fs.readFile(adapterSource, 'utf-8');
|
||||
|
||||
// The source default uses path.resolve(__dirname, '..', '..', 'dist', ...)
|
||||
// which would resolve incorrectly when the adapter is installed outside
|
||||
// the gitnexus package tree (issue #108 regression class). Setup must
|
||||
// replace it with a JSON-string absolute literal pointing at the real CLI.
|
||||
// Under vitest/tsx the resolved __dirname of setup.ts is src/cli/, so the
|
||||
// rewrite resolves to src/cli/index.js; under a packaged install it
|
||||
// resolves to dist/cli/index.js. Accept either.
|
||||
expect(source).toMatch(
|
||||
/path\.resolve\(__dirname, '\.\.', '\.\.', 'dist', 'cli', 'index\.js'\)/,
|
||||
);
|
||||
expect(installed).not.toMatch(
|
||||
/let cliPath = path\.resolve\(__dirname, '\.\.', '\.\.', 'dist', 'cli', 'index\.js'\)/,
|
||||
);
|
||||
expect(installed).toMatch(/let cliPath = "[^"]*(?:src|dist)\/cli\/index\.js"/);
|
||||
});
|
||||
|
||||
it('installs gitnexus skills into ~/.gemini/antigravity/skills/<name>/SKILL.md', async () => {
|
||||
await setupCommand();
|
||||
|
||||
const skillsDir = path.join(antigravityDir, 'skills');
|
||||
const entries = await fs.readdir(skillsDir, { withFileTypes: true });
|
||||
const skillNames = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
||||
|
||||
expect(skillNames).toContain('gitnexus-cli');
|
||||
|
||||
const cliSkill = await fs.readFile(path.join(skillsDir, 'gitnexus-cli', 'SKILL.md'), 'utf-8');
|
||||
expect(cliSkill).toMatch(/GitNexus/i);
|
||||
});
|
||||
|
||||
it('preserves user hooks under BeforeTool and other AfterTool matchers (polite-neighbor merge)', async () => {
|
||||
const settingsPath = path.join(geminiDir, 'settings.json');
|
||||
await fs.writeFile(
|
||||
settingsPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
theme: 'dark',
|
||||
hooks: {
|
||||
BeforeTool: [
|
||||
{
|
||||
matcher: 'write_file',
|
||||
hooks: [{ type: 'command', command: 'echo before', name: 'user-fmt' }],
|
||||
},
|
||||
],
|
||||
AfterTool: [
|
||||
{
|
||||
matcher: 'write_file',
|
||||
hooks: [{ type: 'command', command: 'echo after', name: 'user-fmt' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
await setupCommand();
|
||||
|
||||
const config = JSON.parse(await fs.readFile(settingsPath, 'utf-8'));
|
||||
|
||||
// Unrelated keys preserved
|
||||
expect(config.theme).toBe('dark');
|
||||
|
||||
// User's BeforeTool entry untouched
|
||||
expect(config.hooks.BeforeTool).toHaveLength(1);
|
||||
expect(config.hooks.BeforeTool[0].hooks[0].command).toBe('echo before');
|
||||
|
||||
// Our AfterTool entry appended after the user's
|
||||
expect(config.hooks.AfterTool).toHaveLength(2);
|
||||
expect(config.hooks.AfterTool[0].hooks[0].command).toBe('echo after');
|
||||
expect(config.hooks.AfterTool[1].hooks[0].command).toMatch(/gitnexus-antigravity-hook/);
|
||||
});
|
||||
|
||||
it('is idempotent — re-running setupCommand does not duplicate the AfterTool entry', async () => {
|
||||
await setupCommand();
|
||||
await setupCommand();
|
||||
await setupCommand();
|
||||
|
||||
const config = JSON.parse(await fs.readFile(path.join(geminiDir, 'settings.json'), 'utf-8'));
|
||||
expect(config.hooks.AfterTool).toHaveLength(1);
|
||||
|
||||
const mcpConfig = JSON.parse(
|
||||
await fs.readFile(path.join(antigravityDir, 'mcp_config.json'), 'utf-8'),
|
||||
);
|
||||
// Re-running setup should also leave mcpServers.gitnexus as the single
|
||||
// canonical entry, not duplicate it.
|
||||
expect(Object.keys(mcpConfig.mcpServers)).toEqual(['gitnexus']);
|
||||
});
|
||||
|
||||
it('skips Antigravity setup entirely when ~/.gemini/antigravity is absent', async () => {
|
||||
await fs.rm(geminiDir, { recursive: true, force: true });
|
||||
|
||||
await setupCommand();
|
||||
|
||||
// Neither the MCP config nor the hooks settings should be created when
|
||||
// Antigravity is not installed.
|
||||
await expect(fs.access(path.join(geminiDir, 'settings.json'))).rejects.toThrow();
|
||||
await expect(fs.access(path.join(antigravityDir, 'mcp_config.json'))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('preserves existing keys and other servers when merging into mcp_config.json', async () => {
|
||||
await fs.writeFile(
|
||||
path.join(antigravityDir, 'mcp_config.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
existingKey: 'keep-me',
|
||||
mcpServers: { other: { command: 'foo', args: ['bar'] } },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
await setupCommand();
|
||||
|
||||
const config = JSON.parse(
|
||||
await fs.readFile(path.join(antigravityDir, 'mcp_config.json'), 'utf-8'),
|
||||
);
|
||||
expect(config.existingKey).toBe('keep-me');
|
||||
expect(config.mcpServers.other).toEqual({ command: 'foo', args: ['bar'] });
|
||||
expect(config.mcpServers.gitnexus).toBeDefined();
|
||||
});
|
||||
|
||||
it('leaves a corrupt mcp_config.json untouched rather than overwriting user data', async () => {
|
||||
const mcpPath = path.join(antigravityDir, 'mcp_config.json');
|
||||
const corrupt = '{ definitely not json !!!';
|
||||
await fs.writeFile(mcpPath, corrupt, 'utf-8');
|
||||
|
||||
await setupCommand();
|
||||
|
||||
const raw = await fs.readFile(mcpPath, 'utf-8');
|
||||
expect(raw).toBe(corrupt);
|
||||
});
|
||||
});
|
||||
|
|
@ -220,6 +220,8 @@ describe('CLI help surface', () => {
|
|||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain('--provider <provider>');
|
||||
expect(result.stdout).toContain('claude');
|
||||
expect(result.stdout).toContain('codex');
|
||||
expect(result.stdout).toContain('--review');
|
||||
expect(result.stdout).toContain('-v, --verbose');
|
||||
expect(result.stdout).toContain('--model <model>');
|
||||
|
|
|
|||
36
gitnexus/test/unit/format-elapsed.test.ts
Normal file
36
gitnexus/test/unit/format-elapsed.test.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { formatElapsed } from '../../src/cli/format-elapsed.js';
|
||||
|
||||
describe('formatElapsed', () => {
|
||||
it('formats 0 seconds', () => {
|
||||
expect(formatElapsed(0)).toBe('0s');
|
||||
});
|
||||
|
||||
it('formats seconds below 60', () => {
|
||||
expect(formatElapsed(1)).toBe('1s');
|
||||
expect(formatElapsed(59)).toBe('59s');
|
||||
});
|
||||
|
||||
it('formats exactly 60 seconds as 1m 0s', () => {
|
||||
expect(formatElapsed(60)).toBe('1m 0s');
|
||||
});
|
||||
|
||||
it('formats minutes and seconds', () => {
|
||||
expect(formatElapsed(61)).toBe('1m 1s');
|
||||
expect(formatElapsed(125)).toBe('2m 5s');
|
||||
});
|
||||
|
||||
it('formats the last second before an hour', () => {
|
||||
expect(formatElapsed(3599)).toBe('59m 59s');
|
||||
});
|
||||
|
||||
it('formats exactly 3600 seconds as 1h 0m', () => {
|
||||
expect(formatElapsed(3600)).toBe('1h 0m');
|
||||
});
|
||||
|
||||
it('formats hours and minutes', () => {
|
||||
expect(formatElapsed(3661)).toBe('1h 1m');
|
||||
expect(formatElapsed(7200)).toBe('2h 0m');
|
||||
expect(formatElapsed(7323)).toBe('2h 2m');
|
||||
});
|
||||
});
|
||||
|
|
@ -254,6 +254,10 @@ describe('windowsHide regression', () => {
|
|||
// Hook-layer files. Adding a new hook file MUST be reflected here.
|
||||
const HOOK_FILES: Array<readonly [string, string]> = [
|
||||
['gitnexus/hooks/claude/gitnexus-hook.cjs', CJS_HOOK],
|
||||
[
|
||||
'gitnexus/hooks/antigravity/gitnexus-antigravity-hook.cjs',
|
||||
path.resolve(__dirname, '..', '..', 'hooks', 'antigravity', 'gitnexus-antigravity-hook.cjs'),
|
||||
],
|
||||
[
|
||||
'gitnexus/hooks/claude/hook-db-lock-probe.cjs',
|
||||
path.resolve(__dirname, '..', '..', 'hooks', 'claude', 'hook-db-lock-probe.cjs'),
|
||||
|
|
|
|||
63
gitnexus/test/unit/lbug-native-safe-path.test.ts
Normal file
63
gitnexus/test/unit/lbug-native-safe-path.test.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
/**
|
||||
* Unit Tests: toNativeSafePath
|
||||
*
|
||||
* Verifies the Windows non-ASCII path workaround that converts paths to
|
||||
* 8.3 short-name form before passing them to KuzuDB's native layer.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { toNativeSafePath, cleanupNativePathJunctions } from '../../src/core/lbug/lbug-config.js';
|
||||
|
||||
describe('toNativeSafePath', () => {
|
||||
it('returns ASCII paths unchanged on any platform', () => {
|
||||
const p = 'C:\\Users\\test\\project\\.gitnexus\\lbug';
|
||||
expect(toNativeSafePath(p)).toBe(p);
|
||||
});
|
||||
|
||||
it('returns forward-slash ASCII paths unchanged', () => {
|
||||
const p = '/home/user/project/.gitnexus/lbug';
|
||||
expect(toNativeSafePath(p)).toBe(p);
|
||||
});
|
||||
|
||||
it('returns empty string unchanged', () => {
|
||||
expect(toNativeSafePath('')).toBe('');
|
||||
});
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
it('returns non-ASCII paths unchanged on non-Windows', () => {
|
||||
const p = '/home/用户/project/.gitnexus/lbug';
|
||||
expect(toNativeSafePath(p)).toBe(p);
|
||||
});
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
it('converts a path with non-ASCII parent directory to an ASCII-safe form', () => {
|
||||
// Real-world scenario: repo at C:\Project\中文\code, leaf is ASCII (.gitnexus/lbug).
|
||||
// Create a CJK parent dir with an ASCII leaf to match.
|
||||
const os = require('os');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const cjkParent = path.join(os.tmpdir(), `gn-safepath-测试-${Date.now()}`);
|
||||
const asciiLeaf = path.join(cjkParent, 'lbug');
|
||||
fs.mkdirSync(cjkParent, { recursive: true });
|
||||
try {
|
||||
const result = toNativeSafePath(asciiLeaf);
|
||||
expect(typeof result).toBe('string');
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
// Either 8.3 short path or junction — both produce an all-ASCII result
|
||||
// since the leaf ('lbug') is ASCII and the parent is resolved
|
||||
if (result !== asciiLeaf) {
|
||||
expect(/^[\x00-\x7F]+$/.test(result)).toBe(true);
|
||||
}
|
||||
} finally {
|
||||
cleanupNativePathJunctions();
|
||||
fs.rmSync(cjkParent, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('returns the original path when the target does not exist', () => {
|
||||
const nonexistent = 'C:\\不存在的路径\\test';
|
||||
const result = toNativeSafePath(nonexistent);
|
||||
expect(result).toBe(nonexistent);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -37,6 +37,7 @@ vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({
|
|||
}));
|
||||
vi.mock('../../src/core/lbug/lbug-config.js', () => ({
|
||||
createLbugDatabase: vi.fn(),
|
||||
toNativeSafePath: vi.fn((p: string) => p),
|
||||
isWalCorruptionError: vi.fn(() => false),
|
||||
WAL_RECOVERY_SUGGESTION: '',
|
||||
}));
|
||||
|
|
|
|||
625
gitnexus/test/unit/local-cli-subprocess.test.ts
Normal file
625
gitnexus/test/unit/local-cli-subprocess.test.ts
Normal file
|
|
@ -0,0 +1,625 @@
|
|||
/**
|
||||
* Integration-level tests for local CLI subprocess contracts.
|
||||
*
|
||||
* Validates the actual argv, stdin content, spawn options, and exit
|
||||
* behavior for Claude and Codex providers — the layer that
|
||||
* wiki-flags.test.ts mocks out. Uses a fake spawn that captures
|
||||
* args and emits controlled events.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
function makeFakeChild(opts?: {
|
||||
exitCode?: number;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
stdinEndBehavior?: 'normal' | 'epipe';
|
||||
}) {
|
||||
const child = new EventEmitter() as any;
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
child.stdin = new EventEmitter() as any;
|
||||
child.pid = 12345;
|
||||
child.kill = vi.fn();
|
||||
|
||||
let stdinContent = '';
|
||||
child.stdin.end = vi.fn((data?: string) => {
|
||||
if (data) stdinContent += data;
|
||||
queueMicrotask(() => {
|
||||
if (opts?.stdinEndBehavior === 'epipe') {
|
||||
child.stdin.emit('error', new Error('write EPIPE'));
|
||||
}
|
||||
if (opts?.stdout) {
|
||||
child.stdout.emit('data', Buffer.from(opts.stdout));
|
||||
}
|
||||
if (opts?.stderr) {
|
||||
child.stderr.emit('data', Buffer.from(opts.stderr));
|
||||
}
|
||||
child.emit('close', opts?.exitCode ?? 0);
|
||||
});
|
||||
});
|
||||
|
||||
return { child, getStdin: () => stdinContent };
|
||||
}
|
||||
|
||||
// ─── Claude CLI argv contract ─────────────────────────────────────────
|
||||
|
||||
describe('Claude CLI subprocess contract', () => {
|
||||
let spawnSpy: ReturnType<typeof vi.fn>;
|
||||
let fakeChild: ReturnType<typeof makeFakeChild>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
fakeChild = makeFakeChild({ stdout: 'Claude response text' });
|
||||
spawnSpy = vi.fn(() => fakeChild.child);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('passes correct flags: -p --output-format text --no-session-persistence', async () => {
|
||||
vi.doMock('child_process', () => ({
|
||||
execFileSync: vi.fn().mockReturnValue('claude 1.0.0'),
|
||||
spawn: spawnSpy,
|
||||
}));
|
||||
const { callClaudeLLM } = await import('../../src/core/wiki/local-cli-client.js');
|
||||
|
||||
await callClaudeLLM('user prompt', {});
|
||||
|
||||
const args = spawnSpy.mock.calls[0][1] as string[];
|
||||
expect(args).toContain('-p');
|
||||
expect(args).toContain('--output-format');
|
||||
expect(args).toContain('text');
|
||||
expect(args).toContain('--no-session-persistence');
|
||||
});
|
||||
|
||||
it('appends --model only when model is set', async () => {
|
||||
vi.doMock('child_process', () => ({
|
||||
execFileSync: vi.fn().mockReturnValue('claude 1.0.0'),
|
||||
spawn: spawnSpy,
|
||||
}));
|
||||
const { callClaudeLLM } = await import('../../src/core/wiki/local-cli-client.js');
|
||||
|
||||
await callClaudeLLM('prompt', { model: 'claude-sonnet-4-20250514' });
|
||||
|
||||
const args = spawnSpy.mock.calls[0][1] as string[];
|
||||
expect(args).toContain('--model');
|
||||
expect(args).toContain('claude-sonnet-4-20250514');
|
||||
});
|
||||
|
||||
it('does not include --model when model is empty', async () => {
|
||||
vi.doMock('child_process', () => ({
|
||||
execFileSync: vi.fn().mockReturnValue('claude 1.0.0'),
|
||||
spawn: spawnSpy,
|
||||
}));
|
||||
const { callClaudeLLM } = await import('../../src/core/wiki/local-cli-client.js');
|
||||
|
||||
await callClaudeLLM('prompt', {});
|
||||
|
||||
const args = spawnSpy.mock.calls[0][1] as string[];
|
||||
expect(args).not.toContain('--model');
|
||||
});
|
||||
|
||||
it('sends full prompt (system + separator + user) via stdin', async () => {
|
||||
vi.doMock('child_process', () => ({
|
||||
execFileSync: vi.fn().mockReturnValue('claude 1.0.0'),
|
||||
spawn: spawnSpy,
|
||||
}));
|
||||
const { callClaudeLLM } = await import('../../src/core/wiki/local-cli-client.js');
|
||||
|
||||
await callClaudeLLM('user prompt', {}, 'system prompt');
|
||||
|
||||
const stdinText = fakeChild.getStdin();
|
||||
expect(stdinText).toBe('system prompt\n\n---\n\nuser prompt');
|
||||
});
|
||||
|
||||
it('sends only user prompt when no system prompt', async () => {
|
||||
vi.doMock('child_process', () => ({
|
||||
execFileSync: vi.fn().mockReturnValue('claude 1.0.0'),
|
||||
spawn: spawnSpy,
|
||||
}));
|
||||
const { callClaudeLLM } = await import('../../src/core/wiki/local-cli-client.js');
|
||||
|
||||
await callClaudeLLM('just the user prompt', {});
|
||||
|
||||
expect(fakeChild.getStdin()).toBe('just the user prompt');
|
||||
});
|
||||
|
||||
it('sets CI=1 and windowsHide=true in spawn options', async () => {
|
||||
vi.doMock('child_process', () => ({
|
||||
execFileSync: vi.fn().mockReturnValue('claude 1.0.0'),
|
||||
spawn: spawnSpy,
|
||||
}));
|
||||
const { callClaudeLLM } = await import('../../src/core/wiki/local-cli-client.js');
|
||||
|
||||
await callClaudeLLM('prompt', {});
|
||||
|
||||
const spawnOpts = spawnSpy.mock.calls[0][2];
|
||||
expect(spawnOpts.env.CI).toBe('1');
|
||||
expect(spawnOpts.windowsHide).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects with exit code and stderr on non-zero exit', async () => {
|
||||
fakeChild = makeFakeChild({ exitCode: 1, stderr: 'auth required' });
|
||||
spawnSpy = vi.fn(() => fakeChild.child);
|
||||
|
||||
vi.doMock('child_process', () => ({
|
||||
execFileSync: vi.fn().mockReturnValue('claude 1.0.0'),
|
||||
spawn: spawnSpy,
|
||||
}));
|
||||
const { callClaudeLLM } = await import('../../src/core/wiki/local-cli-client.js');
|
||||
|
||||
await expect(callClaudeLLM('prompt', {})).rejects.toThrow(
|
||||
'claude CLI exited with code 1: auth required',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects with actionable error on empty stdout', async () => {
|
||||
fakeChild = makeFakeChild({ stdout: '' });
|
||||
spawnSpy = vi.fn(() => fakeChild.child);
|
||||
|
||||
vi.doMock('child_process', () => ({
|
||||
execFileSync: vi.fn().mockReturnValue('claude 1.0.0'),
|
||||
spawn: spawnSpy,
|
||||
}));
|
||||
const { callClaudeLLM } = await import('../../src/core/wiki/local-cli-client.js');
|
||||
|
||||
await expect(callClaudeLLM('prompt', {})).rejects.toThrow('claude CLI returned empty output');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Codex CLI argv contract ──────────────────────────────────────────
|
||||
|
||||
describe('Codex CLI subprocess contract', () => {
|
||||
let spawnSpy: ReturnType<typeof vi.fn>;
|
||||
let fakeChild: ReturnType<typeof makeFakeChild>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
fakeChild = makeFakeChild({ stdout: 'codex response' });
|
||||
spawnSpy = vi.fn(() => fakeChild.child);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('passes correct subcommand and flags: exec --sandbox read-only -c approval_policy', async () => {
|
||||
vi.doMock('child_process', () => ({
|
||||
execFileSync: vi.fn().mockReturnValue('codex 0.1.0'),
|
||||
spawn: spawnSpy,
|
||||
}));
|
||||
const { callCodexLLM } = await import('../../src/core/wiki/local-cli-client.js');
|
||||
|
||||
await callCodexLLM('prompt', { workingDirectory: '/repo' });
|
||||
|
||||
const args = spawnSpy.mock.calls[0][1] as string[];
|
||||
expect(args).toContain('exec');
|
||||
expect(args).toContain('--sandbox');
|
||||
expect(args).toContain('read-only');
|
||||
expect(args).toContain('-c');
|
||||
expect(args).toContain('approval_policy="never"');
|
||||
expect(args).toContain('--color');
|
||||
expect(args).toContain('never');
|
||||
});
|
||||
|
||||
it('includes --output-last-message with a temp file path', async () => {
|
||||
vi.doMock('child_process', () => ({
|
||||
execFileSync: vi.fn().mockReturnValue('codex 0.1.0'),
|
||||
spawn: spawnSpy,
|
||||
}));
|
||||
const { callCodexLLM } = await import('../../src/core/wiki/local-cli-client.js');
|
||||
|
||||
await callCodexLLM('prompt', { workingDirectory: '/repo' });
|
||||
|
||||
const args = spawnSpy.mock.calls[0][1] as string[];
|
||||
const outputIdx = args.indexOf('--output-last-message');
|
||||
expect(outputIdx).toBeGreaterThan(-1);
|
||||
const outputPath = args[outputIdx + 1];
|
||||
expect(outputPath).toContain('gitnexus-wiki-codex-');
|
||||
expect(outputPath).toContain('last-message.txt');
|
||||
});
|
||||
|
||||
it('passes --cd with the working directory', async () => {
|
||||
vi.doMock('child_process', () => ({
|
||||
execFileSync: vi.fn().mockReturnValue('codex 0.1.0'),
|
||||
spawn: spawnSpy,
|
||||
}));
|
||||
const { callCodexLLM } = await import('../../src/core/wiki/local-cli-client.js');
|
||||
|
||||
await callCodexLLM('prompt', { workingDirectory: '/my/repo' });
|
||||
|
||||
const args = spawnSpy.mock.calls[0][1] as string[];
|
||||
const cdIdx = args.indexOf('--cd');
|
||||
expect(cdIdx).toBeGreaterThan(-1);
|
||||
expect(args[cdIdx + 1]).toBe('/my/repo');
|
||||
});
|
||||
|
||||
it('ends args with - (stdin marker)', async () => {
|
||||
vi.doMock('child_process', () => ({
|
||||
execFileSync: vi.fn().mockReturnValue('codex 0.1.0'),
|
||||
spawn: spawnSpy,
|
||||
}));
|
||||
const { callCodexLLM } = await import('../../src/core/wiki/local-cli-client.js');
|
||||
|
||||
await callCodexLLM('prompt', { workingDirectory: '/repo' });
|
||||
|
||||
const args = spawnSpy.mock.calls[0][1] as string[];
|
||||
expect(args[args.length - 1]).toBe('-');
|
||||
});
|
||||
|
||||
it('sends full prompt via stdin', async () => {
|
||||
vi.doMock('child_process', () => ({
|
||||
execFileSync: vi.fn().mockReturnValue('codex 0.1.0'),
|
||||
spawn: spawnSpy,
|
||||
}));
|
||||
const { callCodexLLM } = await import('../../src/core/wiki/local-cli-client.js');
|
||||
|
||||
await callCodexLLM('user msg', { workingDirectory: '/repo' }, 'sys msg');
|
||||
|
||||
expect(fakeChild.getStdin()).toBe('sys msg\n\n---\n\nuser msg');
|
||||
});
|
||||
|
||||
it('appends --model only when set', async () => {
|
||||
vi.doMock('child_process', () => ({
|
||||
execFileSync: vi.fn().mockReturnValue('codex 0.1.0'),
|
||||
spawn: spawnSpy,
|
||||
}));
|
||||
const { callCodexLLM } = await import('../../src/core/wiki/local-cli-client.js');
|
||||
|
||||
await callCodexLLM('prompt', { workingDirectory: '/repo', model: 'o3-pro' });
|
||||
|
||||
const args = spawnSpy.mock.calls[0][1] as string[];
|
||||
expect(args).toContain('--model');
|
||||
expect(args).toContain('o3-pro');
|
||||
const modelIdx = args.indexOf('--model');
|
||||
const stdinIdx = args.indexOf('-');
|
||||
expect(modelIdx).toBeLessThan(stdinIdx);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Timeout behavior ─────────────────────────────────────────────────
|
||||
|
||||
describe('local CLI timeout', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('kills child process after requestTimeoutMs and rejects with timeout error', async () => {
|
||||
const child = new EventEmitter() as any;
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
child.stdin = new EventEmitter() as any;
|
||||
child.pid = 99;
|
||||
child.kill = vi.fn();
|
||||
child.stdin.end = vi.fn();
|
||||
|
||||
vi.doMock('child_process', () => ({
|
||||
execFileSync: vi.fn().mockReturnValue('claude 1.0.0'),
|
||||
spawn: vi.fn(() => child),
|
||||
}));
|
||||
const { callClaudeLLM } = await import('../../src/core/wiki/local-cli-client.js');
|
||||
|
||||
const promise = callClaudeLLM('prompt', { requestTimeoutMs: 5000 });
|
||||
|
||||
vi.advanceTimersByTime(5000);
|
||||
child.emit('close', null);
|
||||
await expect(promise).rejects.toThrow('claude CLI timed out after 5s');
|
||||
});
|
||||
|
||||
it('uses taskkill /T /F /PID on Windows for process-tree kill', async () => {
|
||||
const originalPlatform = process.platform;
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
|
||||
try {
|
||||
const child = new EventEmitter() as any;
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
child.stdin = new EventEmitter() as any;
|
||||
child.pid = 42;
|
||||
child.kill = vi.fn();
|
||||
child.stdin.end = vi.fn();
|
||||
|
||||
const execFileSyncSpy = vi.fn().mockImplementation((cmd: string, args: string[]) => {
|
||||
if (cmd !== 'taskkill') return 'claude 1.0.0';
|
||||
return '';
|
||||
});
|
||||
|
||||
vi.doMock('child_process', () => ({
|
||||
execFileSync: execFileSyncSpy,
|
||||
spawn: vi.fn(() => child),
|
||||
}));
|
||||
const { callClaudeLLM } = await import('../../src/core/wiki/local-cli-client.js');
|
||||
|
||||
const promise = callClaudeLLM('prompt', { requestTimeoutMs: 3000 });
|
||||
vi.advanceTimersByTime(3000);
|
||||
// Timeout fires, taskkill runs, but child hasn't emitted close yet.
|
||||
// Emit close now to settle the promise.
|
||||
child.emit('close', null);
|
||||
await expect(promise).rejects.toThrow('claude CLI timed out after 3s');
|
||||
|
||||
const taskkillCalls = execFileSyncSpy.mock.calls.filter(
|
||||
(c: unknown[]) => c[0] === 'taskkill',
|
||||
);
|
||||
expect(taskkillCalls.length).toBe(1);
|
||||
expect(taskkillCalls[0][1]).toEqual(['/T', '/F', '/PID', '42']);
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to child.kill() when taskkill fails on Windows', async () => {
|
||||
const originalPlatform = process.platform;
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
|
||||
try {
|
||||
const child = new EventEmitter() as any;
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
child.stdin = new EventEmitter() as any;
|
||||
child.pid = 42;
|
||||
child.kill = vi.fn();
|
||||
child.stdin.end = vi.fn();
|
||||
|
||||
vi.doMock('child_process', () => ({
|
||||
execFileSync: vi.fn().mockImplementation((cmd: string) => {
|
||||
if (cmd === 'taskkill') throw new Error('taskkill: process not found');
|
||||
return 'claude 1.0.0';
|
||||
}),
|
||||
spawn: vi.fn(() => child),
|
||||
}));
|
||||
const { callClaudeLLM } = await import('../../src/core/wiki/local-cli-client.js');
|
||||
|
||||
const promise = callClaudeLLM('prompt', { requestTimeoutMs: 2000 });
|
||||
vi.advanceTimersByTime(2000);
|
||||
child.emit('close', null);
|
||||
await expect(promise).rejects.toThrow('claude CLI timed out after 2s');
|
||||
expect(child.kill).toHaveBeenCalled();
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
||||
}
|
||||
});
|
||||
|
||||
it('does not set a kill timer when requestTimeoutMs is undefined', async () => {
|
||||
const child = new EventEmitter() as any;
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
child.stdin = new EventEmitter() as any;
|
||||
child.pid = 99;
|
||||
child.kill = vi.fn();
|
||||
child.stdin.end = vi.fn(() => {
|
||||
queueMicrotask(() => {
|
||||
child.stdout.emit('data', Buffer.from('response'));
|
||||
child.emit('close', 0);
|
||||
});
|
||||
});
|
||||
|
||||
vi.doMock('child_process', () => ({
|
||||
execFileSync: vi.fn().mockReturnValue('claude 1.0.0'),
|
||||
spawn: vi.fn(() => child),
|
||||
}));
|
||||
const { callClaudeLLM } = await import('../../src/core/wiki/local-cli-client.js');
|
||||
|
||||
const response = await callClaudeLLM('prompt', {});
|
||||
expect(response.content).toBe('response');
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Codex output file fallback ───────────────────────────────────────
|
||||
|
||||
describe('Codex output file fallback', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('uses stdout when output file is missing', async () => {
|
||||
const fakeChild = makeFakeChild({ stdout: 'stdout content' });
|
||||
|
||||
vi.doMock('child_process', () => ({
|
||||
execFileSync: vi.fn().mockReturnValue('codex 0.1.0'),
|
||||
spawn: vi.fn(() => fakeChild.child),
|
||||
}));
|
||||
const { callCodexLLM } = await import('../../src/core/wiki/local-cli-client.js');
|
||||
|
||||
const result = await callCodexLLM('prompt', { workingDirectory: '/repo' });
|
||||
expect(result.content).toBe('stdout content');
|
||||
});
|
||||
|
||||
it('rejects when both stdout and output file are empty', async () => {
|
||||
const fakeChild = makeFakeChild({ stdout: '' });
|
||||
|
||||
vi.doMock('child_process', () => ({
|
||||
execFileSync: vi.fn().mockReturnValue('codex 0.1.0'),
|
||||
spawn: vi.fn(() => fakeChild.child),
|
||||
}));
|
||||
const { callCodexLLM } = await import('../../src/core/wiki/local-cli-client.js');
|
||||
|
||||
await expect(callCodexLLM('prompt', { workingDirectory: '/repo' })).rejects.toThrow(
|
||||
'codex CLI returned empty output',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── detectLocalCLI diagnostics ───────────────────────────────────────
|
||||
|
||||
describe('detectLocalCLI diagnostics', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('returns null and warns when CLI exists but --version fails (non-ENOENT)', async () => {
|
||||
const warnSpy = vi.fn();
|
||||
vi.doMock('../../src/core/logger.js', () => ({
|
||||
logger: { info: vi.fn(), warn: warnSpy },
|
||||
}));
|
||||
vi.doMock('child_process', () => ({
|
||||
execFileSync: vi.fn().mockImplementation(() => {
|
||||
const err = new Error('exit code 1') as any;
|
||||
err.status = 1;
|
||||
throw err;
|
||||
}),
|
||||
spawn: vi.fn(),
|
||||
}));
|
||||
|
||||
const { detectLocalCLI } = await import('../../src/core/wiki/local-cli-client.js');
|
||||
|
||||
const result = detectLocalCLI('claude');
|
||||
expect(result).toBeNull();
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('--version failed'));
|
||||
});
|
||||
|
||||
it('returns null silently when CLI is truly not found (ENOENT)', async () => {
|
||||
const warnSpy = vi.fn();
|
||||
vi.doMock('../../src/core/logger.js', () => ({
|
||||
logger: { info: vi.fn(), warn: warnSpy },
|
||||
}));
|
||||
vi.doMock('child_process', () => ({
|
||||
execFileSync: vi.fn().mockImplementation(() => {
|
||||
const err = new Error('ENOENT') as any;
|
||||
err.code = 'ENOENT';
|
||||
throw err;
|
||||
}),
|
||||
spawn: vi.fn(),
|
||||
}));
|
||||
|
||||
const { detectLocalCLI } = await import('../../src/core/wiki/local-cli-client.js');
|
||||
|
||||
const result = detectLocalCLI('claude');
|
||||
expect(result).toBeNull();
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── onChunk progress callback ────────────────────────────────────────
|
||||
|
||||
describe('local CLI onChunk callback', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('fires onChunk with cumulative stdout byte count', async () => {
|
||||
const child = new EventEmitter() as any;
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
child.stdin = new EventEmitter() as any;
|
||||
child.pid = 1;
|
||||
child.stdin.end = vi.fn(() => {
|
||||
queueMicrotask(() => {
|
||||
child.stdout.emit('data', Buffer.from('chunk1'));
|
||||
child.stdout.emit('data', Buffer.from('chunk2'));
|
||||
child.emit('close', 0);
|
||||
});
|
||||
});
|
||||
|
||||
vi.doMock('child_process', () => ({
|
||||
execFileSync: vi.fn().mockReturnValue('claude 1.0.0'),
|
||||
spawn: vi.fn(() => child),
|
||||
}));
|
||||
const { callClaudeLLM } = await import('../../src/core/wiki/local-cli-client.js');
|
||||
|
||||
const chunks: number[] = [];
|
||||
await callClaudeLLM('prompt', {}, undefined, { onChunk: (n) => chunks.push(n) });
|
||||
|
||||
expect(chunks).toEqual([6, 12]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Codex CLI flag contract snapshot ─────────────────────────────────
|
||||
|
||||
describe('Codex CLI flag contract snapshot', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('spawn args match the exact expected contract (flag rename = test failure)', async () => {
|
||||
const fakeChild = makeFakeChild({ stdout: 'codex output' });
|
||||
const spawnSpy = vi.fn(() => fakeChild.child);
|
||||
|
||||
vi.doMock('child_process', () => ({
|
||||
execFileSync: vi.fn().mockReturnValue('codex 0.1.0'),
|
||||
spawn: spawnSpy,
|
||||
}));
|
||||
const { callCodexLLM } = await import('../../src/core/wiki/local-cli-client.js');
|
||||
|
||||
await callCodexLLM('prompt', { workingDirectory: '/repo', model: 'o3' });
|
||||
|
||||
const args = spawnSpy.mock.calls[0][1] as string[];
|
||||
|
||||
// The contract flags start at 'exec' — skip any platform argsPrefix
|
||||
// (e.g., ['/d', '/s', '/c', 'codex'] on Windows cmd.exe fallback)
|
||||
const execIdx = args.indexOf('exec');
|
||||
expect(execIdx).toBeGreaterThanOrEqual(0);
|
||||
const contractArgs = args.slice(execIdx);
|
||||
|
||||
// Strip the dynamic temp path for comparison
|
||||
const outputMsgIdx = contractArgs.indexOf('--output-last-message');
|
||||
const normalized = [...contractArgs];
|
||||
if (outputMsgIdx !== -1) {
|
||||
normalized[outputMsgIdx + 1] = '<TEMP_PATH>';
|
||||
}
|
||||
|
||||
expect(normalized).toEqual([
|
||||
'exec',
|
||||
'--cd',
|
||||
'/repo',
|
||||
'--sandbox',
|
||||
'read-only',
|
||||
'-c',
|
||||
'approval_policy="never"',
|
||||
'--color',
|
||||
'never',
|
||||
'--output-last-message',
|
||||
'<TEMP_PATH>',
|
||||
'--model',
|
||||
'o3',
|
||||
'-',
|
||||
]);
|
||||
});
|
||||
|
||||
it('--model appears before - (stdin marker) and after --output-last-message', async () => {
|
||||
const fakeChild = makeFakeChild({ stdout: 'codex output' });
|
||||
const spawnSpy = vi.fn(() => fakeChild.child);
|
||||
|
||||
vi.doMock('child_process', () => ({
|
||||
execFileSync: vi.fn().mockReturnValue('codex 0.1.0'),
|
||||
spawn: spawnSpy,
|
||||
}));
|
||||
const { callCodexLLM } = await import('../../src/core/wiki/local-cli-client.js');
|
||||
|
||||
await callCodexLLM('prompt', { workingDirectory: '/repo', model: 'test-model' });
|
||||
|
||||
const args = spawnSpy.mock.calls[0][1] as string[];
|
||||
const outputIdx = args.indexOf('--output-last-message');
|
||||
const modelIdx = args.indexOf('--model');
|
||||
const stdinIdx = args.lastIndexOf('-');
|
||||
|
||||
expect(outputIdx).toBeLessThan(modelIdx);
|
||||
expect(modelIdx).toBeLessThan(stdinIdx);
|
||||
expect(args[args.length - 1]).toBe('-');
|
||||
});
|
||||
});
|
||||
|
|
@ -35,6 +35,7 @@ vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({
|
|||
|
||||
vi.mock('../../src/core/lbug/lbug-config.js', () => ({
|
||||
createLbugDatabase: vi.fn(),
|
||||
toNativeSafePath: vi.fn((p: string) => p),
|
||||
LBUG_MAX_DB_SIZE: 1024,
|
||||
WAL_RECOVERY_SUGGESTION:
|
||||
'WAL corruption detected. Run `gitnexus analyze --force` to rebuild the index.',
|
||||
|
|
|
|||
|
|
@ -256,6 +256,11 @@ describe('production routes — rate-limit middleware wiring', () => {
|
|||
expect(apiSource).toMatch(/app\.get\('\/api\/health',\s*\(_req,\s*res\)\s*=>/);
|
||||
});
|
||||
|
||||
it('does not register a bare wildcard OPTIONS route under Express 5', () => {
|
||||
expect(apiSource).not.toContain("app.options('*'");
|
||||
expect(apiSource).not.toMatch(/app\.options\(\s*'\/\*'/);
|
||||
});
|
||||
|
||||
it('createServer wires trust proxy to loopback/linklocal/uniquelocal', () => {
|
||||
expect(apiSource).toMatch(
|
||||
/app\.set\(\s*'trust proxy'\s*,\s*'loopback,\s*linklocal,\s*uniquelocal'\s*\)/,
|
||||
|
|
|
|||
107
gitnexus/test/unit/scope-resolution/run-progress.test.ts
Normal file
107
gitnexus/test/unit/scope-resolution/run-progress.test.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import type { ParsedFile, ScopeId, Scope } from 'gitnexus-shared';
|
||||
import {
|
||||
runScopeResolution,
|
||||
type ScopeResolutionSubPhase,
|
||||
} from '../../../src/core/ingestion/scope-resolution/pipeline/run.js';
|
||||
import { createKnowledgeGraph } from '../../../src/core/graph/graph.js';
|
||||
import { createSemanticModel } from '../../../src/core/ingestion/model/semantic-model.js';
|
||||
import type { ScopeResolver } from '../../../src/core/ingestion/scope-resolution/contract/scope-resolver.js';
|
||||
|
||||
const mkScope = (id: ScopeId, filePath: string): Scope => ({
|
||||
id,
|
||||
parent: null,
|
||||
kind: 'Module',
|
||||
range: { startLine: 1, startCol: 0, endLine: 10, endCol: 0 },
|
||||
filePath,
|
||||
bindings: new Map(),
|
||||
ownedDefs: [],
|
||||
imports: [],
|
||||
typeBindings: new Map(),
|
||||
});
|
||||
|
||||
const mkFile = (filePath: string): ParsedFile => ({
|
||||
filePath,
|
||||
moduleScope: `scope:${filePath}#module`,
|
||||
scopes: [mkScope(`scope:${filePath}#module`, filePath)],
|
||||
parsedImports: [],
|
||||
localDefs: [],
|
||||
referenceSites: [],
|
||||
});
|
||||
|
||||
const stubProvider = {
|
||||
language: 'python' as const,
|
||||
languageProvider: {} as ScopeResolver['languageProvider'],
|
||||
importEdgeReason: 'test',
|
||||
populateOwners: () => {},
|
||||
resolveImportTarget: () => null,
|
||||
mergeBindings: (existing: unknown) => existing,
|
||||
buildMro: () => new Map(),
|
||||
propagatesReturnTypesAcrossImports: false,
|
||||
} as unknown as ScopeResolver;
|
||||
|
||||
describe('runScopeResolution onProgress', () => {
|
||||
it('emits sub-phases in order for a 3-file input', () => {
|
||||
const files = [
|
||||
{ path: 'a.py', content: '' },
|
||||
{ path: 'b.py', content: '' },
|
||||
{ path: 'c.py', content: '' },
|
||||
];
|
||||
const preExtracted = new Map<string, ParsedFile>();
|
||||
for (const f of files) preExtracted.set(f.path, mkFile(f.path));
|
||||
|
||||
const calls: { subPhase: ScopeResolutionSubPhase; current: number; total: number }[] = [];
|
||||
const onProgress = (subPhase: ScopeResolutionSubPhase, current: number, total: number) => {
|
||||
calls.push({ subPhase, current, total });
|
||||
};
|
||||
|
||||
runScopeResolution(
|
||||
{
|
||||
graph: createKnowledgeGraph(),
|
||||
model: createSemanticModel(),
|
||||
files,
|
||||
preExtractedParsedFiles: preExtracted,
|
||||
onProgress,
|
||||
},
|
||||
stubProvider,
|
||||
);
|
||||
|
||||
const subPhases = calls.map((c) => c.subPhase);
|
||||
expect(subPhases).toContain('extracting');
|
||||
expect(subPhases).toContain('analyzing types');
|
||||
expect(subPhases).toContain('resolving references');
|
||||
expect(subPhases).toContain('linking symbols');
|
||||
|
||||
const extractCalls = calls.filter((c) => c.subPhase === 'extracting');
|
||||
expect(extractCalls.length).toBeGreaterThan(0);
|
||||
expect(extractCalls[0].total).toBe(3);
|
||||
expect(extractCalls[0].current).toBe(0);
|
||||
expect(extractCalls[extractCalls.length - 1].current).toBe(3);
|
||||
|
||||
const analyzeIdx = subPhases.indexOf('analyzing types');
|
||||
const resolveIdx = subPhases.indexOf('resolving references');
|
||||
const linkIdx = subPhases.indexOf('linking symbols');
|
||||
expect(analyzeIdx).toBeLessThan(resolveIdx);
|
||||
expect(resolveIdx).toBeLessThan(linkIdx);
|
||||
});
|
||||
|
||||
it('emits only extracting (0, 0) then returns early for 0-file input', () => {
|
||||
const calls: { subPhase: ScopeResolutionSubPhase; current: number; total: number }[] = [];
|
||||
const onProgress = (subPhase: ScopeResolutionSubPhase, current: number, total: number) => {
|
||||
calls.push({ subPhase, current, total });
|
||||
};
|
||||
|
||||
const stats = runScopeResolution(
|
||||
{
|
||||
graph: createKnowledgeGraph(),
|
||||
model: createSemanticModel(),
|
||||
files: [],
|
||||
onProgress,
|
||||
},
|
||||
stubProvider,
|
||||
);
|
||||
|
||||
expect(stats.filesProcessed).toBe(0);
|
||||
expect(calls).toEqual([{ subPhase: 'extracting', current: 0, total: 0 }]);
|
||||
});
|
||||
});
|
||||
479
gitnexus/test/unit/setup-antigravity.test.ts
Normal file
479
gitnexus/test/unit/setup-antigravity.test.ts
Normal file
|
|
@ -0,0 +1,479 @@
|
|||
/**
|
||||
* Regression Tests: Antigravity setup + hook adapter
|
||||
*
|
||||
* Covers:
|
||||
* - setupAntigravity: detection of ~/.gemini/antigravity, MCP write, preserve
|
||||
* existing keys, corrupt-file handling, skips when not installed.
|
||||
* - installAntigravityHooks: writes ~/.gemini/settings.json with an
|
||||
* AfterTool entry under the canonical Gemini CLI / Antigravity 2.0 layout
|
||||
* (https://geminicli.com/docs/hooks/reference/); copies the adapter and
|
||||
* lock helpers to ~/.gemini/config/hooks/gitnexus/; idempotent across
|
||||
* re-runs; preserves existing user hooks ("polite neighbor").
|
||||
* - installAntigravitySkills: lays out skills under ~/.gemini/antigravity/skills/.
|
||||
* - hook adapter: AfterTool emits `{hookSpecificOutput.additionalContext}`
|
||||
* with graph context after search-like tools; emits a stale-index hint
|
||||
* after a successful `git commit/merge/rebase/cherry-pick/pull`; ignores
|
||||
* unrelated tools silently.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import fs from 'fs/promises';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { createRequire } from 'module';
|
||||
|
||||
const PKG_VERSION = (createRequire(import.meta.url)('../../package.json') as { version: string })
|
||||
.version;
|
||||
const NPX_REF = `gitnexus@${PKG_VERSION}`;
|
||||
|
||||
// vi.hoisted lets the mock factory below (which is hoisted by Vitest) see
|
||||
// these vi.fn instances. Plain top-level consts would be unreachable at
|
||||
// hoist time, hence the error this pattern avoids.
|
||||
const mocks = vi.hoisted(() => ({
|
||||
execFileMock: vi.fn((...args: any[]) => {
|
||||
const callback = args.at(-1);
|
||||
if (typeof callback === 'function') callback(null, '', '');
|
||||
}),
|
||||
execFileSyncMock: vi.fn(() => {
|
||||
throw new Error('not found');
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('child_process', async () => {
|
||||
// Partial mock: real spawnSync is needed for the hook-adapter tests below
|
||||
// to actually invoke the .cjs script as a child process.
|
||||
const actual = await vi.importActual<typeof import('child_process')>('child_process');
|
||||
return {
|
||||
...actual,
|
||||
execFile: mocks.execFileMock,
|
||||
execFileSync: mocks.execFileSyncMock,
|
||||
};
|
||||
});
|
||||
|
||||
describe('setupAntigravity', () => {
|
||||
let tempHome: string;
|
||||
let originalHome: string | undefined;
|
||||
let originalUserProfile: string | undefined;
|
||||
let platformDescriptor: PropertyDescriptor | undefined;
|
||||
|
||||
const setPlatform = (value: NodeJS.Platform) => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value,
|
||||
configurable: true,
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
|
||||
originalHome = process.env.HOME;
|
||||
originalUserProfile = process.env.USERPROFILE;
|
||||
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-antigravity-setup-'));
|
||||
process.env.HOME = tempHome;
|
||||
process.env.USERPROFILE = tempHome;
|
||||
|
||||
// Only create ~/.gemini/antigravity — no other editor dirs so their
|
||||
// setup branches skip and don't pollute assertions.
|
||||
await fs.mkdir(path.join(tempHome, '.gemini', 'antigravity'), { recursive: true });
|
||||
|
||||
platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
// Default to a non-win32 platform so the MCP entry shape is deterministic
|
||||
// across CI runners. Tests that need win32 behavior override this.
|
||||
setPlatform('darwin');
|
||||
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
|
||||
if (platformDescriptor) {
|
||||
Object.defineProperty(process, 'platform', platformDescriptor);
|
||||
}
|
||||
|
||||
process.env.HOME = originalHome;
|
||||
process.env.USERPROFILE = originalUserProfile;
|
||||
await fs.rm(tempHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('writes MCP config to ~/.gemini/antigravity/mcp_config.json', async () => {
|
||||
const { setupCommand } = await import('../../src/cli/setup.js');
|
||||
await setupCommand();
|
||||
|
||||
const raw = await fs.readFile(
|
||||
path.join(tempHome, '.gemini', 'antigravity', 'mcp_config.json'),
|
||||
'utf-8',
|
||||
);
|
||||
const config = JSON.parse(raw);
|
||||
|
||||
expect(config.mcpServers.gitnexus).toEqual({
|
||||
command: 'npx',
|
||||
args: ['-y', NPX_REF, 'mcp'],
|
||||
});
|
||||
});
|
||||
|
||||
it('writes win32 MCP entry with cmd wrapper', async () => {
|
||||
setPlatform('win32');
|
||||
|
||||
const { setupCommand } = await import('../../src/cli/setup.js');
|
||||
await setupCommand();
|
||||
|
||||
const raw = await fs.readFile(
|
||||
path.join(tempHome, '.gemini', 'antigravity', 'mcp_config.json'),
|
||||
'utf-8',
|
||||
);
|
||||
const config = JSON.parse(raw);
|
||||
|
||||
expect(config.mcpServers.gitnexus).toEqual({
|
||||
command: 'cmd',
|
||||
args: ['/c', 'npx', '-y', NPX_REF, 'mcp'],
|
||||
});
|
||||
});
|
||||
|
||||
it('skips when ~/.gemini/antigravity does not exist', async () => {
|
||||
await fs.rm(path.join(tempHome, '.gemini'), { recursive: true, force: true });
|
||||
|
||||
const { setupCommand } = await import('../../src/cli/setup.js');
|
||||
await setupCommand();
|
||||
|
||||
await expect(
|
||||
fs.access(path.join(tempHome, '.gemini', 'antigravity', 'mcp_config.json')),
|
||||
).rejects.toThrow();
|
||||
await expect(fs.access(path.join(tempHome, '.gemini', 'settings.json'))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('preserves existing keys in mcp_config.json', async () => {
|
||||
const mcpPath = path.join(tempHome, '.gemini', 'antigravity', 'mcp_config.json');
|
||||
await fs.writeFile(
|
||||
mcpPath,
|
||||
JSON.stringify({ existingKey: 'keep-me', mcpServers: { other: { command: 'foo' } } }),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const { setupCommand } = await import('../../src/cli/setup.js');
|
||||
await setupCommand();
|
||||
|
||||
const raw = await fs.readFile(mcpPath, 'utf-8');
|
||||
const config = JSON.parse(raw);
|
||||
|
||||
expect(config.existingKey).toBe('keep-me');
|
||||
expect(config.mcpServers.other).toEqual({ command: 'foo' });
|
||||
expect(config.mcpServers.gitnexus).toBeDefined();
|
||||
});
|
||||
|
||||
it('leaves a corrupt mcp_config.json untouched', async () => {
|
||||
const mcpPath = path.join(tempHome, '.gemini', 'antigravity', 'mcp_config.json');
|
||||
const corrupt = '{ definitely not json !!!';
|
||||
await fs.writeFile(mcpPath, corrupt, 'utf-8');
|
||||
|
||||
const { setupCommand } = await import('../../src/cli/setup.js');
|
||||
await setupCommand();
|
||||
|
||||
const raw = await fs.readFile(mcpPath, 'utf-8');
|
||||
expect(raw).toBe(corrupt);
|
||||
});
|
||||
|
||||
it('writes ~/.gemini/settings.json with an AfterTool entry under hooks', async () => {
|
||||
const { setupCommand } = await import('../../src/cli/setup.js');
|
||||
await setupCommand();
|
||||
|
||||
const raw = await fs.readFile(path.join(tempHome, '.gemini', 'settings.json'), 'utf-8');
|
||||
const config = JSON.parse(raw);
|
||||
|
||||
expect(config.hooks.AfterTool).toBeInstanceOf(Array);
|
||||
expect(config.hooks.AfterTool[0].matcher).toBe('search_file_content|glob|run_shell_command');
|
||||
expect(config.hooks.AfterTool[0].hooks[0].command).toMatch(/gitnexus-antigravity-hook\.cjs/);
|
||||
expect(config.hooks.AfterTool[0].hooks[0].timeout).toBe(10000);
|
||||
expect(config.hooks.AfterTool[0].hooks[0].name).toBe('gitnexus');
|
||||
});
|
||||
|
||||
it('is idempotent — re-running setup does not duplicate hook entries', async () => {
|
||||
const { setupCommand } = await import('../../src/cli/setup.js');
|
||||
await setupCommand();
|
||||
await setupCommand();
|
||||
|
||||
const raw = await fs.readFile(path.join(tempHome, '.gemini', 'settings.json'), 'utf-8');
|
||||
const config = JSON.parse(raw);
|
||||
|
||||
expect(config.hooks.AfterTool).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('preserves existing user hooks in settings.json (polite-neighbor merge)', async () => {
|
||||
const settingsPath = path.join(tempHome, '.gemini', 'settings.json');
|
||||
await fs.writeFile(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
theme: 'dark',
|
||||
hooks: {
|
||||
AfterTool: [
|
||||
{
|
||||
matcher: 'write_file',
|
||||
hooks: [{ type: 'command', command: 'echo "user-hook"', name: 'user-formatter' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const { setupCommand } = await import('../../src/cli/setup.js');
|
||||
await setupCommand();
|
||||
|
||||
const raw = await fs.readFile(settingsPath, 'utf-8');
|
||||
const config = JSON.parse(raw);
|
||||
|
||||
// Unrelated keys preserved
|
||||
expect(config.theme).toBe('dark');
|
||||
|
||||
// User's hook still present
|
||||
expect(config.hooks.AfterTool).toHaveLength(2);
|
||||
expect(config.hooks.AfterTool[0].hooks[0].command).toBe('echo "user-hook"');
|
||||
// Our entry appended after, not replacing
|
||||
expect(config.hooks.AfterTool[1].hooks[0].command).toMatch(/gitnexus-antigravity-hook\.cjs/);
|
||||
});
|
||||
|
||||
it('copies adapter + lock helpers to ~/.gemini/config/hooks/gitnexus/', async () => {
|
||||
const { setupCommand } = await import('../../src/cli/setup.js');
|
||||
await setupCommand();
|
||||
|
||||
const destDir = path.join(tempHome, '.gemini', 'config', 'hooks', 'gitnexus');
|
||||
await expect(
|
||||
fs.access(path.join(destDir, 'gitnexus-antigravity-hook.cjs')),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(fs.access(path.join(destDir, 'hook-lock.cjs'))).resolves.toBeUndefined();
|
||||
await expect(fs.access(path.join(destDir, 'hook-db-lock-probe.cjs'))).resolves.toBeUndefined();
|
||||
// Required by hook-db-lock-probe.cjs on Windows; without it the MCP
|
||||
// server ownership probe silently fails open.
|
||||
await expect(fs.access(path.join(destDir, 'win-rm-list-json.ps1'))).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('installs skills under ~/.gemini/antigravity/skills/<name>/SKILL.md', async () => {
|
||||
// Stage a fixture skills tree so the assertion does not depend on
|
||||
// installSkillsTo's __dirname resolution (which is brittle under
|
||||
// Vitest on Windows). Production reads the real gitnexus/skills/ dir.
|
||||
const fixtureSkillsRoot = path.join(tempHome, 'fixture-skills');
|
||||
await fs.mkdir(fixtureSkillsRoot, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(fixtureSkillsRoot, 'gitnexus-test.md'),
|
||||
'---\nname: gitnexus-test\ndescription: fixture\n---\nbody\n',
|
||||
'utf-8',
|
||||
);
|
||||
process.env.GITNEXUS_TEST_SKILLS_ROOT = fixtureSkillsRoot;
|
||||
|
||||
try {
|
||||
const { setupCommand } = await import('../../src/cli/setup.js');
|
||||
await setupCommand();
|
||||
|
||||
const skillsDir = path.join(tempHome, '.gemini', 'antigravity', 'skills');
|
||||
const entries = await fs.readdir(skillsDir, { withFileTypes: true });
|
||||
const skillDirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
||||
|
||||
expect(skillDirs).toContain('gitnexus-test');
|
||||
await expect(
|
||||
fs.access(path.join(skillsDir, 'gitnexus-test', 'SKILL.md')),
|
||||
).resolves.toBeUndefined();
|
||||
} finally {
|
||||
delete process.env.GITNEXUS_TEST_SKILLS_ROOT;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Hook adapter smoke tests ──────────────────────────────────────
|
||||
//
|
||||
// The adapter relies on sibling helpers (hook-lock.cjs, hook-db-lock-probe.cjs).
|
||||
// For tests we lay out a self-contained copy in a temp dir and spawn it.
|
||||
|
||||
const PROJECT_ROOT = path.resolve(__dirname, '..', '..');
|
||||
const ADAPTER_SRC = path.join(
|
||||
PROJECT_ROOT,
|
||||
'hooks',
|
||||
'antigravity',
|
||||
'gitnexus-antigravity-hook.cjs',
|
||||
);
|
||||
const LOCK_SRC = path.join(PROJECT_ROOT, 'hooks', 'claude', 'hook-lock.cjs');
|
||||
const PROBE_SRC = path.join(PROJECT_ROOT, 'hooks', 'claude', 'hook-db-lock-probe.cjs');
|
||||
const WIN_RM_SRC = path.join(PROJECT_ROOT, 'hooks', 'claude', 'win-rm-list-json.ps1');
|
||||
|
||||
async function stageAdapter(): Promise<string> {
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-antigravity-adapter-'));
|
||||
await fs.copyFile(ADAPTER_SRC, path.join(tmp, 'gitnexus-antigravity-hook.cjs'));
|
||||
await fs.copyFile(LOCK_SRC, path.join(tmp, 'hook-lock.cjs'));
|
||||
await fs.copyFile(PROBE_SRC, path.join(tmp, 'hook-db-lock-probe.cjs'));
|
||||
// hook-db-lock-probe.cjs loads this PowerShell script on Windows; without it,
|
||||
// the lock probe silently fails open and the adapter's Windows DB-lock path
|
||||
// would be untested in child-process smoke tests.
|
||||
await fs.copyFile(WIN_RM_SRC, path.join(tmp, 'win-rm-list-json.ps1'));
|
||||
return path.join(tmp, 'gitnexus-antigravity-hook.cjs');
|
||||
}
|
||||
|
||||
function runAdapter(
|
||||
hookPath: string,
|
||||
input: Record<string, any>,
|
||||
cwd?: string,
|
||||
): { stdout: string; stderr: string; status: number | null } {
|
||||
const result = spawnSync(process.execPath, [hookPath], {
|
||||
input: JSON.stringify(input),
|
||||
encoding: 'utf-8',
|
||||
timeout: 10000,
|
||||
cwd,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
return { stdout: result.stdout || '', stderr: result.stderr || '', status: result.status };
|
||||
}
|
||||
|
||||
describe('gitnexus-antigravity-hook adapter', () => {
|
||||
let adapter: string;
|
||||
let workdir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
adapter = await stageAdapter();
|
||||
workdir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-antigravity-work-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(path.dirname(adapter), { recursive: true, force: true });
|
||||
await fs.rm(workdir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('AfterTool with no .gitnexus/ produces no stdout', async () => {
|
||||
const { stdout } = runAdapter(
|
||||
adapter,
|
||||
{
|
||||
hook_event_name: 'AfterTool',
|
||||
tool_name: 'search_file_content',
|
||||
tool_input: { pattern: 'someSymbol' },
|
||||
tool_response: { llmContent: '...' },
|
||||
cwd: workdir,
|
||||
},
|
||||
workdir,
|
||||
);
|
||||
expect(stdout.trim()).toBe('');
|
||||
});
|
||||
|
||||
it('AfterTool ignores unrelated tools silently', async () => {
|
||||
const { stdout, stderr } = runAdapter(
|
||||
adapter,
|
||||
{
|
||||
hook_event_name: 'AfterTool',
|
||||
tool_name: 'read_file',
|
||||
tool_input: { path: 'README.md' },
|
||||
tool_response: { llmContent: '...' },
|
||||
cwd: workdir,
|
||||
},
|
||||
workdir,
|
||||
);
|
||||
expect(stdout.trim()).toBe('');
|
||||
expect(stderr).not.toMatch(/\[GitNexus\]/);
|
||||
});
|
||||
|
||||
it('AfterTool ignores non-git run_shell_command silently', async () => {
|
||||
const gnDir = path.join(workdir, '.gitnexus');
|
||||
await fs.mkdir(gnDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(gnDir, 'meta.json'),
|
||||
JSON.stringify({ lastCommit: 'deadbeef', stats: {} }),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const { stdout, stderr } = runAdapter(
|
||||
adapter,
|
||||
{
|
||||
hook_event_name: 'AfterTool',
|
||||
tool_name: 'run_shell_command',
|
||||
tool_input: { command: 'npm test' },
|
||||
tool_response: { llmContent: '...' },
|
||||
cwd: workdir,
|
||||
},
|
||||
workdir,
|
||||
);
|
||||
expect(stdout.trim()).toBe('');
|
||||
expect(stderr).not.toMatch(/\[GitNexus\]/);
|
||||
});
|
||||
|
||||
it('AfterTool emits stale-index hint after a successful git commit', async () => {
|
||||
// Initialize a git repo and a stale .gitnexus/meta.json.
|
||||
spawnSync('git', ['init', '-q'], { cwd: workdir });
|
||||
spawnSync('git', ['config', 'user.email', 'test@example.com'], { cwd: workdir });
|
||||
spawnSync('git', ['config', 'user.name', 'Test'], { cwd: workdir });
|
||||
await fs.writeFile(path.join(workdir, 'a.txt'), 'hello', 'utf-8');
|
||||
spawnSync('git', ['add', '.'], { cwd: workdir });
|
||||
spawnSync('git', ['commit', '-q', '-m', 'init'], { cwd: workdir });
|
||||
|
||||
const gnDir = path.join(workdir, '.gitnexus');
|
||||
await fs.mkdir(gnDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(gnDir, 'meta.json'),
|
||||
JSON.stringify({ lastCommit: '0000000000000000000000000000000000000000', stats: {} }),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const { stdout, stderr } = runAdapter(
|
||||
adapter,
|
||||
{
|
||||
hook_event_name: 'AfterTool',
|
||||
tool_name: 'run_shell_command',
|
||||
tool_input: { command: 'git commit -m "x"' },
|
||||
tool_response: { llmContent: '[committed]' },
|
||||
cwd: workdir,
|
||||
},
|
||||
workdir,
|
||||
);
|
||||
|
||||
// Hint surfaces both via the agent-visible channel and stderr (terminal).
|
||||
expect(stderr).toMatch(/\[GitNexus\] index is stale/);
|
||||
expect(stderr).toMatch(/gitnexus analyze/);
|
||||
|
||||
const parsed = JSON.parse(stdout);
|
||||
expect(parsed.hookSpecificOutput.hookEventName).toBe('AfterTool');
|
||||
expect(parsed.hookSpecificOutput.additionalContext).toMatch(/index is stale/);
|
||||
});
|
||||
|
||||
it('AfterTool skips augment when the tool failed', async () => {
|
||||
const gnDir = path.join(workdir, '.gitnexus');
|
||||
await fs.mkdir(gnDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(gnDir, 'meta.json'),
|
||||
JSON.stringify({ lastCommit: 'deadbeef', stats: {} }),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const { stdout } = runAdapter(
|
||||
adapter,
|
||||
{
|
||||
hook_event_name: 'AfterTool',
|
||||
tool_name: 'search_file_content',
|
||||
tool_input: { pattern: 'someSymbol' },
|
||||
tool_response: { error: 'boom' },
|
||||
cwd: workdir,
|
||||
},
|
||||
workdir,
|
||||
);
|
||||
expect(stdout.trim()).toBe('');
|
||||
});
|
||||
|
||||
it('ignores unknown tool names without crashing', async () => {
|
||||
const { status } = runAdapter(
|
||||
adapter,
|
||||
{
|
||||
hook_event_name: 'AfterTool',
|
||||
tool_name: 'unknown_tool',
|
||||
tool_input: {},
|
||||
tool_response: { llmContent: '' },
|
||||
cwd: workdir,
|
||||
},
|
||||
workdir,
|
||||
);
|
||||
expect(status).toBe(0);
|
||||
});
|
||||
|
||||
it('does not crash on empty stdin', () => {
|
||||
const result = spawnSync(process.execPath, [adapter], {
|
||||
input: '',
|
||||
encoding: 'utf-8',
|
||||
timeout: 10000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
expect(result.status).toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
/**
|
||||
* Unit tests for wiki CLI flags: --provider cursor, --review, --verbose
|
||||
* Unit tests for wiki CLI flags: --provider cursor/claude/codex, --review, --verbose
|
||||
*
|
||||
* Tests the new wiki provider infrastructure without requiring an actual
|
||||
* Cursor CLI binary or LLM API key. All external dependencies are mocked.
|
||||
* local agent CLI binary or LLM API key. All external dependencies are mocked.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { EventEmitter } from 'events';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
|
|
@ -81,6 +82,51 @@ describe('resolveCursorConfig', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ─── local agent CLI detection ───────────────────────────────────────
|
||||
|
||||
describe('detectLocalCLI', () => {
|
||||
let execFileSyncSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
execFileSyncSpy = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('detects and caches Claude CLI', async () => {
|
||||
vi.doMock('child_process', () => ({
|
||||
execFileSync: execFileSyncSpy,
|
||||
spawn: vi.fn(),
|
||||
}));
|
||||
const { detectLocalCLI } = await import('../../src/core/wiki/local-cli-client.js');
|
||||
|
||||
execFileSyncSpy.mockImplementation(() => 'claude 1.0.0');
|
||||
|
||||
expect(detectLocalCLI('claude')).toBe('claude');
|
||||
const callsAfterFirstDetection = execFileSyncSpy.mock.calls.length;
|
||||
expect(detectLocalCLI('claude')).toBe('claude');
|
||||
expect(execFileSyncSpy).toHaveBeenCalledTimes(callsAfterFirstDetection);
|
||||
});
|
||||
|
||||
it('caches null when Codex CLI is not found', async () => {
|
||||
vi.doMock('child_process', () => ({
|
||||
execFileSync: execFileSyncSpy.mockImplementation(() => {
|
||||
throw new Error('not found');
|
||||
}),
|
||||
spawn: vi.fn(),
|
||||
}));
|
||||
const { detectLocalCLI } = await import('../../src/core/wiki/local-cli-client.js');
|
||||
|
||||
expect(detectLocalCLI('codex')).toBeNull();
|
||||
const callsAfterFirstDetection = execFileSyncSpy.mock.calls.length;
|
||||
expect(detectLocalCLI('codex')).toBeNull();
|
||||
expect(execFileSyncSpy).toHaveBeenCalledTimes(callsAfterFirstDetection);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── resolveLLMConfig provider routing ───────────────────────────────
|
||||
|
||||
describe('resolveLLMConfig', () => {
|
||||
|
|
@ -116,6 +162,51 @@ describe('resolveLLMConfig', () => {
|
|||
expect(config.model).toBe('claude-4.5-opus-high');
|
||||
});
|
||||
|
||||
it('uses claudeModel when provider is claude', async () => {
|
||||
vi.doMock('../../src/storage/repo-manager.js', () => ({
|
||||
loadCLIConfig: vi.fn().mockResolvedValue({
|
||||
provider: 'claude',
|
||||
claudeModel: 'claude-sonnet-4-6',
|
||||
}),
|
||||
}));
|
||||
|
||||
const { resolveLLMConfig } = await import('../../src/core/wiki/llm-client.js');
|
||||
const config = await resolveLLMConfig({ provider: 'claude' });
|
||||
|
||||
expect(config.provider).toBe('claude');
|
||||
expect(config.model).toBe('claude-sonnet-4-6');
|
||||
});
|
||||
|
||||
it('uses codexModel when provider is codex', async () => {
|
||||
vi.doMock('../../src/storage/repo-manager.js', () => ({
|
||||
loadCLIConfig: vi.fn().mockResolvedValue({
|
||||
provider: 'codex',
|
||||
codexModel: 'gpt-5.4',
|
||||
}),
|
||||
}));
|
||||
|
||||
const { resolveLLMConfig } = await import('../../src/core/wiki/llm-client.js');
|
||||
const config = await resolveLLMConfig({ provider: 'codex' });
|
||||
|
||||
expect(config.provider).toBe('codex');
|
||||
expect(config.model).toBe('gpt-5.4');
|
||||
});
|
||||
|
||||
it('does not inherit HTTP model defaults for local CLI providers', async () => {
|
||||
vi.doMock('../../src/storage/repo-manager.js', () => ({
|
||||
loadCLIConfig: vi.fn().mockResolvedValue({
|
||||
provider: 'openai',
|
||||
model: 'minimax/minimax-m2.5',
|
||||
}),
|
||||
}));
|
||||
|
||||
const { resolveLLMConfig } = await import('../../src/core/wiki/llm-client.js');
|
||||
const config = await resolveLLMConfig({ provider: 'claude' });
|
||||
|
||||
expect(config.provider).toBe('claude');
|
||||
expect(config.model).toBe('');
|
||||
});
|
||||
|
||||
it('uses default OpenRouter model for openai provider', async () => {
|
||||
vi.doMock('../../src/storage/repo-manager.js', () => ({
|
||||
loadCLIConfig: vi.fn().mockResolvedValue({}),
|
||||
|
|
@ -720,11 +811,15 @@ describe('WikiGenerator invokeLLM routing', () => {
|
|||
|
||||
it('routes to callCursorLLM when provider is cursor', async () => {
|
||||
const cursorClient = await import('../../src/core/wiki/cursor-client.js');
|
||||
const localClient = await import('../../src/core/wiki/local-cli-client.js');
|
||||
const llmClient = await import('../../src/core/wiki/llm-client.js');
|
||||
|
||||
const cursorSpy = vi
|
||||
.spyOn(cursorClient, 'callCursorLLM')
|
||||
.mockResolvedValue({ content: 'cursor response' });
|
||||
const claudeSpy = vi
|
||||
.spyOn(localClient, 'callClaudeLLM')
|
||||
.mockResolvedValue({ content: 'claude response' });
|
||||
const openaiSpy = vi
|
||||
.spyOn(llmClient, 'callLLM')
|
||||
.mockResolvedValue({ content: 'openai response' });
|
||||
|
|
@ -751,17 +846,112 @@ describe('WikiGenerator invokeLLM routing', () => {
|
|||
const result = await (generator as any).invokeLLM('test prompt', 'system prompt');
|
||||
|
||||
expect(cursorSpy).toHaveBeenCalledTimes(1);
|
||||
expect(claudeSpy).not.toHaveBeenCalled();
|
||||
expect(openaiSpy).not.toHaveBeenCalled();
|
||||
expect(result.content).toBe('cursor response');
|
||||
});
|
||||
|
||||
it('routes to callLLM when provider is openai', async () => {
|
||||
it('routes to callClaudeLLM when provider is claude', async () => {
|
||||
const cursorClient = await import('../../src/core/wiki/cursor-client.js');
|
||||
const localClient = await import('../../src/core/wiki/local-cli-client.js');
|
||||
const llmClient = await import('../../src/core/wiki/llm-client.js');
|
||||
|
||||
const cursorSpy = vi
|
||||
.spyOn(cursorClient, 'callCursorLLM')
|
||||
.mockResolvedValue({ content: 'cursor response' });
|
||||
const claudeSpy = vi
|
||||
.spyOn(localClient, 'callClaudeLLM')
|
||||
.mockResolvedValue({ content: 'claude response' });
|
||||
const codexSpy = vi
|
||||
.spyOn(localClient, 'callCodexLLM')
|
||||
.mockResolvedValue({ content: 'codex response' });
|
||||
const openaiSpy = vi
|
||||
.spyOn(llmClient, 'callLLM')
|
||||
.mockResolvedValue({ content: 'openai response' });
|
||||
|
||||
const { WikiGenerator } = await import('../../src/core/wiki/generator.js');
|
||||
|
||||
const storagePath = path.join(tmpDir, 'storage');
|
||||
const wikiDir = path.join(storagePath, 'wiki');
|
||||
await fs.mkdir(wikiDir, { recursive: true });
|
||||
|
||||
const repoPath = path.join(tmpDir, 'repo');
|
||||
await fs.mkdir(repoPath, { recursive: true });
|
||||
|
||||
const generator = new WikiGenerator(repoPath, storagePath, path.join(storagePath, 'lbug'), {
|
||||
apiKey: '',
|
||||
baseUrl: '',
|
||||
model: 'claude-sonnet-4-6',
|
||||
maxTokens: 1000,
|
||||
temperature: 0,
|
||||
provider: 'claude',
|
||||
});
|
||||
|
||||
const result = await (generator as any).invokeLLM('test prompt', 'system prompt');
|
||||
|
||||
expect(claudeSpy).toHaveBeenCalledTimes(1);
|
||||
expect(codexSpy).not.toHaveBeenCalled();
|
||||
expect(cursorSpy).not.toHaveBeenCalled();
|
||||
expect(openaiSpy).not.toHaveBeenCalled();
|
||||
expect(result.content).toBe('claude response');
|
||||
});
|
||||
|
||||
it('routes to callCodexLLM when provider is codex', async () => {
|
||||
const cursorClient = await import('../../src/core/wiki/cursor-client.js');
|
||||
const localClient = await import('../../src/core/wiki/local-cli-client.js');
|
||||
const llmClient = await import('../../src/core/wiki/llm-client.js');
|
||||
|
||||
const cursorSpy = vi
|
||||
.spyOn(cursorClient, 'callCursorLLM')
|
||||
.mockResolvedValue({ content: 'cursor response' });
|
||||
const claudeSpy = vi
|
||||
.spyOn(localClient, 'callClaudeLLM')
|
||||
.mockResolvedValue({ content: 'claude response' });
|
||||
const codexSpy = vi
|
||||
.spyOn(localClient, 'callCodexLLM')
|
||||
.mockResolvedValue({ content: 'codex response' });
|
||||
const openaiSpy = vi
|
||||
.spyOn(llmClient, 'callLLM')
|
||||
.mockResolvedValue({ content: 'openai response' });
|
||||
|
||||
const { WikiGenerator } = await import('../../src/core/wiki/generator.js');
|
||||
|
||||
const storagePath = path.join(tmpDir, 'storage');
|
||||
const wikiDir = path.join(storagePath, 'wiki');
|
||||
await fs.mkdir(wikiDir, { recursive: true });
|
||||
|
||||
const repoPath = path.join(tmpDir, 'repo');
|
||||
await fs.mkdir(repoPath, { recursive: true });
|
||||
|
||||
const generator = new WikiGenerator(repoPath, storagePath, path.join(storagePath, 'lbug'), {
|
||||
apiKey: '',
|
||||
baseUrl: '',
|
||||
model: 'gpt-5.4',
|
||||
maxTokens: 1000,
|
||||
temperature: 0,
|
||||
provider: 'codex',
|
||||
});
|
||||
|
||||
const result = await (generator as any).invokeLLM('test prompt', 'system prompt');
|
||||
|
||||
expect(codexSpy).toHaveBeenCalledTimes(1);
|
||||
expect(claudeSpy).not.toHaveBeenCalled();
|
||||
expect(cursorSpy).not.toHaveBeenCalled();
|
||||
expect(openaiSpy).not.toHaveBeenCalled();
|
||||
expect(result.content).toBe('codex response');
|
||||
});
|
||||
|
||||
it('routes to callLLM when provider is openai', async () => {
|
||||
const cursorClient = await import('../../src/core/wiki/cursor-client.js');
|
||||
const localClient = await import('../../src/core/wiki/local-cli-client.js');
|
||||
const llmClient = await import('../../src/core/wiki/llm-client.js');
|
||||
|
||||
const cursorSpy = vi
|
||||
.spyOn(cursorClient, 'callCursorLLM')
|
||||
.mockResolvedValue({ content: 'cursor response' });
|
||||
const codexSpy = vi
|
||||
.spyOn(localClient, 'callCodexLLM')
|
||||
.mockResolvedValue({ content: 'codex response' });
|
||||
const openaiSpy = vi
|
||||
.spyOn(llmClient, 'callLLM')
|
||||
.mockResolvedValue({ content: 'openai response' });
|
||||
|
|
@ -788,6 +978,7 @@ describe('WikiGenerator invokeLLM routing', () => {
|
|||
|
||||
expect(openaiSpy).toHaveBeenCalledTimes(1);
|
||||
expect(cursorSpy).not.toHaveBeenCalled();
|
||||
expect(codexSpy).not.toHaveBeenCalled();
|
||||
expect(result.content).toBe('openai response');
|
||||
});
|
||||
});
|
||||
|
|
@ -817,6 +1008,98 @@ describe('callCursorLLM', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ─── local CLI errors when binaries are not found ────────────────────
|
||||
|
||||
describe('local agent CLI calls', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('throws when Claude CLI is not in PATH', async () => {
|
||||
vi.doMock('child_process', () => ({
|
||||
execFileSync: vi.fn().mockImplementation(() => {
|
||||
throw new Error('not found');
|
||||
}),
|
||||
spawn: vi.fn(),
|
||||
}));
|
||||
|
||||
const { callClaudeLLM } = await import('../../src/core/wiki/local-cli-client.js');
|
||||
|
||||
await expect(callClaudeLLM('hello', {})).rejects.toThrow('Claude CLI not found');
|
||||
});
|
||||
|
||||
it('throws when Codex CLI is not in PATH', async () => {
|
||||
vi.doMock('child_process', () => ({
|
||||
execFileSync: vi.fn().mockImplementation(() => {
|
||||
throw new Error('not found');
|
||||
}),
|
||||
spawn: vi.fn(),
|
||||
}));
|
||||
|
||||
const { callCodexLLM } = await import('../../src/core/wiki/local-cli-client.js');
|
||||
|
||||
await expect(callCodexLLM('hello', {})).rejects.toThrow('Codex CLI not found');
|
||||
});
|
||||
|
||||
it('uses Codex config overrides instead of removed approval flags', async () => {
|
||||
const child = new EventEmitter() as any;
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
child.stdin = new EventEmitter() as any;
|
||||
child.stdin.end = vi.fn(() => {
|
||||
queueMicrotask(() => {
|
||||
child.stdout.emit('data', Buffer.from('codex response'));
|
||||
child.emit('close', 0);
|
||||
});
|
||||
});
|
||||
|
||||
const spawnSpy = vi.fn(() => child);
|
||||
vi.doMock('child_process', () => ({
|
||||
execFileSync: vi.fn().mockReturnValue('codex-cli 0.132.0'),
|
||||
spawn: spawnSpy,
|
||||
}));
|
||||
|
||||
const { callCodexLLM } = await import('../../src/core/wiki/local-cli-client.js');
|
||||
|
||||
const response = await callCodexLLM('hello', { workingDirectory: process.cwd() });
|
||||
|
||||
expect(response.content).toBe('codex response');
|
||||
const args = spawnSpy.mock.calls[0][1] as string[];
|
||||
expect(args).toContain('-c');
|
||||
expect(args).toContain('approval_policy="never"');
|
||||
expect(args).not.toContain('--ask-for-approval');
|
||||
});
|
||||
|
||||
it('reports Codex stderr when the process closes before stdin is fully written', async () => {
|
||||
const child = new EventEmitter() as any;
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
child.stdin = new EventEmitter() as any;
|
||||
child.stdin.end = vi.fn(() => {
|
||||
queueMicrotask(() => {
|
||||
child.stderr.emit('data', Buffer.from("error: unexpected argument '--old-flag' found"));
|
||||
child.stdin.emit('error', new Error('write EOF'));
|
||||
child.emit('close', 2);
|
||||
});
|
||||
});
|
||||
|
||||
vi.doMock('child_process', () => ({
|
||||
execFileSync: vi.fn().mockReturnValue('codex-cli 0.132.0'),
|
||||
spawn: vi.fn(() => child),
|
||||
}));
|
||||
|
||||
const { callCodexLLM } = await import('../../src/core/wiki/local-cli-client.js');
|
||||
|
||||
await expect(callCodexLLM('hello', { workingDirectory: process.cwd() })).rejects.toThrow(
|
||||
"codex CLI exited with code 2: error: unexpected argument '--old-flag' found",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── estimateTokens ─────────────────────────────────────────────────
|
||||
|
||||
describe('estimateTokens', () => {
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ export default defineConfig({
|
|||
'test/integration/lbug-orphan-sidecar-recovery.test.ts',
|
||||
'test/integration/lbug-readonly-init.test.ts',
|
||||
'test/integration/analyze-wal-checkpoint-failure.test.ts',
|
||||
'test/integration/lbug-non-ascii-path.test.ts',
|
||||
],
|
||||
fileParallelism: false,
|
||||
sequence: { groupOrder: 1 },
|
||||
|
|
@ -101,6 +102,7 @@ export default defineConfig({
|
|||
'test/integration/lbug-orphan-sidecar-recovery.test.ts',
|
||||
'test/integration/lbug-readonly-init.test.ts',
|
||||
'test/integration/analyze-wal-checkpoint-failure.test.ts',
|
||||
'test/integration/lbug-non-ascii-path.test.ts',
|
||||
'test/integration/skills-e2e.test.ts',
|
||||
],
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue