Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1003d8b6a5 | ||
|
|
74b9701509 | ||
|
|
f0132c1077 | ||
|
|
f6b92d4f13 | ||
|
|
64b7ff0061 |
@@ -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, Windsurf, OpenCode | Quick exploration, demos, one-off analysis |
|
||||
| **For** | Daily development with Cursor, Claude Code, Windsurf, OpenCode, Codex | 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) |
|
||||
@@ -86,6 +86,7 @@ To configure MCP for your editor, run `npx gitnexus setup` once — or set it up
|
||||
| **Cursor** | Yes | Yes | — | MCP + Skills |
|
||||
| **Windsurf** | Yes | — | — | MCP |
|
||||
| **OpenCode** | Yes | Yes | — | MCP + Skills |
|
||||
| **Codex** | Yes | — | — | MCP |
|
||||
|
||||
> **Claude Code** gets the deepest integration: MCP tools + agent skills + PreToolUse hooks that enrich searches with graph context + PostToolUse hooks that auto-reindex after commits.
|
||||
|
||||
@@ -129,6 +130,14 @@ claude mcp add gitnexus -- npx -y gitnexus@latest mcp
|
||||
}
|
||||
```
|
||||
|
||||
**Codex** (`~/.codex/config.toml` for system scope, or `.codex/config.toml` for project scope):
|
||||
|
||||
```toml
|
||||
[mcp_servers.gitnexus]
|
||||
command = "npx"
|
||||
args = ["-y", "gitnexus@latest", "mcp"]
|
||||
```
|
||||
|
||||
### CLI Commands
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to GitNexus will be documented in this file.
|
||||
|
||||
## [1.4.5] - 2026-03-17
|
||||
|
||||
### Added
|
||||
- **Ruby language support** for CLI and web (#111)
|
||||
- **TypeEnvironment API** with constructor inference, self/this/super resolution (#274)
|
||||
- **Return type inference** with doc-comment parsing (JSDoc, PHPDoc, YARD) and per-language type extractors (#284)
|
||||
- **Phase 4 type resolution** — nullable unwrapping, for-loop typing, assignment chain propagation (#310)
|
||||
- **Phase 5 type resolution** — chained calls, pattern matching, class-as-receiver (#315)
|
||||
- **Phase 6 type resolution** — for-loop Tier 1c, pattern matching, container descriptors, 10-language coverage (#318)
|
||||
- Container descriptor table for generic type argument resolution (Map keys vs values)
|
||||
- Method-aware for-loop extractors with integration tests for all languages
|
||||
- Recursive pattern binding (C# `is` patterns, Kotlin `when/is` smart casts)
|
||||
- Class field declaration unwrapping for C#/Java
|
||||
- PHP `$this->property` foreach member access
|
||||
- C++ pointer dereference range-for
|
||||
- Java `this.data.values()` field access patterns
|
||||
- Position-indexed when/is bindings for branch-local narrowing
|
||||
- **Type resolution system documentation** with architecture guide and roadmap
|
||||
- `.gitignore` and `.gitnexusignore` support during file discovery (#231)
|
||||
- Codex MCP configuration documentation in README (#236)
|
||||
- `skipGraphPhases` pipeline option to skip MRO/community/process phases for faster test runs
|
||||
- `hookTimeout: 120000` in vitest config for CI beforeAll hooks
|
||||
|
||||
### Changed
|
||||
- **Migrated from KuzuDB to LadybugDB v0.15** (#275)
|
||||
- Dynamically discover and install agent skills in CLI (#270)
|
||||
|
||||
### Performance
|
||||
- Worker pool threshold — skip worker creation for small repos (<15 files or <512KB total)
|
||||
- AST walk pruning via `SKIP_SUBTREE_TYPES` for leaf-only nodes (string, comment, number literals)
|
||||
- Pre-computed `interestingNodeTypes` set — single Set.has() replaces 3 checks per AST node
|
||||
- `fastStripNullable` — skip full nullable parsing for simple identifiers (90%+ case)
|
||||
- Replace `.children?.find()` with manual for loops in `extractFunctionName` to eliminate array allocations
|
||||
|
||||
### Fixed
|
||||
- Same-directory Python import resolution (#328)
|
||||
- Ruby method-level call resolution, HAS_METHOD edges, and dispatch table (#278)
|
||||
- C++ fixture file casing for case-sensitive CI
|
||||
- Template string incorrectly included in AST pruning set (contains interpolated expressions)
|
||||
|
||||
## [1.4.0] - Previous release
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gitnexus",
|
||||
"version": "1.4.0",
|
||||
"version": "1.4.5",
|
||||
"description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
|
||||
"author": "Abhigyan Patwari",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
resolvePhpImport,
|
||||
resolveRustImport,
|
||||
resolveRubyImport,
|
||||
resolvePythonImport,
|
||||
} from './resolvers/index.js';
|
||||
import { callRouters } from './call-routing.js';
|
||||
import type { ResolutionContext } from './resolution-context.js';
|
||||
@@ -219,6 +220,14 @@ function resolveLanguageImport(
|
||||
return null; // External framework (Foundation, UIKit, etc.)
|
||||
}
|
||||
|
||||
// Python: relative imports (PEP 328) + proximity-based bare imports
|
||||
// Falls through to standard suffix resolution when proximity finds no match.
|
||||
if (language === SupportedLanguages.Python) {
|
||||
const resolved = resolvePythonImport(filePath, rawImportPath, allFilePaths);
|
||||
if (resolved) return { kind: 'files', files: [resolved] };
|
||||
if (rawImportPath.startsWith('.')) return null; // relative but unresolved — don't suffix-match
|
||||
}
|
||||
|
||||
// Ruby: require / require_relative
|
||||
if (language === SupportedLanguages.Ruby) {
|
||||
const resolved = resolveRubyImport(rawImportPath, normalizedFileList, allFileList, index);
|
||||
|
||||
@@ -21,5 +21,7 @@ export { resolveRustImport, tryRustModulePath } from './rust.js';
|
||||
|
||||
export { resolveRubyImport } from './ruby.js';
|
||||
|
||||
export { resolvePythonImport } from './python.js';
|
||||
|
||||
export { resolveImportPath, RESOLVE_CACHE_CAP } from './standard.js';
|
||||
export type { TsconfigPaths } from './standard.js';
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Python import resolution — PEP 328 relative imports and proximity-based bare imports.
|
||||
* Import system spec: PEP 302 (original), PEP 451 (current).
|
||||
*/
|
||||
|
||||
import { tryResolveWithExtensions } from './utils.js';
|
||||
|
||||
/**
|
||||
* Resolve a Python import to a file path.
|
||||
*
|
||||
* 1. Relative (PEP 328): `.module`, `..module` — 1 dot = current package, each extra dot goes up one level.
|
||||
* 2. Proximity bare import: static heuristic — checks the importer's own directory first.
|
||||
* Approximates the common case where co-located files find each other without an installed package.
|
||||
* Single-segment only — multi-segment (e.g. `os.path`) falls through to suffixResolve.
|
||||
* Checks package (__init__.py) before module (.py), matching CPython's finder order (PEP 451 §4).
|
||||
* Coexistence of both is physically impossible (same name = file vs directory), so the order
|
||||
* only matters for spec compliance.
|
||||
* Note: namespace packages (PEP 420, directory without __init__.py) are not handled.
|
||||
*
|
||||
* Returns null to let the caller fall through to suffixResolve.
|
||||
*/
|
||||
export function resolvePythonImport(
|
||||
currentFile: string,
|
||||
importPath: string,
|
||||
allFiles: Set<string>,
|
||||
): string | null {
|
||||
// Relative import — PEP 328 (https://peps.python.org/pep-0328/)
|
||||
if (importPath.startsWith('.')) {
|
||||
const dotMatch = importPath.match(/^(\.+)(.*)/);
|
||||
if (!dotMatch) return null;
|
||||
|
||||
const dotCount = dotMatch[1].length;
|
||||
const modulePart = dotMatch[2];
|
||||
const dirParts = currentFile.split('/').slice(0, -1);
|
||||
|
||||
// PEP 328: more dots than directory levels → beyond top-level package → invalid
|
||||
if (dotCount - 1 > dirParts.length) return null;
|
||||
for (let i = 1; i < dotCount; i++) dirParts.pop();
|
||||
|
||||
if (modulePart) {
|
||||
dirParts.push(...modulePart.replace(/\./g, '/').split('/'));
|
||||
}
|
||||
|
||||
return tryResolveWithExtensions(dirParts.join('/'), allFiles);
|
||||
}
|
||||
|
||||
// Proximity bare import — single-segment only; package before module (PEP 451 §4)
|
||||
const pathLike = importPath.replace(/\./g, '/');
|
||||
if (pathLike.includes('/')) return null;
|
||||
|
||||
// Normalize for Windows backslashes
|
||||
const importerDir = currentFile.replace(/\\/g, '/').split('/').slice(0, -1).join('/');
|
||||
if (!importerDir) return null;
|
||||
|
||||
if (allFiles.has(`${importerDir}/${pathLike}/__init__.py`)) return `${importerDir}/${pathLike}/__init__.py`;
|
||||
if (allFiles.has(`${importerDir}/${pathLike}.py`)) return `${importerDir}/${pathLike}.py`;
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -113,32 +113,6 @@ export const resolveImportPath = (
|
||||
// Fall through to generic resolution if Rust-specific didn't match
|
||||
}
|
||||
|
||||
// ---- Python relative imports (PEP 328): .module, ..module, ... ----
|
||||
if (language === SupportedLanguages.Python && importPath.startsWith('.')) {
|
||||
const dotMatch = importPath.match(/^(\.+)(.*)/);
|
||||
if (dotMatch) {
|
||||
const dotCount = dotMatch[1].length;
|
||||
const modulePart = dotMatch[2]; // e.g., "models" from ".models"
|
||||
const dirParts = currentFile.split('/').slice(0, -1); // remove filename
|
||||
|
||||
// Navigate up: 1 dot = same package, 2 dots = parent package, etc.
|
||||
// First dot means "current package", each additional dot goes up one level
|
||||
for (let i = 1; i < dotCount; i++) {
|
||||
dirParts.pop();
|
||||
}
|
||||
|
||||
if (modulePart) {
|
||||
// from .models import User → resolve "models" relative to current package
|
||||
const modulePath = modulePart.replace(/\./g, '/');
|
||||
dirParts.push(...modulePath.split('/'));
|
||||
}
|
||||
|
||||
const basePath = dirParts.join('/');
|
||||
const resolved = tryResolveWithExtensions(basePath, allFiles);
|
||||
return cache(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Generic relative import resolution (./ and ../) ----
|
||||
const currentDir = currentFile.split('/').slice(0, -1);
|
||||
const parts = importPath.split('/');
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
class User:
|
||||
def save(self):
|
||||
pass
|
||||
@@ -0,0 +1,5 @@
|
||||
import user
|
||||
|
||||
def authenticate():
|
||||
svc = user.UserService()
|
||||
svc.execute()
|
||||
@@ -0,0 +1,3 @@
|
||||
class UserService:
|
||||
def execute(self):
|
||||
pass
|
||||
@@ -8,6 +8,39 @@ import {
|
||||
runPipelineFromRepo, type PipelineResult,
|
||||
} from './helpers.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// skipGraphPhases: verify pipeline works correctly when graph phases are skipped
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Pipeline skipGraphPhases option', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(FIXTURES, 'javascript-self-this-resolution'),
|
||||
() => {},
|
||||
{ skipGraphPhases: true },
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it('produces graph nodes without community/process phases', () => {
|
||||
expect(getNodesByLabel(result, 'Class').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('still resolves CALLS edges correctly', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('omits communityResult when skipGraphPhases is true', () => {
|
||||
expect(result.communityResult).toBeUndefined();
|
||||
});
|
||||
|
||||
it('omits processResult when skipGraphPhases is true', () => {
|
||||
expect(result.processResult).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// this.save() resolves to enclosing class's own save method
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -339,9 +339,43 @@ describe('Python local definition shadows import', () => {
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constructor-call resolution: User("alice") resolves to User class
|
||||
// Bare import: `import user` from services/auth.py resolves to services/user.py
|
||||
// not models/user.py, even though models/ is indexed first (proximity wins)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Python bare import resolution (proximity over index order)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(FIXTURES, 'python-bare-import'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it('detects User in models/ and UserService in services/', () => {
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('User');
|
||||
expect(getNodesByLabel(result, 'Class')).toContain('UserService');
|
||||
});
|
||||
|
||||
it('resolves `import user` from services/auth.py to services/user.py, not models/user.py', () => {
|
||||
const imports = getRelationships(result, 'IMPORTS');
|
||||
const imp = imports.find(e => e.sourceFilePath === 'services/auth.py');
|
||||
expect(imp).toBeDefined();
|
||||
expect(imp!.targetFilePath).toBe('services/user.py');
|
||||
expect(imp!.targetFilePath).not.toBe('models/user.py');
|
||||
});
|
||||
|
||||
it('resolves svc.execute() CALLS edge to UserService#execute in services/user.py', () => {
|
||||
// End-to-end: correct IMPORTS resolution must propagate through type inference
|
||||
// so that user.UserService() binds svc → UserService, and svc.execute() resolves
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const executeCall = calls.find(c => c.target === 'execute' && c.targetFilePath === 'services/user.py');
|
||||
expect(executeCall).toBeDefined();
|
||||
expect(executeCall!.source).toBe('authenticate');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constructor-inferred type resolution: user = User(); user.save() → User.save
|
||||
// Cross-file SymbolTable verification (no explicit type annotations)
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* Unit tests for proximity-based Python import resolution.
|
||||
*
|
||||
* When two files share the same bare name (e.g. user.py in two different
|
||||
* directories), suffixResolve alone picks whichever was indexed first.
|
||||
* resolvePythonImport addresses this by checking the importer's own directory
|
||||
* first, mirroring Python's sys.path resolution order.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildSuffixIndex, suffixResolve } from '../../src/core/ingestion/resolvers/utils.js';
|
||||
import { resolvePythonImport } from '../../src/core/ingestion/resolvers/python.js';
|
||||
import { resolveImportPath } from '../../src/core/ingestion/resolvers/standard.js';
|
||||
import { SupportedLanguages } from '../../src/config/supported-languages.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeCtx(files: string[]) {
|
||||
const normalized = files.map(f => f.replace(/\\/g, '/'));
|
||||
const allFilesSet = new Set(files);
|
||||
const index = buildSuffixIndex(normalized, files);
|
||||
const cache = new Map<string, string | null>();
|
||||
return { files, normalized, allFilesSet, index, cache };
|
||||
}
|
||||
|
||||
/** Simulate the full dispatch: resolvePythonImport first, then suffixResolve fallback. */
|
||||
function resolvePython(
|
||||
currentFile: string,
|
||||
importPath: string,
|
||||
ctx: ReturnType<typeof makeCtx>,
|
||||
): string | null {
|
||||
const proximity = resolvePythonImport(currentFile, importPath, ctx.allFilesSet);
|
||||
if (proximity) return proximity;
|
||||
if (importPath.startsWith('.')) return null;
|
||||
const pathLike = importPath.replace(/\./g, '/');
|
||||
const parts = pathLike.split('/').filter(Boolean);
|
||||
return suffixResolve(parts, ctx.normalized, ctx.files, ctx.index);
|
||||
}
|
||||
|
||||
/** For non-Python languages, delegate directly to standard resolveImportPath. */
|
||||
function resolve(
|
||||
currentFile: string,
|
||||
importPath: string,
|
||||
language: SupportedLanguages,
|
||||
ctx: ReturnType<typeof makeCtx>,
|
||||
): string | null {
|
||||
return resolveImportPath(
|
||||
currentFile,
|
||||
importPath,
|
||||
ctx.allFilesSet,
|
||||
ctx.files,
|
||||
ctx.normalized,
|
||||
ctx.cache,
|
||||
language,
|
||||
null,
|
||||
ctx.index,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Python proximity resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('resolvePythonImport — proximity-based resolution for Python', () => {
|
||||
it('resolves bare import to same-directory file when multiple files share the name', () => {
|
||||
const ctx = makeCtx([
|
||||
'app/models/user.py', // indexed first — would win without proximity
|
||||
'app/services/user.py',
|
||||
'app/services/auth.py',
|
||||
]);
|
||||
|
||||
const result = resolvePython('app/services/auth.py', 'user', ctx);
|
||||
expect(result).toBe('app/services/user.py');
|
||||
});
|
||||
|
||||
it('falls back to suffix index when no same-directory match exists', () => {
|
||||
const ctx = makeCtx([
|
||||
'app/models/user.py',
|
||||
'app/services/auth.py',
|
||||
]);
|
||||
|
||||
const result = resolvePython('app/services/auth.py', 'user', ctx);
|
||||
expect(result).toBe('app/models/user.py');
|
||||
});
|
||||
|
||||
it('handles importer at repo root (no directory) without crashing', () => {
|
||||
const ctx = makeCtx([
|
||||
'user.py',
|
||||
'auth.py',
|
||||
]);
|
||||
|
||||
// importerDir is '' — proximity skipped, suffix fallback used
|
||||
const result = resolvePython('auth.py', 'user', ctx);
|
||||
expect(result).toBe('user.py');
|
||||
});
|
||||
|
||||
it('does not apply proximity for multi-segment imports (dotted paths)', () => {
|
||||
const ctx = makeCtx([
|
||||
'app/models/utils/helpers.py',
|
||||
'app/services/auth.py',
|
||||
]);
|
||||
|
||||
const result = resolvePython('app/services/auth.py', 'utils.helpers', ctx);
|
||||
expect(result).toBe('app/models/utils/helpers.py');
|
||||
});
|
||||
|
||||
it('resolves same-directory package (user/__init__.py) via proximity', () => {
|
||||
const ctx = makeCtx([
|
||||
'app/models/user/__init__.py', // indexed first — would win without proximity
|
||||
'app/services/user/__init__.py',
|
||||
'app/services/auth.py',
|
||||
]);
|
||||
|
||||
const result = resolvePython('app/services/auth.py', 'user', ctx);
|
||||
expect(result).toBe('app/services/user/__init__.py');
|
||||
});
|
||||
|
||||
it('falls back to suffixResolve for __init__.py when no same-directory package exists', () => {
|
||||
const ctx = makeCtx([
|
||||
'app/models/__init__.py',
|
||||
'app/services/auth.py',
|
||||
]);
|
||||
|
||||
const result = resolvePython('app/services/auth.py', 'models', ctx);
|
||||
expect(result).toBe('app/models/__init__.py');
|
||||
});
|
||||
|
||||
it('handles Windows-style backslash paths in currentFile without crashing', () => {
|
||||
const ctx = makeCtx([
|
||||
'app/services/user.py',
|
||||
'app/services/auth.py',
|
||||
]);
|
||||
|
||||
const result = resolvePython('app\\services\\auth.py', 'user', ctx);
|
||||
expect(result).toBe('app/services/user.py');
|
||||
});
|
||||
|
||||
it('resolves PEP 328 relative import (.user) to same-directory file', () => {
|
||||
const ctx = makeCtx([
|
||||
'app/services/user.py',
|
||||
'app/services/auth.py',
|
||||
]);
|
||||
|
||||
const result = resolvePython('app/services/auth.py', '.user', ctx);
|
||||
expect(result).toBe('app/services/user.py');
|
||||
});
|
||||
|
||||
it('returns null when relative import dots exceed directory depth (PEP 328 over-traversal)', () => {
|
||||
// auth.py is at depth 1 (one directory: 'app').
|
||||
// '...user' has 3 dots → 2 upward hops required, but only 1 directory level exists.
|
||||
// CPython raises ImportError; we return null.
|
||||
const ctx = makeCtx([
|
||||
'app/auth.py',
|
||||
'user.py',
|
||||
]);
|
||||
|
||||
const result = resolvePython('app/auth.py', '...user', ctx);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Namespace packages (PEP 420) — directory with no __init__.py
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('resolvePythonImport — namespace packages (no __init__.py)', () => {
|
||||
// user/ exists as a namespace package: no __init__.py, only submodules.
|
||||
const files = [
|
||||
'app/services/auth.py',
|
||||
'app/services/user/model.py', // user/ has no __init__.py
|
||||
'app/services/user/queries.py',
|
||||
];
|
||||
|
||||
it('bare import of namespace package returns null (no file to resolve to)', () => {
|
||||
// `import user` — proximity finds neither user.py nor user/__init__.py.
|
||||
// suffixResolve also finds nothing because no file is literally named "user".
|
||||
// This is expected: CPython itself sets user.__file__ = None for namespace packages.
|
||||
const ctx = makeCtx(files);
|
||||
const result = resolvePython('app/services/auth.py', 'user', ctx);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('submodule form resolves correctly via suffixResolve fallback', () => {
|
||||
// `import user.model` — multi-segment, proximity skipped, suffixResolve finds user/model.py.
|
||||
const ctx = makeCtx(files);
|
||||
const result = resolvePython('app/services/auth.py', 'user.model', ctx);
|
||||
expect(result).toBe('app/services/user/model.py');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ruby: bare require does NOT use proximity
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('resolveImportPath — Ruby bare require does not use proximity', () => {
|
||||
it('returns first-indexed file for bare require (Ruby $LOAD_PATH excludes current directory)', () => {
|
||||
const ctx = makeCtx([
|
||||
'lib/core/helpers.rb', // indexed first
|
||||
'lib/utils/helpers.rb',
|
||||
'lib/utils/formatter.rb',
|
||||
]);
|
||||
|
||||
// Ruby bare `require 'helpers'` searches $LOAD_PATH — current directory not included.
|
||||
// No proximity bias; first-indexed file is returned, same as before.
|
||||
const result = resolve('lib/utils/formatter.rb', 'helpers', SupportedLanguages.Ruby, ctx);
|
||||
expect(result).toBe('lib/core/helpers.rb');
|
||||
});
|
||||
|
||||
it('resolves require_relative (dot-prefixed) to same-directory file via generic relative resolver', () => {
|
||||
const ctx = makeCtx([
|
||||
'lib/utils/helpers.rb',
|
||||
'lib/utils/formatter.rb',
|
||||
]);
|
||||
|
||||
// require_relative arrives as "./<path>" — caught by generic relative resolver, not proximity
|
||||
const result = resolve('lib/utils/formatter.rb', './helpers', SupportedLanguages.Ruby, ctx);
|
||||
expect(result).toBe('lib/utils/helpers.rb');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Other languages: no proximity applied
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('resolveImportPath — no proximity for Java or TypeScript', () => {
|
||||
it('Java: fully-qualified import resolves to the correct file via unique suffix', () => {
|
||||
const ctx = makeCtx([
|
||||
'src/com/a/User.java',
|
||||
'src/com/b/User.java',
|
||||
'src/com/b/Service.java',
|
||||
]);
|
||||
|
||||
// "com.b.User" → "com/b/User" → unique suffix; no ambiguity
|
||||
const result = resolve('src/com/b/Service.java', 'com.b.User', SupportedLanguages.Java, ctx);
|
||||
expect(result).toBe('src/com/b/User.java');
|
||||
});
|
||||
|
||||
it('TypeScript: relative import resolves via generic relative resolver', () => {
|
||||
const ctx = makeCtx([
|
||||
'src/services/user.ts',
|
||||
'src/services/auth.ts',
|
||||
'src/models/user.ts',
|
||||
]);
|
||||
|
||||
// "./user" is explicit relative — resolved before proximity is checked
|
||||
const result = resolve('src/services/auth.ts', './user', SupportedLanguages.TypeScript, ctx);
|
||||
expect(result).toBe('src/services/user.ts');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3526,4 +3526,65 @@ fn process(opt: Option<User>) {
|
||||
});
|
||||
});
|
||||
|
||||
describe('performance optimizations — coverage for new code paths', () => {
|
||||
it('fastStripNullable: passes through simple identifier without stripping', () => {
|
||||
const tree = parse('function f(user: User) { user.save(); }', TypeScript.typescript);
|
||||
const typeEnv = buildTypeEnv(tree, 'typescript');
|
||||
// lookup exercises fastStripNullable — "User" has no | or ? markers
|
||||
const callNode = tree.rootNode.descendantForIndex(tree.rootNode.text.indexOf('save'));
|
||||
expect(typeEnv.lookup('user', callNode)).toBe('User');
|
||||
});
|
||||
|
||||
it('fastStripNullable: strips nullable union type via full stripNullable', () => {
|
||||
const tree = parse('function f(user: User | null) { user.save(); }', TypeScript.typescript);
|
||||
const typeEnv = buildTypeEnv(tree, 'typescript');
|
||||
const callNode = tree.rootNode.descendantForIndex(tree.rootNode.text.indexOf('save'));
|
||||
expect(typeEnv.lookup('user', callNode)).toBe('User');
|
||||
});
|
||||
|
||||
it('fastStripNullable: rejects bare nullable keyword', () => {
|
||||
const tree = parse('function f(x: null) { x.save(); }', TypeScript.typescript);
|
||||
const typeEnv = buildTypeEnv(tree, 'typescript');
|
||||
const callNode = tree.rootNode.descendantForIndex(tree.rootNode.text.indexOf('save'));
|
||||
expect(typeEnv.lookup('x', callNode)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('fastStripNullable: strips optional type suffix', () => {
|
||||
const tree = parse(`
|
||||
class Foo {
|
||||
process(user: User) {
|
||||
user.save();
|
||||
}
|
||||
}
|
||||
`, TypeScript.typescript);
|
||||
const typeEnv = buildTypeEnv(tree, 'typescript');
|
||||
const callNode = tree.rootNode.descendantForIndex(tree.rootNode.text.indexOf('save'));
|
||||
expect(typeEnv.lookup('user', callNode)).toBe('User');
|
||||
});
|
||||
|
||||
it('SKIP_SUBTREE_TYPES: string literal subtrees do not affect type extraction', () => {
|
||||
const tree = parse(`
|
||||
function f(user: User) {
|
||||
const msg = "hello world this is a long string";
|
||||
user.save();
|
||||
}
|
||||
`, TypeScript.typescript);
|
||||
const { env } = buildTypeEnv(tree, 'typescript');
|
||||
expect(flatGet(env, 'user')).toBe('User');
|
||||
});
|
||||
|
||||
it('interestingNodeTypes: non-declaration nodes skip extractTypeBinding', () => {
|
||||
// Large code with many non-interesting nodes (binary expressions, calls, etc.)
|
||||
const tree = parse(`
|
||||
function calculate(service: Service) {
|
||||
const a = 1 + 2 + 3;
|
||||
const b = true && false;
|
||||
if (a > b) { service.run(); }
|
||||
}
|
||||
`, TypeScript.typescript);
|
||||
const { env } = buildTypeEnv(tree, 'typescript');
|
||||
expect(flatGet(env, 'service')).toBe('Service');
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user