Compare commits

..
Author SHA1 Message Date
Gergo Magyar 1003d8b6a5 test: add coverage for perf optimizations — fastStripNullable, skipGraphPhases, AST pruning
- 6 new unit tests for fastStripNullable branches (simple id, nullable union, bare keyword)
- 4 new integration tests for skipGraphPhases pipeline option
- Tests for SKIP_SUBTREE_TYPES and interestingNodeTypes code paths
2026-03-17 17:31:24 +00:00
Gergo Magyar 74b9701509 chore: bump version to 1.4.5, add CHANGELOG.md 2026-03-17 17:18:35 +00:00
Gergő Magyar f0132c1077 feat: Phase 6 type resolution — for-loop Tier 1c, pattern matching, container descriptors, 10-language coverage (#318)
* feat: Phase 6 type resolution — pattern matching, for-loop Tier 1c, coverage completion

- Add patternBindingNodeTypes gate to LanguageTypeConfig for 50% perf improvement
- Expand ForLoopExtractor signature with optional declarationTypeNodes + scope
- Add extractElementTypeFromString shared utility for container type parsing
- Python match/case: extractPatternBinding for `case User() as u:` pattern
- C# refactor: move is_pattern_expression from extractDeclaration to extractPatternBinding
- Ruby: add extractPendingAssignment for assignment chain propagation
- TS/JS: add for-loop Tier 1c for `for (const user of users)` with User[] inference
- Python: add for-loop Tier 1c for `for user in users:` with type annotation inference
- Go: add for-loop Tier 1c for `for _, user := range users` with []User inference
- Fix 'Property' as any stale cast in call-processor.ts
- Add dual return-type string length cap (2048 pre-cap, 512 post-cap)
- Add chain call integration tests for C#, Go, Rust, Python, JS, C++
- Add Python match/case integration test fixtures
- 27 new extractElementTypeFromString unit tests
- 3 for-loop edge cases skipped (declarationTypeNodes scope key lookup)

* fix: address code review findings for Phase 6

- Add missing patternBindingNodeTypes to C# typeConfig (perf gate)
- Add 2048-char input length guard to extractElementTypeFromString
- Skip Python match/case integration tests (call extraction needs query updates)

* reorganise

* fix: Phase 1 bug fixes — Go range semantics, typed_parameter, bracket depth

- Go single-var range correctly returns early for slices/maps (index, not element)
- Go single-var range on channels correctly resolves element type
- Added map_type and channel_type to extractGoElementTypeFromTypeNode
- Added isChannelType helper for channel detection before skip decision
- Added 'typed_parameter' to TYPED_PARAMETER_TYPES for Python annotated params
- Fixed bracket depth tracking in extractElementTypeFromString — only match
  selected closeChar at depth 0, return undefined for mismatched brackets
- Un-skipped 3 prematurely skipped tests (TS local const, Python List/Sequence)
- Added tests for map range, single-var range semantics, bracket edge cases

* refactor: Phase 2 architecture — shared helper, required params, decoupled type nodes

- Extract resolveIterableElementType shared helper in shared.ts implementing
  3-strategy fallback (declarationTypeNodes → scopeEnv string → AST walk)
- Refactor TS, Python, Go extractors to use shared helper (eliminates 3x duplication)
- Make ForLoopExtractor params required (aligned with PatternBindingExtractor)
- Update Java, Kotlin, C# extractor signatures to accept required params
- Decouple declarationTypeNodes from scopeEnv — capture raw type annotation
  nodes BEFORE extractDeclaration for container types (User[], []User, List[User])
- Hybrid approach: direct name extraction + keysBefore fallback for multi-declarator
- Document declarationTypeNodes invariant change (superset of scopeEnv)

* feat: Phase 3 partial — Rust for-loop + C# var foreach Tier 1c

- Rust: add extractForLoopBinding with for_expression support
  - Handles &users, &mut users via reference_expression unwrapping
  - extractRustElementTypeFromTypeNode: generic_type, reference_type, slice/array
  - findRustParamElementType: AST walk with reference/mut pattern unwrapping
  - 4 unit tests (Vec<User>, &[User], range expr negative, no-annotation negative)

- C#: upgrade foreach to handle var (implicit_type) via Tier 1c
  - extractCSharpElementTypeFromTypeNode: generic_name, array_type, nullable_type
  - findCSharpParamElementType: AST walk to method_declaration parameters
  - 3 unit tests (var foreach, explicit type regression, no-annotation negative)

* feat: Phase 3 complete — all language gaps + pattern matching

Kotlin Tier 1c:
- Unannotated for-loop resolves via shared helper
- extractKotlinElementTypeFromTypeNode handles type_projection unwrapping
- findKotlinParamElementType walks to function_declaration

Java Tier 1c:
- var foreach resolves via shared helper
- extractJavaElementTypeFromTypeNode handles generic_type, array_type
- findJavaParamElementType walks to method_declaration

TypeScript:
- readonly User[] unwrapped via readonly_type → array_type recursion

C# switch patterns:
- declaration_pattern added to patternBindingNodeTypes
- extractPatternBinding handles standalone declaration_pattern (switch case/expr)

Rust match arms:
- match_arm added to patternBindingNodeTypes
- extractPatternBinding extended with match_arm → match_expression parent traversal

Python:
- as_pattern tries childForFieldName('alias') before positional fallback

Tests: 237 pass (was 224), 13 new tests added

* feat: Phase 4 — known limitation tests, match arm fix, final verification

- Fix Rust match_arm pattern extraction: unwrap match_pattern to get
  tuple_struct_pattern inside (tree-sitter-rust wraps in match_pattern node)
- Add first-writer-wins regression test for match arm scope leakage
- Add 5 documented skip tests for known limitations:
  - TS destructured for-of (tuple destructuring)
  - Python tuple unpacking in for-loops
  - TS instanceof narrowing (block-level scoping)
  - Rust for with .iter() (method call iterable)
  - Ruby block parameters (closure param inference)

Final: 238 passed, 5 skipped (documented limitations), tsc clean

* test: integration tests for all Phase 6 language gaps + fix Rust param pattern field

Integration test fixtures and tests (30 new tests, all with exact match + negative):

Rust for-loop (5 tests):
- for user in &users with Vec<User> → User#save, negative Repo#save
- for repo in &repos with Vec<Repo> → Repo#save, negative User#save

Rust match arm (5 tests):
- match opt { Some(user) => user.save() } → User#save, negative Repo#save
- if let Ok(repo) = res → Repo#save, negative User#save

C# var foreach (5 tests):
- foreach (var user in users) with List<User> → User#Save, negative Repo#Save
- foreach (var repo in repos) with List<Repo> → Repo#Save

C# switch pattern (4 tests):
- is User user → User#Save, case Repo repo → Repo#Save

Kotlin unannotated for (4 tests):
- for (user in users) with List<User> → user.save, negative repo.save

Go map range (3 tests):
- for _, user := range userMap with map[string]User → User#Save, negative

TypeScript readonly (4 tests):
- for (const user of users) with readonly User[] → user.save, negative

Bug fix: type-env.ts parameter branch now falls back to childForFieldName('pattern')
for Rust parameters (Rust uses 'pattern' not 'name' for parameter names)

* test: add assertion bodies to known limitation skip tests

Convert empty skip test stubs to proper tests with parse/buildTypeEnv/expect
assertions following the codebase convention (e.g., call-processor.test.ts:319).
Each skip test now documents the exact expected behavior, so removing .skip
will cause a meaningful failure when the limitation is eventually fixed.

Also clarify Python integration skip tests as call-extraction issues (not
type-env) and Swift integration skips as build-dep issues (self/super
resolution code already exists in type-env.ts).

* feat: resolve 4 known limitation skip tests + method-aware type arg selection

Unskip 4 of 5 type-env known limitations with full integration test coverage:

1. TS destructured for-of: handle array_pattern by binding last named child
   to element type. Fix Map<K,V> to return last generic arg (value type).
2. Python dict.items() loop: handle `call` iterables + `pattern_list` left
   side. Fix dict[K,V] extraction via type_parameter with last-arg heuristic.
   Unwrap `type` wrapper in extractPyElementTypeFromAnnotation.
3. TS instanceof narrowing: add extractPatternBinding for binary_expression
   with positional child access. First-writer-wins (not block-scoped).
4. Rust .iter() for-loops: handle call_expression in for_expression value
   node by extracting receiver from field_expression.

Method-aware type arg resolution:
- Add TypeArgPosition ('first'|'last') to resolveIterableElementType
- .keys()/.keySet()/.Keys → first type arg (key); all else → last (value)
- Thread position through all 3 strategy callbacks in TS/Rust/Python
- Add predefined_type to extractSimpleTypeName for TS primitives (string etc)

New fixtures: rust-iter-for-loop, typescript-destructured-for-of,
typescript-instanceof-narrowing, python-dict-items-loop.
248 unit tests pass (6 new), 1 skip (Ruby block params).

* feat: container descriptor table for generic type arg resolution

Replace simple KEY_METHODS heuristic with CONTAINER_DESCRIPTORS table
that maps 30+ container types across all languages to their type parameter
semantics per access method.

Key improvements:
- Container-aware resolution: HashMap.iter() correctly yields V (arity 2),
  while Vec.iter() yields T (arity 1) — same method, different semantics
- Cross-language coverage: Map/HashMap/BTreeMap/dict/Dict/Dictionary/
  ConcurrentHashMap + List/Vec/Set/HashSet/Queue/Deque/Stack etc.
- Method categorization: keyMethods (keys/keySet/Keys) vs valueMethods
  (values/get/pop/iter/first/last) per container type
- Fallback for unknown containers: still uses method name heuristic,
  so MyCache<K,V>.keys() correctly returns first arg
- Exported getContainerDescriptor() for future heritage-chain lookups

Each language extractor now passes containerTypeName from scopeEnv to
methodToTypeArgPosition for descriptor-aware resolution.

252 unit tests pass (4 new descriptor tests), 1 skip (Ruby).

* feat: method-aware for-loop extractors + integration tests for all languages

Upgrade 4 existing extractors + create 3 new ones for full cross-language
coverage of call_expression iterables and container descriptor resolution:

Upgraded (add call expr iterable + methodToTypeArgPosition):
- Java: method_invocation (data.keySet(), data.values())
- Kotlin: navigation_expression + call_expression (data.keys, data.values())
- C#: member_access_expression + invocation_expression (data.Keys, data.Values)
- Go: TypeArgPosition threading for Go 1.18+ generics

New for-loop extractors:
- C++: for_range_loop with auto& unwrapping, template_type + qualified_identifier
  (std::vector<User>) extraction, explicit vs auto type handling
- PHP: foreach_statement with simple/key-value/by-reference forms, PHPDoc
  @param priority over AST array type
- Ruby: for-in with YARD @param type resolution via comment parsing

Integration test fixtures + tests for all 6 languages:
- java-map-keys-values (Map.values() + List iteration)
- kotlin-map-keys-values (HashMap.values + List iteration)
- csharp-dictionary-keys-values (Dictionary.Values foreach)
- cpp-range-for (auto& + const auto& range-based for)
- php-foreach-loop (foreach with PHPDoc @param User[])
- ruby-for-in-loop (for-in with YARD @param Array<User>)

Bugs fixed during integration testing:
- C++: qualified_identifier (std::vector) not unwrapped to template_type
- PHP: extractParameter overwrote PHPDoc-derived types with bare 'array'

252 unit tests pass, 201 integration tests pass across 6 languages.

* fix: update extractElementTypeFromString tests for last-arg default

TypeArgPosition change (default 'last') broke 5 existing tests expecting
first arg from multi-arg generics. Updated expectations and added explicit
pos='first' tests for key type extraction.

* fix: rename C++ fixture files to correct case for case-sensitive CI

On case-sensitive filesystems (Linux/macOS CI), git tracked both the old
lowercase files (app.cpp, user.h) and the new uppercase files (App.cpp,
User.h) as separate files. The pipeline processed both, causing the old
app.cpp (with explicit User& type) to interfere with the new auto& test.

Removes old lowercase entries and re-adds with uppercase casing to match
the #include directives in the fixture.

* feat: PR #318 review findings — pattern bindings, member access iterables, structured bindings

Address all 7 genuine gaps identified in PR #318 deep code review:

- Kotlin: add extractKotlinPatternBinding for when/is (type_test AST node)
  with allowPatternBindingOverwrite for smart-cast semantics
- Java: add type_pattern branch for Java 17+ switch pattern variables
- TypeScript: explicit object_pattern skip in for-of (no false bindings)
- Cross-language: member access iterables (self.users, this.users, repo.users)
  across all 10 language extractors
- C++: structured_binding_declarator handling in range-for (last-child heuristic)
- Rust: closure_parameter added to TYPED_PARAMETER_TYPES
- PHP: normalizePhpType handles angle-bracket generics (Collection<User>)

Code review fixes applied:
- Remove 4 debug console.log statements (c-cpp.ts, call-processor.ts)
- Hoist KNOWN_CONTAINER_PROPS to module scope (csharp.ts)
- Guard keysBefore allocation behind typeNode check (type-env.ts)
- Add depth limits (50) to 7 recursive type extraction functions
- Add 2048-char length cap to extractSimpleTypeName
- Fix PHP/Ruby missing typeArgPos parameter in resolveIterableElementType

Integration test fixtures: kotlin-when-pattern, java-switch-pattern,
cpp-structured-binding, typescript-member-access-for-loop,
python-member-access-for-loop

* fix: position-indexed when/is bindings, Kotlin param extraction, HashMap.values for-loop

Three root causes for failing Kotlin integration tests:

1. When/is multi-arm resolution: flat scopeEnv stored only the last arm's
   type (last-writer-wins). Added PatternOverrides with AST range indexing
   so each when arm resolves to its narrowed type independently.

2. HashMap.values for-loop: navigation_expression without call_suffix was
   classified as bare property access (iterableName='values' instead of
   'data'). Now tries object-as-iterable + property-as-method first, with
   fallback to property-as-iterable for this.users patterns.

3. Kotlin parameter extraction: tree-sitter-kotlin parameter nodes use
   positional children (simple_identifier, user_type) not named fields
   (name, type). Added fallback to findChildByType in both
   extractKotlinParameter and extractTypeBinding.

Integration tests added for .keys/.values/Set/MutableMap iteration,
3-arm when/is, multi-call within arms, and when+else branch.

* feat: enhance PHP type resolution for generics and member access in foreach loops

* feat: Phase 6.1 type resolution gap closure — container descriptors, recursive_pattern, class fields

Add 13 missing container type descriptors (Collection, MutableMap, Stream, SortedSet, etc.)
to CONTAINER_DESCRIPTORS for correct element type extraction across C#, Kotlin, and Java.

Extend C# pattern binding to handle recursive_pattern (obj is User { Name: "Alice" } u)
in both is-expression and switch expression contexts.

Add TypeScript class field declaration support (public_field_definition) so for-loop
iteration over this.fieldName resolves element types from class field type annotations.
Includes file-scope fallback in resolveIterableElementType and nested member_expression
handling for this.field.method() patterns.

* docs: add type resolution system documentation with roadmap

Covers the full architecture, resolution tiers (0-2), scope model,
language feature matrix, container descriptors, pipeline integration,
and the Phase 7-9 roadmap for cross-scope propagation, field-type
resolution, and return-type-aware binding.

* feat: Phase 6.2 review findings — C# nested member foreach, C++ deref range-for, Java field_access

Close two gaps found during fourth-pass review of PR #318:

- C# foreach (var user in this.data.Values): nested member_access_expression
  now extracts intermediate property name for scopeEnv lookup
- C++ for (auto& user : *ptr): pointer_expression dereference now recognized
  as range-for iterable

Root causes fixed in shared infrastructure:
- extractSimpleTypeName: add template_type (C++) and generic_name (C#)
- extractGenericTypeArgs: add generic_name for consistency
- type-env.ts: unwrap variable_declaration wrapper in field_declaration
  for declarationTypeNodes capture (zero-allocation manual loop)

Additional review findings addressed:
- Java: add field_access handler for this.data.values() in method_invocation
- C++ pointer_expression: document limitation (*identifier only)
- TypeScript: fix stale comment about property_identifier

All 525 tests pass (278 unit + 247 integration).

* perf: optimize type resolution pipeline — worker threshold, skip graph phases, AST pruning

- Skip worker pool creation for small repos (<15 files or <512KB) — saves 100-400ms
- Add skipGraphPhases option to runPipelineFromRepo to skip MRO/community/process phases
- Add conservative SKIP_SUBTREE_TYPES for leaf-only AST nodes (string, comment, number)
- Pre-compute interestingNodeTypes set — single Set.has() replaces 3 checks per node
- Add fastStripNullable — skip full stripNullable for simple identifiers (90%+ case)
- Replace .children?.find() with manual for loops in extractFunctionName (no array alloc)
- Add hookTimeout: 120000 to vitest.config.ts for CI beforeAll hooks

* fix: review findings — remove template_string from SKIP_SUBTREE_TYPES, handle bare nullable keywords

- Remove template_string and concatenated_string from SKIP_SUBTREE_TYPES
  (template literals contain interpolated expressions with typed code)
- Add FAST_NULLABLE_KEYWORDS check to fastStripNullable for behavioral
  parity with stripNullable on bare null/undefined/void/None/nil
- Add explanatory comment on extractPendingAssignment scopeEnv guard

* feat: add type resolution system and roadmap documentation
2026-03-17 17:10:22 +00:00
Chirag Nighutandchirag-nighut f6b92d4f13 fix(resolver): fix for same-directory python imports (#328)
* fix(resolver): prefer same-directory file for Python bare imports

Python's sys.path searches the importing script's own directory first,
so `import user` from services/auth.py should resolve to services/user.py
even if models/user.py was indexed first in the suffix index.

Add a proximity check in resolveImportPath that consults the existing
dirMap index (O(1)) before falling back to global suffix matching, for
single-segment bare Python imports only.

Made-with: Cursor

* refactor(resolver): replace dirMap scan with O(1) allFiles.has() for proximity check

The previous implementation used index.getFilesInDir() + siblings.find()
which had two issues:
- dirMap stores all suffix levels, so getFilesInDir('services') matched
  files from every directory named 'services/' across the repo — false
  positives in monorepos
- siblings.find() was an O(n) linear scan despite the O(1) claim

Replace with a direct allFiles.has(importerDir + '/' + name + '.py') lookup.
allFiles is a Set<string> of full repo-relative paths, so the lookup is
truly O(1) and exact — no suffix ambiguity possible.

Also fixes: dead code (the '.rb' branch was unreachable since the outer if
gates on Python), and Windows backslash handling via normalize before split.

Made-with: Cursor

* test: remove flag-based demo from unit tests

Made-with: Cursor

* fix(resolver): cover package __init__.py in proximity check and add end-to-end CALLS test

- Also try importerDir/name/__init__.py as a second O(1) candidate so that
  `import user` resolves to services/user/__init__.py when the target is a
  package rather than a bare module file
- Add unit tests for package proximity, __init__.py fallback, and Windows
  backslash path handling
- Add end-to-end CALLS assertion to the bare-import integration test:
  svc.execute() must resolve to UserService#execute in services/user.py,
  proving the fix propagates correctly through the type inference pipeline

Made-with: Cursor

* refactor: extract Python import resolution into resolvers/python.ts

- Move PEP 328 relative import and proximity-based bare import logic
  from standard.ts into a dedicated resolvers/python.ts (resolvePythonImport)
- Dispatch Python imports from resolveLanguageImport in import-processor.ts,
  consistent with how Ruby, PHP, and other languages are handled
- standard.ts is now language-agnostic (TS/JS aliases, Rust paths, suffix fallback)
- Add inline comment on __init__.py vs .py resolution order edge case
- Update unit tests to call resolvePythonImport directly

Made-with: Cursor

* docs: add PEP 302/328/451 references to python.ts comments

Made-with: Cursor

* fix(python): address reviewer comments on PEP compliance

- Guard dirParts.pop() against over-traversal: return null when dot
  count exceeds directory depth, matching CPython's ImportError for
  'attempted relative import beyond top-level package' (PEP 328)
- Swap __init__.py / .py check order to match CPython's finder
  precedence (PEP 451 §4); coexistence is physically impossible so
  order only matters for spec compliance
- Fix overstated PEP 302 comment: proximity check is a static
  heuristic, not a sys.path[0] implementation
- Acknowledge namespace package gap (PEP 420) in docstring
- Add unit test for over-traversal guard

Made-with: Cursor

* test(python): document namespace package resolution behaviour

Add two unit tests for PEP 420 namespace packages (directory with no
__init__.py): bare import returns null (expected — no file exists to
resolve to, CPython sets __file__ = None), while the submodule form
(import user.model) resolves correctly via suffixResolve fallback.

Made-with: Cursor

---------

Co-authored-by: chirag-nighut <chiragnighut@gmail.com>
2026-03-17 16:32:34 +00:00
Zak 64b7ff0061 docs: add Codex MCP configuration to README (#236)
- Add Codex to Editor Support table
- Add Codex manual config example (~/.codex/config.toml)
- Update editor list in usage table

Fixes #131

Made-with: Cursor
2026-03-16 21:23:14 +00:00
14 changed files with 518 additions and 29 deletions
+10 -1
View File
@@ -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
+45
View File
@@ -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 -1
View File
@@ -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');
});
});
+61
View File
@@ -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');
});
});
});