Compare commits

...
Author SHA1 Message Date
Gergo Magyar fb5270c260 chore: bump version to 1.4.6 and update CHANGELOG 2026-03-18 08:47:45 +00:00
Gergő Magyar 604b575e4b feat: Phase 7 type resolution — return-aware loop inference & PHP class-property iterables (#341)
* feat(type-resolution): Phase 7.1+7.2 foundation — ReturnTypeLookup, context object, pendingCallResults

- Move extractReturnTypeName + helpers from call-processor.ts to type-extractors/shared.ts
  (breaks circular import risk: call-processor → type-env → type-extractors → call-processor)
- Add SymbolTable.lookupFuzzyCallable(name) — lazy callable-only index, O(1) per call,
  invalidated on add(); avoids per-call .filter() on lookupFuzzy results
- Add ReturnTypeLookup interface (conservative: undefined when 0 or 2+ callables match)
- Add ForLoopExtractorContext interface — replaces 4 positional params with context object;
  update all 10 language extractor implementations (go, ts, py, jvm×2, cs, rs, rb, php, c-cpp)
- Add PendingAssignment discriminated union (kind: 'copy' | 'callResult');
  update PendingAssignmentExtractor in all 9 language extractors that implement it
- Wire buildTypeEnv: build ReturnTypeLookup from optional symbolTable; split pendingAssignments
  into pendingCopies + pendingCallResults; add Tier 2b call-result propagation loop
- Update call-processor.test.ts to import extractReturnTypeName from shared.ts

* feat(type-resolution): Phase 7.3 — call_expression iterables in for-loop extractors (7 languages)

Extends for-loop type extraction in all 7 typed-iteration languages to
resolve element types when the iterable is a direct function call.

**New capability**: `for (var u : getUsers())` in Java, `for u in get_users()`
in Python, `for user in getUsers()` in TypeScript, etc. now resolve
`u`/`user` to the callee's return element type via lookupRawReturnType +
extractElementTypeFromString.

Changes per language:
- types.ts: extend ReturnTypeLookup with lookupRawReturnType (raw return
  string for container-type extraction); update ForLoopExtractorContext
  with returnTypeLookup field
- type-env.ts: implement lookupRawReturnType on the concrete ReturnTypeLookup
  built in buildTypeEnv (same guards as lookupReturnType, no extractReturnTypeName)
- go.ts: call_expression branch in range_clause — identifier func or
  selector_expression method; existing isChannelType guards updated
- typescript.ts: identifier fn branch inside call_expression handler
- python.ts: identifier fn branch inside call handler
- jvm.ts (Java): method_invocation without object field in enhanced_for_statement
- jvm.ts (Kotlin): simple_identifier callee branch in call_expression node
- csharp.ts: identifier fn branch in invocation_expression handler
- rust.ts: identifier func branch in call_expression handler (alongside
  existing field_expression/method-call path)

All branches follow the same conservative pattern:
  lookupRawReturnType(callee) → extractElementTypeFromString → bind loop var

* feat(type-resolution): Phase 7.4 — PHP \$this->property iterable via @var class property scan

Adds Strategy C to PHP's extractForLoopBinding for the pattern:

  foreach (\$this->property as \$item)

when Strategy A (resolveIterableElementType) and Strategy B (scopeEnv lookup)
both fail to find the element type.

Strategy C: when the iterable is a member_access_expression with object '$this',
walk up the AST to the enclosing class_declaration, scan its declaration_list
for a property_declaration whose variable_name matches the property, and extract
the element type from:
  1. PHPDoc @var annotation on a preceding comment sibling (/** @var User[] */)
  2. PHP 7.4+ native type field (e.g. UserRepo \$repo — skips generic 'array')

This eliminates the @param workaround that was previously required in the
php-foreach-member-access fixture (which used @param User[] \$users on the method
to populate the method's scopeEnv with a \$users binding).

New helpers in php.ts:
- PHPDOC_VAR_RE: regex for @var extraction
- extractClassPropertyElementType: reads @var or native type from a property_declaration
- findClassPropertyElementType: scans class body for a named property

Tests added (type-env.test.ts):
- PHP: resolves from @var User[] without @param workaround
- PHP: conservative — no binding for unknown property
- PHP: multi-class file — both classes resolve independently

Fixture updated (php-foreach-member-access/App.php):
- Removed the @param User[] \$users workaround from processMembers()
- Test now validates the natural class-property-based resolution path

* docs: mark Phase 7 complete in type-resolution-roadmap.md

Records that 7A (call_expression iterables, 7 languages), 7B (PHP
$this->property via @var scan), and 7C (ReturnTypeLookup + context object)
are all shipped. Adds implementation notes and strikethroughs on resolved
language-specific gaps.

* fix(docs): update project references to feat-phase7-type-resolution in AGENTS.md and CLAUDE.md

* feat(type-resolution): Phase 7.5 — PHP call_expression foreach + integration tests for 7 languages

Add integration test coverage for Phase 7.3's call_expression iterable
resolution across all 7 languages (Go, TypeScript, Python, Java, Kotlin,
PHP, Rust). Each test creates a fixture with competing User/Repo classes
that both define save(), then verifies for-loop iteration over a function
call's return value resolves to the correct class.

PHP was missing function_call_expression support in its for-loop extractor.
Three changes fix this:
- php.ts extractForLoopBinding: handle function_call_expression and
  member_call_expression iterables via returnTypeLookup
- php.ts normalizePhpReturnType: preserve array notation (User[]) in
  SymbolTable so lookupRawReturnType returns useful container types
- parse-worker.ts + parsing-processor.ts: upgrade uninformative AST
  return types (array, iterable) with PHPDoc @return annotations

35 new integration tests (5 per language), 2525 total tests passing.

* fix(type-resolution): address PR #341 review findings — PHP asymmetry + dormant infrastructure docs

- Replace normalizePhpType with extractElementTypeFromString in PHP call-expression
  foreach paths, aligning with all 6 other language extractors and preventing
  incorrect binding of bare non-container types like User
- Add NOTE comments clarifying pendingCallResults Tier 2b is infrastructure-ready
  but no extractor populates it yet
- Expand Go channel-type comments explaining why non-channel assumption is safe

* fix(type-resolution): address verification review — docs accuracy + PHP fallback guard

- Roadmap lines 86/100: correct pendingCallResults from "active" to "dormant infrastructure (Phase 9)"
- type-resolution-system.md line 363: update to reflect Phase 7.3 loop inference is delivered
- type-resolution-system.md line 409: clarify for-loop call-expression resolution (done) vs general assignment propagation (pending)
- php.ts:127: add declaration_list type guard on fallback to prevent silent wrong results
2026-03-18 08:39:38 +00:00
Gergo Magyar 02dfab578c fix(test): add --repo to CLI e2e tool tests for multi-repo environment 2026-03-18 08:12:25 +00:00
Gergo Magyar 1326490a5b fix(workflow): use prefixed temporary branch name for fork PRs to prevent overwriting real branches 2026-03-18 07:40:53 +00:00
林 駿甫 (Shunsuke Hayashi) b48cfe9894 fix(impact): return structured error + partial results instead of crashing (#321) (#345)
* fix(impact): return structured error + partial results instead of crashing (#321)

- Wrap impact() in try-catch to return structured error JSON instead of
  process crash (SIGSEGV/exit 139)
- Extract core logic to _impactImpl() for clean error boundary
- Break out of depth traversal loop on query failure, return partial
  results collected so far (previously silently swallowed errors)
- Add 'partial' flag to response when traversal was interrupted
- Add try-catch in CLI impactCommand with structured error output
- Improve formatImpactResult to show suggestion text and partial warning
- Add 3 new unit tests for error/suggestion/partial scenarios

Fixes #321

* fix: address review feedback — 4 bugs from @claude review

Per @claude's review (requested by @magyargergo):

- [BUG 1] Consistent target field shape: error responses now return
  {name: string} instead of raw string, matching success response schema
- [BUG 2] Remove misleading partial:true from total-failure responses
  (partial is only meaningful when some depth levels succeeded)
- [BUG 3] Move getBackend() inside try-catch in impactCommand so
  backend init failures return structured JSON instead of crashing
- [BUG 4] Safe error message extraction: use instanceof Error check
  to handle thrown strings correctly (err?.message is undefined for
  non-Error thrown values)
- [MINOR] Add radix argument to parseInt (10)

* test: add integration tests for impact error handling (#321)

Per @claude's recommendation (requested by @magyargergo):

- impact: structured error for unknown symbol (no crash)
- impact: error response has consistent {name: string} target shape
- impact: partial:true only set when some results were collected

Tests use existing withTestLbugDB + seeded graph fixture.
2026-03-18 06:45:43 +00:00
林 駿甫 (Shunsuke Hayashi) c1703fc0a9 fix(cli): write tool output to stdout via fd 1 instead of stderr (#324) (#346) 2026-03-18 06:21:32 +00:00
Karesansui 480fae933b fix(impact): add HAS_METHOD and OVERRIDES to VALID_RELATION_TYPES (#350) 2026-03-18 06:01:02 +00:00
林 駿甫 (Shunsuke Hayashi) 3879490817 fix: add postinstall permission fix for CLI and hook scripts (#330) (#348) 2026-03-18 05:41:37 +00:00
Gergo Magyar 50dbd03779 chore: add .worktrees/ to .gitignore 2026-03-17 21:39:09 +00:00
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
208 changed files with 9639 additions and 522 deletions
+4 -2
View File
@@ -85,14 +85,17 @@ jobs:
# for fork PRs. Create a temporary branch ref via the API so the action
# can find it. Using the API (not git push) avoids the GITHUB_TOKEN
# restriction that blocks pushing commits containing workflow file changes.
# Use a prefixed temporary branch name to avoid overwriting real branches
# (e.g. a fork branch named "main" would overwrite origin/main).
- name: Create fork branch ref on origin
id: push-fork
if: steps.pr.outputs.is_fork == 'true'
env:
FORK_BRANCH: ${{ steps.pr.outputs.branch }}
FORK_BRANCH: claude-tmp/fork-pr-${{ steps.pr.outputs.number }}
FORK_SHA: ${{ steps.pr.outputs.sha }}
GH_TOKEN: ${{ github.token }}
run: |
echo "FORK_BRANCH=$FORK_BRANCH" >> "$GITHUB_ENV"
gh api "repos/${{ github.repository }}/git/refs" \
--method POST \
-f ref="refs/heads/$FORK_BRANCH" \
@@ -116,6 +119,5 @@ jobs:
- name: Delete fork branch ref from origin
if: always() && steps.push-fork.outcome == 'success'
env:
FORK_BRANCH: ${{ steps.pr.outputs.branch }}
GH_TOKEN: ${{ github.token }}
run: gh api "repos/${{ github.repository }}/git/refs/heads/$FORK_BRANCH" --method DELETE || true
+5 -2
View File
@@ -64,6 +64,7 @@ jobs:
const isFork = pr.head.repo.full_name !== pr.base.repo.full_name;
core.setOutput('is_pr', 'true');
core.setOutput('number', String(prNumber));
core.setOutput('is_fork', String(isFork));
core.setOutput('branch', pr.head.ref);
core.setOutput('sha', pr.head.sha);
@@ -78,14 +79,17 @@ jobs:
# for fork PRs. Create a temporary branch ref via the API so the action
# can find it. Using the API (not git push) avoids the GITHUB_TOKEN
# restriction that blocks pushing commits containing workflow file changes.
# Use a prefixed temporary branch name to avoid overwriting real branches
# (e.g. a fork branch named "main" would overwrite origin/main).
- name: Create fork branch ref on origin
id: push-fork
if: steps.pr.outputs.is_fork == 'true'
env:
FORK_BRANCH: ${{ steps.pr.outputs.branch }}
FORK_BRANCH: claude-tmp/fork-pr-${{ steps.pr.outputs.number }}
FORK_SHA: ${{ steps.pr.outputs.sha }}
GH_TOKEN: ${{ github.token }}
run: |
echo "FORK_BRANCH=$FORK_BRANCH" >> "$GITHUB_ENV"
gh api "repos/${{ github.repository }}/git/refs" \
--method POST \
-f ref="refs/heads/$FORK_BRANCH" \
@@ -110,6 +114,5 @@ jobs:
- name: Delete fork branch ref from origin
if: always() && steps.push-fork.outcome == 'success'
env:
FORK_BRANCH: ${{ steps.pr.outputs.branch }}
GH_TOKEN: ${{ github.token }}
run: gh api "repos/${{ github.repository }}/git/refs/heads/$FORK_BRANCH" --method DELETE || true
+3 -1
View File
@@ -67,4 +67,6 @@ gitnexus/test/fixtures/mini-repo/.gitignore
# Ignore csharp generated obj and bin folders
gitnexus/test/fixtures/lang-resolution/**/obj
gitnexus/test/fixtures/lang-resolution/**/bin
GitNexus.sln
GitNexus.sln
# Git worktrees
.worktrees/
@@ -0,0 +1,33 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globalSetup: ['test/global-setup.ts'],
include: ['test/**/*.test.ts'],
testTimeout: 30000,
hookTimeout: 120000,
pool: 'forks',
globals: true,
setupFiles: ['test/setup.ts'],
teardownTimeout: 3000,
dangerouslyIgnoreUnhandledErrors: true, // LadybugDB N-API destructor segfaults on fork exit — not a test failure
coverage: {
provider: 'v8',
include: ['src/**/*.ts'],
exclude: [
'src/cli/index.ts', // CLI entry point (commander wiring)
'src/server/**', // HTTP server (requires network)
'src/core/wiki/**', // Wiki generation (requires LLM)
],
// Auto-ratchet: vitest bumps thresholds when coverage exceeds them.
// CI will fail if a PR drops below these floors.
thresholds: {
statements: 26,
branches: 23,
functions: 28,
lines: 27,
autoUpdate: true,
},
},
},
});
+6 -6
View File
@@ -1,7 +1,7 @@
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **GitNexus** (1999 symbols, 4681 relationships, 149 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
This project is indexed by GitNexus as **feat-phase7-type-resolution** (2075 symbols, 4935 relationships, 157 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
@@ -17,7 +17,7 @@ This project is indexed by GitNexus as **GitNexus** (1999 symbols, 4681 relation
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
3. `READ gitnexus://repo/GitNexus/process/{processName}` — trace the full execution flow step by step
3. `READ gitnexus://repo/feat-phase7-type-resolution/process/{processName}` — trace the full execution flow step by step
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
## When Refactoring
@@ -56,10 +56,10 @@ This project is indexed by GitNexus as **GitNexus** (1999 symbols, 4681 relation
| Resource | Use for |
|----------|---------|
| `gitnexus://repo/GitNexus/context` | Codebase overview, check index freshness |
| `gitnexus://repo/GitNexus/clusters` | All functional areas |
| `gitnexus://repo/GitNexus/processes` | All execution flows |
| `gitnexus://repo/GitNexus/process/{name}` | Step-by-step execution trace |
| `gitnexus://repo/feat-phase7-type-resolution/context` | Codebase overview, check index freshness |
| `gitnexus://repo/feat-phase7-type-resolution/clusters` | All functional areas |
| `gitnexus://repo/feat-phase7-type-resolution/processes` | All execution flows |
| `gitnexus://repo/feat-phase7-type-resolution/process/{name}` | Step-by-step execution trace |
## Self-Check Before Finishing
+6 -6
View File
@@ -1,7 +1,7 @@
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **GitNexus** (1999 symbols, 4681 relationships, 149 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
This project is indexed by GitNexus as **feat-phase7-type-resolution** (2075 symbols, 4935 relationships, 157 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
@@ -17,7 +17,7 @@ This project is indexed by GitNexus as **GitNexus** (1999 symbols, 4681 relation
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
3. `READ gitnexus://repo/GitNexus/process/{processName}` — trace the full execution flow step by step
3. `READ gitnexus://repo/feat-phase7-type-resolution/process/{processName}` — trace the full execution flow step by step
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
## When Refactoring
@@ -56,10 +56,10 @@ This project is indexed by GitNexus as **GitNexus** (1999 symbols, 4681 relation
| Resource | Use for |
|----------|---------|
| `gitnexus://repo/GitNexus/context` | Codebase overview, check index freshness |
| `gitnexus://repo/GitNexus/clusters` | All functional areas |
| `gitnexus://repo/GitNexus/processes` | All execution flows |
| `gitnexus://repo/GitNexus/process/{name}` | Step-by-step execution trace |
| `gitnexus://repo/feat-phase7-type-resolution/context` | Codebase overview, check index freshness |
| `gitnexus://repo/feat-phase7-type-resolution/clusters` | All functional areas |
| `gitnexus://repo/feat-phase7-type-resolution/processes` | All execution flows |
| `gitnexus://repo/feat-phase7-type-resolution/process/{name}` | Step-by-step execution trace |
## Self-Check Before Finishing
+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
+57
View File
@@ -0,0 +1,57 @@
---
review_agents: [kieran-typescript-reviewer, pattern-recognition-specialist, architecture-strategist, data-integrity-guardian, security-sentinel, performance-oracle, code-simplicity-reviewer]
plan_review_agents: [kieran-typescript-reviewer, architecture-strategist, code-simplicity-reviewer]
voltagent_agents: [voltagent-lang:typescript-pro, voltagent-qa-sec:security-auditor, voltagent-data-ai:database-optimizer]
---
# Review Context
## Project Overview
GitNexus is a code intelligence tool that builds a knowledge graph from source code using tree-sitter AST parsing across 12 languages and KuzuDB for graph storage. Two packages: `gitnexus/` (CLI/MCP, TypeScript) and `gitnexus-web/` (browser).
## Cross-Language Pattern Consistency (pattern-recognition-specialist)
- 12 language-specific type extractors in `gitnexus/src/core/ingestion/type-extractors/` must follow identical patterns for: async unwrapping, constructor binding, namespace handling, nullable type stripping, for-loop element typing.
- Past bugs: C#/Rust missing `await_expression` unwrapping that TypeScript handled correctly; PHP backslash namespace splitting inconsistent with other languages' `::` / `.` splitting.
- When reviewing type extractor changes, verify the same pattern exists in ALL applicable language files — asymmetry is the #1 source of bugs.
## Data Integrity (data-integrity-guardian)
- KuzuDB graph operations: schema in `gitnexus/src/core/kuzu/schema.ts`, adapter in `kuzu-adapter.ts`.
- The ingestion pipeline writes symbols and relationships to the graph — changes to node/relation schemas or the ingestion pipeline can corrupt the index.
- Known issue: KuzuDB `close()` hangs on Linux due to C++ destructor — use `detachKuzu()` pattern.
- `lbug-adapter.ts` fallback path needs quote/newline escaping for Cypher injection prevention.
## Security (security-sentinel)
- Cypher query construction in `lbug-adapter.ts` and `kuzu-adapter.ts` — watch for injection via unescaped user-provided symbol names.
- CLI accepts `--repo` parameter and file paths — validate against path traversal.
- MCP server exposes tools to external AI agents — all tool inputs are untrusted.
## Performance (performance-oracle)
- Tree-sitter buffer size is adaptive (512KB–32MB) via `getTreeSitterBufferSize()` in `constants.ts`.
- The ingestion pipeline processes entire repositories — O(n) per file with potential O(n²) in cross-file resolution.
- KuzuDB batch inserts vs individual inserts matter for large repos.
## Architecture (architecture-strategist)
- Ingestion pipeline phases: structure → parsing → imports → calls → heritage → processes → type resolution.
- Shared modules: `export-detection.ts`, `constants.ts`, `utils.ts` — changes here have wide blast radius.
- `gitnexus-web` package drifts behind CLI — flag if a change should be mirrored.
## Voltagent Supplementary Agents
Invoke these via the Agent tool alongside `/ce:review` for deeper specialist analysis. These cover gaps that compound-engineering agents don't:
### voltagent-lang:typescript-pro
**When:** Changes touch type-resolution logic, generics, conditional types, or complex type-level programming in `type-env.ts`, `type-extractors/*.ts`, or `types.ts`.
**Why:** The type resolution system uses advanced TypeScript patterns (discriminated unions, mapped types, recursive generics) that benefit from deep TS type-system review beyond what kieran-typescript-reviewer covers.
### voltagent-qa-sec:security-auditor
**When:** Changes touch MCP tool handlers, Cypher query construction, CLI argument parsing, or any code that processes external input.
**Why:** GitNexus is an MCP server — all tool inputs come from untrusted AI agents. Systematic OWASP-level audit catches injection vectors that spot-checking misses. Past finding: `lbug-adapter.ts` fallback path had unescaped newlines in Cypher queries.
### voltagent-data-ai:database-optimizer
**When:** Changes touch `kuzu-adapter.ts`, `schema.ts`, `lbug-adapter.ts`, or any Cypher query construction/execution.
**Why:** No CE agent specializes in graph database optimization. KuzuDB batch insert patterns, index usage, and query planning directly affect analysis speed on large repos.
## Review Tooling
- Use `gitnexus_impact()` before approving changes to any symbol — check d=1 (WILL BREAK) callers.
- Use `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` to map PR diffs to affected execution flows.
- Use claude-mem to surface past architectural decisions relevant to the code under review.
+72
View File
@@ -0,0 +1,72 @@
# Changelog
All notable changes to GitNexus will be documented in this file.
## [1.4.6] - 2026-03-18
### Added
- **Phase 7 type resolution** — return-aware loop inference for call-expression iterables (#341)
- `ReturnTypeLookup` interface with `lookupReturnType` / `lookupRawReturnType` split
- `ForLoopExtractorContext` context object replacing positional `(node, env)` signature
- Call-expression iterable resolution across 8 languages (TS/JS, Java, Kotlin, C#, Go, Rust, Python, PHP)
- PHP `$this->property` foreach via `@var` class property scan (Strategy C)
- PHP `function_call_expression` and `member_call_expression` foreach paths
- `extractElementTypeFromString` as canonical raw-string container unwrapper in `shared.ts`
- `extractReturnTypeName` deduplicated from `call-processor.ts` into `shared.ts` (137 lines removed)
- `SKIP_SUBTREE_TYPES` performance optimization with documented `template_string` exclusion
- `pendingCallResults` infrastructure (dormant — Phase 9 work)
### Fixed
- **impact**: return structured error + partial results instead of crashing (#345)
- **impact**: add `HAS_METHOD` and `OVERRIDES` to `VALID_RELATION_TYPES` (#350)
- **cli**: write tool output to stdout via fd 1 instead of stderr (#346)
- **postinstall**: add permission fix for CLI and hook scripts (#348)
- **workflow**: use prefixed temporary branch name for fork PRs to prevent overwriting real branches
- **test**: add `--repo` to CLI e2e tool tests for multi-repo environment
- **php**: add `declaration_list` type guard on `findClassPropertyElementType` fallback
- **docs**: correct `pendingCallResults` description in roadmap and system docs
### Chore
- Add `.worktrees/` to `.gitignore`
## [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
View File
View File
View File
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "gitnexus",
"version": "1.4.0",
"version": "1.4.6",
"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",
@@ -45,7 +45,8 @@
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"prepare": "npm run build",
"postinstall": "node scripts/patch-tree-sitter-swift.cjs"
"postinstall": "node scripts/patch-tree-sitter-swift.cjs",
"prepack": "npm run build && chmod +x dist/cli/index.js"
},
"dependencies": {
"@huggingface/transformers": "^3.0.0",
View File
+13 -4
View File
@@ -25,6 +25,7 @@
*/
import http from 'http';
import { writeSync } from 'node:fs';
import { LocalBackend } from '../mcp/local/local-backend.js';
export interface EvalServerOptions {
@@ -142,7 +143,10 @@ export function formatContextResult(result: any): string {
}
export function formatImpactResult(result: any): string {
if (result.error) return `Error: ${result.error}`;
if (result.error) {
const suggestion = result.suggestion ? `\nSuggestion: ${result.suggestion}` : '';
return `Error: ${result.error}${suggestion}`;
}
const target = result.target;
const direction = result.direction;
@@ -155,7 +159,11 @@ export function formatImpactResult(result: any): string {
const lines: string[] = [];
const dirLabel = direction === 'upstream' ? 'depends on this (will break if changed)' : 'this depends on';
lines.push(`Blast radius for ${target?.kind || ''} ${target?.name} (${direction}): ${total} symbol(s) ${dirLabel}\n`);
lines.push(`Blast radius for ${target?.kind || ''} ${target?.name} (${direction}): ${total} symbol(s) ${dirLabel}`);
if (result.partial) {
lines.push('⚠️ Partial results — graph traversal was interrupted. Deeper impacts may exist.');
}
lines.push('');
const depthLabels: Record<number, string> = {
1: 'WILL BREAK (direct)',
@@ -401,9 +409,10 @@ export async function evalServerCommand(options?: EvalServerOptions): Promise<vo
console.error(` Auto-shutdown after ${idleTimeoutSec}s idle`);
}
try {
process.stdout.write(`GITNEXUS_EVAL_SERVER_READY:${port}\n`);
// Use fd 1 directly — LadybugDB captures process.stdout (#324)
writeSync(1, `GITNEXUS_EVAL_SERVER_READY:${port}\n`);
} catch {
// stdout may not be available
// stdout may not be available (e.g., broken pipe)
}
});
+46 -13
View File
@@ -10,10 +10,12 @@
* gitnexus impact --target "AuthService" --direction upstream
* gitnexus cypher "MATCH (n:Function) RETURN n.name LIMIT 10"
*
* Note: Output goes to stderr because LadybugDB's native module captures stdout
* at the OS level during init. This is consistent with augment.ts.
* Note: Output goes to stdout via fs.writeSync(fd 1), bypassing LadybugDB's
* native module which captures the Node.js process.stdout stream during init.
* See the output() function for details (#324).
*/
import { writeSync } from 'node:fs';
import { LocalBackend } from '../mcp/local/local-backend.js';
let _backend: LocalBackend | null = null;
@@ -29,10 +31,29 @@ async function getBackend(): Promise<LocalBackend> {
return _backend;
}
/**
* Write tool output to stdout using low-level fd write.
*
* LadybugDB's native module captures Node.js process.stdout during init,
* but the underlying OS file descriptor 1 (stdout) remains intact.
* By using fs.writeSync(1, ...) we bypass the Node.js stream layer
* and write directly to the real stdout fd (#324).
*
* Falls back to stderr if the fd write fails (e.g., broken pipe).
*/
function output(data: any): void {
const text = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
// stderr because LadybugDB captures stdout at OS level
process.stderr.write(text + '\n');
try {
writeSync(1, text + '\n');
} catch (err: any) {
if (err?.code === 'EPIPE') {
// Consumer closed the pipe (e.g., `gitnexus cypher ... | head -1`)
// Exit cleanly per Unix convention
process.exit(0);
}
// Fallback: stderr (previous behavior, works on all platforms)
process.stderr.write(text + '\n');
}
}
export async function queryCommand(queryText: string, options?: {
@@ -92,15 +113,27 @@ export async function impactCommand(target: string, options?: {
process.exit(1);
}
const backend = await getBackend();
const result = await backend.callTool('impact', {
target,
direction: options?.direction || 'upstream',
maxDepth: options?.depth ? parseInt(options.depth) : undefined,
includeTests: options?.includeTests ?? false,
repo: options?.repo,
});
output(result);
try {
const backend = await getBackend();
const result = await backend.callTool('impact', {
target,
direction: options?.direction || 'upstream',
maxDepth: options?.depth ? parseInt(options.depth, 10) : undefined,
includeTests: options?.includeTests ?? false,
repo: options?.repo,
});
output(result);
} catch (err: unknown) {
// Belt-and-suspenders: catch infrastructure failures (getBackend, callTool transport)
// The backend's impact() already returns structured errors for graph query failures
output({
error: (err instanceof Error ? err.message : String(err)) || 'Impact analysis failed unexpectedly',
target: { name: target },
direction: options?.direction || 'upstream',
suggestion: 'Try reducing --depth or using gitnexus context <symbol> as a fallback',
});
process.exit(1);
}
}
export async function cypherCommand(query: string, options?: {
+2 -132
View File
@@ -28,6 +28,7 @@ import type { ConstructorBinding } from './type-env.js';
import { getTreeSitterBufferSize } from './constants.js';
import type { ExtractedCall, ExtractedHeritage, ExtractedRoute, FileConstructorBindings } from './workers/parse-worker.js';
import { callRouters } from './call-routing.js';
import { extractReturnTypeName } from './type-extractors/shared.js';
/**
* Walk up the AST from a node to find the enclosing function/method.
@@ -216,7 +217,7 @@ export const processCalls = async (
const nodeId = generateId('Property', `${file.path}:${item.propName}`);
graph.addNode({
id: nodeId,
label: 'Property' as any, // TODO: add 'Property' to graph node label union
label: 'Property',
properties: {
name: item.propName, filePath: file.path,
startLine: item.startLine, endLine: item.endLine,
@@ -498,136 +499,6 @@ const resolveCallTarget = (
return toResolveResult(filteredCandidates[0], tiered.tier);
};
// ── Return type text helpers ─────────────────────────────────────────────
// extractSimpleTypeName works on AST nodes; this operates on raw return-type
// text already stored in SymbolDefinition (e.g. "User", "Promise<User>",
// "User | null", "*User"). Extracts the base user-defined type name.
/** Primitive / built-in types that should NOT produce a receiver binding. */
const PRIMITIVE_TYPES = new Set([
'string', 'number', 'boolean', 'void', 'int', 'float', 'double', 'long',
'short', 'byte', 'char', 'bool', 'str', 'i8', 'i16', 'i32', 'i64',
'u8', 'u16', 'u32', 'u64', 'f32', 'f64', 'usize', 'isize',
'undefined', 'null', 'None', 'nil',
]);
/**
* Extract a simple type name from raw return-type text.
* Handles common patterns:
* "User" → "User"
* "Promise<User>" → "User" (unwrap wrapper generics)
* "Option<User>" → "User"
* "Result<User, Error>" → "User" (first type arg)
* "User | null" → "User" (strip nullable union)
* "User?" → "User" (strip nullable suffix)
* "*User" → "User" (Go pointer)
* "&User" → "User" (Rust reference)
* Returns undefined for complex types or primitives.
*/
const WRAPPER_GENERICS = new Set([
'Promise', 'Observable', 'Future', 'CompletableFuture', 'Task', 'ValueTask', // async wrappers
'Option', 'Some', 'Optional', 'Maybe', // nullable wrappers
'Result', 'Either', // result wrappers
// Rust smart pointers (Deref to inner type)
'Rc', 'Arc', 'Weak', // pointer types
'MutexGuard', 'RwLockReadGuard', 'RwLockWriteGuard', // guard types
'Ref', 'RefMut', // RefCell guards
'Cow', // copy-on-write
// Containers (List, Array, Vec, Set, etc.) are intentionally excluded —
// methods are called on the container, not the element type.
// Non-wrapper generics return the base type (e.g., List) via the else branch.
]);
/**
* Extracts the first type argument from a comma-separated generic argument string,
* respecting nested angle brackets. For example:
* "Result<User, Error>" → "Result<User, Error>" (no top-level comma)
* "User, Error" → "User"
* "Map<K, V>, string" → "Map<K, V>"
*/
function extractFirstGenericArg(args: string): string {
let depth = 0;
for (let i = 0; i < args.length; i++) {
if (args[i] === '<') depth++;
else if (args[i] === '>') depth--;
else if (args[i] === ',' && depth === 0) return args.slice(0, i).trim();
}
return args.trim();
}
/**
* Extract the first non-lifetime type argument from a generic argument string.
* Skips Rust lifetime parameters (e.g., `'a`, `'_`) to find the actual type.
* "'_, User" → "User"
* "'a, User" → "User"
* "User, Error" → "User" (no lifetime — delegates to extractFirstGenericArg)
*/
function extractFirstTypeArg(args: string): string {
let remaining = args;
while (remaining) {
const first = extractFirstGenericArg(remaining);
if (!first.startsWith("'")) return first;
// Skip past this lifetime arg + the comma separator
const commaIdx = remaining.indexOf(',', first.length);
if (commaIdx < 0) return first; // only lifetimes — fall through
remaining = remaining.slice(commaIdx + 1).trim();
}
return args.trim();
}
export const extractReturnTypeName = (raw: string, depth = 0): string | undefined => {
if (depth > 10) return undefined;
let text = raw.trim();
if (!text) return undefined;
// Strip pointer/reference prefixes: *User, &User, &mut User
text = text.replace(/^[&*]+\s*(mut\s+)?/, '');
// Strip nullable suffix: User?
text = text.replace(/\?$/, '');
// Handle union types: "User | null" → "User"
if (text.includes('|')) {
const parts = text.split('|').map(p => p.trim()).filter(p =>
p !== 'null' && p !== 'undefined' && p !== 'void' && p !== 'None' && p !== 'nil'
);
if (parts.length === 1) text = parts[0];
else return undefined; // genuine union — too complex
}
// Handle generics: Promise<User> → unwrap if wrapper, else take base
const genericMatch = text.match(/^(\w+)\s*<(.+)>$/);
if (genericMatch) {
const [, base, args] = genericMatch;
if (WRAPPER_GENERICS.has(base)) {
// Take the first non-lifetime type argument, using bracket-balanced splitting
// so that nested generics like Result<User, Error> are not split at the inner
// comma. Lifetime parameters (Rust 'a, '_) are skipped.
const firstArg = extractFirstTypeArg(args);
return extractReturnTypeName(firstArg, depth + 1);
}
// Non-wrapper generic: return the base type (e.g., Map<K,V> → Map)
return PRIMITIVE_TYPES.has(base.toLowerCase()) ? undefined : base;
}
// Bare wrapper type without generic argument (e.g. Task, Promise, Option)
// should not produce a binding — these are meaningless without a type parameter
if (WRAPPER_GENERICS.has(text)) return undefined;
// Handle qualified names: models.User → User, Models::User → User, \App\Models\User → User
if (text.includes('::') || text.includes('.') || text.includes('\\')) {
text = text.split(/::|[.\\]/).pop()!;
}
// Final check: skip primitives
if (PRIMITIVE_TYPES.has(text) || PRIMITIVE_TYPES.has(text.toLowerCase())) return undefined;
// Must start with uppercase (class/type convention) or be a valid identifier
if (!/^[A-Z_]\w*$/.test(text)) return undefined;
return text;
};
// ── Scope key helpers ────────────────────────────────────────────────────
// Scope keys use the format "funcName@startIndex" (produced by type-env.ts).
// Source IDs use "Label:filepath:funcName" (produced by parse-worker.ts).
@@ -732,7 +603,6 @@ export const processCallsFromExtracted = async (
if (!list) { list = []; byFile.set(call.filePath, list); }
list.push(call);
}
const totalFiles = byFile.size;
let filesProcessed = 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);
@@ -238,10 +238,12 @@ const processParsingSequential = async (
: undefined;
// Language-specific return type fallback (e.g. Ruby YARD @return [Type])
if (methodSig && !methodSig.returnType && definitionNode) {
// Also upgrades uninformative AST types like PHP `array` with PHPDoc `@return User[]`
if (methodSig && (!methodSig.returnType || methodSig.returnType === 'array' || methodSig.returnType === 'iterable') && definitionNode) {
const tc = typeConfigs[language as keyof typeof typeConfigs];
if (tc?.extractReturnType) {
methodSig.returnType = tc.extractReturnType(definitionNode);
const docReturn = tc.extractReturnType(definitionNode);
if (docReturn) methodSig.returnType = docReturn;
}
}
+139 -119
View File
@@ -33,9 +33,15 @@ const CHUNK_BYTE_BUDGET = 20 * 1024 * 1024; // 20MB
/** Max AST trees to keep in LRU cache */
const AST_CACHE_CAP = 50;
export interface PipelineOptions {
/** Skip MRO, community detection, and process extraction for faster test runs. */
skipGraphPhases?: boolean;
}
export const runPipelineFromRepo = async (
repoPath: string,
onProgress: (progress: PipelineProgress) => void
onProgress: (progress: PipelineProgress) => void,
options?: PipelineOptions,
): Promise<PipelineResult> => {
const graph = createKnowledgeGraph();
const ctx = createResolutionContext();
@@ -154,22 +160,29 @@ export const runPipelineFromRepo = async (
stats: { filesProcessed: 0, totalFiles: totalParseable, nodesCreated: graph.nodeCount },
});
// Don't spawn workers for tiny repos — overhead exceeds benefit
const MIN_FILES_FOR_WORKERS = 15;
const MIN_BYTES_FOR_WORKERS = 512 * 1024;
const totalBytes = parseableScanned.reduce((s, f) => s + f.size, 0);
// Create worker pool once, reuse across chunks
let workerPool: WorkerPool | undefined;
try {
let workerUrl = new URL('./workers/parse-worker.js', import.meta.url);
// When running under vitest, import.meta.url points to src/ where no .js exists.
// Fall back to the compiled dist/ worker so the pool can spawn real worker threads.
const thisDir = fileURLToPath(new URL('.', import.meta.url));
if (!fs.existsSync(fileURLToPath(workerUrl))) {
const distWorker = path.resolve(thisDir, '..', '..', '..', 'dist', 'core', 'ingestion', 'workers', 'parse-worker.js');
if (fs.existsSync(distWorker)) {
workerUrl = pathToFileURL(distWorker) as URL;
if (totalParseable >= MIN_FILES_FOR_WORKERS || totalBytes >= MIN_BYTES_FOR_WORKERS) {
try {
let workerUrl = new URL('./workers/parse-worker.js', import.meta.url);
// When running under vitest, import.meta.url points to src/ where no .js exists.
// Fall back to the compiled dist/ worker so the pool can spawn real worker threads.
const thisDir = fileURLToPath(new URL('.', import.meta.url));
if (!fs.existsSync(fileURLToPath(workerUrl))) {
const distWorker = path.resolve(thisDir, '..', '..', '..', 'dist', 'core', 'ingestion', 'workers', 'parse-worker.js');
if (fs.existsSync(distWorker)) {
workerUrl = pathToFileURL(distWorker) as URL;
}
}
workerPool = createWorkerPool(workerUrl);
} catch (err) {
if (isDev) console.warn('Worker pool creation failed, using sequential fallback:', (err as Error).message);
}
workerPool = createWorkerPool(workerUrl);
} catch (err) {
if (isDev) console.warn('Worker pool creation failed, using sequential fallback:', (err as Error).message);
}
let filesParsedSoFar = 0;
@@ -322,130 +335,137 @@ export const runPipelineFromRepo = async (
(importCtx as any).suffixIndex = null;
(importCtx as any).normalizedFileList = null;
// ── Phase 4.5: Method Resolution Order ──────────────────────────────
onProgress({
phase: 'parsing',
percent: 81,
message: 'Computing method resolution order...',
stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: graph.nodeCount },
});
let communityResult: Awaited<ReturnType<typeof processCommunities>> | undefined;
let processResult: Awaited<ReturnType<typeof processProcesses>> | undefined;
const mroResult = computeMRO(graph);
if (isDev && mroResult.entries.length > 0) {
console.log(`🔀 MRO: ${mroResult.entries.length} classes analyzed, ${mroResult.ambiguityCount} ambiguities found, ${mroResult.overrideEdges} OVERRIDES edges`);
}
// ── Phase 5: Communities ───────────────────────────────────────────
onProgress({
phase: 'communities',
percent: 82,
message: 'Detecting code communities...',
stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: graph.nodeCount },
});
const communityResult = await processCommunities(graph, (message, progress) => {
const communityProgress = 82 + (progress * 0.10);
if (!options?.skipGraphPhases) {
// ── Phase 4.5: Method Resolution Order ──────────────────────────────
onProgress({
phase: 'communities',
percent: Math.round(communityProgress),
message,
phase: 'parsing',
percent: 81,
message: 'Computing method resolution order...',
stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: graph.nodeCount },
});
});
if (isDev) {
console.log(`🏘️ Community detection: ${communityResult.stats.totalCommunities} communities found (modularity: ${communityResult.stats.modularity.toFixed(3)})`);
}
const mroResult = computeMRO(graph);
if (isDev && mroResult.entries.length > 0) {
console.log(`🔀 MRO: ${mroResult.entries.length} classes analyzed, ${mroResult.ambiguityCount} ambiguities found, ${mroResult.overrideEdges} OVERRIDES edges`);
}
communityResult.communities.forEach(comm => {
graph.addNode({
id: comm.id,
label: 'Community' as const,
properties: {
name: comm.label,
filePath: '',
heuristicLabel: comm.heuristicLabel,
cohesion: comm.cohesion,
symbolCount: comm.symbolCount,
}
// ── Phase 5: Communities ───────────────────────────────────────────
onProgress({
phase: 'communities',
percent: 82,
message: 'Detecting code communities...',
stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: graph.nodeCount },
});
});
communityResult.memberships.forEach(membership => {
graph.addRelationship({
id: `${membership.nodeId}_member_of_${membership.communityId}`,
type: 'MEMBER_OF',
sourceId: membership.nodeId,
targetId: membership.communityId,
confidence: 1.0,
reason: 'leiden-algorithm',
});
});
// ── Phase 6: Processes ─────────────────────────────────────────────
onProgress({
phase: 'processes',
percent: 94,
message: 'Detecting execution flows...',
stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: graph.nodeCount },
});
let symbolCount = 0;
graph.forEachNode(n => { if (n.label !== 'File') symbolCount++; });
const dynamicMaxProcesses = Math.max(20, Math.min(300, Math.round(symbolCount / 10)));
const processResult = await processProcesses(
graph,
communityResult.memberships,
(message, progress) => {
const processProgress = 94 + (progress * 0.05);
communityResult = await processCommunities(graph, (message, progress) => {
const communityProgress = 82 + (progress * 0.10);
onProgress({
phase: 'processes',
percent: Math.round(processProgress),
phase: 'communities',
percent: Math.round(communityProgress),
message,
stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: graph.nodeCount },
});
},
{ maxProcesses: dynamicMaxProcesses, minSteps: 3 }
);
});
if (isDev) {
console.log(`🔄 Process detection: ${processResult.stats.totalProcesses} processes found (${processResult.stats.crossCommunityCount} cross-community)`);
if (isDev) {
console.log(`🏘️ Community detection: ${communityResult.stats.totalCommunities} communities found (modularity: ${communityResult.stats.modularity.toFixed(3)})`);
}
communityResult.communities.forEach(comm => {
graph.addNode({
id: comm.id,
label: 'Community' as const,
properties: {
name: comm.label,
filePath: '',
heuristicLabel: comm.heuristicLabel,
cohesion: comm.cohesion,
symbolCount: comm.symbolCount,
}
});
});
communityResult.memberships.forEach(membership => {
graph.addRelationship({
id: `${membership.nodeId}_member_of_${membership.communityId}`,
type: 'MEMBER_OF',
sourceId: membership.nodeId,
targetId: membership.communityId,
confidence: 1.0,
reason: 'leiden-algorithm',
});
});
// ── Phase 6: Processes ─────────────────────────────────────────────
onProgress({
phase: 'processes',
percent: 94,
message: 'Detecting execution flows...',
stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: graph.nodeCount },
});
let symbolCount = 0;
graph.forEachNode(n => { if (n.label !== 'File') symbolCount++; });
const dynamicMaxProcesses = Math.max(20, Math.min(300, Math.round(symbolCount / 10)));
processResult = await processProcesses(
graph,
communityResult.memberships,
(message, progress) => {
const processProgress = 94 + (progress * 0.05);
onProgress({
phase: 'processes',
percent: Math.round(processProgress),
message,
stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: graph.nodeCount },
});
},
{ maxProcesses: dynamicMaxProcesses, minSteps: 3 }
);
if (isDev) {
console.log(`🔄 Process detection: ${processResult.stats.totalProcesses} processes found (${processResult.stats.crossCommunityCount} cross-community)`);
}
processResult.processes.forEach(proc => {
graph.addNode({
id: proc.id,
label: 'Process' as const,
properties: {
name: proc.label,
filePath: '',
heuristicLabel: proc.heuristicLabel,
processType: proc.processType,
stepCount: proc.stepCount,
communities: proc.communities,
entryPointId: proc.entryPointId,
terminalId: proc.terminalId,
}
});
});
processResult.steps.forEach(step => {
graph.addRelationship({
id: `${step.nodeId}_step_${step.step}_${step.processId}`,
type: 'STEP_IN_PROCESS',
sourceId: step.nodeId,
targetId: step.processId,
confidence: 1.0,
reason: 'trace-detection',
step: step.step,
});
});
}
processResult.processes.forEach(proc => {
graph.addNode({
id: proc.id,
label: 'Process' as const,
properties: {
name: proc.label,
filePath: '',
heuristicLabel: proc.heuristicLabel,
processType: proc.processType,
stepCount: proc.stepCount,
communities: proc.communities,
entryPointId: proc.entryPointId,
terminalId: proc.terminalId,
}
});
});
processResult.steps.forEach(step => {
graph.addRelationship({
id: `${step.nodeId}_step_${step.step}_${step.processId}`,
type: 'STEP_IN_PROCESS',
sourceId: step.nodeId,
targetId: step.processId,
confidence: 1.0,
reason: 'trace-detection',
step: step.step,
});
});
onProgress({
phase: 'complete',
percent: 100,
message: `Graph complete! ${communityResult.stats.totalCommunities} communities, ${processResult.stats.totalProcesses} processes detected.`,
message: communityResult && processResult
? `Graph complete! ${communityResult.stats.totalCommunities} communities, ${processResult.stats.totalProcesses} processes detected.`
: 'Graph complete! (graph phases skipped)',
stats: {
filesProcessed: totalFiles,
totalFiles,
@@ -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('/');
+31 -1
View File
@@ -38,6 +38,13 @@ export interface SymbolTable {
* Used when imports are missing or for framework magic
*/
lookupFuzzy: (name: string) => SymbolDefinition[];
/**
* Low Confidence: Look for callable symbols (Function/Method/Constructor) by name.
* Faster than `lookupFuzzy` + filter — backed by a lazy callable-only index.
* Used by ReturnTypeLookup to resolve callee → return type.
*/
lookupFuzzyCallable: (name: string) => SymbolDefinition[];
/**
* Debugging: See how many symbols are tracked
@@ -59,6 +66,13 @@ export const createSymbolTable = (): SymbolTable => {
// Structure: SymbolName -> [List of Definitions]
const globalIndex = new Map<string, SymbolDefinition[]>();
// 3. Lazy Callable Index — populated on first lookupFuzzyCallable call.
// Structure: SymbolName -> [Callable Definitions]
// Only Function, Method, Constructor symbols are indexed.
let callableIndex: Map<string, SymbolDefinition[]> | null = null;
const CALLABLE_TYPES = new Set(['Function', 'Method', 'Constructor']);
const add = (
filePath: string,
name: string,
@@ -86,6 +100,9 @@ export const createSymbolTable = (): SymbolTable => {
globalIndex.set(name, []);
}
globalIndex.get(name)!.push(def);
// Invalidate the lazy callable index — it will be rebuilt on next use
callableIndex = null;
};
const lookupExact = (filePath: string, name: string): string | undefined => {
@@ -100,6 +117,18 @@ export const createSymbolTable = (): SymbolTable => {
return globalIndex.get(name) || [];
};
const lookupFuzzyCallable = (name: string): SymbolDefinition[] => {
if (!callableIndex) {
// Build the callable index lazily on first use
callableIndex = new Map();
for (const [symName, defs] of globalIndex) {
const callables = defs.filter(d => CALLABLE_TYPES.has(d.type));
if (callables.length > 0) callableIndex.set(symName, callables);
}
}
return callableIndex.get(name) ?? [];
};
const getStats = () => ({
fileCount: fileIndex.size,
globalSymbolCount: globalIndex.size
@@ -108,7 +137,8 @@ export const createSymbolTable = (): SymbolTable => {
const clear = () => {
fileIndex.clear();
globalIndex.clear();
callableIndex = null;
};
return { add, lookupExact, lookupExactFull, lookupFuzzy, getStats, clear };
return { add, lookupExact, lookupExactFull, lookupFuzzy, lookupFuzzyCallable, getStats, clear };
};
+268 -40
View File
@@ -1,9 +1,9 @@
import type { SyntaxNode } from './utils.js';
import { FUNCTION_NODE_TYPES, extractFunctionName, CLASS_CONTAINER_TYPES } from './utils.js';
import { FUNCTION_NODE_TYPES, extractFunctionName, CLASS_CONTAINER_TYPES, isBuiltInOrNoise } from './utils.js';
import { SupportedLanguages } from '../../config/supported-languages.js';
import { typeConfigs, TYPED_PARAMETER_TYPES } from './type-extractors/index.js';
import type { ClassNameLookup } from './type-extractors/types.js';
import { extractSimpleTypeName, stripNullable } from './type-extractors/shared.js';
import type { ClassNameLookup, ReturnTypeLookup, ForLoopExtractorContext } from './type-extractors/types.js';
import { extractSimpleTypeName, extractVarName, stripNullable, extractReturnTypeName } from './type-extractors/shared.js';
import type { SymbolTable } from './symbol-table.js';
/**
@@ -48,11 +48,57 @@ export interface TypeEnvironment {
readonly env: TypeEnv;
}
/**
* Position-indexed pattern binding: active only within a specific AST range.
* Used for smart-cast narrowing in mutually exclusive branches (e.g., Kotlin when arms).
*/
interface PatternOverride {
rangeStart: number;
rangeEnd: number;
typeName: string;
}
/** scope → varName → overrides (checked in order, first range match wins) */
type PatternOverrides = Map<string, Map<string, PatternOverride[]>>;
/** AST node types that represent mutually exclusive branch containers for pattern bindings. */
const PATTERN_BRANCH_TYPES = new Set([
'when_entry', // Kotlin when
'switch_block_label', // Java switch (enhanced)
]);
/** Walk up the AST from a pattern node to find the enclosing branch container. */
const findPatternBranchScope = (node: SyntaxNode): SyntaxNode | undefined => {
let current = node.parent;
while (current) {
if (PATTERN_BRANCH_TYPES.has(current.type)) return current;
if (FUNCTION_NODE_TYPES.has(current.type)) return undefined;
current = current.parent;
}
return undefined;
};
/** Bare nullable keywords that fastStripNullable must reject. */
const FAST_NULLABLE_KEYWORDS = new Set(['null', 'undefined', 'void', 'None', 'nil']);
/**
* Fast-path nullable check: 90%+ of type names are simple identifiers (e.g. "User")
* that don't need the full stripNullable parse. Only call stripNullable when the
* string contains nullable markers ('|' for union types, '?' for nullable suffix).
*/
const fastStripNullable = (typeName: string): string | undefined => {
if (FAST_NULLABLE_KEYWORDS.has(typeName)) return undefined;
return (typeName.indexOf('|') === -1 && typeName.indexOf('?') === -1)
? typeName
: stripNullable(typeName);
};
/** Implementation of the lookup logic — shared between TypeEnvironment and the legacy export. */
const lookupInEnv = (
env: TypeEnv,
varName: string,
callNode: SyntaxNode,
patternOverrides?: PatternOverrides,
): string | undefined => {
// Self/this receiver: resolve to enclosing class name via AST walk
if (varName === 'self' || varName === 'this' || varName === '$this') {
@@ -68,19 +114,33 @@ const lookupInEnv = (
// Determine the enclosing function scope for the call
const scopeKey = findEnclosingScopeKey(callNode);
// Check position-indexed pattern overrides first (e.g., Kotlin when/is smart casts).
// These take priority over flat scopeEnv because they represent per-branch narrowing.
if (scopeKey && patternOverrides) {
const varOverrides = patternOverrides.get(scopeKey)?.get(varName);
if (varOverrides) {
const pos = callNode.startIndex;
for (const override of varOverrides) {
if (pos >= override.rangeStart && pos <= override.rangeEnd) {
return fastStripNullable(override.typeName);
}
}
}
}
// Try function-local scope first
if (scopeKey) {
const scopeEnv = env.get(scopeKey);
if (scopeEnv) {
const result = scopeEnv.get(varName);
if (result) return stripNullable(result);
if (result) return fastStripNullable(result);
}
}
// Fall back to file-level scope
const fileEnv = env.get(FILE_SCOPE);
const raw = fileEnv?.get(varName);
return raw ? stripNullable(raw) : undefined;
return raw ? fastStripNullable(raw) : undefined;
};
@@ -283,20 +343,78 @@ const createClassNameLookup = (
* the project are available for constructor inference in languages like Kotlin
* where constructors are syntactically identical to function calls.
*/
/**
* Node types whose subtrees can NEVER contain type-relevant descendants
* (declarations, parameters, for-loops, class definitions, pattern bindings).
* Conservative leaf-only set — verified safe across all 12 supported language grammars.
* IMPORTANT: Do NOT add expression containers (arguments, binary_expression, etc.) —
* they can contain arrow functions with typed parameters.
*/
const SKIP_SUBTREE_TYPES = new Set([
// Plain string literals (NOT template_string — it contains interpolated expressions
// that can hold arrow functions with typed parameters, e.g. `${(x: T) => x}`)
'string', 'string_literal',
'string_content', 'string_fragment', 'heredoc_body',
// Comments
'comment', 'line_comment', 'block_comment',
// Numeric/boolean/null literals
'number', 'integer_literal', 'float_literal',
'true', 'false', 'null',
// Regex
'regex', 'regex_pattern',
]);
export const buildTypeEnv = (
tree: { rootNode: SyntaxNode },
language: SupportedLanguages,
symbolTable?: SymbolTable,
): TypeEnvironment => {
const env: TypeEnv = new Map();
const patternOverrides: PatternOverrides = new Map();
const localClassNames = new Set<string>();
const classNames = createClassNameLookup(localClassNames, symbolTable);
const config = typeConfigs[language];
const bindings: ConstructorBinding[] = [];
const pendingAssignments: Array<{ scope: string; lhs: string; rhs: string }> = [];
// Build ReturnTypeLookup from optional SymbolTable.
// Conservative: returns undefined when callee is ambiguous (0 or 2+ matches).
const returnTypeLookup: ReturnTypeLookup = {
lookupReturnType(callee: string): string | undefined {
if (!symbolTable) return undefined;
if (isBuiltInOrNoise(callee)) return undefined;
const callables = symbolTable.lookupFuzzyCallable(callee);
if (callables.length !== 1) return undefined;
const rawReturn = callables[0].returnType;
if (!rawReturn) return undefined;
return extractReturnTypeName(rawReturn);
},
lookupRawReturnType(callee: string): string | undefined {
if (!symbolTable) return undefined;
if (isBuiltInOrNoise(callee)) return undefined;
const callables = symbolTable.lookupFuzzyCallable(callee);
if (callables.length !== 1) return undefined;
return callables[0].returnType;
}
};
// Pre-compute combined set of node types that need extractTypeBinding.
// Single Set.has() replaces 3 separate checks per node in walk().
const interestingNodeTypes = new Set<string>();
TYPED_PARAMETER_TYPES.forEach(t => interestingNodeTypes.add(t));
config.declarationNodeTypes.forEach(t => interestingNodeTypes.add(t));
config.forLoopNodeTypes?.forEach(t => interestingNodeTypes.add(t));
// Tier 2: copy-propagation (`const b = a`) and call-result propagation (`const b = foo()`)
const pendingCopies: Array<{ scope: string; lhs: string; rhs: string }> = [];
// NOTE: Infrastructure-ready — no language extractor currently returns { kind: 'callResult' }
// from extractPendingAssignment. When one does, this array will bind variables to their
// function return types at TypeEnv build time. See PendingAssignment in types.ts.
const pendingCallResults: Array<{ scope: string; lhs: string; callee: string }> = [];
// Maps `scope\0varName` → the type annotation AST node from the original declaration.
// Allows pattern extractors to navigate back to the declaration's generic type arguments
// (e.g., to extract T from Result<T, E> for `if let Ok(x) = res`).
// NOTE: This is a SUPERSET of scopeEnv — entries exist even when extractSimpleTypeName
// returns undefined for container types (User[], []User, List[User]). This is intentional:
// for-loop Strategy 1 needs the raw AST type node for exactly those container types.
const declarationTypeNodes = new Map<string, SyntaxNode>();
/**
@@ -314,35 +432,94 @@ export const buildTypeEnv = (
const extractTypeBinding = (node: SyntaxNode, scopeEnv: Map<string, string>, scope: string): void => {
// This guard eliminates 90%+ of calls before any language dispatch.
if (TYPED_PARAMETER_TYPES.has(node.type)) {
const keysBefore = new Set(scopeEnv.keys());
config.extractParameter(node, scopeEnv);
// Capture the type node for newly introduced parameter bindings
const typeNode = node.childForFieldName('type');
// Capture the raw type annotation BEFORE extractParameter.
// Most languages use 'name' field; Rust uses 'pattern'; TS uses 'pattern' for some param types.
// Kotlin `parameter` nodes use positional children instead of named fields,
// so we fall back to scanning children by type when childForFieldName returns null.
let typeNode = node.childForFieldName('type');
if (typeNode) {
for (const varName of scopeEnv.keys()) {
if (!keysBefore.has(varName)) {
const nameNode = node.childForFieldName('name')
?? node.childForFieldName('pattern');
if (nameNode) {
const varName = extractVarName(nameNode);
if (varName && !declarationTypeNodes.has(`${scope}\0${varName}`)) {
declarationTypeNodes.set(`${scope}\0${varName}`, typeNode);
}
}
} else {
// Fallback: positional children (Kotlin `parameter` → simple_identifier + user_type)
let fallbackName: SyntaxNode | null = null;
let fallbackType: SyntaxNode | null = null;
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (!child) continue;
if (!fallbackName && (child.type === 'simple_identifier' || child.type === 'identifier')) {
fallbackName = child;
}
if (!fallbackType && (child.type === 'user_type' || child.type === 'type_identifier'
|| child.type === 'generic_type' || child.type === 'parameterized_type')) {
fallbackType = child;
}
}
if (fallbackName && fallbackType) {
const varName = extractVarName(fallbackName);
if (varName && !declarationTypeNodes.has(`${scope}\0${varName}`)) {
declarationTypeNodes.set(`${scope}\0${varName}`, fallbackType);
}
}
}
config.extractParameter(node, scopeEnv);
return;
}
// For-each loop variable bindings (Java/C#/Kotlin): explicit element types in the AST.
// Checked before declarationNodeTypes — loop variables are not declarations.
if (config.forLoopNodeTypes?.has(node.type)) {
config.extractForLoopBinding?.(node, scopeEnv);
if (config.extractForLoopBinding) {
const forLoopCtx: ForLoopExtractorContext = { scopeEnv, declarationTypeNodes, scope, returnTypeLookup };
config.extractForLoopBinding(node, forLoopCtx);
}
return;
}
if (config.declarationNodeTypes.has(node.type)) {
const keysBefore = new Set(scopeEnv.keys());
config.extractDeclaration(node, scopeEnv);
// Capture the type annotation AST node for newly introduced bindings.
// Only declarations with an explicit 'type' field are recorded — constructor
// inferences (Tier 1) don't have a type annotation node to preserve.
const typeNode = node.childForFieldName('type');
// Capture the raw type annotation AST node BEFORE extractDeclaration.
// This decouples type node capture from scopeEnv success — container types
// (User[], []User, List[User]) that fail extractSimpleTypeName still get
// their AST type node recorded for Strategy 1 for-loop resolution.
// Try direct extraction first (works for Go var_spec, Python assignment, Rust let_declaration).
// Try direct type field first, then unwrap wrapper nodes (C# field_declaration,
// local_declaration_statement wrap their type inside a variable_declaration child).
let typeNode = node.childForFieldName('type');
if (!typeNode) {
// C# field_declaration / local_declaration_statement wrap type inside variable_declaration.
// Use manual loop instead of namedChildren.find() to avoid array allocation on hot path.
let wrapped = node.childForFieldName('declaration');
if (!wrapped) {
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c?.type === 'variable_declaration') { wrapped = c; break; }
}
}
if (wrapped) typeNode = wrapped.childForFieldName('type');
}
if (typeNode) {
const nameNode = node.childForFieldName('name')
?? node.childForFieldName('left')
?? node.childForFieldName('pattern');
if (nameNode) {
const varName = extractVarName(nameNode);
if (varName && !declarationTypeNodes.has(`${scope}\0${varName}`)) {
declarationTypeNodes.set(`${scope}\0${varName}`, typeNode);
}
}
}
// Run the language-specific declaration extractor (may or may not add to scopeEnv).
const keysBefore = typeNode ? new Set(scopeEnv.keys()) : undefined;
config.extractDeclaration(node, scopeEnv);
// Fallback: for multi-declarator languages (TS, C#, Java) where the type field
// is on variable_declarator children, capture via keysBefore/keysAfter diff.
if (typeNode && keysBefore) {
for (const varName of scopeEnv.keys()) {
if (!keysBefore.has(varName)) {
if (!keysBefore.has(varName) && !declarationTypeNodes.has(`${scope}\0${varName}`)) {
declarationTypeNodes.set(`${scope}\0${varName}`, typeNode);
}
}
@@ -358,6 +535,9 @@ export const buildTypeEnv = (
};
const walk = (node: SyntaxNode, currentScope: string): void => {
// Fast skip: subtrees that can never contain type-relevant nodes (leaf-like literals).
if (SKIP_SUBTREE_TYPES.has(node.type)) return;
// Collect class/struct names as we encounter them (used by extractInitializer
// to distinguish constructor calls from function calls, e.g. C++ `User()` vs `getUser()`)
// Currently only C++ uses this locally; other languages rely on the SymbolTable path.
@@ -375,20 +555,46 @@ export const buildTypeEnv = (
if (funcName) scope = `${funcName}@${node.startIndex}`;
}
// Get or create the sub-map for this scope
if (!env.has(scope)) env.set(scope, new Map());
const scopeEnv = env.get(scope)!;
extractTypeBinding(node, scopeEnv, scope);
// Only create scope map and call extractTypeBinding for interesting node types.
// Single Set.has() replaces 3 separate checks inside extractTypeBinding.
if (interestingNodeTypes.has(node.type)) {
if (!env.has(scope)) env.set(scope, new Map());
const scopeEnv = env.get(scope)!;
extractTypeBinding(node, scopeEnv, scope);
}
// Pattern binding extraction: handles constructs that introduce NEW typed variables
// via pattern matching (e.g. `if let Some(x) = opt`, `x instanceof T t`).
// Runs after Tier 0/1 so scopeEnv already contains the source variable's type.
// Conservative: extractor returns undefined when source type is unknown.
if (config.extractPatternBinding) {
if (config.extractPatternBinding && (!config.patternBindingNodeTypes || config.patternBindingNodeTypes.has(node.type))) {
// Ensure scopeEnv exists for pattern binding reads/writes
if (!env.has(scope)) env.set(scope, new Map());
const scopeEnv = env.get(scope)!;
const patternBinding = config.extractPatternBinding(node, scopeEnv, declarationTypeNodes, scope);
if (patternBinding && !scopeEnv.has(patternBinding.varName)) {
scopeEnv.set(patternBinding.varName, patternBinding.typeName);
if (patternBinding) {
if (config.allowPatternBindingOverwrite) {
// Position-indexed: store per-branch binding for smart-cast narrowing.
// Each when arm / switch case gets its own type for the variable,
// preventing cross-arm contamination (e.g., Kotlin when/is).
const branchNode = findPatternBranchScope(node);
if (branchNode) {
if (!patternOverrides.has(scope)) patternOverrides.set(scope, new Map());
const varMap = patternOverrides.get(scope)!;
if (!varMap.has(patternBinding.varName)) varMap.set(patternBinding.varName, []);
varMap.get(patternBinding.varName)!.push({
rangeStart: branchNode.startIndex,
rangeEnd: branchNode.endIndex,
typeName: patternBinding.typeName,
});
}
// Also store in flat scopeEnv as fallback (last arm wins — same as before
// for code that doesn't use position-indexed lookup).
scopeEnv.set(patternBinding.varName, patternBinding.typeName);
} else if (!scopeEnv.has(patternBinding.varName)) {
// First-writer-wins for languages without smart-cast overwrite (Java instanceof, etc.)
scopeEnv.set(patternBinding.varName, patternBinding.typeName);
}
}
}
@@ -397,9 +603,18 @@ export const buildTypeEnv = (
// (JS uses variable_declarator/name/value, Rust uses let_declaration/pattern/value,
// Python uses assignment/left/right, Go uses short_var_declaration/expression_list).
if (config.extractPendingAssignment && config.declarationNodeTypes.has(node.type)) {
const pending = config.extractPendingAssignment(node, scopeEnv);
if (pending) {
pendingAssignments.push({ scope, ...pending });
// scopeEnv is guaranteed to exist here because declarationNodeTypes is a subset
// of interestingNodeTypes, so extractTypeBinding already created the scope map above.
const scopeEnv = env.get(scope);
if (scopeEnv) {
const pending = config.extractPendingAssignment(node, scopeEnv);
if (pending) {
if (pending.kind === 'copy') {
pendingCopies.push({ scope, lhs: pending.lhs, rhs: pending.rhs });
} else {
pendingCallResults.push({ scope, lhs: pending.lhs, callee: pending.callee });
}
}
}
}
@@ -407,8 +622,11 @@ export const buildTypeEnv = (
// Only collect if TypeEnv didn't already resolve this binding.
if (config.scanConstructorBinding) {
const result = config.scanConstructorBinding(node);
if (result && !scopeEnv.has(result.varName)) {
bindings.push({ scope, ...result });
if (result) {
const scopeEnv = env.get(scope);
if (!scopeEnv?.has(result.varName)) {
bindings.push({ scope, ...result });
}
}
}
@@ -421,22 +639,32 @@ export const buildTypeEnv = (
walk(tree.rootNode, FILE_SCOPE);
// Tier 2: single-pass assignment chain propagation in source order.
// Resolves `const b = a` where `a` has a known type from Tier 0/1.
// Tier 2a: copy-propagation — `const b = a` where `a` has a known type from Tier 0/1.
// Multi-hop chains resolve when forward-declared (a→b→c in source order);
// reverse-order assignments are depth-1 only. No fixpoint iteration —
// this covers 95%+ of real-world patterns.
for (const { scope, lhs, rhs } of pendingAssignments) {
for (const { scope, lhs, rhs } of pendingCopies) {
const scopeEnv = env.get(scope);
if (!scopeEnv || scopeEnv.has(lhs)) continue;
const rhsType = scopeEnv.get(rhs) ?? env.get(FILE_SCOPE)?.get(rhs);
if (rhsType) {
scopeEnv.set(lhs, rhsType);
}
if (rhsType) scopeEnv.set(lhs, rhsType);
}
// Tier 2b: call-result propagation — `const b = foo()` where `foo` has a declared return type.
// Uses ReturnTypeLookup which is backed by SymbolTable.lookupFuzzyCallable.
// Conservative: only binds when exactly one callable matches (avoids overload ambiguity).
// NOTE: Currently dormant — no extractPendingAssignment implementation emits 'callResult' yet.
// The loop is structurally complete and will activate when any language extractor starts
// returning { kind: 'callResult', lhs, callee } from extractPendingAssignment.
for (const { scope, lhs, callee } of pendingCallResults) {
const scopeEnv = env.get(scope);
if (!scopeEnv || scopeEnv.has(lhs)) continue;
const typeName = returnTypeLookup.lookupReturnType(callee);
if (typeName) scopeEnv.set(lhs, typeName);
}
return {
lookup: (varName, callNode) => lookupInEnv(env, varName, callNode),
lookup: (varName, callNode) => lookupInEnv(env, varName, callNode, patternOverrides),
constructorBindings: bindings,
env,
};
@@ -1,10 +1,9 @@
import type { SyntaxNode } from '../utils.js';
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, PendingAssignmentExtractor } from './types.js';
import { extractSimpleTypeName, extractVarName } from './shared.js';
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, PendingAssignmentExtractor, ForLoopExtractor } from './types.js';
import { extractSimpleTypeName, extractVarName, resolveIterableElementType, methodToTypeArgPosition, type TypeArgPosition } from './shared.js';
const DECLARATION_NODE_TYPES: ReadonlySet<string> = new Set([
'declaration',
'for_range_loop',
]);
/** C++: Type x = ...; Type* x; Type& x; */
@@ -180,14 +179,181 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) =>
if (!finalName) return undefined;
const lhs = extractVarName(finalName);
if (!lhs || scopeEnv.has(lhs)) return undefined;
return { lhs, rhs: value.text };
return { kind: 'copy', lhs, rhs: value.text };
};
// --- For-loop Tier 1c ---
const FOR_LOOP_NODE_TYPES: ReadonlySet<string> = new Set(['for_range_loop']);
/** Extract template type arguments from a C++ template_type node.
* C++ template_type uses template_argument_list (not type_arguments), and each
* argument is a type_descriptor with a 'type' field containing the type_specifier. */
const extractCppTemplateTypeArgs = (templateTypeNode: SyntaxNode): string[] => {
const argsNode = templateTypeNode.childForFieldName('arguments');
if (!argsNode || argsNode.type !== 'template_argument_list') return [];
const result: string[] = [];
for (let i = 0; i < argsNode.namedChildCount; i++) {
let argNode = argsNode.namedChild(i);
if (!argNode) continue;
// type_descriptor wraps the actual type specifier in a 'type' field
if (argNode.type === 'type_descriptor') {
const inner = argNode.childForFieldName('type');
if (inner) argNode = inner;
}
const name = extractSimpleTypeName(argNode);
if (name) result.push(name);
}
return result;
};
/** Extract element type from a C++ type annotation AST node.
* Handles: template_type (vector<User>, map<string, User>),
* pointer/reference types (User*, User&). */
const extractCppElementTypeFromTypeNode = (typeNode: SyntaxNode, pos: TypeArgPosition = 'last', depth = 0): string | undefined => {
if (depth > 50) return undefined;
// template_type: vector<User>, map<string, User> — extract type arg based on position
if (typeNode.type === 'template_type') {
const args = extractCppTemplateTypeArgs(typeNode);
if (args.length >= 1) return pos === 'first' ? args[0] : args[args.length - 1];
}
// reference/pointer types: unwrap and recurse (vector<User>& → vector<User>)
if (typeNode.type === 'reference_type' || typeNode.type === 'pointer_type'
|| typeNode.type === 'type_descriptor') {
const inner = typeNode.lastNamedChild;
if (inner) return extractCppElementTypeFromTypeNode(inner, pos, depth + 1);
}
// qualified/scoped types: std::vector<User> → unwrap to template_type child
if (typeNode.type === 'qualified_identifier' || typeNode.type === 'scoped_type_identifier') {
const inner = typeNode.lastNamedChild;
if (inner) return extractCppElementTypeFromTypeNode(inner, pos, depth + 1);
}
return undefined;
};
/** Walk up from a for-range-loop to the enclosing function_definition and search parameters
* for one named `iterableName`. Returns the element type from its annotation. */
const findCppParamElementType = (iterableName: string, startNode: SyntaxNode, pos: TypeArgPosition = 'last'): string | undefined => {
let current: SyntaxNode | null = startNode.parent;
while (current) {
if (current.type === 'function_definition') {
const declarator = current.childForFieldName('declarator');
// function_definition > declarator (function_declarator) > parameters (parameter_list)
const paramsNode = declarator?.childForFieldName('parameters');
if (paramsNode) {
for (let i = 0; i < paramsNode.namedChildCount; i++) {
const param = paramsNode.namedChild(i);
if (!param || param.type !== 'parameter_declaration') continue;
const paramDeclarator = param.childForFieldName('declarator');
if (!paramDeclarator) continue;
// Unwrap reference/pointer declarators: vector<User>& users → &users
let identNode = paramDeclarator;
if (identNode.type === 'reference_declarator' || identNode.type === 'pointer_declarator') {
identNode = identNode.firstNamedChild ?? identNode;
}
if (identNode.text !== iterableName) continue;
const typeNode = param.childForFieldName('type');
if (typeNode) return extractCppElementTypeFromTypeNode(typeNode, pos);
}
}
break;
}
current = current.parent;
}
return undefined;
};
/** C++: for (auto& user : users) — extract loop variable binding.
* Handles explicit types (for (User& user : users)) and auto (for (auto& user : users)).
* For auto, resolves element type from the iterable's container type. */
const extractForLoopBinding: ForLoopExtractor = (node, { scopeEnv, declarationTypeNodes, scope } ): void => {
if (node.type !== 'for_range_loop') return;
const typeNode = node.childForFieldName('type');
const declaratorNode = node.childForFieldName('declarator');
const rightNode = node.childForFieldName('right');
if (!typeNode || !declaratorNode || !rightNode) return;
// Unwrap reference/pointer declarator to get the loop variable name
let nameNode = declaratorNode;
if (nameNode.type === 'reference_declarator' || nameNode.type === 'pointer_declarator') {
nameNode = nameNode.firstNamedChild ?? nameNode;
}
// Handle structured bindings: auto& [key, value] or auto [key, value]
// Bind the last identifier (value heuristic for [key, value] patterns)
let loopVarName: string | undefined;
if (nameNode.type === 'structured_binding_declarator') {
const lastChild = nameNode.lastNamedChild;
if (lastChild?.type === 'identifier') {
loopVarName = lastChild.text;
}
} else if (declaratorNode.type === 'structured_binding_declarator') {
const lastChild = declaratorNode.lastNamedChild;
if (lastChild?.type === 'identifier') {
loopVarName = lastChild.text;
}
}
const varName = loopVarName ?? extractVarName(nameNode);
if (!varName) return;
// Check if the type is auto/placeholder — if not, use the explicit type directly
const isAuto = typeNode.type === 'placeholder_type_specifier'
|| typeNode.text === 'auto'
|| typeNode.text === 'const auto'
|| typeNode.text === 'decltype(auto)';
if (!isAuto) {
// Explicit type: for (User& user : users) — extract directly
const typeName = extractSimpleTypeName(typeNode);
if (typeName) scopeEnv.set(varName, typeName);
return;
}
// auto/const auto/auto& — resolve from the iterable's container type
// Extract iterable name + optional method
let iterableName: string | undefined;
let methodName: string | undefined;
if (rightNode.type === 'identifier') {
iterableName = rightNode.text;
} else if (rightNode.type === 'field_expression') {
const prop = rightNode.lastNamedChild;
if (prop) iterableName = prop.text;
} else if (rightNode.type === 'call_expression') {
// users.begin() is NOT used in range-for, but container.items() etc. might be
const fieldExpr = rightNode.childForFieldName('function');
if (fieldExpr?.type === 'field_expression') {
const obj = fieldExpr.firstNamedChild;
if (obj?.type === 'identifier') iterableName = obj.text;
const field = fieldExpr.lastNamedChild;
if (field?.type === 'field_identifier') methodName = field.text;
}
} else if (rightNode.type === 'pointer_expression') {
// Dereference: for (auto& user : *ptr) → pointer_expression > identifier
// Only handles simple *identifier; *this->field and **ptr are not resolved.
const operand = rightNode.lastNamedChild;
if (operand?.type === 'identifier') iterableName = operand.text;
}
if (!iterableName) return;
const containerTypeName = scopeEnv.get(iterableName);
const typeArgPos = methodToTypeArgPosition(methodName, containerTypeName);
const elementType = resolveIterableElementType(
iterableName, node, scopeEnv, declarationTypeNodes, scope,
extractCppElementTypeFromTypeNode, findCppParamElementType,
typeArgPos,
);
if (elementType) scopeEnv.set(varName, elementType);
};
export const typeConfig: LanguageTypeConfig = {
declarationNodeTypes: DECLARATION_NODE_TYPES,
forLoopNodeTypes: FOR_LOOP_NODE_TYPES,
extractDeclaration,
extractParameter,
extractInitializer,
scanConstructorBinding,
extractForLoopBinding,
extractPendingAssignment,
};
@@ -1,31 +1,18 @@
import type { SyntaxNode } from '../utils.js';
import type { ConstructorBindingScanner, ForLoopExtractor, LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, PendingAssignmentExtractor } from './types.js';
import { extractSimpleTypeName, extractVarName, findChildByType, unwrapAwait } from './shared.js';
import type { ConstructorBindingScanner, ForLoopExtractor, LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, PendingAssignmentExtractor, PatternBindingExtractor } from './types.js';
import { extractSimpleTypeName, extractVarName, findChildByType, unwrapAwait, extractGenericTypeArgs, resolveIterableElementType, methodToTypeArgPosition, extractElementTypeFromString, type TypeArgPosition } from './shared.js';
/** Known container property accessors that operate on the container itself (e.g., dict.Keys, dict.Values) */
const KNOWN_CONTAINER_PROPS: ReadonlySet<string> = new Set(['Keys', 'Values']);
const DECLARATION_NODE_TYPES: ReadonlySet<string> = new Set([
'local_declaration_statement',
'variable_declaration',
'field_declaration',
'is_pattern_expression',
]);
/** C#: Type x = ...; var x = new Type(); obj is Type x */
/** C#: Type x = ...; var x = new Type(); */
const extractDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: Map<string, string>): void => {
// C# pattern matching: `obj is User user` → is_pattern_expression > declaration_pattern
if (node.type === 'is_pattern_expression') {
const pattern = node.childForFieldName('pattern');
if (pattern?.type === 'declaration_pattern') {
const typeNode = pattern.childForFieldName('type');
const nameNode = pattern.childForFieldName('name');
if (typeNode && nameNode) {
const typeName = extractSimpleTypeName(typeNode);
const varName = extractVarName(nameNode);
if (typeName && varName) env.set(varName, typeName);
}
}
return;
}
// C# tree-sitter: local_declaration_statement > variable_declaration > ...
// Recursively descend through wrapper nodes
for (let i = 0; i < node.namedChildCount; i++) {
@@ -147,17 +134,176 @@ const FOR_LOOP_NODE_TYPES: ReadonlySet<string> = new Set([
'foreach_statement',
]);
/** C#: foreach (User user in users) — extract loop variable binding */
const extractForLoopBinding: ForLoopExtractor = (node: SyntaxNode, scopeEnv: Map<string, string>): void => {
/** Extract element type from a C# type annotation AST node.
* Handles generic_name (List<User>), array_type (User[]), nullable_type (?).
* `pos` selects which type arg: 'first' for keys, 'last' for values (default). */
const extractCSharpElementTypeFromTypeNode = (typeNode: SyntaxNode, pos: TypeArgPosition = 'last', depth = 0): string | undefined => {
if (depth > 50) return undefined;
// generic_name: List<User>, IEnumerable<User>, Dictionary<string, User>
// C# uses generic_name (not generic_type)
if (typeNode.type === 'generic_name') {
const argList = findChildByType(typeNode, 'type_argument_list');
if (argList && argList.namedChildCount >= 1) {
if (pos === 'first') {
const firstArg = argList.namedChild(0);
if (firstArg) return extractSimpleTypeName(firstArg);
} else {
const lastArg = argList.namedChild(argList.namedChildCount - 1);
if (lastArg) return extractSimpleTypeName(lastArg);
}
}
}
// array_type: User[]
if (typeNode.type === 'array_type') {
const elemNode = typeNode.firstNamedChild;
if (elemNode) return extractSimpleTypeName(elemNode);
}
// nullable_type: unwrap and recurse (List<User>? → List<User> → User)
if (typeNode.type === 'nullable_type') {
const inner = typeNode.firstNamedChild;
if (inner) return extractCSharpElementTypeFromTypeNode(inner, pos, depth + 1);
}
return undefined;
};
/** Walk up from a foreach to the enclosing method and search parameters. */
const findCSharpParamElementType = (iterableName: string, startNode: SyntaxNode, pos: TypeArgPosition = 'last'): string | undefined => {
let current: SyntaxNode | null = startNode.parent;
while (current) {
if (current.type === 'method_declaration' || current.type === 'local_function_statement') {
const paramsNode = current.childForFieldName('parameters');
if (paramsNode) {
for (let i = 0; i < paramsNode.namedChildCount; i++) {
const param = paramsNode.namedChild(i);
if (!param || param.type !== 'parameter') continue;
const nameNode = param.childForFieldName('name');
if (nameNode?.text !== iterableName) continue;
const typeNode = param.childForFieldName('type');
if (typeNode) return extractCSharpElementTypeFromTypeNode(typeNode, pos);
}
}
break;
}
current = current.parent;
}
return undefined;
};
/** C#: foreach (User user in users) — extract loop variable binding.
* Tier 1c: for `foreach (var user in users)`, resolves element type from iterable. */
const extractForLoopBinding: ForLoopExtractor = (node, { scopeEnv, declarationTypeNodes, scope, returnTypeLookup }): void => {
const typeNode = node.childForFieldName('type');
// The loop variable name is in the 'left' field in tree-sitter-c-sharp
const nameNode = node.childForFieldName('left');
if (!typeNode || !nameNode) return;
// Skip 'var' — type would need to be inferred from the collection element type
if (typeNode.type === 'implicit_type' && typeNode.text === 'var') return;
const typeName = extractSimpleTypeName(typeNode);
const varName = extractVarName(nameNode);
if (typeName && varName) scopeEnv.set(varName, typeName);
if (!varName) return;
// Explicit type (existing behavior): foreach (User user in users)
if (!(typeNode.type === 'implicit_type' && typeNode.text === 'var')) {
const typeName = extractSimpleTypeName(typeNode);
if (typeName) scopeEnv.set(varName, typeName);
return;
}
// Tier 1c: implicit type (var) — resolve from iterable's container type
const rightNode = node.childForFieldName('right');
let iterableName: string | undefined;
let methodName: string | undefined;
let callExprElementType: string | undefined;
if (rightNode?.type === 'identifier') {
iterableName = rightNode.text;
} else if (rightNode?.type === 'member_access_expression') {
// C# property access: data.Keys, data.Values → member_access_expression
// Also handles bare member access: this.users, repo.users → use property as iterableName
const obj = rightNode.childForFieldName('expression');
const prop = rightNode.childForFieldName('name');
const propText = prop?.type === 'identifier' ? prop.text : undefined;
if (propText && KNOWN_CONTAINER_PROPS.has(propText)) {
if (obj?.type === 'identifier') {
iterableName = obj.text;
} else if (obj?.type === 'member_access_expression') {
// Nested member access: this.data.Values → obj is "this.data", extract "data"
const innerProp = obj.childForFieldName('name');
if (innerProp) iterableName = innerProp.text;
}
methodName = propText;
} else if (propText) {
// Bare member access: this.users → use property name for scopeEnv lookup
iterableName = propText;
}
} else if (rightNode?.type === 'invocation_expression') {
// C# method call: data.Select(...) → invocation_expression > member_access_expression
// Direct function call: GetUsers() → invocation_expression > identifier
const fn = rightNode.firstNamedChild;
if (fn?.type === 'member_access_expression') {
const obj = fn.childForFieldName('expression');
const prop = fn.childForFieldName('name');
if (obj?.type === 'identifier') iterableName = obj.text;
if (prop?.type === 'identifier') methodName = prop.text;
} else if (fn?.type === 'identifier') {
// Direct function call: foreach (var u in GetUsers())
const rawReturn = returnTypeLookup.lookupRawReturnType(fn.text);
if (rawReturn) callExprElementType = extractElementTypeFromString(rawReturn);
}
}
if (!iterableName && !callExprElementType) return;
let elementType: string | undefined;
if (callExprElementType) {
elementType = callExprElementType;
} else {
const containerTypeName = scopeEnv.get(iterableName!);
const typeArgPos = methodToTypeArgPosition(methodName, containerTypeName);
elementType = resolveIterableElementType(
iterableName!, node, scopeEnv, declarationTypeNodes, scope,
extractCSharpElementTypeFromTypeNode, findCSharpParamElementType,
typeArgPos,
);
}
if (elementType) scopeEnv.set(varName, elementType);
};
/**
* C# pattern binding extractor for `obj is Type variable` (type pattern).
*
* AST structure:
* is_pattern_expression
* expression: (the variable being tested)
* pattern: declaration_pattern
* type: (the declared type)
* name: single_variable_designation > identifier (the new variable name)
*
* Conservative: returns undefined when the pattern field is absent, is not a
* declaration_pattern, or when the type/name cannot be extracted.
* No scopeEnv lookup is needed — the pattern explicitly declares the new variable's type.
*/
const extractPatternBinding: PatternBindingExtractor = (node) => {
// is_pattern_expression: `obj is User user` — has a declaration_pattern child
if (node.type === 'is_pattern_expression') {
const pattern = node.childForFieldName('pattern');
if (pattern?.type !== 'declaration_pattern' && pattern?.type !== 'recursive_pattern') return undefined;
const typeNode = pattern.childForFieldName('type');
const nameNode = pattern.childForFieldName('name');
if (!typeNode || !nameNode) return undefined;
const typeName = extractSimpleTypeName(typeNode);
const varName = extractVarName(nameNode);
if (!typeName || !varName) return undefined;
return { varName, typeName };
}
// declaration_pattern / recursive_pattern: standalone in switch statements and switch expressions
// `case User u:` or `User u =>` or `User { Name: "Alice" } u =>`
// Both use the same 'type' and 'name' fields.
if (node.type === 'declaration_pattern' || node.type === 'recursive_pattern') {
const typeNode = node.childForFieldName('type');
const nameNode = node.childForFieldName('name');
if (!typeNode || !nameNode) return undefined;
const typeName = extractSimpleTypeName(typeNode);
const varName = extractVarName(nameNode);
if (!typeName || !varName) return undefined;
return { varName, typeName };
}
return undefined;
};
/** C#: var alias = u → variable_declarator with name + equals_value_clause.
@@ -179,7 +325,7 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) =>
}
const valueNode = evc?.firstNamedChild ?? child.namedChild(child.namedChildCount - 1);
if (valueNode && valueNode !== nameNode && (valueNode.type === 'identifier' || valueNode.type === 'simple_identifier')) {
return { lhs, rhs: valueNode.text };
return { kind: 'copy', lhs, rhs: valueNode.text };
}
}
return undefined;
@@ -188,9 +334,11 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) =>
export const typeConfig: LanguageTypeConfig = {
declarationNodeTypes: DECLARATION_NODE_TYPES,
forLoopNodeTypes: FOR_LOOP_NODE_TYPES,
patternBindingNodeTypes: new Set(['is_pattern_expression', 'declaration_pattern', 'recursive_pattern']),
extractDeclaration,
extractParameter,
scanConstructorBinding,
extractForLoopBinding,
extractPendingAssignment,
extractPatternBinding,
};
@@ -1,6 +1,6 @@
import type { SyntaxNode } from '../utils.js';
import type { ConstructorBindingScanner, LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, PendingAssignmentExtractor } from './types.js';
import { extractSimpleTypeName, extractVarName } from './shared.js';
import type { ConstructorBindingScanner, ForLoopExtractor, LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, PendingAssignmentExtractor } from './types.js';
import { extractSimpleTypeName, extractVarName, extractElementTypeFromString, extractGenericTypeArgs, findChildByType, resolveIterableElementType, methodToTypeArgPosition, type TypeArgPosition } from './shared.js';
const DECLARATION_NODE_TYPES: ReadonlySet<string> = new Set([
'var_declaration',
@@ -181,6 +181,209 @@ const scanConstructorBinding: ConstructorBindingScanner = (node) => {
return { varName: leftIds[0].text, calleeName };
};
const FOR_LOOP_NODE_TYPES: ReadonlySet<string> = new Set([
'for_statement',
]);
/** Go function/method node types that carry a parameter list. */
const GO_FUNCTION_NODE_TYPES = new Set([
'function_declaration', 'method_declaration', 'func_literal',
]);
/**
* Extract element type from a Go type annotation AST node.
* Handles:
* slice_type "[]User" → element field → type_identifier "User"
* array_type "[10]User" → element field → type_identifier "User"
* Falls back to text-based extraction via extractElementTypeFromString.
*/
const extractGoElementTypeFromTypeNode = (typeNode: SyntaxNode, pos: TypeArgPosition = 'last'): string | undefined => {
// slice_type: []User — element field is the element type
if (typeNode.type === 'slice_type' || typeNode.type === 'array_type') {
const elemNode = typeNode.childForFieldName('element');
if (elemNode) return extractSimpleTypeName(elemNode);
}
// map_type: map[string]User — value field is the element type (for range, second var gets value)
if (typeNode.type === 'map_type') {
const valueNode = typeNode.childForFieldName('value');
if (valueNode) return extractSimpleTypeName(valueNode);
}
// channel_type: chan User — the type argument is the element type
if (typeNode.type === 'channel_type') {
const valueNode = typeNode.childForFieldName('value') ?? typeNode.lastNamedChild;
if (valueNode) return extractSimpleTypeName(valueNode);
}
// generic_type: Go 1.18+ generics (e.g., MySlice[User], Cache[string, User])
// Use position-aware arg selection: 'first' for keys, 'last' for values.
if (typeNode.type === 'generic_type') {
const args = extractGenericTypeArgs(typeNode);
if (args.length >= 1) return pos === 'first' ? args[0] : args[args.length - 1];
}
// Fallback: text-based extraction ([]User → User, User[] → User)
return extractElementTypeFromString(typeNode.text, pos);
};
/** Check if a Go type node represents a channel type. Used to determine
* whether single-var range yields the element (channels) vs index (slices/maps). */
const isChannelType = (
iterableName: string,
scopeEnv: ReadonlyMap<string, string>,
declarationTypeNodes?: ReadonlyMap<string, SyntaxNode>,
scope?: string,
): boolean => {
if (declarationTypeNodes && scope) {
const typeNode = declarationTypeNodes.get(`${scope}\0${iterableName}`);
if (typeNode) return typeNode.type === 'channel_type';
}
const t = scopeEnv.get(iterableName);
return !!t && t.startsWith('chan ');
};
/**
* Walk up the AST from a for-statement to find the enclosing function declaration,
* then search its parameters for one named `iterableName`.
* Returns the element type extracted from its type annotation, or undefined.
*
* Go parameter_declaration has:
* name field: identifier (the parameter name)
* type field: the type node (slice_type for []User)
*/
const findGoParamElementType = (iterableName: string, startNode: SyntaxNode, pos: TypeArgPosition = 'last'): string | undefined => {
let current: SyntaxNode | null = startNode.parent;
while (current) {
if (GO_FUNCTION_NODE_TYPES.has(current.type)) {
const paramsNode = current.childForFieldName('parameters');
if (paramsNode) {
for (let i = 0; i < paramsNode.namedChildCount; i++) {
const paramDecl = paramsNode.namedChild(i);
if (!paramDecl || paramDecl.type !== 'parameter_declaration') continue;
// parameter_declaration: name type — name field is the identifier
const nameNode = paramDecl.childForFieldName('name');
if (nameNode?.text === iterableName) {
const typeNode = paramDecl.childForFieldName('type');
if (typeNode) return extractGoElementTypeFromTypeNode(typeNode, pos);
}
}
}
break;
}
current = current.parent;
}
return undefined;
};
/**
* Go: for _, user := range users where users has a known slice type.
*
* Go uses a single `for_statement` node for all for-loop forms. We detect
* range-based loops by looking for a `range_clause` child node. C-style for
* loops (with `for_clause`) and infinite loops (no clause) are ignored.
*
* Tier 1c: resolves the element type via three strategies in priority order:
* 1. declarationTypeNodes — raw type annotation AST node
* 2. scopeEnv string — extractElementTypeFromString on the stored type
* 3. AST walk — walks up to the enclosing function's parameters to read []User directly
* For `_, user := range users`, the loop variable is the second identifier in
* the `left` expression_list (index is discarded, value is the element).
*/
const extractForLoopBinding: ForLoopExtractor = (node, { scopeEnv, declarationTypeNodes, scope, returnTypeLookup }): void => {
if (node.type !== 'for_statement') return;
// Find the range_clause child — this distinguishes range loops from other for forms.
let rangeClause: SyntaxNode | null = null;
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child?.type === 'range_clause') {
rangeClause = child;
break;
}
}
if (!rangeClause) return;
// The iterable is the `right` field of the range_clause.
const rightNode = rangeClause.childForFieldName('right');
let iterableName: string | undefined;
let callExprElementType: string | undefined;
if (rightNode?.type === 'identifier') {
iterableName = rightNode.text;
} else if (rightNode?.type === 'selector_expression') {
const field = rightNode.childForFieldName('field');
if (field) iterableName = field.text;
} else if (rightNode?.type === 'call_expression') {
// Range over a call result: `for _, v := range getItems()` or `for _, v := range repo.All()`
const funcNode = rightNode.childForFieldName('function');
let callee: string | undefined;
if (funcNode?.type === 'identifier') {
callee = funcNode.text;
} else if (funcNode?.type === 'selector_expression') {
const field = funcNode.childForFieldName('field');
if (field) callee = field.text;
}
if (callee) {
const rawReturn = returnTypeLookup.lookupRawReturnType(callee);
if (rawReturn) callExprElementType = extractElementTypeFromString(rawReturn);
}
}
if (!iterableName && !callExprElementType) return;
let elementType: string | undefined;
if (callExprElementType) {
elementType = callExprElementType;
} else {
const containerTypeName = scopeEnv.get(iterableName!);
const typeArgPos = methodToTypeArgPosition(undefined, containerTypeName);
elementType = resolveIterableElementType(
iterableName!, node, scopeEnv, declarationTypeNodes, scope,
extractGoElementTypeFromTypeNode, findGoParamElementType,
typeArgPos,
);
}
if (!elementType) return;
// The loop variable(s) are in the `left` field.
// Go range semantics:
// Slice/Array/String: single-var → INDEX (int); two-var → (index, element)
// Map: single-var → KEY; two-var → (key, value)
// Channel: single-var → ELEMENT (channels have no index)
const leftNode = rangeClause.childForFieldName('left');
if (!leftNode) return;
let loopVarNode: SyntaxNode | null = null;
if (leftNode.type === 'expression_list') {
if (leftNode.namedChildCount >= 2) {
// Two-var form: `_, user` or `i, user` — second variable gets element/value type
loopVarNode = leftNode.namedChild(1);
} else {
// Single-var in expression_list — yields INDEX for slices/maps, ELEMENT for channels.
// For call-expression iterables (iterableName undefined), conservative: treat as non-channel.
// Channels are rarely returned from function calls, and even if they were, skipping here
// just means we miss a binding rather than create an incorrect one.
if (iterableName && isChannelType(iterableName, scopeEnv, declarationTypeNodes, scope)) {
loopVarNode = leftNode.namedChild(0);
} else {
return; // index-only range on slice/map — skip
}
}
} else {
// Plain identifier (single-var form without expression_list)
// For call-expression iterables (iterableName undefined), conservative: treat as non-channel.
// Channels are rarely returned from function calls, and even if they were, skipping here
// just means we miss a binding rather than create an incorrect one.
if (iterableName && isChannelType(iterableName, scopeEnv, declarationTypeNodes, scope)) {
loopVarNode = leftNode;
} else {
return; // index-only range on slice/map — skip
}
}
if (!loopVarNode) return;
// Skip the blank identifier `_`
if (loopVarNode.text === '_') return;
const loopVarName = extractVarName(loopVarNode);
if (loopVarName) scopeEnv.set(loopVarName, elementType);
};
/** Go: alias := u (short_var_declaration) or var b = u (var_spec) */
const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) => {
if (node.type === 'short_var_declaration') {
@@ -193,7 +396,7 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) =>
if (lhsNode.type !== 'identifier') return undefined;
const lhs = lhsNode.text;
if (scopeEnv.has(lhs)) return undefined;
if (rhsNode.type === 'identifier') return { lhs, rhs: rhsNode.text };
if (rhsNode.type === 'identifier') return { kind: 'copy', lhs, rhs: rhsNode.text };
return undefined;
}
if (node.type === 'var_spec' || node.type === 'var_declaration') {
@@ -218,7 +421,7 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) =>
if (spec.child(i)?.type === 'expression_list') { exprList = spec.child(i); break; }
}
const rhsNode = exprList?.firstNamedChild;
if (rhsNode?.type === 'identifier') return { lhs, rhs: rhsNode.text };
if (rhsNode?.type === 'identifier') return { kind: 'copy', lhs, rhs: rhsNode.text };
}
}
return undefined;
@@ -226,8 +429,10 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) =>
export const typeConfig: LanguageTypeConfig = {
declarationNodeTypes: DECLARATION_NODE_TYPES,
forLoopNodeTypes: FOR_LOOP_NODE_TYPES,
extractDeclaration,
extractParameter,
scanConstructorBinding,
extractForLoopBinding,
extractPendingAssignment,
};
@@ -1,6 +1,6 @@
import type { SyntaxNode } from '../utils.js';
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, ForLoopExtractor, PendingAssignmentExtractor, PatternBindingExtractor } from './types.js';
import { extractSimpleTypeName, extractVarName, findChildByType } from './shared.js';
import { extractSimpleTypeName, extractVarName, findChildByType, extractGenericTypeArgs, resolveIterableElementType, methodToTypeArgPosition, extractElementTypeFromString, type TypeArgPosition } from './shared.js';
// ── Java ──────────────────────────────────────────────────────────────────
@@ -89,14 +89,103 @@ const JAVA_FOR_LOOP_NODE_TYPES: ReadonlySet<string> = new Set([
'enhanced_for_statement',
]);
/** Java: for (User user : users) — extract loop variable binding */
const extractJavaForLoopBinding: ForLoopExtractor = (node: SyntaxNode, scopeEnv: Map<string, string>): void => {
/** Extract element type from a Java type annotation AST node.
* Handles generic_type (List<User>), array_type (User[]). */
const extractJavaElementTypeFromTypeNode = (typeNode: SyntaxNode, pos: TypeArgPosition = 'last'): string | undefined => {
if (typeNode.type === 'generic_type') {
const args = extractGenericTypeArgs(typeNode);
if (args.length >= 1) return pos === 'first' ? args[0] : args[args.length - 1];
}
if (typeNode.type === 'array_type') {
const elemNode = typeNode.firstNamedChild;
if (elemNode) return extractSimpleTypeName(elemNode);
}
return undefined;
};
/** Walk up from a for-each to the enclosing method_declaration and search parameters. */
const findJavaParamElementType = (iterableName: string, startNode: SyntaxNode, pos: TypeArgPosition = 'last'): string | undefined => {
let current: SyntaxNode | null = startNode.parent;
while (current) {
if (current.type === 'method_declaration' || current.type === 'constructor_declaration') {
const paramsNode = current.childForFieldName('parameters');
if (paramsNode) {
for (let i = 0; i < paramsNode.namedChildCount; i++) {
const param = paramsNode.namedChild(i);
if (!param || param.type !== 'formal_parameter') continue;
const nameNode = param.childForFieldName('name');
if (nameNode?.text !== iterableName) continue;
const typeNode = param.childForFieldName('type');
if (typeNode) return extractJavaElementTypeFromTypeNode(typeNode, pos);
}
}
break;
}
current = current.parent;
}
return undefined;
};
/** Java: for (User user : users) — extract loop variable binding.
* Tier 1c: for `for (var user : users)`, resolves element type from iterable. */
const extractJavaForLoopBinding: ForLoopExtractor = (node, { scopeEnv, declarationTypeNodes, scope, returnTypeLookup }): void => {
const typeNode = node.childForFieldName('type');
const nameNode = node.childForFieldName('name');
if (!typeNode || !nameNode) return;
const typeName = extractSimpleTypeName(typeNode);
const varName = extractVarName(nameNode);
if (typeName && varName) scopeEnv.set(varName, typeName);
if (!varName) return;
// Explicit type (existing behavior): for (User user : users)
const typeName = extractSimpleTypeName(typeNode);
if (typeName && typeName !== 'var') {
scopeEnv.set(varName, typeName);
return;
}
// Tier 1c: var — resolve from iterable's container type
const iterableNode = node.childForFieldName('value');
if (!iterableNode) return;
let iterableName: string | undefined;
let methodName: string | undefined;
let callExprElementType: string | undefined;
if (iterableNode.type === 'identifier') {
iterableName = iterableNode.text;
} else if (iterableNode.type === 'field_access') {
const field = iterableNode.childForFieldName('field');
if (field) iterableName = field.text;
} else if (iterableNode.type === 'method_invocation') {
// data.keySet() → method_invocation > object: identifier + name: identifier
// Also handles this.data.values() → object is field_access, extract inner field name
const obj = iterableNode.childForFieldName('object');
const name = iterableNode.childForFieldName('name');
if (obj?.type === 'identifier') {
iterableName = obj.text;
} else if (obj?.type === 'field_access') {
const innerField = obj.childForFieldName('field');
if (innerField) iterableName = innerField.text;
} else if (!obj && name) {
// Direct function call: for (var u : getUsers()) — no receiver object
const rawReturn = returnTypeLookup.lookupRawReturnType(name.text);
if (rawReturn) callExprElementType = extractElementTypeFromString(rawReturn);
}
if (name) methodName = name.text;
}
if (!iterableName && !callExprElementType) return;
let elementType: string | undefined;
if (callExprElementType) {
elementType = callExprElementType;
} else {
const containerTypeName = scopeEnv.get(iterableName!);
const typeArgPos = methodToTypeArgPosition(methodName, containerTypeName);
elementType = resolveIterableElementType(
iterableName!, node, scopeEnv, declarationTypeNodes, scope,
extractJavaElementTypeFromTypeNode, findJavaParamElementType,
typeArgPos,
);
}
if (elementType) scopeEnv.set(varName, elementType);
};
/** Java: var alias = u → local_variable_declaration > variable_declarator with name/value */
@@ -109,7 +198,7 @@ const extractJavaPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv
if (!nameNode || !valueNode) continue;
const lhs = nameNode.text;
if (scopeEnv.has(lhs)) continue;
if (valueNode.type === 'identifier' || valueNode.type === 'simple_identifier') return { lhs, rhs: valueNode.text };
if (valueNode.type === 'identifier' || valueNode.type === 'simple_identifier') return { kind: 'copy', lhs, rhs: valueNode.text };
}
return undefined;
};
@@ -130,6 +219,19 @@ const extractJavaPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv
* declares the new type, so no scopeEnv lookup is needed.
*/
const extractJavaPatternBinding: PatternBindingExtractor = (node) => {
if (node.type === 'type_pattern') {
// Java 17+ switch pattern: case User u -> ...
// type_pattern has positional children (NO named fields):
// namedChild(0) = type (type_identifier, e.g., User)
// namedChild(1) = identifier (e.g., u)
const typeNode = node.namedChild(0);
const nameNode = node.namedChild(1);
if (!typeNode || !nameNode) return undefined;
const typeName = extractSimpleTypeName(typeNode);
const varName = extractVarName(nameNode);
if (!typeName || !varName) return undefined;
return { varName, typeName };
}
if (node.type !== 'instanceof_expression') return undefined;
const nameNode = node.childForFieldName('name');
if (!nameNode) return undefined;
@@ -143,11 +245,12 @@ const extractJavaPatternBinding: PatternBindingExtractor = (node) => {
export const javaTypeConfig: LanguageTypeConfig = {
declarationNodeTypes: JAVA_DECLARATION_NODE_TYPES,
forLoopNodeTypes: JAVA_FOR_LOOP_NODE_TYPES,
patternBindingNodeTypes: new Set(['instanceof_expression', 'type_pattern']),
extractDeclaration: extractJavaDeclaration,
extractParameter: extractJavaParameter,
extractInitializer: extractJavaInitializer,
scanConstructorBinding: scanJavaConstructorBinding,
forLoopNodeTypes: JAVA_FOR_LOOP_NODE_TYPES,
extractForLoopBinding: extractJavaForLoopBinding,
extractPendingAssignment: extractJavaPendingAssignment,
extractPatternBinding: extractJavaPatternBinding,
@@ -195,7 +298,10 @@ const extractKotlinDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: M
}
};
/** Kotlin: formal_parameter → type name */
/** Kotlin: parameter / formal_parameter → type name.
* Kotlin's tree-sitter grammar uses positional children (simple_identifier, user_type)
* rather than named fields (name, type) on `parameter` nodes, so we fall back to
* findChildByType when childForFieldName returns null. */
const extractKotlinParameter: ParameterExtractor = (node: SyntaxNode, env: Map<string, string>): void => {
let nameNode: SyntaxNode | null = null;
let typeNode: SyntaxNode | null = null;
@@ -208,6 +314,10 @@ const extractKotlinParameter: ParameterExtractor = (node: SyntaxNode, env: Map<s
typeNode = node.childForFieldName('type');
}
// Fallback: Kotlin `parameter` nodes use positional children, not named fields
if (!nameNode) nameNode = findChildByType(node, 'simple_identifier');
if (!typeNode) typeNode = findChildByType(node, 'user_type');
if (!nameNode || !typeNode) return;
const varName = extractVarName(nameNode);
const typeName = extractSimpleTypeName(typeNode);
@@ -279,19 +389,143 @@ const KOTLIN_FOR_LOOP_NODE_TYPES: ReadonlySet<string> = new Set([
'for_statement',
]);
/** Kotlin: for (user: User in users) — extract loop variable binding when explicit type annotation exists */
const extractKotlinForLoopBinding: ForLoopExtractor = (node: SyntaxNode, scopeEnv: Map<string, string>): void => {
// Kotlin loop variable: variable_declaration child with optional user_type annotation
/** Extract element type from a Kotlin type annotation AST node (user_type wrapping generic).
* Kotlin: user_type → [type_identifier, type_arguments → [type_projection → user_type]]
* Handles the type_projection wrapper that Kotlin uses for generic type arguments. */
const extractKotlinElementTypeFromTypeNode = (typeNode: SyntaxNode, pos: TypeArgPosition = 'last'): string | undefined => {
if (typeNode.type === 'user_type') {
const argsNode = findChildByType(typeNode, 'type_arguments');
if (argsNode && argsNode.namedChildCount >= 1) {
const targetArg = pos === 'first'
? argsNode.namedChild(0)
: argsNode.namedChild(argsNode.namedChildCount - 1);
if (!targetArg) return undefined;
// Kotlin wraps type args in type_projection — unwrap to get the inner type
const inner = targetArg.type === 'type_projection'
? targetArg.firstNamedChild
: targetArg;
if (inner) return extractSimpleTypeName(inner);
}
}
return undefined;
};
/** Walk up from a for-loop to the enclosing function_declaration and search parameters.
* Kotlin parameters use positional children (simple_identifier, user_type), not named fields. */
const findKotlinParamElementType = (iterableName: string, startNode: SyntaxNode, pos: TypeArgPosition = 'last'): string | undefined => {
let current: SyntaxNode | null = startNode.parent;
while (current) {
if (current.type === 'function_declaration') {
const paramsNode = findChildByType(current, 'function_value_parameters');
if (paramsNode) {
for (let i = 0; i < paramsNode.namedChildCount; i++) {
const param = paramsNode.namedChild(i);
if (!param || param.type !== 'parameter') continue;
const nameNode = findChildByType(param, 'simple_identifier');
if (nameNode?.text !== iterableName) continue;
const typeNode = findChildByType(param, 'user_type');
if (typeNode) return extractKotlinElementTypeFromTypeNode(typeNode, pos);
}
}
break;
}
current = current.parent;
}
return undefined;
};
/** Kotlin: for (user: User in users) — extract loop variable binding.
* Tier 1c: for `for (user in users)` without annotation, resolves from iterable. */
const extractKotlinForLoopBinding: ForLoopExtractor = (node, ctx): void => {
const { scopeEnv, declarationTypeNodes, scope, returnTypeLookup } = ctx;
const varDecl = findChildByType(node, 'variable_declaration');
if (!varDecl) return;
// Only extract when there is an explicit type annotation (user_type node)
const typeNode = findChildByType(varDecl, 'user_type');
if (!typeNode) return;
const nameNode = findChildByType(varDecl, 'simple_identifier');
if (!nameNode) return;
const typeName = extractSimpleTypeName(typeNode);
const varName = extractVarName(nameNode);
if (typeName && varName) scopeEnv.set(varName, typeName);
if (!varName) return;
// Explicit type annotation (existing behavior): for (user: User in users)
const typeNode = findChildByType(varDecl, 'user_type');
if (typeNode) {
const typeName = extractSimpleTypeName(typeNode);
if (typeName) scopeEnv.set(varName, typeName);
return;
}
// Tier 1c: no annotation — resolve from iterable's container type
// Kotlin for-loop children: [variable_declaration, iterable_expr, control_structure_body]
// The iterable is the second named child of the for_statement (after variable_declaration)
let iterableName: string | undefined;
let methodName: string | undefined;
let fallbackIterableName: string | undefined;
let callExprElementType: string | undefined;
let foundVarDecl = false;
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child === varDecl) { foundVarDecl = true; continue; }
if (!foundVarDecl || !child) continue;
if (child.type === 'simple_identifier') {
iterableName = child.text;
break;
}
if (child.type === 'navigation_expression') {
// data.keys → navigation_expression > simple_identifier(data) + navigation_suffix > simple_identifier(keys)
const obj = child.firstNamedChild;
const suffix = findChildByType(child, 'navigation_suffix');
const prop = suffix ? findChildByType(suffix, 'simple_identifier') : null;
const hasCallSuffix = suffix ? findChildByType(suffix, 'call_suffix') !== null : false;
// Always try object as iterable + property as method first (handles data.values, data.keys).
// For bare property access without call_suffix, also save property as fallback
// (handles this.users, repo.items where the property IS the iterable).
if (obj?.type === 'simple_identifier') iterableName = obj.text;
if (prop) methodName = prop.text;
if (!hasCallSuffix && prop) {
fallbackIterableName = prop.text;
}
break;
}
if (child.type === 'call_expression') {
// data.values() → call_expression > navigation_expression > simple_identifier + navigation_suffix
const callee = child.firstNamedChild;
if (callee?.type === 'navigation_expression') {
const obj = callee.firstNamedChild;
if (obj?.type === 'simple_identifier') iterableName = obj.text;
const suffix = findChildByType(callee, 'navigation_suffix');
if (suffix) {
const prop = findChildByType(suffix, 'simple_identifier');
if (prop) methodName = prop.text;
}
} else if (callee?.type === 'simple_identifier') {
// Direct function call: for (u in getUsers())
const rawReturn = returnTypeLookup.lookupRawReturnType(callee.text);
if (rawReturn) callExprElementType = extractElementTypeFromString(rawReturn);
}
break;
}
}
if (!iterableName && !callExprElementType) return;
let elementType: string | undefined;
if (callExprElementType) {
elementType = callExprElementType;
} else {
let containerTypeName = scopeEnv.get(iterableName!);
// Fallback: if object has no type in scope, try the property as the iterable name.
// Handles patterns like this.users where the property itself is the iterable variable.
if (!containerTypeName && fallbackIterableName) {
iterableName = fallbackIterableName;
methodName = undefined;
containerTypeName = scopeEnv.get(iterableName);
}
const typeArgPos = methodToTypeArgPosition(methodName, containerTypeName);
elementType = resolveIterableElementType(
iterableName!, node, scopeEnv, declarationTypeNodes, scope,
extractKotlinElementTypeFromTypeNode, findKotlinParamElementType,
typeArgPos,
);
}
if (elementType) scopeEnv.set(varName, elementType);
};
/** Kotlin: val alias = u → property_declaration or variable_declaration.
@@ -314,7 +548,7 @@ const extractKotlinPendingAssignment: PendingAssignmentExtractor = (node, scopeE
if (!child) continue;
if (child.type === '=') { foundEq = true; continue; }
if (foundEq && child.type === 'simple_identifier') {
return { lhs, rhs: child.text };
return { kind: 'copy', lhs, rhs: child.text };
}
}
return undefined;
@@ -336,7 +570,7 @@ const extractKotlinPendingAssignment: PendingAssignmentExtractor = (node, scopeE
if (!child) continue;
if (child.type === '=') { foundEq = true; continue; }
if (foundEq && child.type === 'simple_identifier') {
return { lhs, rhs: child.text };
return { kind: 'copy', lhs, rhs: child.text };
}
}
return undefined;
@@ -345,13 +579,42 @@ const extractKotlinPendingAssignment: PendingAssignmentExtractor = (node, scopeE
return undefined;
};
/** Walk up from a node to find an ancestor of a given type. */
const findAncestorByType = (node: SyntaxNode, type: string): SyntaxNode | undefined => {
let current = node.parent;
while (current) {
if (current.type === type) return current;
current = current.parent;
}
return undefined;
};
const extractKotlinPatternBinding: PatternBindingExtractor = (node) => {
if (node.type !== 'type_test') return undefined;
const typeNode = node.lastNamedChild;
if (!typeNode) return undefined;
const typeName = extractSimpleTypeName(typeNode);
if (!typeName) return undefined;
const whenExpr = findAncestorByType(node, 'when_expression');
if (!whenExpr) return undefined;
const whenSubject = whenExpr.namedChild(0);
const subject = whenSubject?.firstNamedChild ?? whenSubject;
if (!subject) return undefined;
const varName = extractVarName(subject);
if (!varName) return undefined;
return { varName, typeName };
};
export const kotlinTypeConfig: LanguageTypeConfig = {
allowPatternBindingOverwrite: true,
declarationNodeTypes: KOTLIN_DECLARATION_NODE_TYPES,
forLoopNodeTypes: KOTLIN_FOR_LOOP_NODE_TYPES,
patternBindingNodeTypes: new Set(['type_test']),
extractDeclaration: extractKotlinDeclaration,
extractParameter: extractKotlinParameter,
extractInitializer: extractKotlinInitializer,
scanConstructorBinding: scanKotlinConstructorBinding,
extractForLoopBinding: extractKotlinForLoopBinding,
extractPendingAssignment: extractKotlinPendingAssignment,
extractPatternBinding: extractKotlinPatternBinding,
};
@@ -1,6 +1,6 @@
import type { SyntaxNode } from '../utils.js';
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, ReturnTypeExtractor, PendingAssignmentExtractor } from './types.js';
import { extractSimpleTypeName, extractVarName, extractCalleeName } from './shared.js';
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, ReturnTypeExtractor, PendingAssignmentExtractor, ForLoopExtractor } from './types.js';
import { extractSimpleTypeName, extractVarName, extractCalleeName, resolveIterableElementType, extractElementTypeFromString } from './shared.js';
const DECLARATION_NODE_TYPES: ReadonlySet<string> = new Set([
'assignment_expression', // For constructor inference: $x = new User()
@@ -61,6 +61,15 @@ const normalizePhpType = (raw: string): string | undefined => {
type = segments[segments.length - 1];
// Skip uninformative types
if (type === 'mixed' || type === 'void' || type === 'self' || type === 'static' || type === 'object') return undefined;
// Extract element type from generic: Collection<User> → User
// PHPDoc generics encode the element type in angle brackets. Since PHP's Strategy B
// uses the scopeEnv value directly as the element type, we must store the inner type,
// not the container name. This mirrors how User[] → User is handled by the [] strip above.
const genericMatch = type.match(/^(\w+)\s*</);
if (genericMatch) {
const elementType = extractElementTypeFromString(type);
return elementType ?? undefined;
}
if (/^\w+$/.test(type)) return type;
return undefined;
};
@@ -73,6 +82,67 @@ const SKIP_NODE_TYPES: ReadonlySet<string> = new Set(['attribute_list', 'attribu
const PHPDOC_PARAM_RE = /@param\s+(\S+)\s+\$(\w+)/g;
/** Alternate PHPDoc order: `@param $name Type` (name first) */
const PHPDOC_PARAM_ALT_RE = /@param\s+\$(\w+)\s+(\S+)/g;
/** Regex to extract PHPDoc @var annotations: `@var Type` */
const PHPDOC_VAR_RE = /@var\s+(\S+)/;
/**
* Extract the element type for a class property from its PHPDoc @var annotation or
* PHP 7.4+ native type. Walks backward from the property_declaration node to find
* an immediately preceding comment containing @var.
*
* Returns the normalized element type (e.g. User[] → User, Collection<User> → User).
* Returns undefined when no usable type annotation is found.
*/
const extractClassPropertyElementType = (propDecl: SyntaxNode): string | undefined => {
// Strategy 1: PHPDoc @var annotation on a preceding comment sibling
let sibling = propDecl.previousSibling;
while (sibling) {
if (sibling.type === 'comment') {
const match = PHPDOC_VAR_RE.exec(sibling.text);
if (match) return normalizePhpType(match[1]);
} else if (sibling.isNamed && !SKIP_NODE_TYPES.has(sibling.type)) {
break;
}
sibling = sibling.previousSibling;
}
// Strategy 2: PHP 7.4+ native type field — skip generic 'array' since element type is unknown
const typeNode = propDecl.childForFieldName('type');
if (!typeNode) return undefined;
const typeName = extractSimpleTypeName(typeNode);
if (!typeName || typeName === 'array') return undefined;
return typeName;
};
/**
* Scan a class body for a property_declaration matching the given property name,
* and extract its element type. The class body is the `declaration_list` child of
* a `class_declaration` node.
*
* Used as Strategy C in extractForLoopBinding for `$this->property` iterables
* where Strategy A (resolveIterableElementType) and Strategy B (scopeEnv lookup)
* both fail to find the type.
*/
const findClassPropertyElementType = (propName: string, classNode: SyntaxNode): string | undefined => {
const declList = classNode.childForFieldName('body')
?? (classNode.namedChild(classNode.namedChildCount - 1)?.type === 'declaration_list'
? classNode.namedChild(classNode.namedChildCount - 1)
: null); // fallback: last named child, only if it's a declaration_list
if (!declList) return undefined;
for (let i = 0; i < declList.namedChildCount; i++) {
const child = declList.namedChild(i);
if (child?.type !== 'property_declaration') continue;
// Check if any property_element has a variable_name matching '$propName'
for (let j = 0; j < child.namedChildCount; j++) {
const elem = child.namedChild(j);
if (elem?.type !== 'property_element') continue;
const varNameNode = elem.firstNamedChild; // variable_name node
if (varNameNode?.text === '$' + propName) {
return extractClassPropertyElementType(child);
}
}
}
return undefined;
};
/**
* Collect PHPDoc @param type bindings from comment nodes preceding a method/function.
@@ -190,8 +260,12 @@ const extractParameter: ParameterExtractor = (node: SyntaxNode, env: Map<string,
if (!nameNode || !typeNode) return;
const varName = extractVarName(nameNode);
if (!varName) return;
// Don't overwrite PHPDoc-derived types (e.g. @param User[] $users → User)
// with the less-specific AST type annotation (e.g. array).
if (env.has(varName)) return;
const typeName = extractSimpleTypeName(typeNode);
if (varName && typeName) env.set(varName, typeName);
if (typeName) env.set(varName, typeName);
};
/** PHP: $x = SomeFactory() or $x = $this->getUser() — bind variable to call return type */
@@ -229,16 +303,41 @@ const scanConstructorBinding: ConstructorBindingScanner = (node) => {
/** Regex to extract PHPDoc @return annotations: `@return User` */
const PHPDOC_RETURN_RE = /@return\s+(\S+)/;
/**
* Normalize a PHPDoc return type for storage in the SymbolTable.
* Unlike normalizePhpType (which strips User[] → User for scopeEnv), this preserves
* array notation so lookupRawReturnType can extract element types for for-loop resolution.
* \App\Models\User[] → User[]
* ?User → User
* Collection<User> → Collection<User> (preserved for extractElementTypeFromString)
*/
const normalizePhpReturnType = (raw: string): string | undefined => {
// Strip nullable prefix: ?User[] → User[]
let type = raw.startsWith('?') ? raw.slice(1) : raw;
// Strip union with null/false/void: User[]|null → User[]
const parts = type.split('|').filter(p => p !== 'null' && p !== 'false' && p !== 'void' && p !== 'mixed');
if (parts.length !== 1) return undefined;
type = parts[0];
// Strip namespace: \App\Models\User[] → User[]
const segments = type.split('\\');
type = segments[segments.length - 1];
// Skip uninformative types
if (type === 'mixed' || type === 'void' || type === 'self' || type === 'static' || type === 'object' || type === 'array') return undefined;
if (/^\w+(\[\])?$/.test(type) || /^\w+\s*</.test(type)) return type;
return undefined;
};
/**
* Extract return type from PHPDoc `@return Type` annotation preceding a method.
* Walks backwards through preceding siblings looking for comment nodes.
* Preserves array notation (e.g., User[]) for for-loop element type extraction.
*/
const extractReturnType: ReturnTypeExtractor = (node) => {
let sibling = node.previousSibling;
while (sibling) {
if (sibling.type === 'comment') {
const match = PHPDOC_RETURN_RE.exec(sibling.text);
if (match) return normalizePhpType(match[1]);
if (match) return normalizePhpReturnType(match[1]);
} else if (sibling.isNamed && !SKIP_NODE_TYPES.has(sibling.type)) break;
sibling = sibling.previousSibling;
}
@@ -256,15 +355,173 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) =>
const lhs = left.text;
const rhs = right.text;
if (!lhs || !rhs || scopeEnv.has(lhs)) return undefined;
return { lhs, rhs };
return { kind: 'copy', lhs, rhs };
};
const FOR_LOOP_NODE_TYPES: ReadonlySet<string> = new Set([
'foreach_statement',
]);
/** Extract element type from a PHP type annotation AST node.
* PHP has limited AST-level container types — `array` is a primitive_type with no generic args.
* Named types (e.g., `Collection`) are returned as-is (container descriptor lookup handles them). */
const extractPhpElementTypeFromTypeNode = (_typeNode: SyntaxNode): string | undefined => {
// PHP AST type nodes don't carry generic parameters (array<User> is PHPDoc-only).
// primitive_type 'array' and named_type 'Collection' don't encode element types.
return undefined;
};
/** Walk up from a foreach to the enclosing function and search parameter type annotations.
* PHP parameter type hints are limited (array, ClassName) — this extracts element type when possible. */
const findPhpParamElementType = (iterableName: string, startNode: SyntaxNode): string | undefined => {
let current: SyntaxNode | null = startNode.parent;
while (current) {
if (current.type === 'method_declaration' || current.type === 'function_definition') {
const paramsNode = current.childForFieldName('parameters');
if (paramsNode) {
for (let i = 0; i < paramsNode.namedChildCount; i++) {
const param = paramsNode.namedChild(i);
if (!param || param.type !== 'simple_parameter') continue;
const nameNode = param.childForFieldName('name');
if (nameNode?.text !== iterableName) continue;
const typeNode = param.childForFieldName('type');
if (typeNode) return extractPhpElementTypeFromTypeNode(typeNode);
}
}
break;
}
current = current.parent;
}
return undefined;
};
/**
* PHP: foreach ($users as $user) — extract loop variable binding.
*
* AST structure (from tree-sitter-php grammar):
* foreach_statement — no named fields for iterable/value (only 'body')
* children[0]: expression (iterable, e.g. $users)
* children[1]: expression (simple value) OR pair ($key => $value)
* pair children: expression (key), expression (value)
*
* PHP's PHPDoc @param normalizes `User[]` → `User` in the env, so the iterable's
* stored type IS the element type. We first try resolveIterableElementType (for
* constructor-binding cases that retain container types), then fall back to direct
* scopeEnv lookup (for PHPDoc-normalized types).
*/
const extractForLoopBinding: ForLoopExtractor = (node, { scopeEnv, declarationTypeNodes, scope, returnTypeLookup }): void => {
if (node.type !== 'foreach_statement') return;
// Collect non-body named children: first is the iterable, second is value or pair
const children: SyntaxNode[] = [];
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child && child !== node.childForFieldName('body')) {
children.push(child);
}
}
if (children.length < 2) return;
const iterableNode = children[0];
const valueOrPair = children[1];
// Determine the loop variable node
let loopVarNode: SyntaxNode;
if (valueOrPair.type === 'pair') {
// $key => $value — the value is the last named child of the pair
const lastChild = valueOrPair.namedChild(valueOrPair.namedChildCount - 1);
if (!lastChild) return;
// Handle by_ref: foreach ($arr as $k => &$v)
loopVarNode = lastChild.type === 'by_ref' ? (lastChild.firstNamedChild ?? lastChild) : lastChild;
} else {
// Simple: foreach ($users as $user) or foreach ($users as &$user)
loopVarNode = valueOrPair.type === 'by_ref' ? (valueOrPair.firstNamedChild ?? valueOrPair) : valueOrPair;
}
const varName = extractVarName(loopVarNode);
if (!varName) return;
// Get iterable variable name (PHP vars include $ prefix)
let iterableName: string | undefined;
let callExprElementType: string | undefined;
if (iterableNode.type === 'variable_name') {
iterableName = iterableNode.text;
} else if (iterableNode?.type === 'member_access_expression') {
const name = iterableNode.childForFieldName('name');
// PHP properties are stored in scopeEnv with $ prefix ($users), but
// member_access_expression.name returns without $ (users). Add $ to match.
if (name) iterableName = '$' + name.text;
} else if (iterableNode?.type === 'function_call_expression') {
// foreach (getUsers() as $user) — resolve via return type lookup
const calleeName = extractCalleeName(iterableNode);
if (calleeName) {
const rawReturn = returnTypeLookup.lookupRawReturnType(calleeName);
if (rawReturn) callExprElementType = extractElementTypeFromString(rawReturn);
}
} else if (iterableNode?.type === 'member_call_expression') {
// foreach ($this->getUsers() as $user) — resolve via return type lookup
const methodName = iterableNode.childForFieldName('name');
if (methodName) {
const rawReturn = returnTypeLookup.lookupRawReturnType(methodName.text);
if (rawReturn) callExprElementType = extractElementTypeFromString(rawReturn);
}
}
if (!iterableName && !callExprElementType) return;
// If we resolved the element type from a call expression, bind and return early
if (callExprElementType) {
scopeEnv.set(varName, callExprElementType);
return;
}
// Strategy A: try resolveIterableElementType (handles constructor-binding container types)
const elementType = resolveIterableElementType(
iterableName, node, scopeEnv, declarationTypeNodes, scope,
extractPhpElementTypeFromTypeNode, findPhpParamElementType,
undefined,
);
if (elementType) {
scopeEnv.set(varName, elementType);
return;
}
// Strategy B: direct scopeEnv lookup — PHP normalizePhpType strips User[] → User,
// so the iterable's stored type is already the element type from PHPDoc annotations.
const iterableType = scopeEnv.get(iterableName);
if (iterableType) {
scopeEnv.set(varName, iterableType);
return;
}
// Strategy C: $this->property — scan the enclosing class body for the property
// declaration and extract its element type from @var PHPDoc or native type.
// This handles the common PHP pattern where the property type is declared on the
// class body (/** @var User[] */ private $users) but the foreach is in a method
// whose scopeEnv does not contain the property type.
if (iterableNode?.type === 'member_access_expression') {
const obj = iterableNode.childForFieldName('object');
if (obj?.text === '$this') {
const nameNode = iterableNode.childForFieldName('name');
const propName = nameNode?.text;
if (propName) {
const classNode = findEnclosingClass(iterableNode);
if (classNode) {
const elementType = findClassPropertyElementType(propName, classNode);
if (elementType) scopeEnv.set(varName, elementType);
}
}
}
}
};
export const typeConfig: LanguageTypeConfig = {
declarationNodeTypes: DECLARATION_NODE_TYPES,
forLoopNodeTypes: FOR_LOOP_NODE_TYPES,
extractDeclaration,
extractParameter,
extractInitializer,
scanConstructorBinding,
extractReturnType,
extractForLoopBinding,
extractPendingAssignment,
};
@@ -1,6 +1,6 @@
import type { SyntaxNode } from '../utils.js';
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, PendingAssignmentExtractor } from './types.js';
import { extractSimpleTypeName, extractVarName } from './shared.js';
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, PendingAssignmentExtractor, PatternBindingExtractor, ForLoopExtractor } from './types.js';
import { extractSimpleTypeName, extractVarName, extractElementTypeFromString, extractGenericTypeArgs, resolveIterableElementType, methodToTypeArgPosition, type TypeArgPosition } from './shared.js';
const DECLARATION_NODE_TYPES: ReadonlySet<string> = new Set([
'assignment',
@@ -134,6 +134,163 @@ const scanConstructorBinding: ConstructorBindingScanner = (node) => {
return { varName: left.text, calleeName };
};
const FOR_LOOP_NODE_TYPES: ReadonlySet<string> = new Set([
'for_statement',
]);
/** Python function/method node types that carry a parameters list. */
const PY_FUNCTION_NODE_TYPES = new Set([
'function_definition', 'decorated_definition',
]);
/**
* Extract element type from a Python type annotation AST node.
* Handles:
* subscript "List[User]" → extractElementTypeFromString("List[User]") → "User"
* generic_type → extractGenericTypeArgs → first arg
* Falls back to text-based extraction.
*/
const extractPyElementTypeFromAnnotation = (typeNode: SyntaxNode, pos: TypeArgPosition = 'last'): string | undefined => {
// Unwrap 'type' wrapper node to get to the actual type (e.g., type > generic_type)
const inner = typeNode.type === 'type' ? (typeNode.firstNamedChild ?? typeNode) : typeNode;
// Python subscript: List[User], Sequence[User] — use raw text
if (inner.type === 'subscript') {
return extractElementTypeFromString(inner.text, pos);
}
// generic_type: dict[str, User] — tree-sitter-python uses type_parameter child
if (inner.type === 'generic_type') {
// Try standard extractGenericTypeArgs first (handles type_arguments)
const args = extractGenericTypeArgs(inner);
if (args.length >= 1) return pos === 'first' ? args[0] : args[args.length - 1];
// Fallback: look for type_parameter child (tree-sitter-python specific)
for (let i = 0; i < inner.namedChildCount; i++) {
const child = inner.namedChild(i);
if (child?.type === 'type_parameter') {
if (pos === 'first') {
const firstArg = child.firstNamedChild;
if (firstArg) return extractSimpleTypeName(firstArg);
} else {
const lastArg = child.lastNamedChild;
if (lastArg) return extractSimpleTypeName(lastArg);
}
}
}
}
// Fallback: raw text extraction (handles User[], [User], etc.)
return extractElementTypeFromString(inner.text, pos);
};
/**
* Walk up the AST from a for-statement to find the enclosing function definition,
* then search its parameters for one named `iterableName`.
* Returns the element type extracted from its type annotation, or undefined.
*
* Handles both `parameter` and `typed_parameter` node types in tree-sitter-python.
* `typed_parameter` may not expose the name as a `name` field — falls back to
* checking the first identifier-type named child.
*/
const findPyParamElementType = (iterableName: string, startNode: SyntaxNode, pos: TypeArgPosition = 'last'): string | undefined => {
let current: SyntaxNode | null = startNode.parent;
while (current) {
if (current.type === 'function_definition') {
const paramsNode = current.childForFieldName('parameters');
if (paramsNode) {
for (let i = 0; i < paramsNode.namedChildCount; i++) {
const param = paramsNode.namedChild(i);
if (!param) continue;
// Try named `name` field first (parameter node), then first identifier child
// (typed_parameter node may store name as first positional child)
const nameNode = param.childForFieldName('name')
?? (param.firstNamedChild?.type === 'identifier' ? param.firstNamedChild : null);
if (nameNode?.text !== iterableName) continue;
// Try `type` field, then last named child (typed_parameter stores type last)
const typeAnnotation = param.childForFieldName('type')
?? (param.namedChildCount >= 2 ? param.namedChild(param.namedChildCount - 1) : null);
if (typeAnnotation && typeAnnotation !== nameNode) {
return extractPyElementTypeFromAnnotation(typeAnnotation, pos);
}
}
}
break;
}
current = current.parent;
}
return undefined;
};
/**
* Python: for user in users: where users has a known container type annotation.
*
* AST node: `for_statement` with `left` (loop variable) and `right` (iterable).
*
* Tier 1c: resolves the element type via three strategies in priority order:
* 1. declarationTypeNodes — raw type annotation AST node (covers stored container types)
* 2. scopeEnv string — extractElementTypeFromString on the stored type
* 3. AST walk — walks up to the enclosing function's parameters to read List[User] directly
*/
const extractForLoopBinding: ForLoopExtractor = (node, { scopeEnv, declarationTypeNodes, scope, returnTypeLookup }): void => {
if (node.type !== 'for_statement') return;
// The iterable is the `right` field — may be identifier, attribute, or call.
const rightNode = node.childForFieldName('right');
let iterableName: string | undefined;
let methodName: string | undefined;
let callExprElementType: string | undefined;
if (rightNode?.type === 'identifier') {
iterableName = rightNode.text;
} else if (rightNode?.type === 'attribute') {
const prop = rightNode.lastNamedChild;
if (prop) iterableName = prop.text;
} else if (rightNode?.type === 'call') {
// data.items() → call > function: attribute > identifier('data') + identifier('items')
// get_users() → call > function: identifier (Phase 7.3 — return-type path)
const fn = rightNode.childForFieldName('function');
if (fn?.type === 'attribute') {
const obj = fn.firstNamedChild;
if (obj?.type === 'identifier') iterableName = obj.text;
// Extract method name: items, keys, values
const method = fn.lastNamedChild;
if (method?.type === 'identifier' && method !== obj) methodName = method.text;
} else if (fn?.type === 'identifier') {
// Direct function call: for user in get_users()
const rawReturn = returnTypeLookup.lookupRawReturnType(fn.text);
if (rawReturn) callExprElementType = extractElementTypeFromString(rawReturn);
}
}
if (!iterableName && !callExprElementType) return;
let elementType: string | undefined;
if (callExprElementType) {
elementType = callExprElementType;
} else {
const containerTypeName = scopeEnv.get(iterableName!);
const typeArgPos = methodToTypeArgPosition(methodName, containerTypeName);
elementType = resolveIterableElementType(
iterableName!, node, scopeEnv, declarationTypeNodes, scope,
extractPyElementTypeFromAnnotation, findPyParamElementType,
typeArgPos,
);
}
if (!elementType) return;
// The loop variable is the `left` field — identifier or pattern_list.
const leftNode = node.childForFieldName('left');
if (!leftNode) return;
// Handle tuple unpacking: for key, value in data.items()
if (leftNode.type === 'pattern_list') {
const lastChild = leftNode.lastNamedChild;
if (lastChild?.type === 'identifier') {
scopeEnv.set(lastChild.text, elementType);
}
return;
}
const loopVarName = extractVarName(leftNode);
if (loopVarName) scopeEnv.set(loopVarName, elementType);
};
/** Python: alias = u → assignment with left/right fields.
* Also handles walrus operator: alias := u → named_expression with name/value fields. */
const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) => {
@@ -153,15 +310,81 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) =>
if (!left || !right) return undefined;
const lhs = left.type === 'identifier' ? left.text : undefined;
if (!lhs || scopeEnv.has(lhs)) return undefined;
if (right.type === 'identifier') return { lhs, rhs: right.text };
if (right.type === 'identifier') return { kind: 'copy', lhs, rhs: right.text };
return undefined;
};
/**
* Python match/case `as` pattern binding: `case User() as u:`
*
* AST structure (tree-sitter-python):
* as_pattern
* alias: as_pattern_target ← the bound variable name (e.g. "u")
* children[0]: case_pattern ← wraps class_pattern (or is class_pattern directly)
* class_pattern
* dotted_name ← the class name (e.g. "User")
*
* The `alias` field is an `as_pattern_target` node whose `.text` is the identifier.
* The class name lives in the first non-alias named child: either a `case_pattern`
* wrapping a `class_pattern`, or a direct `class_pattern`.
*
* Conservative: returns undefined when:
* - The node is not an `as_pattern`
* - The pattern side is not a class_pattern (e.g. guard or literal match)
* - The variable was already bound in scopeEnv
*/
const extractPatternBinding: PatternBindingExtractor = (node, scopeEnv) => {
if (node.type !== 'as_pattern') return undefined;
// as_pattern: `case User() as u:` — binds matched value to a name.
// Try named field first (future grammar versions may expose it), fall back to positional.
if (node.namedChildCount < 2) return undefined;
const patternChild = node.namedChild(0);
const varNameNode = node.childForFieldName('alias')
?? node.namedChild(node.namedChildCount - 1);
if (!patternChild || !varNameNode) return undefined;
if (varNameNode.type !== 'identifier') return undefined;
const varName = varNameNode.text;
if (!varName || scopeEnv.has(varName)) return undefined;
// Find the class_pattern — may be direct or wrapped in case_pattern.
let classPattern: SyntaxNode | null = null;
if (patternChild.type === 'class_pattern') {
classPattern = patternChild;
} else if (patternChild.type === 'case_pattern') {
// Unwrap one level: case_pattern wraps class_pattern
for (let j = 0; j < patternChild.namedChildCount; j++) {
const inner = patternChild.namedChild(j);
if (inner?.type === 'class_pattern') {
classPattern = inner;
break;
}
}
}
if (!classPattern) return undefined;
// class_pattern children: dotted_name (the class name) + optional keyword_pattern args.
const classNameNode = classPattern.firstNamedChild;
if (!classNameNode || (classNameNode.type !== 'dotted_name' && classNameNode.type !== 'identifier')) return undefined;
const typeName = classNameNode.text;
if (!typeName) return undefined;
return { varName, typeName };
};
const PATTERN_BINDING_NODE_TYPES: ReadonlySet<string> = new Set(['as_pattern']);
export const typeConfig: LanguageTypeConfig = {
declarationNodeTypes: DECLARATION_NODE_TYPES,
forLoopNodeTypes: FOR_LOOP_NODE_TYPES,
patternBindingNodeTypes: PATTERN_BINDING_NODE_TYPES,
extractDeclaration,
extractParameter,
extractInitializer,
scanConstructorBinding,
extractForLoopBinding,
extractPendingAssignment,
extractPatternBinding,
};
@@ -1,5 +1,5 @@
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, ReturnTypeExtractor } from './types.js';
import { extractRubyConstructorAssignment, extractSimpleTypeName } from './shared.js';
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, ReturnTypeExtractor, PendingAssignmentExtractor, ForLoopExtractor } from './types.js';
import { extractRubyConstructorAssignment, extractSimpleTypeName, extractElementTypeFromString, extractVarName, resolveIterableElementType } from './shared.js';
import type { SyntaxNode } from '../utils.js';
/**
@@ -261,11 +261,146 @@ const scanConstructorBinding: ConstructorBindingScanner = (node) => {
return { varName: left.text, calleeName };
};
/** Ruby method node types that carry a parameter list. */
const RUBY_METHOD_NODE_TYPES = new Set(['method', 'singleton_method']);
const FOR_LOOP_NODE_TYPES: ReadonlySet<string> = new Set(['for']);
/**
* Collect raw YARD @param type strings from comment nodes preceding a method.
* Unlike collectYardParams which returns simplified type names, this returns the
* raw bracket content (e.g., "Array<User>" not "Array") for element type extraction.
*/
const collectYardRawParams = (methodNode: SyntaxNode): Map<string, string> => {
const params = new Map<string, string>();
const commentTexts: string[] = [];
const collectComments = (startNode: SyntaxNode): void => {
let sibling = startNode.previousSibling;
while (sibling) {
if (sibling.type === 'comment') {
commentTexts.unshift(sibling.text);
} else if (sibling.isNamed) {
break;
}
sibling = sibling.previousSibling;
}
};
collectComments(methodNode);
if (commentTexts.length === 0 && methodNode.parent?.type === 'body_statement') {
collectComments(methodNode.parent);
}
const commentBlock = commentTexts.join('\n');
let match: RegExpExecArray | null;
YARD_PARAM_RE.lastIndex = 0;
while ((match = YARD_PARAM_RE.exec(commentBlock)) !== null) {
params.set(match[1], match[2]);
}
YARD_PARAM_ALT_RE.lastIndex = 0;
while ((match = YARD_PARAM_ALT_RE.exec(commentBlock)) !== null) {
if (!params.has(match[2])) params.set(match[2], match[1]);
}
return params;
};
/**
* Walk up the AST from a for-statement to find the enclosing method,
* then search its YARD @param annotations for one named `iterableName`.
* Returns the element type extracted from the raw YARD type string.
*
* Example: `@param users [Array<User>]` → extracts "User" from "Array<User>".
*/
const findRubyParamElementType = (iterableName: string, startNode: SyntaxNode): string | undefined => {
let current: SyntaxNode | null = startNode.parent;
while (current) {
if (RUBY_METHOD_NODE_TYPES.has(current.type)) {
const rawParams = collectYardRawParams(current);
const rawType = rawParams.get(iterableName);
if (rawType) return extractElementTypeFromString(rawType);
break;
}
current = current.parent;
}
return undefined;
};
/**
* Ruby: for user in users ... end
*
* tree-sitter-ruby `for` node structure:
* pattern field: the loop variable (identifier)
* value field: `in` node whose child is the iterable expression
*
* Tier 1c: resolves the element type via:
* 1. scopeEnv string — extractElementTypeFromString on the stored type
* 2. AST walk — walks up to the enclosing method's YARD @param to read Array<User> directly
*
* Ruby has no static types on loop variables, so this mainly works when the
* iterable has a YARD-annotated container type (e.g., `@param users [Array<User>]`).
*/
const extractForLoopBinding: ForLoopExtractor = (node, { scopeEnv, declarationTypeNodes, scope }): void => {
if (node.type !== 'for') return;
// The loop variable is the `pattern` field (identifier).
const patternNode = node.childForFieldName('pattern');
if (!patternNode) return;
const loopVarName = extractVarName(patternNode);
if (!loopVarName) return;
// The iterable is inside the `value` field which is an `in` node wrapping the expression.
const inNode = node.childForFieldName('value');
if (!inNode) return;
const iterableNode = inNode.firstNamedChild;
let iterableName: string | undefined;
if (iterableNode?.type === 'identifier') {
iterableName = iterableNode.text;
} else if (iterableNode?.type === 'call') {
const method = iterableNode.childForFieldName('method');
if (method) iterableName = method.text;
}
if (!iterableName) return;
// Ruby has no extractFromTypeNode (no AST type annotations), pass a no-op.
const noopExtractFromTypeNode = (): string | undefined => undefined;
const elementType = resolveIterableElementType(
iterableName, node, scopeEnv, declarationTypeNodes, scope,
noopExtractFromTypeNode, findRubyParamElementType,
undefined,
);
if (!elementType) return;
scopeEnv.set(loopVarName, elementType);
};
/**
* Ruby: alias_user = user → assignment with left/right identifier fields.
* Only handles plain identifier RHS (not calls, not literals).
* Skips if LHS already has a resolved type in scopeEnv.
*/
const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) => {
if (node.type !== 'assignment') return undefined;
const lhsNode = node.childForFieldName('left');
if (!lhsNode || lhsNode.type !== 'identifier') return undefined;
const varName = lhsNode.text;
if (scopeEnv.has(varName)) return undefined;
const rhsNode = node.childForFieldName('right');
if (!rhsNode || rhsNode.type !== 'identifier') return undefined;
return { kind: 'copy', lhs: varName, rhs: rhsNode.text };
};
export const typeConfig: LanguageTypeConfig = {
declarationNodeTypes: DECLARATION_NODE_TYPES,
forLoopNodeTypes: FOR_LOOP_NODE_TYPES,
extractDeclaration,
extractParameter,
extractInitializer,
scanConstructorBinding,
extractReturnType,
extractForLoopBinding,
extractPendingAssignment,
};
@@ -1,6 +1,6 @@
import type { SyntaxNode } from '../utils.js';
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, PendingAssignmentExtractor, PatternBindingExtractor } from './types.js';
import { extractSimpleTypeName, extractVarName, hasTypeAnnotation, unwrapAwait, extractGenericTypeArgs } from './shared.js';
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, PendingAssignmentExtractor, PatternBindingExtractor, ForLoopExtractor } from './types.js';
import { extractSimpleTypeName, extractVarName, hasTypeAnnotation, unwrapAwait, extractGenericTypeArgs, resolveIterableElementType, methodToTypeArgPosition, extractElementTypeFromString, type TypeArgPosition } from './shared.js';
const DECLARATION_NODE_TYPES: ReadonlySet<string> = new Set([
'let_declaration',
@@ -35,7 +35,8 @@ const extractStructPatternType = (structPattern: SyntaxNode): string | undefined
* Recursively scan a pattern tree for captured_pattern nodes (x @ StructType { .. })
* and extract variable → type bindings from them.
*/
const extractCapturedPatternBindings = (pattern: SyntaxNode, env: Map<string, string>): void => {
const extractCapturedPatternBindings = (pattern: SyntaxNode, env: Map<string, string>, depth = 0): void => {
if (depth > 50) return;
if (pattern.type === 'captured_pattern') {
// captured_pattern: identifier @ inner_pattern
// The first named child is the identifier, followed by the inner pattern.
@@ -57,7 +58,7 @@ const extractCapturedPatternBindings = (pattern: SyntaxNode, env: Map<string, st
if (pattern.type === 'tuple_struct_pattern') {
for (let i = 0; i < pattern.namedChildCount; i++) {
const child = pattern.namedChild(i);
if (child) extractCapturedPatternBindings(child, env);
if (child) extractCapturedPatternBindings(child, env, depth + 1);
}
}
};
@@ -189,7 +190,7 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) =>
if (!pattern || !value) return undefined;
const lhs = extractVarName(pattern);
if (!lhs || scopeEnv.has(lhs)) return undefined;
if (value.type === 'identifier') return { lhs, rhs: value.text };
if (value.type === 'identifier') return { kind: 'copy', lhs, rhs: value.text };
return undefined;
};
@@ -215,10 +216,25 @@ const extractPatternBinding: PatternBindingExtractor = (
declarationTypeNodes,
scope,
) => {
if (node.type !== 'let_condition') return undefined;
let patternNode: SyntaxNode | null = null;
let valueNode: SyntaxNode | null = null;
const patternNode = node.childForFieldName('pattern');
const valueNode = node.childForFieldName('value');
if (node.type === 'let_condition') {
patternNode = node.childForFieldName('pattern');
valueNode = node.childForFieldName('value');
} else if (node.type === 'match_arm') {
// match_arm → pattern field is match_pattern wrapping the actual pattern
const matchPatternNode = node.childForFieldName('pattern');
// Unwrap match_pattern to get the tuple_struct_pattern inside
patternNode = matchPatternNode?.type === 'match_pattern'
? matchPatternNode.firstNamedChild
: matchPatternNode;
// source variable is in the parent match_expression's 'value' field
const matchExpr = node.parent?.parent; // match_arm → match_block → match_expression
if (matchExpr?.type === 'match_expression') {
valueNode = matchExpr.childForFieldName('value');
}
}
if (!patternNode || !valueNode) return undefined;
// Only handle tuple_struct_pattern: Some(x) or Ok(x)
@@ -269,12 +285,136 @@ const extractPatternBinding: PatternBindingExtractor = (
return { varName: innerVar, typeName: typeArgs[argIndex] };
};
// --- For-loop Tier 1c ---
const FOR_LOOP_NODE_TYPES: ReadonlySet<string> = new Set(['for_expression']);
/** Extract element type from a Rust type annotation AST node.
* Handles: generic_type (Vec<User>), reference_type (&[User]), array_type ([User; N]),
* slice_type ([User]). For call-graph purposes, strips references (&User → User). */
const extractRustElementTypeFromTypeNode = (typeNode: SyntaxNode, pos: TypeArgPosition = 'last', depth = 0): string | undefined => {
if (depth > 50) return undefined;
// generic_type: Vec<User>, HashMap<K, V> — extract type arg based on position
if (typeNode.type === 'generic_type') {
const args = extractGenericTypeArgs(typeNode);
if (args.length >= 1) return pos === 'first' ? args[0] : args[args.length - 1];
}
// reference_type: &[User] or &Vec<User> — unwrap the reference and recurse
if (typeNode.type === 'reference_type') {
const inner = typeNode.lastNamedChild;
if (inner) return extractRustElementTypeFromTypeNode(inner, pos, depth + 1);
}
// array_type: [User; N] — element is the first child
if (typeNode.type === 'array_type') {
const elemNode = typeNode.firstNamedChild;
if (elemNode) return extractSimpleTypeName(elemNode);
}
// slice_type: [User] — element is the first child
if (typeNode.type === 'slice_type') {
const elemNode = typeNode.firstNamedChild;
if (elemNode) return extractSimpleTypeName(elemNode);
}
return undefined;
};
/** Walk up from a for-loop to the enclosing function_item and search parameters
* for one named `iterableName`. Returns the element type from its annotation. */
const findRustParamElementType = (iterableName: string, startNode: SyntaxNode, pos: TypeArgPosition = 'last'): string | undefined => {
let current: SyntaxNode | null = startNode.parent;
while (current) {
if (current.type === 'function_item') {
const paramsNode = current.childForFieldName('parameters');
if (paramsNode) {
for (let i = 0; i < paramsNode.namedChildCount; i++) {
const param = paramsNode.namedChild(i);
if (!param || param.type !== 'parameter') continue;
const nameNode = param.childForFieldName('pattern');
if (!nameNode) continue;
// Unwrap reference patterns: &users, &mut users
let identNode = nameNode;
if (identNode.type === 'reference_pattern') {
identNode = identNode.lastNamedChild ?? identNode;
}
if (identNode.type === 'mut_pattern') {
identNode = identNode.firstNamedChild ?? identNode;
}
if (identNode.text !== iterableName) continue;
const typeNode = param.childForFieldName('type');
if (typeNode) return extractRustElementTypeFromTypeNode(typeNode, pos);
}
}
break;
}
current = current.parent;
}
return undefined;
};
/** Rust: for user in &users where users has a known container type.
* Unwraps reference_expression (&users, &mut users) to get the iterable name. */
const extractForLoopBinding: ForLoopExtractor = (node, { scopeEnv, declarationTypeNodes, scope, returnTypeLookup }): void => {
if (node.type !== 'for_expression') return;
const patternNode = node.childForFieldName('pattern');
const valueNode = node.childForFieldName('value');
if (!patternNode || !valueNode) return;
// Extract iterable name + method — may be &users, users, or users.iter()/keys()/values()
let iterableName: string | undefined;
let methodName: string | undefined;
let callExprElementType: string | undefined;
if (valueNode.type === 'reference_expression') {
const inner = valueNode.lastNamedChild;
if (inner?.type === 'identifier') iterableName = inner.text;
} else if (valueNode.type === 'identifier') {
iterableName = valueNode.text;
} else if (valueNode.type === 'field_expression') {
const prop = valueNode.lastNamedChild;
if (prop) iterableName = prop.text;
} else if (valueNode.type === 'call_expression') {
const funcExpr = valueNode.childForFieldName('function');
if (funcExpr?.type === 'field_expression') {
// users.iter() → field_expression > identifier + field_identifier
const obj = funcExpr.firstNamedChild;
if (obj?.type === 'identifier') iterableName = obj.text;
// Extract method name: iter, keys, values, into_iter, etc.
const field = funcExpr.lastNamedChild;
if (field?.type === 'field_identifier') methodName = field.text;
} else if (funcExpr?.type === 'identifier') {
// Direct function call: for user in get_users()
const rawReturn = returnTypeLookup.lookupRawReturnType(funcExpr.text);
if (rawReturn) callExprElementType = extractElementTypeFromString(rawReturn);
}
}
if (!iterableName && !callExprElementType) return;
let elementType: string | undefined;
if (callExprElementType) {
elementType = callExprElementType;
} else {
const containerTypeName = scopeEnv.get(iterableName!);
const typeArgPos = methodToTypeArgPosition(methodName, containerTypeName);
elementType = resolveIterableElementType(
iterableName!, node, scopeEnv, declarationTypeNodes, scope,
extractRustElementTypeFromTypeNode, findRustParamElementType,
typeArgPos,
);
}
if (!elementType) return;
const loopVarName = extractVarName(patternNode);
if (loopVarName) scopeEnv.set(loopVarName, elementType);
};
export const typeConfig: LanguageTypeConfig = {
declarationNodeTypes: DECLARATION_NODE_TYPES,
forLoopNodeTypes: FOR_LOOP_NODE_TYPES,
patternBindingNodeTypes: new Set(['let_condition', 'match_arm']),
extractDeclaration,
extractInitializer,
extractParameter,
scanConstructorBinding,
extractForLoopBinding,
extractPendingAssignment,
extractPatternBinding,
};
@@ -1,9 +1,175 @@
import type { SyntaxNode } from '../utils.js';
/** Which type argument to extract from a multi-arg generic container.
* - 'first': key type (e.g., K from Map<K,V>) — used for .keys(), .keySet()
* - 'last': value type (e.g., V from Map<K,V>) — used for .values(), .items(), .iter() */
export type TypeArgPosition = 'first' | 'last';
// ---------------------------------------------------------------------------
// Container type descriptors — maps container base names to type parameter
// semantics per access method. Replaces the simple KEY_METHODS heuristic.
//
// For user-defined generics (MyCache<K,V> extends Map<K,V>), heritage-aware
// fallback can walk the EXTENDS chain to find a matching descriptor.
// ---------------------------------------------------------------------------
/** Describes which type parameter position each access method yields. */
interface ContainerDescriptor {
/** Number of type parameters (1 = single-element, 2 = key-value) */
arity: number;
/** Methods that yield the first type parameter (key type for maps) */
keyMethods: ReadonlySet<string>;
/** Methods that yield the last type parameter (value type) */
valueMethods: ReadonlySet<string>;
}
/** Empty set for containers that have no key-yielding methods */
const NO_KEYS: ReadonlySet<string> = new Set();
/** Standard key-yielding methods across languages */
const STD_KEY_METHODS: ReadonlySet<string> = new Set(['keys']);
const JAVA_KEY_METHODS: ReadonlySet<string> = new Set(['keySet']);
const CSHARP_KEY_METHODS: ReadonlySet<string> = new Set(['Keys']);
/** Standard value-yielding methods across languages */
const STD_VALUE_METHODS: ReadonlySet<string> = new Set(['values', 'get', 'pop', 'remove']);
const CSHARP_VALUE_METHODS: ReadonlySet<string> = new Set(['Values', 'TryGetValue']);
const SINGLE_ELEMENT_METHODS: ReadonlySet<string> = new Set([
'iter', 'into_iter', 'iterator', 'get', 'first', 'last', 'pop',
'peek', 'poll', 'find', 'filter', 'map',
]);
const CONTAINER_DESCRIPTORS: ReadonlyMap<string, ContainerDescriptor> = new Map([
// --- Map / Dict types (arity 2: key + value) ---
['Map', { arity: 2, keyMethods: STD_KEY_METHODS, valueMethods: STD_VALUE_METHODS }],
['WeakMap', { arity: 2, keyMethods: STD_KEY_METHODS, valueMethods: STD_VALUE_METHODS }],
['HashMap', { arity: 2, keyMethods: STD_KEY_METHODS, valueMethods: STD_VALUE_METHODS }],
['BTreeMap', { arity: 2, keyMethods: STD_KEY_METHODS, valueMethods: STD_VALUE_METHODS }],
['LinkedHashMap', { arity: 2, keyMethods: JAVA_KEY_METHODS, valueMethods: STD_VALUE_METHODS }],
['TreeMap', { arity: 2, keyMethods: JAVA_KEY_METHODS, valueMethods: STD_VALUE_METHODS }],
['dict', { arity: 2, keyMethods: STD_KEY_METHODS, valueMethods: STD_VALUE_METHODS }],
['Dict', { arity: 2, keyMethods: STD_KEY_METHODS, valueMethods: STD_VALUE_METHODS }],
['Dictionary', { arity: 2, keyMethods: CSHARP_KEY_METHODS, valueMethods: CSHARP_VALUE_METHODS }],
['SortedDictionary', { arity: 2, keyMethods: CSHARP_KEY_METHODS, valueMethods: CSHARP_VALUE_METHODS }],
['Record', { arity: 2, keyMethods: STD_KEY_METHODS, valueMethods: STD_VALUE_METHODS }],
['OrderedDict', { arity: 2, keyMethods: STD_KEY_METHODS, valueMethods: STD_VALUE_METHODS }],
['ConcurrentHashMap', { arity: 2, keyMethods: JAVA_KEY_METHODS, valueMethods: STD_VALUE_METHODS }],
['ConcurrentDictionary', { arity: 2, keyMethods: CSHARP_KEY_METHODS, valueMethods: CSHARP_VALUE_METHODS }],
// --- Single-element containers (arity 1) ---
['Array', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
['List', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
['ArrayList', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
['LinkedList',{ arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
['Vec', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
['VecDeque', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
['Set', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
['HashSet', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
['BTreeSet', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
['TreeSet', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
['Queue', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
['Deque', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
['Stack', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
['Sequence', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
['Iterable', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
['Iterator', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
['IEnumerable', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
['IList', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
['ICollection', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
['Collection', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
['ObservableCollection', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
['IEnumerator', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
['SortedSet', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
['Stream', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
['MutableList', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
['MutableSet', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
['LinkedHashSet', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
['ArrayDeque', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
['PriorityQueue', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
['MutableMap', { arity: 2, keyMethods: STD_KEY_METHODS, valueMethods: STD_VALUE_METHODS }],
['list', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
['set', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
['tuple', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
['frozenset', { arity: 1, keyMethods: NO_KEYS, valueMethods: SINGLE_ELEMENT_METHODS }],
]);
/** Determine which type arg to extract based on container type name and access method.
*
* Resolution order:
* 1. If container is known and method is in keyMethods → 'first'
* 2. If container is known with arity 1 → 'last' (same as 'first' for single-arg)
* 3. If container is unknown → fall back to method name heuristic
* 4. Default: 'last' (value type)
*/
export function methodToTypeArgPosition(methodName: string | undefined, containerTypeName?: string): TypeArgPosition {
if (containerTypeName) {
const desc = CONTAINER_DESCRIPTORS.get(containerTypeName);
if (desc) {
// Single-element container: always 'last' (= only arg)
if (desc.arity === 1) return 'last';
// Multi-element: check if method yields key type
if (methodName && desc.keyMethods.has(methodName)) return 'first';
// Default for multi-element: value type
return 'last';
}
}
// Fallback for unknown containers: simple method name heuristic
if (methodName && (methodName === 'keys' || methodName === 'keySet' || methodName === 'Keys')) {
return 'first';
}
return 'last';
}
/** Look up the container descriptor for a type name. Exported for heritage-chain lookups. */
export function getContainerDescriptor(typeName: string): ContainerDescriptor | undefined {
return CONTAINER_DESCRIPTORS.get(typeName);
}
/**
* Shared 3-strategy fallback for resolving the element type of a container variable.
* Used by all for-loop extractors to resolve the loop variable's type from the iterable.
*
* Strategy 1: declarationTypeNodes — raw AST type annotation node (handles container types
* where extractSimpleTypeName returned undefined, e.g., User[], List[User])
* Strategy 2: scopeEnv string — extractElementTypeFromString on the stored type string
* Strategy 3: AST walk — language-specific upward walk to enclosing function parameters
*
* @param extractFromTypeNode Language-specific function to extract element type from AST node
* @param findParamElementType Optional language-specific AST walk to find parameter type
* @param typeArgPos Which generic type arg to extract: 'first' for keys, 'last' for values (default)
*/
export function resolveIterableElementType(
iterableName: string,
node: SyntaxNode,
scopeEnv: ReadonlyMap<string, string>,
declarationTypeNodes: ReadonlyMap<string, SyntaxNode>,
scope: string,
extractFromTypeNode: (typeNode: SyntaxNode, pos?: TypeArgPosition) => string | undefined,
findParamElementType?: (name: string, startNode: SyntaxNode, pos?: TypeArgPosition) => string | undefined,
typeArgPos: TypeArgPosition = 'last',
): string | undefined {
// Strategy 1: declarationTypeNodes AST node (check current scope, then file scope)
const typeNode = declarationTypeNodes.get(`${scope}\0${iterableName}`)
?? (scope !== '' ? declarationTypeNodes.get(`\0${iterableName}`) : undefined);
if (typeNode) {
const t = extractFromTypeNode(typeNode, typeArgPos);
if (t) return t;
}
// Strategy 2: scopeEnv string → extractElementTypeFromString
const iterableType = scopeEnv.get(iterableName);
if (iterableType) {
const el = extractElementTypeFromString(iterableType, typeArgPos);
if (el) return el;
}
// Strategy 3: AST walk to function parameters
if (findParamElementType) return findParamElementType(iterableName, node, typeArgPos);
return undefined;
}
/** Known single-arg nullable wrapper types that unwrap to their inner type
* for receiver resolution. Optional<User> → "User", Option<User> → "User".
* Only nullable wrappers — NOT containers (List, Vec) or async wrappers (Promise, Future).
* See call-processor.ts WRAPPER_GENERICS for the full set used in return-type inference. */
* See WRAPPER_GENERICS below for the full set used in return-type inference. */
const NULLABLE_WRAPPER_TYPES = new Set([
'Optional', // Java
'Option', // Rust, Scala
@@ -16,7 +182,8 @@ const NULLABLE_WRAPPER_TYPES = new Set([
* (e.g., models.User → User), and nullable types (e.g., User? → User).
* Returns undefined for complex types (unions, intersections, function types).
*/
export const extractSimpleTypeName = (typeNode: SyntaxNode): string | undefined => {
export const extractSimpleTypeName = (typeNode: SyntaxNode, depth = 0): string | undefined => {
if (depth > 50 || typeNode.text.length > 2048) return undefined;
// Direct type identifier (includes Ruby 'constant' for class names)
if (typeNode.type === 'type_identifier' || typeNode.type === 'identifier'
|| typeNode.type === 'simple_identifier' || typeNode.type === 'constant') {
@@ -40,14 +207,21 @@ export const extractSimpleTypeName = (typeNode: SyntaxNode): string | undefined
}
}
// C++ template_type (e.g., vector<User>, map<string, User>): extract base name
if (typeNode.type === 'template_type') {
const base = typeNode.childForFieldName('name') ?? typeNode.firstNamedChild;
if (base) return extractSimpleTypeName(base, depth + 1);
}
// Generic types: extract the base type (e.g., List<User> → List)
// For nullable wrappers (Optional<User>, Option<User>), unwrap to inner type.
if (typeNode.type === 'generic_type' || typeNode.type === 'parameterized_type') {
if (typeNode.type === 'generic_type' || typeNode.type === 'parameterized_type'
|| typeNode.type === 'generic_name') {
const base = typeNode.childForFieldName('name')
?? typeNode.childForFieldName('type')
?? typeNode.firstNamedChild;
if (!base) return undefined;
const baseName = extractSimpleTypeName(base);
const baseName = extractSimpleTypeName(base, depth + 1);
// Unwrap known nullable wrappers: Optional<User> → User, Option<User> → User
if (baseName && NULLABLE_WRAPPER_TYPES.has(baseName)) {
const args = extractGenericTypeArgs(typeNode);
@@ -59,7 +233,7 @@ export const extractSimpleTypeName = (typeNode: SyntaxNode): string | undefined
// Nullable types (Kotlin User?, C# User?)
if (typeNode.type === 'nullable_type') {
const inner = typeNode.firstNamedChild;
if (inner) return extractSimpleTypeName(inner);
if (inner) return extractSimpleTypeName(inner, depth + 1);
}
// Nullable union types (TS/JS: User | null, User | undefined, User | null | undefined)
@@ -76,7 +250,7 @@ export const extractSimpleTypeName = (typeNode: SyntaxNode): string | undefined
}
// Only unwrap if exactly one meaningful type remains
if (nonNullTypes.length === 1) {
return extractSimpleTypeName(nonNullTypes[0]);
return extractSimpleTypeName(nonNullTypes[0], depth + 1);
}
}
@@ -84,24 +258,25 @@ export const extractSimpleTypeName = (typeNode: SyntaxNode): string | undefined
if (typeNode.type === 'type_annotation' || typeNode.type === 'type'
|| typeNode.type === 'user_type') {
const inner = typeNode.firstNamedChild;
if (inner) return extractSimpleTypeName(inner);
if (inner) return extractSimpleTypeName(inner, depth + 1);
}
// Pointer/reference types (C++, Rust): User*, &User, &mut User
if (typeNode.type === 'pointer_type' || typeNode.type === 'reference_type') {
const inner = typeNode.firstNamedChild;
if (inner) return extractSimpleTypeName(inner);
if (inner) return extractSimpleTypeName(inner, depth + 1);
}
// PHP primitive_type (string, int, float, bool)
if (typeNode.type === 'primitive_type') {
// Primitive/predefined types: string, int, float, bool, number, unknown, any
// PHP: primitive_type; TS/JS: predefined_type
if (typeNode.type === 'primitive_type' || typeNode.type === 'predefined_type') {
return typeNode.text;
}
// PHP named_type / optional_type
if (typeNode.type === 'named_type' || typeNode.type === 'optional_type') {
const inner = typeNode.childForFieldName('name') ?? typeNode.firstNamedChild;
if (inner) return extractSimpleTypeName(inner);
if (inner) return extractSimpleTypeName(inner, depth + 1);
}
// Name node (PHP)
@@ -119,7 +294,7 @@ export const extractSimpleTypeName = (typeNode: SyntaxNode): string | undefined
export const extractVarName = (node: SyntaxNode): string | undefined => {
if (node.type === 'identifier' || node.type === 'simple_identifier'
|| node.type === 'variable_name' || node.type === 'name'
|| node.type === 'constant') {
|| node.type === 'constant' || node.type === 'property_identifier') {
return node.text;
}
// variable_declarator (Java/C#): has a 'name' field
@@ -141,9 +316,11 @@ export const TYPED_PARAMETER_TYPES = new Set([
'optional_parameter', // TS: (x?: Foo)
'formal_parameter', // Java/Kotlin
'parameter', // C#/Rust/Go/Python/Swift
'typed_parameter', // Python: def f(x: Foo) — distinct from 'parameter' in tree-sitter-python
'parameter_declaration', // C/C++ void f(Type name)
'simple_parameter', // PHP function(Foo $x)
'property_promotion_parameter', // PHP 8.0+ constructor promotion: __construct(private Foo $x)
'closure_parameter', // Rust: |user: User| — typed closure parameters
]);
/**
@@ -164,18 +341,20 @@ export const TYPED_PARAMETER_TYPES = new Set([
* returns [] for non-generic types).
* @returns Array of resolved type argument names. Unresolvable arguments are omitted.
*/
export const extractGenericTypeArgs = (typeNode: SyntaxNode): string[] => {
export const extractGenericTypeArgs = (typeNode: SyntaxNode, depth = 0): string[] => {
if (depth > 50) return [];
// Unwrap wrapper nodes that may sit above the generic_type
if (typeNode.type === 'type_annotation' || typeNode.type === 'type'
|| typeNode.type === 'user_type' || typeNode.type === 'nullable_type'
|| typeNode.type === 'optional_type') {
const inner = typeNode.firstNamedChild;
if (inner) return extractGenericTypeArgs(inner);
if (inner) return extractGenericTypeArgs(inner, depth + 1);
return [];
}
// Only process generic/parameterized type nodes
if (typeNode.type !== 'generic_type' && typeNode.type !== 'parameterized_type') {
// Only process generic/parameterized type nodes (includes C#'s generic_name)
if (typeNode.type !== 'generic_type' && typeNode.type !== 'parameterized_type'
&& typeNode.type !== 'generic_name') {
return [];
}
@@ -316,3 +495,253 @@ export const findChildByType = (node: SyntaxNode, type: string): SyntaxNode | nu
}
return null;
};
// Internal helper: extract the first comma-separated argument from a string,
// respecting nested angle-bracket and square-bracket depth.
function extractFirstArg(args: string): string {
let depth = 0;
for (let i = 0; i < args.length; i++) {
const ch = args[i];
if (ch === '<' || ch === '[') depth++;
else if (ch === '>' || ch === ']') depth--;
else if (ch === ',' && depth === 0) return args.slice(0, i).trim();
}
return args.trim();
}
/**
* Extract element type from a container type string.
* Uses bracket-balanced parsing (no regex) for generic argument extraction.
* Returns undefined for ambiguous or unparseable strings.
*
* Handles:
* - Array<User> → User (generic angle brackets)
* - User[] → User (array suffix)
* - []User → User (Go slice prefix)
* - List[User] → User (Python subscript)
* - [User] → User (Swift array sugar)
* - vector<User> → User (C++ container)
* - Vec<User> → User (Rust container)
*
* For multi-argument generics (Map<K, V>), returns the first or last type arg
* based on `pos` ('first' for keys, 'last' for values — default 'last').
* Returns undefined when the extracted type is not a simple word.
*/
export function extractElementTypeFromString(typeStr: string, pos: TypeArgPosition = 'last'): string | undefined {
if (!typeStr || typeStr.length === 0 || typeStr.length > 2048) return undefined;
// 1. Array suffix: User[] → User
if (typeStr.endsWith('[]')) {
const base = typeStr.slice(0, -2).trim();
return base && /^\w+$/.test(base) ? base : undefined;
}
// 2. Go slice prefix: []User → User
if (typeStr.startsWith('[]')) {
const element = typeStr.slice(2).trim();
return element && /^\w+$/.test(element) ? element : undefined;
}
// 3. Swift array sugar: [User] → User
// Must start with '[', end with ']', and contain no angle brackets
// (to avoid confusing with List[User] handled below).
if (typeStr.startsWith('[') && typeStr.endsWith(']') && !typeStr.includes('<')) {
const element = typeStr.slice(1, -1).trim();
return element && /^\w+$/.test(element) ? element : undefined;
}
// 4. Generic bracket-balanced extraction: Array<User> / List[User] / Vec<User>
// Find the first opening bracket (< or [) and pick the one that appears first.
const openAngle = typeStr.indexOf('<');
const openSquare = typeStr.indexOf('[');
let openIdx = -1;
let openChar = '';
let closeChar = '';
if (openAngle >= 0 && (openSquare < 0 || openAngle < openSquare)) {
openIdx = openAngle;
openChar = '<';
closeChar = '>';
} else if (openSquare >= 0) {
openIdx = openSquare;
openChar = '[';
closeChar = ']';
}
if (openIdx < 0) return undefined;
// Walk bracket-balanced from the character after the opening bracket to find
// the matching close bracket, tracking depth for nested brackets.
// All bracket types (<, >, [, ]) contribute to depth uniformly, but only the
// selected closeChar can match at depth 0 (prevents cross-bracket miscounting).
let depth = 0;
const start = openIdx + 1;
let lastCommaIdx = -1; // Track last top-level comma for 'last' position
for (let i = start; i < typeStr.length; i++) {
const ch = typeStr[i];
if (ch === '<' || ch === '[') {
depth++;
} else if (ch === '>' || ch === ']') {
if (depth === 0) {
// At depth 0 — only match if it is our selected close bracket.
if (ch !== closeChar) return undefined; // mismatched bracket = malformed
if (pos === 'last' && lastCommaIdx >= 0) {
// Return last arg (text after last comma)
const lastArg = typeStr.slice(lastCommaIdx + 1, i).trim();
return lastArg && /^\w+$/.test(lastArg) ? lastArg : undefined;
}
const inner = typeStr.slice(start, i).trim();
const firstArg = extractFirstArg(inner);
return firstArg && /^\w+$/.test(firstArg) ? firstArg : undefined;
}
depth--;
} else if (ch === ',' && depth === 0) {
if (pos === 'first') {
// Return first arg (text before first comma)
const arg = typeStr.slice(start, i).trim();
return arg && /^\w+$/.test(arg) ? arg : undefined;
}
lastCommaIdx = i;
}
}
return undefined;
}
// ── Return type text helpers ─────────────────────────────────────────────
// extractReturnTypeName works on raw return-type text already stored in
// SymbolDefinition (e.g. "User", "Promise<User>", "User | null", "*User").
// Extracts the base user-defined type name.
/** Primitive / built-in types that should NOT produce a receiver binding. */
const PRIMITIVE_TYPES = new Set([
'string', 'number', 'boolean', 'void', 'int', 'float', 'double', 'long',
'short', 'byte', 'char', 'bool', 'str', 'i8', 'i16', 'i32', 'i64',
'u8', 'u16', 'u32', 'u64', 'f32', 'f64', 'usize', 'isize',
'undefined', 'null', 'None', 'nil',
]);
/**
* Extract a simple type name from raw return-type text.
* Handles common patterns:
* "User" → "User"
* "Promise<User>" → "User" (unwrap wrapper generics)
* "Option<User>" → "User"
* "Result<User, Error>" → "User" (first type arg)
* "User | null" → "User" (strip nullable union)
* "User?" → "User" (strip nullable suffix)
* "*User" → "User" (Go pointer)
* "&User" → "User" (Rust reference)
* Returns undefined for complex types or primitives.
*/
const WRAPPER_GENERICS = new Set([
'Promise', 'Observable', 'Future', 'CompletableFuture', 'Task', 'ValueTask', // async wrappers
'Option', 'Some', 'Optional', 'Maybe', // nullable wrappers
'Result', 'Either', // result wrappers
// Rust smart pointers (Deref to inner type)
'Rc', 'Arc', 'Weak', // pointer types
'MutexGuard', 'RwLockReadGuard', 'RwLockWriteGuard', // guard types
'Ref', 'RefMut', // RefCell guards
'Cow', // copy-on-write
// Containers (List, Array, Vec, Set, etc.) are intentionally excluded —
// methods are called on the container, not the element type.
// Non-wrapper generics return the base type (e.g., List) via the else branch.
]);
/**
* Extracts the first type argument from a comma-separated generic argument string,
* respecting nested angle brackets. For example:
* "Result<User, Error>" → "Result<User, Error>" (no top-level comma)
* "User, Error" → "User"
* "Map<K, V>, string" → "Map<K, V>"
*/
function extractFirstGenericArg(args: string): string {
let depth = 0;
for (let i = 0; i < args.length; i++) {
if (args[i] === '<') depth++;
else if (args[i] === '>') depth--;
else if (args[i] === ',' && depth === 0) return args.slice(0, i).trim();
}
return args.trim();
}
/**
* Extract the first non-lifetime type argument from a generic argument string.
* Skips Rust lifetime parameters (e.g., `'a`, `'_`) to find the actual type.
* "'_, User" → "User"
* "'a, User" → "User"
* "User, Error" → "User" (no lifetime — delegates to extractFirstGenericArg)
*/
function extractFirstTypeArg(args: string): string {
let remaining = args;
while (remaining) {
const first = extractFirstGenericArg(remaining);
if (!first.startsWith("'")) return first;
// Skip past this lifetime arg + the comma separator
const commaIdx = remaining.indexOf(',', first.length);
if (commaIdx < 0) return first; // only lifetimes — fall through
remaining = remaining.slice(commaIdx + 1).trim();
}
return args.trim();
}
const MAX_RETURN_TYPE_INPUT_LENGTH = 2048;
const MAX_RETURN_TYPE_LENGTH = 512;
export const extractReturnTypeName = (raw: string, depth = 0): string | undefined => {
if (depth > 10) return undefined;
if (raw.length > MAX_RETURN_TYPE_INPUT_LENGTH) return undefined;
let text = raw.trim();
if (!text) return undefined;
// Strip pointer/reference prefixes: *User, &User, &mut User
text = text.replace(/^[&*]+\s*(mut\s+)?/, '');
// Strip nullable suffix: User?
text = text.replace(/\?$/, '');
// Handle union types: "User | null" → "User"
if (text.includes('|')) {
const parts = text.split('|').map(p => p.trim()).filter(p =>
p !== 'null' && p !== 'undefined' && p !== 'void' && p !== 'None' && p !== 'nil'
);
if (parts.length === 1) text = parts[0];
else return undefined; // genuine union — too complex
}
// Handle generics: Promise<User> → unwrap if wrapper, else take base
const genericMatch = text.match(/^(\w+)\s*<(.+)>$/);
if (genericMatch) {
const [, base, args] = genericMatch;
if (WRAPPER_GENERICS.has(base)) {
// Take the first non-lifetime type argument, using bracket-balanced splitting
// so that nested generics like Result<User, Error> are not split at the inner
// comma. Lifetime parameters (Rust 'a, '_) are skipped.
const firstArg = extractFirstTypeArg(args);
return extractReturnTypeName(firstArg, depth + 1);
}
// Non-wrapper generic: return the base type (e.g., Map<K,V> → Map)
return PRIMITIVE_TYPES.has(base.toLowerCase()) ? undefined : base;
}
// Bare wrapper type without generic argument (e.g. Task, Promise, Option)
// should not produce a binding — these are meaningless without a type parameter
if (WRAPPER_GENERICS.has(text)) return undefined;
// Handle qualified names: models.User → User, Models::User → User, \App\Models\User → User
if (text.includes('::') || text.includes('.') || text.includes('\\')) {
text = text.split(/::|[.\\]/).pop()!;
}
// Final check: skip primitives
if (PRIMITIVE_TYPES.has(text) || PRIMITIVE_TYPES.has(text.toLowerCase())) return undefined;
// Must start with uppercase (class/type convention) or be a valid identifier
if (!/^[A-Z_]\w*$/.test(text)) return undefined;
// If the final extracted type name is too long, reject it
if (text.length > MAX_RETURN_TYPE_LENGTH) return undefined;
return text;
};
@@ -24,19 +24,49 @@ export type ConstructorBindingScanner = (node: SyntaxNode) => { varName: string;
* rather than in AST fields. Returns undefined if no return type can be determined. */
export type ReturnTypeExtractor = (node: SyntaxNode) => string | undefined;
/** Extracts loop variable type binding from a for-each statement. */
export type ForLoopExtractor = (
node: SyntaxNode,
scopeEnv: Map<string, string>,
) => void;
/** Narrow lookup interface for resolving a callee name → return type name.
* Backed by SymbolTable.lookupFuzzyCallable; passed via ForLoopExtractorContext.
* Conservative: returns undefined when the callee is ambiguous (0 or 2+ matches). */
export interface ReturnTypeLookup {
/** Processed type name after stripping wrappers (e.g., 'User' from 'Promise<User>').
* Use for call-result variable bindings (`const b = foo()`). */
lookupReturnType(callee: string): string | undefined;
/** Raw return type as declared in the symbol (e.g., '[]User', 'List<User>').
* Use for iterable-element extraction (`for v := range foo()`). */
lookupRawReturnType(callee: string): string | undefined;
}
/** Extracts a plain-identifier assignment for Tier 2 propagation.
* For `const b = a`, returns { lhs: 'b', rhs: 'a' } when the LHS has no resolved type.
* Returns undefined if the node is not a plain identifier assignment. */
/** Context object passed to ForLoopExtractor.
* Groups the four parameters that were previously positional. */
export interface ForLoopExtractorContext {
/** Mutable type-env for the current scope — extractor writes bindings here */
scopeEnv: Map<string, string>;
/** Maps `scope\0varName` to the declaration's type annotation AST node */
declarationTypeNodes: ReadonlyMap<string, SyntaxNode>;
/** Current scope key, e.g. `"process@42"` */
scope: string;
/** Resolves a callee name to its declared return type (undefined = unknown/ambiguous) */
returnTypeLookup: ReturnTypeLookup;
}
/** Extracts loop variable type binding from a for-each statement. */
export type ForLoopExtractor = (node: SyntaxNode, ctx: ForLoopExtractorContext) => void;
/** Discriminated union for pending Tier-2 propagation items.
* - `copy` — `const b = a` (identifier alias, propagate a's type to b)
* - `callResult` — `const b = foo()` (bind b to foo's declared return type) */
export type PendingAssignment =
| { kind: 'copy'; lhs: string; rhs: string }
| { kind: 'callResult'; lhs: string; callee: string };
/** Extracts a pending assignment for Tier 2 propagation.
* Returns a PendingAssignment when the RHS is a bare identifier (`copy`) or a
* call expression (`callResult`) and the LHS has no resolved type yet.
* Returns undefined if the node is not a matching assignment. */
export type PendingAssignmentExtractor = (
node: SyntaxNode,
scopeEnv: ReadonlyMap<string, string>,
) => { lhs: string; rhs: string } | undefined;
) => PendingAssignment | undefined;
/** Extracts a typed variable binding from a pattern-matching construct.
* Returns { varName, typeName } for patterns that introduce NEW variables.
@@ -57,10 +87,20 @@ export type PatternBindingExtractor = (
/** Per-language type extraction configuration */
export interface LanguageTypeConfig {
/** Allow pattern binding to overwrite existing scopeEnv entries.
* WARNING: Enables function-scope type pollution. Only for languages with
* smart-cast semantics (e.g., Kotlin `when/is`) where the subject variable
* already exists in scopeEnv from its declaration. */
readonly allowPatternBindingOverwrite?: boolean;
/** Node types that represent typed declarations for this language */
declarationNodeTypes: ReadonlySet<string>;
/** AST node types for for-each/for-in statements with explicit element types. */
forLoopNodeTypes?: ReadonlySet<string>;
/** Optional allowlist of AST node types on which extractPatternBinding should run.
* When present, extractPatternBinding is only invoked for nodes whose type is in this set,
* short-circuiting the call for all other node types. When absent, every node is passed to
* extractPatternBinding (legacy behaviour). */
patternBindingNodeTypes?: ReadonlySet<string>;
/** Extract a (varName → typeName) binding from a declaration node */
extractDeclaration: TypeBindingExtractor;
/** Extract a (varName → typeName) binding from a parameter node */
@@ -79,9 +119,10 @@ export interface LanguageTypeConfig {
extractReturnType?: ReturnTypeExtractor;
/** Extract loop variable → type binding from a for-each AST node. */
extractForLoopBinding?: ForLoopExtractor;
/** Extract plain-identifier assignment (e.g. `const b = a`) for Tier 2 chain propagation.
* Called on declaration/assignment nodes; returns {lhs, rhs} when the RHS is a bare identifier
* and the LHS has no resolved type yet. Language-specific because AST shapes differ widely. */
/** Extract pending assignment for Tier 2 propagation.
* Called on declaration/assignment nodes; returns a PendingAssignment when the RHS
* is a bare identifier (copy) or call expression (callResult) and the LHS has no
* resolved type yet. Language-specific because AST shapes differ widely. */
extractPendingAssignment?: PendingAssignmentExtractor;
/** Extract a typed variable binding from a pattern-matching construct.
* Called on every AST node; returns { varName, typeName } when the node introduces a new
@@ -1,12 +1,13 @@
import type { SyntaxNode } from '../utils.js';
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, ReturnTypeExtractor, PendingAssignmentExtractor } from './types.js';
import { extractSimpleTypeName, extractVarName, hasTypeAnnotation, unwrapAwait, extractCalleeName } from './shared.js';
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, ReturnTypeExtractor, PendingAssignmentExtractor, ForLoopExtractor, PatternBindingExtractor } from './types.js';
import { extractSimpleTypeName, extractVarName, hasTypeAnnotation, unwrapAwait, extractCalleeName, extractElementTypeFromString, extractGenericTypeArgs, resolveIterableElementType, methodToTypeArgPosition, type TypeArgPosition } from './shared.js';
const DECLARATION_NODE_TYPES: ReadonlySet<string> = new Set([
'lexical_declaration',
'variable_declaration',
'function_declaration', // JSDoc @param on function declarations
'method_definition', // JSDoc @param on class methods
'public_field_definition', // class field: private users: User[]
]);
const normalizeJsDocType = (raw: string): string | undefined => {
@@ -80,6 +81,18 @@ const extractDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: Map<str
return;
}
// Class field: `private users: User[]` — public_field_definition has name + type fields directly.
if (node.type === 'public_field_definition') {
const nameNode = node.childForFieldName('name');
const typeAnnotation = node.childForFieldName('type');
if (!nameNode || !typeAnnotation) return;
const varName = nameNode.text;
if (!varName) return;
const typeName = extractSimpleTypeName(typeAnnotation);
if (typeName) env.set(varName, typeName);
return;
}
for (let i = 0; i < node.namedChildCount; i++) {
const declarator = node.namedChild(i);
if (declarator?.type !== 'variable_declarator') continue;
@@ -191,6 +204,231 @@ const extractReturnType: ReturnTypeExtractor = (node) => {
return undefined;
};
const FOR_LOOP_NODE_TYPES: ReadonlySet<string> = new Set([
'for_in_statement',
]);
/** TS function/method node types that carry a parameters list. */
const TS_FUNCTION_NODE_TYPES = new Set([
'function_declaration', 'function_expression', 'arrow_function',
'method_definition', 'generator_function', 'generator_function_declaration',
]);
/**
* Extract element type from a TypeScript type annotation AST node.
* Handles:
* type_annotation ": User[]" → array_type → type_identifier "User"
* type_annotation ": Array<User>" → generic_type → extractGenericTypeArgs → "User"
* Falls back to text-based extraction via extractElementTypeFromString.
*/
const extractTsElementTypeFromAnnotation = (typeAnnotation: SyntaxNode, pos: TypeArgPosition = 'last', depth = 0): string | undefined => {
if (depth > 50) return undefined;
// Unwrap type_annotation (the node text includes ': ' prefix)
const inner = typeAnnotation.type === 'type_annotation'
? (typeAnnotation.firstNamedChild ?? typeAnnotation)
: typeAnnotation;
// readonly User[] — readonly_type wraps array_type: unwrap and recurse
if (inner.type === 'readonly_type') {
const wrapped = inner.firstNamedChild;
if (wrapped) return extractTsElementTypeFromAnnotation(wrapped, pos, depth + 1);
}
// User[] — array_type: first named child is the element type
if (inner.type === 'array_type') {
const elem = inner.firstNamedChild;
if (elem) return extractSimpleTypeName(elem);
}
// Array<User>, Map<string, User> — generic_type
// pos determines which type arg: 'first' for keys, 'last' for values
if (inner.type === 'generic_type') {
const args = extractGenericTypeArgs(inner);
if (args.length >= 1) return pos === 'first' ? args[0] : args[args.length - 1];
}
// Fallback: strip ': ' prefix from type_annotation text and use string extraction
const rawText = inner.text;
return extractElementTypeFromString(rawText, pos);
};
/**
* Search a statement_block (function body) for a variable_declarator named `iterableName`
* that has a type annotation, preceding the given `beforeNode`.
* Returns the element type from the type annotation, or undefined.
*/
const findTsLocalDeclElementType = (
iterableName: string,
blockNode: SyntaxNode,
beforeNode: SyntaxNode,
pos: TypeArgPosition = 'last',
): string | undefined => {
for (let i = 0; i < blockNode.namedChildCount; i++) {
const stmt = blockNode.namedChild(i);
if (!stmt) continue;
// Stop when we reach the for-loop itself
if (stmt === beforeNode || stmt.startIndex >= beforeNode.startIndex) break;
// Look for lexical_declaration or variable_declaration
if (stmt.type !== 'lexical_declaration' && stmt.type !== 'variable_declaration') continue;
for (let j = 0; j < stmt.namedChildCount; j++) {
const decl = stmt.namedChild(j);
if (decl?.type !== 'variable_declarator') continue;
const nameNode = decl.childForFieldName('name');
if (nameNode?.text !== iterableName) continue;
const typeAnnotation = decl.childForFieldName('type');
if (typeAnnotation) return extractTsElementTypeFromAnnotation(typeAnnotation, pos);
}
}
return undefined;
};
/**
* Walk up the AST from a for-loop node to find the enclosing function scope,
* then search (1) its parameter list and (2) local declarations in the body
* for a variable named `iterableName` with a container type annotation.
* Returns the element type extracted from the annotation, or undefined.
*/
const findTsIterableElementType = (iterableName: string, startNode: SyntaxNode, pos: TypeArgPosition = 'last'): string | undefined => {
let current: SyntaxNode | null = startNode.parent;
// Capture the immediate statement_block parent to search local declarations
const blockNode = current?.type === 'statement_block' ? current : null;
while (current) {
if (TS_FUNCTION_NODE_TYPES.has(current.type)) {
// Search function parameters
const paramsNode = current.childForFieldName('parameters')
?? current.childForFieldName('formal_parameters');
if (paramsNode) {
for (let i = 0; i < paramsNode.namedChildCount; i++) {
const param = paramsNode.namedChild(i);
if (!param) continue;
const patternNode = param.childForFieldName('pattern') ?? param.childForFieldName('name');
if (patternNode?.text === iterableName) {
const typeAnnotation = param.childForFieldName('type');
if (typeAnnotation) return extractTsElementTypeFromAnnotation(typeAnnotation, pos);
}
}
}
// Search local declarations in the function body (statement_block)
if (blockNode) {
const result = findTsLocalDeclElementType(iterableName, blockNode, startNode, pos);
if (result) return result;
}
break; // stop at the nearest function boundary
}
current = current.parent;
}
return undefined;
};
/**
* TypeScript/JavaScript: for (const user of users) where users has a known array type.
*
* Both `for...of` and `for...in` use the same `for_in_statement` AST node in tree-sitter.
* We differentiate by checking for the `of` keyword among the unnamed children.
*
* Tier 1c: resolves the element type via three strategies in priority order:
* 1. declarationTypeNodes — raw type annotation AST node (covers Array<User> from declarations)
* 2. scopeEnv string — extractElementTypeFromString on the stored type (covers locally annotated vars)
* 3. AST walk — walks up to the enclosing function's parameters to read User[] annotations directly
* Only handles `for...of`; `for...in` produces string keys, not element types.
*/
const extractForLoopBinding: ForLoopExtractor = (node, { scopeEnv, declarationTypeNodes, scope, returnTypeLookup }): void => {
if (node.type !== 'for_in_statement') return;
// Confirm this is `for...of`, not `for...in`, by scanning unnamed children for the keyword text.
let isForOf = false;
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child && !child.isNamed && child.text === 'of') {
isForOf = true;
break;
}
}
if (!isForOf) return;
// The iterable is the `right` field — may be identifier, member_expression, or call_expression.
const rightNode = node.childForFieldName('right');
let iterableName: string | undefined;
let methodName: string | undefined;
let callExprElementType: string | undefined;
if (rightNode?.type === 'identifier') {
iterableName = rightNode.text;
} else if (rightNode?.type === 'member_expression') {
const prop = rightNode.childForFieldName('property');
if (prop) iterableName = prop.text;
} else if (rightNode?.type === 'call_expression') {
// entries.values() → call_expression > function: member_expression > object + property
// this.repos.values() → nested member_expression: extract property from inner member
// getUsers() → call_expression > function: identifier (Phase 7.3 — return-type path)
const fn = rightNode.childForFieldName('function');
if (fn?.type === 'member_expression') {
const obj = fn.childForFieldName('object');
const prop = fn.childForFieldName('property');
if (obj?.type === 'identifier') {
iterableName = obj.text;
} else if (obj?.type === 'member_expression') {
// this.repos.values() → obj = this.repos → extract 'repos'
const innerProp = obj.childForFieldName('property');
if (innerProp) iterableName = innerProp.text;
}
if (prop?.type === 'property_identifier') methodName = prop.text;
} else if (fn?.type === 'identifier') {
// Direct function call: for (const user of getUsers())
const rawReturn = returnTypeLookup.lookupRawReturnType(fn.text);
if (rawReturn) callExprElementType = extractElementTypeFromString(rawReturn);
}
}
if (!iterableName && !callExprElementType) return;
let elementType: string | undefined;
if (callExprElementType) {
elementType = callExprElementType;
} else {
// Look up the container's base type name for descriptor-aware resolution
const containerTypeName = scopeEnv.get(iterableName!);
const typeArgPos = methodToTypeArgPosition(methodName, containerTypeName);
elementType = resolveIterableElementType(
iterableName!, node, scopeEnv, declarationTypeNodes, scope,
extractTsElementTypeFromAnnotation, findTsIterableElementType,
typeArgPos,
);
}
if (!elementType) return;
// The loop variable is the `left` field.
const leftNode = node.childForFieldName('left');
if (!leftNode) return;
// Handle destructured for-of: for (const [k, v] of entries)
// AST: left = array_pattern directly (no variable_declarator wrapper)
// Bind the LAST identifier to the element type (value in [key, value] patterns)
if (leftNode.type === 'array_pattern') {
const lastChild = leftNode.lastNamedChild;
if (lastChild?.type === 'identifier') {
scopeEnv.set(lastChild.text, elementType);
}
return;
}
if (leftNode.type === 'object_pattern') {
// Object destructuring (e.g., `for (const { id } of users)`) destructures
// into fields of the element type. Without field-level resolution, we cannot
// bind individual properties to their correct types. Skip to avoid false bindings.
return;
}
let loopVarNode: SyntaxNode | null = leftNode;
// `const user` parses as: left → variable_declarator containing an identifier named `user`
if (loopVarNode.type === 'variable_declarator') {
loopVarNode = loopVarNode.childForFieldName('name') ?? loopVarNode.firstNamedChild;
}
if (!loopVarNode) return;
const loopVarName = extractVarName(loopVarNode);
if (loopVarName) scopeEnv.set(loopVarName, elementType);
};
/** TS/JS: const alias = u → variable_declarator with name/value fields */
const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) => {
for (let i = 0; i < node.namedChildCount; i++) {
@@ -201,17 +439,36 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) =>
if (!nameNode || !valueNode) continue;
const lhs = nameNode.text;
if (scopeEnv.has(lhs)) continue;
if (valueNode.type === 'identifier') return { lhs, rhs: valueNode.text };
if (valueNode.type === 'identifier') return { kind: 'copy', lhs, rhs: valueNode.text };
}
return undefined;
};
/** TS instanceof narrowing: `x instanceof User` → bind x to User.
* Only works when x has no prior type binding (e.g. x: unknown, untyped params).
* Typed params (x: Animal) are blocked by the !scopeEnv.has() guard in buildTypeEnv.
* Uses first-writer-wins, same as Rust match arm bindings. */
const extractPatternBinding: PatternBindingExtractor = (node) => {
if (node.type !== 'binary_expression') return undefined;
const op = node.children.find(c => !c.isNamed && c.text === 'instanceof');
if (!op) return undefined;
// binary_expression children are positional — no left/right fields
const left = node.namedChild(0);
const right = node.namedChild(1);
if (left?.type !== 'identifier' || right?.type !== 'identifier') return undefined;
return { varName: left.text, typeName: right.text };
};
export const typeConfig: LanguageTypeConfig = {
declarationNodeTypes: DECLARATION_NODE_TYPES,
forLoopNodeTypes: FOR_LOOP_NODE_TYPES,
patternBindingNodeTypes: new Set(['binary_expression']),
extractDeclaration,
extractParameter,
extractInitializer,
scanConstructorBinding,
extractReturnType,
extractForLoopBinding,
extractPendingAssignment,
extractPatternBinding,
};
+90 -29
View File
@@ -35,7 +35,7 @@ export const DEFINITION_CAPTURE_KEYS = [
] as const;
/** Extract the definition node from a tree-sitter query capture map. */
export const getDefinitionNodeFromCaptures = (captureMap: Record<string, any>): any | null => {
export const getDefinitionNodeFromCaptures = (captureMap: Record<string, any>): SyntaxNode | null => {
for (const key of DEFINITION_CAPTURE_KEYS) {
if (captureMap[key]) return captureMap[key];
}
@@ -351,7 +351,7 @@ export const findEnclosingClassId = (node: any, filePath: string): string | null
* Extract function name and label from a function_definition or similar AST node.
* Handles C/C++ qualified_identifier (ClassName::MethodName) and other language patterns.
*/
export const extractFunctionName = (node: any): { funcName: string | null; label: string } => {
export const extractFunctionName = (node: SyntaxNode): { funcName: string | null; label: string } => {
let funcName: string | null = null;
let label = 'Function';
@@ -366,21 +366,40 @@ export const extractFunctionName = (node: any): { funcName: string | null; label
if (FUNCTION_DECLARATION_TYPES.has(node.type)) {
// C/C++: function_definition -> [pointer_declarator ->] function_declarator -> qualified_identifier/identifier
// Unwrap pointer_declarator / reference_declarator wrappers to reach function_declarator
let declarator = node.childForFieldName?.('declarator') ||
node.children?.find((c: any) => c.type === 'function_declarator');
let declarator = node.childForFieldName?.('declarator');
if (!declarator) {
for (let i = 0; i < node.childCount; i++) {
const c = node.child(i);
if (c?.type === 'function_declarator') { declarator = c; break; }
}
}
while (declarator && (declarator.type === 'pointer_declarator' || declarator.type === 'reference_declarator')) {
declarator = declarator.childForFieldName?.('declarator') ||
declarator.children?.find((c: any) =>
c.type === 'function_declarator' || c.type === 'pointer_declarator' || c.type === 'reference_declarator');
let nextDeclarator = declarator.childForFieldName?.('declarator');
if (!nextDeclarator) {
for (let i = 0; i < declarator.childCount; i++) {
const c = declarator.child(i);
if (c?.type === 'function_declarator' || c?.type === 'pointer_declarator' || c?.type === 'reference_declarator') { nextDeclarator = c; break; }
}
}
declarator = nextDeclarator;
}
if (declarator) {
const innerDeclarator = declarator.childForFieldName?.('declarator') ||
declarator.children?.find((c: any) =>
c.type === 'qualified_identifier' || c.type === 'identifier' || c.type === 'parenthesized_declarator');
let innerDeclarator = declarator.childForFieldName?.('declarator');
if (!innerDeclarator) {
for (let i = 0; i < declarator.childCount; i++) {
const c = declarator.child(i);
if (c?.type === 'qualified_identifier' || c?.type === 'identifier' || c?.type === 'parenthesized_declarator') { innerDeclarator = c; break; }
}
}
if (innerDeclarator?.type === 'qualified_identifier') {
const nameNode = innerDeclarator.childForFieldName?.('name') ||
innerDeclarator.children?.find((c: any) => c.type === 'identifier');
let nameNode = innerDeclarator.childForFieldName?.('name');
if (!nameNode) {
for (let i = 0; i < innerDeclarator.childCount; i++) {
const c = innerDeclarator.child(i);
if (c?.type === 'identifier') { nameNode = c; break; }
}
}
if (nameNode?.text) {
funcName = nameNode.text;
label = 'Method';
@@ -388,11 +407,19 @@ export const extractFunctionName = (node: any): { funcName: string | null; label
} else if (innerDeclarator?.type === 'identifier') {
funcName = innerDeclarator.text;
} else if (innerDeclarator?.type === 'parenthesized_declarator') {
const nestedId = innerDeclarator.children?.find((c: any) =>
c.type === 'qualified_identifier' || c.type === 'identifier');
let nestedId: SyntaxNode | null = null;
for (let i = 0; i < innerDeclarator.childCount; i++) {
const c = innerDeclarator.child(i);
if (c?.type === 'qualified_identifier' || c?.type === 'identifier') { nestedId = c; break; }
}
if (nestedId?.type === 'qualified_identifier') {
const nameNode = nestedId.childForFieldName?.('name') ||
nestedId.children?.find((c: any) => c.type === 'identifier');
let nameNode = nestedId.childForFieldName?.('name');
if (!nameNode) {
for (let i = 0; i < nestedId.childCount; i++) {
const c = nestedId.child(i);
if (c?.type === 'identifier') { nameNode = c; break; }
}
}
if (nameNode?.text) {
funcName = nameNode.text;
label = 'Method';
@@ -405,38 +432,72 @@ export const extractFunctionName = (node: any): { funcName: string | null; label
// Fallback for other languages (Kotlin uses simple_identifier, Swift uses simple_identifier)
if (!funcName) {
const nameNode = node.childForFieldName?.('name') ||
node.children?.find((c: any) => c.type === 'identifier' || c.type === 'property_identifier' || c.type === 'simple_identifier');
let nameNode = node.childForFieldName?.('name');
if (!nameNode) {
for (let i = 0; i < node.childCount; i++) {
const c = node.child(i);
if (c?.type === 'identifier' || c?.type === 'property_identifier' || c?.type === 'simple_identifier') { nameNode = c; break; }
}
}
funcName = nameNode?.text;
}
} else if (node.type === 'impl_item') {
const funcItem = node.children?.find((c: any) => c.type === 'function_item');
let funcItem: SyntaxNode | null = null;
for (let i = 0; i < node.childCount; i++) {
const c = node.child(i);
if (c?.type === 'function_item') { funcItem = c; break; }
}
if (funcItem) {
const nameNode = funcItem.childForFieldName?.('name') ||
funcItem.children?.find((c: any) => c.type === 'identifier');
let nameNode = funcItem.childForFieldName?.('name');
if (!nameNode) {
for (let i = 0; i < funcItem.childCount; i++) {
const c = funcItem.child(i);
if (c?.type === 'identifier') { nameNode = c; break; }
}
}
funcName = nameNode?.text;
label = 'Method';
}
} else if (node.type === 'method_definition') {
const nameNode = node.childForFieldName?.('name') ||
node.children?.find((c: any) => c.type === 'property_identifier');
let nameNode = node.childForFieldName?.('name');
if (!nameNode) {
for (let i = 0; i < node.childCount; i++) {
const c = node.child(i);
if (c?.type === 'property_identifier') { nameNode = c; break; }
}
}
funcName = nameNode?.text;
label = 'Method';
} else if (node.type === 'method_declaration' || node.type === 'constructor_declaration') {
const nameNode = node.childForFieldName?.('name') ||
node.children?.find((c: any) => c.type === 'identifier');
let nameNode = node.childForFieldName?.('name');
if (!nameNode) {
for (let i = 0; i < node.childCount; i++) {
const c = node.child(i);
if (c?.type === 'identifier') { nameNode = c; break; }
}
}
funcName = nameNode?.text;
label = 'Method';
} else if (node.type === 'arrow_function' || node.type === 'function_expression') {
const parent = node.parent;
if (parent?.type === 'variable_declarator') {
const nameNode = parent.childForFieldName?.('name') ||
parent.children?.find((c: any) => c.type === 'identifier');
let nameNode = parent.childForFieldName?.('name');
if (!nameNode) {
for (let i = 0; i < parent.childCount; i++) {
const c = parent.child(i);
if (c?.type === 'identifier') { nameNode = c; break; }
}
}
funcName = nameNode?.text;
}
} else if (node.type === 'method' || node.type === 'singleton_method') {
const nameNode = node.childForFieldName?.('name') ||
node.children?.find((c: any) => c.type === 'identifier');
let nameNode = node.childForFieldName?.('name');
if (!nameNode) {
for (let i = 0; i < node.childCount; i++) {
const c = node.child(i);
if (c?.type === 'identifier') { nameNode = c; break; }
}
}
funcName = nameNode?.text;
label = 'Method';
}
@@ -1115,10 +1115,12 @@ const processFileGroup = (
returnType = sig.returnType;
// Language-specific return type fallback (e.g. Ruby YARD @return [Type])
if (!returnType && definitionNode) {
// Also upgrades uninformative AST types like PHP `array` with PHPDoc `@return User[]`
if ((!returnType || returnType === 'array' || returnType === 'iterable') && definitionNode) {
const tc = typeConfigs[language as keyof typeof typeConfigs];
if (tc?.extractReturnType) {
returnType = tc.extractReturnType(definitionNode);
const docReturn = tc.extractReturnType(definitionNode);
if (docReturn) returnType = docReturn;
}
}
}
+33 -2
View File
@@ -47,7 +47,7 @@ export const VALID_NODE_LABELS = new Set([
]);
/** Valid relation types for impact analysis filtering */
export const VALID_RELATION_TYPES = new Set(['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS']);
export const VALID_RELATION_TYPES = new Set(['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'OVERRIDES']);
/** Regex to detect write operations in user-supplied Cypher queries */
export const CYPHER_WRITE_RE = /\b(CREATE|DELETE|SET|MERGE|REMOVE|DROP|ALTER|COPY|DETACH)\b/i;
@@ -1329,6 +1329,29 @@ export class LocalBackend {
relationTypes?: string[];
includeTests?: boolean;
minConfidence?: number;
}): Promise<any> {
try {
return await this._impactImpl(repo, params);
} catch (err: any) {
// Return structured error instead of crashing (#321)
return {
error: (err instanceof Error ? err.message : String(err)) || 'Impact analysis failed',
target: { name: params.target },
direction: params.direction,
impactedCount: 0,
risk: 'UNKNOWN',
suggestion: 'The graph query failed — try gitnexus context <symbol> as a fallback',
};
}
}
private async _impactImpl(repo: RepoHandle, params: {
target: string;
direction: 'upstream' | 'downstream';
maxDepth?: number;
relationTypes?: string[];
includeTests?: boolean;
minConfidence?: number;
}): Promise<any> {
await this.ensureInitialized(repo.id);
@@ -1358,6 +1381,7 @@ export class LocalBackend {
const impacted: any[] = [];
const visited = new Set<string>([symId]);
let frontier = [symId];
let traversalComplete = true;
for (let depth = 1; depth <= maxDepth && frontier.length > 0; depth++) {
const nextFrontier: string[] = [];
@@ -1391,7 +1415,13 @@ export class LocalBackend {
});
}
}
} catch (e) { logQueryError('impact:depth-traversal', e); }
} catch (e) {
logQueryError('impact:depth-traversal', e);
// Break out of depth loop on query failure but return partial results
// collected so far, rather than silently swallowing the error (#321)
traversalComplete = false;
break;
}
frontier = nextFrontier;
}
@@ -1474,6 +1504,7 @@ export class LocalBackend {
direction,
impactedCount: impacted.length,
risk,
...(!traversalComplete && { partial: true }),
summary: {
direct: directCount,
processes_affected: processCount,
@@ -0,0 +1,7 @@
#include "service.h"
#include "repo.h"
void processUser() {
UserService svc;
svc.getUser().save();
}
@@ -0,0 +1,6 @@
#pragma once
class Repo {
public:
bool save() { return true; }
};
@@ -0,0 +1,7 @@
#pragma once
#include "user.h"
class UserService {
public:
User getUser() { return User(); }
};
@@ -0,0 +1,15 @@
#include "User.h"
#include "Repo.h"
#include <vector>
void processUsers(std::vector<User>* usersPtr) {
for (auto& user : *usersPtr) {
user.save();
}
}
void processRepos(std::vector<Repo>* reposPtr) {
for (const auto& repo : *reposPtr) {
repo.save();
}
}
@@ -0,0 +1,10 @@
#pragma once
#include <string>
class Repo {
public:
Repo(const std::string& name) : name_(name) {}
void save() {}
private:
std::string name_;
};
@@ -0,0 +1,10 @@
#pragma once
#include <string>
class User {
public:
User(const std::string& name) : name_(name) {}
void save() {}
private:
std::string name_;
};
@@ -0,0 +1,15 @@
#include "User.h"
#include "Repo.h"
#include <vector>
void processUsers(const std::vector<User>& users) {
for (auto& user : users) {
user.save();
}
}
void processRepos(const std::vector<Repo>& repos) {
for (const auto& repo : repos) {
repo.save();
}
}
@@ -0,0 +1,10 @@
#pragma once
#include <string>
class Repo {
public:
Repo(const std::string& name) : name_(name) {}
void save() {}
private:
std::string name_;
};
@@ -0,0 +1,10 @@
#pragma once
#include <string>
class User {
public:
User(const std::string& name) : name_(name) {}
void save() {}
private:
std::string name_;
};
@@ -1,8 +0,0 @@
#include "user.h"
#include <vector>
void processUsers(std::vector<User> users) {
for (User& user : users) {
user.save();
}
}
@@ -0,0 +1,17 @@
#include "User.h"
#include "Repo.h"
#include <map>
#include <string>
#include <vector>
void processUserMap(std::map<std::string, User> userMap) {
for (auto& [key, user] : userMap) {
user.save();
}
}
void processRepoMap(std::map<std::string, Repo> repoMap) {
for (const auto& [key, repo] : repoMap) {
repo.save();
}
}
@@ -0,0 +1,10 @@
#pragma once
#include <string>
class Repo {
public:
Repo(const std::string& name) : name_(name) {}
void save() {}
private:
std::string name_;
};
@@ -0,0 +1,10 @@
#pragma once
#include <string>
class User {
public:
User(const std::string& name) : name_(name) {}
void save() {}
private:
std::string name_;
};
@@ -0,0 +1,9 @@
namespace ChainCall.Models;
public class Repo
{
public bool Save()
{
return true;
}
}
@@ -0,0 +1,9 @@
namespace ChainCall.Models;
public class User
{
public bool Save()
{
return true;
}
}
@@ -0,0 +1,10 @@
using ChainCall.Services;
public class App
{
public void ProcessUser()
{
var svc = new UserService();
svc.GetUser().Save();
}
}
@@ -0,0 +1,11 @@
using ChainCall.Models;
namespace ChainCall.Services;
public class UserService
{
public User GetUser()
{
return new User();
}
}
@@ -0,0 +1,15 @@
using System.Collections.Generic;
public class App {
public void ProcessValues(Dictionary<string, User> data) {
foreach (var user in data.Values) {
user.Save();
}
}
public void ProcessList(List<User> users) {
foreach (var user in users) {
user.Save();
}
}
}
@@ -0,0 +1,4 @@
public class Repo {
public string Name { get; set; }
public void Save() {}
}
@@ -0,0 +1,4 @@
public class User {
public string Name { get; set; }
public void Save() {}
}
@@ -0,0 +1,17 @@
using System.Collections.Generic;
public class App {
private Dictionary<string, User> data;
public void ProcessValues() {
foreach (var user in this.data.Values) {
user.Save();
}
}
public void ProcessKeys() {
foreach (var key in this.data.Keys) {
key.ToString();
}
}
}
@@ -0,0 +1,4 @@
public class Repo {
public string Name { get; set; }
public void Save() {}
}
@@ -0,0 +1,4 @@
public class User {
public string Name { get; set; }
public void Save() {}
}
@@ -0,0 +1,7 @@
namespace Models;
public class Repo
{
public string Name { get; set; } = "";
public bool Save() { return true; }
}
@@ -0,0 +1,7 @@
namespace Models;
public class User
{
public string Name { get; set; } = "";
public bool Save() { return true; }
}
@@ -0,0 +1,20 @@
using Models;
namespace App;
public class AppService
{
public void ProcessWithRecursivePattern(object obj)
{
if (obj is User { Name: "Alice" } u)
{
u.Save();
}
var result = obj switch
{
Repo { Name: "main" } r => r.Save(),
_ => false
};
}
}
@@ -0,0 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
</Project>
@@ -0,0 +1,6 @@
namespace Models;
public class Repo
{
public bool Save() { return false; }
}
@@ -0,0 +1,6 @@
namespace Models;
public class User
{
public bool Save() { return true; }
}
@@ -0,0 +1,21 @@
using Models;
namespace App;
public class AppService
{
public void Process(object obj)
{
if (obj is User user)
{
user.Save();
}
switch (obj)
{
case Repo repo:
repo.Save();
break;
}
}
}
@@ -0,0 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
</Project>
@@ -0,0 +1,6 @@
namespace Models;
public class Repo
{
public bool Save() { return false; }
}
@@ -0,0 +1,6 @@
namespace Models;
public class User
{
public bool Save() { return true; }
}
@@ -0,0 +1,29 @@
using Models;
using System.Collections.Generic;
namespace App;
public class AppService
{
public void ProcessUsers(List<User> users)
{
foreach (var user in users)
{
user.Save();
}
}
public void ProcessRepos(List<Repo> repos)
{
foreach (var repo in repos)
{
repo.Save();
}
}
public void Direct(User u, Repo r)
{
u.Save();
r.Save();
}
}
@@ -0,0 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
</Project>
@@ -0,0 +1,14 @@
package main
import "example.com/chaincall/models"
type UserService struct{}
func (s *UserService) GetUser() *models.User {
return &models.User{Name: "alice"}
}
func processUser() {
svc := &UserService{}
svc.GetUser().Save()
}
@@ -0,0 +1,3 @@
module example.com/chaincall
go 1.21
@@ -0,0 +1,9 @@
package models
type Repo struct {
Name string
}
func (r *Repo) Save() bool {
return true
}
@@ -0,0 +1,9 @@
package models
type User struct {
Name string
}
func (u *User) Save() bool {
return true
}
@@ -0,0 +1,17 @@
package main
import "example.com/for-call-expr/models"
func processUsers() {
for _, user := range models.GetUsers() {
user.Save()
}
}
func processRepos() {
for _, repo := range models.GetRepos() {
repo.Save()
}
}
func main() {}
@@ -0,0 +1,3 @@
module example.com/for-call-expr
go 1.21
@@ -0,0 +1,13 @@
package models
type Repo struct {
Name string
}
func (r *Repo) Save() error {
return nil
}
func GetRepos() []Repo {
return []Repo{{Name: "main"}}
}
@@ -0,0 +1,13 @@
package models
type User struct {
Name string
}
func (u *User) Save() error {
return nil
}
func GetUsers() []User {
return []User{{Name: "alice"}}
}
@@ -0,0 +1,9 @@
package main
import "models"
func processMap(userMap map[string]models.User) {
for _, user := range userMap {
user.Save()
}
}
@@ -0,0 +1,7 @@
package models
type Repo struct {
Path string
}
func (r Repo) Save() {}
@@ -0,0 +1,7 @@
package models
type User struct {
Name string
}
func (u User) Save() {}
@@ -0,0 +1,16 @@
import models.User;
import models.Repo;
public class Main {
void processUsers() {
for (User user : User.getUsers()) {
user.save();
}
}
void processRepos() {
for (Repo repo : Repo.getRepos()) {
repo.save();
}
}
}
@@ -0,0 +1,17 @@
package models;
import java.util.List;
public class Repo {
private String name;
public Repo(String name) {
this.name = name;
}
public void save() {}
public static List<Repo> getRepos() {
return List.of(new Repo("main"));
}
}
@@ -0,0 +1,17 @@
package models;
import java.util.List;
public class User {
private String name;
public User(String name) {
this.name = name;
}
public void save() {}
public static List<User> getUsers() {
return List.of(new User("alice"));
}
}
@@ -0,0 +1,18 @@
package src;
import java.util.Map;
import java.util.List;
public class App {
public void processValues(Map<String, User> data) {
for (var user : data.values()) {
user.save();
}
}
public void processList(List<User> users) {
for (var user : users) {
user.save();
}
}
}
@@ -0,0 +1,7 @@
package src;
public class Repo {
private String name;
public Repo(String name) { this.name = name; }
public void save() {}
}
@@ -0,0 +1,7 @@
package src;
public class User {
private String name;
public User(String name) { this.name = name; }
public void save() {}
}
@@ -0,0 +1,19 @@
import models.User;
import models.Repo;
public class App {
public static void processAny(Object obj) {
switch (obj) {
case User user -> user.save();
case Repo repo -> repo.save();
default -> {}
}
}
public static void handleUser(Object obj) {
switch (obj) {
case User user -> user.save();
default -> {}
}
}
}
@@ -0,0 +1,5 @@
package models;
public class Repo {
public void save() {}
}
@@ -0,0 +1,5 @@
package models;
public class User {
public void save() {}
}
@@ -0,0 +1,8 @@
const { UserService } = require('./service');
function processUser() {
const svc = new UserService();
svc.getUser().save();
}
module.exports = { processUser };
@@ -0,0 +1,7 @@
class Repo {
save() {
return true;
}
}
module.exports = { Repo };
@@ -0,0 +1,12 @@
const { User } = require('./user');
class UserService {
/**
* @returns {User}
*/
getUser() {
return new User();
}
}
module.exports = { UserService };
@@ -0,0 +1,7 @@
class User {
save() {
return true;
}
}
module.exports = { User };
@@ -0,0 +1,16 @@
import models.getUsers
import models.getRepos
fun processUsers() {
for (user in getUsers()) {
user.save()
}
}
fun processRepos() {
for (repo in getRepos()) {
repo.save()
}
}
fun main() {}

Some files were not shown because too many files have changed in this diff Show More