Compare commits

...
39 Commits
Author SHA1 Message Date
Gergo Magyar 58f67d07f7 fix(ci): grant actions:read to publish workflow for pr-report job 2026-03-19 13:07:48 +00:00
Gergo Magyar a4863605e1 chore: bump version to 1.4.7 and update CHANGELOG 2026-03-19 12:50:24 +00:00
Gergő Magyar fc58c415f7 Merge pull request #379 from abhigyanpatwari/feat/phase9-call-result-binding
feat: Phase 9 — Return-type-aware variable binding with unified fixpoint chain propagation
2026-03-19 12:43:45 +00:00
Gergo Magyar 790d1d5b0f refactor: update Type Resolution Roadmap to clarify phases and principles for static analysis evolution 2026-03-19 12:39:19 +00:00
Gergo Magyar 8273324f3c fix: address PR review — Rust await unwrap, stale doc claims, this-receiver footnote
Review follow-ups from compiler front-end review (#379):

- Rust extractPendingAssignment now calls unwrapAwait() on value before
  type checks, so `let user = get_user().await` resolves correctly
- type-resolution-system.md: removed "no fixpoint inference" from
  limitations, updated "Single-pass" to "Walk + fixpoint", replaced
  stale single-pass Tier 2 description with fixpoint loop explanation
- type-resolution-roadmap.md: Phase 9 body updated — 9C is delivered,
  9B walk-order dependency documented (for-loop Tier 0b runs before
  fixpoint, so fixpoint-resolved types can't update loop variables)
- Added this/self/$this fixpoint gap footnote to feature matrix
2026-03-19 12:14:54 +00:00
Gergo Magyar 7b71b64427 fix(mcp): update tool descriptions for Phase 9C capabilities
- context tool: remove outdated "Phase 2" ACCESSES reference, document
  that CALLS edges resolve through field/method chains
- cypher tool: fix Property query example to use declaredType (not description)
- schema resource: add node_properties section documenting Method returnType,
  Property declaredType, Function parameterCount etc.
- schema resource: clarify ACCESSES edge read/write coverage
2026-03-19 11:59:10 +00:00
Gergo Magyar e6b8edc1ac feat: Phase 9C unified fixpoint with field access and method-call-result binding
Replace the sequential Tier 2b/2a propagation with a unified fixpoint
loop that handles four binding kinds: callResult, copy, fieldAccess,
and methodCallResult. The loop iterates until no new bindings are
produced (max 10 iterations), enabling arbitrary-depth mixed chains:

  const user = getUser();       // callResult → User
  const addr = user.address;    // fieldAccess → Address
  const city = addr.getCity();  // methodCallResult → City
  city.save();                  // resolves to City#save

Infrastructure:
- PendingAssignment union extended with fieldAccess and methodCallResult
- resolveFieldType helper: typeName → class nodeId → lookupFieldByOwner
- resolveMethodReturnType helper: typeName → class nodeId → lookupFuzzyCallable filtered by ownerId
- Fixpoint also resolves reverse-order copy chains that single-pass missed

Languages: TS, JS, Java, Kotlin, C#, Go, Rust, Python, PHP, Ruby, C++.
Each gets field access and/or method-call-with-receiver detection in
extractPendingAssignment, plus method-chain-binding test fixtures.
2026-03-19 11:50:50 +00:00
Gergo Magyar 5769872b70 feat: Phase 9 call-result variable binding across 11 languages
Activate the dormant Tier 2b pendingCallResults infrastructure in
type-env.ts by extending each language's extractPendingAssignment to
emit { kind: 'callResult', lhs, callee } when the RHS of an untyped
variable declaration is a simple function call.

This enables `var user = getUser(); user.save()` to resolve at TypeEnv
build time. Tier 2b now runs before Tier 2a copy-propagation, enabling
mixed chains like `const user = getUser(); const alias = user;
alias.save()`.

Languages: TS, JS, Java, Kotlin, C#, Go, Rust, Python, PHP, Ruby, C++.
Swift excluded. Each language gets a call-result-binding test fixture
and integration tests.

Conservative: only simple calls (no method calls with receivers), only
when exactly one callable matches, first-writer-wins.
2026-03-19 08:59:16 +00:00
60c93d7d4a feat: upgrade @ladybugdb/core to 0.15.2 and remove segfault workarounds (#374)
* feat: upgrade @ladybugdb/core to 0.15.2 and remove segfault workarounds

The upstream fix (ladybug-nodejs#1) resolves the child QueryResult lifetime
segfault, making .close() safe on all platforms. This removes 6 workaround
sites:

- Remove `dangerouslyIgnoreUnhandledErrors` from vitest config
- Remove platform-conditional .close() guards in global-setup and test helper
- Delete test/setup.ts (process._getActiveHandles unref hack)
- Replace no-op cleanup in test-indexed-db.ts with real adapter close
- Fix pool adapter closeOne() to properly close connections with shared
  Database refcount guard and orphaned connection handling in checkin()
- Update segfault-related comments across the codebase

Also bumps @ladybugdb/wasm-core to ^0.15.2 in gitnexus-web for consistency.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: keep dangerouslyIgnoreUnhandledErrors for macOS N-API exit crash

The N-API destructor ordering crash during worker fork exit on macOS is
independent of the QueryResult lifetime fix in 0.15.2. Tests pass, but
the exit triggers a crash. Keep the flag with an updated comment
explaining the actual cause. Can be removed once LadybugDB fixes all
destructor ordering issues upstream.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* ci: unify test run for single-pass coverage

- Update `npm test` to run all tests (unit + integration + lbug-db)
  via `vitest run` instead of `vitest run test/unit`
- Add `test:unit` script for running unit tests only
- Remove `ci-integration.yml` — the per-file lbug-db process isolation
  is no longer needed with `dangerouslyIgnoreUnhandledErrors` and
  `fileParallelism: false` handling fork exit issues
- Update `ci-unit-tests.yml` to run all tests with build + coverage
- Simplify `ci.yml` gate (two jobs: quality + tests)
- Simplify `ci-report.yml` (single coverage artifact, no merge step)

* fix: update cli-commands test for renamed test:all → test:unit script

* fix: set USERPROFILE in setup-skills test for Windows compatibility

os.homedir() checks USERPROFILE on Windows, not HOME.

* fix: add isolate: false to lbug-db project to prevent fork crashes

On macOS, N-API destructors crash fork workers on exit. With
isolate: true (default), vitest recycles the fork between files,
triggering the crash after each file. After several crashes, the
remaining lbug-db files never execute.

isolate: false keeps all 8 lbug-db files in a single fork — the
fork only exits once after all files complete, and that single exit
crash is caught by dangerouslyIgnoreUnhandledErrors.

* fix: add unique sequence.groupOrder to vitest projects

Vitest v4 requires unique groupOrder when projects have different
maxWorkers (lbug-db has fileParallelism: false → maxWorkers: 1).

* fix: await async close() in global-setup and remove isolate: false

global-setup.ts called conn.close() and db.close() without await —
these return Promise<void> in @ladybugdb/core 0.15.2.  The setup
function returned before the DB was fully closed, so vitest forks
hit a stale file lock when opening the same DB path, crashing the
lbug-db worker before any test ran.

isolate: false caused native state corruption after 2-3 open/close
cycles in the same fork (vitest-specific, not reproducible in plain
Node.js).  Without it, each file gets its own module scope and the
N-API destructor crash at fork exit is caught by
dangerouslyIgnoreUnhandledErrors.

Also fixes fire-and-forget close() calls in the pool adapter —
try/catch around an async close() never catches rejections; changed
to .catch(() => {}) for proper unhandled-rejection prevention.

Before: 0/8 lbug-db files ran on macOS CI (fork crash).
After:  8/8 pass, 84 files, 3077 tests, zero errors.

* fix: update project index references in AGENTS.md and CLAUDE.md to reflect correct symbol counts and relationships

* feat: enhance lbug adapter with external database support and write operation validation

* feat: create ci-tests workflow for comprehensive test coverage across platforms

* ci: move PR report inline to ci.yml, delete ci-report.yml

The old ci-report.yml used workflow_run which always runs code from
the default branch (main). This meant the PR comment used main's
stale report template that still referenced the old unit/integration
split architecture — causing "Merge coverage reports" failures.

Moving the report inline to ci.yml means it runs from the PR branch
and uses the current report template. The report now shows:
- per-platform status (Ubuntu/Windows/macOS columns)
- unified test counts from the single vitest run
- coverage with base branch (main) delta comparison
- commit SHA for traceability

Also removes the save-pr-meta job since the report no longer needs
a separate workflow_run trigger.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
2026-03-19 08:25:43 +00:00
Gergo Magyar 1e19986ef3 fix(tests): update property edge and write access expectations across multiple language tests 2026-03-18 22:25:30 +00:00
Gergő Magyar 973c7bfbf0 feat: ACCESSES edge type with read/write field access tracking (#372)
* feat: Phase 1 ACCESSES edge type — read tracking from chain resolution

Add ACCESSES relationship type to track field read access during call
chain resolution. When walkMixedChain resolves a field access (e.g.,
user.address.save()), an ACCESSES edge with reason 'read' is emitted
from the calling function to the Property node.

Schema: ACCESSES added to RelationshipType, REL_TYPES, VALID_RELATION_TYPES,
context queries, tools/resources descriptions. Excluded from default
impact BFS to prevent traversal explosion.

Implementation: resolveFieldAccessType now returns FieldResolution with
fieldNodeId. walkMixedChain accepts optional onFieldResolved callback.
makeAccessEmitter factory provides Set-based dedup per source node.

Bug fix: Added Java 'field_access' to FIELD_ACCESS_NODE_TYPES — was
missing, causing extractMixedChain to fail for Java member access.

* feat: Phase 2 ACCESSES write edges — assignment detection across 12 languages

Add tree-sitter query patterns for field write detection (obj.field = value)
across all supported languages: TS/JS, Python, Java, Go, C++, C#, Rust,
PHP, Ruby (setter syntax), Kotlin, Swift.

Processing: Sequential path handles assignment captures inline. Worker
path extracts ExtractedAssignment data for deferred resolution via new
processAssignmentsFromExtracted function.

Bug fix: Kotlin/Swift assignment queries used invalid navigation_expression
wrapper — fixed to match actual directly_assignable_expression AST structure.

Tests: Write access integration tests for TS, Java, Python, Go with
dedicated fixtures. All use strict toBe() assertions.

* test: add unit tests for call-routing, shared type extractors, and symbol-table branches

Add 215 new unit tests across 3 files to increase branch coverage toward
the 23% global threshold (was 21.49%):

- call-routing.test.ts (49 tests): Ruby call routing — require/require_relative,
  include/extend/prepend heritage, attr_accessor properties with YARD types
- shared-type-extractors.test.ts (108 tests): pure string functions —
  extractElementTypeFromString, stripNullable, extractReturnTypeName,
  methodToTypeArgPosition, getContainerDescriptor
- symbol-table.test.ts (+29 tests): Property/fieldByOwner index, metadata
  spread branches, lazy callable index, lookupExactFull shape

* fix: defer write-access resolution to fix Ruby cross-file property timing

Ruby attr_accessor properties are registered during processCalls (not
the parsing phase), so lookupFieldByOwner fails when service.rb is
processed before models.rb. Fix by collecting pending write-access
edges during the file loop and resolving them after all files are done.

Also adds write-access integration tests and fixtures for 7 languages
(C++, C#, JS, Kotlin, PHP, Ruby, Rust), Ruby compound assignment query,
PHP static property write query, and Kotlin property type extraction.

* fix: address PR #372 review — write-access constructor bindings parity and docs

- Add verified constructor bindings fallback to write-access resolution
  in both sequential path (receiverIndex lookup) and worker path
  (constructorBindings param for processAssignmentsFromExtracted),
  closing the read/write ACCESSES edge asymmetry for factory-returned
  receivers
- Clarify inner guard control flow comment in processCalls match loop
- Document Go inc_statement/dec_statement gap in roadmap
- Clarify PHP nullsafe write footnote (invalid syntax, not just untracked)
- Update symbol-table tests for intentional fieldByOwner behavior change
  (Properties without declaredType now indexed for dynamic language
  write-access tracking)
2026-03-19 03:48:04 +05:30
abhigyanpatwariandClaude Opus 4.6 c0b4098c4e fix(web): add missing Kotlin entries to Record<SupportedLanguages> maps
The SupportedLanguages enum includes Kotlin but the web project's
LANGUAGE_QUERIES and languageFileMap Records were missing it, breaking
the Vercel build with TS2741.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 02:17:03 +05:30
Gergő Magyar 11a3d0515c feat: Phase 8 field/property type resolution (#354)
* feat: Phase 8 field/property type resolution — resolve chained member access

Add field/property type extraction to the type resolution system so that
chained member access like `user.address.save()` resolves the intermediate
receiver type (`address → Address`) through Property symbols in SymbolTable.

Key changes:
- SymbolTable: add `declaredType` field, `fieldByOwner` O(1) index,
  `lookupFieldByOwner()` method, P0 conditional callableIndex invalidation,
  P2 exclude Properties from globalIndex to prevent namespace pollution
- tree-sitter queries: add `definition.property` for TypeScript, Java, Go
- parse-worker: extract declared types for Property nodes via
  `extractPropertyDeclaredType()`, capture field-access receiver info
- call-processor: add `resolveFieldAccessType()` helper and field-access
  branch in both sequential and worker receiver resolution paths
- Integration tests: new field-types test suite verifying end-to-end
  `user.address.save() → Address#save` resolution

* fix: Go tree-sitter query captures field_declaration not field_declaration_list

Post-review fix: the Go struct field query incorrectly put @definition.property
on field_declaration_list (the list container) instead of field_declaration
(the individual field). Also removed unused `language` parameter from
extractPropertyDeclaredType.

* feat: expand field-type tests to 6 languages, fix Go ownerId and Kotlin navigation_expression

- Add integration test fixtures for Java, C#, Go, Kotlin, PHP (alongside existing TS)
- Fix Go: add type_declaration handling in findEnclosingClassId for struct fields
  (field_declaration → field_declaration_list → struct_type → type_spec → type_declaration)
- Fix Kotlin: add navigation_expression handling in field-access resolution
  (Kotlin uses navigation_expression + navigation_suffix, not member_expression)
- Add extractMemberAccessParts helper in call-processor for cross-language member access
- All 24 field-type tests pass across 6 languages, 181 Go+Kotlin tests pass with no regressions

* refactor: split HAS_METHOD into HAS_METHOD + HAS_PROPERTY edge types

Property nodes now use HAS_PROPERTY edges instead of HAS_METHOD, giving
the graph schema proper semantic separation between methods and fields.

- HAS_METHOD: Method, Constructor, Function (when inside a class)
- HAS_PROPERTY: Property nodes (class fields, struct fields, attributes)

MRO processor only reads HAS_METHOD — properties correctly excluded from
method resolution order. Impact analysis accepts both edge types.

Updated 12 files: graph types, schema, tools docs, parse-worker,
parsing-processor, call-processor, and 6 test files.

* fix(test): update security test to expect 7 VALID_RELATION_TYPES (added HAS_PROPERTY)

* test: add unit tests for Phase 8 SymbolTable features (39 tests, up from 19)

Cover all new branches: declaredType metadata, Property exclusion from
globalIndex, conditional callableIndex invalidation, lookupFieldByOwner
(happy path + edge cases), lookupFuzzyCallable filtering, and clear()
with fieldByOwner. Fixes branch coverage threshold (21.8% → 23%+).

* feat: Phase 8B mixed field+method chain resolution, C++/Rust chain fixes

Unify field and method chain resolution into a single `extractMixedChain`
walker that handles interleaved patterns like `svc.getUser().address.save()`.
Fix C++ chain calls (tree-sitter-cpp `field_expression` uses `argument` not
`object`), Rust unit struct instantiation (`let svc = TypeName;`), and add
stdlib passthrough for `unwrap()`/`clone()`/`expect()` in chain loops.

Key changes:
- Replace `receiverCallChain` + `receiverFieldAccess` with unified
  `receiverMixedChain: MixedChainStep[]` on ExtractedCall
- Add `extractMixedChain` in utils.ts (handles both call_expression and
  field_expression nodes, including C++ `argument` field)
- Add `TYPE_PRESERVING_METHODS` set for stdlib identity operations
- Add C++ inline method double-indexing guard in parsing-processor.ts
  and parse-worker.ts
- Add Rust unit struct recognition in type-extractors/rust.ts
- Split field-types.test.ts into per-language test files
- Add ts-mixed-chain fixture and integration tests
- Resolve rust.test.ts todo: Option<T>.unwrap().save() now works
- Update roadmap: Phases 7+8 complete, Phase 9 is next

* fix: Python declaredType extraction and sequential-path property registration

- Move @definition.property capture from expression_statement to assignment
  node in Python queries so Strategy 1 childForFieldName('type') succeeds
- Pass item.declaredType through ctx.symbols.add in sequential call-processor
  path, matching worker path behavior (fixes Ruby YARD declaredType drop)
- Add Python chain resolution integration test (user.address.save → Address#save)
- Update Rust/Python status in roadmap and system docs to reflect actual coverage

* fix: Python/Ruby field type disambiguation and Rust chain test

Three fixes from PR #354 third review:

1. Python typed_parameter name extraction: tree-sitter-python's
   typed_parameter uses positional children for the name, not a named
   field. TypeEnv and extractParameter now fall back to firstNamedChild.

2. Ruby/Python call-step field resolution: Ruby's AST uses `call` nodes
   for both property access and method calls. The chain walker now tries
   resolveFieldAccessType before resolveCallTarget for call steps, so
   attr_accessor properties resolve via declaredType.

3. Rust chain resolution test: added missing integration test asserting
   user.address.save() resolves to Address#save.

Also splits C/C++ and TS/JS columns in type-resolution-system.md
language matrix with footnotes for accuracy.

1062 resolver integration tests passing, 0 failures.

* refactor: Phase 8 code review cleanup — extract walkMixedChain, fix MCP agent gaps

- Extract duplicated chain resolution loop into shared walkMixedChain() helper,
  eliminating ~60 lines of copy-pasted code between sequential and worker paths
- Add returnType to ResolveResult, removing redundant lookupFuzzy+find per chain step
- Fix context() tool to include HAS_METHOD, HAS_PROPERTY, OVERRIDES in queries
  so agents can discover class members
- Fix p.declaredType Cypher example (column doesn't exist) → p.description
- Add HAS_METHOD, HAS_PROPERTY, OVERRIDES to schema resource
- Document HAS_METHOD/HAS_PROPERTY in impact tool description
- Delete dead code extractMemberAccessParts (superseded by extractMixedChain)
- Replace any with SyntaxNode on extractPropertyDeclaredType
- Add Rust deep-field-chain test (5 tests), Java mixed-chain (4), Go mixed-chain (4)
- All 1075 tests pass (13 new, 0 regressions)

* refactor: type SymbolDefinition.type as NodeLabel, add O(1) receiver index

- Change SymbolDefinition.type from string to NodeLabel union (35 members)
  across symbol-table.ts, parse-worker.ts, parsing-processor.ts — compiler
  now enforces correctness at all comparison/assignment sites
- Replace O(N*M) linear scan in lookupReceiverType with pre-built
  ReceiverTypeIndex (Map<funcName, Map<varName, Entry>>) for O(1) lookups
  with proper ambiguity handling and file-level fallback
- All 1075 tests pass, 0 regressions

* fix: capture C++ pointer/ref fields, Kotlin data class props, PHP constructor promotion

Add tree-sitter query patterns for three previously missed property declaration
forms: C++ pointer/reference member fields (Address* addr; Address& ref;),
Kotlin primary constructor val/var parameters (data class User(val name: String)),
and PHP 8.0+ constructor property promotion (public Address $address).

Fix "10 languages" off-by-one in docs (Ruby is single-level only, not deep chain).
Update Python feature matrix cell from No* to Yes* after 31b95f0 fix.

11 new integration tests with per-language fixtures verify property capture,
HAS_PROPERTY edge emission, and field-access chain resolution.
2026-03-18 18:47:33 +00:00
Berk Demirci e0a6c40b45 Fix undefined parsing error on languages missing from call routers (#364)
* fix: mapping all supported languages to callRouters to fix undefined apply error (#352)

* fix(cli): remove duplicate Swift/Kotlin keys in callRouters causing TS1117

* refactor: remove runtime callRouter fallbacks, rely on Record<SupportedLanguages> compile-time enforcement

* refactor: use 'satisfies' keyword for callRouters compile-time enforcement
2026-03-18 14:52:54 +00:00
Chirag Nighutandchirag-nighut aa1bab597b feat: add Python enumerate() for-loop support with nested tuple patterns (#356)
- Handle `for i, k, v in enumerate(d.items())` — flat pattern
- Handle `for i, (k, v) in enumerate(d.items())` — nested tuple_pattern
- Handle `for (k, v) in enumerate(users)` — parenthesized tuple as top-level

Extract helper functions for cleaner code:
- `extractMethodCall()` — deduplicate method call parsing
- `collectPatternIdentifiers()` — recursively collect identifiers from patterns

Add unit tests for TypeEnv and integration tests verifying CALLS edges.

Made-with: Cursor

Co-authored-by: chirag-nighut <chiragnighut@gmail.com>
2026-03-18 13:05:10 +00:00
Hazem 60ede20a11 fix: MCP server crashes under parallel tool calls (#326) (#349)
* fix: MCP server crashes under parallel tool calls (#326)

* fix: ensure full connection pool is pre-created to avoid race conditions during query execution

* fix: improve graceful shutdown handling with exit codes

* fix: resolve critical concurrency bugs in connection pool init

- Add initPromises dedup map to prevent double-init race when parallel
  tool calls trigger initLbug for the same repoId simultaneously
- Move pool.set() after FTS load so concurrent checkout can't grab a
  connection mid-async-init (FTS race on available[0])
- Replace lazy createConnection growth path with integrity error — pool
  is pre-warmed, lazy creation would silence stdout during active queries
- Add preWarmActive flag so watchdog timer skips stdout restore during
  the synchronous pre-warm loop
- Unify stdout capture: server.ts imports realStdoutWrite from
  lbug-adapter instead of capturing its own copy

* test: add connection pool parallel stability tests

7 integration tests covering concurrent query safety, waiter queue
overflow, stdout.write restoration, connection leak detection, initLbug
deduplication, atomic pool visibility, and mixed query types.

* fix: run LadybugDB tests sequentially via vitest projects config

Vitest's projects feature splits test files into two groups: lbug-db
(fileParallelism: false) and default (parallel). This prevents native
mmap file-lock conflicts on Windows without requiring the CI shell loop
locally.

* test: add enrichment Promise.all regression test for #292/#316

Verifies that 3 concurrent queries via Promise.all (the exact pattern
from the impact command's enrichment phase at local-backend.ts:1415)
complete without SIGSEGV on a pre-warmed connection pool.
2026-03-18 13:02:01 +00:00
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
Gergő Magyar f2d3df48f6 feat: Phase 5 type resolution — chained calls, pattern matching, class-as-receiver (#315)
* feat: Phase 5 type resolution — chained calls, pattern matching, class-as-receiver, code review fixes

Phase 5.1: Chained method call resolution (depth-capped at 3)
- resolveChainedReceiver() resolves a.getUser().save() by walking the chain
  and looking up intermediate return types from the SymbolTable
- extractReceiverNode() + extractCallChain() shared in utils.ts
- receiverCallChain on ExtractedCall for worker path parity
- MAX_CHAIN_DEPTH=3 enforced in both extraction and resolution

Phase 5.2: Pattern matching binding extractors
- PatternBindingExtractor type added to LanguageTypeConfig
- declarationTypeNodes map tracks original type AST nodes for generic unwrapping
- Rust: if let Some(x)/Ok(x) unwrapping with extractGenericTypeArgs
- Java: instanceof pattern variables (Java 16+)
- C#: is-pattern disambiguation fixture (already working via extractDeclaration)

Phase 5.5d: Python standalone type annotations (name: str)
- expression_statement with type child now captured in DECLARATION_NODE_TYPES

Phase 5.5e: ReceiverKey collision fix for overloaded methods
- receiverKey preserves @startIndex to prevent same-name method collisions
- lookupReceiverType does prefix scan with ambiguity refusal

Class-as-receiver for static method calls (#289)
- UserService.find_user() now resolves via ctx.resolve() tiered lookup
- Respects import scoping — no false positives from unrelated packages

Code review fixes:
- Extracted CALL_EXPRESSION_TYPES + extractCallChain to utils.ts (eliminated duplication)
- Converted resolveChainedReceiver from recursion to loop (no exposed depth param)
- Added depth cap to extractReturnTypeName (defense against nested wrapper types)
- Replaced lookupFuzzy with ctx.resolve for class-as-receiver (architecturally consistent)

Closes #289

Test coverage: 6 new fixtures, 12+ new unit tests, 7 new integration test suites

* fix: Ruby chain calls, Rust Err(x) unwrap, Enum class-as-receiver (#315)

Address three per-language gaps identified in Phase 5 code review:

- Ruby: add `method`/`receiver` field fallbacks to extractCallChain
  (tree-sitter-ruby uses different field names than other grammars)
- Rust: handle `Err(e)` pattern binding via typeArgs[1] from Result<T,E>
- Enum: include Enum type in class-as-receiver filter (both paths)

Integration tests added for all three fixes.

* fix: chain base type resolution parity between serial and worker paths (#315)

- Worker path: add typeEnv.lookup for chain base receiver after extraction
  (typed parameters like `fn process(svc: &UserService)` were silently lost)
- Serial path: add ctx.resolve class-as-receiver fallback for chain base
  (class-name chains like `UserService.find_user().save()` failed)
- Fix misleading comment in parse-worker.ts that described unimplemented logic
- Integration tests: typed-parameter chain, static class-name chain

* fix: Kotlin chain call extraction, createClassNameLookup Enum/Struct (#315)

- Kotlin: extractCallChain now handles navigation_expression → navigation_suffix
  AST structure (Kotlin's call_expression has no 'function' field)
- createClassNameLookup: include Enum and Struct alongside Class for consistent
  constructor recognition in extractInitializer
- Integration test: kotlin-chain-call fixture verifying svc.getUser().save()
2026-03-16 19:38:09 +00:00
Gergő Magyar 5fa73bafdf feat: Phase 4 type resolution — nullable unwrapping, for-loop typing, assignment chains, code review fixes (#310)
* feat: Phase 4 type resolution — nullable unwrapping, for-loop typing, assignment chains, Kotlin return types

Phase 4.1: Nullable/optional chain unwrapping
- Add stripNullable utility in shared.ts for stripping nullable wrappers
- Apply in lookupInEnv to unwrap User | null → User, User? → User before receiver lookup
- Handles TS union, Kotlin/C#/Swift nullable suffix, Python Union[T, None], Rust Option<T>
- Enables receiver-type disambiguation through ?. optional chaining

Phase 4.2: For-loop element typing (Tier 0 — Java/C#/Kotlin)
- Add ForLoopExtractor type and forLoopNodeTypes to LanguageTypeConfig
- Java enhanced_for_statement, C# foreach_statement, Kotlin for_statement extractors
- Only explicit element types in AST (Tier 0); inference-based languages deferred

Phase 4.3: Assignment chain propagation (single-pass, depth-1)
- Add PendingAssignmentExtractor to LanguageTypeConfig with per-language implementations
- Handles TS/JS variable_declarator, Rust let_declaration, Python assignment,
  Go short_var_declaration, C# equals_value_clause, Java/Kotlin variable_declarator
- Single post-walk propagation pass (no fixpoint iteration per Sorbet/Pyright design)
- Resolves const b = a; b.save() when a has known type from Tier 0/1/1b

Phase 4.5: Kotlin return type extraction (bug fix)
- Fix extractMethodSignature to handle Kotlin user_type after function_value_parameters
- Remove lenient test assertions, add strict disambiguation proof

Integration tests across 10+ languages with competing same-name methods
and negative assertions proving disambiguation.

* fix: per-language assignment chain gaps from code review

- Kotlin: new extractKotlinPendingAssignment for property_declaration →
  variable_declaration AST (Java's variable_declarator doesn't exist in Kotlin)
- Go: handle var_spec (var b = u) alongside short_var_declaration (:=)
- PHP: add extractPendingAssignment for $alias = $user with $ prefix preserved

Integration tests added for all three languages with competing
same-name methods and negative disambiguation assertions.

* fix: code review fixes — DRY nullable keywords, avoid array allocations, clarify depth comment

Addresses findings from 6-agent code review on PR #310:

- Move stripNullable JSDoc to correct position (was orphaned above NULLABLE_KEYWORDS)
- DRY: reuse NULLABLE_KEYWORDS set in pipe-split filter instead of inline strings
- Replace node.children.find() with findChildByType/manual loops in jvm.ts,
  go.ts, csharp.ts to avoid unnecessary array allocations per tree-sitter call
- Clarify "depth-1" comment in type-env.ts: single-pass resolves multi-hop
  chains when forward-declared; reverse-order is depth-1 only
- Annotate extractGenericTypeArgs as Phase 5 infrastructure (zero production callers)
- Re-export PendingAssignmentExtractor from index.ts for API consistency
- Add explicit return undefined in Go extractPendingAssignment
- Remove redundant child.text === '=' check in Kotlin extractor

Test coverage:
- 20 new unit tests: stripNullable edge cases, per-language assignment chains,
  reverse-order depth limitation, nullable lookup resolution
- 15 new integration tests: multi-hop chains (a→b→c), nullable+chain combined
  (User|null + alias), Python User|None through stripNullable path
- 3 new fixtures: ts-multi-hop-chain, ts-nullable-chain, python-nullable-chain

* fix: third-pass review — walrus chain, scanner allocations, Kotlin variable_declaration, C# type guard

Addresses 4 new findings from third-pass CI review:

1. Python walrus operator (:=) now handled by extractPendingAssignment —
   named_expression nodes propagate alias chains alongside regular assignment
2. Scanner .namedChildren.find()/.some() in jvm.ts replaced with
   findChildByType() — consistent with 98daed4 code review fixes
3. Kotlin extractPendingAssignment extended to handle variable_declaration
   nodes in addition to property_declaration (function-local val/var)
4. C# extractPendingAssignment early-returns for is_pattern_expression and
   field_declaration nodes (never contain variable_declarator children)

Integration tests:
- Python: walrus chain (alias := u) with disambiguation (5 tests, 1 fixture)
- Kotlin: assignment chain with typed declarations (5 tests, 1 fixture)
- C#: assignment chain + is-pattern coexistence (6 tests, 1 fixture)
- Unit: Python walrus propagation (1 test)

* feat: nullable wrapper unwrapping + C++ assignment chains

Gaps 1, 2, 4 from code review — architectural changes to type resolution:

1. extractSimpleTypeName now unwraps nullable wrapper generics:
   - Optional<User> → "User" (Java), Option<User> → "User" (Rust),
     Maybe<User> → "User" (Kotlin Arrow/Haskell-style)
   - Containers (List, Map) and async wrappers (Promise, Future) are NOT
     unwrapped — methods are called on the container, not the inner type
   - Uses existing extractGenericTypeArgs (now production-active, was dead code)
   - NULLABLE_WRAPPER_TYPES set: Optional, Option, Maybe

2. C++ extractPendingAssignment added for auto alias chains:
   - auto alias = user; alias.save() now propagates User type
   - Handles pointer/reference declarators, auto/decltype(auto)

3. Updated existing Rust test: Option<User> parameter now correctly
   stores "User" instead of "Option" in TypeEnv

Integration tests with fixtures for Java Optional, Rust Option, C++ auto
chain. Full pipeline resolution marked .todo — requires call-processor
enhancement (TypeEnv stores correct types but call-processor needs
additional work to produce CALLS edges for these patterns).

Unit tests: 196 passed (7 new). Integration: all 9 languages green.

* fix: resolve .todo tests — stale dist/ was the root cause

The Rust Option<User> and C++ auto assignment chain integration tests
were marked .todo because the pipeline didn't produce CALLS edges.
Root cause: dist/ was compiled from pre-Phase 4 source and lacked:
- NULLABLE_WRAPPER_TYPES unwrapping in extractSimpleTypeName
- C++ extractPendingAssignment

After npm run build, all tests pass as real assertions:
- Rust: alias.save() resolves to User#save via Option<User> unwrap + chain
- C++: alias.save() and rAlias.save() resolve via auto assignment chain
  with correct disambiguation (User vs Repo)

Only remaining .todo: Rust user.unwrap().save() (Phase 5 — chained
return type inference, not a TypeEnv issue).
2026-03-16 15:21:54 +00:00
fbff6d08c0 feat(ingestion): respect .gitignore and .gitnexusignore during file discovery (#231)
* feat(ingestion): respect .gitignore and .gitnexusignore during file discovery

Add support for excluding files from indexing based on .gitignore and
.gitnexusignore patterns. Previously, GitNexus used only a hardcoded
ignore list, causing significant index pollution in repositories with
git-ignored directories containing code (e.g., Docker-mounted volumes).

Changes:
- Add `ignore` package for gitignore-spec pattern matching
- Add `loadIgnoreRules()` to parse .gitignore + .gitnexusignore
- Add `createIgnoreFilter()` returning glob-compatible IgnoreLike object
- Integrate filter into glob's `ignore` option for directory-level pruning
- Remove post-glob `.filter()` call (now handled during traversal)

The hardcoded DEFAULT_IGNORE_LIST remains as fallback for non-git repos.

Closes #228

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(ingestion): address review feedback on ignore filtering

- Distinguish ENOENT vs EACCES in loadIgnoreRules (warn on permission errors)
- Add GITNEXUS_NO_GITIGNORE env var to bypass .gitignore parsing
- Fix bare-name pattern matching in childrenIgnored (check both with/without trailing slash)
- Rename isIgnoredDirectory to isHardcodedIgnoredDirectory for clarity
- Add clarifying comments for design decisions (D2 negation, D3 dot:false redundancy)
- Add tests for bare-name patterns, file-glob patterns, EACCES handling, env var

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(ingestion): address second round of review feedback

- G1: Document GITNEXUS_NO_GITIGNORE in `analyze --help` and log when active
- G2: Add comment clarifying path-scurry POSIX normalization contract
- G3: Add IgnoreOptions interface — env var now falls back, callers can
  pass `noGitignore` explicitly for testability and future CLI flag
- G4: Add integration test verifying walkRepositoryPaths respects the env var

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(ingestion): gracefully skip files with unavailable tree-sitter grammars

Port unsupported language resilience from PR #301 by @jecanore.
- Make Kotlin import optional (like Swift) in parser-loader and parse-worker
- Add worker-local isLanguageAvailable() with filePath param for tsx distinction
- Track and log skipped files per language in both sequential and worker paths
- Add skippedLanguages to ParseWorkerResult for worker→main aggregation
- Add isLanguageAvailable unit tests

Refs: #301, #155, #228

Co-Authored-By: jecanore <juan@housingbase.io>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test(e2e): add ignore + language-skip end-to-end test with fixture repo

Add a fixture repo (test/fixtures/ignore-and-skip-repo/) with .gitignore,
.gitnexusignore, TypeScript source files, and a Swift file to exercise
all three features end-to-end:

- File discovery: verifies .gitignore excludes data/ and *.log,
  .gitnexusignore excludes vendor/, source files are discovered
- Parsing: verifies TypeScript files produce Function nodes and DEFINES
  relationships, Swift files are skipped gracefully when grammar is
  unavailable

Add the test to the standalone group in ci-integration.yml and coverage job.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(ci): move ignore-and-skip-e2e test to e2e group per review feedback

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(test): use temp directory instead of fixture for e2e ignore test

The fixture's .gitignore prevented data/seed.json and debug.log from
being committed — these files would be missing after checkout in CI.

Switch to creating the entire test structure in a temp directory via
beforeAll (matching filesystem-walker.test.ts pattern). This ensures
all files exist regardless of git ignore rules.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(test): correct graph API usage in e2e ignore test

Use graph.nodes property getter instead of graph.getNodes(), and check
Function node filePath instead of non-existent File nodes (File nodes
are created by processStructure, not processParsing).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ci: add workflows permission to ci-integration.yml

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ci: change workflows permission to write per review

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ci: move workflows permission from ci-integration.yml to ci.yml caller

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(ci): fix Claude workflows for fork PRs, remove misplaced workflows perm

Three issues prevented Claude from running on fork PRs:

1. claude-code-review.yml lacked workflows:write — push failed when
   fork PRs modify .github/workflows/ files
2. claude.yml had no fork PR support — checked out main and couldn't
   fetch the fork's branch from origin
3. Cleanup step unconditionally deleted branches even when push failed,
   breaking the concurrent claude.yml workflow

Also removes workflows:write from ci.yml's integration job — CI tests
don't need that permission. The permission belongs on the claude
workflows that push fork branches.

Changes:
- Add workflows:write to both claude workflow permissions blocks
- Add fork PR detection + branch push/cleanup to claude.yml
- Add step id to push-fork; cleanup only runs if push succeeded
- Pass branch names via env vars to prevent shell injection (security)
- Add concurrency groups to prevent race conditions between workflows
- Remove misplaced workflows:write from ci.yml integration job

* fix(ci): use GitHub API for fork branch refs instead of git push

GITHUB_TOKEN cannot have 'workflows' permission — it's only valid for
PATs and GitHub Apps. This means git push fails whenever a fork PR
modifies .github/workflows/ files.

Replace git push with the GitHub REST API (POST/PATCH /git/refs) to
create temporary branch refs. The API creates a pointer to the
already-existing PR head commit without triggering the workflow file
push protection. Similarly, cleanup uses DELETE /git/refs instead of
git push --delete.

Also removes the invalid 'workflows: write' from permissions blocks.

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: jecanore <juan@housingbase.io>
Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
2026-03-16 13:26:20 +00:00
Gergő Magyar 6c18ae08f7 feat: return type inference, doc-comment parsing, and per-language type extractors (#284)
* feat: Phase 3 — return type inference, generic args extraction, Ruby YARD type extractor

Three architectural improvements to the type resolution system:

1. Return type inference — wire extractMethodSignature returnType through
   SymbolDefinition into call-processor. When var = callee() and callee
   has a known return type, bind var to that type. Handles Promise<T>
   unwrapping, nullable stripping, pointer/reference removal.

2. Generic type argument extraction — new extractGenericTypeArgs() utility
   that extracts type parameters from List<User> → ['User']. Handles
   TS/Java/Kotlin/C#/Rust generic syntax. Building block for for-loop
   variable typing.

3. Ruby dedicated type extractor — replaces the stub with YARD annotation
   parsing (@param name [Type]), handling qualified types, nullable types,
   and singleton methods. Ruby now has real type resolution.

Unit tests: 127 → 192+ (type-env) + 65 (symbol-table, call-processor) + 18 (generics)
Integration tests: 8+ new test cases with fixtures across TS/Python/Go/Java/Ruby

* fix: Phase 3 gaps — WRAPPER_GENERICS correctness, Ruby :: qualifier, namespaced constructors

- Remove collection types (List, Array, Vec, Set) from WRAPPER_GENERICS to prevent
  false CALLS edges (e.g. List<User> no longer unwraps to User)
- Add :: qualifier handling in extractReturnTypeName for Ruby/C++/Rust namespaced types
- Add Ruby `constant` and `scope_resolution` node types to shared extractors
- Extract shared extractRubyConstructorAssignment helper (dedup type-env.ts + ruby.ts)
- Add integration tests for return type inference: Python, TypeScript, Go, Java, Ruby
- Add Ruby namespaced constructor fixture (Models::UserService.new)
- Add unit tests for collection reclassification and :: qualifiers

* feat: Phase 4 — CONSTRUCTOR_BINDING_SCANNERS for all languages + return type inference tests

Add CONSTRUCTOR_BINDING_SCANNERS for 6 missing languages, completing
return type inference coverage across all 11 supported languages:

- TypeScript/JS: variable_declarator with call_expression, unwraps await
- Go: short_var_declaration single-assignment (skips multi-return, new/make)
- Java: local_variable_declaration with `var` type + method_invocation
- C#: variable_declaration with implicit_type (var) + invocation_expression
- Rust: let_declaration without type annotation, handles mut_pattern
- PHP: assignment_expression with function_call_expression

Also adds property_identifier to extractSimpleTypeName for qualified
member calls (repo.getUser → getUser), fixing namespaced constructor
inference that was previously a known limitation.

Integration tests added for all 11 languages with correct label
assertions (Function vs Method per language's tree-sitter queries).

* refactor: merge CONSTRUCTOR_BINDING_SCANNERS into per-language LanguageTypeConfig

Eliminates the parallel dispatch map in type-env.ts by moving all 11
constructor binding scanners into their respective type-extractors/*.ts
files as `scanConstructorBinding` on LanguageTypeConfig.

- Add ConstructorBindingScanner type to types.ts
- Add shared helpers: hasTypeAnnotation, unwrapAwait, extractCalleeName
- Move scanners to typescript.ts, jvm.ts, python.ts, php.ts, go.ts,
  rust.ts, swift.ts, c-cpp.ts, csharp.ts, ruby.ts
- Fix `any` types in C# scanner → SyntaxNode | null
- Delete ~300 lines from type-env.ts (CONSTRUCTOR_BINDING_SCANNERS map)
- Update buildTypeEnv to use config.scanConstructorBinding

All 143 type-env unit tests and all 10 language integration suites pass.

* fix: remove unused import, fix any type in Java scanner, update stale comment

- Remove unused extractCalleeName import from jvm.ts
- Fix (c: any) → (c: SyntaxNode) in Java scanner
- Update stale CONSTRUCTOR_BINDING_SCANNERS reference in ruby.ts comment

* fix: C# and PHP return type inference — scanner fixes, method signature extraction, and cross-file resolution

Addresses code review findings on PR #284:

C# scanner (csharp.ts):
- Fix type node lookup: iterate children instead of childForFieldName('type')
  which returns undefined in tree-sitter-c-sharp
- Fix initializer lookup: handle direct invocation_expression children
  (no equals_value_clause wrapper in tree-sitter-c-sharp)

C# return type extraction (utils.ts):
- Add 'returns' field check to extractMethodSignature — tree-sitter-c-sharp
  uses 'returns', not 'type', for method return types

C# cross-file resolution (call-processor.ts + fixture):
- Add constructor binding verification to sequential processCalls path
  (was only in the worker processCallsFromExtracted path)
- Add ReturnType.csproj to csharp-return-type fixture
- Update fixture namespaces to use ReturnType.Models/ReturnType.Services
  prefix (matches real C# project conventions)

PHP scanner (php.ts):
- Extend scanConstructorBinding to handle member_call_expression
  ($this->getUser() patterns), not just function_call_expression

Shared (shared.ts):
- Add member_access_expression to extractSimpleTypeName qualified-names
  block (C# method calls like svc.GetUser())

Tests:
- Add Repo.cs/Repo.php disambiguation fixtures (two Save methods)
- Strengthen C# and PHP return type tests with hard disambiguation assertions
- Add C# scanner unit tests and return type extraction test

* feat: per-language ReturnTypeExtractor + doc-comment @param parsing for PHP, JS, Ruby

Add ReturnTypeExtractor to LanguageTypeConfig interface with implementations
for Ruby (YARD @return), PHP (PHPDoc @return), and JS/TS (JSDoc @returns).
The fallback is wired in both parsing-processor and parse-worker paths,
activating only when extractMethodSignature finds no AST-based return type.

Also add doc-comment @param type extraction for PHP and JS/TS, following
Ruby's existing collectYardParams pattern. This enables parameter.method()
resolution in loosely-typed codebases using PHPDoc @param or JSDoc @param.

Additional fixes from PR #284 code review:
- Go: add selector_expression + field_identifier to extractSimpleTypeName
  (enables package-qualified factory calls like models.NewUser())
- Ruby: broaden scanConstructorBinding to capture plain call assignments
  (user = get_user()) in addition to Class.new patterns
- Ruby: harden return-type fixture with disambiguation (two save methods)

Test coverage: +14 new integration tests across Go, Ruby, PHP, JS/TS

* fix: JSDoc async return type, PHP attribute walkers, and $this receiver disambiguation

Three fixes from fourth-pass code review on PR #284:

1. JSDoc `@returns {Promise<User>}` no longer stripped to `Promise` — extractReturnType
   now uses sanitizeReturnType (preserves generics) instead of normalizeJsDocType
   (which stripped them before extractReturnTypeName could unwrap WRAPPER_GENERICS).

2. PHP 8+ `#[Attribute]` and JS `@decorator` nodes no longer break doc-comment walkers.
   Both extractReturnType and collect*Params functions now skip attribute_list/decorator
   nodes instead of breaking on them as named siblings.

3. PHP `$this->method()` now provides receiverClassName for disambiguation.
   When two classes define the same method, the enclosing class narrows candidates
   via ownerId matching in call-processor, preventing false no-binding results.

* fix: sanitizeReturnType dot corruption, JS test assertions, Ruby constant receiver

- Remove redundant dot-path stripping from sanitizeReturnType that corrupted
  qualified names inside generics (e.g. Promise<models.User> → User>)
- Split JS async fixture into separate files and add negative assertions
  to properly verify disambiguation (mirroring PHP test pattern)
- Accept 'constant' node type in Ruby scanConstructorBinding for factory
  call assignments (SERVICE = build_service())
- Add 'constant' to SIMPLE_RECEIVER_TYPES so extractReceiverName handles
  Ruby constant receivers (SERVICE.process)

* fix: nested generic arg splitting, JS/Ruby test false positives

- Replace naive comma split in extractReturnTypeName with bracket-balanced
  extractFirstGenericArg so nested types like Future<Result<User, Error>>
  unwrap correctly instead of producing malformed "Result<User"
- Add CompletableFuture to WRAPPER_GENERICS for Java async unwrapping
- Split js-jsdoc-return-type fixture models.js into user.js/repo.js and
  add negative assertions to prove disambiguation (not just file match)
- Split ruby-constant-factory-call fixture into separate service files
  and add negative assertions against AdminService resolution

* fix: review findings — receiverClassName parity, Rust wrappers, Go multi-return, Kotlin/Swift qualified calls

P1: Sequential path now includes receiverClassName narrowing for PHP
$this->method() disambiguation (was missing vs worker path).

P2: Added Rc/Arc/Weak/MutexGuard/Cow + 6 more Rust Deref types to
WRAPPER_GENERICS (Box excluded — Java Swing collision). Extended
Kotlin/Swift scanners to handle navigation_expression callees.
Added Go multi-return support (user, err := f()) with blank/_/err/ok
guard + AST-level first-return extraction in extractMethodSignature.

P3: Extracted shared verifyConstructorBindings() eliminating 60 lines
of duplication between sequential and worker paths. Added return-type
inference integration tests for C++, Rust, Swift with competing
methods and negative disambiguation assertions.

* fix: Swift navigation_suffix unwrapping, Rust lifetime skipping, Kotlin disambiguation tests

- Swift scanConstructorBinding: handle tree-sitter wrapping qualified
  identifiers in navigation_suffix nodes
- Add extractFirstTypeArg to skip Rust lifetime parameters ('a, '_)
  when unwrapping wrapper generics like Ref<'_, User>
- Kotlin tests: add Repo class fixture with competing save() methods
  to prove disambiguation; assert no spurious edges on known gap
- Remove tree-sitter-kotlin from optionalDependencies (now regular dep)

* fix: C# null-conditional calls, Ruby YARD bracket-balanced split, PHPDoc alternate order, escapeValue hardening

- Add C# null-conditional call support (user?.Save()): tree-sitter query for
  conditional_access_expression, member_binding_expression in MEMBER_ACCESS_NODE_TYPES,
  receiver extraction via conditional_access_expression parent walk
- Fix Ruby YARD type parsing for nested generics (Hash<Symbol, User>): replace
  naive split(',') with bracket-balanced splitter respecting <> depth
- Add alternate YARD format (@param [Type] name) alongside standard (@param name [Type])
- Add alternate PHPDoc format (@param $name Type) alongside standard (@param Type $name)
- Harden escapeValue in kuzu-adapter.ts: escape \n and \r to prevent Cypher injection
- Integration tests: C# null-conditional fixture (5 tests), Ruby YARD generics fixture (6 tests)
- Unit tests: PHPDoc alternate order (2 tests), C# null-conditional call-form (updated)

* test: add Python static/classmethod integration tests (issue #289)

Verifies that classes using only @staticmethod/@classmethod have HAS_METHOD
edges connecting them to their child methods. This was the root cause of
issue #289 where context() and impact() returned empty for such classes.

Tests cover: HAS_METHOD edge emission, unique static method resolution
(create_user, delete_user), and ambiguous same-named method handling
(find_user on both UserService and AdminService — safely refused).

* fix: lbug batch escapeValue newline hardening, Rust ::default() scanner exclusion

- Apply \n/\r escaping to batch upsert escapeValue in lbug-adapter.ts:429
  (missed instance of the CREATE-path fix from ec4dca4)
- Exclude Rust ::default() from scanConstructorBinding to match
  extractInitializer behavior — avoids wasted cross-file lookups on
  the broadly-implemented Default trait
- Unit tests: 2 new scanner exclusion tests (::default and ::new)
- Integration tests: 6 new Rust ::default() constructor resolution tests
  with disambiguation fixture (User::default vs Repo::default)

* fix: C#/Rust async await unwrap, PHP backslash namespace, fallback escaping

- C# scanConstructorBinding: unwrap await_expression to find invocation_expression
  (var user = await svc.GetUserAsync() now produces constructor binding)
- Rust scanConstructorBinding: unwrap .await postfix via shared unwrapAwait helper
  (let user = get_user().await now produces constructor binding)
- extractReturnTypeName: handle PHP backslash namespace separator (\App\Models\User → User)
- fallbackRelationshipInserts: match batch escapeValue hardening with \n/\r escaping

Tests: 2 unit (type-env), 3 unit (call-processor), 7 integration (csharp+rust), 7 fixtures

* fix: C#/Rust async-binding test false positives — add competing types and negative assertions

C# fixture: add Order.cs with Order.Save(), change OrderService to return
Task<Order> via GetOrderAsync, add negative assertion proving user.Save()
does not resolve to Order#Save.

Rust fixture: split models.rs into user.rs/repo.rs, make process_user and
process_repo async fn, add bidirectional negative assertions proving no
cross-contamination between User#save and Repo#save.

* fix: C# async-binding broken assertion, bare wrapper type leak, JSDoc optional params

- Split Program.cs Main into ProcessUser/ProcessOrder so negative
  assertions use strict toBeUndefined() (matching Rust pattern)
- Guard bare wrapper types (Task, Promise, Option…) in
  extractReturnTypeName — return undefined instead of the wrapper name
- Update JSDOC_PARAM_RE to capture @param {Type} [optionalName] syntax

* fix: update symbol and relationship counts in documentation
2026-03-15 18:49:40 +00:00
Candido Sales GomesandClaude Opus 4.6 5a5850832c refactor: migrate from KuzuDB to LadybugDB v0.15 (#275)
* refactor: migrate from KuzuDB to LadybugDB v0.15

KuzuDB was archived (Apple acquisition, Oct 2025). LadybugDB is the
community fork with full API compatibility.

- Package swap: kuzu → @ladybugdb/core, kuzu-wasm → @ladybugdb/wasm-core
- Rename all internal paths: kuzu → lbug (adapters, schema, storage)
- Storage path: .gitnexus/kuzu → .gitnexus/lbug (with auto-cleanup)
- Add explicit VECTOR extension loading (required in v0.15)
- Update CI workflow, documentation, and all tests
- 1151 unit + 27 integration tests passing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address code review findings (P1-P3)

P1: Fix WASM adapter to use getAll() API, wire cleanupOldKuzuFiles
into analyze command, add symlink path traversal protection.
P2: Cache VECTOR extension load state, batch augmentation engine
queries (20→4), fix web getCopyQuery for multi-language tables,
fix stale KuzuDB references, correct brainstorm package names.
P3: Complete lbug-wasm.d.ts type declarations, batch semantic
search per-label, update stale BM25 comment.

* chore: remove outdated KuzuDB migration brainstorming document

* fix: load FTS extension in MCP pool adapter on init

The read-only pool adapter never loaded the FTS extension, so all
QUERY_FTS_INDEX calls failed silently. This broke search-pool and
augmentation integration tests, and caused empty results in the
web UI server mode.

* feat: implement shared Database caching and connection reference counting

* feat: enhance KuzuDB migration handling and status reporting

* fix: mock cleanupOldKuzuFiles in local backend callTool tests

* fix: update mock for cleanupOldKuzuFiles and adjust imports in callTool tests

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 15:53:01 +00:00
Gergő Magyar 62242d5f44 feat: TypeEnvironment API with constructor inference, self/this/super resolution (#274)
* feat(type-env): constructor-call type inference for TypeEnv (Phase 1)

Add extractInitializer as a Tier 1 fallback in buildTypeEnv: when a
declaration node has no explicit type annotation, infer the type from
constructor-call patterns (new X(), X::new(), X::default(), $x = new X()).

Languages covered: TypeScript/JS, Java (var), Rust, PHP, C++ (auto).
Python/Kotlin/Swift deferred — need symbol-table access to distinguish
class constructors from function calls.

Adds 20 new unit tests covering constructor inference, annotation
precedence, and known limitations across all supported languages.

* fix(type-env): class-aware constructor resolution, multi-declarator fix

- Add collectClassNames pre-scan: walks AST to build Set<string> of
  class/struct names defined in the file
- C++ extractInitializer uses classNames.has() to verify identifier is
  a known class before inferring (auto x = User() resolves, auto x =
  getUser() does not — no false positives)
- Add InitializerExtractor type that receives classNames parameter
- Fix env.size gating: always call extractInitializer when available,
  so mixed declarators like const a: A = x, b = new B() resolve both
- Add env.has() guard in Java extractInitializer to skip already-bound vars
- Document Rust new/default whitelist rationale
- Pin all test assertions, add mixed multi-declarator test case

* fix(type-env): resolve Self/self/static/parent to actual type names

- Rust: Self::new()/Self::default() resolves to enclosing impl type
- PHP: new self()/static() resolves to enclosing class, parent() to superclass
- Rust: Tier 0 annotation guard prevents overwrite by constructor inference
- Rust: mut_pattern handling in extractVarName for let mut bindings
- TS: fix misleading comment in extractInitializer
- 58 tests passing (3 new Self/self resolution tests)

* perf(type-env): single-pass AST walk with closure-scoped state

Refactors buildTypeEnv to use closures instead of passing mutable state
as parameters. classNames, env, and config are captured by the inner
walk and extractTypeBinding functions — no parameter mutation.

- Eliminates separate collectClassNames pre-scan (O(2n) → O(n))
- config looked up once per file instead of per-node
- 29 fewer lines

* feat(type-env): constructor-inferred type resolution for all languages

Add cross-file constructor type inference to the ingestion pipeline,
enabling receiver-type disambiguation for member calls like
`user.save()` when the variable is assigned from a constructor without
explicit type annotations.

Pipeline changes:
- Add extractInitializer to Python and Swift type extractors
- Add CONSTRUCTOR_BINDING_SCANNERS for Python, Swift, C/C++ in type-env
- Wire constructorBindings through parse-worker → parsing-processor →
  pipeline → processCallsFromExtracted
- Rewrite resolveCallTarget receiver-type filtering (step D) to use
  tiered import resolution (same-file → import-scoped → global) before
  falling back to fuzzy ownerId matching
- Use collectTieredCandidates for constructor binding verification
  instead of raw lookupFuzzy

Bug fixes:
- Fix C++ inline method query: @definition.method was captured on
  field_declaration_list instead of function_definition, causing wrong
  parameterCount for all inline class methods
- Fix parse-worker accumulated/flush results missing constructorBindings

CI changes:
- Add swift.test.ts to ci-integration pipeline group and coverage job
- Update ci-report to fetch base branch (main) coverage for delta
  reporting instead of showing config thresholds
- Add per-suite timing breakdown table (unit/integration/total)
- Add expandable skipped test details section

Tests: 288 passed, 4 skipped (swift — macOS only) across 10 languages
- 36 new constructor-inferred integration tests (4 per language)
- 10 fixture directories with cross-file constructor patterns
- TypeScript, JavaScript, Java, Kotlin, Python, PHP, Rust, Go, C++, Swift

* fix(type-extractors): add type assertion for LanguageTypeConfig

* feat(ruby): constructor-inferred type resolution and self-receiver mapping

Add Ruby User.new constructor binding scanner to type-env, enabling
receiver-type disambiguation for member calls like user.save vs repo.save.
Add self/this → enclosing class resolution in lookupTypeEnv so self.method()
calls resolve to the correct class even when the method name is ambiguous.

* docs: update README with constructor inference and self/this resolution details

* refactor(ingestion): unified ResolutionContext replaces fragmented map passing

Introduce createResolutionContext() as the single resolution API for all
processors. Eliminates duplicated tier-selection logic, fixes heritage
namedImportMap bug, and adds per-file resolution caching.

- NEW resolution-context.ts: closure-factory with resolve(), per-file cache,
  TIER_CONFIDENCE constant, and shared ResolutionTier type
- DELETE symbol-resolver.ts: zero production importers, logic now in
  resolution-context.ts
- call-processor: all functions take ctx instead of 6 separate maps,
  collectTieredCandidates removed (ctx.resolve replaces it),
  D4 redundant re-resolve eliminated
- heritage-processor: takes ctx, resolveHeritageId helper extracts
  repeated 14-line fallback pattern, namedImportMap now included
- import-processor: takes ctx, dead createImportMap/createPackageMap/
  createNamedImportMap factories removed
- pipeline: creates single ctx, wires onProgress to all processors,
  logs cache hit rate in dev mode
- Tier renamed: unique-global → global (honest about returning all candidates)
- Tests migrated: 1178 unit + 84 integration passing

* feat(type-env): self/this/super resolution, TypeEnvironment API, and review fixes

Add cross-language receiver keyword resolution:
- self/this/$this → enclosing class name via AST walk
- super/base/parent → parent class name via heritage AST extraction
  (8 grammar variants: TS/JS, Java, Python, Ruby, C#, PHP, Kotlin, C++, Swift)
- D-phase widening in resolveCallTarget for super→parent method dispatch

Introduce TypeEnvironment API replacing loose TypeEnvResult + lookupTypeEnv:
- buildTypeEnv() returns TypeEnvironment with .lookup() method
- Single-pass AST walk merges constructor binding scan (was separate traversal)
- ClassNameLookup type replaces over-broad ReadonlySet<string> facade
- Memoized class name lookups to avoid redundant SymbolTable scans

Code review fixes (6 agents, 11 findings):
- Replace ctx.resolve(name, '') hack with direct symbols.lookupFuzzy()
- Extract scope key helpers (extractFuncNameFromScope, receiverKey)
- Simplify D-phase from 5 steps to 4 with deduped typeNodeIds
- Remove C from CONSTRUCTOR_BINDING_SCANNERS (YAGNI — C has no constructors)
- Cache Map reuse in ResolutionContext to reduce GC pressure
- Remove unused TieredCandidates import

Integration tests for self/this, parent, and super resolution across all
12 supported languages with per-language fixture directories.

* fix(type-env): generic parent resolution, TS cast inference, C++ brace-init

Fix generic parent class breaking super resolution:
- extractParentClassFromNode now uses extractSimpleTypeName to strip
  generic params (Base<T> → Base) and qualified names (models.Model → Model)
- Affects TS, Java, Python, C# heritage extraction

Fix TypeScript new X() as T / new X()! missed inference:
- Unwrap as_expression and non_null_expression before checking for
  new_expression in extractInitializer

Fix C++ brace-init User{} missed inference:
- Handle compound_literal_expression with type_identifier child
  in extractInitializer

Clean up deprecated lookupTypeEnv:
- Remove standalone lookupTypeEnv export, migrate all callers to
  TypeEnvironment.lookup() method
- Update all 80+ test assertions to use the new API

Integration test fixtures added:
- typescript-cast-constructor-inference (new X() as T, new X()!)
- typescript/java/csharp/kotlin-generic-parent-resolution
- cpp-brace-init-inference (auto x = User{})

* fix(type-extractors): Go &User{}, TS double-cast, Swift .init inference

Fix Go pointer-to-struct literal not inferred:
- Unwrap unary_expression (address-of &) before composite_literal check
- user := &User{} now correctly infers type User

Fix TypeScript double-cast only unwrapping one level:
- Change if to while loop for nested as_expression/non_null_expression
- new User() as unknown as Admin now correctly infers type User

Fix Swift User.init(name:) explicit init call missed:
- Handle navigation_expression callee with .init suffix in extractInitializer

Integration test fixtures:
- go-pointer-constructor-inference (&User{}, &Repo{})
- typescript-double-cast-inference (as unknown as T)

* feat: Rust struct literal, Python qualified ctor, Go new(), Swift .init scanner

- Rust: handle struct_expression in extractInitializer (User { name: "alice" })
- Python: support attribute nodes in extractInitializer (models.User("alice"))
  and the cross-file scanner — extractSimpleTypeName handles qualified names
- Go: handle new(User) built-in in extractGoShortVarDeclaration
- Swift: extend CONSTRUCTOR_BINDING_SCANNERS to handle navigation_expression
  callee for User.init(name:) cross-file resolution

Unit tests: 87 → 96 (Rust struct literal, Go new(), Python qualified ctor,
Python scanner qualified, plus edge cases)
Integration tests: 4 new describe blocks with fixtures

* fix: Rust Self{} resolution, C++ scoped brace-init, PHP promotion params, Ruby constants

- Rust: resolve Self {} struct literal to enclosing impl type (was stored as "Self")
- C++: replace type_identifier guard with extractSimpleTypeName for compound_literal_expression,
  enabling ns::User{} scoped brace-init (closes previously deferred gap)
- PHP: add property_promotion_parameter to TYPED_PARAMETER_TYPES for PHP 8.0+
  constructor property promotion (__construct(private Foo $x))
- Ruby: extend extractRubyConstructorBinding to accept constant left-hand side
  (REPO = Repo.new)

Unit tests: 96 → 101 (+5: Rust Self{} ×2, C++ ns::User{} ×1, PHP promotion ×1,
Ruby constant ×1)
Integration tests: 4 new describe blocks with fixtures

* feat: Phase 1 type resolution gaps — walrus, PHP properties, nullable, Go make/assert

Phase 1 quick wins from the type resolution gap analysis:

1. Python walrus operator := (named_expression) — extractInitializer + scanner
2. PHP 7.4+ typed class properties — property_declaration in extractDeclaration
3. Nullable union unwrapping — User | null → User in extractSimpleTypeName
4. Go make() builtin — slice/map element type extraction
5. Go type assertions — iface.(User) type extraction

Also: PHP primitive_type handling in extractSimpleTypeName (string, int, etc.)

Unit tests: 101 → 114 (+13)
Integration tests: 8 new describe blocks with fixtures

* feat: Phase 2 type resolution gaps — C++ range-for, Rust if-let, C# pattern matching, Python class annotations

Phase 2 medium-effort improvements:

1. C++ range-for with explicit type — for (User& u : vec) binds u: User
2. Rust if-let/while-let captured_pattern — user @ User { .. } binds user: User
3. C# is-pattern matching — if (obj is User user) binds user: User
4. Python class-level annotations — confirmed already working, added tests

Unit tests: 114 → 127 (+13)
Integration tests: 11 new test cases with fixtures
2026-03-14 19:05:49 +00:00
Gergő Magyar 6e38db879e fix(ruby): method-level call resolution, HAS_METHOD edges, and dispatch table (#278)
* fix(ruby): method-level call resolution, HAS_METHOD edges, and dispatch table refactoring

- Replace all `if (language === Ruby)` checks in processors with a
  `callRouters` dispatch table in call-routing.ts (renamed from
  ruby-call-routing.ts to preserve git history)
- Add Ruby `method` and `singleton_method` to FUNCTION_NODE_TYPES so
  findEnclosingFunction produces Method-level CALLS sources
- Add Ruby `class` and `module` to CLASS_CONTAINER_TYPES for HAS_METHOD
  edge generation
- Add bare call capture via tree-sitter query `(body_statement (identifier))`
  for Ruby methods called without parentheses
- Add Ruby member call detection (`call` node with `receiver` field) to
  inferCallForm and extractReceiverName
- Wire resolveRubyImport into resolveLanguageImport
- Add 24 integration tests across 5 suites: heritage/properties, arity
  filtering, member calls, ambiguous disambiguation, local shadow
- Add ruby.test.ts to CI integration workflow

* fix(ruby): resolve 6 Ruby resolution gaps from PR review

- Fix singleton_method label mismatch: @definition.function → @definition.method
  so CALLS edges from `def self.foo` bodies get correct sourceId
- Add ownerId and HAS_METHOD edges to attr_* Property nodes by calling
  findEnclosingClassId in both parse-worker and call-processor property branches
- Distinguish include/extend/prepend heritage: add heritageKind to
  RubyHeritageItem, propagate through heritage pipeline as IMPLEMENTS reason
- Document bare call over-capture limitation in tree-sitter query comment
- Add bare `require` (non-relative) import test coverage
- Add prepend/extend test coverage with distinct Loggable/Cacheable modules

31 Ruby integration tests passing, no regressions in other language resolvers.

* fix(ruby): web package parity — heritage reasons, property HAS_METHOD, singleton_method label

- Web call-processor: use item.heritageKind as IMPLEMENTS reason instead of
  hardcoded 'trait-impl', add :${kind} suffix to edge ID for uniqueness
- Web call-processor: port findEnclosingClassId, add HAS_METHOD edges for
  attr_* Property nodes to match CLI fix
- Web call-processor: singleton_method label 'Function' → 'Method' to match
  CLI tree-sitter query fix
- CLI parse-worker: update stale ExtractedHeritage.kind JSDoc to include
  'include' | 'extend' | 'prepend'

* fix(web): add HAS_METHOD to RelationshipType union

Web package was missing HAS_METHOD in the RelationshipType union,
causing a type mismatch with the HAS_METHOD edges emitted by the
attr_* property fix in call-processor.ts.
2026-03-14 11:34:30 +00:00
Candido Sales Gomes 0999595444 feat(ruby): Add Ruby language support for CLI and web (#111) 2026-03-13 21:46:59 +00:00
Chirag Nighut 649ad80dbb fix(cli): dynamically discover and install agent skills (#270) 2026-03-13 19:09:03 +00:00
882 changed files with 37299 additions and 3655 deletions
-192
View File
@@ -1,192 +0,0 @@
name: Integration Tests
on:
workflow_call:
inputs:
collect-coverage:
description: 'Whether to run the coverage collection job (only needed for PR reports)'
required: false
default: true
type: boolean
jobs:
# ── Integration test matrix ─────────────────────────────────────────
# Each test-group runs on a SEPARATE runner per OS, giving full process
# isolation for the KuzuDB native C++ addon.
# 3 OS x 4 groups = 12 parallel jobs.
#
# Groups:
# kuzu-db — 7 files using withTestKuzuDB / kuzu-adapter (native addon)
# Each file runs as its own `vitest run` invocation for full
# process isolation. KuzuDB's native N-API addon registers
# persistent handles that prevent fork workers from exiting
# on Linux, and its C++ destructors segfault during
# process.exit(). Running each file in its own process lets
# the OS reclaim all resources cleanly.
# pipeline — 12 files: ingestion pipeline + csv + 9 resolver tests
# e2e — 2 files: child-process only (spawnSync), no in-process kuzu
# standalone — 4 files: pure logic, no kuzu, no child processes
test-matrix:
name: integration (${{ matrix.os }} / ${{ matrix.test-group }})
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
test-group: [kuzu-db, pipeline, e2e, standalone]
include:
- test-group: kuzu-db
# Marker — actual files are listed in the run step below
test-glob: ''
- test-group: pipeline
test-glob: >-
test/integration/pipeline.test.ts
test/integration/csv-pipeline.test.ts
test/integration/parsing.test.ts
test/integration/resolvers/typescript.test.ts
test/integration/resolvers/csharp.test.ts
test/integration/resolvers/cpp.test.ts
test/integration/resolvers/java.test.ts
test/integration/resolvers/python.test.ts
test/integration/resolvers/rust.test.ts
test/integration/resolvers/go.test.ts
test/integration/resolvers/kotlin.test.ts
test/integration/resolvers/php.test.ts
- test-group: e2e
test-glob: >-
test/integration/cli-e2e.test.ts
test/integration/hooks-e2e.test.ts
test/integration/skills-e2e.test.ts
- test-group: standalone
test-glob: >-
test/integration/filesystem-walker.test.ts
test/integration/enrichment.test.ts
test/integration/tree-sitter-languages.test.ts
test/integration/worker-pool.test.ts
runs-on: ${{ matrix.os }}
timeout-minutes: 25
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: ./.github/actions/setup-gitnexus
with:
build: 'true'
# kuzu-db: run each file in its own vitest process for full isolation.
# KuzuDB's native addon hangs fork workers on Linux — process isolation
# is the only reliable fix boundary.
- name: Run integration tests — kuzu-db (process-isolated)
if: matrix.test-group == 'kuzu-db'
working-directory: gitnexus
shell: bash
run: |
set -e
files=(
test/integration/kuzu-core-adapter.test.ts
test/integration/kuzu-pool.test.ts
test/integration/local-backend.test.ts
test/integration/local-backend-calltool.test.ts
test/integration/search-core.test.ts
test/integration/search-pool.test.ts
test/integration/augmentation.test.ts
)
exit_code=0
for f in "${files[@]}"; do
echo "::group::$f"
if ! npx vitest run --reporter=verbose --pool=forks "$f"; then
exit_code=1
echo "::error::Test file failed: $f"
fi
echo "::endgroup::"
done
exit $exit_code
# Non-kuzu groups: run all files in a single vitest invocation
- name: Run integration tests — ${{ matrix.test-group }}
if: matrix.test-group != 'kuzu-db'
shell: bash
env:
TEST_GLOB: ${{ matrix.test-glob }}
run: npx vitest run --reporter=verbose $TEST_GLOB
working-directory: gitnexus
# ── Coverage collection (ubuntu only) ─────────────────────────────────
# Runs non-kuzu integration tests with coverage enabled so the PR report
# can merge integration + unit coverage for a combined view.
# kuzu-db tests are excluded because each file must run in its own vitest
# process (native addon isolation) which prevents single-run coverage merge.
coverage:
name: integration (ubuntu / coverage)
if: inputs.collect-coverage
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: ./.github/actions/setup-gitnexus
with:
build: 'true'
- name: Run integration tests with coverage
working-directory: gitnexus
run: >-
npx vitest run
--reporter=default
--reporter=json
--outputFile=integration-results.json
--coverage
--coverage.reporter=json-summary
--coverage.reporter=json
--coverage.reporter=text
--coverage.thresholdAutoUpdate=false
--coverage.reportOnFailure=true
--coverage.thresholds.statements=0
--coverage.thresholds.branches=0
--coverage.thresholds.functions=0
--coverage.thresholds.lines=0
test/integration/pipeline.test.ts
test/integration/csv-pipeline.test.ts
test/integration/parsing.test.ts
test/integration/cli-e2e.test.ts
test/integration/hooks-e2e.test.ts
test/integration/filesystem-walker.test.ts
test/integration/enrichment.test.ts
test/integration/tree-sitter-languages.test.ts
test/integration/worker-pool.test.ts
test/integration/resolvers/typescript.test.ts
test/integration/resolvers/csharp.test.ts
test/integration/resolvers/cpp.test.ts
test/integration/resolvers/java.test.ts
test/integration/resolvers/python.test.ts
test/integration/resolvers/rust.test.ts
test/integration/resolvers/go.test.ts
test/integration/resolvers/kotlin.test.ts
test/integration/resolvers/php.test.ts
- name: Upload integration coverage
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: integration-reports
path: |
gitnexus/coverage/coverage-summary.json
gitnexus/coverage/coverage-final.json
gitnexus/integration-results.json
retention-days: 5
# ── Unified status gate ──────────────────────────────────────────────
# Branch protection should require THIS job, not the matrix jobs directly.
# ci.yml's needs.integration.result aggregates through this gate.
status:
name: integration (all groups)
needs: test-matrix
if: always()
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Check all matrix jobs passed
shell: bash
env:
RESULT: ${{ needs.test-matrix.result }}
run: |
if [[ "$RESULT" != "success" ]]; then
echo "::error::Integration matrix failed or cancelled: $RESULT"
exit 1
fi
-432
View File
@@ -1,432 +0,0 @@
name: CI Report
# Triggered after the CI workflow completes. Because workflow_run
# always runs code from the *default branch*, it receives a read/write
# GITHUB_TOKEN — even when the triggering PR comes from a fork.
on:
workflow_run:
workflows: ["CI"]
types: [completed]
permissions:
actions: read # needed to list/download workflow run artifacts
contents: read # needed for sparse checkout of vitest.config.ts
pull-requests: write # needed to post sticky PR comment
jobs:
pr-report:
name: PR Report
# Only run for pull-request CI runs
if: >-
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion != 'cancelled'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
# ── Download artifacts from the CI run ────────────────────────
- name: Download artifacts
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
with:
script: |
const fs = require('fs');
const path = require('path');
const runId = context.payload.workflow_run.id;
const allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: runId,
});
async function downloadArtifact(name, dest) {
const match = allArtifacts.data.artifacts.find(a => a.name === name);
if (!match) {
core.warning(`Artifact "${name}" not found`);
return false;
}
const zip = await github.rest.actions.downloadArtifact({
owner: context.repo.owner,
repo: context.repo.repo,
artifact_id: match.id,
archive_format: 'zip',
});
fs.mkdirSync(dest, { recursive: true });
fs.writeFileSync(path.join(dest, `${name}.zip`), Buffer.from(zip.data));
return true;
}
const temp = process.env.RUNNER_TEMP;
await downloadArtifact('pr-meta', path.join(temp, 'dl'));
await downloadArtifact('test-reports', path.join(temp, 'dl'));
await downloadArtifact('integration-reports', path.join(temp, 'dl'));
- name: Extract artifacts
shell: bash
run: |
cd "$RUNNER_TEMP/dl"
# Extract each artifact into its own directory to avoid filename collisions
for z in *.zip; do
[ -f "$z" ] || continue
name="${z%.zip}"
mkdir -p "$RUNNER_TEMP/artifacts/$name"
unzip -o "$z" -d "$RUNNER_TEMP/artifacts/$name"
done
- name: Read PR metadata
id: meta
shell: bash
run: |
DIR="$RUNNER_TEMP/artifacts/pr-meta"
if [ ! -f "$DIR/pr_number" ]; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "::warning::pr_number artifact missing — skipping report"
exit 0
fi
# Validate PR number is a positive integer (artifact comes from
# untrusted fork code, so treat contents defensively).
PR_NUM=$(cat "$DIR/pr_number" | tr -d '[:space:]')
if ! [[ "$PR_NUM" =~ ^[0-9]+$ ]]; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "::error::Invalid PR number in artifact: '$PR_NUM'"
exit 0
fi
echo "skip=false" >> "$GITHUB_OUTPUT"
echo "pr_number=$PR_NUM" >> "$GITHUB_OUTPUT"
# Validate job-result strings against known GitHub Actions values.
# Artifact contents come from the PR workflow (potentially untrusted
# fork code), so we whitelist to prevent newline injection into
# GITHUB_OUTPUT.
validate_result() {
local val
val=$(cat "$1" | tr -d '[:space:]')
case "$val" in
success|failure|cancelled|skipped) echo "$val" ;;
*) echo "unknown" ;;
esac
}
echo "quality=$(validate_result "$DIR/quality_result")" >> "$GITHUB_OUTPUT"
echo "unit=$(validate_result "$DIR/unit_result")" >> "$GITHUB_OUTPUT"
echo "integration=$(validate_result "$DIR/integration_result")" >> "$GITHUB_OUTPUT"
- name: Checkout (for vitest config)
if: steps.meta.outputs.skip != 'true'
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
sparse-checkout: gitnexus/vitest.config.ts
sparse-checkout-cone-mode: false
# ── Merge coverage from unit + integration ─────────────────────
- name: Setup Node.js
if: steps.meta.outputs.skip != 'true'
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20
- name: Install coverage merge tools
if: steps.meta.outputs.skip != 'true'
run: npm install --no-save istanbul-lib-coverage istanbul-lib-report istanbul-reports
- name: Merge coverage reports
if: steps.meta.outputs.skip != 'true'
id: coverage
shell: bash
run: |
DIR="$RUNNER_TEMP/artifacts"
UNIT_COV=$(find "$DIR/test-reports" -name "coverage-final.json" -type f 2>/dev/null | head -1)
INTEG_COV=$(find "$DIR/integration-reports" -name "coverage-final.json" -type f 2>/dev/null | head -1)
MERGED_DIR="$RUNNER_TEMP/merged-coverage"
mkdir -p "$MERGED_DIR"
if [ -n "$UNIT_COV" ] && [ -n "$INTEG_COV" ]; then
echo "has_merged=true" >> "$GITHUB_OUTPUT"
# Merge using Node.js + istanbul-lib-coverage.
# Paths are passed via env vars to avoid shell interpolation
# inside the script string.
UNIT_COV_PATH="$UNIT_COV" \
INTEG_COV_PATH="$INTEG_COV" \
MERGED_OUT_DIR="$MERGED_DIR" \
node -e "
const libCoverage = require('istanbul-lib-coverage');
const libReport = require('istanbul-lib-report');
const reports = require('istanbul-reports');
const fs = require('fs');
const map = libCoverage.createCoverageMap({});
map.merge(JSON.parse(fs.readFileSync(process.env.UNIT_COV_PATH, 'utf8')));
map.merge(JSON.parse(fs.readFileSync(process.env.INTEG_COV_PATH, 'utf8')));
const context = libReport.createContext({
coverageMap: map,
dir: process.env.MERGED_OUT_DIR,
});
reports.create('json-summary').execute(context);
console.log('Merged coverage written to ' + process.env.MERGED_OUT_DIR + '/coverage-summary.json');
"
elif [ -n "$UNIT_COV" ]; then
echo "has_merged=false" >> "$GITHUB_OUTPUT"
echo "::warning::Integration coverage not found — using unit coverage only"
else
echo "has_merged=false" >> "$GITHUB_OUTPUT"
echo "::warning::No coverage data found"
fi
- name: Build report
if: steps.meta.outputs.skip != 'true'
id: report
shell: bash
env:
QUALITY: ${{ steps.meta.outputs.quality }}
UNIT: ${{ steps.meta.outputs.unit }}
INTEG: ${{ steps.meta.outputs.integration }}
HAS_MERGED: ${{ steps.coverage.outputs.has_merged }}
RUN_URL: ${{ github.event.workflow_run.html_url }}
run: |
DIR="$RUNNER_TEMP/artifacts"
MERGED_DIR="$RUNNER_TEMP/merged-coverage"
# ── Helper: read coverage summary into prefixed vars ──
# Uses printf -v for safe variable assignment (no eval).
read_cov() {
local prefix=$1 file=$2
if [ -n "$file" ] && [ -f "$file" ]; then
local val
val=$(jq -r '.total.statements.pct // "N/A"' "$file" 2>/dev/null) || val="N/A"
printf -v "${prefix}_STMTS" '%s' "$val"
val=$(jq -r '.total.branches.pct // "N/A"' "$file" 2>/dev/null) || val="N/A"
printf -v "${prefix}_BRANCH" '%s' "$val"
val=$(jq -r '.total.functions.pct // "N/A"' "$file" 2>/dev/null) || val="N/A"
printf -v "${prefix}_FUNCS" '%s' "$val"
val=$(jq -r '.total.lines.pct // "N/A"' "$file" 2>/dev/null) || val="N/A"
printf -v "${prefix}_LINES" '%s' "$val"
val=$(jq -r '"\(.total.statements.covered)/\(.total.statements.total)"' "$file" 2>/dev/null) || val=""
printf -v "${prefix}_STMTS_COV" '%s' "$val"
val=$(jq -r '"\(.total.branches.covered)/\(.total.branches.total)"' "$file" 2>/dev/null) || val=""
printf -v "${prefix}_BRANCH_COV" '%s' "$val"
val=$(jq -r '"\(.total.functions.covered)/\(.total.functions.total)"' "$file" 2>/dev/null) || val=""
printf -v "${prefix}_FUNCS_COV" '%s' "$val"
val=$(jq -r '"\(.total.lines.covered)/\(.total.lines.total)"' "$file" 2>/dev/null) || val=""
printf -v "${prefix}_LINES_COV" '%s' "$val"
return 0
else
printf -v "${prefix}_STMTS" '%s' "N/A"
printf -v "${prefix}_BRANCH" '%s' "N/A"
printf -v "${prefix}_FUNCS" '%s' "N/A"
printf -v "${prefix}_LINES" '%s' "N/A"
printf -v "${prefix}_STMTS_COV" '%s' ""
printf -v "${prefix}_BRANCH_COV" '%s' ""
printf -v "${prefix}_FUNCS_COV" '%s' ""
printf -v "${prefix}_LINES_COV" '%s' ""
return 1
fi
}
# ── Read all three coverage reports ──
UNIT_SUMMARY=$(find "$DIR/test-reports" -name "coverage-summary.json" -type f 2>/dev/null | head -1)
INTEG_SUMMARY=$(find "$DIR/integration-reports" -name "coverage-summary.json" -type f 2>/dev/null | head -1)
MERGED_SUMMARY="$MERGED_DIR/coverage-summary.json"
read_cov "U" "$UNIT_SUMMARY"
HAS_UNIT=$?
read_cov "I" "$INTEG_SUMMARY"
HAS_INTEG=$?
read_cov "M" "$MERGED_SUMMARY"
# ── Locate test results (unit) ──
RESULTS_FILE=$(find "$DIR/test-reports" -name "test-results.json" -type f 2>/dev/null | head -1)
INTEG_RESULTS=$(find "$DIR/integration-reports" -name "integration-results.json" -type f 2>/dev/null | head -1)
if [ -n "$RESULTS_FILE" ]; then
U_TOTAL=$(jq -r '.numTotalTests' "$RESULTS_FILE" 2>/dev/null || echo 0)
U_PASSED=$(jq -r '.numPassedTests' "$RESULTS_FILE" 2>/dev/null || echo 0)
U_FAILED=$(jq -r '.numFailedTests' "$RESULTS_FILE" 2>/dev/null || echo 0)
U_SKIPPED=$(jq -r '.numPendingTests' "$RESULTS_FILE" 2>/dev/null || echo 0)
U_SUITES=$(jq -r '.numTotalTestSuites' "$RESULTS_FILE" 2>/dev/null || echo 0)
U_DURATION=$(jq -r '((.testResults | map(.endTime) | max) - (.startTime)) / 1000 | floor' "$RESULTS_FILE" 2>/dev/null || echo 0)
else
U_TOTAL=0; U_PASSED=0; U_FAILED=0; U_SKIPPED=0; U_SUITES=0; U_DURATION=0
fi
if [ -n "$INTEG_RESULTS" ]; then
I_TOTAL=$(jq -r '.numTotalTests' "$INTEG_RESULTS" 2>/dev/null || echo 0)
I_PASSED=$(jq -r '.numPassedTests' "$INTEG_RESULTS" 2>/dev/null || echo 0)
I_FAILED=$(jq -r '.numFailedTests' "$INTEG_RESULTS" 2>/dev/null || echo 0)
I_SKIPPED=$(jq -r '.numPendingTests' "$INTEG_RESULTS" 2>/dev/null || echo 0)
I_SUITES=$(jq -r '.numTotalTestSuites' "$INTEG_RESULTS" 2>/dev/null || echo 0)
I_DURATION=$(jq -r '((.testResults | map(.endTime) | max) - (.startTime)) / 1000 | floor' "$INTEG_RESULTS" 2>/dev/null || echo 0)
else
I_TOTAL=0; I_PASSED=0; I_FAILED=0; I_SKIPPED=0; I_SUITES=0; I_DURATION=0
fi
# ── Sum test results ──
TOTAL=$((U_TOTAL + I_TOTAL))
PASSED=$((U_PASSED + I_PASSED))
FAILED=$((U_FAILED + I_FAILED))
SKIPPED=$((U_SKIPPED + I_SKIPPED))
SUITES=$((U_SUITES + I_SUITES))
DURATION=$((U_DURATION + I_DURATION))
# ── Coverage thresholds (read from vitest.config.ts) ──
if [ -f gitnexus/vitest.config.ts ]; then
THRESH_STMTS=$(grep -oP 'statements:\s*\K[0-9]+' gitnexus/vitest.config.ts || echo 0)
THRESH_BRANCH=$(grep -oP 'branches:\s*\K[0-9]+' gitnexus/vitest.config.ts || echo 0)
THRESH_FUNCS=$(grep -oP 'functions:\s*\K[0-9]+' gitnexus/vitest.config.ts || echo 0)
THRESH_LINES=$(grep -oP 'lines:\s*\K[0-9]+' gitnexus/vitest.config.ts || echo 0)
else
THRESH_STMTS=0; THRESH_BRANCH=0; THRESH_FUNCS=0; THRESH_LINES=0
fi
# ── Status helpers ──
status_icon() {
case "$1" in
success) echo "✅" ;;
failure) echo "❌" ;;
cancelled) echo "⏭️" ;;
*) echo "❓" ;;
esac
}
cov_bar() {
local pct=$1 thresh=$2
if [ "$pct" = "N/A" ]; then echo "—"; return; fi
local filled
filled=$(awk "BEGIN { printf \"%d\", $pct / 5 }")
(( filled < 0 )) && filled=0
(( filled > 20 )) && filled=20
local empty=$((20 - filled))
local bar=""
for ((i=0; i<filled; i++)); do bar+="█"; done
for ((i=0; i<empty; i++)); do bar+="░"; done
if [ "$(awk "BEGIN { print ($pct >= $thresh) ? 1 : 0 }")" = "1" ]; then
echo "🟢 ${bar}"
else
echo "🔴 ${bar}"
fi
}
# ── Overall status ──
if [[ "$QUALITY" == "success" && "$UNIT" == "success" && "$INTEG" == "success" ]]; then
OVERALL="✅ **All checks passed**"
else
OVERALL="❌ **Some checks failed**"
fi
# ── Build markdown ──
{
echo "body<<GITNEXUS_CI_REPORT_EOF_7f3a"
echo "## CI Report"
echo ""
echo "${OVERALL}"
echo ""
echo "### Pipeline Status"
echo ""
echo "| Stage | Status | Details |"
echo "|-------|--------|---------|"
echo "| $(status_icon "$QUALITY") Typecheck | \`${QUALITY}\` | tsc --noEmit |"
echo "| $(status_icon "$UNIT") Unit Tests | \`${UNIT}\` | 3 platforms |"
echo "| $(status_icon "$INTEG") Integration | \`${INTEG}\` | 3 OS x 4 groups = 12 jobs |"
echo ""
if [ "$TOTAL" -gt 0 ] 2>/dev/null; then
echo "### Test Results"
echo ""
if [ "$FAILED" = "0" ]; then
echo "✅ **${PASSED}** passed"
else
echo "❌ **${FAILED}** failed / **${PASSED}** passed"
fi
if [ "$SKIPPED" != "0" ]; then
echo " · ${SKIPPED} skipped"
fi
echo " · ${SUITES} suites · ${TOTAL} total"
echo " · ⏱️ ${DURATION}s"
if [ "$I_TOTAL" -gt 0 ] 2>/dev/null; then
echo " · 📊 ${U_TOTAL} unit + ${I_TOTAL} integration"
fi
echo ""
fi
# ── Coverage table helper ──
cov_table() {
local label=$1 s=$2 b=$3 f=$4 l=$5 sc=$6 bc=$7 fc=$8 lc=$9
shift 9
local ts=$1 tb=$2 tf=$3 tl=$4
echo "#### ${label}"
echo ""
echo "| Metric | Coverage | Covered | Threshold | Status |"
echo "|--------|----------|---------|-----------|--------|"
echo "| Statements | **${s}%** | ${sc} | ${ts}% | $(cov_bar "$s" "$ts") |"
echo "| Branches | **${b}%** | ${bc} | ${tb}% | $(cov_bar "$b" "$tb") |"
echo "| Functions | **${f}%** | ${fc} | ${tf}% | $(cov_bar "$f" "$tf") |"
echo "| Lines | **${l}%** | ${lc} | ${tl}% | $(cov_bar "$l" "$tl") |"
echo ""
}
if [ "$M_STMTS" != "N/A" ]; then
echo "### Code Coverage"
echo ""
cov_table "Combined (Unit + Integration)" \
"$M_STMTS" "$M_BRANCH" "$M_FUNCS" "$M_LINES" \
"$M_STMTS_COV" "$M_BRANCH_COV" "$M_FUNCS_COV" "$M_LINES_COV" \
"$THRESH_STMTS" "$THRESH_BRANCH" "$THRESH_FUNCS" "$THRESH_LINES"
echo "<details>"
echo "<summary>Coverage breakdown by test suite</summary>"
echo ""
if [ "$U_STMTS" != "N/A" ]; then
cov_table "Unit Tests" \
"$U_STMTS" "$U_BRANCH" "$U_FUNCS" "$U_LINES" \
"$U_STMTS_COV" "$U_BRANCH_COV" "$U_FUNCS_COV" "$U_LINES_COV" \
"$THRESH_STMTS" "$THRESH_BRANCH" "$THRESH_FUNCS" "$THRESH_LINES"
fi
if [ "$I_STMTS" != "N/A" ]; then
cov_table "Integration Tests" \
"$I_STMTS" "$I_BRANCH" "$I_FUNCS" "$I_LINES" \
"$I_STMTS_COV" "$I_BRANCH_COV" "$I_FUNCS_COV" "$I_LINES_COV" \
"$THRESH_STMTS" "$THRESH_BRANCH" "$THRESH_FUNCS" "$THRESH_LINES"
fi
echo "</details>"
echo ""
echo "<details>"
echo "<summary>Coverage thresholds are auto-ratcheted — they only go up</summary>"
echo ""
echo "Vitest \`thresholds.autoUpdate\` bumps the floor whenever local coverage exceeds it."
echo "CI enforces the current thresholds; developers commit the ratcheted values."
echo "</details>"
echo ""
elif [ "$U_STMTS" != "N/A" ]; then
echo "### Code Coverage (Unit only)"
echo ""
cov_table "Unit Tests" \
"$U_STMTS" "$U_BRANCH" "$U_FUNCS" "$U_LINES" \
"$U_STMTS_COV" "$U_BRANCH_COV" "$U_FUNCS_COV" "$U_LINES_COV" \
"$THRESH_STMTS" "$THRESH_BRANCH" "$THRESH_FUNCS" "$THRESH_LINES"
echo "<details>"
echo "<summary>Coverage thresholds are auto-ratcheted — they only go up</summary>"
echo ""
echo "Vitest \`thresholds.autoUpdate\` bumps the floor whenever local coverage exceeds it."
echo "CI enforces the current thresholds; developers commit the ratcheted values."
echo "</details>"
echo ""
else
echo "### Code Coverage"
echo ""
echo "⚠️ Coverage data unavailable - check the [unit test job](${RUN_URL}) for details."
echo ""
fi
echo "---"
echo "<sub>📋 [View full run](${RUN_URL}) · Generated by CI</sub>"
echo "GITNEXUS_CI_REPORT_EOF_7f3a"
} >> "$GITHUB_OUTPUT"
- name: Comment on PR
if: steps.meta.outputs.skip != 'true'
uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2
with:
header: ci-report
number: ${{ steps.meta.outputs.pr_number }}
message: ${{ steps.report.outputs.body }}
@@ -1,20 +1,22 @@
name: Unit Tests
name: Tests
on:
workflow_call:
jobs:
unit-tests:
name: unit (ubuntu / coverage)
tests:
name: ubuntu / coverage
runs-on: ubuntu-latest
timeout-minutes: 15
timeout-minutes: 25
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: ./.github/actions/setup-gitnexus
with:
build: 'true'
- name: Run unit tests with coverage
- name: Run all tests with coverage
run: >-
npx vitest run test/unit
npx vitest run
--reporter=default
--reporter=json
--outputFile=test-results.json
@@ -38,16 +40,18 @@ jobs:
retention-days: 5
cross-platform:
name: unit (${{ matrix.os }})
name: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
# Ubuntu already covered by the coverage job above
os: [windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 15
timeout-minutes: 25
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: ./.github/actions/setup-gitnexus
- run: npx vitest run test/unit
with:
build: 'true'
- run: npx vitest run
working-directory: gitnexus
+268 -53
View File
@@ -16,10 +16,10 @@ concurrency:
# ── Reusable workflow orchestration ─────────────────────────────────
# Each concern lives in its own workflow file for maintainability:
# ci-quality.yml — typecheck (tsc --noEmit)
# ci-unit-tests.yml — unit tests with coverage + cross-platform
# ci-integration.yml — integration test matrix (3 OS x 4 groups)
# ci-tests.yml — all tests with coverage (ubuntu) + cross-platform
#
# Shared setup is DRY via .github/actions/setup-gitnexus composite action.
# The PR report runs inline (not via workflow_run) so it uses the
# PR branch's code instead of main's — avoids stale report templates.
jobs:
quality:
@@ -27,56 +27,16 @@ jobs:
permissions:
contents: read
unit-tests:
uses: ./.github/workflows/ci-unit-tests.yml
tests:
uses: ./.github/workflows/ci-tests.yml
permissions:
contents: read
integration:
uses: ./.github/workflows/ci-integration.yml
with:
collect-coverage: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
# ── Save PR metadata for the reporting workflow ─────────────────
# The ci-report.yml workflow (triggered by workflow_run) needs the
# PR number and job results to post a comment. We save them as an
# artifact because workflow_run context doesn't reliably carry PR
# info for fork PRs.
save-pr-meta:
name: Save PR Metadata
if: always() && github.event_name == 'pull_request'
needs: [quality, unit-tests, integration]
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Write metadata
shell: bash
env:
PR_NUMBER: ${{ github.event.number }}
QUALITY: ${{ needs.quality.result }}
UNIT: ${{ needs.unit-tests.result }}
INTEG: ${{ needs.integration.result }}
run: |
mkdir -p pr-meta
echo "$PR_NUMBER" > pr-meta/pr_number
echo "$QUALITY" > pr-meta/quality_result
echo "$UNIT" > pr-meta/unit_result
echo "$INTEG" > pr-meta/integration_result
- name: Upload PR metadata
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: pr-meta
path: pr-meta/
retention-days: 1
# ── Unified CI gate ──────────────────────────────────────────────
# Single required check for branch protection.
ci-status:
name: CI Gate
needs: [quality, unit-tests, integration]
needs: [quality, tests]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 5
@@ -85,15 +45,270 @@ jobs:
shell: bash
env:
QUALITY: ${{ needs.quality.result }}
UNIT: ${{ needs.unit-tests.result }}
INTEG: ${{ needs.integration.result }}
TESTS: ${{ needs.tests.result }}
run: |
echo "Quality: $QUALITY"
echo "Unit Tests: $UNIT"
echo "Integration: $INTEG"
echo "Quality: $QUALITY"
echo "Tests: $TESTS"
if [[ "$QUALITY" != "success" ]] ||
[[ "$UNIT" != "success" ]] ||
[[ "$INTEG" != "success" ]]; then
[[ "$TESTS" != "success" ]]; then
echo "::error::One or more CI jobs failed"
exit 1
fi
# ── PR Report ────────────────────────────────────────────────────
# Posts a sticky comment with test results, coverage, and
# per-platform status. Runs inline so it uses the PR branch's
# report template (not main's stale version via workflow_run).
pr-report:
name: PR Report
if: always() && github.event_name == 'pull_request'
needs: [quality, tests]
runs-on: ubuntu-latest
permissions:
contents: read
actions: read
pull-requests: write
timeout-minutes: 5
steps:
- name: Download test reports
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: test-reports
path: ${{ runner.temp }}/test-reports
continue-on-error: true
- name: Fetch cross-platform job results
id: jobs
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
with:
script: |
const jobs = await github.rest.actions.listJobsForWorkflowRun({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: context.runId,
per_page: 50,
});
const results = {};
for (const job of jobs.data.jobs) {
if (job.name.includes('ubuntu')) results.ubuntu = job.conclusion || 'pending';
else if (job.name.includes('windows')) results.windows = job.conclusion || 'pending';
else if (job.name.includes('macos')) results.macos = job.conclusion || 'pending';
}
core.setOutput('ubuntu', results.ubuntu || 'unknown');
core.setOutput('windows', results.windows || 'unknown');
core.setOutput('macos', results.macos || 'unknown');
- name: Fetch base branch coverage
id: base-coverage
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
with:
script: |
const fs = require('fs');
const path = require('path');
const runs = await github.rest.actions.listWorkflowRuns({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'ci.yml',
branch: 'main',
status: 'success',
per_page: 1,
});
if (runs.data.workflow_runs.length === 0) {
core.setOutput('found', 'false');
return;
}
const mainRunId = runs.data.workflow_runs[0].id;
const artifacts = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: mainRunId,
});
const testReports = artifacts.data.artifacts.find(a => a.name === 'test-reports');
if (!testReports) {
core.setOutput('found', 'false');
return;
}
const zip = await github.rest.actions.downloadArtifact({
owner: context.repo.owner,
repo: context.repo.repo,
artifact_id: testReports.id,
archive_format: 'zip',
});
const dest = path.join(process.env.RUNNER_TEMP, 'base-coverage');
fs.mkdirSync(dest, { recursive: true });
fs.writeFileSync(path.join(dest, 'base.zip'), Buffer.from(zip.data));
core.setOutput('found', 'true');
core.setOutput('dir', dest);
- name: Extract base coverage
if: steps.base-coverage.outputs.found == 'true'
shell: bash
run: |
cd "${{ steps.base-coverage.outputs.dir }}"
unzip -o base.zip -d base
- name: Build and post report
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
env:
QUALITY: ${{ needs.quality.result }}
TESTS: ${{ needs.tests.result }}
UBUNTU: ${{ steps.jobs.outputs.ubuntu }}
WINDOWS: ${{ steps.jobs.outputs.windows }}
MACOS: ${{ steps.jobs.outputs.macos }}
BASE_FOUND: ${{ steps.base-coverage.outputs.found }}
BASE_DIR: ${{ steps.base-coverage.outputs.dir }}
with:
script: |
const fs = require('fs');
const path = require('path');
const icon = (s) => ({ success: '✅', failure: '❌', cancelled: '⏭️' }[s] || '❓');
const temp = process.env.RUNNER_TEMP;
// ── Read coverage ──
function readCov(dir) {
const out = { stmts: 'N/A', branch: 'N/A', funcs: 'N/A', lines: 'N/A',
stmtsCov: '', branchCov: '', funcsCov: '', linesCov: '' };
try {
const files = require('child_process')
.execSync(`find "${dir}" -name coverage-summary.json -type f`, { encoding: 'utf8' })
.trim().split('\n').filter(Boolean);
if (!files.length) return out;
const d = JSON.parse(fs.readFileSync(files[0], 'utf8')).total;
out.stmts = d.statements.pct; out.branch = d.branches.pct;
out.funcs = d.functions.pct; out.lines = d.lines.pct;
out.stmtsCov = `${d.statements.covered}/${d.statements.total}`;
out.branchCov = `${d.branches.covered}/${d.branches.total}`;
out.funcsCov = `${d.functions.covered}/${d.functions.total}`;
out.linesCov = `${d.lines.covered}/${d.lines.total}`;
} catch {}
return out;
}
const cov = readCov(path.join(temp, 'test-reports'));
const base = process.env.BASE_FOUND === 'true'
? readCov(path.join(process.env.BASE_DIR, 'base'))
: { stmts: 'N/A', branch: 'N/A', funcs: 'N/A', lines: 'N/A' };
// ── Read test results ──
let total = 0, passed = 0, failed = 0, skipped = 0, suites = 0, duration = '0s';
let skippedTests = [];
try {
const files = require('child_process')
.execSync(`find "${path.join(temp, 'test-reports')}" -name test-results.json -type f`, { encoding: 'utf8' })
.trim().split('\n').filter(Boolean);
if (files.length) {
const r = JSON.parse(fs.readFileSync(files[0], 'utf8'));
total = r.numTotalTests || 0;
passed = r.numPassedTests || 0;
failed = r.numFailedTests || 0;
skipped = r.numPendingTests || 0;
suites = r.numTotalTestSuites || 0;
const durS = Math.floor((Math.max(...r.testResults.map(t => t.endTime)) - r.startTime) / 1000);
duration = durS >= 60 ? `${Math.floor(durS / 60)}m ${durS % 60}s` : `${durS}s`;
// Collect skipped test names
for (const suite of r.testResults) {
for (const t of (suite.assertionResults || [])) {
if (t.status === 'pending' || t.status === 'skipped') {
skippedTests.push(`- ${t.ancestorTitles.join(' > ')} > ${t.title}`);
}
}
}
}
} catch {}
// ── Coverage delta ──
function delta(pct, basePct) {
if (pct === 'N/A' || basePct === 'N/A') return '—';
const d = (pct - basePct).toFixed(1);
if (d > 0) return `📈 +${d}%`;
if (d < 0) return `📉 ${d}%`;
return '=';
}
// ── Build markdown ──
const { QUALITY, TESTS, UBUNTU, WINDOWS, MACOS } = process.env;
const overall = (QUALITY === 'success' && TESTS === 'success')
? '✅ **All checks passed**' : '❌ **Some checks failed**';
const sha = context.sha.slice(0, 7);
let body = `## CI Report\n\n${overall} &ensp; \`${sha}\`\n\n`;
body += `### Pipeline\n\n`;
body += `| Stage | Status | Ubuntu | Windows | macOS |\n`;
body += `|-------|--------|--------|---------|-------|\n`;
body += `| Typecheck | ${icon(QUALITY)} \`${QUALITY}\` | — | — | — |\n`;
body += `| Tests | ${icon(TESTS)} \`${TESTS}\` | ${icon(UBUNTU)} | ${icon(WINDOWS)} | ${icon(MACOS)} |\n\n`;
if (total > 0) {
body += `### Tests\n\n`;
body += `| Metric | Value |\n|--------|-------|\n`;
body += `| Total | **${total}** |\n`;
body += `| Passed | **${passed}** |\n`;
if (failed > 0) body += `| Failed | **${failed}** |\n`;
if (skipped > 0) body += `| Skipped | ${skipped} |\n`;
body += `| Files | ${suites} |\n`;
body += `| Duration | ${duration} |\n\n`;
if (failed === 0) {
body += `✅ All **${passed}** tests passed across **${suites}** files\n`;
} else {
body += `❌ **${failed}** failed / **${passed}** passed\n`;
}
if (skippedTests.length > 0) {
body += `\n<details>\n<summary>${skipped} test(s) skipped</summary>\n\n`;
body += skippedTests.join('\n') + '\n\n</details>\n';
}
body += '\n';
}
if (cov.stmts !== 'N/A') {
body += `### Coverage\n\n`;
body += `| Metric | Coverage | Covered | Base (main) | Delta |\n`;
body += `|--------|----------|---------|-------------|-------|\n`;
body += `| Statements | **${cov.stmts}%** | ${cov.stmtsCov} | ${base.stmts}% | ${delta(cov.stmts, base.stmts)} |\n`;
body += `| Branches | **${cov.branch}%** | ${cov.branchCov} | ${base.branch}% | ${delta(cov.branch, base.branch)} |\n`;
body += `| Functions | **${cov.funcs}%** | ${cov.funcsCov} | ${base.funcs}% | ${delta(cov.funcs, base.funcs)} |\n`;
body += `| Lines | **${cov.lines}%** | ${cov.linesCov} | ${base.lines}% | ${delta(cov.lines, base.lines)} |\n\n`;
} else {
body += `### Coverage\n\n⚠️ Coverage data unavailable — check the [test job](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}) for details.\n\n`;
}
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
body += `---\n<sub>📋 [Full run](${runUrl}) · Coverage from Ubuntu · Generated by CI</sub>`;
// ── Post sticky comment ──
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
per_page: 100,
});
const marker = '<!-- ci-report -->';
const existing = comments.find(c => c.body?.includes(marker));
const fullBody = marker + '\n' + body;
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body: fullBody,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: fullBody,
});
}
+35 -9
View File
@@ -15,6 +15,12 @@ on:
issue_comment:
types: [created]
# Serialize per-PR so concurrent @claude comments don't race on the
# temporary fork branch push/delete.
concurrency:
group: claude-review-${{ github.event.issue.number || github.event.pull_request.number }}
cancel-in-progress: false
jobs:
claude-review:
# Run only when:
@@ -41,7 +47,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: write # needed to push fork branch to origin
contents: write # needed to create fork branch ref via API
pull-requests: write
issues: read
id-token: write
@@ -76,11 +82,28 @@ jobs:
fetch-depth: 1
# claude-code-action fetches branches by name from origin, which fails
# for fork PRs. Work around by pushing the fork branch to origin so
# the action can find it. Cleaned up in the post step below.
- name: Push fork branch to origin
# 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'
run: git push origin HEAD:refs/heads/${{ steps.pr.outputs.branch }}
env:
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" \
-f sha="$FORK_SHA" \
|| gh api "repos/${{ github.repository }}/git/refs/heads/$FORK_BRANCH" \
--method PATCH \
-f sha="$FORK_SHA" \
-F force=true
- name: Run Claude Code Review
id: claude-review
@@ -91,7 +114,10 @@ jobs:
plugins: 'code-review@claude-code-plugins'
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ steps.pr.outputs.number }}'
# Clean up the temporary branch we pushed for fork PRs
- name: Delete fork branch from origin
if: always() && steps.pr.outputs.is_fork == 'true'
run: git push origin --delete refs/heads/${{ steps.pr.outputs.branch }} || true
# Clean up the temporary branch ref we created for fork PRs.
# Only delete if the create step actually succeeded.
- name: Delete fork branch ref from origin
if: always() && steps.push-fork.outcome == 'success'
env:
GH_TOKEN: ${{ github.token }}
run: gh api "repos/${{ github.repository }}/git/refs/heads/$FORK_BRANCH" --method DELETE || true
+78 -2
View File
@@ -10,6 +10,12 @@ on:
pull_request_review:
types: [submitted]
# Serialize per-PR so concurrent @claude comments don't race on the
# temporary fork branch push/delete.
concurrency:
group: claude-code-${{ github.event.issue.number || github.event.pull_request.number || github.event.issue.id }}
cancel-in-progress: false
jobs:
claude:
if: |
@@ -20,17 +26,79 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
contents: write # needed to create fork branch ref via API
pull-requests: write
issues: write
id-token: write
actions: read # Required for Claude to read CI results on PRs
actions: read # required for Claude to read CI results on PRs
steps:
# For PR-related triggers, resolve fork context so we can create a
# temporary branch ref (claude-code-action fetches by branch name).
- name: Resolve PR context
id: pr
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
with:
script: |
// Determine if this event is PR-related
let prNumber = null;
if (context.eventName === 'issue_comment' && context.payload.issue.pull_request) {
prNumber = context.payload.issue.number;
} else if (context.eventName === 'pull_request_review_comment') {
prNumber = context.payload.pull_request.number;
} else if (context.eventName === 'pull_request_review') {
prNumber = context.payload.pull_request.number;
}
if (!prNumber) {
core.setOutput('is_pr', 'false');
core.setOutput('is_fork', 'false');
return;
}
const resp = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
});
const pr = resp.data;
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);
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: ${{ steps.pr.outputs.is_fork == 'true' && steps.pr.outputs.sha || '' }}
fetch-depth: 1
# claude-code-action fetches branches by name from origin, which fails
# 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: 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" \
-f sha="$FORK_SHA" \
|| gh api "repos/${{ github.repository }}/git/refs/heads/$FORK_BRANCH" \
--method PATCH \
-f sha="$FORK_SHA" \
-F force=true
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@9469d113c6afd29550c402740f22d1a97dd1209b # v1
@@ -40,3 +108,11 @@ jobs:
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Clean up the temporary branch ref we created for fork PRs.
# Only delete if the create step actually succeeded.
- name: Delete fork branch ref from origin
if: always() && steps.push-fork.outcome == 'success'
env:
GH_TOKEN: ${{ github.token }}
run: gh api "repos/${{ github.repository }}/git/refs/heads/$FORK_BRANCH" --method DELETE || true
+1
View File
@@ -12,6 +12,7 @@ jobs:
uses: ./.github/workflows/ci.yml
permissions:
contents: read
actions: read
pull-requests: write
publish:
+8 -1
View File
@@ -62,4 +62,11 @@ docs/plans/
gitnexus/test/fixtures/mini-repo/*.md
gitnexus/test/fixtures/mini-repo/.claude
gitnexus/test/fixtures/mini-repo/.gitignore
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
# 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,
},
},
},
});
+19 -21
View File
@@ -1,7 +1,7 @@
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **GitNexus** (1747 symbols, 4569 relationships, 130 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
This project is indexed by GitNexus as **GitNexus** (2094 symbols, 4982 relationships, 159 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.
@@ -69,6 +69,24 @@ Before completing any code modification task, verify:
3. `gitnexus_detect_changes()` confirms changes match expected scope
4. All d=1 (WILL BREAK) dependents were updated
## Keeping the Index Fresh
After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it:
```bash
npx gitnexus analyze
```
If the index previously included embeddings, preserve them by adding `--embeddings`:
```bash
npx gitnexus analyze --embeddings
```
To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.**
> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`.
## CLI
| Task | Read this skill file |
@@ -79,25 +97,5 @@ Before completing any code modification task, verify:
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
| Work in the Ingestion area (135 symbols) | `.claude/skills/generated/ingestion/SKILL.md` |
| Work in the Workers area (70 symbols) | `.claude/skills/generated/workers/SKILL.md` |
| Work in the Cli area (63 symbols) | `.claude/skills/generated/cli/SKILL.md` |
| Work in the Kuzu area (52 symbols) | `.claude/skills/generated/kuzu/SKILL.md` |
| Work in the Wiki area (52 symbols) | `.claude/skills/generated/wiki/SKILL.md` |
| Work in the Embeddings area (48 symbols) | `.claude/skills/generated/embeddings/SKILL.md` |
| Work in the Components area (42 symbols) | `.claude/skills/generated/components/SKILL.md` |
| Work in the Local area (36 symbols) | `.claude/skills/generated/local/SKILL.md` |
| Work in the Storage area (36 symbols) | `.claude/skills/generated/storage/SKILL.md` |
| Work in the Services area (35 symbols) | `.claude/skills/generated/services/SKILL.md` |
| Work in the Mcp area (32 symbols) | `.claude/skills/generated/mcp/SKILL.md` |
| Work in the Llm area (30 symbols) | `.claude/skills/generated/llm/SKILL.md` |
| Work in the Eval area (18 symbols) | `.claude/skills/generated/eval/SKILL.md` |
| Work in the Bridge area (15 symbols) | `.claude/skills/generated/bridge/SKILL.md` |
| Work in the Hooks area (14 symbols) | `.claude/skills/generated/hooks/SKILL.md` |
| Work in the Search area (11 symbols) | `.claude/skills/generated/search/SKILL.md` |
| Work in the Environments area (11 symbols) | `.claude/skills/generated/environments/SKILL.md` |
| Work in the Analysis area (10 symbols) | `.claude/skills/generated/analysis/SKILL.md` |
| Work in the Agents area (9 symbols) | `.claude/skills/generated/agents/SKILL.md` |
| Work in the Graph area (6 symbols) | `.claude/skills/generated/graph/SKILL.md` |
<!-- gitnexus:end -->
+8
View File
@@ -2,6 +2,14 @@
All notable changes to GitNexus will be documented in this file.
## [Unreleased]
### Changed
- Migrated from KuzuDB to LadybugDB v0.15 (`@ladybugdb/core`, `@ladybugdb/wasm-core`)
- Renamed all internal paths from `kuzu` to `lbug` (storage: `.gitnexus/kuzu` → `.gitnexus/lbug`)
- Added automatic cleanup of stale KuzuDB index files
- LadybugDB v0.15 requires explicit VECTOR extension loading for semantic search
## [1.4.0] - 2026-03-13
### Added
+19 -21
View File
@@ -1,7 +1,7 @@
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **GitNexus** (1747 symbols, 4569 relationships, 130 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
This project is indexed by GitNexus as **GitNexus** (2094 symbols, 4982 relationships, 159 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.
@@ -69,6 +69,24 @@ Before completing any code modification task, verify:
3. `gitnexus_detect_changes()` confirms changes match expected scope
4. All d=1 (WILL BREAK) dependents were updated
## Keeping the Index Fresh
After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it:
```bash
npx gitnexus analyze
```
If the index previously included embeddings, preserve them by adding `--embeddings`:
```bash
npx gitnexus analyze --embeddings
```
To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.**
> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`.
## CLI
| Task | Read this skill file |
@@ -79,25 +97,5 @@ Before completing any code modification task, verify:
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
| Work in the Ingestion area (135 symbols) | `.claude/skills/generated/ingestion/SKILL.md` |
| Work in the Workers area (70 symbols) | `.claude/skills/generated/workers/SKILL.md` |
| Work in the Cli area (63 symbols) | `.claude/skills/generated/cli/SKILL.md` |
| Work in the Kuzu area (52 symbols) | `.claude/skills/generated/kuzu/SKILL.md` |
| Work in the Wiki area (52 symbols) | `.claude/skills/generated/wiki/SKILL.md` |
| Work in the Embeddings area (48 symbols) | `.claude/skills/generated/embeddings/SKILL.md` |
| Work in the Components area (42 symbols) | `.claude/skills/generated/components/SKILL.md` |
| Work in the Local area (36 symbols) | `.claude/skills/generated/local/SKILL.md` |
| Work in the Storage area (36 symbols) | `.claude/skills/generated/storage/SKILL.md` |
| Work in the Services area (35 symbols) | `.claude/skills/generated/services/SKILL.md` |
| Work in the Mcp area (32 symbols) | `.claude/skills/generated/mcp/SKILL.md` |
| Work in the Llm area (30 symbols) | `.claude/skills/generated/llm/SKILL.md` |
| Work in the Eval area (18 symbols) | `.claude/skills/generated/eval/SKILL.md` |
| Work in the Bridge area (15 symbols) | `.claude/skills/generated/bridge/SKILL.md` |
| Work in the Hooks area (14 symbols) | `.claude/skills/generated/hooks/SKILL.md` |
| Work in the Search area (11 symbols) | `.claude/skills/generated/search/SKILL.md` |
| Work in the Environments area (11 symbols) | `.claude/skills/generated/environments/SKILL.md` |
| Work in the Analysis area (10 symbols) | `.claude/skills/generated/analysis/SKILL.md` |
| Work in the Agents area (9 symbols) | `.claude/skills/generated/agents/SKILL.md` |
| Work in the Graph area (6 symbols) | `.claude/skills/generated/graph/SKILL.md` |
<!-- gitnexus:end -->
+37 -11
View File
@@ -48,10 +48,10 @@ 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** | KuzuDB native (fast, persistent) | KuzuDB WASM (in-memory, per session) |
| **Storage** | LadybugDB native (fast, persistent) | LadybugDB WASM (in-memory, per session) |
| **Parsing** | Tree-sitter native bindings | Tree-sitter WASM |
| **Privacy** | Everything local, no network | Everything in-browser, no server |
@@ -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
@@ -224,8 +233,8 @@ flowchart TD
Server["server.ts"]
Backend["LocalBackend"]
Pool["Connection Pool"]
ConnA["KuzuDB conn A"]
ConnB["KuzuDB conn B"]
ConnA["LadybugDB conn A"]
ConnB["LadybugDB conn B"]
end
Setup -->|"writes global MCP config"| CursorConfig["~/.cursor/mcp.json"]
@@ -242,7 +251,7 @@ flowchart TD
ConnB -->|"queries"| RepoB
```
**How it works:** Each `gitnexus analyze` stores the index in `.gitnexus/` inside the repo (portable, gitignored) and registers a pointer in `~/.gitnexus/registry.json`. When an AI agent starts, the MCP server reads the registry and can serve any indexed repo. KuzuDB connections are opened lazily on first query and evicted after 5 minutes of inactivity (max 5 concurrent). If only one repo is indexed, the `repo` parameter is optional on all tools — agents don't need to change anything.
**How it works:** Each `gitnexus analyze` stores the index in `.gitnexus/` inside the repo (portable, gitignored) and registers a pointer in `~/.gitnexus/registry.json`. When an AI agent starts, the MCP server reads the registry and can serve any indexed repo. LadybugDB connections are opened lazily on first query and evicted after 5 minutes of inactivity (max 5 concurrent). If only one repo is indexed, the `repo` parameter is optional on all tools — agents don't need to change anything.
---
@@ -263,7 +272,7 @@ npm install
npm run dev
```
The web UI uses the same indexing pipeline as the CLI but runs entirely in WebAssembly (Tree-sitter WASM, KuzuDB WASM, in-browser embeddings). It's great for quick exploration but limited by browser memory for larger repos.
The web UI uses the same indexing pipeline as the CLI but runs entirely in WebAssembly (Tree-sitter WASM, LadybugDB WASM, in-browser embeddings). It's great for quick exploration but limited by browser memory for larger repos.
**Local Backend Mode:** Run `gitnexus serve` and open the web UI locally — it auto-detects the server and shows all your indexed repos, with full AI chat support. No need to re-upload or re-index. The agent's tools (Cypher queries, search, code navigation) route through the backend HTTP API automatically.
@@ -320,14 +329,30 @@ GitNexus builds a complete knowledge graph of your codebase through a multi-phas
1. **Structure** — Walks the file tree and maps folder/file relationships
2. **Parsing** — Extracts functions, classes, methods, and interfaces using Tree-sitter ASTs
3. **Resolution** — Resolves imports and function calls across files with language-aware logic
3. **Resolution** — Resolves imports, function calls, heritage, constructor inference, and `self`/`this` receiver types across files with language-aware logic
4. **Clustering** — Groups related symbols into functional communities
5. **Processes** — Traces execution flows from entry points through call chains
6. **Search** — Builds hybrid search indexes for fast retrieval
### Supported Languages
TypeScript, JavaScript, Python, Java, Kotlin, C, C++, C#, Go, Rust, PHP, Swift
| Language | Imports | Named Bindings | Exports | Heritage | Type Annotations | Constructor Inference | Config | Frameworks | Entry Points |
|----------|---------|----------------|---------|----------|-----------------|---------------------|--------|------------|-------------|
| TypeScript | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| JavaScript | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ | ✓ |
| Python | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| Java | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
| Kotlin | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
| C# | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| Go | ✓ | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| Rust | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
| PHP | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ | ✓ | ✓ |
| Ruby | ✓ | — | ✓ | ✓ | — | ✓ | — | ✓ | ✓ |
| Swift | — | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| C | — | — | ✓ | — | ✓ | ✓ | — | ✓ | ✓ |
| C++ | — | — | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
**Imports** — cross-file import resolution · **Named Bindings** — `import { X as Y }` / re-export tracking · **Exports** — public/exported symbol detection · **Heritage** — class inheritance, interfaces, mixins · **Type Annotations** — explicit type extraction for receiver resolution · **Constructor Inference** — infer receiver type from constructor calls (`self`/`this` resolution included for all languages) · **Config** — language toolchain config parsing (tsconfig, go.mod, etc.) · **Frameworks** — AST-based framework pattern detection · **Entry Points** — entry point scoring heuristics
---
@@ -466,7 +491,7 @@ The wiki generator reads the indexed graph structure, groups files into modules
| ------------------------- | ------------------------------------- | --------------------------------------- |
| **Runtime** | Node.js (native) | Browser (WASM) |
| **Parsing** | Tree-sitter native bindings | Tree-sitter WASM |
| **Database** | KuzuDB native | KuzuDB WASM |
| **Database** | LadybugDB native | LadybugDB WASM |
| **Embeddings** | HuggingFace transformers.js (GPU/CPU) | transformers.js (WebGPU/WASM) |
| **Search** | BM25 + semantic + RRF | BM25 + semantic + RRF |
| **Agent Interface** | MCP (stdio) | LangChain ReAct agent |
@@ -487,9 +512,10 @@ The wiki generator reads the indexed graph structure, groups files into modules
### Recently Completed
- [X] Constructor-Inferred Type Resolution, `self`/`this` Receiver Mapping
- [X] Wiki Generation, Multi-File Rename, Git-Diff Impact Analysis
- [X] Process-Grouped Search, 360-Degree Context, Claude Code Hooks
- [X] Multi-Repo MCP, Zero-Config Setup, 11 Language Support
- [X] Multi-Repo MCP, Zero-Config Setup, 13 Language Support
- [X] Community Detection, Process Detection, Confidence Scoring
- [X] Hybrid Search, Vector Index
@@ -506,7 +532,7 @@ The wiki generator reads the indexed graph structure, groups files into modules
## Acknowledgments
- [Tree-sitter](https://tree-sitter.github.io/) — AST parsing
- [KuzuDB](https://kuzudb.com/) — Embedded graph database with vector support
- [LadybugDB](https://ladybugdb.com/) — Embedded graph database with vector support (formerly KuzuDB)
- [Sigma.js](https://www.sigmajs.org/) — WebGL graph rendering
- [transformers.js](https://huggingface.co/docs/transformers.js) — Browser ML
- [Graphology](https://graphology.github.io/) — Graph data structures
+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.
+2 -2
View File
@@ -148,7 +148,7 @@ Each mode has a `system_{mode}.jinja` + `instance_{mode}.jinja` pair. The agent
1. Docker container starts with SWE-bench instance (repo at specific commit)
2. **GitNexus setup**: Node.js + gitnexus installed, `gitnexus analyze` runs (or restores from cache)
3. **Eval-server starts**: `gitnexus eval-server` daemon (persistent HTTP server, keeps KuzuDB warm)
3. **Eval-server starts**: `gitnexus eval-server` daemon (persistent HTTP server, keeps LadybugDB warm)
4. **Standalone tool scripts installed** in `/usr/local/bin/` — works with `subprocess.run` (no `.bashrc` needed)
5. Agent runs with the configured model + system prompt + GitNexus tools
6. Agent's patch is extracted as a git diff
@@ -167,7 +167,7 @@ Each tool script in `/usr/local/bin/` is standalone — no sourcing, no env inhe
### Eval-server
The eval-server is a lightweight HTTP daemon that:
- Keeps KuzuDB warm in memory (no cold start per tool call)
- Keeps LadybugDB warm in memory (no cold start per tool call)
- Returns LLM-friendly text (not raw JSON — saves tokens)
- Includes next-step hints to guide tool chaining (query → context → impact → fix)
- Auto-shuts down after idle timeout
@@ -160,7 +160,7 @@ function handlePreToolUse(input) {
* PostToolUse handler — detect index staleness after git mutations.
*
* Instead of spawning a full `gitnexus analyze` synchronously (which blocks
* the agent for up to 120s and risks KuzuDB corruption on timeout), we do a
* the agent for up to 120s and risks LadybugDB corruption on timeout), we do a
* lightweight staleness check: compare `git rev-parse HEAD` against the
* lastCommit stored in `.gitnexus/meta.json`. If they differ, notify the
* agent so it can decide when to reindex.
+25 -26
View File
@@ -10,6 +10,7 @@
"dependencies": {
"@huggingface/transformers": "^3.0.0",
"@isomorphic-git/lightning-fs": "^4.6.2",
"@ladybugdb/wasm-core": "^0.15.1",
"@langchain/anthropic": "^1.3.10",
"@langchain/core": "^1.1.15",
"@langchain/google-genai": "^2.1.10",
@@ -30,7 +31,6 @@
"graphology-utils": "^2.3.0",
"isomorphic-git": "^1.36.1",
"jszip": "^3.10.1",
"kuzu-wasm": "^0.11.1",
"langchain": "^1.2.10",
"lru-cache": "^11.2.4",
"lucide-react": "^0.562.0",
@@ -1643,6 +1643,30 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@ladybugdb/wasm-core": {
"version": "0.15.1",
"resolved": "https://registry.npmjs.org/@ladybugdb/wasm-core/-/wasm-core-0.15.1.tgz",
"integrity": "sha512-dHEq8inJQBkHnJrqZMKGdltSfeSv9OHECkzWQixqDLApXXGlbJ5Ugq5rRfk2PLJuZ74LVHT0cZvcn4JLmsnAIA==",
"license": "MIT",
"dependencies": {
"threads": "^1.7.0",
"tiny-worker": "^2.3.0",
"uuid": "^11.0.3"
}
},
"node_modules/@ladybugdb/wasm-core/node_modules/uuid": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/esm/bin/uuid"
}
},
"node_modules/@langchain/anthropic": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/@langchain/anthropic/-/anthropic-1.3.10.tgz",
@@ -6194,31 +6218,6 @@
"resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz",
"integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="
},
"node_modules/kuzu-wasm": {
"version": "0.11.3",
"resolved": "https://registry.npmjs.org/kuzu-wasm/-/kuzu-wasm-0.11.3.tgz",
"integrity": "sha512-+bLOqXgYZJJ2dHJG1y9LTLyb9ZB73eLxErRZahZz2rPokfIdyLaktTJFzJH7wX39hgyukKn8QxeRNobH6gl27g==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"license": "MIT",
"dependencies": {
"threads": "^1.7.0",
"tiny-worker": "^2.3.0",
"uuid": "^11.0.3"
}
},
"node_modules/kuzu-wasm/node_modules/uuid": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/esm/bin/uuid"
}
},
"node_modules/langchain": {
"version": "1.2.10",
"resolved": "https://registry.npmjs.org/langchain/-/langchain-1.2.10.tgz",
+1 -1
View File
@@ -33,7 +33,7 @@
"graphology-layout-noverlap": "^0.4.2",
"isomorphic-git": "^1.36.1",
"jszip": "^3.10.1",
"kuzu-wasm": "^0.11.1",
"@ladybugdb/wasm-core": "^0.15.2",
"langchain": "^1.2.10",
"lru-cache": "^11.2.4",
"lucide-react": "^0.562.0",
Binary file not shown.
Binary file not shown.
@@ -5,6 +5,42 @@ import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { useAppState } from '../hooks/useAppState';
import { NODE_COLORS } from '../lib/constants';
/** Map file extension to Prism syntax highlighter language identifier */
const getSyntaxLanguage = (filePath: string | undefined): string => {
if (!filePath) return 'text';
const ext = filePath.split('.').pop()?.toLowerCase();
switch (ext) {
case 'js': case 'jsx': case 'mjs': case 'cjs': return 'javascript';
case 'ts': case 'tsx': case 'mts': case 'cts': return 'typescript';
case 'py': case 'pyw': return 'python';
case 'rb': case 'rake': case 'gemspec': return 'ruby';
case 'java': return 'java';
case 'go': return 'go';
case 'rs': return 'rust';
case 'c': case 'h': return 'c';
case 'cpp': case 'cc': case 'cxx': case 'hpp': case 'hxx': case 'hh': return 'cpp';
case 'cs': return 'csharp';
case 'php': return 'php';
case 'kt': case 'kts': return 'kotlin';
case 'swift': return 'swift';
case 'json': return 'json';
case 'yaml': case 'yml': return 'yaml';
case 'md': case 'mdx': return 'markdown';
case 'html': case 'htm': case 'erb': return 'markup';
case 'css': case 'scss': case 'sass': return 'css';
case 'sh': case 'bash': case 'zsh': return 'bash';
case 'sql': return 'sql';
case 'xml': return 'xml';
default: break;
}
// Handle extensionless Ruby files
const basename = filePath.split('/').pop() || '';
if (['Rakefile', 'Gemfile', 'Guardfile', 'Vagrantfile', 'Brewfile'].includes(basename)) return 'ruby';
if (['Makefile'].includes(basename)) return 'makefile';
if (['Dockerfile'].includes(basename)) return 'docker';
return 'text';
};
// Match the code theme used elsewhere in the app
const customTheme = {
...vscDarkPlus,
@@ -267,12 +303,7 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
<div className="flex-1 min-h-0 overflow-auto scrollbar-thin">
{selectedFileContent ? (
<SyntaxHighlighter
language={
selectedFilePath?.endsWith('.py') ? 'python' :
selectedFilePath?.endsWith('.js') || selectedFilePath?.endsWith('.jsx') ? 'javascript' :
selectedFilePath?.endsWith('.ts') || selectedFilePath?.endsWith('.tsx') ? 'typescript' :
'text'
}
language={getSyntaxLanguage(selectedFilePath)}
style={customTheme as any}
showLineNumbers
startingLineNumber={1}
@@ -339,11 +370,7 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
const hasRange = typeof ref.startLine === 'number';
const startDisplay = hasRange ? (ref.startLine ?? 0) + 1 : undefined;
const endDisplay = hasRange ? (ref.endLine ?? ref.startLine ?? 0) + 1 : undefined;
const language =
ref.filePath.endsWith('.py') ? 'python' :
ref.filePath.endsWith('.js') || ref.filePath.endsWith('.jsx') ? 'javascript' :
ref.filePath.endsWith('.ts') || ref.filePath.endsWith('.tsx') ? 'typescript' :
'text';
const language = getSyntaxLanguage(ref.filePath);
const isGlowing = glowRefId === ref.id;
@@ -83,7 +83,7 @@ export const EmbeddingStatus = () => {
<button
onClick={handleTestArrayParams}
className="flex items-center gap-1 px-2 py-1.5 bg-surface border border-border-subtle rounded-lg text-xs text-text-muted hover:bg-hover hover:text-text-secondary transition-all"
title="Test if KuzuDB supports array params"
title="Test if LadybugDB supports array params"
>
<FlaskConical className="w-3 h-3" />
{testResult || 'Test'}
@@ -9,6 +9,7 @@ export enum SupportedLanguages {
Go = 'go',
Rust = 'rust',
PHP = 'php',
// Ruby = 'ruby',
Ruby = 'ruby',
Kotlin = 'kotlin',
Swift = 'swift',
}
+1 -1
View File
@@ -275,7 +275,7 @@ export const embedBatch = async (texts: string[]): Promise<Float32Array[]> => {
};
/**
* Convert Float32Array to regular number array (for KuzuDB storage)
* Convert Float32Array to regular number array (for LadybugDB storage)
*/
export const embeddingToArray = (embedding: Float32Array): number[] => {
return Array.from(embedding);
@@ -2,10 +2,10 @@
* Embedding Pipeline Module
*
* Orchestrates the background embedding process:
* 1. Query embeddable nodes from KuzuDB
* 1. Query embeddable nodes from LadybugDB
* 2. Generate text representations
* 3. Batch embed using transformers.js
* 4. Update KuzuDB with embeddings
* 4. Update LadybugDB with embeddings
* 5. Create vector index for semantic search
*/
@@ -27,7 +27,7 @@ import {
export type EmbeddingProgressCallback = (progress: EmbeddingProgress) => void;
/**
* Query all embeddable nodes from KuzuDB
* Query all embeddable nodes from LadybugDB
* Uses table-specific queries (File has different schema than code elements)
*/
const queryEmbeddableNodes = async (
@@ -102,9 +102,23 @@ const batchInsertEmbeddings = async (
* Create the vector index for semantic search
* Now indexes the separate CodeEmbedding table
*/
let vectorExtensionLoaded = false;
const createVectorIndex = async (
executeQuery: (cypher: string) => Promise<any[]>
): Promise<void> => {
// LadybugDB v0.15+ requires explicit VECTOR extension loading (once per session)
if (!vectorExtensionLoaded) {
try {
await executeQuery('INSTALL VECTOR');
await executeQuery('LOAD EXTENSION VECTOR');
vectorExtensionLoaded = true;
} catch {
// Extension may already be loaded — CREATE_VECTOR_INDEX will fail clearly if not
vectorExtensionLoaded = true;
}
}
const cypher = `
CALL CREATE_VECTOR_INDEX('CodeEmbedding', 'code_embedding_idx', 'embedding', metric := 'cosine')
`;
@@ -122,7 +136,7 @@ const createVectorIndex = async (
/**
* Run the embedding pipeline
*
* @param executeQuery - Function to execute Cypher queries against KuzuDB
* @param executeQuery - Function to execute Cypher queries against LadybugDB
* @param executeWithReusedStatement - Function to execute with reused prepared statement
* @param onProgress - Callback for progress updates
* @param config - Optional configuration override
@@ -206,7 +220,7 @@ export const runEmbeddingPipeline = async (
// Embed the batch
const embeddings = await embedBatch(texts);
// Update KuzuDB with embeddings
// Update LadybugDB with embeddings
const updates = batch.map((node, i) => ({
id: node.id,
embedding: embeddingToArray(embeddings[i]),
@@ -313,51 +327,64 @@ export const semanticSearch = async (
return [];
}
// Get metadata for each result by querying each node table
const results: SemanticSearchResult[] = [];
// Group results by label for batched metadata queries
const byLabel = new Map<string, Array<{ nodeId: string; distance: number }>>();
for (const embRow of embResults) {
const nodeId = embRow.nodeId ?? embRow[0];
const distance = embRow.distance ?? embRow[1];
// Extract label from node ID (format: Label:path:name)
const labelEndIdx = nodeId.indexOf(':');
const label = labelEndIdx > 0 ? nodeId.substring(0, labelEndIdx) : 'Unknown';
// Query the specific table for this node
// File nodes don't have startLine/endLine
if (!byLabel.has(label)) byLabel.set(label, []);
byLabel.get(label)!.push({ nodeId, distance });
}
// Batch-fetch metadata per label
const results: SemanticSearchResult[] = [];
for (const [label, items] of byLabel) {
const idList = items.map(i => `'${i.nodeId.replace(/'/g, "''")}'`).join(', ');
try {
let nodeQuery: string;
if (label === 'File') {
nodeQuery = `
MATCH (n:File {id: '${nodeId.replace(/'/g, "''")}'})
RETURN n.name AS name, n.filePath AS filePath
MATCH (n:File) WHERE n.id IN [${idList}]
RETURN n.id AS id, n.name AS name, n.filePath AS filePath
`;
} else {
nodeQuery = `
MATCH (n:${label} {id: '${nodeId.replace(/'/g, "''")}'})
RETURN n.name AS name, n.filePath AS filePath,
MATCH (n:${label}) WHERE n.id IN [${idList}]
RETURN n.id AS id, n.name AS name, n.filePath AS filePath,
n.startLine AS startLine, n.endLine AS endLine
`;
}
const nodeRows = await executeQuery(nodeQuery);
if (nodeRows.length > 0) {
const nodeRow = nodeRows[0];
results.push({
nodeId,
name: nodeRow.name ?? nodeRow[0] ?? '',
label,
filePath: nodeRow.filePath ?? nodeRow[1] ?? '',
distance,
startLine: label !== 'File' ? (nodeRow.startLine ?? nodeRow[2]) : undefined,
endLine: label !== 'File' ? (nodeRow.endLine ?? nodeRow[3]) : undefined,
});
const rowMap = new Map<string, any>();
for (const row of nodeRows) {
const id = row.id ?? row[0];
rowMap.set(id, row);
}
for (const item of items) {
const nodeRow = rowMap.get(item.nodeId);
if (nodeRow) {
results.push({
nodeId: item.nodeId,
name: nodeRow.name ?? nodeRow[1] ?? '',
label,
filePath: nodeRow.filePath ?? nodeRow[2] ?? '',
distance: item.distance,
startLine: label !== 'File' ? (nodeRow.startLine ?? nodeRow[3]) : undefined,
endLine: label !== 'File' ? (nodeRow.endLine ?? nodeRow[4]) : undefined,
});
}
}
} catch {
// Table might not exist, skip
}
}
// Re-sort by distance since batch queries may have mixed order
results.sort((a, b) => a.distance - b.distance);
return results;
};
+1 -1
View File
@@ -92,7 +92,7 @@ export interface SemanticSearchResult {
}
/**
* Node data for embedding (minimal structure from KuzuDB query)
* Node data for embedding (minimal structure from LadybugDB query)
*/
export interface EmbeddableNode {
id: string;
+1
View File
@@ -54,6 +54,7 @@ export type RelationshipType =
| 'DECORATES'
| 'IMPLEMENTS'
| 'EXTENDS'
| 'HAS_METHOD'
| 'MEMBER_OF'
| 'STEP_IN_PROCESS'
+190 -34
View File
@@ -6,6 +6,7 @@ import { loadParser, loadLanguage } from '../tree-sitter/parser-loader';
import { LANGUAGE_QUERIES } from './tree-sitter-queries';
import { generateId } from '../../lib/utils';
import { getLanguageFromFilename } from './utils';
import { callRouters } from './call-routing';
/**
* Node types that represent function/method definitions across languages.
@@ -35,6 +36,9 @@ const FUNCTION_NODE_TYPES = new Set([
// Rust
'function_item',
'impl_item', // Methods inside impl blocks
// Ruby
'method', // def foo
'singleton_method', // def self.foo
]);
/**
@@ -92,6 +96,18 @@ const findEnclosingFunction = (
current.children?.find((c: any) => c.type === 'identifier');
funcName = nameNode?.text;
label = 'Method'; // Treat constructors as methods for process detection
} else if (current.type === 'method') {
// Ruby instance method: def foo
const nameNode = current.childForFieldName?.('name') ||
current.children?.find((c: any) => c.type === 'identifier');
funcName = nameNode?.text;
label = 'Method';
} else if (current.type === 'singleton_method') {
// Ruby class method: def self.foo
const nameNode = current.childForFieldName?.('name') ||
current.children?.find((c: any) => c.type === 'identifier');
funcName = nameNode?.text;
label = 'Method';
} else if (current.type === 'arrow_function' || current.type === 'function_expression') {
// Arrow/expression: const foo = () => {} - check parent variable declarator
const parent = current.parent;
@@ -126,6 +142,47 @@ const findEnclosingFunction = (
return null; // Top-level call (not inside any function)
};
/** AST node types that represent a class-like container */
const CLASS_CONTAINER_TYPES = new Set([
'class_declaration', 'abstract_class_declaration',
'interface_declaration', 'struct_declaration', 'record_declaration',
'class_specifier', 'struct_specifier',
'impl_item', 'trait_item',
'class_definition',
'trait_declaration',
'protocol_declaration',
'class', 'module', // Ruby
]);
const CONTAINER_TYPE_TO_LABEL: Record<string, string> = {
class_declaration: 'Class', abstract_class_declaration: 'Class',
interface_declaration: 'Interface',
struct_declaration: 'Struct', struct_specifier: 'Struct',
class_specifier: 'Class', class_definition: 'Class',
impl_item: 'Impl', trait_item: 'Trait', trait_declaration: 'Trait',
record_declaration: 'Record', protocol_declaration: 'Interface',
class: 'Class', module: 'Module',
};
/** Walk up AST to find enclosing class/struct/interface, return its generateId or null. */
const findEnclosingClassId = (node: any, filePath: string): string | null => {
let current = node.parent;
while (current) {
if (CLASS_CONTAINER_TYPES.has(current.type)) {
const nameNode = current.childForFieldName?.('name')
?? current.children?.find((c: any) =>
c.type === 'type_identifier' || c.type === 'identifier' || c.type === 'name' || c.type === 'constant'
);
if (nameNode) {
const label = CONTAINER_TYPE_TO_LABEL[current.type] || 'Class';
return generateId(label, `${filePath}:${nameNode.text}`);
}
}
current = current.parent;
}
return null;
};
export const processCalls = async (
graph: KnowledgeGraph,
files: { path: string; content: string }[],
@@ -171,6 +228,8 @@ export const processCalls = async (
continue;
}
const callRouter = callRouters[language];
// 3. Process each call match
matches.forEach(match => {
const captureMap: Record<string, any> = {};
@@ -184,6 +243,68 @@ export const processCalls = async (
const calledName = nameNode.text;
// Dispatch: route language-specific calls (heritage, properties, imports)
const routed = callRouter(calledName, captureMap['call']);
if (routed) {
switch (routed.kind) {
case 'skip':
case 'import': // handled by import-processor
return;
case 'heritage':
for (const item of routed.items) {
const childId = symbolTable.lookupExact(file.path, item.enclosingClass) ||
symbolTable.lookupFuzzy(item.enclosingClass)[0]?.nodeId ||
generateId('Class', `${file.path}:${item.enclosingClass}`);
const parentId = symbolTable.lookupFuzzy(item.mixinName)[0]?.nodeId ||
generateId('Module', `${item.mixinName}`);
if (childId && parentId) {
const relId = generateId('IMPLEMENTS', `${childId}->${parentId}:${item.heritageKind}`);
graph.addRelationship({
id: relId, sourceId: childId, targetId: parentId,
type: 'IMPLEMENTS', confidence: 1.0, reason: item.heritageKind,
});
}
}
return;
case 'properties': {
const fileId = generateId('File', file.path);
const propEnclosingClassId = findEnclosingClassId(captureMap['call'], file.path);
for (const item of routed.items) {
const nodeId = generateId('Property', `${file.path}:${item.propName}`);
graph.addNode({
id: nodeId,
label: 'Property' as any, // TODO: add 'Property' to graph node label union
properties: {
name: item.propName, filePath: file.path,
startLine: item.startLine, endLine: item.endLine,
language, isExported: true,
description: item.accessorType,
},
});
symbolTable.add(file.path, item.propName, nodeId, 'Property');
const relId = generateId('DEFINES', `${fileId}->${nodeId}`);
graph.addRelationship({
id: relId, sourceId: fileId, targetId: nodeId,
type: 'DEFINES', confidence: 1.0, reason: '',
});
if (propEnclosingClassId) {
graph.addRelationship({
id: generateId('HAS_METHOD', `${propEnclosingClassId}->${nodeId}`),
sourceId: propEnclosingClassId, targetId: nodeId,
type: 'HAS_METHOD', confidence: 1.0, reason: '',
});
}
}
return;
}
case 'call':
break; // fall through to normal call processing below
}
}
// Skip common built-ins and noise
if (isBuiltInOrNoise(calledName)) return;
@@ -200,10 +321,10 @@ export const processCalls = async (
// 5. Find the enclosing function (caller)
const callNode = captureMap['call'];
const enclosingFuncId = findEnclosingFunction(callNode, file.path, symbolTable);
// Use enclosing function as source, fallback to file for top-level calls
const sourceId = enclosingFuncId || generateId('File', file.path);
const relId = generateId('CALLS', `${sourceId}:${calledName}->${resolved.nodeId}`);
graph.addRelationship({
@@ -711,37 +832,72 @@ const resolveCallTarget = (
* Filter out common built-in functions and noise
* that shouldn't be tracked as calls
*/
const isBuiltInOrNoise = (name: string): boolean => {
const builtIns = new Set([
// JavaScript/TypeScript built-ins
'console', 'log', 'warn', 'error', 'info', 'debug',
'setTimeout', 'setInterval', 'clearTimeout', 'clearInterval',
'parseInt', 'parseFloat', 'isNaN', 'isFinite',
'encodeURI', 'decodeURI', 'encodeURIComponent', 'decodeURIComponent',
'JSON', 'parse', 'stringify',
'Object', 'Array', 'String', 'Number', 'Boolean', 'Symbol', 'BigInt',
'Map', 'Set', 'WeakMap', 'WeakSet',
'Promise', 'resolve', 'reject', 'then', 'catch', 'finally',
'Math', 'Date', 'RegExp', 'Error',
'require', 'import', 'export',
'fetch', 'Response', 'Request',
// React hooks and common functions
'useState', 'useEffect', 'useCallback', 'useMemo', 'useRef', 'useContext',
'useReducer', 'useLayoutEffect', 'useImperativeHandle', 'useDebugValue',
'createElement', 'createContext', 'createRef', 'forwardRef', 'memo', 'lazy',
// Common array/object methods
'map', 'filter', 'reduce', 'forEach', 'find', 'findIndex', 'some', 'every',
'includes', 'indexOf', 'slice', 'splice', 'concat', 'join', 'split',
'push', 'pop', 'shift', 'unshift', 'sort', 'reverse',
'keys', 'values', 'entries', 'assign', 'freeze', 'seal',
'hasOwnProperty', 'toString', 'valueOf',
// Python built-ins
'print', 'len', 'range', 'str', 'int', 'float', 'list', 'dict', 'set', 'tuple',
'open', 'read', 'write', 'close', 'append', 'extend', 'update',
'super', 'type', 'isinstance', 'issubclass', 'getattr', 'setattr', 'hasattr',
'enumerate', 'zip', 'sorted', 'reversed', 'min', 'max', 'sum', 'abs',
]);
/** Pre-built set (module-level singleton) to avoid re-creating per call */
const BUILT_IN_NAMES = new Set([
// JavaScript/TypeScript built-ins
'console', 'log', 'warn', 'error', 'info', 'debug',
'setTimeout', 'setInterval', 'clearTimeout', 'clearInterval',
'parseInt', 'parseFloat', 'isNaN', 'isFinite',
'encodeURI', 'decodeURI', 'encodeURIComponent', 'decodeURIComponent',
'JSON', 'parse', 'stringify',
'Object', 'Array', 'String', 'Number', 'Boolean', 'Symbol', 'BigInt',
'Map', 'Set', 'WeakMap', 'WeakSet',
'Promise', 'resolve', 'reject', 'then', 'catch', 'finally',
'Math', 'Date', 'RegExp', 'Error',
'require', 'import', 'export',
'fetch', 'Response', 'Request',
// React hooks and common functions
'useState', 'useEffect', 'useCallback', 'useMemo', 'useRef', 'useContext',
'useReducer', 'useLayoutEffect', 'useImperativeHandle', 'useDebugValue',
'createElement', 'createContext', 'createRef', 'forwardRef', 'memo', 'lazy',
// Common array/object methods
'map', 'filter', 'reduce', 'forEach', 'find', 'findIndex', 'some', 'every',
'includes', 'indexOf', 'slice', 'splice', 'concat', 'join', 'split',
'push', 'pop', 'shift', 'unshift', 'sort', 'reverse',
'keys', 'values', 'entries', 'assign', 'freeze', 'seal',
'hasOwnProperty', 'toString', 'valueOf',
// Python built-ins
'print', 'len', 'range', 'str', 'int', 'float', 'list', 'dict', 'set', 'tuple',
'open', 'read', 'write', 'close', 'append', 'extend', 'update',
'super', 'type', 'isinstance', 'issubclass', 'getattr', 'setattr', 'hasattr',
'enumerate', 'zip', 'sorted', 'reversed', 'min', 'max', 'sum', 'abs',
// C/C++ standard library and common kernel helpers
'printf', 'fprintf', 'sprintf', 'snprintf', 'vprintf', 'vfprintf', 'vsprintf', 'vsnprintf',
'scanf', 'fscanf', 'sscanf',
'malloc', 'calloc', 'realloc', 'free', 'memcpy', 'memmove', 'memset', 'memcmp',
'strlen', 'strcpy', 'strncpy', 'strcat', 'strncat', 'strcmp', 'strncmp', 'strstr', 'strchr', 'strrchr',
'atoi', 'atol', 'atof', 'strtol', 'strtoul', 'strtoll', 'strtoull', 'strtod',
'sizeof', 'offsetof', 'typeof',
'assert', 'abort', 'exit', '_exit',
'fopen', 'fclose', 'fread', 'fwrite', 'fseek', 'ftell', 'rewind', 'fflush', 'fgets', 'fputs',
// Linux kernel common macros/helpers (not real call targets)
'likely', 'unlikely', 'BUG', 'BUG_ON', 'WARN', 'WARN_ON', 'WARN_ONCE',
'IS_ERR', 'PTR_ERR', 'ERR_PTR', 'IS_ERR_OR_NULL',
'ARRAY_SIZE', 'container_of', 'list_for_each_entry', 'list_for_each_entry_safe',
'min', 'max', 'clamp', 'abs', 'swap',
'pr_info', 'pr_warn', 'pr_err', 'pr_debug', 'pr_notice', 'pr_crit', 'pr_emerg',
'printk', 'dev_info', 'dev_warn', 'dev_err', 'dev_dbg',
'GFP_KERNEL', 'GFP_ATOMIC',
'spin_lock', 'spin_unlock', 'spin_lock_irqsave', 'spin_unlock_irqrestore',
'mutex_lock', 'mutex_unlock', 'mutex_init',
'kfree', 'kmalloc', 'kzalloc', 'kcalloc', 'krealloc', 'kvmalloc', 'kvfree',
'get', 'put',
// Ruby built-ins and Kernel methods
'puts', 'print', 'p', 'pp', 'warn', 'raise', 'fail',
'require', 'require_relative', 'load', 'autoload',
'include', 'extend', 'prepend',
'attr_accessor', 'attr_reader', 'attr_writer',
'public', 'private', 'protected', 'module_function',
'lambda', 'proc', 'block_given?',
'nil?', 'is_a?', 'kind_of?', 'instance_of?', 'respond_to?',
'freeze', 'frozen?', 'dup', 'clone', 'tap', 'then', 'yield_self',
// Ruby enumerables
'each', 'map', 'select', 'reject', 'find', 'detect', 'collect',
'inject', 'reduce', 'flat_map', 'each_with_object', 'each_with_index',
'any?', 'all?', 'none?', 'count', 'first', 'last',
'sort', 'sort_by', 'min', 'max', 'min_by', 'max_by',
'group_by', 'partition', 'zip', 'compact', 'flatten', 'uniq',
]);
return builtIns.has(name);
};
const isBuiltInOrNoise = (name: string): boolean => BUILT_IN_NAMES.has(name);
@@ -0,0 +1,149 @@
/**
* Shared Ruby call routing logic.
*
* Ruby expresses imports, heritage (mixins), and property definitions as
* method calls rather than syntax-level constructs. This module provides a
* routing function used by the CLI call-processor, CLI parse-worker, and
* the web call-processor so that the classification logic lives in one place.
*
* NOTE: This file is intentionally duplicated in gitnexus-web/ because the
* two packages have separate build targets (Node native vs WASM/browser).
* Keep both copies in sync until a shared package is introduced.
*/
import { SupportedLanguages } from '../../config/supported-languages';
// ── Call routing dispatch table ─────────────────────────────────────────────
/** null = this call was not routed; fall through to default call handling */
export type CallRoutingResult = RubyCallRouting | null;
export type CallRouter = (
calledName: string,
callNode: any,
) => CallRoutingResult;
/** No-op router: returns null for every call (passthrough to normal processing) */
const noRouting: CallRouter = () => null;
/** Per-language call routing. noRouting = no special routing (normal call processing) */
export const callRouters = {
[SupportedLanguages.JavaScript]: noRouting,
[SupportedLanguages.TypeScript]: noRouting,
[SupportedLanguages.Python]: noRouting,
[SupportedLanguages.Java]: noRouting,
[SupportedLanguages.Go]: noRouting,
[SupportedLanguages.Rust]: noRouting,
[SupportedLanguages.CSharp]: noRouting,
[SupportedLanguages.PHP]: noRouting,
[SupportedLanguages.Swift]: noRouting,
[SupportedLanguages.CPlusPlus]: noRouting,
[SupportedLanguages.C]: noRouting,
[SupportedLanguages.Ruby]: routeRubyCall,
[SupportedLanguages.Kotlin]: noRouting,
} satisfies Record<SupportedLanguages, CallRouter>;
// ── Result types ────────────────────────────────────────────────────────────
export type RubyCallRouting =
| { kind: 'import'; importPath: string; isRelative: boolean }
| { kind: 'heritage'; items: RubyHeritageItem[] }
| { kind: 'properties'; items: RubyPropertyItem[] }
| { kind: 'call' }
| { kind: 'skip' };
export interface RubyHeritageItem {
enclosingClass: string;
mixinName: string;
heritageKind: 'include' | 'extend' | 'prepend';
}
export type RubyAccessorType = 'attr_accessor' | 'attr_reader' | 'attr_writer';
export interface RubyPropertyItem {
propName: string;
accessorType: RubyAccessorType;
startLine: number;
endLine: number;
}
// ── Pre-allocated singletons for common return values ────────────────────────
const CALL_RESULT: RubyCallRouting = { kind: 'call' };
const SKIP_RESULT: RubyCallRouting = { kind: 'skip' };
/** Max depth for parent-walking loops to prevent pathological AST traversals */
const MAX_PARENT_DEPTH = 50;
// ── Routing function ────────────────────────────────────────────────────────
/**
* Classify a Ruby call node and extract its semantic payload.
*
* @param calledName - The method name (e.g. 'require', 'include', 'attr_accessor')
* @param callNode - The tree-sitter `call` AST node
* @returns A discriminated union describing the call's semantic role
*/
export function routeRubyCall(calledName: string, callNode: any): RubyCallRouting {
// ── require / require_relative → import ─────────────────────────────────
if (calledName === 'require' || calledName === 'require_relative') {
const argList = callNode.childForFieldName?.('arguments');
const stringNode = argList?.children?.find((c: any) => c.type === 'string');
const contentNode = stringNode?.children?.find((c: any) => c.type === 'string_content');
if (!contentNode) return SKIP_RESULT;
let importPath: string = contentNode.text;
// Validate: reject null bytes, control chars, excessively long paths
if (!importPath || importPath.length > 1024 || /[\x00-\x1f]/.test(importPath)) {
return SKIP_RESULT;
}
const isRelative = calledName === 'require_relative';
if (isRelative && !importPath.startsWith('.')) {
importPath = './' + importPath;
}
return { kind: 'import', importPath, isRelative };
}
// ── include / extend / prepend → heritage (mixin) ──────────────────────
if (calledName === 'include' || calledName === 'extend' || calledName === 'prepend') {
let enclosingClass: string | null = null;
let current = callNode.parent;
let depth = 0;
while (current && ++depth <= MAX_PARENT_DEPTH) {
if (current.type === 'class' || current.type === 'module') {
const nameNode = current.childForFieldName?.('name');
if (nameNode) { enclosingClass = nameNode.text; break; }
}
current = current.parent;
}
if (!enclosingClass) return SKIP_RESULT;
const items: RubyHeritageItem[] = [];
const argList = callNode.childForFieldName?.('arguments');
for (const arg of (argList?.children ?? [])) {
if (arg.type === 'constant' || arg.type === 'scope_resolution') {
items.push({ enclosingClass, mixinName: arg.text, heritageKind: calledName as 'include' | 'extend' | 'prepend' });
}
}
return items.length > 0 ? { kind: 'heritage', items } : SKIP_RESULT;
}
// ── attr_accessor / attr_reader / attr_writer → property definitions ───
if (calledName === 'attr_accessor' || calledName === 'attr_reader' || calledName === 'attr_writer') {
const items: RubyPropertyItem[] = [];
const argList = callNode.childForFieldName?.('arguments');
for (const arg of (argList?.children ?? [])) {
if (arg.type === 'simple_symbol') {
items.push({
propName: arg.text.startsWith(':') ? arg.text.slice(1) : arg.text,
accessorType: calledName as RubyAccessorType,
startLine: arg.startPosition.row,
endLine: arg.endPosition.row,
});
}
}
return items.length > 0 ? { kind: 'properties', items } : SKIP_RESULT;
}
// ── Everything else → regular call ─────────────────────────────────────
return CALL_RESULT;
}
@@ -13,7 +13,7 @@
import { detectFrameworkFromPath } from './framework-detection';
// ============================================================================
// NAME PATTERNS - All 9 supported languages
// NAME PATTERNS - All 11 supported languages
// ============================================================================
/**
@@ -143,6 +143,13 @@ const ENTRY_POINT_PATTERNS: Record<string, RegExp[]> = {
/^save$/, // Repository::save()
/^delete$/, // Repository::delete()
],
// Ruby
'ruby': [
/^call$/, // Service objects (MyService.call)
/^perform$/, // Background jobs (Sidekiq, ActiveJob)
/^execute$/, // Command pattern
],
};
// ============================================================================
@@ -302,7 +309,12 @@ export function isTestFile(filePath: string): boolean {
p.endsWith('test.php') ||
p.endsWith('spec.php') ||
p.includes('/tests/feature/') ||
p.includes('/tests/unit/')
p.includes('/tests/unit/') ||
// Ruby test patterns
p.endsWith('_spec.rb') ||
p.endsWith('_test.rb') ||
p.includes('/spec/') ||
p.includes('/test/fixtures/')
);
}
@@ -257,6 +257,17 @@ export function detectFrameworkFromPath(filePath: string): FrameworkHint | null
return { framework: 'laravel', entryPointMultiplier: 1.5, reason: 'laravel-repository' };
}
// ========== RUBY ==========
// Ruby: bin/ or exe/ (CLI entry points)
if ((p.includes('/bin/') || p.includes('/exe/')) && p.endsWith('.rb')) {
return { framework: 'ruby', entryPointMultiplier: 2.5, reason: 'ruby-executable' };
}
// Ruby: Rakefile or *.rake (task definitions)
if (p.endsWith('/rakefile') || p.endsWith('.rake')) {
return { framework: 'ruby', entryPointMultiplier: 1.5, reason: 'ruby-rake' };
}
// ========== SWIFT / iOS ==========
// iOS App entry points (highest priority)
@@ -4,6 +4,7 @@ import { loadParser, loadLanguage } from '../tree-sitter/parser-loader';
import { LANGUAGE_QUERIES } from './tree-sitter-queries';
import { generateId } from '../../lib/utils';
import { getLanguageFromFilename } from './utils';
import { callRouters } from './call-routing';
// Type: Map<FilePath, Set<ResolvedFilePath>>
// Stores all files that a given file imports from
@@ -53,7 +54,9 @@ const resolveImportPath = (
// Go
'.go',
// Rust
'.rs', '/mod.rs'
'.rs', '/mod.rs',
// Ruby
'.rb', '.rake',
];
if (importPath.startsWith('.')) {
@@ -220,6 +223,35 @@ export const processImports = async (
importMap.get(file.path)!.add(resolvedPath);
}
}
// ---- Language-specific call-as-import routing (Ruby require, etc.) ----
if (captureMap['call']) {
const callNameNode = captureMap['call.name'];
if (callNameNode) {
const callRouter = callRouters[language];
const routed = callRouter(callNameNode.text, captureMap['call']);
if (routed && routed.kind === 'import') {
totalImportsFound++;
const resolvedPath = resolveImportPath(
file.path, routed.importPath, allFilePaths, allFileList, resolveCache
);
if (resolvedPath) {
const sourceId = generateId('File', file.path);
const targetId = generateId('File', resolvedPath);
const relId = generateId('IMPORTS', `${file.path}->${resolvedPath}`);
totalImportsResolved++;
graph.addRelationship({
id: relId, sourceId, targetId,
type: 'IMPORTS', confidence: 1.0, reason: '',
});
if (!importMap.has(file.path)) {
importMap.set(file.path, new Set());
}
importMap.get(file.path)!.add(resolvedPath);
}
}
}
}
});
// If re-parsed just for this, delete the tree to save memory
@@ -14,7 +14,7 @@ export type FileProgressCallback = (current: number, total: number, filePath: st
/**
* Check if a symbol (function, class, etc.) is exported/public
* Handles all 9 supported languages with explicit logic
* Handles all 11 supported languages with explicit logic
*
* @param node - The AST node for the symbol name
* @param name - The symbol name
@@ -104,7 +104,11 @@ const isNodeExported = (node: any, name: string, language: string): boolean => {
case 'c':
case 'cpp':
return false;
// Ruby: All top-level definitions are public by default
case 'ruby':
return true;
default:
return false;
}
@@ -396,6 +396,40 @@ export const PHP_QUERIES = `
[(name) (qualified_name)] @heritage.trait))) @heritage
`;
// Ruby queries - works with tree-sitter-ruby
// NOTE: Ruby uses `call` for require, include, extend, prepend, attr_* etc.
// These are all captured as @call and routed in JS post-processing:
// - require/require_relative → import extraction
// - include/extend/prepend → heritage (mixin) extraction
// - attr_accessor/attr_reader/attr_writer → property definition extraction
// - everything else → regular call extraction
export const RUBY_QUERIES = `
; ── Modules ──────────────────────────────────────────────────────────────────
(module
name: (constant) @name) @definition.module
; ── Classes ──────────────────────────────────────────────────────────────────
(class
name: (constant) @name) @definition.class
; ── Instance methods ─────────────────────────────────────────────────────────
(method
name: (identifier) @name) @definition.method
; ── Singleton (class-level) methods ──────────────────────────────────────────
(singleton_method
name: (identifier) @name) @definition.function
; ── All calls (require, include, attr_*, and regular calls routed in JS) ─────
(call
method: (identifier) @call.name) @call
; ── Heritage: class < SuperClass ─────────────────────────────────────────────
(class
name: (constant) @heritage.class
superclass: (superclass
(constant) @heritage.extends)) @heritage`;
// Swift queries - works with tree-sitter-swift
export const SWIFT_QUERIES = `
; Classes
@@ -460,6 +494,8 @@ export const LANGUAGE_QUERIES: Record<SupportedLanguages, string> = {
[SupportedLanguages.CSharp]: CSHARP_QUERIES,
[SupportedLanguages.Rust]: RUST_QUERIES,
[SupportedLanguages.PHP]: PHP_QUERIES,
[SupportedLanguages.Ruby]: RUBY_QUERIES,
[SupportedLanguages.Kotlin]: '', // Kotlin WASM parser not yet available for web
[SupportedLanguages.Swift]: SWIFT_QUERIES,
};
+12
View File
@@ -1,5 +1,8 @@
import { SupportedLanguages } from '../../config/supported-languages';
/** Ruby extensionless filenames recognised as Ruby source */
const RUBY_EXTENSIONLESS_FILES = new Set(['Rakefile', 'Gemfile', 'Guardfile', 'Vagrantfile', 'Brewfile']);
/**
* Map file extension to SupportedLanguage enum
*/
@@ -31,6 +34,15 @@ export const getLanguageFromFilename = (filename: string): SupportedLanguages |
filename.endsWith('.php5') || filename.endsWith('.php8')) {
return SupportedLanguages.PHP;
}
// Ruby (extensions)
if (filename.endsWith('.rb') || filename.endsWith('.rake') || filename.endsWith('.gemspec')) {
return SupportedLanguages.Ruby;
}
// Ruby (extensionless files)
const basename = filename.split('/').pop() || filename;
if (RUBY_EXTENSIONLESS_FILES.has(basename)) {
return SupportedLanguages.Ruby;
}
// Swift
if (filename.endsWith('.swift')) return SupportedLanguages.Swift;
return null;
@@ -1,5 +1,5 @@
/**
* CSV Generator for KuzuDB Hybrid Schema
* CSV Generator for LadybugDB Hybrid Schema
*
* Generates separate CSV files for each node table and one relation CSV.
* This enables efficient bulk loading via COPY FROM for hybrid schema.
@@ -18,10 +18,10 @@ import { NODE_TABLES, NodeTableName } from './schema';
// ============================================================================
/**
* Sanitize string to ensure valid UTF-8 and safe CSV content for KuzuDB
* Sanitize string to ensure valid UTF-8 and safe CSV content for LadybugDB
* Removes or replaces invalid characters that would break CSV parsing.
*
* Critical: KuzuDB's CSV parser can misinterpret \r\n inside quoted fields.
* Critical: LadybugDB's CSV parser can misinterpret \r\n inside quoted fields.
* We normalize all line endings to \n only.
*/
const sanitizeUTF8 = (str: string): string => {
@@ -213,7 +213,7 @@ const generateCommunityCSV = (nodes: GraphNode[]): string => {
for (const node of nodes) {
if (node.label !== 'Community') continue;
// Handle keywords array - convert to KuzuDB array format
// Handle keywords array - convert to LadybugDB array format
const keywords = (node.properties as any).keywords || [];
const keywordsStr = `[${keywords.map((k: string) => `'${k.replace(/'/g, "''")}'`).join(',')}]`;
@@ -221,7 +221,7 @@ const generateCommunityCSV = (nodes: GraphNode[]): string => {
escapeCSVField(node.id),
escapeCSVField(node.properties.name || ''), // label is stored in name
escapeCSVField(node.properties.heuristicLabel || ''),
keywordsStr, // Array format for KuzuDB
keywordsStr, // Array format for LadybugDB
escapeCSVField((node.properties as any).description || ''),
escapeCSVField((node.properties as any).enrichedBy || 'heuristic'),
escapeCSVNumber(node.properties.cohesion, 0),
@@ -1,51 +1,51 @@
/**
* KuzuDB Adapter
*
* Manages the KuzuDB WASM instance for client-side graph database operations.
* LadybugDB Adapter
*
* Manages the LadybugDB WASM instance for client-side graph database operations.
* Uses the "Snapshot / Bulk Load" pattern with COPY FROM for performance.
*
*
* Multi-table schema: separate tables for File, Function, Class, etc.
*/
import { KnowledgeGraph } from '../graph/types';
import {
NODE_TABLES,
import {
NODE_TABLES,
REL_TABLE_NAME,
SCHEMA_QUERIES,
SCHEMA_QUERIES,
EMBEDDING_TABLE_NAME,
NodeTableName,
} from './schema';
import { generateAllCSVs } from './csv-generator';
// Holds the reference to the dynamically loaded module
let kuzu: any = null;
let lbug: any = null;
let db: any = null;
let conn: any = null;
/**
* Initialize KuzuDB WASM module and create in-memory database
* Initialize LadybugDB WASM module and create in-memory database
*/
export const initKuzu = async () => {
if (conn) return { db, conn, kuzu };
export const initLbug = async () => {
if (conn) return { db, conn, lbug };
try {
if (import.meta.env.DEV) console.log('🚀 Initializing KuzuDB...');
if (import.meta.env.DEV) console.log('🚀 Initializing LadybugDB...');
// 1. Dynamic Import (Fixes the "not a function" bundler issue)
const kuzuModule = await import('kuzu-wasm');
const lbugModule = await import('@ladybugdb/wasm-core');
// 2. Handle Vite/Webpack "default" wrapping
kuzu = kuzuModule.default || kuzuModule;
lbug = lbugModule.default || lbugModule;
// 3. Initialize WASM
await kuzu.init();
// 4. Create Database with 512MB buffer pool
await lbug.init();
// 4. Create Database with 512MB buffer manager
const BUFFER_POOL_SIZE = 512 * 1024 * 1024; // 512MB
db = new kuzu.Database(':memory:', BUFFER_POOL_SIZE);
conn = new kuzu.Connection(db);
if (import.meta.env.DEV) console.log('✅ KuzuDB WASM Initialized');
db = new lbug.Database(':memory:', BUFFER_POOL_SIZE);
conn = new lbug.Connection(db);
if (import.meta.env.DEV) console.log('✅ LadybugDB WASM Initialized');
// 5. Initialize Schema (all node tables, then rel tables, then embedding table)
for (const schemaQuery of SCHEMA_QUERIES) {
@@ -58,60 +58,60 @@ export const initKuzu = async () => {
}
}
}
if (import.meta.env.DEV) console.log('✅ KuzuDB Multi-Table Schema Created');
return { db, conn, kuzu };
if (import.meta.env.DEV) console.log('✅ LadybugDB Multi-Table Schema Created');
return { db, conn, lbug };
} catch (error) {
if (import.meta.env.DEV) console.error('❌ KuzuDB Initialization Failed:', error);
if (import.meta.env.DEV) console.error('❌ LadybugDB Initialization Failed:', error);
throw error;
}
};
/**
* Load a KnowledgeGraph into KuzuDB using COPY FROM (bulk load)
* Load a KnowledgeGraph into LadybugDB using COPY FROM (bulk load)
* Uses batched CSV writes and COPY statements for optimal performance
*/
export const loadGraphToKuzu = async (
graph: KnowledgeGraph,
export const loadGraphToLbug = async (
graph: KnowledgeGraph,
fileContents: Map<string, string>
) => {
const { conn, kuzu } = await initKuzu();
const { conn, lbug } = await initLbug();
try {
if (import.meta.env.DEV) console.log(`KuzuDB: Generating CSVs for ${graph.nodeCount} nodes...`);
if (import.meta.env.DEV) console.log(`LadybugDB: Generating CSVs for ${graph.nodeCount} nodes...`);
// 1. Generate all CSVs (per-table)
const csvData = generateAllCSVs(graph, fileContents);
const fs = kuzu.FS;
const fs = lbug.FS;
// 2. Write all node CSVs to virtual filesystem
const nodeFiles: Array<{ table: NodeTableName; path: string }> = [];
for (const [tableName, csv] of csvData.nodes.entries()) {
// Skip empty CSVs (only header row)
if (csv.split('\n').length <= 1) continue;
const path = `/${tableName.toLowerCase()}.csv`;
try { await fs.unlink(path); } catch {}
await fs.writeFile(path, csv);
nodeFiles.push({ table: tableName, path });
}
// 3. Parse relation CSV and prepare for INSERT (COPY FROM doesn't work with multi-pair tables)
const relLines = csvData.relCSV.split('\n').slice(1).filter(line => line.trim());
const relCount = relLines.length;
if (import.meta.env.DEV) {
console.log(`KuzuDB: Wrote ${nodeFiles.length} node CSVs, ${relCount} relations to insert`);
console.log(`LadybugDB: Wrote ${nodeFiles.length} node CSVs, ${relCount} relations to insert`);
}
// 4. COPY all node tables (must complete before rels due to FK constraints)
for (const { table, path } of nodeFiles) {
const copyQuery = getCopyQuery(table, path);
await conn.query(copyQuery);
}
// 5. INSERT relations one by one (COPY doesn't work with multi-pair REL tables)
// Build a set of valid table names for fast lookup
const validTables = new Set<string>(NODE_TABLES as readonly string[]);
@@ -135,13 +135,13 @@ export const loadGraphToKuzu = async (
// Format: "from","to","type",confidence,"reason",step
const match = line.match(/"([^"]*)","([^"]*)","([^"]*)",([0-9.]+),"([^"]*)",([0-9-]+)/);
if (!match) continue;
const [, fromId, toId, relType, confidenceStr, reason, stepStr] = match;
const fromLabel = getNodeLabel(fromId);
const toLabel = getNodeLabel(toId);
// Skip relationships where either node's label doesn't have a table in KuzuDB
// Skip relationships where either node's label doesn't have a table in LadybugDB
// Querying a non-existent table causes a fatal native crash
if (!validTables.has(fromLabel) || !validTables.has(toLabel)) {
skippedRels++;
@@ -150,7 +150,7 @@ export const loadGraphToKuzu = async (
const confidence = parseFloat(confidenceStr) || 1.0;
const step = parseInt(stepStr) || 0;
const insertQuery = `
MATCH (a:${escapeLabel(fromLabel)} {id: '${fromId.replace(/'/g, "''")}'}),
(b:${escapeLabel(toLabel)} {id: '${toId.replace(/'/g, "''")}'})
@@ -167,38 +167,39 @@ export const loadGraphToKuzu = async (
const toLabel = getNodeLabel(toId);
const key = `${relType}:${fromLabel}->` + toLabel;
skippedRelStats.set(key, (skippedRelStats.get(key) || 0) + 1);
if (import.meta.env.DEV) {
console.warn(`⚠️ Skipped: ${key} | "${fromId}" → "${toId}" | ${err instanceof Error ? err.message : String(err)}`);
}
}
}
}
if (import.meta.env.DEV) {
console.log(`KuzuDB: Inserted ${insertedRels}/${relCount} relations`);
console.log(`LadybugDB: Inserted ${insertedRels}/${relCount} relations`);
if (skippedRels > 0) {
const topSkipped = Array.from(skippedRelStats.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 10);
console.warn(`KuzuDB: Skipped ${skippedRels}/${relCount} relations (top by kind/pair):`, topSkipped);
console.warn(`LadybugDB: Skipped ${skippedRels}/${relCount} relations (top by kind/pair):`, topSkipped);
}
}
// 6. Verify results
let totalNodes = 0;
for (const tableName of NODE_TABLES) {
try {
const countRes = await conn.query(`MATCH (n:${tableName}) RETURN count(n) AS cnt`);
const countRow = await countRes.getNext();
const countRows = await countRes.getAll();
const countRow = countRows[0];
const count = countRow ? (countRow.cnt ?? countRow[0] ?? 0) : 0;
totalNodes += Number(count);
} catch {
// Table might be empty, skip
}
}
if (import.meta.env.DEV) console.log(`✅ KuzuDB Bulk Load Complete. Total nodes: ${totalNodes}, edges: ${insertedRels}`);
if (import.meta.env.DEV) console.log(`✅ LadybugDB Bulk Load Complete. Total nodes: ${totalNodes}, edges: ${insertedRels}`);
// 7. Cleanup CSV files
for (const { path } of nodeFiles) {
@@ -208,12 +209,12 @@ export const loadGraphToKuzu = async (
return { success: true, count: totalNodes };
} catch (error) {
if (import.meta.env.DEV) console.error('❌ KuzuDB Bulk Load Failed:', error);
if (import.meta.env.DEV) console.error('❌ LadybugDB Bulk Load Failed:', error);
return { success: false, count: 0 };
}
};
// KuzuDB default ESCAPE is '\' (backslash), but our CSV uses RFC 4180 escaping ("" for literal quotes).
// LadybugDB default ESCAPE is '\' (backslash), but our CSV uses RFC 4180 escaping ("" for literal quotes).
// Source code content is full of backslashes which confuse the auto-detection.
// We MUST explicitly set ESCAPE='"' and disable auto_detect.
const COPY_CSV_OPTS = `(HEADER=true, ESCAPE='"', DELIM=',', QUOTE='"', PARALLEL=false, auto_detect=false)`;
@@ -229,6 +230,9 @@ const escapeTableName = (table: string): string => {
return BACKTICK_TABLES.has(table) ? `\`${table}\`` : table;
};
/** Tables with isExported column (TypeScript/JS-native types) */
const TABLES_WITH_EXPORTED = new Set<string>(['Function', 'Class', 'Interface', 'Method', 'CodeElement']);
/**
* Get the COPY query for a node table with correct column mapping
*/
@@ -246,8 +250,12 @@ const getCopyQuery = (table: NodeTableName, path: string): string => {
if (table === 'Process') {
return `COPY ${t}(id, label, heuristicLabel, processType, stepCount, communities, entryPointId, terminalId) FROM "${path}" ${COPY_CSV_OPTS}`;
}
// Code element tables (Function, Class, Interface, Method, CodeElement, and multi-language)
return `COPY ${t}(id, name, filePath, startLine, endLine, isExported, content) FROM "${path}" ${COPY_CSV_OPTS}`;
// TypeScript/JS code element tables have isExported; multi-language tables do not
if (TABLES_WITH_EXPORTED.has(table)) {
return `COPY ${t}(id, name, filePath, startLine, endLine, isExported, content) FROM "${path}" ${COPY_CSV_OPTS}`;
}
// Multi-language tables (Struct, Impl, Trait, Macro, etc.)
return `COPY ${t}(id, name, filePath, startLine, endLine, content) FROM "${path}" ${COPY_CSV_OPTS}`;
};
/**
@@ -256,12 +264,12 @@ const getCopyQuery = (table: NodeTableName, path: string): string => {
*/
export const executeQuery = async (cypher: string): Promise<any[]> => {
if (!conn) {
await initKuzu();
await initLbug();
}
try {
const result = await conn.query(cypher);
// Extract column names from RETURN clause
const returnMatch = cypher.match(/RETURN\s+(.+?)(?:\s+ORDER|\s+LIMIT|\s+SKIP|\s*$)/is);
let columnNames: string[] = [];
@@ -284,12 +292,11 @@ export const executeQuery = async (cypher: string): Promise<any[]> => {
return col.replace(/[^a-zA-Z0-9_]/g, '_');
});
}
// Collect all rows
const allRows = await result.getAll();
const rows: any[] = [];
while (await result.hasNext()) {
const row = await result.getNext();
for (const row of allRows) {
// Convert tuple to named object if we have column names and row is array
if (Array.isArray(row) && columnNames.length === row.length) {
const namedRow: Record<string, any> = {};
@@ -302,7 +309,7 @@ export const executeQuery = async (cypher: string): Promise<any[]> => {
rows.push(row);
}
}
return rows;
} catch (error) {
if (import.meta.env.DEV) console.error('Query execution failed:', error);
@@ -313,7 +320,7 @@ export const executeQuery = async (cypher: string): Promise<any[]> => {
/**
* Get database statistics
*/
export const getKuzuStats = async (): Promise<{ nodes: number; edges: number }> => {
export const getLbugStats = async (): Promise<{ nodes: number; edges: number }> => {
if (!conn) {
return { nodes: 0, edges: 0 };
}
@@ -324,43 +331,45 @@ export const getKuzuStats = async (): Promise<{ nodes: number; edges: number }>
for (const tableName of NODE_TABLES) {
try {
const nodeResult = await conn.query(`MATCH (n:${tableName}) RETURN count(n) AS cnt`);
const nodeRow = await nodeResult.getNext();
const nodeRows = await nodeResult.getAll();
const nodeRow = nodeRows[0];
totalNodes += Number(nodeRow?.cnt ?? nodeRow?.[0] ?? 0);
} catch {
// Table might not exist or be empty
}
}
// Count edges from single relation table
let totalEdges = 0;
try {
const edgeResult = await conn.query(`MATCH ()-[r:${REL_TABLE_NAME}]->() RETURN count(r) AS cnt`);
const edgeRow = await edgeResult.getNext();
const edgeRows = await edgeResult.getAll();
const edgeRow = edgeRows[0];
totalEdges = Number(edgeRow?.cnt ?? edgeRow?.[0] ?? 0);
} catch {
// Table might not exist or be empty
}
return { nodes: totalNodes, edges: totalEdges };
} catch (error) {
if (import.meta.env.DEV) {
console.warn('Failed to get Kuzu stats:', error);
console.warn('Failed to get LadybugDB stats:', error);
}
return { nodes: 0, edges: 0 };
}
};
/**
* Check if KuzuDB is initialized and has data
* Check if LadybugDB is initialized and has data
*/
export const isKuzuReady = (): boolean => {
export const isLbugReady = (): boolean => {
return conn !== null && db !== null;
};
/**
* Close the database connection (cleanup)
*/
export const closeKuzu = async (): Promise<void> => {
export const closeLbug = async (): Promise<void> => {
if (conn) {
try {
await conn.close();
@@ -373,7 +382,7 @@ export const closeKuzu = async (): Promise<void> => {
} catch {}
db = null;
}
kuzu = null;
lbug = null;
};
/**
@@ -387,24 +396,20 @@ export const executePrepared = async (
params: Record<string, any>
): Promise<any[]> => {
if (!conn) {
await initKuzu();
await initLbug();
}
try {
const stmt = await conn.prepare(cypher);
if (!stmt.isSuccess()) {
const errMsg = await stmt.getErrorMessage();
throw new Error(`Prepare failed: ${errMsg}`);
}
const result = await conn.execute(stmt, params);
const rows: any[] = [];
while (await result.hasNext()) {
const row = await result.getNext();
rows.push(row);
}
const rows = await result.getAll();
await stmt.close();
return rows;
} catch (error) {
@@ -421,22 +426,22 @@ export const executeWithReusedStatement = async (
paramsList: Array<Record<string, any>>
): Promise<void> => {
if (!conn) {
await initKuzu();
await initLbug();
}
if (paramsList.length === 0) return;
const SUB_BATCH_SIZE = 4;
for (let i = 0; i < paramsList.length; i += SUB_BATCH_SIZE) {
const subBatch = paramsList.slice(i, i + SUB_BATCH_SIZE);
const stmt = await conn.prepare(cypher);
if (!stmt.isSuccess()) {
const errMsg = await stmt.getErrorMessage();
throw new Error(`Prepare failed: ${errMsg}`);
}
try {
for (const params of subBatch) {
await conn.execute(stmt, params);
@@ -444,7 +449,7 @@ export const executeWithReusedStatement = async (
} finally {
await stmt.close();
}
if (i + SUB_BATCH_SIZE < paramsList.length) {
await new Promise(r => setTimeout(r, 0));
}
@@ -456,65 +461,67 @@ export const executeWithReusedStatement = async (
*/
export const testArrayParams = async (): Promise<{ success: boolean; error?: string }> => {
if (!conn) {
await initKuzu();
await initLbug();
}
try {
const testEmbedding = new Array(384).fill(0).map((_, i) => i / 384);
// Get any node ID to test with (try File first, then others)
let testNodeId: string | null = null;
for (const tableName of NODE_TABLES) {
try {
const nodeResult = await conn.query(`MATCH (n:${tableName}) RETURN n.id AS id LIMIT 1`);
const nodeRow = await nodeResult.getNext();
const nodeRows = await nodeResult.getAll();
const nodeRow = nodeRows[0];
if (nodeRow) {
testNodeId = nodeRow.id ?? nodeRow[0];
break;
}
} catch {}
}
if (!testNodeId) {
return { success: false, error: 'No nodes found to test with' };
}
if (import.meta.env.DEV) {
console.log('🧪 Testing array params with node:', testNodeId);
}
// First create an embedding entry
const createQuery = `CREATE (e:${EMBEDDING_TABLE_NAME} {nodeId: $nodeId, embedding: $embedding})`;
const stmt = await conn.prepare(createQuery);
if (!stmt.isSuccess()) {
const errMsg = await stmt.getErrorMessage();
return { success: false, error: `Prepare failed: ${errMsg}` };
}
await conn.execute(stmt, {
nodeId: testNodeId,
embedding: testEmbedding,
});
await stmt.close();
// Verify it was stored
const verifyResult = await conn.query(
`MATCH (e:${EMBEDDING_TABLE_NAME} {nodeId: '${testNodeId}'}) RETURN e.embedding AS emb`
);
const verifyRow = await verifyResult.getNext();
const verifyRows = await verifyResult.getAll();
const verifyRow = verifyRows[0];
const storedEmb = verifyRow?.emb ?? verifyRow?.[0];
if (storedEmb && Array.isArray(storedEmb) && storedEmb.length === 384) {
if (import.meta.env.DEV) {
console.log('✅ Array params WORK! Stored embedding length:', storedEmb.length);
}
return { success: true };
} else {
return {
success: false,
error: `Embedding not stored correctly. Got: ${typeof storedEmb}, length: ${storedEmb?.length}`
return {
success: false,
error: `Embedding not stored correctly. Got: ${typeof storedEmb}, length: ${storedEmb?.length}`
};
}
} catch (error) {
@@ -1,5 +1,5 @@
/**
* KuzuDB Schema Definitions
* LadybugDB Schema Definitions
*
* Hybrid Schema:
* - Separate node tables for each code element type (File, Function, Class, etc.)
+2 -2
View File
@@ -17,7 +17,7 @@ import { z } from 'zod';
import { WebGPUNotAvailableError, embedText, embeddingToArray, initEmbedder, isEmbedderReady } from '../embeddings/embedder';
/**
* Tool factory - creates tools bound to the KuzuDB query functions
* Tool factory - creates tools bound to the LadybugDB query functions
*/
export const createGraphRAGTools = (
executeQuery: (cypher: string) => Promise<any[]>,
@@ -975,7 +975,7 @@ MATCH (n:Function {id: emb.nodeId}) RETURN n`,
// For code elements (Function, Class, etc.), use the direct id
const isFileTarget = targetType === 'File';
// Query each depth level separately (KuzuDB doesn't support list comprehensions on paths)
// Query each depth level separately (LadybugDB doesn't support list comprehensions on paths)
// For depth 1: direct connections only
// For depth 2+: chain multiple single-hop queries
const depthQueries: Promise<any[]>[] = [];
+1 -1
View File
@@ -224,7 +224,7 @@ export interface AgentStep {
* Graph schema information for LLM context
*/
export const GRAPH_SCHEMA_DESCRIPTION = `
KUZU GRAPH DATABASE SCHEMA (Multi-Table):
LADYBUG GRAPH DATABASE SCHEMA (Multi-Table):
NODE TABLES:
1. File - Source files
@@ -40,6 +40,8 @@ const getWasmPath = (language: SupportedLanguages, filePath?: string): string =>
[SupportedLanguages.Go]: '/wasm/go/tree-sitter-go.wasm',
[SupportedLanguages.Rust]: '/wasm/rust/tree-sitter-rust.wasm',
[SupportedLanguages.PHP]: '/wasm/php/tree-sitter-php.wasm',
[SupportedLanguages.Ruby]: '/wasm/ruby/tree-sitter-ruby.wasm',
[SupportedLanguages.Kotlin]: '', // Kotlin WASM parser not yet available for web
[SupportedLanguages.Swift]: '/wasm/swift/tree-sitter-swift.wasm',
};
@@ -1,28 +1,35 @@
declare module 'kuzu-wasm' {
declare module '@ladybugdb/wasm-core' {
export function init(): Promise<void>;
export class Database {
constructor(path: string);
constructor(path: string, bufferPoolSize?: number);
close(): Promise<void>;
}
export class Connection {
constructor(db: Database);
query(cypher: string): Promise<QueryResult>;
prepare(cypher: string): Promise<PreparedStatement>;
execute(stmt: PreparedStatement, params?: Record<string, any>): Promise<QueryResult>;
close(): Promise<void>;
}
export interface QueryResult {
getAll(): Promise<any[]>;
hasNext(): Promise<boolean>;
getNext(): Promise<any>;
}
export interface PreparedStatement {
isSuccess(): boolean;
getErrorMessage(): Promise<string>;
close(): Promise<void>;
}
export const FS: {
writeFile(path: string, data: string): Promise<void>;
unlink(path: string): Promise<void>;
};
const kuzu: {
const lbug: {
init: typeof init;
Database: typeof Database;
Connection: typeof Connection;
FS: typeof FS;
};
export default kuzu;
export default lbug;
}
+66 -66
View File
@@ -26,13 +26,13 @@ import {
type HybridSearchResult,
} from '../core/search';
// Lazy import for Kuzu to avoid breaking worker if SharedArrayBuffer unavailable
let kuzuAdapter: typeof import('../core/kuzu/kuzu-adapter') | null = null;
const getKuzuAdapter = async () => {
if (!kuzuAdapter) {
kuzuAdapter = await import('../core/kuzu/kuzu-adapter');
// Lazy import for LadybugDB to avoid breaking worker if SharedArrayBuffer unavailable
let lbugAdapter: typeof import('../core/lbug/lbug-adapter') | null = null;
const getLbugAdapter = async () => {
if (!lbugAdapter) {
lbugAdapter = await import('../core/lbug/lbug-adapter');
}
return kuzuAdapter;
return lbugAdapter;
};
// Embedding state
@@ -172,52 +172,52 @@ const workerApi = {
console.log(`🔍 BM25 index built: ${bm25DocCount} documents`);
}
// Load graph into KuzuDB for querying (optional - gracefully degrades)
// Load graph into LadybugDB for querying (optional - gracefully degrades)
try {
onProgress({
phase: 'complete',
percent: 98,
message: 'Loading into KuzuDB...',
message: 'Loading into LadybugDB...',
stats: {
filesProcessed: result.graph.nodeCount,
totalFiles: result.graph.nodeCount,
nodesCreated: result.graph.nodeCount,
},
});
const kuzu = await getKuzuAdapter();
await kuzu.loadGraphToKuzu(result.graph, result.fileContents);
const lbug = await getLbugAdapter();
await lbug.loadGraphToLbug(result.graph, result.fileContents);
if (import.meta.env.DEV) {
const stats = await kuzu.getKuzuStats();
console.log('KuzuDB loaded:', stats);
const stats = await lbug.getLbugStats();
console.log('LadybugDB loaded:', stats);
console.log('📁 Stored', storedFileContents.size, 'files for grep/read tools');
}
} catch {
// KuzuDB is optional - silently continue without it
// LadybugDB is optional - silently continue without it
}
// Store clustering config for background enrichment (runs after graph loads)
if (clusteringConfig) {
pendingEnrichmentConfig = clusteringConfig;
console.log('📋 Clustering config saved for background enrichment');
}
// Convert to serializable format for transfer back to main thread
return serializePipelineResult(result);
},
/**
* Execute a Cypher query against the KuzuDB database
* Execute a Cypher query against the LadybugDB database
* @param cypher - The Cypher query string
* @returns Query results as an array of objects
*/
async runQuery(cypher: string): Promise<any[]> {
const kuzu = await getKuzuAdapter();
if (!kuzu.isKuzuReady()) {
const lbug = await getLbugAdapter();
if (!lbug.isLbugReady()) {
throw new Error('Database not ready. Please load a repository first.');
}
return kuzu.executeQuery(cypher);
return lbug.executeQuery(cypher);
},
/**
@@ -225,8 +225,8 @@ const workerApi = {
*/
async isReady(): Promise<boolean> {
try {
const kuzu = await getKuzuAdapter();
return kuzu.isKuzuReady();
const lbug = await getLbugAdapter();
return lbug.isLbugReady();
} catch {
return false;
}
@@ -237,8 +237,8 @@ const workerApi = {
*/
async getStats(): Promise<{ nodes: number; edges: number }> {
try {
const kuzu = await getKuzuAdapter();
return kuzu.getKuzuStats();
const lbug = await getLbugAdapter();
return lbug.getLbugStats();
} catch {
return { nodes: 0, edges: 0 };
}
@@ -276,29 +276,29 @@ const workerApi = {
console.log(`🔍 BM25 index built: ${bm25DocCount} documents`);
}
// Load graph into KuzuDB for querying (optional - gracefully degrades)
// Load graph into LadybugDB for querying (optional - gracefully degrades)
try {
onProgress({
phase: 'complete',
percent: 98,
message: 'Loading into KuzuDB...',
message: 'Loading into LadybugDB...',
stats: {
filesProcessed: result.graph.nodeCount,
totalFiles: result.graph.nodeCount,
nodesCreated: result.graph.nodeCount,
},
});
const kuzu = await getKuzuAdapter();
await kuzu.loadGraphToKuzu(result.graph, result.fileContents);
const lbug = await getLbugAdapter();
await lbug.loadGraphToLbug(result.graph, result.fileContents);
if (import.meta.env.DEV) {
const stats = await kuzu.getKuzuStats();
console.log('KuzuDB loaded:', stats);
const stats = await lbug.getLbugStats();
console.log('LadybugDB loaded:', stats);
console.log('📁 Stored', storedFileContents.size, 'files for grep/read tools');
}
} catch {
// KuzuDB is optional - silently continue without it
// LadybugDB is optional - silently continue without it
}
// Store clustering config for background enrichment (runs after graph loads)
@@ -325,8 +325,8 @@ const workerApi = {
onProgress: (progress: EmbeddingProgress) => void,
forceDevice?: 'webgpu' | 'wasm'
): Promise<void> {
const kuzu = await getKuzuAdapter();
if (!kuzu.isKuzuReady()) {
const lbug = await getLbugAdapter();
if (!lbug.isLbugReady()) {
throw new Error('Database not ready. Please load a repository first.');
}
@@ -343,8 +343,8 @@ const workerApi = {
};
await runEmbeddingPipeline(
kuzu.executeQuery,
kuzu.executeWithReusedStatement,
lbug.executeQuery,
lbug.executeWithReusedStatement,
progressCallback,
forceDevice ? { device: forceDevice } : {}
);
@@ -400,15 +400,15 @@ const workerApi = {
k: number = 10,
maxDistance: number = 0.5
): Promise<SemanticSearchResult[]> {
const kuzu = await getKuzuAdapter();
if (!kuzu.isKuzuReady()) {
const lbug = await getLbugAdapter();
if (!lbug.isLbugReady()) {
throw new Error('Database not ready. Please load a repository first.');
}
if (!isEmbeddingComplete) {
throw new Error('Embeddings not ready. Please wait for embedding pipeline to complete.');
}
return doSemanticSearch(kuzu.executeQuery, query, k, maxDistance);
return doSemanticSearch(lbug.executeQuery, query, k, maxDistance);
},
/**
@@ -424,15 +424,15 @@ const workerApi = {
k: number = 5,
hops: number = 2
): Promise<any[]> {
const kuzu = await getKuzuAdapter();
if (!kuzu.isKuzuReady()) {
const lbug = await getLbugAdapter();
if (!lbug.isLbugReady()) {
throw new Error('Database not ready. Please load a repository first.');
}
if (!isEmbeddingComplete) {
throw new Error('Embeddings not ready. Please wait for embedding pipeline to complete.');
}
return doSemanticSearchWithContext(kuzu.executeQuery, query, k, hops);
return doSemanticSearchWithContext(lbug.executeQuery, query, k, hops);
},
/**
@@ -458,9 +458,9 @@ const workerApi = {
let semanticResults: SemanticSearchResult[] = [];
if (isEmbeddingComplete) {
try {
const kuzu = await getKuzuAdapter();
if (kuzu.isKuzuReady()) {
semanticResults = await doSemanticSearch(kuzu.executeQuery, query, k * 3, 0.5);
const lbug = await getLbugAdapter();
if (lbug.isLbugReady()) {
semanticResults = await doSemanticSearch(lbug.executeQuery, query, k * 3, 0.5);
}
} catch {
// Semantic search failed, continue with BM25 only
@@ -516,15 +516,15 @@ const workerApi = {
},
/**
* Test if KuzuDB supports array parameters in prepared statements
* Test if LadybugDB supports array parameters in prepared statements
* This is a diagnostic function
*/
async testArrayParams(): Promise<{ success: boolean; error?: string }> {
const kuzu = await getKuzuAdapter();
if (!kuzu.isKuzuReady()) {
const lbug = await getLbugAdapter();
if (!lbug.isLbugReady()) {
return { success: false, error: 'Database not ready' };
}
return kuzu.testArrayParams();
return lbug.testArrayParams();
},
// ============================================================
@@ -539,8 +539,8 @@ const workerApi = {
*/
async initializeAgent(config: ProviderConfig, projectName?: string): Promise<{ success: boolean; error?: string }> {
try {
const kuzu = await getKuzuAdapter();
if (!kuzu.isKuzuReady()) {
const lbug = await getLbugAdapter();
if (!lbug.isLbugReady()) {
return { success: false, error: 'Database not ready. Please load a repository first.' };
}
@@ -549,31 +549,31 @@ const workerApi = {
if (!isEmbeddingComplete) {
throw new Error('Embeddings not ready');
}
return doSemanticSearch(kuzu.executeQuery, query, k, maxDistance);
return doSemanticSearch(lbug.executeQuery, query, k, maxDistance);
};
const semanticSearchWithContextWrapper = async (query: string, k?: number, hops?: number) => {
if (!isEmbeddingComplete) {
throw new Error('Embeddings not ready');
}
return doSemanticSearchWithContext(kuzu.executeQuery, query, k, hops);
return doSemanticSearchWithContext(lbug.executeQuery, query, k, hops);
};
// Hybrid search wrapper - combines BM25 + semantic
const hybridSearchWrapper = async (query: string, k?: number) => {
// Get BM25 results (always available after ingestion)
const bm25Results = searchBM25(query, (k ?? 10) * 3);
// Get semantic results if embeddings are ready
let semanticResults: any[] = [];
if (isEmbeddingComplete) {
try {
semanticResults = await doSemanticSearch(kuzu.executeQuery, query, (k ?? 10) * 3, 0.5);
semanticResults = await doSemanticSearch(lbug.executeQuery, query, (k ?? 10) * 3, 0.5);
} catch {
// Semantic search failed, continue with BM25 only
}
}
// Merge with RRF
return mergeWithRRF(bm25Results, semanticResults, k ?? 10);
};
@@ -586,7 +586,7 @@ const workerApi = {
let codebaseContext;
try {
codebaseContext = await buildCodebaseContext(kuzu.executeQuery, resolvedProjectName);
codebaseContext = await buildCodebaseContext(lbug.executeQuery, resolvedProjectName);
if (import.meta.env.DEV) {
console.log('📊 Codebase context built:', {
files: codebaseContext.stats.fileCount,
@@ -600,7 +600,7 @@ const workerApi = {
currentAgent = createGraphRAGAgent(
config,
kuzu.executeQuery,
lbug.executeQuery,
semanticSearchWrapper,
semanticSearchWithContextWrapper,
hybridSearchWrapper,
@@ -627,7 +627,7 @@ const workerApi = {
/**
* Initialize the Graph RAG agent in backend mode (HTTP-backed tools).
* Uses HTTP wrappers instead of local KuzuDB for all tool queries.
* Uses HTTP wrappers instead of local LadybugDB for all tool queries.
* @param config - Provider configuration for the LLM
* @param backendUrl - Base URL of the gitnexus serve backend
* @param repoName - Repository name on the backend
@@ -848,9 +848,9 @@ const workerApi = {
}
});
// Update KuzuDB with new data
// Update LadybugDB with new data
try {
const kuzu = await getKuzuAdapter();
const lbug = await getLbugAdapter();
onProgress(enrichments.size, enrichments.size); // Done
@@ -872,11 +872,11 @@ const workerApi = {
c.enrichedBy = "llm"
`;
await kuzu.executeQuery(query);
await lbug.executeQuery(query);
}
} catch (err) {
console.error('Failed to update KuzuDB with enrichment:', err);
console.error('Failed to update LadybugDB with enrichment:', err);
}
// Convert Map to Record for serialization
+5 -5
View File
@@ -12,11 +12,11 @@ export default defineConfig({
tailwindcss(),
wasm(),
topLevelAwait(),
// Copy kuzu-wasm worker file to assets folder for production
// Copy lbug-wasm worker file to assets folder for production
viteStaticCopy({
targets: [
{
src: 'node_modules/kuzu-wasm/kuzu_wasm_worker.js',
src: 'node_modules/@ladybugdb/wasm-core/lbug_wasm_worker.js',
dest: 'assets'
}
]
@@ -35,12 +35,12 @@ export default defineConfig({
define: {
global: 'globalThis',
},
// Optimize deps - exclude kuzu-wasm from pre-bundling (it has WASM files)
// Optimize deps - exclude lbug-wasm from pre-bundling (it has WASM files)
optimizeDeps: {
exclude: ['kuzu-wasm'],
exclude: ['@ladybugdb/wasm-core'],
include: ['buffer'],
},
// Required for KuzuDB WASM (SharedArrayBuffer needs Cross-Origin Isolation)
// Required for LadybugDB WASM (SharedArrayBuffer needs Cross-Origin Isolation)
server: {
headers: {
'Cross-Origin-Opener-Policy': 'same-origin',
+7
View File
@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"mcp__plugin_claude-mem_mcp-search__get_observations"
]
}
}
+99
View File
@@ -0,0 +1,99 @@
# Changelog
All notable changes to GitNexus will be documented in this file.
## [1.4.7] - 2026-03-19
### Added
- **Phase 8 field/property type resolution** — ACCESSES edges with `declaredType` for field reads/writes (#354)
- **Phase 9 return-type variable binding** — call-result variable binding across 11 languages (#379)
- `extractPendingAssignment` in per-language type extractors captures `let x = getUser()` patterns
- Unified fixpoint loop resolves variable types from function return types after initial walk
- Field access on call-result variables: `user.name` resolves `name` via return type's class definition
- Method-call-result chaining: `user.getProfile().bio` resolves through intermediate return types
- 22 new test fixtures covering call-result and method-chain binding across all supported languages
- Integration tests added for all 10 language resolver suites
- **ACCESSES edge type** with read/write field access tracking (#372)
- **Python `enumerate()` for-loop support** with nested tuple patterns (#356)
- **MCP tool/resource descriptions** updated to reflect Phase 9 ACCESSES edge semantics and `declaredType` property
### Fixed
- **mcp**: server crashes under parallel tool calls (#326, #349)
- **parsing**: undefined error on languages missing from call routers (#364)
- **web**: add missing Kotlin entries to `Record<SupportedLanguages>` maps
- **rust**: `await` expression unwrapping in `extractPendingAssignment` for async call-result binding
- **tests**: update property edge and write access expectations across multiple language tests
- **docs**: corrected stale "single-pass" claims in type-resolution-system.md to reflect walk+fixpoint architecture
### Changed
- **Upgrade `@ladybugdb/core` to 0.15.2** and remove segfault workarounds (#374)
- **type-resolution-roadmap.md** overhauled — completed phases condensed to summaries, Phases 10–14 added with full engineering specs
## [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
+9
View File
@@ -0,0 +1,9 @@
FROM node:22-bookworm
WORKDIR /app
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
COPY . .
RUN npm ci --ignore-scripts \
&& node scripts/patch-tree-sitter-swift.cjs \
&& (npm rebuild 2>&1 || true) \
&& cd node_modules/tree-sitter-kotlin && npx --yes node-gyp rebuild 2>&1
CMD ["npx", "vitest", "run", "test/integration", "--reporter=verbose"]
+18 -17
View File
@@ -96,7 +96,7 @@ GitNexus builds a complete knowledge graph of your codebase through a multi-phas
5. **Processes** — Traces execution flows from entry points through call chains
6. **Search** — Builds hybrid search indexes for fast retrieval
The result is a **KuzuDB graph database** stored locally in `.gitnexus/` with full-text search and semantic embeddings.
The result is a **LadybugDB graph database** stored locally in `.gitnexus/` with full-text search and semantic embeddings.
## MCP Tools
@@ -157,26 +157,27 @@ GitNexus supports indexing multiple repositories. Each `gitnexus analyze` regist
## Supported Languages
TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust, PHP, Kotlin, Swift
TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust, PHP, Kotlin, Swift, Ruby
### Language Feature Matrix
| Language | Imports | Types | Exports | Named Bindings | Config | Frameworks | Entry Points | Heritage |
|----------|---------|-------|---------|----------------|--------|------------|-------------|----------|
| TypeScript | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| JavaScript | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| Python | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| C# | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| Java | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ |
| Kotlin | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ |
| Go | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ | ✓ |
| Rust | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ |
| PHP | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — |
| Swift | — | ✓ | ✓ | — | ✓ | ✓ | ✓ | ✓ |
| C | — | ✓ | ✓ | — | — | ✓ | ✓ | ✓ |
| C++ | — | ✓ | ✓ | — | — | ✓ | ✓ | ✓ |
| Language | Imports | Named Bindings | Exports | Heritage | Type Annotations | Constructor Inference | Config | Frameworks | Entry Points |
|----------|---------|----------------|---------|----------|-----------------|---------------------|--------|------------|-------------|
| TypeScript | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| JavaScript | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ | ✓ |
| Python | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| Java | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
| Kotlin | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
| C# | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| Go | ✓ | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| Rust | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
| PHP | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ | ✓ | ✓ |
| Ruby | ✓ | — | ✓ | ✓ | — | ✓ | — | ✓ | ✓ |
| Swift | — | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| C | — | — | ✓ | — | ✓ | ✓ | — | ✓ | ✓ |
| C++ | — | — | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
**Imports** — cross-file import resolution · **Types** — type annotation extraction · **Exports** — public/exported symbol detection · **Named Bindings** — `import { X }` tracking · **Config** — language toolchain config parsing (tsconfig, go.mod, etc.) · **Frameworks** — AST-based framework pattern detection · **Entry Points** — entry point scoring heuristics · **Heritage** — class inheritance / interface implementation
**Imports** — cross-file import resolution · **Named Bindings** — `import { X as Y }` / re-export tracking · **Exports** — public/exported symbol detection · **Heritage** — class inheritance, interfaces, mixins · **Type Annotations** — explicit type extraction for receiver resolution · **Constructor Inference** — infer receiver type from constructor calls (`self`/`this` resolution included for all languages) · **Config** — language toolchain config parsing (tsconfig, go.mod, etc.) · **Frameworks** — AST-based framework pattern detection · **Entry Points** — entry point scoring heuristics
## Agent Skills
View File
View File
View File
+231 -503
View File
@@ -1,16 +1,17 @@
{
"name": "gitnexus",
"version": "1.4.0",
"version": "1.4.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gitnexus",
"version": "1.4.0",
"version": "1.4.7",
"hasInstallScript": true,
"license": "PolyForm-Noncommercial-1.0.0",
"dependencies": {
"@huggingface/transformers": "^3.0.0",
"@ladybugdb/core": "^0.15.2",
"@modelcontextprotocol/sdk": "^1.0.0",
"cli-progress": "^3.12.0",
"commander": "^12.0.0",
@@ -20,7 +21,7 @@
"graphology": "^0.25.4",
"graphology-indices": "^0.17.0",
"graphology-utils": "^2.3.0",
"kuzu": "^0.11.3",
"ignore": "^7.0.5",
"lru-cache": "^11.0.0",
"mnemonist": "^0.39.0",
"pandemonium": "^2.4.0",
@@ -31,9 +32,9 @@
"tree-sitter-go": "^0.21.0",
"tree-sitter-java": "^0.21.0",
"tree-sitter-javascript": "^0.21.0",
"tree-sitter-kotlin": "^0.3.8",
"tree-sitter-php": "^0.23.12",
"tree-sitter-python": "^0.21.0",
"tree-sitter-ruby": "^0.23.1",
"tree-sitter-rust": "^0.21.0",
"tree-sitter-typescript": "^0.21.0",
"uuid": "^13.0.0"
@@ -56,6 +57,7 @@
"node": ">=18.0.0"
},
"optionalDependencies": {
"tree-sitter-kotlin": "^0.3.8",
"tree-sitter-swift": "^0.6.0"
}
},
@@ -1147,6 +1149,191 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@ladybugdb/core": {
"version": "0.15.2",
"resolved": "https://registry.npmjs.org/@ladybugdb/core/-/core-0.15.2.tgz",
"integrity": "sha512-DpseEj9CM/QTV0z+rvBk6nB2mOoG4GVhnKKLiXChGTVddgpH6R/Pv2YiDZB7rUIDnFpJxVQNbQaYEkZ7i1h1KA==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"cmake-js": "^8.0.0",
"node-addon-api": "^6.0.0"
},
"optionalDependencies": {
"@ladybugdb/core-darwin-arm64": "0.15.2",
"@ladybugdb/core-linux-arm64": "0.15.2",
"@ladybugdb/core-linux-x64": "0.15.2",
"@ladybugdb/core-win32-x64": "0.15.2"
}
},
"node_modules/@ladybugdb/core-darwin-arm64": {
"version": "0.15.2",
"resolved": "https://registry.npmjs.org/@ladybugdb/core-darwin-arm64/-/core-darwin-arm64-0.15.2.tgz",
"integrity": "sha512-ifLyUTPzlh2zR1IqkUT5AfldX+X4zfWBzwakmGTgMPxyrEiRNDwUKfnNxHeLQ/TJTOS/nfzYxxLLt5CZf2/FhA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@ladybugdb/core-linux-arm64": {
"version": "0.15.2",
"resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-arm64/-/core-linux-arm64-0.15.2.tgz",
"integrity": "sha512-9537UbHOiuSr/BaTfjcoBsHxEKF4uEXWyXEjm/AQCGXQFocX3nQDVNDYJzuDYjKZ51oJRJ0oSuesAStOCwjolA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@ladybugdb/core-linux-x64": {
"version": "0.15.2",
"resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-x64/-/core-linux-x64-0.15.2.tgz",
"integrity": "sha512-1+xLoapjbMQzDHxcPpMPt8Suuvms3nhOIZFNGPDcWz90NwEmLAjWNFQZZHeg8DRz0vG2j8UY292bvGORVcxs8g==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@ladybugdb/core-win32-x64": {
"version": "0.15.2",
"resolved": "https://registry.npmjs.org/@ladybugdb/core-win32-x64/-/core-win32-x64-0.15.2.tgz",
"integrity": "sha512-+LIJVKBNSrf2bGruJO4l0ihrLKZkv5+lNitK8xc3T7gC1bcc+FaYtRMvlgZP6Qh2rEHAjqfbaSKVrdw0M2EXTw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@ladybugdb/core/node_modules/chownr": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=18"
}
},
"node_modules/@ladybugdb/core/node_modules/cmake-js": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/cmake-js/-/cmake-js-8.0.0.tgz",
"integrity": "sha512-YbUP88RDwCvoQkZhRtGURYm9RIpWdtvZuhT87fKNoLjk8kIFIFeARpKfuZQGdwfH99GZpUmqSfcDrK62X7lTgg==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
"fs-extra": "^11.3.3",
"node-api-headers": "^1.8.0",
"rc": "1.2.8",
"semver": "^7.7.3",
"tar": "^7.5.6",
"url-join": "^4.0.1",
"which": "^6.0.0",
"yargs": "^17.7.2"
},
"bin": {
"cmake-js": "bin/cmake-js"
},
"engines": {
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/@ladybugdb/core/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/@ladybugdb/core/node_modules/isexe": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz",
"integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=20"
}
},
"node_modules/@ladybugdb/core/node_modules/minizlib": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
"integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
"license": "MIT",
"dependencies": {
"minipass": "^7.1.2"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/@ladybugdb/core/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/@ladybugdb/core/node_modules/tar": {
"version": "7.5.11",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.11.tgz",
"integrity": "sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==",
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/fs-minipass": "^4.0.0",
"chownr": "^3.0.0",
"minipass": "^7.1.2",
"minizlib": "^3.1.0",
"yallist": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@ladybugdb/core/node_modules/which": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz",
"integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==",
"license": "ISC",
"dependencies": {
"isexe": "^4.0.0"
},
"bin": {
"node-which": "bin/which.js"
},
"engines": {
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/@ladybugdb/core/node_modules/yallist": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=18"
}
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.25.3",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.3.tgz",
@@ -2273,26 +2460,6 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/aproba": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz",
"integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==",
"license": "ISC"
},
"node_modules/are-we-there-yet": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz",
"integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==",
"deprecated": "This package is no longer supported.",
"license": "ISC",
"dependencies": {
"delegates": "^1.0.0",
"readable-stream": "^3.6.0"
},
"engines": {
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -2321,23 +2488,6 @@
"js-tokens": "^10.0.0"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz",
"integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/body-parser": {
"version": "1.20.4",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
@@ -2432,15 +2582,6 @@
"node": ">=18"
}
},
"node_modules/chownr": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
"integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==",
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/cli-progress": {
"version": "3.12.0",
"resolved": "https://registry.npmjs.org/cli-progress/-/cli-progress-3.12.0.tgz",
@@ -2581,55 +2722,6 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/cmake-js": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/cmake-js/-/cmake-js-7.4.0.tgz",
"integrity": "sha512-Lw0JxEHrmk+qNj1n9W9d4IvkDdYTBn7l2BW6XmtLj7WPpIo2shvxUy+YokfjMxAAOELNonQwX3stkPhM5xSC2Q==",
"license": "MIT",
"dependencies": {
"axios": "^1.6.5",
"debug": "^4",
"fs-extra": "^11.2.0",
"memory-stream": "^1.0.0",
"node-api-headers": "^1.1.0",
"npmlog": "^6.0.2",
"rc": "^1.2.7",
"semver": "^7.5.4",
"tar": "^6.2.0",
"url-join": "^4.0.1",
"which": "^2.0.2",
"yargs": "^17.7.2"
},
"bin": {
"cmake-js": "bin/cmake-js"
},
"engines": {
"node": ">= 14.15.0"
}
},
"node_modules/cmake-js/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/cmake-js/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -2648,27 +2740,6 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/color-support": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
"integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==",
"license": "ISC",
"bin": {
"color-support": "bin.js"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/commander": {
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
@@ -2678,12 +2749,6 @@
"node": ">=18"
}
},
"node_modules/console-control-strings": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
"integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==",
"license": "ISC"
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -2803,21 +2868,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/delegates": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
"integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==",
"license": "MIT"
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -2930,21 +2980,6 @@
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es6-error": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
@@ -3204,26 +3239,6 @@
"integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==",
"license": "Apache-2.0"
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/foreground-child": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
@@ -3240,22 +3255,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -3288,30 +3287,6 @@
"node": ">=14.14"
}
},
"node_modules/fs-minipass": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
"integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==",
"license": "ISC",
"dependencies": {
"minipass": "^3.0.0"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/fs-minipass/node_modules/minipass": {
"version": "3.3.6",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
"integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
"license": "ISC",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -3336,73 +3311,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gauge": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz",
"integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==",
"deprecated": "This package is no longer supported.",
"license": "ISC",
"dependencies": {
"aproba": "^1.0.3 || ^2.0.0",
"color-support": "^1.1.3",
"console-control-strings": "^1.1.0",
"has-unicode": "^2.0.1",
"signal-exit": "^3.0.7",
"string-width": "^4.2.3",
"strip-ansi": "^6.0.1",
"wide-align": "^1.1.5"
},
"engines": {
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
}
},
"node_modules/gauge/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/gauge/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/gauge/node_modules/signal-exit": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"license": "ISC"
},
"node_modules/gauge/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/gauge/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
@@ -3618,27 +3526,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-unicode": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
"integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==",
"license": "ISC"
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@@ -3700,6 +3587,15 @@
"node": ">=0.10.0"
}
},
"node_modules/ignore": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@@ -3842,18 +3738,6 @@
"graceful-fs": "^4.1.6"
}
},
"node_modules/kuzu": {
"version": "0.11.3",
"resolved": "https://registry.npmjs.org/kuzu/-/kuzu-0.11.3.tgz",
"integrity": "sha512-4+hD3Y+YMV3e0uiqTv1/GUal47D04l8qluw1WFWg8Nx3k7rLsHG1Pmq9WHIOlf1742svxQvTYQiuY6oS1qxAZA==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"cmake-js": "^7.3.0",
"node-addon-api": "^6.0.0"
}
},
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
@@ -3937,15 +3821,6 @@
"node": ">= 0.6"
}
},
"node_modules/memory-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/memory-stream/-/memory-stream-1.0.0.tgz",
"integrity": "sha512-Wm13VcsPIMdG96dzILfij09PvuS3APtcKNh7M28FsCA/w6+1mjR7hhPmfFNoilX9xU7wTdhsH5lJAm6XNzdtww==",
"license": "MIT",
"dependencies": {
"readable-stream": "^3.4.0"
}
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
@@ -4030,43 +3905,6 @@
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/minizlib": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz",
"integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==",
"license": "MIT",
"dependencies": {
"minipass": "^3.0.0",
"yallist": "^4.0.0"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/minizlib/node_modules/minipass": {
"version": "3.3.6",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
"integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
"license": "ISC",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/mkdirp": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
"integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
"license": "MIT",
"bin": {
"mkdirp": "bin/cmd.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/mnemonist": {
"version": "0.39.8",
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.8.tgz",
@@ -4133,22 +3971,6 @@
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/npmlog": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz",
"integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==",
"deprecated": "This package is no longer supported.",
"license": "ISC",
"dependencies": {
"are-we-there-yet": "^3.0.0",
"console-control-strings": "^1.1.0",
"gauge": "^4.0.3",
"set-blocking": "^2.0.0"
},
"engines": {
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -4469,12 +4291,6 @@
"node": ">= 0.10"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/qs": {
"version": "6.14.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
@@ -4545,20 +4361,6 @@
"rc": "cli.js"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -4802,12 +4604,6 @@
"node": ">= 0.8.0"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -5009,15 +4805,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
@@ -5136,33 +4923,6 @@
"node": ">=8"
}
},
"node_modules/tar": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
"integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==",
"deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me",
"license": "ISC",
"dependencies": {
"chownr": "^2.0.0",
"fs-minipass": "^2.0.0",
"minipass": "^5.0.0",
"minizlib": "^2.1.1",
"mkdirp": "^1.0.3",
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/tar/node_modules/minipass": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
"integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==",
"license": "ISC",
"engines": {
"node": ">=8"
}
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -5415,6 +5175,7 @@
"integrity": "sha512-A4obq6bjzmYrA+F0JLLoheFPcofFkctNaZSpnDd+GPn1SfVZLY4/GG4C0cYVBTOShuPBGGAOPLM1JWLZQV4m1g==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"node-addon-api": "^7.1.0",
"node-gyp-build": "^4.8.0"
@@ -5432,7 +5193,8 @@
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"license": "MIT"
"license": "MIT",
"optional": true
},
"node_modules/tree-sitter-php": {
"version": "0.23.12",
@@ -5487,6 +5249,34 @@
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"license": "MIT"
},
"node_modules/tree-sitter-ruby": {
"version": "0.23.1",
"resolved": "https://registry.npmjs.org/tree-sitter-ruby/-/tree-sitter-ruby-0.23.1.tgz",
"integrity": "sha512-d9/RXgWjR6HanN7wTYhS5bpBQLz1VkH048Vm3CodPGyJVnamXMGb8oEhDypVCBq4QnHui9sTXuJBBP3WtCw5RA==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^8.2.2",
"node-gyp-build": "^4.8.2"
},
"peerDependencies": {
"tree-sitter": "^0.21.1"
},
"peerDependenciesMeta": {
"tree-sitter": {
"optional": true
}
}
},
"node_modules/tree-sitter-ruby/node_modules/node-addon-api": {
"version": "8.6.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.6.0.tgz",
"integrity": "sha512-gBVjCaqDlRUk0EwoPNKzIr9KkS9041G/q31IBShPs1Xz6UTA+EXdZADbzqAJQrpDRq71CIMnOP5VMut3SL0z5Q==",
"license": "MIT",
"engines": {
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/tree-sitter-rust": {
"version": "0.21.0",
"resolved": "https://registry.npmjs.org/tree-sitter-rust/-/tree-sitter-rust-0.21.0.tgz",
@@ -5677,12 +5467,6 @@
"integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==",
"license": "MIT"
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
@@ -5899,56 +5683,6 @@
"node": ">=8"
}
},
"node_modules/wide-align": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
"integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==",
"license": "ISC",
"dependencies": {
"string-width": "^1.0.2 || 2 || 3 || 4"
}
},
"node_modules/wide-align/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/wide-align/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/wide-align/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wide-align/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wrap-ansi": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
@@ -6055,12 +5789,6 @@
"node": ">=10"
}
},
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC"
},
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
+9 -6
View File
@@ -1,6 +1,6 @@
{
"name": "gitnexus",
"version": "1.4.0",
"version": "1.4.7",
"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",
@@ -39,13 +39,14 @@
"scripts": {
"build": "tsc",
"dev": "tsx watch src/cli/index.ts",
"test": "vitest run test/unit",
"test": "vitest run",
"test:unit": "vitest run test/unit",
"test:integration": "vitest run test/integration",
"test:all": "vitest run",
"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",
@@ -58,7 +59,8 @@
"graphology": "^0.25.4",
"graphology-indices": "^0.17.0",
"graphology-utils": "^2.3.0",
"kuzu": "^0.11.3",
"@ladybugdb/core": "^0.15.2",
"ignore": "^7.0.5",
"lru-cache": "^11.0.0",
"mnemonist": "^0.39.0",
"pandemonium": "^2.4.0",
@@ -69,14 +71,15 @@
"tree-sitter-go": "^0.21.0",
"tree-sitter-java": "^0.21.0",
"tree-sitter-javascript": "^0.21.0",
"tree-sitter-kotlin": "^0.3.8",
"tree-sitter-php": "^0.23.12",
"tree-sitter-python": "^0.21.0",
"tree-sitter-ruby": "^0.23.1",
"tree-sitter-rust": "^0.21.0",
"tree-sitter-typescript": "^0.21.0",
"uuid": "^13.0.0"
},
"optionalDependencies": {
"tree-sitter-kotlin": "^0.3.8",
"tree-sitter-swift": "^0.6.0"
},
"devDependencies": {
View File
+40 -28
View File
@@ -9,12 +9,12 @@ import { execFileSync } from 'child_process';
import v8 from 'v8';
import cliProgress from 'cli-progress';
import { runPipelineFromRepo } from '../core/ingestion/pipeline.js';
import { initKuzu, loadGraphToKuzu, getKuzuStats, executeQuery, executeWithReusedStatement, closeKuzu, createFTSIndex, loadCachedEmbeddings } from '../core/kuzu/kuzu-adapter.js';
import { initLbug, loadGraphToLbug, getLbugStats, executeQuery, executeWithReusedStatement, closeLbug, createFTSIndex, loadCachedEmbeddings } from '../core/lbug/lbug-adapter.js';
// Embedding imports are lazy (dynamic import) so onnxruntime-node is never
// loaded when embeddings are not requested. This avoids crashes on Node
// versions whose ABI is not yet supported by the native binary (#89).
// disposeEmbedder intentionally not called — ONNX Runtime segfaults on cleanup (see #38)
import { getStoragePaths, saveMeta, loadMeta, addToGitignore, registerRepo, getGlobalRegistryPath } from '../storage/repo-manager.js';
import { getStoragePaths, saveMeta, loadMeta, addToGitignore, registerRepo, getGlobalRegistryPath, cleanupOldKuzuFiles } from '../storage/repo-manager.js';
import { getCurrentCommit, isGitRepo, getGitRoot } from '../storage/git.js';
import { generateAIContextFiles } from './ai-context.js';
import { generateSkillFiles, type GeneratedSkillInfo } from './skill-gen.js';
@@ -63,7 +63,7 @@ const PHASE_LABELS: Record<string, string> = {
communities: 'Detecting communities',
processes: 'Detecting processes',
complete: 'Pipeline complete',
kuzu: 'Loading into KuzuDB',
lbug: 'Loading into LadybugDB',
fts: 'Creating search indexes',
embeddings: 'Generating embeddings',
done: 'Done',
@@ -100,7 +100,15 @@ export const analyzeCommand = async (
return;
}
const { storagePath, kuzuPath } = getStoragePaths(repoPath);
const { storagePath, lbugPath } = getStoragePaths(repoPath);
// Clean up stale KuzuDB files from before the LadybugDB migration.
// If kuzu existed but lbug doesn't, we're doing a migration re-index — say so.
const kuzuResult = await cleanupOldKuzuFiles(storagePath);
if (kuzuResult.found && kuzuResult.needsReindex) {
console.log(' Migrating from KuzuDB to LadybugDB — rebuilding index...\n');
}
const currentCommit = getCurrentCommit(repoPath);
const existingMeta = await loadMeta(storagePath);
@@ -109,6 +117,10 @@ export const analyzeCommand = async (
return;
}
if (process.env.GITNEXUS_NO_GITIGNORE) {
console.log(' GITNEXUS_NO_GITIGNORE is set — skipping .gitignore (still reading .gitnexusignore)\n');
}
// Single progress bar for entire pipeline
const bar = new cliProgress.SingleBar({
format: ' {bar} {percentage}% | {phase}',
@@ -130,7 +142,7 @@ export const analyzeCommand = async (
aborted = true;
bar.stop();
console.log('\n Interrupted — cleaning up...');
closeKuzu().catch(() => {}).finally(() => process.exit(130));
closeLbug().catch(() => {}).finally(() => process.exit(130));
};
process.on('SIGINT', sigintHandler);
@@ -180,13 +192,13 @@ export const analyzeCommand = async (
if (options?.embeddings && existingMeta && !options?.force) {
try {
updateBar(0, 'Caching embeddings...');
await initKuzu(kuzuPath);
await initLbug(lbugPath);
const cached = await loadCachedEmbeddings();
cachedEmbeddingNodeIds = cached.embeddingNodeIds;
cachedEmbeddings = cached.embeddings;
await closeKuzu();
await closeLbug();
} catch {
try { await closeKuzu(); } catch {}
try { await closeLbug(); } catch {}
}
}
@@ -197,25 +209,25 @@ export const analyzeCommand = async (
updateBar(scaled, phaseLabel);
});
// ── Phase 2: KuzuDB (60–85%) ──────────────────────────────────────
updateBar(60, 'Loading into KuzuDB...');
// ── Phase 2: LadybugDB (60–85%) ──────────────────────────────────────
updateBar(60, 'Loading into LadybugDB...');
await closeKuzu();
const kuzuFiles = [kuzuPath, `${kuzuPath}.wal`, `${kuzuPath}.lock`];
for (const f of kuzuFiles) {
await closeLbug();
const lbugFiles = [lbugPath, `${lbugPath}.wal`, `${lbugPath}.lock`];
for (const f of lbugFiles) {
try { await fs.rm(f, { recursive: true, force: true }); } catch {}
}
const t0Kuzu = Date.now();
await initKuzu(kuzuPath);
let kuzuMsgCount = 0;
const kuzuResult = await loadGraphToKuzu(pipelineResult.graph, pipelineResult.repoPath, storagePath, (msg) => {
kuzuMsgCount++;
const progress = Math.min(84, 60 + Math.round((kuzuMsgCount / (kuzuMsgCount + 10)) * 24));
const t0Lbug = Date.now();
await initLbug(lbugPath);
let lbugMsgCount = 0;
const lbugResult = await loadGraphToLbug(pipelineResult.graph, pipelineResult.repoPath, storagePath, (msg) => {
lbugMsgCount++;
const progress = Math.min(84, 60 + Math.round((lbugMsgCount / (lbugMsgCount + 10)) * 24));
updateBar(progress, msg);
});
const kuzuTime = ((Date.now() - t0Kuzu) / 1000).toFixed(1);
const kuzuWarnings = kuzuResult.warnings;
const lbugTime = ((Date.now() - t0Lbug) / 1000).toFixed(1);
const lbugWarnings = lbugResult.warnings;
// ── Phase 3: FTS (85–90%) ─────────────────────────────────────────
updateBar(85, 'Creating search indexes...');
@@ -249,7 +261,7 @@ export const analyzeCommand = async (
}
// ── Phase 4: Embeddings (90–98%) ──────────────────────────────────
const stats = await getKuzuStats();
const stats = await getLbugStats();
let embeddingTime = '0.0';
let embeddingSkipped = true;
let embeddingSkipReason = 'off (use --embeddings to enable)';
@@ -334,7 +346,7 @@ export const analyzeCommand = async (
processes: pipelineResult.processResult?.stats.totalProcesses,
}, generatedSkills);
await closeKuzu();
await closeLbug();
// Note: we intentionally do NOT call disposeEmbedder() here.
// ONNX Runtime's native cleanup segfaults on macOS and some Linux configs.
// Since the process exits immediately after, Node.js reclaims everything.
@@ -355,7 +367,7 @@ export const analyzeCommand = async (
const embeddingsCached = cachedEmbeddings.length > 0;
console.log(`\n Repository indexed successfully (${totalTime}s)${embeddingsCached ? ` [${cachedEmbeddings.length} embeddings cached]` : ''}\n`);
console.log(` ${stats.nodes.toLocaleString()} nodes | ${stats.edges.toLocaleString()} edges | ${pipelineResult.communityResult?.stats.totalCommunities || 0} clusters | ${pipelineResult.processResult?.stats.totalProcesses || 0} flows`);
console.log(` KuzuDB ${kuzuTime}s | FTS ${ftsTime}s | Embeddings ${embeddingSkipped ? embeddingSkipReason : embeddingTime + 's'}`);
console.log(` LadybugDB ${lbugTime}s | FTS ${ftsTime}s | Embeddings ${embeddingSkipped ? embeddingSkipReason : embeddingTime + 's'}`);
console.log(` ${repoPath}`);
if (aiContext.files.length > 0) {
@@ -363,12 +375,12 @@ export const analyzeCommand = async (
}
// Show a quiet summary if some edge types needed fallback insertion
if (kuzuWarnings.length > 0) {
const totalFallback = kuzuWarnings.reduce((sum, w) => {
if (lbugWarnings.length > 0) {
const totalFallback = lbugWarnings.reduce((sum, w) => {
const m = w.match(/\((\d+) edges\)/);
return sum + (m ? parseInt(m[1]) : 0);
}, 0);
console.log(` Note: ${totalFallback} edges across ${kuzuWarnings.length} types inserted via fallback (schema will be updated in next release)`);
console.log(` Note: ${totalFallback} edges across ${lbugWarnings.length} types inserted via fallback (schema will be updated in next release)`);
}
try {
@@ -379,7 +391,7 @@ export const analyzeCommand = async (
console.log('');
// KuzuDB's native module holds open handles that prevent Node from exiting.
// LadybugDB's native module holds open handles that prevent Node from exiting.
// ONNX Runtime also registers native atexit hooks that segfault on some
// platforms (#38, #40). Force-exit to ensure clean termination.
process.exit(0);
+1 -1
View File
@@ -23,7 +23,7 @@ export async function augmentCommand(pattern: string): Promise<void> {
if (result) {
// IMPORTANT: Write to stderr, NOT stdout.
// KuzuDB's native module captures stdout fd at OS level during init,
// LadybugDB's native module captures stdout fd at OS level during init,
// which makes stdout permanently broken in subprocess contexts.
// stderr is never captured, so it works reliably everywhere.
// The hook reads from the subprocess's stderr.
+14 -5
View File
@@ -1,7 +1,7 @@
/**
* Eval Server — Lightweight HTTP server for SWE-bench evaluation
*
* Keeps KuzuDB warm in memory so tool calls from the agent are near-instant.
* Keeps LadybugDB warm in memory so tool calls from the agent are near-instant.
* Designed to run inside Docker containers during SWE-bench evaluation.
*
* KEY DESIGN: Returns LLM-friendly text, not raw JSON.
@@ -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)
}
});
+1
View File
@@ -28,6 +28,7 @@ program
.option('--embeddings', 'Enable embedding generation for semantic search (off by default)')
.option('--skills', 'Generate repo-specific skill files from detected communities')
.option('-v, --verbose', 'Enable verbose ingestion warnings (default: false)')
.addHelpText('after', '\nEnvironment variables:\n GITNEXUS_NO_GITIGNORE=1 Skip .gitignore parsing (still reads .gitnexusignore)')
.action(createLazyAction(() => import('./analyze.js'), 'analyzeCommand'));
program
+1 -1
View File
@@ -11,7 +11,7 @@ import { LocalBackend } from '../mcp/local/local-backend.js';
export const mcpCommand = async () => {
// Prevent unhandled errors from crashing the MCP server process.
// KuzuDB lock conflicts and transient errors should degrade gracefully.
// LadybugDB lock conflicts and transient errors should degrade gracefully.
process.on('uncaughtException', (err) => {
console.error(`GitNexus MCP: uncaught exception — ${err.message}`);
// Process is in an undefined state after uncaughtException — exit after flushing
+27 -15
View File
@@ -10,6 +10,7 @@ import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import { fileURLToPath } from 'url';
import { glob } from 'glob';
import { getGlobalDir } from '../storage/repo-manager.js';
const __filename = fileURLToPath(import.meta.url);
@@ -240,8 +241,6 @@ async function setupOpenCode(result: SetupResult): Promise<void> {
// ─── Skill Installation ───────────────────────────────────────────
const SKILL_NAMES = ['gitnexus-exploring', 'gitnexus-debugging', 'gitnexus-impact-analysis', 'gitnexus-refactoring', 'gitnexus-guide', 'gitnexus-cli'];
/**
* Install GitNexus skills to a target directory.
* Each skill is installed as {targetDir}/gitnexus-{skillName}/SKILL.md
@@ -255,25 +254,38 @@ async function installSkillsTo(targetDir: string): Promise<string[]> {
const installed: string[] = [];
const skillsRoot = path.join(__dirname, '..', '..', 'skills');
for (const skillName of SKILL_NAMES) {
let flatFiles: string[] = [];
let dirSkillFiles: string[] = [];
try {
[flatFiles, dirSkillFiles] = await Promise.all([
glob('*.md', { cwd: skillsRoot }),
glob('*/SKILL.md', { cwd: skillsRoot }),
]);
} catch {
return [];
}
const skillSources = new Map<string, { isDirectory: boolean }>();
for (const relPath of dirSkillFiles) {
skillSources.set(path.dirname(relPath), { isDirectory: true });
}
for (const relPath of flatFiles) {
const skillName = path.basename(relPath, '.md');
if (!skillSources.has(skillName)) {
skillSources.set(skillName, { isDirectory: false });
}
}
for (const [skillName, source] of skillSources) {
const skillDir = path.join(targetDir, skillName);
try {
// Try directory-based skill first (skills/{name}/SKILL.md)
const dirSource = path.join(skillsRoot, skillName);
const dirSkillFile = path.join(dirSource, 'SKILL.md');
let isDirectory = false;
try {
const stat = await fs.stat(dirSource);
isDirectory = stat.isDirectory();
} catch { /* not a directory */ }
if (isDirectory) {
if (source.isDirectory) {
const dirSource = path.join(skillsRoot, skillName);
await copyDirRecursive(dirSource, skillDir);
installed.push(skillName);
} else {
// Fall back to flat file (skills/{name}.md)
const flatSource = path.join(skillsRoot, `${skillName}.md`);
const content = await fs.readFile(flatSource, 'utf-8');
await fs.mkdir(skillDir, { recursive: true });
+13 -5
View File
@@ -4,12 +4,12 @@
* Shows the indexing status of the current repository.
*/
import { findRepo } from '../storage/repo-manager.js';
import { getCurrentCommit, isGitRepo } from '../storage/git.js';
import { findRepo, getStoragePaths, hasKuzuIndex } from '../storage/repo-manager.js';
import { getCurrentCommit, isGitRepo, getGitRoot } from '../storage/git.js';
export const statusCommand = async () => {
const cwd = process.cwd();
if (!isGitRepo(cwd)) {
console.log('Not a git repository.');
return;
@@ -17,8 +17,16 @@ export const statusCommand = async () => {
const repo = await findRepo(cwd);
if (!repo) {
console.log('Repository not indexed.');
console.log('Run: gitnexus analyze');
// Check if there's a stale KuzuDB index that needs migration
const repoRoot = getGitRoot(cwd) ?? cwd;
const { storagePath } = getStoragePaths(repoRoot);
if (await hasKuzuIndex(storagePath)) {
console.log('Repository has a stale KuzuDB index from a previous version.');
console.log('Run: gitnexus analyze (rebuilds the index with LadybugDB)');
} else {
console.log('Repository not indexed.');
console.log('Run: gitnexus analyze');
}
return;
}
+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 KuzuDB'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 KuzuDB 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 -2
View File
@@ -101,7 +101,7 @@ export const wikiCommand = async (
}
// ── Check for existing index ────────────────────────────────────────
const { storagePath, kuzuPath } = getStoragePaths(repoPath);
const { storagePath, lbugPath } = getStoragePaths(repoPath);
const meta = await loadMeta(storagePath);
if (!meta) {
@@ -247,7 +247,7 @@ export const wikiCommand = async (
const generator = new WikiGenerator(
repoPath,
storagePath,
kuzuPath,
lbugPath,
llmConfig,
wikiOptions,
(phase, percent, detail) => {
+92
View File
@@ -1,3 +1,8 @@
import ignore, { type Ignore } from 'ignore';
import fs from 'fs/promises';
import nodePath from 'path';
import type { Path } from 'path-scurry';
const DEFAULT_IGNORE_LIST = new Set([
// Version Control
'.git',
@@ -186,6 +191,10 @@ const IGNORED_FILES = new Set([
// NOTE: Negation patterns in .gitnexusignore (e.g. `!vendor/`) cannot override
// entries in DEFAULT_IGNORE_LIST — this is intentional. The hardcoded list protects
// against indexing directories that are almost never source code (node_modules, .git, etc.).
// Users who need to include such directories should remove them from the hardcoded list.
export const shouldIgnorePath = (filePath: string): boolean => {
const normalizedPath = filePath.replace(/\\/g, '/');
const parts = normalizedPath.split('/');
@@ -237,3 +246,86 @@ export const shouldIgnorePath = (filePath: string): boolean => {
return false;
}
/** Check if a directory name is in the hardcoded ignore list */
export const isHardcodedIgnoredDirectory = (name: string): boolean => {
return DEFAULT_IGNORE_LIST.has(name);
};
/**
* Load .gitignore and .gitnexusignore rules from the repo root.
* Returns an `ignore` instance with all patterns, or null if no files found.
*/
export interface IgnoreOptions {
/** Skip .gitignore parsing, only read .gitnexusignore. Defaults to GITNEXUS_NO_GITIGNORE env var. */
noGitignore?: boolean;
}
export const loadIgnoreRules = async (
repoPath: string,
options?: IgnoreOptions
): Promise<Ignore | null> => {
const ig = ignore();
let hasRules = false;
// Allow users to bypass .gitignore parsing (e.g. when .gitignore accidentally excludes source files)
const skipGitignore = options?.noGitignore ?? !!process.env.GITNEXUS_NO_GITIGNORE;
const filenames = skipGitignore
? ['.gitnexusignore']
: ['.gitignore', '.gitnexusignore'];
for (const filename of filenames) {
try {
const content = await fs.readFile(nodePath.join(repoPath, filename), 'utf-8');
ig.add(content);
hasRules = true;
} catch (err: unknown) {
const code = (err as NodeJS.ErrnoException).code;
if (code !== 'ENOENT') {
console.warn(` Warning: could not read ${filename}: ${(err as Error).message}`);
}
}
}
return hasRules ? ig : null;
};
/**
* Create a glob-compatible ignore filter combining:
* - .gitignore / .gitnexusignore patterns (via `ignore` package)
* - Hardcoded DEFAULT_IGNORE_LIST, IGNORED_EXTENSIONS, IGNORED_FILES
*
* Returns an IgnoreLike object for glob's `ignore` option,
* enabling directory-level pruning during traversal.
*/
export const createIgnoreFilter = async (repoPath: string, options?: IgnoreOptions) => {
const ig = await loadIgnoreRules(repoPath, options);
return {
ignored(p: Path): boolean {
// path-scurry's Path.relative() returns POSIX paths on all platforms,
// which is what the `ignore` package expects. No explicit normalization needed.
const rel = p.relative();
if (!rel) return false;
// Check .gitignore / .gitnexusignore patterns
if (ig && ig.ignores(rel)) return true;
// Fall back to hardcoded rules
return shouldIgnorePath(rel);
},
childrenIgnored(p: Path): boolean {
// Fast path: check directory name against hardcoded list.
// Note: dot-directories (.git, .vscode, etc.) are primarily excluded by
// glob's `dot: false` option in filesystem-walker.ts. This check is
// defense-in-depth — do not remove `dot: false` assuming this covers it.
if (DEFAULT_IGNORE_LIST.has(p.name)) return true;
// Check against .gitignore / .gitnexusignore patterns.
// Test both bare path and path with trailing slash to handle
// bare-name patterns (e.g. `local`) and dir-only patterns (e.g. `local/`).
if (ig) {
const rel = p.relative();
if (rel && (ig.ignores(rel) || ig.ignores(rel + '/'))) return true;
}
return false;
},
};
};
+1 -1
View File
@@ -7,9 +7,9 @@ export enum SupportedLanguages {
CPlusPlus = 'cpp',
CSharp = 'csharp',
Go = 'go',
Ruby = 'ruby',
Rust = 'rust',
PHP = 'php',
Kotlin = 'kotlin',
// Ruby = 'ruby',
Swift = 'swift',
}
+102 -77
View File
@@ -24,7 +24,7 @@ import { listRegisteredRepos } from '../../storage/repo-manager.js';
async function findRepoForCwd(cwd: string): Promise<{
name: string;
storagePath: string;
kuzuPath: string;
lbugPath: string;
} | null> {
try {
const entries = await listRegisteredRepos({ validate: true });
@@ -66,7 +66,7 @@ async function findRepoForCwd(cwd: string): Promise<{
return {
name: bestMatch.name,
storagePath: bestMatch.storagePath,
kuzuPath: path.join(bestMatch.storagePath, 'kuzu'),
lbugPath: path.join(bestMatch.storagePath, 'lbug'),
};
} catch {
return null;
@@ -92,19 +92,19 @@ export async function augment(pattern: string, cwd?: string): Promise<string> {
const repo = await findRepoForCwd(workDir);
if (!repo) return '';
// Lazy-load kuzu adapter (skip unnecessary init)
const { initKuzu, executeQuery, isKuzuReady } = await import('../../mcp/core/kuzu-adapter.js');
const { searchFTSFromKuzu } = await import('../search/bm25-index.js');
// Lazy-load lbug adapter (skip unnecessary init)
const { initLbug, executeQuery, isLbugReady } = await import('../../mcp/core/lbug-adapter.js');
const { searchFTSFromLbug } = await import('../search/bm25-index.js');
const repoId = repo.name.toLowerCase();
// Init KuzuDB if not already
if (!isKuzuReady(repoId)) {
await initKuzu(repoId, repo.kuzuPath);
// Init LadybugDB if not already
if (!isLbugReady(repoId)) {
await initLbug(repoId, repo.lbugPath);
}
// Step 1: BM25 search (fast, no embeddings)
const bm25Results = await searchFTSFromKuzu(pattern, 10, repoId);
const bm25Results = await searchFTSFromLbug(pattern, 10, repoId);
if (bm25Results.length === 0) return '';
@@ -140,8 +140,90 @@ export async function augment(pattern: string, cwd?: string): Promise<string> {
if (symbolMatches.length === 0) return '';
// Step 3: For top matches, fetch callers/callees/processes
// Also get cluster cohesion internally for ranking
// Step 3: Batch-fetch callers/callees/processes/cohesion for top matches
// Uses batched WHERE n.id IN [...] queries instead of per-symbol queries
const uniqueSymbols = symbolMatches.slice(0, 5).filter((sym, i, arr) =>
arr.findIndex(s => s.nodeId === sym.nodeId) === i
);
if (uniqueSymbols.length === 0) return '';
const idList = uniqueSymbols.map(s => `'${s.nodeId.replace(/'/g, "''")}'`).join(', ');
// Batch fetch callers
const callersMap = new Map<string, string[]>();
try {
const rows = await executeQuery(repoId, `
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(n)
WHERE n.id IN [${idList}]
RETURN n.id AS targetId, caller.name AS name
LIMIT 15
`);
for (const r of rows) {
const tid = r.targetId || r[0];
const name = r.name || r[1];
if (tid && name) {
if (!callersMap.has(tid)) callersMap.set(tid, []);
callersMap.get(tid)!.push(name);
}
}
} catch { /* skip */ }
// Batch fetch callees
const calleesMap = new Map<string, string[]>();
try {
const rows = await executeQuery(repoId, `
MATCH (n)-[:CodeRelation {type: 'CALLS'}]->(callee)
WHERE n.id IN [${idList}]
RETURN n.id AS sourceId, callee.name AS name
LIMIT 15
`);
for (const r of rows) {
const sid = r.sourceId || r[0];
const name = r.name || r[1];
if (sid && name) {
if (!calleesMap.has(sid)) calleesMap.set(sid, []);
calleesMap.get(sid)!.push(name);
}
}
} catch { /* skip */ }
// Batch fetch processes
const processesMap = new Map<string, string[]>();
try {
const rows = await executeQuery(repoId, `
MATCH (n)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
WHERE n.id IN [${idList}]
RETURN n.id AS nodeId, p.heuristicLabel AS label, r.step AS step, p.stepCount AS stepCount
`);
for (const r of rows) {
const nid = r.nodeId || r[0];
const label = r.label || r[1];
const step = r.step || r[2];
const stepCount = r.stepCount || r[3];
if (nid && label) {
if (!processesMap.has(nid)) processesMap.set(nid, []);
processesMap.get(nid)!.push(`${label} (step ${step}/${stepCount})`);
}
}
} catch { /* skip */ }
// Batch fetch cohesion
const cohesionMap = new Map<string, number>();
try {
const rows = await executeQuery(repoId, `
MATCH (n)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
WHERE n.id IN [${idList}]
RETURN n.id AS nodeId, c.cohesion AS cohesion
`);
for (const r of rows) {
const nid = r.nodeId || r[0];
const coh = r.cohesion ?? r[1] ?? 0;
if (nid) cohesionMap.set(nid, coh);
}
} catch { /* skip */ }
// Assemble enriched results
const enriched: Array<{
name: string;
filePath: string;
@@ -150,72 +232,15 @@ export async function augment(pattern: string, cwd?: string): Promise<string> {
processes: string[];
cohesion: number;
}> = [];
const seen = new Set<string>();
for (const sym of symbolMatches.slice(0, 5)) {
if (seen.has(sym.nodeId)) continue;
seen.add(sym.nodeId);
const escaped = sym.nodeId.replace(/'/g, "''");
// Callers
let callers: string[] = [];
try {
const rows = await executeQuery(repoId, `
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(n {id: '${escaped}'})
RETURN caller.name AS name
LIMIT 3
`);
callers = rows.map((r: any) => r.name || r[0]).filter(Boolean);
} catch { /* skip */ }
// Callees
let callees: string[] = [];
try {
const rows = await executeQuery(repoId, `
MATCH (n {id: '${escaped}'})-[:CodeRelation {type: 'CALLS'}]->(callee)
RETURN callee.name AS name
LIMIT 3
`);
callees = rows.map((r: any) => r.name || r[0]).filter(Boolean);
} catch { /* skip */ }
// Processes
let processes: string[] = [];
try {
const rows = await executeQuery(repoId, `
MATCH (n {id: '${escaped}'})-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
RETURN p.heuristicLabel AS label, r.step AS step, p.stepCount AS stepCount
`);
processes = rows.map((r: any) => {
const label = r.label || r[0];
const step = r.step || r[1];
const stepCount = r.stepCount || r[2];
return `${label} (step ${step}/${stepCount})`;
}).filter(Boolean);
} catch { /* skip */ }
// Cluster cohesion (internal ranking signal)
let cohesion = 0;
try {
const rows = await executeQuery(repoId, `
MATCH (n {id: '${escaped}'})-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
RETURN c.cohesion AS cohesion
LIMIT 1
`);
if (rows.length > 0) {
cohesion = (rows[0].cohesion ?? rows[0][0]) || 0;
}
} catch { /* skip */ }
for (const sym of uniqueSymbols) {
enriched.push({
name: sym.name,
filePath: sym.filePath,
callers,
callees,
processes,
cohesion,
callers: (callersMap.get(sym.nodeId) || []).slice(0, 3),
callees: (calleesMap.get(sym.nodeId) || []).slice(0, 3),
processes: processesMap.get(sym.nodeId) || [],
cohesion: cohesionMap.get(sym.nodeId) || 0,
});
}
+1 -1
View File
@@ -262,7 +262,7 @@ export const embedBatch = async (texts: string[]): Promise<Float32Array[]> => {
};
/**
* Convert Float32Array to regular number array (for KuzuDB storage)
* Convert Float32Array to regular number array (for LadybugDB storage)
*/
export const embeddingToArray = (embedding: Float32Array): number[] => {
return Array.from(embedding);
@@ -2,10 +2,10 @@
* Embedding Pipeline Module
*
* Orchestrates the background embedding process:
* 1. Query embeddable nodes from KuzuDB
* 1. Query embeddable nodes from LadybugDB
* 2. Generate text representations
* 3. Batch embed using transformers.js
* 4. Update KuzuDB with embeddings
* 4. Update LadybugDB with embeddings
* 5. Create vector index for semantic search
*/
@@ -29,7 +29,7 @@ const isDev = process.env.NODE_ENV === 'development';
export type EmbeddingProgressCallback = (progress: EmbeddingProgress) => void;
/**
* Query all embeddable nodes from KuzuDB
* Query all embeddable nodes from LadybugDB
* Uses table-specific queries (File has different schema than code elements)
*/
const queryEmbeddableNodes = async (
@@ -104,9 +104,23 @@ const batchInsertEmbeddings = async (
* Create the vector index for semantic search
* Now indexes the separate CodeEmbedding table
*/
let vectorExtensionLoaded = false;
const createVectorIndex = async (
executeQuery: (cypher: string) => Promise<any[]>
): Promise<void> => {
// LadybugDB v0.15+ requires explicit VECTOR extension loading (once per session)
if (!vectorExtensionLoaded) {
try {
await executeQuery('INSTALL VECTOR');
await executeQuery('LOAD EXTENSION VECTOR');
vectorExtensionLoaded = true;
} catch {
// Extension may already be loaded — CREATE_VECTOR_INDEX will fail clearly if not
vectorExtensionLoaded = true;
}
}
const cypher = `
CALL CREATE_VECTOR_INDEX('CodeEmbedding', 'code_embedding_idx', 'embedding', metric := 'cosine')
`;
@@ -124,7 +138,7 @@ const createVectorIndex = async (
/**
* Run the embedding pipeline
*
* @param executeQuery - Function to execute Cypher queries against KuzuDB
* @param executeQuery - Function to execute Cypher queries against LadybugDB
* @param executeWithReusedStatement - Function to execute with reused prepared statement
* @param onProgress - Callback for progress updates
* @param config - Optional configuration override
@@ -219,7 +233,7 @@ export const runEmbeddingPipeline = async (
// Embed the batch
const embeddings = await embedBatch(texts);
// Update KuzuDB with embeddings
// Update LadybugDB with embeddings
const updates = batch.map((node, i) => ({
id: node.id,
embedding: embeddingToArray(embeddings[i]),
@@ -326,51 +340,64 @@ export const semanticSearch = async (
return [];
}
// Get metadata for each result by querying each node table
const results: SemanticSearchResult[] = [];
// Group results by label for batched metadata queries
const byLabel = new Map<string, Array<{ nodeId: string; distance: number }>>();
for (const embRow of embResults) {
const nodeId = embRow.nodeId ?? embRow[0];
const distance = embRow.distance ?? embRow[1];
// Extract label from node ID (format: Label:path:name)
const labelEndIdx = nodeId.indexOf(':');
const label = labelEndIdx > 0 ? nodeId.substring(0, labelEndIdx) : 'Unknown';
// Query the specific table for this node
// File nodes don't have startLine/endLine
if (!byLabel.has(label)) byLabel.set(label, []);
byLabel.get(label)!.push({ nodeId, distance });
}
// Batch-fetch metadata per label
const results: SemanticSearchResult[] = [];
for (const [label, items] of byLabel) {
const idList = items.map(i => `'${i.nodeId.replace(/'/g, "''")}'`).join(', ');
try {
let nodeQuery: string;
if (label === 'File') {
nodeQuery = `
MATCH (n:File {id: '${nodeId.replace(/'/g, "''")}'})
RETURN n.name AS name, n.filePath AS filePath
MATCH (n:File) WHERE n.id IN [${idList}]
RETURN n.id AS id, n.name AS name, n.filePath AS filePath
`;
} else {
nodeQuery = `
MATCH (n:${label} {id: '${nodeId.replace(/'/g, "''")}'})
RETURN n.name AS name, n.filePath AS filePath,
MATCH (n:${label}) WHERE n.id IN [${idList}]
RETURN n.id AS id, n.name AS name, n.filePath AS filePath,
n.startLine AS startLine, n.endLine AS endLine
`;
}
const nodeRows = await executeQuery(nodeQuery);
if (nodeRows.length > 0) {
const nodeRow = nodeRows[0];
results.push({
nodeId,
name: nodeRow.name ?? nodeRow[0] ?? '',
label,
filePath: nodeRow.filePath ?? nodeRow[1] ?? '',
distance,
startLine: label !== 'File' ? (nodeRow.startLine ?? nodeRow[2]) : undefined,
endLine: label !== 'File' ? (nodeRow.endLine ?? nodeRow[3]) : undefined,
});
const rowMap = new Map<string, any>();
for (const row of nodeRows) {
const id = row.id ?? row[0];
rowMap.set(id, row);
}
for (const item of items) {
const nodeRow = rowMap.get(item.nodeId);
if (nodeRow) {
results.push({
nodeId: item.nodeId,
name: nodeRow.name ?? nodeRow[1] ?? '',
label,
filePath: nodeRow.filePath ?? nodeRow[2] ?? '',
distance: item.distance,
startLine: label !== 'File' ? (nodeRow.startLine ?? nodeRow[3]) : undefined,
endLine: label !== 'File' ? (nodeRow.endLine ?? nodeRow[4]) : undefined,
});
}
}
} catch {
// Table might not exist, skip
}
}
// Re-sort by distance since batch queries may have mixed order
results.sort((a, b) => a.distance - b.distance);
return results;
};
+1 -1
View File
@@ -92,7 +92,7 @@ export interface SemanticSearchResult {
}
/**
* Node data for embedding (minimal structure from KuzuDB query)
* Node data for embedding (minimal structure from LadybugDB query)
*/
export interface EmbeddableNode {
id: string;
+3 -1
View File
@@ -80,6 +80,8 @@ export type RelationshipType =
| 'IMPLEMENTS'
| 'EXTENDS'
| 'HAS_METHOD'
| 'HAS_PROPERTY'
| 'ACCESSES'
| 'MEMBER_OF'
| 'STEP_IN_PROCESS'
@@ -96,7 +98,7 @@ export interface GraphRelationship {
type: RelationshipType,
/** Confidence score 0-1 (1.0 = certain, lower = uncertain resolution) */
confidence: number,
/** Resolution reason: 'import-resolved', 'same-file', 'fuzzy-global', or empty for non-CALLS */
/** Semantics are edge-type-dependent: CALLS uses resolution tier, ACCESSES uses 'read'/'write', OVERRIDES uses MRO reason */
reason: string,
/** Step number for STEP_IN_PROCESS relationships (1-indexed) */
step?: number,
File diff suppressed because it is too large Load Diff
+171
View File
@@ -0,0 +1,171 @@
/**
* Shared Ruby call routing logic.
*
* Ruby expresses imports, heritage (mixins), and property definitions as
* method calls rather than syntax-level constructs. This module provides a
* routing function used by the CLI call-processor, CLI parse-worker, and
* the web call-processor so that the classification logic lives in one place.
*
* NOTE: This file is intentionally duplicated in gitnexus-web/ because the
* two packages have separate build targets (Node native vs WASM/browser).
* Keep both copies in sync until a shared package is introduced.
*/
import { SupportedLanguages } from '../../config/supported-languages.js';
// ── Call routing dispatch table ─────────────────────────────────────────────
/** null = this call was not routed; fall through to default call handling */
export type CallRoutingResult = RubyCallRouting | null;
export type CallRouter = (
calledName: string,
callNode: any,
) => CallRoutingResult;
/** No-op router: returns null for every call (passthrough to normal processing) */
const noRouting: CallRouter = () => null;
/** Per-language call routing. noRouting = no special routing (normal call processing) */
export const callRouters = {
[SupportedLanguages.JavaScript]: noRouting,
[SupportedLanguages.TypeScript]: noRouting,
[SupportedLanguages.Python]: noRouting,
[SupportedLanguages.Java]: noRouting,
[SupportedLanguages.Kotlin]: noRouting,
[SupportedLanguages.Go]: noRouting,
[SupportedLanguages.Rust]: noRouting,
[SupportedLanguages.CSharp]: noRouting,
[SupportedLanguages.PHP]: noRouting,
[SupportedLanguages.Swift]: noRouting,
[SupportedLanguages.CPlusPlus]: noRouting,
[SupportedLanguages.C]: noRouting,
[SupportedLanguages.Ruby]: routeRubyCall,
} satisfies Record<SupportedLanguages, CallRouter>;
// ── Result types ────────────────────────────────────────────────────────────
export type RubyCallRouting =
| { kind: 'import'; importPath: string; isRelative: boolean }
| { kind: 'heritage'; items: RubyHeritageItem[] }
| { kind: 'properties'; items: RubyPropertyItem[] }
| { kind: 'call' }
| { kind: 'skip' };
export interface RubyHeritageItem {
enclosingClass: string;
mixinName: string;
heritageKind: 'include' | 'extend' | 'prepend';
}
export type RubyAccessorType = 'attr_accessor' | 'attr_reader' | 'attr_writer';
export interface RubyPropertyItem {
propName: string;
accessorType: RubyAccessorType;
startLine: number;
endLine: number;
/** YARD @return [Type] annotation preceding the attr_accessor call */
declaredType?: string;
}
// ── Pre-allocated singletons for common return values ────────────────────────
const CALL_RESULT: RubyCallRouting = { kind: 'call' };
const SKIP_RESULT: RubyCallRouting = { kind: 'skip' };
/** Max depth for parent-walking loops to prevent pathological AST traversals */
const MAX_PARENT_DEPTH = 50;
// ── Routing function ────────────────────────────────────────────────────────
/**
* Classify a Ruby call node and extract its semantic payload.
*
* @param calledName - The method name (e.g. 'require', 'include', 'attr_accessor')
* @param callNode - The tree-sitter `call` AST node
* @returns A discriminated union describing the call's semantic role
*/
export function routeRubyCall(calledName: string, callNode: any): RubyCallRouting {
// ── require / require_relative → import ─────────────────────────────────
if (calledName === 'require' || calledName === 'require_relative') {
const argList = callNode.childForFieldName?.('arguments');
const stringNode = argList?.children?.find((c: any) => c.type === 'string');
const contentNode = stringNode?.children?.find((c: any) => c.type === 'string_content');
if (!contentNode) return SKIP_RESULT;
let importPath: string = contentNode.text;
// Validate: reject null bytes, control chars, excessively long paths
if (!importPath || importPath.length > 1024 || /[\x00-\x1f]/.test(importPath)) {
return SKIP_RESULT;
}
const isRelative = calledName === 'require_relative';
if (isRelative && !importPath.startsWith('.')) {
importPath = './' + importPath;
}
return { kind: 'import', importPath, isRelative };
}
// ── include / extend / prepend → heritage (mixin) ──────────────────────
if (calledName === 'include' || calledName === 'extend' || calledName === 'prepend') {
let enclosingClass: string | null = null;
let current = callNode.parent;
let depth = 0;
while (current && ++depth <= MAX_PARENT_DEPTH) {
if (current.type === 'class' || current.type === 'module') {
const nameNode = current.childForFieldName?.('name');
if (nameNode) { enclosingClass = nameNode.text; break; }
}
current = current.parent;
}
if (!enclosingClass) return SKIP_RESULT;
const items: RubyHeritageItem[] = [];
const argList = callNode.childForFieldName?.('arguments');
for (const arg of (argList?.children ?? [])) {
if (arg.type === 'constant' || arg.type === 'scope_resolution') {
items.push({ enclosingClass, mixinName: arg.text, heritageKind: calledName as 'include' | 'extend' | 'prepend' });
}
}
return items.length > 0 ? { kind: 'heritage', items } : SKIP_RESULT;
}
// ── attr_accessor / attr_reader / attr_writer → property definitions ───
if (calledName === 'attr_accessor' || calledName === 'attr_reader' || calledName === 'attr_writer') {
// Extract YARD @return [Type] from preceding comment (e.g. `# @return [Address]`)
let yardType: string | undefined;
let sibling = callNode.previousSibling;
while (sibling) {
if (sibling.type === 'comment') {
const match = /@return\s+\[([^\]]+)\]/.exec(sibling.text);
if (match) {
const raw = match[1].trim();
// Extract simple type name: "User", "Array<User>" → "User"
const simple = raw.match(/^([A-Z]\w*)/);
if (simple) yardType = simple[1];
break;
}
} else if (sibling.isNamed) {
break; // stop at non-comment named sibling
}
sibling = sibling.previousSibling;
}
const items: RubyPropertyItem[] = [];
const argList = callNode.childForFieldName?.('arguments');
for (const arg of (argList?.children ?? [])) {
if (arg.type === 'simple_symbol') {
items.push({
propName: arg.text.startsWith(':') ? arg.text.slice(1) : arg.text,
accessorType: calledName as RubyAccessorType,
startLine: arg.startPosition.row,
endLine: arg.endPosition.row,
...(yardType ? { declaredType: yardType } : {}),
});
}
}
return items.length > 0 ? { kind: 'properties', items } : SKIP_RESULT;
}
// ── Everything else → regular call ─────────────────────────────────────
return CALL_RESULT;
}
@@ -14,7 +14,7 @@ import { detectFrameworkFromPath } from './framework-detection.js';
import { SupportedLanguages } from '../../config/supported-languages.js';
// ============================================================================
// NAME PATTERNS - All 9 supported languages
// NAME PATTERNS - All 11 supported languages
// ============================================================================
/**
@@ -191,6 +191,13 @@ const ENTRY_POINT_PATTERNS: Record<string, RegExp[]> = {
/^save$/, // Repository::save()
/^delete$/, // Repository::delete()
],
// Ruby
[SupportedLanguages.Ruby]: [
/^call$/, // Service objects (MyService.call)
/^perform$/, // Background jobs (Sidekiq, ActiveJob)
/^execute$/, // Command pattern
],
};
/** Pre-computed merged patterns (universal + language-specific) to avoid per-call array allocation. */
@@ -361,7 +368,12 @@ export function isTestFile(filePath: string): boolean {
p.endsWith('test.php') ||
p.endsWith('spec.php') ||
p.includes('/tests/feature/') ||
p.includes('/tests/unit/')
p.includes('/tests/unit/') ||
// Ruby test patterns
p.endsWith('_spec.rb') ||
p.endsWith('_test.rb') ||
p.includes('/spec/') ||
p.includes('/test/fixtures/')
);
}
@@ -222,6 +222,7 @@ const exportCheckers = {
[SupportedLanguages.CPlusPlus]: cCppExportChecker,
[SupportedLanguages.PHP]: phpExportChecker,
[SupportedLanguages.Swift]: swiftExportChecker,
[SupportedLanguages.Ruby]: (_node, _name) => true,
} satisfies Record<SupportedLanguages, ExportChecker>;
// ============================================================================
@@ -1,7 +1,7 @@
import fs from 'fs/promises';
import path from 'path';
import { glob } from 'glob';
import { shouldIgnorePath } from '../../config/ignore-service.js';
import { createIgnoreFilter } from '../../config/ignore-service.js';
export interface FileEntry {
path: string;
@@ -32,13 +32,14 @@ export const walkRepositoryPaths = async (
repoPath: string,
onProgress?: (current: number, total: number, filePath: string) => void
): Promise<ScannedFile[]> => {
const files = await glob('**/*', {
const ignoreFilter = await createIgnoreFilter(repoPath);
const filtered = await glob('**/*', {
cwd: repoPath,
nodir: true,
dot: false,
ignore: ignoreFilter,
});
const filtered = files.filter(file => !shouldIgnorePath(file));
const entries: ScannedFile[] = [];
let processed = 0;
let skippedLarge = 0;
@@ -330,6 +330,18 @@ export function detectFrameworkFromPath(filePath: string): FrameworkHint | null
return { framework: 'laravel', entryPointMultiplier: 1.5, reason: 'laravel-repository' };
}
// ========== RUBY ==========
// Ruby: bin/ or exe/ (CLI entry points)
if ((p.includes('/bin/') || p.includes('/exe/')) && p.endsWith('.rb')) {
return { framework: 'ruby', entryPointMultiplier: 2.5, reason: 'ruby-executable' };
}
// Ruby: Rakefile or *.rake (task definitions)
if (p.endsWith('/rakefile') || p.endsWith('.rake')) {
return { framework: 'ruby', entryPointMultiplier: 1.5, reason: 'ruby-rake' };
}
// ========== SWIFT / iOS ==========
// iOS App entry points (highest priority)
@@ -16,7 +16,6 @@
import { KnowledgeGraph } from '../graph/types.js';
import { ASTCache } from './ast-cache.js';
import { SymbolTable, SymbolDefinition } from './symbol-table.js';
import Parser from 'tree-sitter';
import { isLanguageAvailable, loadParser, loadLanguage } from '../tree-sitter/parser-loader.js';
import { LANGUAGE_QUERIES } from './tree-sitter-queries.js';
@@ -25,8 +24,7 @@ import { getLanguageFromFilename, isVerboseIngestionEnabled, yieldToEventLoop }
import { SupportedLanguages } from '../../config/supported-languages.js';
import { getTreeSitterBufferSize } from './constants.js';
import type { ExtractedHeritage } from './workers/parse-worker.js';
import { resolveSymbol } from './symbol-resolver.js';
import type { ImportMap, PackageMap } from './import-processor.js';
import type { ResolutionContext } from './resolution-context.js';
/** C#/Java convention: interfaces start with I followed by an uppercase letter */
const INTERFACE_NAME_RE = /^I[A-Z]/;
@@ -42,14 +40,12 @@ const INTERFACE_NAME_RE = /^I[A-Z]/;
const resolveExtendsType = (
parentName: string,
currentFilePath: string,
symbolTable: SymbolTable,
importMap: ImportMap,
ctx: ResolutionContext,
language: SupportedLanguages,
packageMap?: PackageMap,
): { type: 'EXTENDS' | 'IMPLEMENTS'; idPrefix: string } => {
const resolved = resolveSymbol(parentName, currentFilePath, symbolTable, importMap, packageMap);
if (resolved) {
const isInterface = resolved.type === 'Interface';
const resolved = ctx.resolve(parentName, currentFilePath);
if (resolved && resolved.candidates.length > 0) {
const isInterface = resolved.candidates[0].type === 'Interface';
return isInterface
? { type: 'IMPLEMENTS', idPrefix: 'Interface' }
: { type: 'EXTENDS', idPrefix: 'Class' };
@@ -66,14 +62,34 @@ const resolveExtendsType = (
return { type: 'EXTENDS', idPrefix: 'Class' };
};
/**
* Resolve a symbol ID for heritage, with fallback to generated ID.
* Uses ctx.resolve() → pick first candidate's nodeId → generate synthetic ID.
*/
const resolveHeritageId = (
name: string,
filePath: string,
ctx: ResolutionContext,
fallbackLabel: string,
fallbackKey?: string,
): string => {
const resolved = ctx.resolve(name, filePath);
if (resolved && resolved.candidates.length > 0) {
// For global with multiple candidates, refuse (a wrong edge is worse than no edge)
if (resolved.tier === 'global' && resolved.candidates.length > 1) {
return generateId(fallbackLabel, fallbackKey ?? name);
}
return resolved.candidates[0].nodeId;
}
return generateId(fallbackLabel, fallbackKey ?? name);
};
export const processHeritage = async (
graph: KnowledgeGraph,
files: { path: string; content: string }[],
astCache: ASTCache,
symbolTable: SymbolTable,
importMap: ImportMap,
packageMap?: PackageMap,
onProgress?: (current: number, total: number) => void
ctx: ResolutionContext,
onProgress?: (current: number, total: number) => void,
) => {
const parser = await loadParser();
const logSkipped = isVerboseIngestionEnabled();
@@ -102,8 +118,6 @@ export const processHeritage = async (
// 3. Get AST
let tree = astCache.get(file.path);
let wasReparsed = false;
if (!tree) {
// Use larger bufferSize for files > 32KB
try {
@@ -112,7 +126,6 @@ export const processHeritage = async (
// Skip files that can't be parsed
continue;
}
wasReparsed = true;
// Cache re-parsed tree for potential future use
astCache.set(file.path, tree);
}
@@ -148,14 +161,10 @@ export const processHeritage = async (
const className = captureMap['heritage.class'].text;
const parentClassName = captureMap['heritage.extends'].text;
const { type: relType, idPrefix } = resolveExtendsType(parentClassName, file.path, symbolTable, importMap, language, packageMap);
const { type: relType, idPrefix } = resolveExtendsType(parentClassName, file.path, ctx, language);
const childId = symbolTable.lookupExact(file.path, className) ||
resolveSymbol(className, file.path, symbolTable, importMap, packageMap)?.nodeId ||
generateId('Class', `${file.path}:${className}`);
const parentId = resolveSymbol(parentClassName, file.path, symbolTable, importMap, packageMap)?.nodeId ||
generateId(idPrefix, `${parentClassName}`);
const childId = resolveHeritageId(className, file.path, ctx, 'Class', `${file.path}:${className}`);
const parentId = resolveHeritageId(parentClassName, file.path, ctx, idPrefix);
if (childId && parentId && childId !== parentId) {
graph.addRelationship({
@@ -174,19 +183,12 @@ export const processHeritage = async (
const className = captureMap['heritage.class'].text;
const interfaceName = captureMap['heritage.implements'].text;
// Resolve class and interface IDs
const classId = symbolTable.lookupExact(file.path, className) ||
resolveSymbol(className, file.path, symbolTable, importMap, packageMap)?.nodeId ||
generateId('Class', `${file.path}:${className}`);
const interfaceId = resolveSymbol(interfaceName, file.path, symbolTable, importMap, packageMap)?.nodeId ||
generateId('Interface', `${interfaceName}`);
const classId = resolveHeritageId(className, file.path, ctx, 'Class', `${file.path}:${className}`);
const interfaceId = resolveHeritageId(interfaceName, file.path, ctx, 'Interface');
if (classId && interfaceId) {
const relId = generateId('IMPLEMENTS', `${classId}->${interfaceId}`);
graph.addRelationship({
id: relId,
id: generateId('IMPLEMENTS', `${classId}->${interfaceId}`),
sourceId: classId,
targetId: interfaceId,
type: 'IMPLEMENTS',
@@ -201,19 +203,12 @@ export const processHeritage = async (
const structName = captureMap['heritage.class'].text;
const traitName = captureMap['heritage.trait'].text;
// Resolve struct and trait IDs
const structId = symbolTable.lookupExact(file.path, structName) ||
resolveSymbol(structName, file.path, symbolTable, importMap, packageMap)?.nodeId ||
generateId('Struct', `${file.path}:${structName}`);
const traitId = resolveSymbol(traitName, file.path, symbolTable, importMap, packageMap)?.nodeId ||
generateId('Trait', `${traitName}`);
const structId = resolveHeritageId(structName, file.path, ctx, 'Struct', `${file.path}:${structName}`);
const traitId = resolveHeritageId(traitName, file.path, ctx, 'Trait');
if (structId && traitId) {
const relId = generateId('IMPLEMENTS', `${structId}->${traitId}`);
graph.addRelationship({
id: relId,
id: generateId('IMPLEMENTS', `${structId}->${traitId}`),
sourceId: structId,
targetId: traitId,
type: 'IMPLEMENTS',
@@ -243,10 +238,8 @@ export const processHeritage = async (
export const processHeritageFromExtracted = async (
graph: KnowledgeGraph,
extractedHeritage: ExtractedHeritage[],
symbolTable: SymbolTable,
importMap: ImportMap,
packageMap?: PackageMap,
onProgress?: (current: number, total: number) => void
ctx: ResolutionContext,
onProgress?: (current: number, total: number) => void,
) => {
const total = extractedHeritage.length;
@@ -261,14 +254,10 @@ export const processHeritageFromExtracted = async (
if (h.kind === 'extends') {
const fileLanguage = getLanguageFromFilename(h.filePath);
if (!fileLanguage) continue;
const { type: relType, idPrefix } = resolveExtendsType(h.parentName, h.filePath, symbolTable, importMap, fileLanguage, packageMap);
const { type: relType, idPrefix } = resolveExtendsType(h.parentName, h.filePath, ctx, fileLanguage);
const childId = symbolTable.lookupExact(h.filePath, h.className) ||
resolveSymbol(h.className, h.filePath, symbolTable, importMap, packageMap)?.nodeId ||
generateId('Class', `${h.filePath}:${h.className}`);
const parentId = resolveSymbol(h.parentName, h.filePath, symbolTable, importMap, packageMap)?.nodeId ||
generateId(idPrefix, `${h.parentName}`);
const childId = resolveHeritageId(h.className, h.filePath, ctx, 'Class', `${h.filePath}:${h.className}`);
const parentId = resolveHeritageId(h.parentName, h.filePath, ctx, idPrefix);
if (childId && parentId && childId !== parentId) {
graph.addRelationship({
@@ -281,12 +270,8 @@ export const processHeritageFromExtracted = async (
});
}
} else if (h.kind === 'implements') {
const classId = symbolTable.lookupExact(h.filePath, h.className) ||
resolveSymbol(h.className, h.filePath, symbolTable, importMap, packageMap)?.nodeId ||
generateId('Class', `${h.filePath}:${h.className}`);
const interfaceId = resolveSymbol(h.parentName, h.filePath, symbolTable, importMap, packageMap)?.nodeId ||
generateId('Interface', `${h.parentName}`);
const classId = resolveHeritageId(h.className, h.filePath, ctx, 'Class', `${h.filePath}:${h.className}`);
const interfaceId = resolveHeritageId(h.parentName, h.filePath, ctx, 'Interface');
if (classId && interfaceId) {
graph.addRelationship({
@@ -298,22 +283,18 @@ export const processHeritageFromExtracted = async (
reason: '',
});
}
} else if (h.kind === 'trait-impl') {
const structId = symbolTable.lookupExact(h.filePath, h.className) ||
resolveSymbol(h.className, h.filePath, symbolTable, importMap, packageMap)?.nodeId ||
generateId('Struct', `${h.filePath}:${h.className}`);
const traitId = resolveSymbol(h.parentName, h.filePath, symbolTable, importMap, packageMap)?.nodeId ||
generateId('Trait', `${h.parentName}`);
} else if (h.kind === 'trait-impl' || h.kind === 'include' || h.kind === 'extend' || h.kind === 'prepend') {
const structId = resolveHeritageId(h.className, h.filePath, ctx, 'Struct', `${h.filePath}:${h.className}`);
const traitId = resolveHeritageId(h.parentName, h.filePath, ctx, 'Trait');
if (structId && traitId) {
graph.addRelationship({
id: generateId('IMPLEMENTS', `${structId}->${traitId}`),
id: generateId('IMPLEMENTS', `${structId}->${traitId}:${h.kind}`),
sourceId: structId,
targetId: traitId,
type: 'IMPLEMENTS',
confidence: 1.0,
reason: 'trait-impl',
reason: h.kind,
});
}
}
+44 -16
View File
@@ -30,7 +30,11 @@ import {
resolveCSharpNamespaceDir,
resolvePhpImport,
resolveRustImport,
resolveRubyImport,
resolvePythonImport,
} from './resolvers/index.js';
import { callRouters } from './call-routing.js';
import type { ResolutionContext } from './resolution-context.js';
import type {
SuffixIndex,
TsconfigPaths,
@@ -54,15 +58,11 @@ const isDev = process.env.NODE_ENV === 'development';
// Stores all files that a given file imports from
export type ImportMap = Map<string, Set<string>>;
export const createImportMap = (): ImportMap => new Map();
// Type: Map<FilePath, Set<PackageDirSuffix>>
// Stores Go package directory suffixes imported by a file (e.g., "/internal/auth/").
// Avoids expanding every Go package import into N individual ImportMap edges.
export type PackageMap = Map<string, Set<string>>;
export const createPackageMap = (): PackageMap => new Map();
// Type: Map<ImportingFilePath, Map<LocalName, {sourcePath, exportedName}>>
// Tracks which specific names a file imports from which sources (TS/Python only).
// Used to tighten Tier 2a resolution: `import { User } from './models'`
@@ -72,8 +72,6 @@ export const createPackageMap = (): PackageMap => new Map();
export interface NamedImportBinding { sourcePath: string; exportedName: string }
export type NamedImportMap = Map<string, Map<string, NamedImportBinding>>;
export const createNamedImportMap = (): NamedImportMap => new Map();
/**
* Check if a file path is directly inside a package directory identified by its suffix.
* Used by the symbol resolver for Go and C# directory-level import matching.
@@ -222,6 +220,20 @@ 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);
return resolved ? { kind: 'files', files: [resolved] } : null;
}
// Rust: expand top-level grouped imports: use {crate::a, crate::b}
if (language === SupportedLanguages.Rust && rawImportPath.startsWith('{') && rawImportPath.endsWith('}')) {
const inner = rawImportPath.slice(1, -1);
@@ -301,13 +313,14 @@ export const processImports = async (
graph: KnowledgeGraph,
files: { path: string; content: string }[],
astCache: ASTCache,
importMap: ImportMap,
ctx: ResolutionContext,
onProgress?: (current: number, total: number) => void,
repoRoot?: string,
allPaths?: string[],
packageMap?: PackageMap,
namedImportMap?: NamedImportMap,
) => {
const importMap = ctx.importMap;
const packageMap = ctx.packageMap;
const namedImportMap = ctx.namedImportMap;
// Use allPaths (full repo) when available for cross-chunk resolution, else fall back to chunk files
const allFileList = allPaths ?? files.map(f => f.path);
const allFilePaths = new Set(allFileList);
@@ -333,7 +346,7 @@ export const processImports = async (
swiftPackageConfig: await loadSwiftPackageConfig(effectiveRoot),
csharpConfigs: await loadCSharpProjectConfig(effectiveRoot),
};
const ctx: ResolveCtx = { allFilePaths, allFileList, normalizedFileList, index, resolveCache };
const resolveCtx: ResolveCtx = { allFilePaths, allFileList, normalizedFileList, index, resolveCache };
// Helper: add an IMPORTS edge to the graph only (no ImportMap update)
const addImportGraphEdge = (filePath: string, resolvedPath: string) => {
@@ -440,10 +453,24 @@ export const processImports = async (
: sourceNode.text.replace(/['"<>]/g, '');
totalImportsFound++;
const result = resolveLanguageImport(file.path, rawImportPath, language, configs, ctx);
const result = resolveLanguageImport(file.path, rawImportPath, language, configs, resolveCtx);
const bindings = namedImportMap ? extractNamedBindings(captureMap['import'], language) : undefined;
applyImportResult(result, file.path, importMap, packageMap, addImportEdge, addImportGraphEdge, bindings, namedImportMap);
}
// ---- Language-specific call-as-import routing (Ruby require, etc.) ----
if (captureMap['call']) {
const callNameNode = captureMap['call.name'];
if (callNameNode) {
const callRouter = callRouters[language];
const routed = callRouter(callNameNode.text, captureMap['call']);
if (routed && routed.kind === 'import') {
totalImportsFound++;
const result = resolveLanguageImport(file.path, routed.importPath, language, configs, resolveCtx);
applyImportResult(result, file.path, importMap, packageMap, addImportEdge, addImportGraphEdge);
}
}
}
});
// Tree is now owned by the LRU cache — no manual delete needed
@@ -470,15 +497,16 @@ export const processImportsFromExtracted = async (
graph: KnowledgeGraph,
files: { path: string }[],
extractedImports: ExtractedImport[],
importMap: ImportMap,
ctx: ResolutionContext,
onProgress?: (current: number, total: number) => void,
repoRoot?: string,
prebuiltCtx?: ImportResolutionContext,
packageMap?: PackageMap,
namedImportMap?: NamedImportMap,
) => {
const ctx = prebuiltCtx ?? buildImportResolutionContext(files.map(f => f.path));
const { allFilePaths, allFileList, normalizedFileList, suffixIndex: index, resolveCache } = ctx;
const importMap = ctx.importMap;
const packageMap = ctx.packageMap;
const namedImportMap = ctx.namedImportMap;
const importCtx = prebuiltCtx ?? buildImportResolutionContext(files.map(f => f.path));
const { allFilePaths, allFileList, normalizedFileList, suffixIndex: index, resolveCache } = importCtx;
let totalImportsFound = 0;
let totalImportsResolved = 0;
@@ -1,15 +1,18 @@
import { KnowledgeGraph, GraphNode, GraphRelationship } from '../graph/types.js';
import { KnowledgeGraph, GraphNode, GraphRelationship, type NodeLabel } from '../graph/types.js';
import Parser from 'tree-sitter';
import { loadParser, loadLanguage } from '../tree-sitter/parser-loader.js';
import { loadParser, loadLanguage, isLanguageAvailable } from '../tree-sitter/parser-loader.js';
import { LANGUAGE_QUERIES } from './tree-sitter-queries.js';
import { generateId } from '../../lib/utils.js';
import { SymbolTable } from './symbol-table.js';
import { ASTCache } from './ast-cache.js';
import { getLanguageFromFilename, yieldToEventLoop, DEFINITION_CAPTURE_KEYS, getDefinitionNodeFromCaptures, findEnclosingClassId, extractMethodSignature } from './utils.js';
import { getLanguageFromFilename, yieldToEventLoop, getDefinitionNodeFromCaptures, findEnclosingClassId, extractMethodSignature } from './utils.js';
import { extractPropertyDeclaredType } from './type-extractors/shared.js';
import { isNodeExported } from './export-detection.js';
import { detectFrameworkFromAST } from './framework-detection.js';
import { typeConfigs } from './type-extractors/index.js';
import { SupportedLanguages } from '../../config/supported-languages.js';
import { WorkerPool } from './workers/worker-pool.js';
import type { ParseWorkerResult, ParseWorkerInput, ExtractedImport, ExtractedCall, ExtractedHeritage, ExtractedRoute } from './workers/parse-worker.js';
import type { ParseWorkerResult, ParseWorkerInput, ExtractedImport, ExtractedCall, ExtractedAssignment, ExtractedHeritage, ExtractedRoute, FileConstructorBindings } from './workers/parse-worker.js';
import { getTreeSitterBufferSize, TREE_SITTER_MAX_BUFFER } from './constants.js';
export type FileProgressCallback = (current: number, total: number, filePath: string) => void;
@@ -17,8 +20,10 @@ export type FileProgressCallback = (current: number, total: number, filePath: st
export interface WorkerExtractedData {
imports: ExtractedImport[];
calls: ExtractedCall[];
assignments: ExtractedAssignment[];
heritage: ExtractedHeritage[];
routes: ExtractedRoute[];
constructorBindings: FileConstructorBindings[];
}
// isNodeExported imported from ./export-detection.js (shared module)
@@ -44,7 +49,7 @@ const processParsingWithWorkers = async (
if (lang) parseableFiles.push({ path: file.path, content: file.content });
}
if (parseableFiles.length === 0) return { imports: [], calls: [], heritage: [], routes: [] };
if (parseableFiles.length === 0) return { imports: [], calls: [], assignments: [], heritage: [], routes: [], constructorBindings: [] };
const total = files.length;
@@ -59,8 +64,10 @@ const processParsingWithWorkers = async (
// Merge results from all workers into graph and symbol table
const allImports: ExtractedImport[] = [];
const allCalls: ExtractedCall[] = [];
const allAssignments: ExtractedAssignment[] = [];
const allHeritage: ExtractedHeritage[] = [];
const allRoutes: ExtractedRoute[] = [];
const allConstructorBindings: FileConstructorBindings[] = [];
for (const result of chunkResults) {
for (const node of result.nodes) {
graph.addNode({
@@ -77,19 +84,37 @@ const processParsingWithWorkers = async (
for (const sym of result.symbols) {
symbolTable.add(sym.filePath, sym.name, sym.nodeId, sym.type, {
parameterCount: sym.parameterCount,
returnType: sym.returnType,
declaredType: sym.declaredType,
ownerId: sym.ownerId,
});
}
allImports.push(...result.imports);
allCalls.push(...result.calls);
allAssignments.push(...result.assignments);
allHeritage.push(...result.heritage);
allRoutes.push(...result.routes);
allConstructorBindings.push(...result.constructorBindings);
}
// Merge and log skipped languages from workers
const skippedLanguages = new Map<string, number>();
for (const result of chunkResults) {
for (const [lang, count] of Object.entries(result.skippedLanguages)) {
skippedLanguages.set(lang, (skippedLanguages.get(lang) || 0) + count);
}
}
if (skippedLanguages.size > 0) {
const summary = Array.from(skippedLanguages.entries())
.map(([lang, count]) => `${lang}: ${count}`)
.join(', ');
console.warn(` Skipped unsupported languages: ${summary}`);
}
// Final progress
onFileProgress?.(total, total, 'done');
return { imports: allImports, calls: allCalls, heritage: allHeritage, routes: allRoutes };
return { imports: allImports, calls: allCalls, assignments: allAssignments, heritage: allHeritage, routes: allRoutes, constructorBindings: allConstructorBindings };
};
// ============================================================================
@@ -105,6 +130,7 @@ const processParsingSequential = async (
) => {
const parser = await loadParser();
const total = files.length;
const skippedLanguages = new Map<string, number>();
for (let i = 0; i < files.length; i++) {
const file = files[i];
@@ -117,13 +143,19 @@ const processParsingSequential = async (
if (!language) continue;
// Skip unsupported languages (e.g. Swift when tree-sitter-swift not installed)
if (!isLanguageAvailable(language)) {
skippedLanguages.set(language, (skippedLanguages.get(language) || 0) + 1);
continue;
}
// Skip files larger than the max tree-sitter buffer (32 MB)
if (file.content.length > TREE_SITTER_MAX_BUFFER) continue;
try {
await loadLanguage(language, file.path);
} catch {
continue; // parser unavailable — already warned in pipeline
continue; // parser unavailable — safety net
}
let tree;
@@ -172,9 +204,24 @@ const processParsingSequential = async (
if (!nameNode && !captureMap['definition.constructor']) return;
const nodeName = nameNode ? nameNode.text : 'init';
let nodeLabel = 'CodeElement';
let nodeLabel: NodeLabel = 'CodeElement';
if (captureMap['definition.function']) nodeLabel = 'Function';
if (captureMap['definition.function']) {
// C/C++: @definition.function is broad and also matches inline class methods (inside
// a class/struct body). Those are already captured by @definition.method, so skip
// the duplicate Function entry to prevent double-indexing in globalIndex.
if (language === SupportedLanguages.CPlusPlus || language === SupportedLanguages.C) {
let ancestor = captureMap['definition.function']?.parent;
while (ancestor) {
if (ancestor.type === 'class_specifier' || ancestor.type === 'struct_specifier') {
break;
}
ancestor = ancestor.parent;
}
if (ancestor) return; // inside a class body — handled by @definition.method
}
nodeLabel = 'Function';
}
else if (captureMap['definition.class']) nodeLabel = 'Class';
else if (captureMap['definition.interface']) nodeLabel = 'Interface';
else if (captureMap['definition.method']) nodeLabel = 'Method';
@@ -211,6 +258,16 @@ const processParsingSequential = async (
? extractMethodSignature(definitionNode)
: undefined;
// Language-specific return type fallback (e.g. Ruby YARD @return [Type])
// 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) {
const docReturn = tc.extractReturnType(definitionNode);
if (docReturn) methodSig.returnType = docReturn;
}
}
const node: GraphNode = {
id: nodeId,
label: nodeLabel as any,
@@ -239,8 +296,15 @@ const processParsingSequential = async (
const needsOwner = nodeLabel === 'Method' || nodeLabel === 'Constructor' || nodeLabel === 'Property' || nodeLabel === 'Function';
const enclosingClassId = needsOwner ? findEnclosingClassId(nameNode || definitionNodeForRange, file.path) : null;
// Extract declared type for Property nodes (field/property type annotations)
const declaredType = (nodeLabel === 'Property' && definitionNode)
? extractPropertyDeclaredType(definitionNode)
: undefined;
symbolTable.add(file.path, nodeName, nodeId, nodeLabel, {
parameterCount: methodSig?.parameterCount,
returnType: methodSig?.returnType,
declaredType,
ownerId: enclosingClassId ?? undefined,
});
@@ -259,19 +323,27 @@ const processParsingSequential = async (
graph.addRelationship(relationship);
// ── HAS_METHOD: link method/constructor/property to enclosing class ──
// ── HAS_METHOD / HAS_PROPERTY: link member to enclosing class ──
if (enclosingClassId) {
const memberEdgeType = nodeLabel === 'Property' ? 'HAS_PROPERTY' : 'HAS_METHOD';
graph.addRelationship({
id: generateId('HAS_METHOD', `${enclosingClassId}->${nodeId}`),
id: generateId(memberEdgeType, `${enclosingClassId}->${nodeId}`),
sourceId: enclosingClassId,
targetId: nodeId,
type: 'HAS_METHOD',
type: memberEdgeType,
confidence: 1.0,
reason: '',
});
}
});
}
if (skippedLanguages.size > 0) {
const summary = Array.from(skippedLanguages.entries())
.map(([lang, count]) => `${lang}: ${count}`)
.join(', ');
console.warn(` Skipped unsupported languages: ${summary}`);
}
};
// ============================================================================
+210 -149
View File
@@ -4,17 +4,14 @@ import { processParsing } from './parsing-processor.js';
import {
processImports,
processImportsFromExtracted,
createImportMap,
createPackageMap,
createNamedImportMap,
buildImportResolutionContext
} from './import-processor.js';
import { processCalls, processCallsFromExtracted, processRoutesFromExtracted } from './call-processor.js';
import { processCalls, processCallsFromExtracted, processAssignmentsFromExtracted, processRoutesFromExtracted } from './call-processor.js';
import { processHeritage, processHeritageFromExtracted } from './heritage-processor.js';
import { computeMRO } from './mro-processor.js';
import { processCommunities } from './community-processor.js';
import { processProcesses } from './process-processor.js';
import { createSymbolTable } from './symbol-table.js';
import { createResolutionContext } from './resolution-context.js';
import { createASTCache } from './ast-cache.js';
import { PipelineProgress, PipelineResult } from '../../types/pipeline.js';
import { walkRepositoryPaths, readFileContents } from './filesystem-walker.js';
@@ -36,20 +33,24 @@ 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 symbolTable = createSymbolTable();
const ctx = createResolutionContext();
const symbolTable = ctx.symbols;
let astCache = createASTCache(AST_CACHE_CAP);
const importMap = createImportMap();
const packageMap = createPackageMap();
const namedImportMap = createNamedImportMap();
const cleanup = () => {
astCache.clear();
symbolTable.clear();
ctx.clear();
};
try {
@@ -159,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;
@@ -221,38 +229,73 @@ export const runPipelineFromRepo = async (
workerPool,
);
const chunkBasePercent = 20 + ((filesParsedSoFar / totalParseable) * 62);
if (chunkWorkerData) {
// Imports
await processImportsFromExtracted(graph, allPathObjects, chunkWorkerData.imports, importMap, undefined, repoPath, importCtx, packageMap, namedImportMap);
await processImportsFromExtracted(graph, allPathObjects, chunkWorkerData.imports, ctx, (current, total) => {
onProgress({
phase: 'parsing',
percent: Math.round(chunkBasePercent),
message: `Resolving imports (chunk ${chunkIdx + 1}/${numChunks})...`,
detail: `${current}/${total} files`,
stats: { filesProcessed: filesParsedSoFar, totalFiles: totalParseable, nodesCreated: graph.nodeCount },
});
}, repoPath, importCtx);
// Calls + Heritage + Routes — resolve in parallel (no shared mutable state between them)
// This is safe because each writes disjoint relationship types into idempotent id-keyed Maps,
// and the single-threaded event loop prevents races between synchronous addRelationship calls.
await Promise.all([
processCallsFromExtracted(
graph,
chunkWorkerData.calls,
symbolTable, importMap,
packageMap,
undefined,
namedImportMap
graph,
chunkWorkerData.calls,
ctx,
(current, total) => {
onProgress({
phase: 'parsing',
percent: Math.round(chunkBasePercent),
message: `Resolving calls (chunk ${chunkIdx + 1}/${numChunks})...`,
detail: `${current}/${total} files`,
stats: { filesProcessed: filesParsedSoFar, totalFiles: totalParseable, nodesCreated: graph.nodeCount },
});
},
chunkWorkerData.constructorBindings,
),
processHeritageFromExtracted(
graph,
chunkWorkerData.heritage,
symbolTable,
importMap,
packageMap
graph,
chunkWorkerData.heritage,
ctx,
(current, total) => {
onProgress({
phase: 'parsing',
percent: Math.round(chunkBasePercent),
message: `Resolving heritage (chunk ${chunkIdx + 1}/${numChunks})...`,
detail: `${current}/${total} records`,
stats: { filesProcessed: filesParsedSoFar, totalFiles: totalParseable, nodesCreated: graph.nodeCount },
});
},
),
processRoutesFromExtracted(
graph,
chunkWorkerData.routes ?? [],
symbolTable,
importMap,
packageMap
graph,
chunkWorkerData.routes ?? [],
ctx,
(current, total) => {
onProgress({
phase: 'parsing',
percent: Math.round(chunkBasePercent),
message: `Resolving routes (chunk ${chunkIdx + 1}/${numChunks})...`,
detail: `${current}/${total} routes`,
stats: { filesProcessed: filesParsedSoFar, totalFiles: totalParseable, nodesCreated: graph.nodeCount },
});
},
),
]);
// Process field write assignments (synchronous, runs after calls resolve)
if (chunkWorkerData.assignments?.length) {
processAssignmentsFromExtracted(graph, chunkWorkerData.assignments, ctx, chunkWorkerData.constructorBindings);
}
} else {
await processImports(graph, chunkFiles, astCache, importMap, undefined, repoPath, allPaths, packageMap, namedImportMap);
await processImports(graph, chunkFiles, astCache, ctx, undefined, repoPath, allPaths);
sequentialChunkPaths.push(chunkPaths);
}
@@ -273,11 +316,22 @@ export const runPipelineFromRepo = async (
.filter(p => chunkContents.has(p))
.map(p => ({ path: p, content: chunkContents.get(p)! }));
astCache = createASTCache(chunkFiles.length);
await processCalls(graph, chunkFiles, astCache, symbolTable, importMap, packageMap, undefined, namedImportMap);
await processHeritage(graph, chunkFiles, astCache, symbolTable, importMap, packageMap);
const rubyHeritage = await processCalls(graph, chunkFiles, astCache, ctx);
await processHeritage(graph, chunkFiles, astCache, ctx);
if (rubyHeritage.length > 0) {
await processHeritageFromExtracted(graph, rubyHeritage, ctx);
}
astCache.clear();
}
// Log resolution cache stats
if (isDev) {
const rcStats = ctx.getStats();
const total = rcStats.cacheHits + rcStats.cacheMisses;
const hitRate = total > 0 ? ((rcStats.cacheHits / total) * 100).toFixed(1) : '0';
console.log(`🔍 Resolution cache: ${rcStats.cacheHits} hits, ${rcStats.cacheMisses} misses (${hitRate}% hit rate)`);
}
// Free import resolution context — suffix index + resolve cache no longer needed
// (allPathObjects and importCtx hold ~94MB+ for large repos)
allPathObjects.length = 0;
@@ -285,130 +339,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,
@@ -0,0 +1,192 @@
/**
* Resolution Context
*
* Single implementation of tiered name resolution. Replaces the duplicated
* tier-selection logic previously split between symbol-resolver.ts and
* call-processor.ts.
*
* Resolution tiers (highest confidence first):
* 1. Same file (lookupExactFull — authoritative)
* 2a-named. Named binding chain (walkBindingChain via NamedImportMap)
* 2a. Import-scoped (lookupFuzzy filtered by ImportMap)
* 2b. Package-scoped (lookupFuzzy filtered by PackageMap)
* 3. Global (all candidates — consumers must check candidate count)
*/
import type { SymbolTable, SymbolDefinition } from './symbol-table.js';
import { createSymbolTable } from './symbol-table.js';
import type { NamedImportBinding } from './import-processor.js';
import { isFileInPackageDir } from './import-processor.js';
import { walkBindingChain } from './named-binding-extraction.js';
/** Resolution tier for tracking, logging, and test assertions. */
export type ResolutionTier = 'same-file' | 'import-scoped' | 'global';
/** Tier-selected candidates with metadata. */
export interface TieredCandidates {
readonly candidates: readonly SymbolDefinition[];
readonly tier: ResolutionTier;
}
/** Confidence scores per resolution tier. */
export const TIER_CONFIDENCE: Record<ResolutionTier, number> = {
'same-file': 0.95,
'import-scoped': 0.9,
'global': 0.5,
};
// --- Map types ---
export type ImportMap = Map<string, Set<string>>;
export type PackageMap = Map<string, Set<string>>;
export type NamedImportMap = Map<string, Map<string, NamedImportBinding>>;
export interface ResolutionContext {
/**
* The only resolution API. Returns all candidates at the winning tier.
*
* Tier 3 ('global') returns ALL candidates regardless of count —
* consumers must check candidates.length and refuse ambiguous matches.
*/
resolve(name: string, fromFile: string): TieredCandidates | null;
// --- Data access (for pipeline wiring, not resolution) ---
/** Symbol table — used by parsing-processor to populate symbols. */
readonly symbols: SymbolTable;
/** Raw maps — used by import-processor to populate import data. */
readonly importMap: ImportMap;
readonly packageMap: PackageMap;
readonly namedImportMap: NamedImportMap;
// --- Per-file cache lifecycle ---
enableCache(filePath: string): void;
clearCache(): void;
// --- Operational ---
getStats(): { fileCount: number; globalSymbolCount: number; cacheHits: number; cacheMisses: number };
clear(): void;
}
export const createResolutionContext = (): ResolutionContext => {
const symbols = createSymbolTable();
const importMap: ImportMap = new Map();
const packageMap: PackageMap = new Map();
const namedImportMap: NamedImportMap = new Map();
// Per-file cache state
let cacheFile: string | null = null;
let cache: Map<string, TieredCandidates | null> | null = null;
let cacheHits = 0;
let cacheMisses = 0;
// --- Core resolution (single implementation of tier logic) ---
const resolveUncached = (name: string, fromFile: string): TieredCandidates | null => {
// Tier 1: Same file — authoritative match
const localDef = symbols.lookupExactFull(fromFile, name);
if (localDef) {
return { candidates: [localDef], tier: 'same-file' };
}
// Get all global definitions for subsequent tiers
const allDefs = symbols.lookupFuzzy(name);
// Tier 2a-named: Check named bindings BEFORE empty-allDefs early return
// because aliased imports mean lookupFuzzy('U') returns empty but we
// can resolve via the exported name.
const chainResult = walkBindingChain(name, fromFile, symbols, namedImportMap, allDefs);
if (chainResult && chainResult.length > 0) {
return { candidates: chainResult, tier: 'import-scoped' };
}
if (allDefs.length === 0) return null;
// Tier 2a: Import-scoped — definition in a file imported by fromFile
const importedFiles = importMap.get(fromFile);
if (importedFiles) {
const importedDefs = allDefs.filter(def => importedFiles.has(def.filePath));
if (importedDefs.length > 0) {
return { candidates: importedDefs, tier: 'import-scoped' };
}
}
// Tier 2b: Package-scoped — definition in a package dir imported by fromFile
const importedPackages = packageMap.get(fromFile);
if (importedPackages) {
const packageDefs = allDefs.filter(def => {
for (const dirSuffix of importedPackages) {
if (isFileInPackageDir(def.filePath, dirSuffix)) return true;
}
return false;
});
if (packageDefs.length > 0) {
return { candidates: packageDefs, tier: 'import-scoped' };
}
}
// Tier 3: Global — pass all candidates through.
// Consumers must check candidate count and refuse ambiguous matches.
return { candidates: allDefs, tier: 'global' };
};
const resolve = (name: string, fromFile: string): TieredCandidates | null => {
// Check cache (only when enabled AND fromFile matches cached file)
if (cache && cacheFile === fromFile) {
if (cache.has(name)) {
cacheHits++;
return cache.get(name)!;
}
cacheMisses++;
}
const result = resolveUncached(name, fromFile);
// Store in cache if active and file matches
if (cache && cacheFile === fromFile) {
cache.set(name, result);
}
return result;
};
// --- Cache lifecycle ---
const enableCache = (filePath: string): void => {
cacheFile = filePath;
if (!cache) cache = new Map();
else cache.clear();
};
const clearCache = (): void => {
cacheFile = null;
// Reuse the Map instance — just clear entries to reduce GC pressure at scale.
cache?.clear();
};
const getStats = () => ({
...symbols.getStats(),
cacheHits,
cacheMisses,
});
const clear = (): void => {
symbols.clear();
importMap.clear();
packageMap.clear();
namedImportMap.clear();
clearCache();
cacheHits = 0;
cacheMisses = 0;
};
return {
resolve,
symbols,
importMap,
packageMap,
namedImportMap,
enableCache,
clearCache,
getStats,
clear,
};
};
@@ -19,5 +19,9 @@ export type { ComposerConfig } from './php.js';
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;
}
@@ -0,0 +1,23 @@
/**
* Ruby require/require_relative import resolution.
* Handles path resolution for Ruby's require and require_relative calls.
*/
import type { SuffixIndex } from './utils.js';
import { suffixResolve } from './utils.js';
/**
* Resolve a Ruby require/require_relative path to a matching .rb file.
*
* require_relative paths are pre-normalized to './' prefix by the caller.
* require paths use suffix matching (gem-style paths like 'json', 'net/http').
*/
export function resolveRubyImport(
importPath: string,
normalizedFileList: string[],
allFileList: string[],
index?: SuffixIndex,
): string | null {
const pathParts = importPath.replace(/^\.\//, '').split('/').filter(Boolean);
return suffixResolve(pathParts, normalizedFileList, allFileList, index);
}
@@ -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('/');
@@ -26,6 +26,8 @@ export const EXTENSIONS = [
'.php', '.phtml',
// Swift
'.swift',
// Ruby
'.rb',
];
/**
@@ -1,123 +0,0 @@
/**
* Symbol Resolver
*
* Import-filtered candidate narrowing for bare identifier resolution.
* NOT FQN resolution — does not parse qualifiers (ns::Bar, com.foo.Bar).
*
* Shared between heritage-processor.ts and call-processor.ts.
*/
import type { SymbolTable, SymbolDefinition } from './symbol-table.js';
import type { ImportMap, PackageMap, NamedImportMap } from './import-processor.js';
import { isFileInPackageDir } from './import-processor.js';
import { walkBindingChain } from './named-binding-extraction.js';
/** Resolution tier for internal tracking, logging, and test assertions. */
export type ResolutionTier = 'same-file' | 'import-scoped' | 'unique-global';
/** Internal resolution result preserving tier metadata. */
export interface InternalResolution {
definition: SymbolDefinition;
tier: ResolutionTier;
candidateCount: number;
}
/**
* Resolve a bare identifier to its best-matching definition using import context.
*
* Resolution tiers (highest confidence first):
* 1. Same file (lookupExactFull — authoritative)
* 2. Import-scoped (lookupFuzzy filtered by importMap — acceptable)
* 3. Unique global (lookupFuzzy with exactly 1 match — acceptable fallback)
*
* If multiple global candidates remain after filtering, returns null.
* A wrong edge is worse than no edge.
*/
export const resolveSymbol = (
name: string,
currentFilePath: string,
symbolTable: SymbolTable,
importMap: ImportMap,
packageMap?: PackageMap,
namedImportMap?: NamedImportMap,
): SymbolDefinition | null => {
return resolveSymbolInternal(name, currentFilePath, symbolTable, importMap, packageMap, namedImportMap)?.definition ?? null;
};
/** Internal resolver preserving tier metadata for logging and test assertions. */
export const resolveSymbolInternal = (
name: string,
currentFilePath: string,
symbolTable: SymbolTable,
importMap: ImportMap,
packageMap?: PackageMap,
namedImportMap?: NamedImportMap,
): InternalResolution | null => {
// Tier 1: Same file — authoritative match
const localDef = symbolTable.lookupExactFull(currentFilePath, name);
if (localDef) return { definition: localDef, tier: 'same-file', candidateCount: 1 };
// Get all global definitions for subsequent tiers
const allDefs = symbolTable.lookupFuzzy(name);
// Tier 2a-named: Check named bindings BEFORE the empty-allDefs early return,
// because aliased imports (import { User as U }) mean lookupFuzzy('U') returns
// empty but we can resolve via the exported name.
if (namedImportMap) {
const result = resolveNamedBindingChain(name, currentFilePath, symbolTable, namedImportMap, allDefs);
if (result) return result;
}
if (allDefs.length === 0) return null;
// Tier 2a: Import-scoped — check if any definition is in a file imported by currentFile
const importedFiles = importMap.get(currentFilePath);
if (importedFiles) {
for (const def of allDefs) {
if (importedFiles.has(def.filePath)) {
return { definition: def, tier: 'import-scoped', candidateCount: allDefs.length };
}
}
}
// Tier 2b: Package-scoped — check if any definition is in a package/namespace dir imported by currentFile
// Used for Go packages and C# namespace imports to avoid ImportMap expansion bloat
const importedPackages = packageMap?.get(currentFilePath);
if (importedPackages) {
for (const def of allDefs) {
for (const dirSuffix of importedPackages) {
if (isFileInPackageDir(def.filePath, dirSuffix)) {
return { definition: def, tier: 'import-scoped', candidateCount: allDefs.length };
}
}
}
}
// Tier 3: Unique global — ONLY if exactly one candidate exists
// Ambiguous global matches are refused. A wrong edge is worse than no edge.
if (allDefs.length === 1) {
return { definition: allDefs[0], tier: 'unique-global', candidateCount: 1 };
}
// Ambiguous: multiple global candidates, no import or same-file match → refuse
return null;
};
/**
* Follow re-export chains through NamedImportMap.
* Delegates chain-walking to the shared walkBindingChain utility, then
* applies symbol-resolver semantics: exactly one match required.
*/
const resolveNamedBindingChain = (
name: string,
currentFilePath: string,
symbolTable: SymbolTable,
namedImportMap: NamedImportMap,
allDefs: SymbolDefinition[],
): InternalResolution | null => {
const defs = walkBindingChain(name, currentFilePath, symbolTable, namedImportMap, allDefs);
if (defs?.length === 1) {
return { definition: defs[0], tier: 'import-scoped', candidateCount: defs.length };
}
return null;
};
+75 -9
View File
@@ -1,9 +1,15 @@
import type { NodeLabel } from '../graph/types.js';
export interface SymbolDefinition {
nodeId: string;
filePath: string;
type: string; // 'Function', 'Class', etc.
type: NodeLabel;
parameterCount?: number;
/** Links Method/Constructor to owning Class/Struct/Trait nodeId */
/** Raw return type text extracted from AST (e.g. 'User', 'Promise<User>') */
returnType?: string;
/** Declared type for non-callable symbols — fields/properties (e.g. 'Address', 'List<User>') */
declaredType?: string;
/** Links Method/Constructor/Property to owning Class/Struct/Trait nodeId */
ownerId?: string;
}
@@ -15,8 +21,8 @@ export interface SymbolTable {
filePath: string,
name: string,
nodeId: string,
type: string,
metadata?: { parameterCount?: number; ownerId?: string }
type: NodeLabel,
metadata?: { parameterCount?: number; returnType?: string; declaredType?: string; ownerId?: string }
) => void;
/**
@@ -36,7 +42,21 @@ 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[];
/**
* Look up a field/property by its owning class nodeId and field name.
* O(1) via dedicated eagerly-populated index keyed by `ownerNodeId\0fieldName`.
* Returns undefined when no matching property exists or the owner is ambiguous.
*/
lookupFieldByOwner: (ownerNodeId: string, fieldName: string) => SymbolDefinition | undefined;
/**
* Debugging: See how many symbols are tracked
*/
@@ -57,18 +77,31 @@ 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;
// 4. Eagerly-populated Field/Property Index — keyed by "ownerNodeId\0fieldName".
// Only Property symbols with ownerId and declaredType are indexed.
const fieldByOwner = new Map<string, SymbolDefinition>();
const CALLABLE_TYPES = new Set(['Function', 'Method', 'Constructor']);
const add = (
filePath: string,
name: string,
nodeId: string,
type: string,
metadata?: { parameterCount?: number; ownerId?: string }
type: NodeLabel,
metadata?: { parameterCount?: number; returnType?: string; declaredType?: string; ownerId?: string }
) => {
const def: SymbolDefinition = {
nodeId,
filePath,
type,
...(metadata?.parameterCount !== undefined ? { parameterCount: metadata.parameterCount } : {}),
...(metadata?.returnType !== undefined ? { returnType: metadata.returnType } : {}),
...(metadata?.declaredType !== undefined ? { declaredType: metadata.declaredType } : {}),
...(metadata?.ownerId !== undefined ? { ownerId: metadata.ownerId } : {}),
};
@@ -78,11 +111,26 @@ export const createSymbolTable = (): SymbolTable => {
}
fileIndex.get(filePath)!.set(name, def);
// B. Add to Global Index (same object reference)
// B. Properties go to fieldByOwner index only — skip globalIndex to prevent
// namespace pollution for common names like 'id', 'name', 'type'.
// Index ALL properties (even without declaredType) so write-access tracking
// can resolve field ownership for dynamically-typed languages (Ruby, JS).
if (type === 'Property' && metadata?.ownerId) {
fieldByOwner.set(`${metadata.ownerId}\0${name}`, def);
// Still add to fileIndex above (for lookupExact), but skip globalIndex
return;
}
// C. Add to Global Index (same object reference)
if (!globalIndex.has(name)) {
globalIndex.set(name, []);
}
globalIndex.get(name)!.push(def);
// D. Invalidate the lazy callable index only when adding callable types
if (CALLABLE_TYPES.has(type)) {
callableIndex = null;
}
};
const lookupExact = (filePath: string, name: string): string | undefined => {
@@ -97,6 +145,22 @@ 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 lookupFieldByOwner = (ownerNodeId: string, fieldName: string): SymbolDefinition | undefined => {
return fieldByOwner.get(`${ownerNodeId}\0${fieldName}`);
};
const getStats = () => ({
fileCount: fileIndex.size,
globalSymbolCount: globalIndex.size
@@ -105,7 +169,9 @@ export const createSymbolTable = (): SymbolTable => {
const clear = () => {
fileIndex.clear();
globalIndex.clear();
callableIndex = null;
fieldByOwner.clear();
};
return { add, lookupExact, lookupExactFull, lookupFuzzy, getStats, clear };
return { add, lookupExact, lookupExactFull, lookupFuzzy, lookupFuzzyCallable, lookupFieldByOwner, getStats, clear };
};
@@ -62,6 +62,19 @@ export const TYPESCRIPT_QUERIES = `
(new_expression
constructor: (identifier) @call.name) @call
; Class properties — public_field_definition covers most TS class fields
(public_field_definition
name: (property_identifier) @name) @definition.property
; Private class fields: #address: Address
(public_field_definition
name: (private_property_identifier) @name) @definition.property
; Constructor parameter properties: constructor(public address: Address)
(required_parameter
(accessibility_modifier)
pattern: (identifier) @name) @definition.property
; Heritage queries - class extends
(class_declaration
name: (type_identifier) @heritage.class
@@ -75,6 +88,20 @@ export const TYPESCRIPT_QUERIES = `
(class_heritage
(implements_clause
(type_identifier) @heritage.implements))) @heritage.impl
; Write access: obj.field = value
(assignment_expression
left: (member_expression
object: (_) @assignment.receiver
property: (property_identifier) @assignment.property)
right: (_)) @assignment
; Write access: obj.field += value (compound assignment)
(augmented_assignment_expression
left: (member_expression
object: (_) @assignment.receiver
property: (property_identifier) @assignment.property)
right: (_)) @assignment
`;
// JavaScript queries - works with tree-sitter-javascript
@@ -128,12 +155,30 @@ export const JAVASCRIPT_QUERIES = `
(new_expression
constructor: (identifier) @call.name) @call
; Class fields — field_definition captures JS class fields (class User { address = ... })
(field_definition
property: (property_identifier) @name) @definition.property
; Heritage queries - class extends (JavaScript uses different AST than TypeScript)
; In tree-sitter-javascript, class_heritage directly contains the parent identifier
(class_declaration
name: (identifier) @heritage.class
(class_heritage
(identifier) @heritage.extends)) @heritage
; Write access: obj.field = value
(assignment_expression
left: (member_expression
object: (_) @assignment.receiver
property: (property_identifier) @assignment.property)
right: (_)) @assignment
; Write access: obj.field += value (compound assignment)
(augmented_assignment_expression
left: (member_expression
object: (_) @assignment.receiver
property: (property_identifier) @assignment.property)
right: (_)) @assignment
`;
// Python queries - works with tree-sitter-python
@@ -160,11 +205,33 @@ export const PYTHON_QUERIES = `
function: (attribute
attribute: (identifier) @call.name)) @call
; Class attribute type annotations — PEP 526: address: Address or address: Address = Address()
; Both bare annotations (address: Address) and annotated assignments (name: str = "test")
; are parsed as (assignment left: ... type: ...) in tree-sitter-python.
(expression_statement
(assignment
left: (identifier) @name
type: (type)) @definition.property)
; Heritage queries - Python class inheritance
(class_definition
name: (identifier) @heritage.class
superclasses: (argument_list
(identifier) @heritage.extends)) @heritage
; Write access: obj.field = value
(assignment
left: (attribute
object: (_) @assignment.receiver
attribute: (identifier) @assignment.property)
right: (_)) @assignment
; Write access: obj.field += value (compound assignment)
(augmented_assignment
left: (attribute
object: (_) @assignment.receiver
attribute: (identifier) @assignment.property)
right: (_)) @assignment
`;
// Java queries - works with tree-sitter-java
@@ -179,6 +246,11 @@ export const JAVA_QUERIES = `
(method_declaration name: (identifier) @name) @definition.method
(constructor_declaration name: (identifier) @name) @definition.constructor
; Fields — typed field declarations inside class bodies
(field_declaration
declarator: (variable_declarator
name: (identifier) @name)) @definition.property
; Imports - capture any import declaration child as source
(import_declaration (_) @import.source) @import
@@ -196,6 +268,13 @@ export const JAVA_QUERIES = `
; Heritage - implements interfaces
(class_declaration name: (identifier) @heritage.class
(super_interfaces (type_list (type_identifier) @heritage.implements))) @heritage.impl
; Write access: obj.field = value
(assignment_expression
left: (field_access
object: (_) @assignment.receiver
field: (identifier) @assignment.property)
right: (_)) @assignment
`;
// C queries - works with tree-sitter-c
@@ -243,6 +322,11 @@ export const GO_QUERIES = `
(import_declaration (import_spec path: (interpreted_string_literal) @import.source)) @import
(import_declaration (import_spec_list (import_spec path: (interpreted_string_literal) @import.source))) @import
; Struct fields — named field declarations inside struct types
(field_declaration_list
(field_declaration
name: (field_identifier) @name) @definition.property)
; Struct embedding (anonymous fields = inheritance)
(type_declaration
(type_spec
@@ -258,6 +342,14 @@ export const GO_QUERIES = `
; Struct literal construction: User{Name: "Alice"}
(composite_literal type: (type_identifier) @call.name) @call
; Write access: obj.field = value
(assignment_statement
left: (expression_list
(selector_expression
operand: (_) @assignment.receiver
field: (field_identifier) @assignment.property))
right: (_)) @assignment
`;
// C++ queries - works with tree-sitter-cpp
@@ -299,6 +391,21 @@ export const CPP_QUERIES = `
(declaration declarator: (function_declarator declarator: (identifier) @name)) @definition.function
(declaration declarator: (pointer_declarator declarator: (function_declarator declarator: (identifier) @name))) @definition.function
; Class/struct data member fields (Address address; int count;)
; Uses field_identifier to exclude method declarations (which use function_declarator)
(field_declaration
declarator: (field_identifier) @name) @definition.property
; Pointer member fields (Address* address;)
(field_declaration
declarator: (pointer_declarator
declarator: (field_identifier) @name)) @definition.property
; Reference member fields (Address& address;)
(field_declaration
declarator: (reference_declarator
(field_identifier) @name)) @definition.property
; Inline class method declarations (inside class body, no body: void Foo();)
(field_declaration declarator: (function_declarator declarator: (identifier) @name)) @definition.method
@@ -306,7 +413,7 @@ export const CPP_QUERIES = `
(field_declaration_list
(function_definition
declarator: (function_declarator
declarator: [(field_identifier) (identifier) (operator_name) (destructor_name)] @name))) @definition.method
declarator: [(field_identifier) (identifier) (operator_name) (destructor_name)] @name)) @definition.method)
; Templates
(template_declaration (class_specifier name: (type_identifier) @name)) @definition.template
@@ -329,6 +436,14 @@ export const CPP_QUERIES = `
(base_class_clause (type_identifier) @heritage.extends)) @heritage
(class_specifier name: (type_identifier) @heritage.class
(base_class_clause (access_specifier) (type_identifier) @heritage.extends)) @heritage
; Write access: obj.field = value
(assignment_expression
left: (field_expression
argument: (_) @assignment.receiver
field: (field_identifier) @assignment.property)
right: (_)) @assignment
`;
// C# queries - works with tree-sitter-c-sharp
@@ -365,6 +480,13 @@ export const CSHARP_QUERIES = `
(invocation_expression function: (identifier) @call.name) @call
(invocation_expression function: (member_access_expression name: (identifier) @call.name)) @call
; Null-conditional method calls: user?.Save()
; Parses as: invocation_expression → conditional_access_expression → member_binding_expression → identifier
(invocation_expression
function: (conditional_access_expression
(member_binding_expression
(identifier) @call.name))) @call
; Constructor calls: new Foo() and new Foo { Props }
(object_creation_expression type: (identifier) @call.name) @call
@@ -376,6 +498,13 @@ export const CSHARP_QUERIES = `
(base_list (identifier) @heritage.extends)) @heritage
(class_declaration name: (identifier) @heritage.class
(base_list (generic_name (identifier) @heritage.extends))) @heritage
; Write access: obj.field = value
(assignment_expression
left: (member_access_expression
expression: (_) @assignment.receiver
name: (identifier) @assignment.property)
right: (_)) @assignment
`;
// Rust queries - works with tree-sitter-rust
@@ -407,11 +536,30 @@ export const RUST_QUERIES = `
; Struct literal construction: User { name: value }
(struct_expression name: (type_identifier) @call.name) @call
; Struct fields — named field declarations inside struct bodies
(field_declaration_list
(field_declaration
name: (field_identifier) @name) @definition.property)
; Heritage (trait implementation) — all combinations of concrete/generic trait × concrete/generic type
(impl_item trait: (type_identifier) @heritage.trait type: (type_identifier) @heritage.class) @heritage
(impl_item trait: (generic_type type: (type_identifier) @heritage.trait) type: (type_identifier) @heritage.class) @heritage
(impl_item trait: (type_identifier) @heritage.trait type: (generic_type type: (type_identifier) @heritage.class)) @heritage
(impl_item trait: (generic_type type: (type_identifier) @heritage.trait) type: (generic_type type: (type_identifier) @heritage.class)) @heritage
; Write access: obj.field = value
(assignment_expression
left: (field_expression
value: (_) @assignment.receiver
field: (field_identifier) @assignment.property)
right: (_)) @assignment
; Write access: obj.field += value (compound assignment)
(compound_assignment_expr
left: (field_expression
value: (_) @assignment.receiver
field: (field_identifier) @assignment.property)
right: (_)) @assignment
`;
// PHP queries - works with tree-sitter-php (php_only grammar)
@@ -450,6 +598,13 @@ export const PHP_QUERIES = `
(variable_name
(name) @name))) @definition.property
; Constructor property promotion (PHP 8.0+: public Address $address in __construct)
(method_declaration
parameters: (formal_parameters
(property_promotion_parameter
name: (variable_name
(name) @name)))) @definition.property
; ── Imports: use statements ──────────────────────────────────────────────────
; Simple: use App\\Models\\User;
(namespace_use_declaration
@@ -494,6 +649,79 @@ export const PHP_QUERIES = `
body: (declaration_list
(use_declaration
[(name) (qualified_name)] @heritage.trait))) @heritage
; Write access: $obj->field = value
(assignment_expression
left: (member_access_expression
object: (_) @assignment.receiver
name: (name) @assignment.property)
right: (_)) @assignment
; Write access: ClassName::$field = value (static property)
(assignment_expression
left: (scoped_property_access_expression
scope: (_) @assignment.receiver
name: (variable_name (name) @assignment.property))
right: (_)) @assignment
`;
// Ruby queries - works with tree-sitter-ruby
// NOTE: Ruby uses `call` for require, include, extend, prepend, attr_* etc.
// These are all captured as @call and routed in JS post-processing:
// - require/require_relative → import extraction
// - include/extend/prepend → heritage (mixin) extraction
// - attr_accessor/attr_reader/attr_writer → property definition extraction
// - everything else → regular call extraction
export const RUBY_QUERIES = `
; ── Modules ──────────────────────────────────────────────────────────────────
(module
name: (constant) @name) @definition.module
; ── Classes ──────────────────────────────────────────────────────────────────
(class
name: (constant) @name) @definition.class
; ── Instance methods ─────────────────────────────────────────────────────────
(method
name: (identifier) @name) @definition.method
; ── Singleton (class-level) methods ──────────────────────────────────────────
(singleton_method
name: (identifier) @name) @definition.method
; ── All calls (require, include, attr_*, and regular calls routed in JS) ─────
(call
method: (identifier) @call.name) @call
; ── Bare calls without parens (identifiers at statement level are method calls) ─
; NOTE: This may over-capture variable reads as calls (e.g. 'result' at
; statement level). Ruby's grammar makes bare identifiers ambiguous — they
; could be local variables or zero-arity method calls. Post-processing via
; isBuiltInOrNoise and symbol resolution filtering suppresses most false
; positives, but a variable name that coincidentally matches a method name
; elsewhere may produce a false CALLS edge.
(body_statement
(identifier) @call.name @call)
; ── Heritage: class < SuperClass ─────────────────────────────────────────────
(class
name: (constant) @heritage.class
superclass: (superclass
(constant) @heritage.extends)) @heritage
; Write access: obj.field = value (Ruby setter — syntactically a method call to field=)
(assignment
left: (call
receiver: (_) @assignment.receiver
method: (identifier) @assignment.property)
right: (_)) @assignment
; Write access: obj.field += value (compound assignment — operator_assignment node, not assignment)
(operator_assignment
left: (call
receiver: (_) @assignment.receiver
method: (identifier) @assignment.property)
right: (_)) @assignment
`;
// Kotlin queries - works with tree-sitter-kotlin (fwcd/tree-sitter-kotlin)
@@ -530,6 +758,12 @@ export const KOTLIN_QUERIES = `
(variable_declaration
(simple_identifier) @name)) @definition.property
; Primary constructor val/var parameters (data class, value class, regular class)
; binding_pattern_kind contains "val" or "var" — without it, the param is not a property
(class_parameter
(binding_pattern_kind)
(simple_identifier) @name) @definition.property
; ── Enum entries ─────────────────────────────────────────────────────────
(enum_entry
(simple_identifier) @name) @definition.enum
@@ -574,6 +808,15 @@ export const KOTLIN_QUERIES = `
(delegation_specifier
(constructor_invocation
(user_type (type_identifier) @heritage.extends)))) @heritage
; Write access: obj.field = value
(assignment
(directly_assignable_expression
(_) @assignment.receiver
(navigation_suffix
(simple_identifier) @assignment.property))
(_)) @assignment
`;
// Swift queries - works with tree-sitter-swift
@@ -632,6 +875,15 @@ export const SWIFT_QUERIES = `
; Extensions wrap the name in user_type unlike class/struct/enum declarations
(class_declaration "extension" name: (user_type (type_identifier) @heritage.class)
(inheritance_specifier inherits_from: (user_type (type_identifier) @heritage.extends))) @heritage
; Write access: obj.field = value
(assignment
(directly_assignable_expression
(_) @assignment.receiver
(navigation_suffix
(simple_identifier) @assignment.property))
(_)) @assignment
`;
export const LANGUAGE_QUERIES: Record<SupportedLanguages, string> = {
@@ -643,6 +895,7 @@ export const LANGUAGE_QUERIES: Record<SupportedLanguages, string> = {
[SupportedLanguages.Go]: GO_QUERIES,
[SupportedLanguages.CPlusPlus]: CPP_QUERIES,
[SupportedLanguages.CSharp]: CSHARP_QUERIES,
[SupportedLanguages.Ruby]: RUBY_QUERIES,
[SupportedLanguages.Rust]: RUST_QUERIES,
[SupportedLanguages.PHP]: PHP_QUERIES,
[SupportedLanguages.Kotlin]: KOTLIN_QUERIES,
+681 -60
View File
@@ -1,7 +1,10 @@
import type { SyntaxNode } from './utils.js';
import { FUNCTION_NODE_TYPES, extractFunctionName } 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, ReturnTypeLookup, ForLoopExtractorContext, PendingAssignment } from './type-extractors/types.js';
import { extractSimpleTypeName, extractVarName, stripNullable, extractReturnTypeName } from './type-extractors/shared.js';
import type { SymbolTable } from './symbol-table.js';
/**
* Per-file scoped type environment: maps (scope, variableName) → typeName.
@@ -9,7 +12,9 @@ import { typeConfigs, TYPED_PARAMETER_TYPES } from './type-extractors/index.js';
* file-level variables use the '' (empty string) scope.
*
* Design constraints:
* - Explicit-only: only type annotations, never inferred types
* - Explicit-only: Tier 0 uses type annotations; Tier 1 infers from constructors
* - Tier 2: single-pass assignment chain propagation in source order — resolves
* `const b = a` when `a` already has a type from Tier 0/1
* - Scope-aware: function-local variables don't collide across functions
* - Conservative: complex/generic types extract the base name only
* - Per-file: built once, used for receiver resolution, then discarded
@@ -19,30 +24,271 @@ export type TypeEnv = Map<string, Map<string, string>>;
/** File-level scope key */
const FILE_SCOPE = '';
/** Fallback for languages where class names aren't in a 'name' field (e.g. Kotlin uses type_identifier). */
const findTypeIdentifierChild = (node: SyntaxNode): SyntaxNode | null => {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child && child.type === 'type_identifier') return child;
}
return null;
};
/**
* Look up a variable's type in the TypeEnv, trying the call's enclosing
* function scope first, then falling back to file-level scope.
* Per-file type environment with receiver resolution.
* Built once per file via `buildTypeEnv`, used for receiver-type filtering,
* then discarded. Encapsulates scope-aware type lookup and self/this/super
* AST resolution behind a single `.lookup()` method.
*/
export const lookupTypeEnv = (
export interface TypeEnvironment {
/** Look up a variable's resolved type, with self/this/super AST resolution. */
lookup(varName: string, callNode: SyntaxNode): string | undefined;
/** Unverified cross-file constructor bindings for SymbolTable verification. */
readonly constructorBindings: readonly ConstructorBinding[];
/** Raw per-scope type bindings — for testing and debugging. */
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') {
return findEnclosingClassName(callNode);
}
// Super/base/parent receiver: resolve to the parent class name via AST walk.
// Walks up to the enclosing class, then extracts the superclass from its heritage node.
if (varName === 'super' || varName === 'base' || varName === 'parent') {
return findEnclosingParentClassName(callNode);
}
// 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 result;
if (result) return fastStripNullable(result);
}
}
// Fall back to file-level scope
const fileEnv = env.get(FILE_SCOPE);
return fileEnv?.get(varName);
const raw = fileEnv?.get(varName);
return raw ? fastStripNullable(raw) : undefined;
};
/**
* Walk up the AST from a node to find the enclosing class/module name.
* Used to resolve `self`/`this` receivers to their containing type.
*/
const findEnclosingClassName = (node: SyntaxNode): string | undefined => {
let current = node.parent;
while (current) {
if (CLASS_CONTAINER_TYPES.has(current.type)) {
const nameNode = current.childForFieldName('name')
?? findTypeIdentifierChild(current);
if (nameNode) return nameNode.text;
}
current = current.parent;
}
return undefined;
};
/**
* Walk up the AST to find the enclosing class, then extract its parent class name
* from the heritage/superclass AST node. Used to resolve `super`/`base`/`parent`.
*
* Supported patterns per tree-sitter grammar:
* - Java/Ruby: `superclass` field → type_identifier/constant
* - Python: `superclasses` field → argument_list → first identifier
* - TypeScript/JS: unnamed `class_heritage` child → `extends_clause` → identifier
* - C#: unnamed `base_list` child → first identifier
* - PHP: unnamed `base_clause` child → name
* - Kotlin: unnamed `delegation_specifier` child → constructor_invocation → user_type → type_identifier
* - C++: unnamed `base_class_clause` child → type_identifier
* - Swift: unnamed `inheritance_specifier` child → user_type → type_identifier
*/
const findEnclosingParentClassName = (node: SyntaxNode): string | undefined => {
let current = node.parent;
while (current) {
if (CLASS_CONTAINER_TYPES.has(current.type)) {
return extractParentClassFromNode(current);
}
current = current.parent;
}
return undefined;
};
/** Extract the parent/superclass name from a class declaration AST node. */
const extractParentClassFromNode = (classNode: SyntaxNode): string | undefined => {
// 1. Named fields: Java (superclass), Ruby (superclass), Python (superclasses)
const superclassNode = classNode.childForFieldName('superclass');
if (superclassNode) {
// Java: superclass > type_identifier or generic_type, Ruby: superclass > constant
const inner = superclassNode.childForFieldName('type')
?? superclassNode.firstNamedChild
?? superclassNode;
return extractSimpleTypeName(inner) ?? inner.text;
}
const superclassesNode = classNode.childForFieldName('superclasses');
if (superclassesNode) {
// Python: argument_list with identifiers or attribute nodes (e.g. models.Model)
const first = superclassesNode.firstNamedChild;
if (first) return extractSimpleTypeName(first) ?? first.text;
}
// 2. Unnamed children: walk class node's children looking for heritage nodes
for (let i = 0; i < classNode.childCount; i++) {
const child = classNode.child(i);
if (!child) continue;
switch (child.type) {
// TypeScript: class_heritage > extends_clause > type_identifier
// JavaScript: class_heritage > identifier (no extends_clause wrapper)
case 'class_heritage': {
for (let j = 0; j < child.childCount; j++) {
const clause = child.child(j);
if (clause?.type === 'extends_clause') {
const typeNode = clause.firstNamedChild;
if (typeNode) return extractSimpleTypeName(typeNode) ?? typeNode.text;
}
// JS: direct identifier child (no extends_clause wrapper)
if (clause?.type === 'identifier' || clause?.type === 'type_identifier') {
return clause.text;
}
}
break;
}
// C#: base_list > identifier or generic_name > identifier
case 'base_list': {
const first = child.firstNamedChild;
if (first) {
// generic_name wraps the identifier: BaseClass<T>
if (first.type === 'generic_name') {
const inner = first.childForFieldName('name') ?? first.firstNamedChild;
if (inner) return inner.text;
}
return first.text;
}
break;
}
// PHP: base_clause > name
case 'base_clause': {
const name = child.firstNamedChild;
if (name) return name.text;
break;
}
// C++: base_class_clause > type_identifier (with optional access_specifier before it)
case 'base_class_clause': {
for (let j = 0; j < child.childCount; j++) {
const inner = child.child(j);
if (inner?.type === 'type_identifier') return inner.text;
}
break;
}
// Kotlin: delegation_specifier > constructor_invocation > user_type > type_identifier
case 'delegation_specifier': {
const delegate = child.firstNamedChild;
if (delegate?.type === 'constructor_invocation') {
const userType = delegate.firstNamedChild;
if (userType?.type === 'user_type') {
const typeId = userType.firstNamedChild;
if (typeId) return typeId.text;
}
}
// Also handle plain user_type (interface conformance without parentheses)
if (delegate?.type === 'user_type') {
const typeId = delegate.firstNamedChild;
if (typeId) return typeId.text;
}
break;
}
// Swift: inheritance_specifier > user_type > type_identifier
case 'inheritance_specifier': {
const userType = child.childForFieldName('inherits_from') ?? child.firstNamedChild;
if (userType?.type === 'user_type') {
const typeId = userType.firstNamedChild;
if (typeId) return typeId.text;
}
break;
}
}
}
return undefined;
};
/** Find the enclosing function name for scope lookup. */
@@ -59,66 +305,441 @@ const findEnclosingScopeKey = (node: SyntaxNode): string | undefined => {
};
/**
* Build a scoped TypeEnv from a tree-sitter AST for a given language.
* Walks the tree tracking enclosing function scopes, so that variables
* inside different functions don't collide.
* Create a lookup that checks both local AST class names AND the SymbolTable's
* global index. This allows extractInitializer functions to distinguish
* constructor calls from function calls (e.g. Kotlin `User()` vs `getUser()`)
* using cross-file type information when available.
*
* Only `.has()` is exposed — the SymbolTable doesn't support iteration.
* Results are memoized to avoid redundant lookupFuzzy scans across declarations.
*/
export const buildTypeEnv = (
tree: { rootNode: SyntaxNode },
language: SupportedLanguages,
): TypeEnv => {
const env: TypeEnv = new Map();
walkForTypes(tree.rootNode, language, env, FILE_SCOPE);
return env;
};
const createClassNameLookup = (
localNames: Set<string>,
symbolTable?: SymbolTable,
): ClassNameLookup => {
if (!symbolTable) return localNames;
const walkForTypes = (
node: SyntaxNode,
language: SupportedLanguages,
env: TypeEnv,
currentScope: string,
): void => {
// Detect scope boundaries (function/method definitions)
let scope = currentScope;
if (FUNCTION_NODE_TYPES.has(node.type)) {
const { funcName } = extractFunctionName(node);
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)!;
// Check if this node provides type information
extractTypeBinding(node, language, scopeEnv);
// Recurse into children
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child) walkForTypes(child, language, env, scope);
}
const memo = new Map<string, boolean>();
return {
has(name: string): boolean {
if (localNames.has(name)) return true;
const cached = memo.get(name);
if (cached !== undefined) return cached;
const result = symbolTable.lookupFuzzy(name).some(def =>
def.type === 'Class' || def.type === 'Enum' || def.type === 'Struct',
);
memo.set(name, result);
return result;
},
};
};
/**
* Try to extract a (variableName → typeName) binding from a single AST node.
* Delegates to per-language type configurations.
* Build a TypeEnvironment from a tree-sitter AST for a given language.
* Single-pass: collects class/struct names, type bindings, AND constructor
* bindings that couldn't be resolved locally — all in one AST walk.
*
* When a symbolTable is provided (call-processor path), class names from across
* the project are available for constructor inference in languages like Kotlin
* where constructors are syntactically identical to function calls.
*/
const extractTypeBinding = (
node: SyntaxNode,
/**
* 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',
]);
const CLASS_LIKE_TYPES = new Set(['Class', 'Struct', 'Interface']);
/** Resolve a field's declared type given a receiver variable and field name.
* Uses SymbolTable to find the class nodeId for the receiver's type, then
* looks up the field via the eagerly-populated fieldByOwner index. */
const resolveFieldType = (
receiver: string, field: string,
scopeEnv: ReadonlyMap<string, string>, symbolTable?: SymbolTable,
): string | undefined => {
if (!symbolTable) return undefined;
const receiverType = scopeEnv.get(receiver);
if (!receiverType) return undefined;
const classDefs = symbolTable.lookupFuzzy(receiverType)
.filter(d => CLASS_LIKE_TYPES.has(d.type));
if (classDefs.length !== 1) return undefined;
const fieldDef = symbolTable.lookupFieldByOwner(classDefs[0].nodeId, field);
if (!fieldDef?.declaredType) return undefined;
return extractReturnTypeName(fieldDef.declaredType);
};
/** Resolve a method's return type given a receiver variable and method name.
* Uses SymbolTable to find class nodeIds for the receiver's type, then
* looks up the method via lookupFuzzyCallable filtered by ownerId. */
const resolveMethodReturnType = (
receiver: string, method: string,
scopeEnv: ReadonlyMap<string, string>, symbolTable?: SymbolTable,
): string | undefined => {
if (!symbolTable) return undefined;
const receiverType = scopeEnv.get(receiver);
if (!receiverType) return undefined;
const classDefs = symbolTable.lookupFuzzy(receiverType)
.filter(d => CLASS_LIKE_TYPES.has(d.type));
if (classDefs.length === 0) return undefined;
const classNodeIds = new Set(classDefs.map(d => d.nodeId));
const methods = symbolTable.lookupFuzzyCallable(method)
.filter(d => d.ownerId && classNodeIds.has(d.ownerId));
if (methods.length !== 1) return undefined;
if (!methods[0].returnType) return undefined;
return extractReturnTypeName(methods[0].returnType);
};
export const buildTypeEnv = (
tree: { rootNode: SyntaxNode },
language: SupportedLanguages,
env: Map<string, string>,
): void => {
// === PARAMETERS (most languages) ===
// This guard eliminates 90%+ of calls before any language dispatch.
if (TYPED_PARAMETER_TYPES.has(node.type)) {
const config = typeConfigs[language];
config.extractParameter(node, env);
return;
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[] = [];
// 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: unified fixpoint propagation — collects copy, callResult, fieldAccess, and
// methodCallResult items during walk(), then iterates until no new bindings are produced.
// Handles arbitrary-depth mixed chains: callResult → fieldAccess → methodCallResult → copy.
const pendingItems: Array<{ scope: string } & PendingAssignment> = [];
// 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>();
/**
* Try to extract a (variableName → typeName) binding from a single AST node.
*
* Resolution tiers (first match wins):
* - Tier 0: explicit type annotations via extractDeclaration / extractForLoopBinding
* - Tier 1: constructor-call inference via extractInitializer (fallback)
*
* Side effect: populates declarationTypeNodes for variables that have an explicit
* type annotation field on the declaration node. This allows pattern extractors to
* retrieve generic type arguments from the original declaration (e.g., extracting T
* from Result<T, E> for `if let Ok(x) = res`).
*/
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)) {
// 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) {
const nameNode = node.childForFieldName('name')
?? node.childForFieldName('pattern')
// Python typed_parameter: name is a positional child (identifier), not a named field
?? (node.firstNamedChild?.type === 'identifier' ? node.firstNamedChild : null);
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)) {
if (config.extractForLoopBinding) {
const forLoopCtx: ForLoopExtractorContext = { scopeEnv, declarationTypeNodes, scope, returnTypeLookup };
config.extractForLoopBinding(node, forLoopCtx);
}
return;
}
if (config.declarationNodeTypes.has(node.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) && !declarationTypeNodes.has(`${scope}\0${varName}`)) {
declarationTypeNodes.set(`${scope}\0${varName}`, typeNode);
}
}
}
// Tier 1: constructor-call inference as fallback.
// Always called when available — each language's extractInitializer
// internally skips declarators that already have explicit annotations,
// so this handles mixed cases like `const a: A = x, b = new B()`.
if (config.extractInitializer) {
config.extractInitializer(node, scopeEnv, classNames);
}
}
};
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.
if (CLASS_CONTAINER_TYPES.has(node.type)) {
// Most languages use 'name' field; Kotlin uses a type_identifier child instead
const nameNode = node.childForFieldName('name')
?? findTypeIdentifierChild(node);
if (nameNode) localClassNames.add(nameNode.text);
}
// Detect scope boundaries (function/method definitions)
let scope = currentScope;
if (FUNCTION_NODE_TYPES.has(node.type)) {
const { funcName } = extractFunctionName(node);
if (funcName) scope = `${funcName}@${node.startIndex}`;
}
// 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 && (!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) {
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);
}
}
}
// Tier 2: collect plain-identifier RHS assignments for post-walk propagation.
// Delegates to per-language extractPendingAssignment — AST shapes differ widely
// (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)) {
// 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) {
pendingItems.push({ scope, ...pending });
}
}
}
// Scan for constructor bindings that couldn't be resolved locally.
// Only collect if TypeEnv didn't already resolve this binding.
if (config.scanConstructorBinding) {
const result = config.scanConstructorBinding(node);
if (result) {
const scopeEnv = env.get(scope);
if (!scopeEnv?.has(result.varName)) {
bindings.push({ scope, ...result });
}
}
}
// Recurse into children
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child) walk(child, scope);
}
};
walk(tree.rootNode, FILE_SCOPE);
// Unified fixpoint propagation: iterate over ALL pending items (copy, callResult,
// fieldAccess, methodCallResult) until no new bindings are produced.
// Handles arbitrary-depth mixed chains:
// const user = getUser(); // callResult → User
// const addr = user.address; // fieldAccess → Address (depends on user)
// const city = addr.getCity(); // methodCallResult → City (depends on addr)
// const alias = city; // copy → City (depends on city)
// Data flow: SymbolTable (immutable) + scopeEnv → resolve → scopeEnv.
// Termination: finite entries, each bound at most once (first-writer-wins), max 10 iterations.
const MAX_FIXPOINT_ITERATIONS = 10;
const resolved = new Set<number>();
for (let iter = 0; iter < MAX_FIXPOINT_ITERATIONS; iter++) {
let changed = false;
for (let i = 0; i < pendingItems.length; i++) {
if (resolved.has(i)) continue;
const item = pendingItems[i];
const scopeEnv = env.get(item.scope);
if (!scopeEnv || scopeEnv.has(item.lhs)) { resolved.add(i); continue; }
let typeName: string | undefined;
switch (item.kind) {
case 'callResult':
typeName = returnTypeLookup.lookupReturnType(item.callee);
break;
case 'copy':
typeName = scopeEnv.get(item.rhs) ?? env.get(FILE_SCOPE)?.get(item.rhs);
break;
case 'fieldAccess':
typeName = resolveFieldType(item.receiver, item.field, scopeEnv, symbolTable);
break;
case 'methodCallResult':
typeName = resolveMethodReturnType(item.receiver, item.method, scopeEnv, symbolTable);
break;
}
if (typeName) {
scopeEnv.set(item.lhs, typeName);
resolved.add(i);
changed = true;
}
}
if (!changed) break;
}
// === Per-language declaration extraction ===
const config = typeConfigs[language];
if (config.declarationNodeTypes.has(node.type)) {
config.extractDeclaration(node, env);
}
return {
lookup: (varName, callNode) => lookupInEnv(env, varName, callNode, patternOverrides),
constructorBindings: bindings,
env,
};
};
/**
* Unverified constructor binding: a `val x = Callee()` pattern where we
* couldn't confirm the callee is a class (because it's defined in another file).
* The caller must verify `calleeName` against the SymbolTable before trusting.
*/
export interface ConstructorBinding {
/** Function scope key (matches TypeEnv scope keys) */
scope: string;
/** Variable name that received the constructor result */
varName: string;
/** Name of the callee (potential class constructor) */
calleeName: string;
/** Enclosing class name when callee is a method on a known receiver (e.g. $this) */
receiverClassName?: string;
}
@@ -1,6 +1,6 @@
import type { SyntaxNode } from '../utils.js';
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor } 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',
@@ -32,6 +32,75 @@ const extractDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: Map<str
if (varName) env.set(varName, typeName);
};
/** C++: auto x = new User(); auto x = User(); */
const extractInitializer: InitializerExtractor = (node: SyntaxNode, env: Map<string, string>, classNames: ClassNameLookup): void => {
const typeNode = node.childForFieldName('type');
if (!typeNode) return;
// Only handle auto/placeholder — typed declarations are handled by extractDeclaration
const typeText = typeNode.text;
if (
typeText !== 'auto' &&
typeText !== 'decltype(auto)' &&
typeNode.type !== 'placeholder_type_specifier'
) return;
const declarator = node.childForFieldName('declarator');
if (!declarator) return;
// Must be an init_declarator (i.e., has an initializer value)
if (declarator.type !== 'init_declarator') return;
const value = declarator.childForFieldName('value');
if (!value) return;
// Resolve the variable name, unwrapping pointer/reference declarators
const nameNode = declarator.childForFieldName('declarator');
if (!nameNode) return;
const finalName =
nameNode.type === 'pointer_declarator' || nameNode.type === 'reference_declarator'
? nameNode.firstNamedChild
: nameNode;
if (!finalName) return;
const varName = extractVarName(finalName);
if (!varName) return;
// auto x = new User() — new_expression
if (value.type === 'new_expression') {
const ctorType = value.childForFieldName('type');
if (ctorType) {
const typeName = extractSimpleTypeName(ctorType);
if (typeName) env.set(varName, typeName);
}
return;
}
// auto x = User() — call_expression where function is a type name
// tree-sitter-cpp may parse the constructor name as type_identifier or identifier.
// For plain identifiers, verify against known class names from the file's AST
// to distinguish constructor calls (User()) from function calls (getUser()).
if (value.type === 'call_expression') {
const func = value.childForFieldName('function');
if (!func) return;
if (func.type === 'type_identifier') {
const typeName = func.text;
if (typeName) env.set(varName, typeName);
} else if (func.type === 'identifier') {
const text = func.text;
if (text && classNames.has(text)) env.set(varName, text);
}
return;
}
// auto x = User{} — compound_literal_expression (brace initialization)
// AST: compound_literal_expression > type_identifier + initializer_list
if (value.type === 'compound_literal_expression') {
const typeId = value.firstNamedChild;
const typeName = typeId ? extractSimpleTypeName(typeId) : undefined;
if (typeName) env.set(varName, typeName);
}
};
/** C/C++: parameter_declaration → type declarator */
const extractParameter: ParameterExtractor = (node: SyntaxNode, env: Map<string, string>): void => {
let nameNode: SyntaxNode | null = null;
@@ -56,8 +125,259 @@ const extractParameter: ParameterExtractor = (node: SyntaxNode, env: Map<string,
if (varName && typeName) env.set(varName, typeName);
};
/** C/C++: auto x = User() where function is an identifier (not type_identifier) */
const scanConstructorBinding: ConstructorBindingScanner = (node) => {
if (node.type !== 'declaration') return undefined;
const typeNode = node.childForFieldName('type');
if (!typeNode) return undefined;
const typeText = typeNode.text;
if (typeText !== 'auto' && typeText !== 'decltype(auto)' && typeNode.type !== 'placeholder_type_specifier') return undefined;
const declarator = node.childForFieldName('declarator');
if (!declarator || declarator.type !== 'init_declarator') return undefined;
const value = declarator.childForFieldName('value');
if (!value || value.type !== 'call_expression') return undefined;
const func = value.childForFieldName('function');
if (!func) return undefined;
if (func.type === 'qualified_identifier' || func.type === 'scoped_identifier') {
const last = func.lastNamedChild;
if (!last) return undefined;
const nameNode = declarator.childForFieldName('declarator');
if (!nameNode) return undefined;
const finalName = nameNode.type === 'pointer_declarator' || nameNode.type === 'reference_declarator'
? nameNode.firstNamedChild : nameNode;
if (!finalName) return undefined;
return { varName: finalName.text, calleeName: last.text };
}
if (func.type !== 'identifier') return undefined;
const nameNode = declarator.childForFieldName('declarator');
if (!nameNode) return undefined;
const finalName = nameNode.type === 'pointer_declarator' || nameNode.type === 'reference_declarator'
? nameNode.firstNamedChild : nameNode;
if (!finalName) return undefined;
const varName = finalName.text;
if (!varName) return undefined;
return { varName, calleeName: func.text };
};
/** C++: auto alias = user → declaration with auto type + init_declarator where value is identifier */
const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) => {
if (node.type !== 'declaration') return undefined;
const typeNode = node.childForFieldName('type');
if (!typeNode) return undefined;
// Only handle auto — typed declarations already resolved by extractDeclaration
const typeText = typeNode.text;
if (typeText !== 'auto' && typeText !== 'decltype(auto)'
&& typeNode.type !== 'placeholder_type_specifier') return undefined;
const declarator = node.childForFieldName('declarator');
if (!declarator || declarator.type !== 'init_declarator') return undefined;
const value = declarator.childForFieldName('value');
if (!value) return undefined;
const nameNode = declarator.childForFieldName('declarator');
if (!nameNode) return undefined;
const finalName = nameNode.type === 'pointer_declarator' || nameNode.type === 'reference_declarator'
? nameNode.firstNamedChild : nameNode;
if (!finalName) return undefined;
const lhs = extractVarName(finalName);
if (!lhs || scopeEnv.has(lhs)) return undefined;
if (value.type === 'identifier') return { kind: 'copy', lhs, rhs: value.text };
// field_expression RHS → fieldAccess (a.field)
if (value.type === 'field_expression') {
const obj = value.firstNamedChild;
const field = value.lastNamedChild;
if (obj?.type === 'identifier' && field?.type === 'field_identifier') {
return { kind: 'fieldAccess', lhs, receiver: obj.text, field: field.text };
}
}
// call_expression RHS
if (value.type === 'call_expression') {
const funcNode = value.childForFieldName('function');
if (funcNode?.type === 'identifier') {
return { kind: 'callResult', lhs, callee: funcNode.text };
}
// method call with receiver: call_expression → function: field_expression
if (funcNode?.type === 'field_expression') {
const obj = funcNode.firstNamedChild;
const field = funcNode.lastNamedChild;
if (obj?.type === 'identifier' && field?.type === 'field_identifier') {
return { kind: 'methodCallResult', lhs, receiver: obj.text, method: field.text };
}
}
}
return undefined;
};
// --- 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,6 +1,9 @@
import type { SyntaxNode } from '../utils.js';
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor } from './types.js';
import { extractSimpleTypeName, extractVarName, findChildByType } 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',
@@ -44,7 +47,8 @@ const extractDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: Map<str
let typeName: string | undefined;
if (typeNode.type === 'implicit_type' && typeNode.text === 'var') {
// Try to infer from initializer: var x = new Foo()
// C# tree-sitter puts object_creation_expression as direct child of variable_declarator
// tree-sitter-c-sharp may put object_creation_expression as direct child
// or inside equals_value_clause depending on grammar version
if (declarators.length === 1) {
const initializer = findChildByType(declarators[0], 'object_creation_expression')
?? findChildByType(declarators[0], 'equals_value_clause')?.firstNamedChild;
@@ -86,8 +90,295 @@ const extractParameter: ParameterExtractor = (node: SyntaxNode, env: Map<string,
if (varName && typeName) env.set(varName, typeName);
};
/** C#: var x = SomeFactory(...) → bind x to SomeFactory (constructor-like call) */
const scanConstructorBinding: ConstructorBindingScanner = (node) => {
if (node.type !== 'variable_declaration') return undefined;
// Find type and declarator children by iterating (C# grammar doesn't expose 'type' as a named field)
let typeNode: SyntaxNode | null = null;
let declarator: SyntaxNode | null = null;
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (!child) continue;
if (child.type === 'variable_declarator') { if (!declarator) declarator = child; }
else if (!typeNode) { typeNode = child; }
}
// Only handle implicit_type (var) — explicit types handled by extractDeclaration
if (!typeNode || typeNode.type !== 'implicit_type') return undefined;
if (!declarator) return undefined;
const nameNode = declarator.childForFieldName('name') ?? declarator.firstNamedChild;
if (!nameNode || nameNode.type !== 'identifier') return undefined;
// Find the initializer value: either inside equals_value_clause or as a direct child
// (tree-sitter-c-sharp puts invocation_expression directly inside variable_declarator)
let value: SyntaxNode | null = null;
for (let i = 0; i < declarator.namedChildCount; i++) {
const child = declarator.namedChild(i);
if (!child) continue;
if (child.type === 'equals_value_clause') { value = child.firstNamedChild; break; }
if (child.type === 'invocation_expression' || child.type === 'object_creation_expression' || child.type === 'await_expression') { value = child; break; }
}
if (!value) return undefined;
// Unwrap await: `var user = await svc.GetUserAsync()` → await_expression wraps invocation_expression
value = unwrapAwait(value);
if (!value) return undefined;
// Skip object_creation_expression (new User()) — handled by extractInitializer
if (value.type === 'object_creation_expression') return undefined;
if (value.type !== 'invocation_expression') return undefined;
const func = value.firstNamedChild;
if (!func) return undefined;
const calleeName = extractSimpleTypeName(func);
if (!calleeName) return undefined;
return { varName: nameNode.text, calleeName };
};
const FOR_LOOP_NODE_TYPES: ReadonlySet<string> = new Set([
'foreach_statement',
]);
/** 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');
const nameNode = node.childForFieldName('left');
if (!typeNode || !nameNode) return;
const varName = extractVarName(nameNode);
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.
* Only local_declaration_statement and variable_declaration contain variable_declarator children;
* is_pattern_expression and field_declaration never do — skip them early. */
const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) => {
if (node.type === 'is_pattern_expression' || node.type === 'field_declaration') return undefined;
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (!child || child.type !== 'variable_declarator') continue;
const nameNode = child.childForFieldName('name');
if (!nameNode) continue;
const lhs = nameNode.text;
if (scopeEnv.has(lhs)) continue;
// C# wraps value in equals_value_clause; fall back to last named child
let evc: SyntaxNode | null = null;
for (let j = 0; j < child.childCount; j++) {
if (child.child(j)?.type === 'equals_value_clause') { evc = child.child(j); break; }
}
const valueNode = evc?.firstNamedChild ?? child.namedChild(child.namedChildCount - 1);
if (valueNode && valueNode !== nameNode && (valueNode.type === 'identifier' || valueNode.type === 'simple_identifier')) {
return { kind: 'copy', lhs, rhs: valueNode.text };
}
// member_access_expression RHS → fieldAccess (a.Field)
if (valueNode?.type === 'member_access_expression') {
const expr = valueNode.childForFieldName('expression');
const name = valueNode.childForFieldName('name');
if (expr?.type === 'identifier' && name?.type === 'identifier') {
return { kind: 'fieldAccess', lhs, receiver: expr.text, field: name.text };
}
}
// invocation_expression RHS
if (valueNode?.type === 'invocation_expression') {
const funcNode = valueNode.firstNamedChild;
if (funcNode?.type === 'identifier_name' || funcNode?.type === 'identifier') {
return { kind: 'callResult', lhs, callee: funcNode.text };
}
// method call with receiver → methodCallResult: a.GetC()
if (funcNode?.type === 'member_access_expression') {
const expr = funcNode.childForFieldName('expression');
const name = funcNode.childForFieldName('name');
if (expr?.type === 'identifier' && name?.type === 'identifier') {
return { kind: 'methodCallResult', lhs, receiver: expr.text, method: name.text };
}
}
}
// await_expression → unwrap and check inner
if (valueNode?.type === 'await_expression') {
const inner = valueNode.firstNamedChild;
if (inner?.type === 'invocation_expression') {
const funcNode = inner.firstNamedChild;
if (funcNode?.type === 'identifier_name' || funcNode?.type === 'identifier') {
return { kind: 'callResult', lhs, callee: funcNode.text };
}
if (funcNode?.type === 'member_access_expression') {
const expr = funcNode.childForFieldName('expression');
const name = funcNode.childForFieldName('name');
if (expr?.type === 'identifier' && name?.type === 'identifier') {
return { kind: 'methodCallResult', lhs, receiver: expr.text, method: name.text };
}
}
}
}
}
return undefined;
};
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 { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor } 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',
@@ -59,7 +59,51 @@ const extractGoShortVarDeclaration = (node: SyntaxNode, env: Map<string, string>
// Pair each LHS name with its corresponding RHS value
const count = Math.min(lhsNodes.length, rhsNodes.length);
for (let i = 0; i < count; i++) {
const valueNode = rhsNodes[i];
let valueNode = rhsNodes[i];
// Unwrap &User{} — unary_expression (address-of) wrapping composite_literal
if (valueNode.type === 'unary_expression' && valueNode.firstNamedChild?.type === 'composite_literal') {
valueNode = valueNode.firstNamedChild;
}
// Go built-in new(User) — call_expression with 'new' callee and type argument
// Go built-in make([]User, 0) / make(map[string]User) — extract element/value type
if (valueNode.type === 'call_expression') {
const funcNode = valueNode.childForFieldName('function');
if (funcNode?.text === 'new') {
const args = valueNode.childForFieldName('arguments');
if (args?.firstNamedChild) {
const typeName = extractSimpleTypeName(args.firstNamedChild);
const varName = extractVarName(lhsNodes[i]);
if (varName && typeName) env.set(varName, typeName);
}
} else if (funcNode?.text === 'make') {
const args = valueNode.childForFieldName('arguments');
const firstArg = args?.firstNamedChild;
if (firstArg) {
let innerType: SyntaxNode | null = null;
if (firstArg.type === 'slice_type') {
innerType = firstArg.childForFieldName('element');
} else if (firstArg.type === 'map_type') {
innerType = firstArg.childForFieldName('value');
}
if (innerType) {
const typeName = extractSimpleTypeName(innerType);
const varName = extractVarName(lhsNodes[i]);
if (varName && typeName) env.set(varName, typeName);
}
}
}
continue;
}
// Go type assertion: user := iface.(User) — type_assertion_expression with 'type' field
if (valueNode.type === 'type_assertion_expression') {
const typeNode = valueNode.childForFieldName('type');
if (typeNode) {
const typeName = extractSimpleTypeName(typeNode);
const varName = extractVarName(lhsNodes[i]);
if (varName && typeName) env.set(varName, typeName);
}
continue;
}
if (valueNode.type !== 'composite_literal') continue;
const typeNode = valueNode.childForFieldName('type');
if (!typeNode) continue;
@@ -97,8 +141,343 @@ const extractParameter: ParameterExtractor = (node: SyntaxNode, env: Map<string,
if (varName && typeName) env.set(varName, typeName);
};
/** Go: user := NewUser(...) — infer type from single-assignment call expression */
const scanConstructorBinding: ConstructorBindingScanner = (node) => {
if (node.type !== 'short_var_declaration') return undefined;
const left = node.childForFieldName('left');
const right = node.childForFieldName('right');
if (!left || !right) return undefined;
const leftIds = left.type === 'expression_list' ? left.namedChildren : [left];
const rightExprs = right.type === 'expression_list' ? right.namedChildren : [right];
// Multi-return: user, err := NewUser() — bind first var when second is err/ok/_
if (leftIds.length === 2 && rightExprs.length === 1) {
const secondVar = leftIds[1];
const isErrorOrDiscard =
secondVar.text === '_' ||
secondVar.text === 'err' ||
secondVar.text === 'ok' ||
secondVar.text === 'error';
if (isErrorOrDiscard && leftIds[0].type === 'identifier') {
if (rightExprs[0].type !== 'call_expression') return undefined;
const func = rightExprs[0].childForFieldName('function');
if (!func) return undefined;
if (func.text === 'new' || func.text === 'make') return undefined;
const calleeName = extractSimpleTypeName(func);
if (!calleeName) return undefined;
return { varName: leftIds[0].text, calleeName };
}
}
// Single assignment only
if (leftIds.length !== 1 || leftIds[0].type !== 'identifier') return undefined;
if (rightExprs.length !== 1 || rightExprs[0].type !== 'call_expression') return undefined;
const func = rightExprs[0].childForFieldName('function');
if (!func) return undefined;
// Skip new() and make() — already handled by extractDeclaration
if (func.text === 'new' || func.text === 'make') return undefined;
const calleeName = extractSimpleTypeName(func);
if (!calleeName) return undefined;
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') {
const left = node.childForFieldName('left');
const right = node.childForFieldName('right');
if (!left || !right) return undefined;
const lhsNode = left.type === 'expression_list' ? left.firstNamedChild : left;
const rhsNode = right.type === 'expression_list' ? right.firstNamedChild : right;
if (!lhsNode || !rhsNode) return undefined;
if (lhsNode.type !== 'identifier') return undefined;
const lhs = lhsNode.text;
if (scopeEnv.has(lhs)) return undefined;
if (rhsNode.type === 'identifier') return { kind: 'copy', lhs, rhs: rhsNode.text };
// selector_expression RHS → fieldAccess (a.field)
if (rhsNode.type === 'selector_expression') {
const operand = rhsNode.childForFieldName('operand');
const field = rhsNode.childForFieldName('field');
if (operand?.type === 'identifier' && field) {
return { kind: 'fieldAccess', lhs, receiver: operand.text, field: field.text };
}
}
// call_expression RHS
if (rhsNode.type === 'call_expression') {
const funcNode = rhsNode.childForFieldName('function');
if (funcNode?.type === 'identifier') {
return { kind: 'callResult', lhs, callee: funcNode.text };
}
// method call with receiver: call_expression → function: selector_expression
if (funcNode?.type === 'selector_expression') {
const operand = funcNode.childForFieldName('operand');
const field = funcNode.childForFieldName('field');
if (operand?.type === 'identifier' && field) {
return { kind: 'methodCallResult', lhs, receiver: operand.text, method: field.text };
}
}
}
return undefined;
}
if (node.type === 'var_spec' || node.type === 'var_declaration') {
// var_declaration contains var_spec children; var_spec has name + expression_list value
const specs: SyntaxNode[] = [];
if (node.type === 'var_declaration') {
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c?.type === 'var_spec') specs.push(c);
}
} else {
specs.push(node);
}
for (const spec of specs) {
const nameNode = spec.childForFieldName('name');
if (!nameNode || nameNode.type !== 'identifier') continue;
const lhs = nameNode.text;
if (scopeEnv.has(lhs)) continue;
// Check if the last named child is a bare identifier (no type annotation between name and value)
let exprList: SyntaxNode | null = null;
for (let i = 0; i < spec.childCount; i++) {
if (spec.child(i)?.type === 'expression_list') { exprList = spec.child(i); break; }
}
const rhsNode = exprList?.firstNamedChild;
if (rhsNode?.type === 'identifier') return { kind: 'copy', lhs, rhs: rhsNode.text };
// selector_expression RHS → fieldAccess
if (rhsNode?.type === 'selector_expression') {
const operand = rhsNode.childForFieldName('operand');
const field = rhsNode.childForFieldName('field');
if (operand?.type === 'identifier' && field) {
return { kind: 'fieldAccess', lhs, receiver: operand.text, field: field.text };
}
}
// call_expression RHS
if (rhsNode?.type === 'call_expression') {
const funcNode = rhsNode.childForFieldName('function');
if (funcNode?.type === 'identifier') {
return { kind: 'callResult', lhs, callee: funcNode.text };
}
if (funcNode?.type === 'selector_expression') {
const operand = funcNode.childForFieldName('operand');
const field = funcNode.childForFieldName('field');
if (operand?.type === 'identifier' && field) {
return { kind: 'methodCallResult', lhs, receiver: operand.text, method: field.text };
}
}
}
}
}
return undefined;
};
export const typeConfig: LanguageTypeConfig = {
declarationNodeTypes: DECLARATION_NODE_TYPES,
forLoopNodeTypes: FOR_LOOP_NODE_TYPES,
extractDeclaration,
extractParameter,
scanConstructorBinding,
extractForLoopBinding,
extractPendingAssignment,
};
@@ -15,6 +15,7 @@ import { typeConfig as pythonConfig } from './python.js';
import { typeConfig as swiftConfig } from './swift.js';
import { typeConfig as cCppConfig } from './c-cpp.js';
import { typeConfig as phpConfig } from './php.js';
import { typeConfig as rubyConfig } from './ruby.js';
export const typeConfigs = {
[SupportedLanguages.JavaScript]: typescriptConfig,
@@ -29,7 +30,23 @@ export const typeConfigs = {
[SupportedLanguages.C]: cCppConfig,
[SupportedLanguages.CPlusPlus]: cCppConfig,
[SupportedLanguages.PHP]: phpConfig,
[SupportedLanguages.Ruby]: rubyConfig,
} satisfies Record<SupportedLanguages, LanguageTypeConfig>;
export type { LanguageTypeConfig, TypeBindingExtractor, ParameterExtractor } from './types.js';
export { TYPED_PARAMETER_TYPES, extractSimpleTypeName, extractVarName, findChildByType } from './shared.js';
export type {
LanguageTypeConfig,
TypeBindingExtractor,
ParameterExtractor,
ConstructorBindingScanner,
ForLoopExtractor,
PendingAssignmentExtractor,
PatternBindingExtractor,
} from './types.js';
export {
TYPED_PARAMETER_TYPES,
extractSimpleTypeName,
extractGenericTypeArgs,
extractVarName,
findChildByType,
extractRubyConstructorAssignment
} from './shared.js';
@@ -1,6 +1,6 @@
import type { SyntaxNode } from '../utils.js';
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor } from './types.js';
import { extractSimpleTypeName, extractVarName, findChildByType } from './shared.js';
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor, InitializerExtractor, ClassNameLookup, ConstructorBindingScanner, ForLoopExtractor, PendingAssignmentExtractor, PatternBindingExtractor } from './types.js';
import { extractSimpleTypeName, extractVarName, findChildByType, extractGenericTypeArgs, resolveIterableElementType, methodToTypeArgPosition, extractElementTypeFromString, type TypeArgPosition } from './shared.js';
// ── Java ──────────────────────────────────────────────────────────────────
@@ -14,7 +14,7 @@ const extractJavaDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: Map
const typeNode = node.childForFieldName('type');
if (!typeNode) return;
const typeName = extractSimpleTypeName(typeNode);
if (!typeName) return;
if (!typeName || typeName === 'var') return; // skip Java 10 var — handled by extractInitializer
// Find variable_declarator children
for (let i = 0; i < node.namedChildCount; i++) {
@@ -28,6 +28,25 @@ const extractJavaDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: Map
}
};
/** Java 10+: var x = new User() — infer type from object_creation_expression */
const extractJavaInitializer: InitializerExtractor = (node: SyntaxNode, env: Map<string, string>, _classNames: ClassNameLookup): void => {
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child?.type !== 'variable_declarator') continue;
const nameNode = child.childForFieldName('name');
const valueNode = child.childForFieldName('value');
if (!nameNode || !valueNode) continue;
// Skip declarators that already have a binding from extractDeclaration
const varName = extractVarName(nameNode);
if (!varName || env.has(varName)) continue;
if (valueNode.type !== 'object_creation_expression') continue;
const ctorType = valueNode.childForFieldName('type');
if (!ctorType) continue;
const typeName = extractSimpleTypeName(ctorType);
if (typeName) env.set(varName, typeName);
}
};
/** Java: formal_parameter → type name */
const extractJavaParameter: ParameterExtractor = (node: SyntaxNode, env: Map<string, string>): void => {
let nameNode: SyntaxNode | null = null;
@@ -48,10 +67,218 @@ const extractJavaParameter: ParameterExtractor = (node: SyntaxNode, env: Map<str
if (varName && typeName) env.set(varName, typeName);
};
/** Java: var x = SomeFactory.create() — constructor binding for `var` with method_invocation */
const scanJavaConstructorBinding: ConstructorBindingScanner = (node) => {
if (node.type !== 'local_variable_declaration') return undefined;
const typeNode = node.childForFieldName('type');
if (!typeNode) return undefined;
if (typeNode.text !== 'var') return undefined;
const declarator = findChildByType(node, 'variable_declarator');
if (!declarator) return undefined;
const nameNode = declarator.childForFieldName('name');
const value = declarator.childForFieldName('value');
if (!nameNode || !value) return undefined;
if (value.type === 'object_creation_expression') return undefined;
if (value.type !== 'method_invocation') return undefined;
const methodName = value.childForFieldName('name');
if (!methodName) return undefined;
return { varName: nameNode.text, calleeName: methodName.text };
};
const JAVA_FOR_LOOP_NODE_TYPES: ReadonlySet<string> = new Set([
'enhanced_for_statement',
]);
/** 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 varName = extractVarName(nameNode);
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 */
const extractJavaPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) => {
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (!child || child.type !== 'variable_declarator') continue;
const nameNode = child.childForFieldName('name');
const valueNode = child.childForFieldName('value');
if (!nameNode || !valueNode) continue;
const lhs = nameNode.text;
if (scopeEnv.has(lhs)) continue;
if (valueNode.type === 'identifier' || valueNode.type === 'simple_identifier') return { kind: 'copy', lhs, rhs: valueNode.text };
// field_access RHS → fieldAccess (a.field)
if (valueNode.type === 'field_access') {
const obj = valueNode.childForFieldName('object');
const field = valueNode.childForFieldName('field');
if (obj?.type === 'identifier' && field) {
return { kind: 'fieldAccess', lhs, receiver: obj.text, field: field.text };
}
}
// method_invocation RHS
if (valueNode.type === 'method_invocation') {
const objField = valueNode.childForFieldName('object');
if (!objField) {
// No receiver → callResult
const nameField = valueNode.childForFieldName('name');
if (nameField?.type === 'identifier') {
return { kind: 'callResult', lhs, callee: nameField.text };
}
} else if (objField.type === 'identifier') {
// With receiver → methodCallResult
const nameField = valueNode.childForFieldName('name');
if (nameField?.type === 'identifier') {
return { kind: 'methodCallResult', lhs, receiver: objField.text, method: nameField.text };
}
}
}
}
return undefined;
};
/**
* Java 16+ `instanceof` pattern variable: `x instanceof User user`
*
* AST structure:
* instanceof_expression
* left: expression (the variable being tested)
* instanceof keyword
* right: type (the type to test against)
* name: identifier (the pattern variable — optional, Java 16+)
*
* Conservative: returns undefined when the `name` field is absent (plain instanceof
* without pattern variable, e.g. `x instanceof User`) or when the type cannot be
* extracted. The source variable's existing type is NOT used — the pattern explicitly
* 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;
const typeNode = node.childForFieldName('right');
if (!typeNode) return undefined;
const typeName = extractSimpleTypeName(typeNode);
const varName = extractVarName(nameNode);
if (!typeName || !varName) return undefined;
return { varName, typeName };
};
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,
extractForLoopBinding: extractJavaForLoopBinding,
extractPendingAssignment: extractJavaPendingAssignment,
extractPatternBinding: extractJavaPatternBinding,
};
// ── Kotlin ────────────────────────────────────────────────────────────────
@@ -96,7 +323,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;
@@ -109,14 +339,353 @@ 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);
if (varName && typeName) env.set(varName, typeName);
};
/** Kotlin: val user = User() — infer type from call_expression when callee is a known class.
* Kotlin constructors are syntactically identical to function calls, so we verify
* against classNames (which may include cross-file SymbolTable lookups). */
const extractKotlinInitializer: InitializerExtractor = (node: SyntaxNode, env: Map<string, string>, classNames: ClassNameLookup): void => {
if (node.type !== 'property_declaration') return;
// Skip if there's an explicit type annotation — Tier 0 already handled it
const varDecl = findChildByType(node, 'variable_declaration');
if (varDecl && findChildByType(varDecl, 'user_type')) return;
// Get the initializer value — the call_expression after '='
const value = node.childForFieldName('value')
?? findChildByType(node, 'call_expression');
if (!value || value.type !== 'call_expression') return;
// The callee is the first child of call_expression (simple_identifier for direct calls)
const callee = value.firstNamedChild;
if (!callee || callee.type !== 'simple_identifier') return;
const calleeName = callee.text;
if (!calleeName || !classNames.has(calleeName)) return;
// Extract the variable name from the variable_declaration inside property_declaration
const nameNode = varDecl
? findChildByType(varDecl, 'simple_identifier')
: findChildByType(node, 'simple_identifier');
if (!nameNode) return;
const varName = extractVarName(nameNode);
if (varName) env.set(varName, calleeName);
};
/** Kotlin: val x = User(...) — constructor binding for property_declaration with call_expression */
const scanKotlinConstructorBinding: ConstructorBindingScanner = (node) => {
if (node.type !== 'property_declaration') return undefined;
const varDecl = findChildByType(node, 'variable_declaration');
if (!varDecl) return undefined;
if (findChildByType(varDecl, 'user_type')) return undefined;
const callExpr = findChildByType(node, 'call_expression');
if (!callExpr) return undefined;
const callee = callExpr.firstNamedChild;
if (!callee) return undefined;
let calleeName: string | undefined;
if (callee.type === 'simple_identifier') {
calleeName = callee.text;
} else if (callee.type === 'navigation_expression') {
// Extract method name from qualified call: service.getUser() → getUser
const suffix = callee.lastNamedChild;
if (suffix?.type === 'navigation_suffix') {
const methodName = suffix.lastNamedChild;
if (methodName?.type === 'simple_identifier') {
calleeName = methodName.text;
}
}
}
if (!calleeName) return undefined;
const nameNode = findChildByType(varDecl, 'simple_identifier');
if (!nameNode) return undefined;
return { varName: nameNode.text, calleeName };
};
const KOTLIN_FOR_LOOP_NODE_TYPES: ReadonlySet<string> = new Set([
'for_statement',
]);
/** 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;
const nameNode = findChildByType(varDecl, 'simple_identifier');
if (!nameNode) return;
const varName = extractVarName(nameNode);
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.
* property_declaration has: binding_pattern_kind("val"), variable_declaration("alias"),
* "=", and the RHS value (simple_identifier "u").
* variable_declaration appears directly inside functions and has simple_identifier children. */
const extractKotlinPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) => {
if (node.type === 'property_declaration') {
// Find the variable name from variable_declaration child
const varDecl = findChildByType(node, 'variable_declaration');
if (!varDecl) return undefined;
const nameNode = varDecl.firstNamedChild;
if (!nameNode || nameNode.type !== 'simple_identifier') return undefined;
const lhs = nameNode.text;
if (scopeEnv.has(lhs)) return undefined;
// Find the RHS after the "=" token
let foundEq = false;
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (!child) continue;
if (child.type === '=') { foundEq = true; continue; }
if (foundEq && child.type === 'simple_identifier') {
return { kind: 'copy', lhs, rhs: child.text };
}
// navigation_expression RHS → fieldAccess (a.field)
if (foundEq && child.type === 'navigation_expression') {
const recv = child.firstNamedChild;
const suffix = child.lastNamedChild;
const fieldNode = suffix?.type === 'navigation_suffix' ? suffix.lastNamedChild : suffix;
if (recv?.type === 'simple_identifier' && fieldNode?.type === 'simple_identifier') {
return { kind: 'fieldAccess', lhs, receiver: recv.text, field: fieldNode.text };
}
}
// call_expression RHS
if (foundEq && child.type === 'call_expression') {
const calleeNode = child.firstNamedChild;
if (calleeNode?.type === 'simple_identifier') {
return { kind: 'callResult', lhs, callee: calleeNode.text };
}
// navigation_expression callee → methodCallResult (a.method())
if (calleeNode?.type === 'navigation_expression') {
const recv = calleeNode.firstNamedChild;
const suffix = calleeNode.lastNamedChild;
const methodNode = suffix?.type === 'navigation_suffix' ? suffix.lastNamedChild : suffix;
if (recv?.type === 'simple_identifier' && methodNode?.type === 'simple_identifier') {
return { kind: 'methodCallResult', lhs, receiver: recv.text, method: methodNode.text };
}
}
}
}
return undefined;
}
if (node.type === 'variable_declaration') {
// variable_declaration directly inside functions: simple_identifier children
const nameNode = findChildByType(node, 'simple_identifier');
if (!nameNode) return undefined;
const lhs = nameNode.text;
if (scopeEnv.has(lhs)) return undefined;
// Look for RHS after "=" in the parent (property_declaration)
const parent = node.parent;
if (!parent) return undefined;
let foundEq = false;
for (let i = 0; i < parent.childCount; i++) {
const child = parent.child(i);
if (!child) continue;
if (child.type === '=') { foundEq = true; continue; }
if (foundEq && child.type === 'simple_identifier') {
return { kind: 'copy', lhs, rhs: child.text };
}
if (foundEq && child.type === 'navigation_expression') {
const recv = child.firstNamedChild;
const suffix = child.lastNamedChild;
const fieldNode = suffix?.type === 'navigation_suffix' ? suffix.lastNamedChild : suffix;
if (recv?.type === 'simple_identifier' && fieldNode?.type === 'simple_identifier') {
return { kind: 'fieldAccess', lhs, receiver: recv.text, field: fieldNode.text };
}
}
if (foundEq && child.type === 'call_expression') {
const calleeNode = child.firstNamedChild;
if (calleeNode?.type === 'simple_identifier') {
return { kind: 'callResult', lhs, callee: calleeNode.text };
}
if (calleeNode?.type === 'navigation_expression') {
const recv = calleeNode.firstNamedChild;
const suffix = calleeNode.lastNamedChild;
const methodNode = suffix?.type === 'navigation_suffix' ? suffix.lastNamedChild : suffix;
if (recv?.type === 'simple_identifier' && methodNode?.type === 'simple_identifier') {
return { kind: 'methodCallResult', lhs, receiver: recv.text, method: methodNode.text };
}
}
}
}
return undefined;
}
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,13 +1,248 @@
import type { SyntaxNode } from '../utils.js';
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor } from './types.js';
import { extractSimpleTypeName, extractVarName } 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';
// PHP has no local variable type annotations; only params carry types
const DECLARATION_NODE_TYPES: ReadonlySet<string> = new Set<string>();
const DECLARATION_NODE_TYPES: ReadonlySet<string> = new Set([
'assignment_expression', // For constructor inference: $x = new User()
'property_declaration', // PHP 7.4+ typed properties: private UserRepo $repo;
'method_declaration', // PHPDoc @param on class methods
'function_definition', // PHPDoc @param on top-level functions
]);
/** PHP: no typed local variable declarations */
const extractDeclaration: TypeBindingExtractor = (_node: SyntaxNode, _env: Map<string, string>): void => {
// PHP has no local variable type annotations
/** Walk up the AST to find the enclosing class declaration. */
const findEnclosingClass = (node: SyntaxNode): SyntaxNode | null => {
let current = node.parent;
while (current) {
if (current.type === 'class_declaration') return current;
current = current.parent;
}
return null;
};
/**
* Resolve PHP self/static/parent to the actual class name.
* - self/static → enclosing class name
* - parent → superclass from base_clause
*/
const resolvePhpKeyword = (keyword: string, node: SyntaxNode): string | undefined => {
if (keyword === 'self' || keyword === 'static') {
const cls = findEnclosingClass(node);
if (!cls) return undefined;
const nameNode = cls.childForFieldName('name');
return nameNode?.text;
}
if (keyword === 'parent') {
const cls = findEnclosingClass(node);
if (!cls) return undefined;
// base_clause contains the parent class name
for (let i = 0; i < cls.namedChildCount; i++) {
const child = cls.namedChild(i);
if (child?.type === 'base_clause') {
const parentName = child.firstNamedChild;
if (parentName) return extractSimpleTypeName(parentName);
}
}
return undefined;
}
return undefined;
};
const normalizePhpType = (raw: string): string | undefined => {
// Strip nullable prefix: ?User → User
let type = raw.startsWith('?') ? raw.slice(1) : raw;
// Strip array suffix: User[] → User
type = type.replace(/\[\]$/, '');
// 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') 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;
};
/** Node types to skip when walking backwards to find doc-comments.
* PHP 8+ attributes (#[Route(...)]) appear as named siblings between PHPDoc and method. */
const SKIP_NODE_TYPES: ReadonlySet<string> = new Set(['attribute_list', 'attribute']);
/** Regex to extract PHPDoc @param annotations: `@param Type $name` (standard order) */
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.
* Returns a map of paramName → typeName (without $ prefix).
*/
const collectPhpDocParams = (methodNode: SyntaxNode): Map<string, string> => {
const commentTexts: string[] = [];
let sibling = methodNode.previousSibling;
while (sibling) {
if (sibling.type === 'comment') {
commentTexts.unshift(sibling.text);
} else if (sibling.isNamed && !SKIP_NODE_TYPES.has(sibling.type)) {
break;
}
sibling = sibling.previousSibling;
}
if (commentTexts.length === 0) return new Map();
const params = new Map<string, string>();
const commentBlock = commentTexts.join('\n');
PHPDOC_PARAM_RE.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = PHPDOC_PARAM_RE.exec(commentBlock)) !== null) {
const typeName = normalizePhpType(match[1]);
const paramName = match[2]; // without $ prefix
if (typeName) {
// Store with $ prefix to match how PHP variables appear in the env
params.set('$' + paramName, typeName);
}
}
// Also check alternate PHPDoc order: @param $name Type
PHPDOC_PARAM_ALT_RE.lastIndex = 0;
while ((match = PHPDOC_PARAM_ALT_RE.exec(commentBlock)) !== null) {
const paramName = match[1];
if (params.has('$' + paramName)) continue; // standard format takes priority
const typeName = normalizePhpType(match[2]);
if (typeName) {
params.set('$' + paramName, typeName);
}
}
return params;
};
/**
* PHP: typed class properties (PHP 7.4+): private UserRepo $repo;
* Also: PHPDoc @param annotations on method/function definitions.
*/
const extractDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: Map<string, string>): void => {
// PHPDoc @param on methods/functions — pre-populate env with param types
if (node.type === 'method_declaration' || node.type === 'function_definition') {
const phpDocParams = collectPhpDocParams(node);
for (const [paramName, typeName] of phpDocParams) {
if (!env.has(paramName)) env.set(paramName, typeName);
}
return;
}
if (node.type !== 'property_declaration') return;
const typeNode = node.childForFieldName('type');
if (!typeNode) return;
const typeName = extractSimpleTypeName(typeNode);
if (!typeName) return;
// The variable name is inside property_element > variable_name
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child?.type === 'property_element') {
const varNameNode = child.firstNamedChild; // variable_name
if (varNameNode) {
const varName = extractVarName(varNameNode);
if (varName) env.set(varName, typeName);
}
break;
}
}
};
/** PHP: $x = new User() — infer type from object_creation_expression */
const extractInitializer: InitializerExtractor = (node: SyntaxNode, env: Map<string, string>, _classNames: ClassNameLookup): void => {
if (node.type !== 'assignment_expression') return;
const left = node.childForFieldName('left');
const right = node.childForFieldName('right');
if (!left || !right) return;
if (right.type !== 'object_creation_expression') return;
// The class name is the first named child of object_creation_expression
// (tree-sitter-php uses 'name' or 'qualified_name' nodes here)
const ctorType = right.firstNamedChild;
if (!ctorType) return;
const typeName = extractSimpleTypeName(ctorType);
if (!typeName) return;
// Resolve PHP self/static/parent to actual class names
const resolvedType = (typeName === 'self' || typeName === 'static' || typeName === 'parent')
? resolvePhpKeyword(typeName, node)
: typeName;
if (!resolvedType) return;
const varName = extractVarName(left);
if (varName) env.set(varName, resolvedType);
};
/** PHP: simple_parameter → type $name */
@@ -25,12 +260,294 @@ 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 */
const scanConstructorBinding: ConstructorBindingScanner = (node) => {
if (node.type !== 'assignment_expression') return undefined;
const left = node.childForFieldName('left');
const right = node.childForFieldName('right');
if (!left || !right) return undefined;
if (left.type !== 'variable_name') return undefined;
// Skip object_creation_expression (new User()) — handled by extractInitializer
if (right.type === 'object_creation_expression') return undefined;
// Handle both standalone function calls and method calls ($this->getUser())
if (right.type === 'function_call_expression') {
const calleeName = extractCalleeName(right);
if (!calleeName) return undefined;
return { varName: left.text, calleeName };
}
if (right.type === 'member_call_expression') {
const methodName = right.childForFieldName('name');
if (!methodName) return undefined;
// When receiver is $this/self/static, qualify with enclosing class for disambiguation
const receiver = right.childForFieldName('object');
const receiverText = receiver?.text;
let receiverClassName: string | undefined;
if (receiverText === '$this' || receiverText === 'self' || receiverText === 'static') {
const cls = findEnclosingClass(node);
const clsName = cls?.childForFieldName('name');
if (clsName) receiverClassName = clsName.text;
}
return { varName: left.text, calleeName: methodName.text, receiverClassName };
}
return undefined;
};
/** 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 normalizePhpReturnType(match[1]);
} else if (sibling.isNamed && !SKIP_NODE_TYPES.has(sibling.type)) break;
sibling = sibling.previousSibling;
}
return undefined;
};
/** PHP: $alias = $user → assignment_expression with variable_name left/right.
* PHP TypeEnv stores variables WITH $ prefix ($user → User), so we keep $ in lhs/rhs. */
const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) => {
if (node.type !== 'assignment_expression') return undefined;
const left = node.childForFieldName('left');
const right = node.childForFieldName('right');
if (!left || !right) return undefined;
if (left.type !== 'variable_name') return undefined;
const lhs = left.text;
if (!lhs || scopeEnv.has(lhs)) return undefined;
if (right.type === 'variable_name') {
const rhs = right.text;
if (rhs) return { kind: 'copy', lhs, rhs };
}
// member_access_expression RHS → fieldAccess ($a->field)
if (right.type === 'member_access_expression') {
const obj = right.childForFieldName('object');
const name = right.childForFieldName('name');
if (obj?.type === 'variable_name' && name) {
return { kind: 'fieldAccess', lhs, receiver: obj.text, field: name.text };
}
}
// function_call_expression RHS → callResult (bare function calls only)
if (right.type === 'function_call_expression') {
const funcNode = right.childForFieldName('function');
if (funcNode?.type === 'name') {
return { kind: 'callResult', lhs, callee: funcNode.text };
}
}
// member_call_expression RHS → methodCallResult ($a->method())
if (right.type === 'member_call_expression') {
const obj = right.childForFieldName('object');
const name = right.childForFieldName('name');
if (obj?.type === 'variable_name' && name) {
return { kind: 'methodCallResult', lhs, receiver: obj.text, method: name.text };
}
}
return undefined;
};
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,20 +1,53 @@
import type { SyntaxNode } from '../utils.js';
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor } 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',
'named_expression',
'expression_statement',
]);
/** Python: x: Foo = ... (PEP 484 annotations) */
/** Python: x: Foo = ... (PEP 484 annotated assignment) or x: Foo (standalone annotation).
*
* tree-sitter-python grammar produces two distinct shapes:
*
* 1. Annotated assignment with value: `name: str = ""`
* Node type: `assignment`
* Fields: left=identifier, type=identifier/type, right=value
*
* 2. Standalone annotation (no value): `name: str`
* Node type: `expression_statement`
* Child: `type` node with fields name=identifier, type=identifier/type
*
* Both appear at file scope and inside class bodies (PEP 526 class variable annotations).
*/
const extractDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: Map<string, string>): void => {
// Python annotated assignment: left : type = value
// tree-sitter represents this differently based on grammar version
if (node.type === 'expression_statement') {
// Standalone annotation: expression_statement > type { name: identifier, type: identifier }
const typeChild = node.firstNamedChild;
if (!typeChild || typeChild.type !== 'type') return;
const nameNode = typeChild.childForFieldName('name');
const typeNode = typeChild.childForFieldName('type');
if (!nameNode || !typeNode) return;
const varName = extractVarName(nameNode);
const inner = typeNode.type === 'type' ? (typeNode.firstNamedChild ?? typeNode) : typeNode;
const typeName = extractSimpleTypeName(inner) ?? inner.text;
if (varName && typeName) env.set(varName, typeName);
return;
}
// Annotated assignment: left : type = value
const left = node.childForFieldName('left');
const typeNode = node.childForFieldName('type');
if (!left || !typeNode) return;
const varName = extractVarName(left);
const typeName = extractSimpleTypeName(typeNode);
// extractSimpleTypeName handles identifiers and qualified names.
// Python 3.10+ union syntax `User | None` is parsed as binary_operator,
// which extractSimpleTypeName doesn't handle. Fall back to raw text so
// stripNullable can process it at lookup time (e.g., "User | None" → "User").
const inner = typeNode.type === 'type' ? (typeNode.firstNamedChild ?? typeNode) : typeNode;
const typeName = extractSimpleTypeName(inner) ?? inner.text;
if (varName && typeName) env.set(varName, typeName);
};
@@ -29,6 +62,10 @@ const extractParameter: ParameterExtractor = (node: SyntaxNode, env: Map<string,
} else {
nameNode = node.childForFieldName('name') ?? node.childForFieldName('pattern');
typeNode = node.childForFieldName('type');
// Python typed_parameter: name is a positional child (identifier), not a named field
if (!nameNode && node.type === 'typed_parameter') {
nameNode = node.firstNamedChild?.type === 'identifier' ? node.firstNamedChild : null;
}
}
if (!nameNode || !typeNode) return;
@@ -37,8 +74,387 @@ const extractParameter: ParameterExtractor = (node: SyntaxNode, env: Map<string,
if (varName && typeName) env.set(varName, typeName);
};
/** Python: user = User("alice") — infer type from call when callee is a known class.
* Python constructors are syntactically identical to function calls, so we verify
* against classNames (which may include cross-file SymbolTable lookups).
* Also handles walrus operator: if (user := User("alice")): */
const extractInitializer: InitializerExtractor = (node: SyntaxNode, env: Map<string, string>, classNames: ClassNameLookup): void => {
let left: SyntaxNode | null;
let right: SyntaxNode | null;
if (node.type === 'named_expression') {
// Walrus operator: (user := User("alice"))
// tree-sitter-python: named_expression has 'name' and 'value' fields
left = node.childForFieldName('name');
right = node.childForFieldName('value');
} else if (node.type === 'assignment') {
left = node.childForFieldName('left');
right = node.childForFieldName('right');
// Skip if already has type annotation — extractDeclaration handled it
if (node.childForFieldName('type')) return;
} else {
return;
}
if (!left || !right) return;
const varName = extractVarName(left);
if (!varName || env.has(varName)) return;
if (right.type !== 'call') return;
const func = right.childForFieldName('function');
if (!func) return;
// Support both direct calls (User()) and qualified calls (models.User())
// tree-sitter-python: direct → identifier, qualified → attribute
const calleeName = extractSimpleTypeName(func);
if (!calleeName) return;
if (classNames.has(calleeName)) {
env.set(varName, calleeName);
}
};
/** Python: user = User("alice") — scan assignment/walrus for constructor-like calls.
* Returns {varName, calleeName} without checking classNames (caller validates). */
const scanConstructorBinding: ConstructorBindingScanner = (node) => {
let left: SyntaxNode | null;
let right: SyntaxNode | null;
if (node.type === 'named_expression') {
left = node.childForFieldName('name');
right = node.childForFieldName('value');
} else if (node.type === 'assignment') {
left = node.childForFieldName('left');
right = node.childForFieldName('right');
if (node.childForFieldName('type')) return undefined;
} else {
return undefined;
}
if (!left || !right) return undefined;
if (left.type !== 'identifier') return undefined;
if (right.type !== 'call') return undefined;
const func = right.childForFieldName('function');
if (!func) return undefined;
const calleeName = extractSimpleTypeName(func);
if (!calleeName) return undefined;
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;
};
/**
* Extracts iterableName and methodName from a call expression like `data.items()`.
* Returns undefined if the call doesn't match the expected pattern.
*/
const extractMethodCall = (callNode: SyntaxNode): { iterableName: string; methodName?: string } | undefined => {
const fn = callNode.childForFieldName('function');
if (fn?.type !== 'attribute') return undefined;
const obj = fn.firstNamedChild;
if (obj?.type !== 'identifier') return undefined;
const method = fn.lastNamedChild;
const methodName = (method?.type === 'identifier' && method !== obj) ? method.text : undefined;
return { iterableName: obj.text, methodName };
};
/**
* Collects all identifier nodes from a pattern, descending into nested tuple_patterns.
* For `i, (k, v)` returns [i, k, v]. For `key, value` returns [key, value].
*/
const collectPatternIdentifiers = (pattern: SyntaxNode): SyntaxNode[] => {
const vars: SyntaxNode[] = [];
for (let i = 0; i < pattern.namedChildCount; i++) {
const child = pattern.namedChild(i);
if (child?.type === 'identifier') {
vars.push(child);
} else if (child?.type === 'tuple_pattern') {
vars.push(...collectPatternIdentifiers(child));
}
}
return vars;
};
/**
* 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
*
* Also handles `enumerate(iterable)` — unwraps the outer call and skips the integer
* index variable so the value variable still resolves to the element type.
*/
const extractForLoopBinding: ForLoopExtractor = (node, { scopeEnv, declarationTypeNodes, scope, returnTypeLookup }): void => {
if (node.type !== 'for_statement') return;
const rightNode = node.childForFieldName('right');
let iterableName: string | undefined;
let methodName: string | undefined;
let callExprElementType: string | undefined;
let isEnumerate = false;
// Extract iterable info from the `right` field — may be identifier, attribute, or call.
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') {
const fn = rightNode.childForFieldName('function');
if (fn?.type === 'identifier' && fn.text === 'enumerate') {
// enumerate(iterable) or enumerate(d.items()) — unwrap to inner iterable.
isEnumerate = true;
const innerArg = rightNode.childForFieldName('arguments')?.firstNamedChild;
if (innerArg?.type === 'identifier') {
iterableName = innerArg.text;
} else if (innerArg?.type === 'call') {
const extracted = extractMethodCall(innerArg);
if (extracted) ({ iterableName, methodName } = extracted);
}
} else if (fn?.type === 'attribute') {
// data.items() → call > function: attribute > identifier('data') + identifier('items')
const extracted = extractMethodCall(rightNode);
if (extracted) ({ iterableName, methodName } = extracted);
} else if (fn?.type === 'identifier') {
// Direct function call: for user in get_users() (Phase 7.3 — return-type path)
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;
if (leftNode.type === 'pattern_list' || leftNode.type === 'tuple_pattern') {
// Tuple unpacking: `key, value` or `i, (k, v)` or `(k, v)` — bind the last identifier to element type.
// With enumerate, skip binding if there's only one var (just the index, no value to bind).
const vars = collectPatternIdentifiers(leftNode);
if (vars.length > 0 && (!isEnumerate || vars.length > 1)) {
scopeEnv.set(vars[vars.length - 1].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) => {
let left: SyntaxNode | null;
let right: SyntaxNode | null;
if (node.type === 'assignment') {
left = node.childForFieldName('left');
right = node.childForFieldName('right');
} else if (node.type === 'named_expression') {
left = node.childForFieldName('name');
right = node.childForFieldName('value');
} else {
return undefined;
}
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 { kind: 'copy', lhs, rhs: right.text };
// attribute RHS → fieldAccess (a.field)
if (right.type === 'attribute') {
const obj = right.firstNamedChild;
const field = right.lastNamedChild;
if (obj?.type === 'identifier' && field?.type === 'identifier' && obj !== field) {
return { kind: 'fieldAccess', lhs, receiver: obj.text, field: field.text };
}
}
// call RHS
if (right.type === 'call') {
const funcNode = right.childForFieldName('function');
if (funcNode?.type === 'identifier') {
return { kind: 'callResult', lhs, callee: funcNode.text };
}
// method call with receiver: call → function: attribute
if (funcNode?.type === 'attribute') {
const obj = funcNode.firstNamedChild;
const method = funcNode.lastNamedChild;
if (obj?.type === 'identifier' && method?.type === 'identifier' && obj !== method) {
return { kind: 'methodCallResult', lhs, receiver: obj.text, method: method.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,
};
@@ -0,0 +1,420 @@
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';
/**
* Ruby type extractor — YARD annotation parsing.
*
* Ruby has no static type system, but the YARD documentation convention
* provides de facto type annotations via comments:
*
* # @param name [String] the user's name
* # @param repo [UserRepo] the repository
* # @return [User]
* def create(name, repo)
* repo.save
* end
*
* This extractor parses `@param name [Type]` patterns from comment nodes
* preceding method definitions and binds parameter names to their types.
*
* Resolution tiers:
* - Tier 0: YARD @param annotations (extractDeclaration pre-populates env)
* - Tier 1: Constructor inference via `user = User.new` (handled by scanConstructorBinding in typeConfig)
*/
/** Regex to extract @param annotations: `@param name [Type]` */
const YARD_PARAM_RE = /@param\s+(\w+)\s+\[([^\]]+)\]/g;
/** Alternate YARD order: `@param [Type] name` */
const YARD_PARAM_ALT_RE = /@param\s+\[([^\]]+)\]\s+(\w+)/g;
/** Regex to extract @return annotations: `@return [Type]` */
const YARD_RETURN_RE = /@return\s+\[([^\]]+)\]/;
/**
* Extract the simple type name from a YARD type string.
* Handles:
* - Simple types: "String" → "String"
* - Qualified types: "Models::User" → "User"
* - Generic types: "Array<User>" → "Array"
* - Nullable types: "String, nil" → "String"
* - Union types: "String, Integer" → undefined (ambiguous)
*/
const extractYardTypeName = (yardType: string): string | undefined => {
const trimmed = yardType.trim();
// Handle nullable: "Type, nil" or "nil, Type"
// Use bracket-balanced split to avoid breaking on commas inside generics like Hash<Symbol, User>
const parts: string[] = [];
let depth = 0, start = 0;
for (let i = 0; i < trimmed.length; i++) {
if (trimmed[i] === '<') depth++;
else if (trimmed[i] === '>') depth--;
else if (trimmed[i] === ',' && depth === 0) {
parts.push(trimmed.slice(start, i).trim());
start = i + 1;
}
}
parts.push(trimmed.slice(start).trim());
const filtered = parts.filter(p => p !== '' && p !== 'nil');
if (filtered.length !== 1) return undefined; // ambiguous union
const typePart = filtered[0];
// Handle qualified: "Models::User" → "User"
const segments = typePart.split('::');
const last = segments[segments.length - 1];
// Handle generic: "Array<User>" → "Array"
const genericMatch = last.match(/^(\w+)\s*[<{(]/);
if (genericMatch) return genericMatch[1];
// Simple identifier check
if (/^\w+$/.test(last)) return last;
return undefined;
};
/**
* Collect YARD @param annotations from comment nodes preceding a method definition.
* Returns a map of paramName → typeName.
*
* In tree-sitter-ruby, comments are sibling nodes that appear before the method node.
* We walk backwards through preceding siblings collecting consecutive comment nodes.
*/
const collectYardParams = (methodNode: SyntaxNode): Map<string, string> => {
const params = new Map<string, string>();
// In tree-sitter-ruby, YARD comments preceding a method inside a class body
// are placed as children of the `class` node, NOT as siblings of the `method`
// inside `body_statement`. The AST structure is:
//
// class
// constant = "ClassName"
// comment = "# @param ..." ← sibling of body_statement
// comment = "# @param ..." ← sibling of body_statement
// body_statement
// method ← method is here, no preceding siblings
//
// For top-level methods (outside classes), comments ARE direct siblings.
// We handle both by checking: if method has no preceding comment siblings,
// look at parent (body_statement) siblings instead.
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;
}
};
// Try method's own siblings first (top-level methods)
collectComments(methodNode);
// If no comments found and parent is body_statement, check parent's siblings
if (commentTexts.length === 0 && methodNode.parent?.type === 'body_statement') {
collectComments(methodNode.parent);
}
// Parse all comment lines for @param annotations
const commentBlock = commentTexts.join('\n');
let match: RegExpExecArray | null;
// Reset regex state
YARD_PARAM_RE.lastIndex = 0;
while ((match = YARD_PARAM_RE.exec(commentBlock)) !== null) {
const paramName = match[1];
const rawType = match[2];
const typeName = extractYardTypeName(rawType);
if (typeName) {
params.set(paramName, typeName);
}
}
// Also check alternate YARD order: @param [Type] name
YARD_PARAM_ALT_RE.lastIndex = 0;
while ((match = YARD_PARAM_ALT_RE.exec(commentBlock)) !== null) {
const rawType = match[1];
const paramName = match[2];
if (params.has(paramName)) continue; // standard format takes priority
const typeName = extractYardTypeName(rawType);
if (typeName) {
params.set(paramName, typeName);
}
}
return params;
};
/**
* Ruby node types that may carry type bindings.
* - `method`/`singleton_method`: YARD @param annotations (via extractDeclaration)
* - `assignment`: Constructor inference like `user = User.new` (via extractInitializer;
* extractDeclaration returns early for these nodes)
*/
const DECLARATION_NODE_TYPES: ReadonlySet<string> = new Set([
'method',
'singleton_method',
'assignment',
]);
/**
* Extract YARD annotations from method definitions.
* Pre-populates the scope env with parameter types before the
* standard parameter walk (which won't find types since Ruby has none).
*/
const extractDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: Map<string, string>): void => {
if (node.type !== 'method' && node.type !== 'singleton_method') return;
const yardParams = collectYardParams(node);
if (yardParams.size === 0) return;
// Pre-populate env with YARD type bindings for each parameter
for (const [paramName, typeName] of yardParams) {
env.set(paramName, typeName);
}
};
/**
* Ruby parameter extraction.
* Ruby parameters (identifiers inside method_parameters) have no inline
* type annotations. YARD types are already populated by extractDeclaration,
* so this is a no-op — the bindings are already in the env.
*
* We still register this to maintain the LanguageTypeConfig contract.
*/
const extractParameter: ParameterExtractor = (_node: SyntaxNode, _env: Map<string, string>): void => {
// Ruby parameters have no type annotations.
// YARD types are pre-populated by extractDeclaration.
};
/**
* Ruby constructor inference: user = User.new or service = Models::User.new
* Uses the shared extractRubyConstructorAssignment helper for AST matching,
* then resolves against locally-known class names.
*/
const extractInitializer: InitializerExtractor = (node, env, classNames): void => {
const result = extractRubyConstructorAssignment(node);
if (!result) return;
if (env.has(result.varName)) return;
if (classNames.has(result.calleeName)) {
env.set(result.varName, result.calleeName);
}
};
/**
* Extract return type from YARD `@return [Type]` annotation preceding a method.
* Reuses the same comment-walking strategy as collectYardParams: try direct
* siblings first, fall back to parent (body_statement) siblings for class methods.
*/
const extractReturnType: ReturnTypeExtractor = (node) => {
const search = (startNode: SyntaxNode): string | undefined => {
let sibling = startNode.previousSibling;
while (sibling) {
if (sibling.type === 'comment') {
const match = YARD_RETURN_RE.exec(sibling.text);
if (match) return extractYardTypeName(match[1]);
} else if (sibling.isNamed) {
break;
}
sibling = sibling.previousSibling;
}
return undefined;
};
const result = search(node);
if (result) return result;
if (node.parent?.type === 'body_statement') {
return search(node.parent);
}
return undefined;
};
/**
* Ruby constructor binding scanner: captures both `user = User.new` and
* plain call assignments like `user = get_user()`.
* The `.new` pattern returns the class name directly; plain calls return the
* callee name for return-type inference via SymbolTable lookup.
*/
const scanConstructorBinding: ConstructorBindingScanner = (node) => {
// Try the .new pattern first (returns class name directly)
const newResult = extractRubyConstructorAssignment(node);
if (newResult) return newResult;
// Plain call assignment: user = get_user() / user = Models.create()
if (node.type !== 'assignment') return undefined;
const left = node.childForFieldName('left');
const right = node.childForFieldName('right');
if (!left || !right) return undefined;
if (left.type !== 'identifier' && left.type !== 'constant') return undefined;
if (right.type !== 'call') return undefined;
const method = right.childForFieldName('method');
if (!method) return undefined;
const calleeName = extractSimpleTypeName(method);
if (!calleeName) return undefined;
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) return undefined;
if (rhsNode.type === 'identifier') return { kind: 'copy', lhs: varName, rhs: rhsNode.text };
// call/method_call RHS — Ruby uses method calls for both field access and method calls
if (rhsNode.type === 'call' || rhsNode.type === 'method_call') {
const methodNode = rhsNode.childForFieldName('method');
const receiverNode = rhsNode.childForFieldName('receiver');
if (!receiverNode && methodNode?.type === 'identifier') {
// No receiver → callResult (bare function call)
return { kind: 'callResult', lhs: varName, callee: methodNode.text };
}
if (receiverNode?.type === 'identifier' && methodNode?.type === 'identifier') {
// With receiver → methodCallResult (a.method)
return { kind: 'methodCallResult', lhs: varName, receiver: receiverNode.text, method: methodNode.text };
}
}
return undefined;
};
export const typeConfig: LanguageTypeConfig = {
declarationNodeTypes: DECLARATION_NODE_TYPES,
forLoopNodeTypes: FOR_LOOP_NODE_TYPES,
extractDeclaration,
extractParameter,
extractInitializer,
scanConstructorBinding,
extractReturnType,
extractForLoopBinding,
extractPendingAssignment,
};
@@ -1,13 +1,91 @@
import type { SyntaxNode } from '../utils.js';
import type { LanguageTypeConfig, ParameterExtractor, TypeBindingExtractor } 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, hasTypeAnnotation, unwrapAwait, extractGenericTypeArgs, resolveIterableElementType, methodToTypeArgPosition, extractElementTypeFromString, type TypeArgPosition } from './shared.js';
const DECLARATION_NODE_TYPES: ReadonlySet<string> = new Set([
'let_declaration',
'let_condition',
]);
/** Rust: let x: Foo = ... */
/** Walk up the AST to find the enclosing impl block and extract the implementing type name. */
const findEnclosingImplType = (node: SyntaxNode): string | undefined => {
let current = node.parent;
while (current) {
if (current.type === 'impl_item') {
// The 'type' field holds the implementing type (e.g., `impl User { ... }`)
const typeNode = current.childForFieldName('type');
if (typeNode) return extractSimpleTypeName(typeNode);
}
current = current.parent;
}
return undefined;
};
/**
* Extract the type name from a struct_pattern's 'type' field.
* Handles both simple `User { .. }` and scoped `Message::Data { .. }`.
*/
const extractStructPatternType = (structPattern: SyntaxNode): string | undefined => {
const typeNode = structPattern.childForFieldName('type');
if (!typeNode) return undefined;
return extractSimpleTypeName(typeNode);
};
/**
* 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>, 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.
const nameNode = pattern.firstNamedChild;
if (!nameNode || nameNode.type !== 'identifier') return;
// Find the struct_pattern child — that gives us the type
for (let i = 0; i < pattern.namedChildCount; i++) {
const child = pattern.namedChild(i);
if (child?.type === 'struct_pattern') {
const typeName = extractStructPatternType(child);
if (typeName) env.set(nameNode.text, typeName);
return;
}
}
return;
}
// Recurse into tuple_struct_pattern children to find nested captured_patterns
// e.g., Some(user @ User { .. })
if (pattern.type === 'tuple_struct_pattern') {
for (let i = 0; i < pattern.namedChildCount; i++) {
const child = pattern.namedChild(i);
if (child) extractCapturedPatternBindings(child, env, depth + 1);
}
}
};
/** Rust: let x: Foo = ... | if let / while let pattern bindings */
const extractDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: Map<string, string>): void => {
if (node.type === 'let_condition') {
// if let / while let: extract type bindings from pattern matching.
//
// Supported patterns:
// - captured_pattern: `if let user @ User { .. } = expr` → user: User
// - tuple_struct_pattern with nested captured_pattern:
// `if let Some(user @ User { .. }) = expr` → user: User
//
// NOT supported (requires generic unwrapping — Phase 3):
// - `if let Some(x) = opt` where opt: Option<T> → x: T
//
// struct_pattern without capture (`if let User { name } = expr`)
// destructures fields — individual field types are unknown without
// field-type resolution, so no bindings are extracted.
const pattern = node.childForFieldName('pattern');
if (!pattern) return;
extractCapturedPatternBindings(pattern, env);
return;
}
// Standard let_declaration: let x: Foo = ...
const pattern = node.childForFieldName('pattern');
const typeNode = node.childForFieldName('type');
if (!pattern || !typeNode) return;
@@ -16,6 +94,53 @@ const extractDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: Map<str
if (varName && typeName) env.set(varName, typeName);
};
/** Rust: let x = User::new(), let x = User::default(), or let x = User { ... } */
const extractInitializer: InitializerExtractor = (node: SyntaxNode, env: Map<string, string>, classNames: ClassNameLookup): void => {
// Skip if there's an explicit type annotation — Tier 0 already handled it
if (node.childForFieldName('type') !== null) return;
const pattern = node.childForFieldName('pattern');
const value = node.childForFieldName('value');
if (!pattern || !value) return;
// Rust struct literal: let user = User { name: "alice", age: 30 }
// tree-sitter-rust: struct_expression with 'name' field holding the type
if (value.type === 'struct_expression') {
const typeNode = value.childForFieldName('name');
if (!typeNode) return;
const rawType = extractSimpleTypeName(typeNode);
if (!rawType) return;
// Resolve Self to the actual struct/enum name from the enclosing impl block
const typeName = rawType === 'Self' ? findEnclosingImplType(node) : rawType;
const varName = extractVarName(pattern);
if (varName && typeName) env.set(varName, typeName);
return;
}
// Unit struct instantiation: let svc = UserService; (bare identifier, no braces or call)
if (value.type === 'identifier' && classNames.has(value.text)) {
const varName = extractVarName(pattern);
if (varName) env.set(varName, value.text);
return;
}
if (value.type !== 'call_expression') return;
const func = value.childForFieldName('function');
if (!func || func.type !== 'scoped_identifier') return;
const nameField = func.childForFieldName('name');
// Only match ::new() and ::default() — the two idiomatic Rust constructors.
// Deliberately excludes ::from(), ::with_capacity(), etc. to avoid false positives
// (e.g. String::from("x") is not necessarily the "String" type we want for method resolution).
if (!nameField || (nameField.text !== 'new' && nameField.text !== 'default')) return;
const pathField = func.childForFieldName('path');
if (!pathField) return;
const rawType = extractSimpleTypeName(pathField);
if (!rawType) return;
// Resolve Self to the actual struct/enum name from the enclosing impl block
const typeName = rawType === 'Self' ? findEnclosingImplType(node) : rawType;
const varName = extractVarName(pattern);
if (varName && typeName) env.set(varName, typeName);
};
/** Rust: parameter → pattern: type */
const extractParameter: ParameterExtractor = (node: SyntaxNode, env: Map<string, string>): void => {
let nameNode: SyntaxNode | null = null;
@@ -35,8 +160,295 @@ const extractParameter: ParameterExtractor = (node: SyntaxNode, env: Map<string,
if (varName && typeName) env.set(varName, typeName);
};
/** Rust: let user = get_user("alice") — let_declaration with call_expression value, no type annotation.
* Skips `let user: User = ...` (explicit type annotation — handled by extractDeclaration).
* Skips `let user = User::new()` (scoped_identifier callee named "new" — handled by extractInitializer).
* Unwraps `let mut user = get_user()` by looking inside mut_pattern for the inner identifier.
*/
const scanConstructorBinding: ConstructorBindingScanner = (node) => {
if (node.type !== 'let_declaration') return undefined;
if (hasTypeAnnotation(node)) return undefined;
let patternNode = node.childForFieldName('pattern');
if (!patternNode) return undefined;
if (patternNode.type === 'mut_pattern') {
patternNode = patternNode.firstNamedChild;
if (!patternNode) return undefined;
}
if (patternNode.type !== 'identifier') return undefined;
// Unwrap `.await`: `let user = get_user().await` → await_expression wraps call_expression
const value = unwrapAwait(node.childForFieldName('value'));
if (!value || value.type !== 'call_expression') return undefined;
const func = value.childForFieldName('function');
if (!func) return undefined;
if (func.type === 'scoped_identifier') {
const methodName = func.lastNamedChild;
if (methodName?.text === 'new' || methodName?.text === 'default') return undefined;
}
const calleeName = extractSimpleTypeName(func);
if (!calleeName) return undefined;
return { varName: patternNode.text, calleeName };
};
/** Rust: let alias = u; → let_declaration with pattern + value fields */
const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) => {
if (node.type !== 'let_declaration') return undefined;
const pattern = node.childForFieldName('pattern');
const value = node.childForFieldName('value');
if (!pattern || !value) return undefined;
const lhs = extractVarName(pattern);
if (!lhs || scopeEnv.has(lhs)) return undefined;
// Unwrap Rust .await: `let user = get_user().await` → call_expression
const unwrapped = unwrapAwait(value) ?? value;
if (unwrapped.type === 'identifier') return { kind: 'copy', lhs, rhs: unwrapped.text };
// field_expression RHS → fieldAccess (a.field)
if (unwrapped.type === 'field_expression') {
const obj = unwrapped.firstNamedChild;
const field = unwrapped.lastNamedChild;
if (obj?.type === 'identifier' && field?.type === 'field_identifier') {
return { kind: 'fieldAccess', lhs, receiver: obj.text, field: field.text };
}
}
// call_expression RHS → callResult (simple calls only)
if (unwrapped.type === 'call_expression') {
const funcNode = unwrapped.childForFieldName('function');
if (funcNode?.type === 'identifier') {
return { kind: 'callResult', lhs, callee: funcNode.text };
}
}
// method_call_expression RHS → methodCallResult (receiver.method())
if (unwrapped.type === 'method_call_expression') {
const obj = unwrapped.firstNamedChild;
if (obj?.type === 'identifier') {
const methodNode = unwrapped.childForFieldName('name') ?? unwrapped.namedChild(1);
if (methodNode?.type === 'field_identifier') {
return { kind: 'methodCallResult', lhs, receiver: obj.text, method: methodNode.text };
}
}
}
return undefined;
};
/**
* Rust pattern binding extractor for `if let` / `while let` constructs that unwrap
* enum variants and introduce new typed variables.
*
* Supported patterns:
* - `if let Some(x) = opt` → x: T (opt: Option<T>, T already in scopeEnv via NULLABLE_WRAPPER_TYPES)
* - `if let Ok(x) = res` → x: T (res: Result<T, E>, T extracted from declarationTypeNodes)
*
* These complement the captured_pattern support in extractDeclaration (which handles
* `if let x @ Struct { .. } = expr` but NOT tuple struct unwrapping like Some(x) / Ok(x)).
*
* Conservative: returns undefined when:
* - The source variable's type is unknown (not in scopeEnv)
* - The wrapper is not a known single-unwrap variant (Some / Ok)
* - The value side is not a simple identifier
*/
const extractPatternBinding: PatternBindingExtractor = (
node,
scopeEnv,
declarationTypeNodes,
scope,
) => {
let patternNode: SyntaxNode | null = null;
let valueNode: SyntaxNode | null = null;
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)
if (patternNode.type !== 'tuple_struct_pattern') return undefined;
// Extract the wrapper type name: Some | Ok
const wrapperTypeNode = patternNode.childForFieldName('type');
if (!wrapperTypeNode) return undefined;
const wrapperName = extractSimpleTypeName(wrapperTypeNode);
if (wrapperName !== 'Some' && wrapperName !== 'Ok' && wrapperName !== 'Err') return undefined;
// Extract the inner variable name from the single child of the tuple_struct_pattern.
// `Some(x)` → the first named child after the type field is the identifier.
// tree-sitter-rust: tuple_struct_pattern has 'type' field + unnamed children for args.
let innerVar: string | undefined;
for (let i = 0; i < patternNode.namedChildCount; i++) {
const child = patternNode.namedChild(i);
if (!child) continue;
// Skip the type node itself
if (child === wrapperTypeNode) continue;
if (child.type === 'identifier') {
innerVar = child.text;
break;
}
}
if (!innerVar) return undefined;
// The value must be a simple identifier so we can look it up in scopeEnv
const sourceVarName = valueNode.type === 'identifier' ? valueNode.text : undefined;
if (!sourceVarName) return undefined;
// For `Some(x)`: Option<T> is already unwrapped to T in scopeEnv (via NULLABLE_WRAPPER_TYPES).
// For `Ok(x)`: Result<T, E> stores "Result" in scopeEnv — must use declarationTypeNodes.
if (wrapperName === 'Some') {
const innerType = scopeEnv.get(sourceVarName);
if (!innerType) return undefined;
return { varName: innerVar, typeName: innerType };
}
// wrapperName === 'Ok' or 'Err': look up the Result<T, E> type AST node.
// Ok(x) → extract T (typeArgs[0]), Err(e) → extract E (typeArgs[1]).
const typeNodeKey = `${scope}\0${sourceVarName}`;
const typeAstNode = declarationTypeNodes.get(typeNodeKey);
if (!typeAstNode) return undefined;
const typeArgs = extractGenericTypeArgs(typeAstNode);
const argIndex = wrapperName === 'Err' ? 1 : 0;
if (typeArgs.length < argIndex + 1) return undefined;
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,61 +1,288 @@
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 WRAPPER_GENERICS below for the full set used in return-type inference. */
const NULLABLE_WRAPPER_TYPES = new Set([
'Optional', // Java
'Option', // Rust, Scala
'Maybe', // Haskell-style, Kotlin Arrow
]);
/**
* Extract the simple type name from a type AST node.
* Handles generic types (e.g., List<User> → List), qualified names
* (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 => {
// Direct type identifier
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 === 'simple_identifier' || typeNode.type === 'constant') {
return typeNode.text;
}
// Qualified/scoped names: take the last segment (e.g., models.User → User)
// Qualified/scoped names: take the last segment (e.g., models.User → User, Models::User → User)
if (typeNode.type === 'scoped_identifier' || typeNode.type === 'qualified_identifier'
|| typeNode.type === 'scoped_type_identifier' || typeNode.type === 'qualified_name'
|| typeNode.type === 'qualified_type'
|| typeNode.type === 'member_expression' || typeNode.type === 'attribute') {
|| typeNode.type === 'member_expression' || typeNode.type === 'member_access_expression'
|| typeNode.type === 'attribute'
|| typeNode.type === 'scope_resolution'
|| typeNode.type === 'selector_expression') {
const last = typeNode.lastNamedChild;
if (last && (last.type === 'type_identifier' || last.type === 'identifier'
|| last.type === 'simple_identifier' || last.type === 'name')) {
|| last.type === 'simple_identifier' || last.type === 'name'
|| last.type === 'constant' || last.type === 'property_identifier'
|| last.type === 'field_identifier')) {
return last.text;
}
}
// 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)
if (typeNode.type === 'generic_type' || typeNode.type === 'parameterized_type') {
// For nullable wrappers (Optional<User>, Option<User>), unwrap to inner 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 extractSimpleTypeName(base);
if (!base) return undefined;
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);
if (args.length >= 1) return args[0];
}
return baseName;
}
// 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)
// Extract the single non-null/undefined type from the union.
if (typeNode.type === 'union_type') {
const nonNullTypes: SyntaxNode[] = [];
for (let i = 0; i < typeNode.namedChildCount; i++) {
const child = typeNode.namedChild(i);
if (!child) continue;
// Skip null/undefined/void literal types
const text = child.text;
if (text === 'null' || text === 'undefined' || text === 'void') continue;
nonNullTypes.push(child);
}
// Only unwrap if exactly one meaningful type remains
if (nonNullTypes.length === 1) {
return extractSimpleTypeName(nonNullTypes[0], depth + 1);
}
}
// Type annotations that wrap the actual type (TS/Python: `: Foo`, Kotlin: user_type)
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);
// Skip mutable_specifier for Rust &mut references — firstNamedChild would be
// `mutable_specifier` not the actual type. Walk named children to find the type.
for (let i = 0; i < typeNode.namedChildCount; i++) {
const child = typeNode.namedChild(i);
if (child && child.type !== 'mutable_specifier') {
return extractSimpleTypeName(child, depth + 1);
}
}
}
// 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)
@@ -72,7 +299,8 @@ 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 === 'variable_name' || node.type === 'name'
|| node.type === 'constant' || node.type === 'property_identifier') {
return node.text;
}
// variable_declarator (Java/C#): has a 'name' field
@@ -80,6 +308,11 @@ export const extractVarName = (node: SyntaxNode): string | undefined => {
const nameChild = node.childForFieldName('name');
if (nameChild) return extractVarName(nameChild);
}
// Rust: let mut x = ... — mut_pattern wraps an identifier
if (node.type === 'mut_pattern') {
const inner = node.firstNamedChild;
if (inner) return extractVarName(inner);
}
return undefined;
};
@@ -89,10 +322,177 @@ 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
]);
/**
* Extract type arguments from a generic type node.
* e.g., List<User, String> → ['User', 'String'], Vec<User> → ['User']
*
* Used by extractSimpleTypeName to unwrap nullable wrappers (Optional<User> → User).
*
* Handles language-specific AST structures:
* - TS/Java/Rust/Go: generic_type > type_arguments > type nodes
* - C#: generic_type > type_argument_list > type nodes
* - Kotlin: generic_type > type_arguments > type_projection > type nodes
*
* Note: Go slices/maps use slice_type/map_type, not generic_type — those are
* NOT handled here. Use language-specific extractors for Go container types.
*
* @param typeNode A generic_type or parameterized_type AST node (or any node —
* returns [] for non-generic types).
* @returns Array of resolved type argument names. Unresolvable arguments are omitted.
*/
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, depth + 1);
return [];
}
// 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 [];
}
// Find the type_arguments / type_argument_list child
let argsNode: SyntaxNode | null = null;
for (let i = 0; i < typeNode.namedChildCount; i++) {
const child = typeNode.namedChild(i);
if (child && (child.type === 'type_arguments' || child.type === 'type_argument_list')) {
argsNode = child;
break;
}
}
if (!argsNode) return [];
const result: string[] = [];
for (let i = 0; i < argsNode.namedChildCount; i++) {
let argNode = argsNode.namedChild(i);
if (!argNode) continue;
// Kotlin: type_arguments > type_projection > user_type > type_identifier
if (argNode.type === 'type_projection') {
argNode = argNode.firstNamedChild;
if (!argNode) continue;
}
const name = extractSimpleTypeName(argNode);
if (name) result.push(name);
}
return result;
};
/**
* Match Ruby constructor assignment: `user = User.new` or `service = Models::User.new`.
* Returns { varName, calleeName } or undefined if the node is not a Ruby constructor assignment.
* Handles both simple constants and scope_resolution (namespaced) receivers.
*/
export const extractRubyConstructorAssignment = (
node: SyntaxNode,
): { varName: string; calleeName: string } | undefined => {
if (node.type !== 'assignment') return undefined;
const left = node.childForFieldName('left');
const right = node.childForFieldName('right');
if (!left || !right) return undefined;
if (left.type !== 'identifier' && left.type !== 'constant') return undefined;
if (right.type !== 'call') return undefined;
const method = right.childForFieldName('method');
if (!method || method.text !== 'new') return undefined;
const receiver = right.childForFieldName('receiver');
if (!receiver) return undefined;
let calleeName: string;
if (receiver.type === 'constant') {
calleeName = receiver.text;
} else if (receiver.type === 'scope_resolution') {
// Models::User → extract last segment "User"
const last = receiver.lastNamedChild;
if (!last || last.type !== 'constant') return undefined;
calleeName = last.text;
} else {
return undefined;
}
return { varName: left.text, calleeName };
};
/**
* Check if an AST node has an explicit type annotation.
* Checks both named fields ('type') and child nodes ('type_annotation').
* Used by constructor binding scanners to skip annotated declarations.
*/
export const hasTypeAnnotation = (node: SyntaxNode): boolean => {
if (node.childForFieldName('type')) return true;
for (let i = 0; i < node.childCount; i++) {
if (node.child(i)?.type === 'type_annotation') return true;
}
return false;
};
/** Bare nullable keywords that should not produce a receiver binding. */
const NULLABLE_KEYWORDS = new Set(['null', 'undefined', 'void', 'None', 'nil']);
/**
* Strip nullable wrappers from a type name string.
* Used by both lookupInEnv (TypeEnv annotations) and extractReturnTypeName
* (return-type text) to normalize types before receiver lookup.
*
* "User | null" → "User"
* "User | undefined" → "User"
* "User | null | undefined" → "User"
* "User?" → "User"
* "User | Repo" → undefined (genuine union — refuse)
* "null" → undefined
*/
export const stripNullable = (typeName: string): string | undefined => {
let text = typeName.trim();
if (!text) return undefined;
if (NULLABLE_KEYWORDS.has(text)) return undefined;
// Strip nullable suffix: User? → User
if (text.endsWith('?')) text = text.slice(0, -1).trim();
// Strip union with null/undefined/None/nil/void
if (text.includes('|')) {
const parts = text.split('|').map(p => p.trim()).filter(p =>
p !== '' && !NULLABLE_KEYWORDS.has(p)
);
if (parts.length === 1) return parts[0];
return undefined; // genuine union or all-nullable — refuse
}
return text || undefined;
};
/**
* Unwrap an await_expression to get the inner value.
* Returns the node itself if not an await_expression, or null if input is null.
*/
export const unwrapAwait = (node: SyntaxNode | null): SyntaxNode | null => {
if (!node) return null;
return node.type === 'await_expression' ? node.firstNamedChild : node;
};
/**
* Extract the callee name from a call_expression node.
* Navigates to the 'function' field (or first named child) and extracts a simple type name.
*/
export const extractCalleeName = (callNode: SyntaxNode): string | undefined => {
const func = callNode.childForFieldName('function') ?? callNode.firstNamedChild;
if (!func) return undefined;
return extractSimpleTypeName(func);
};
/** Find the first named child with the given node type */
export const findChildByType = (node: SyntaxNode, type: string): SyntaxNode | null => {
for (let i = 0; i < node.namedChildCount; i++) {
@@ -101,3 +501,352 @@ 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;
};
// ── Property declared-type extraction ────────────────────────────────────
// Shared between parse-worker (worker path) and parsing-processor (sequential path).
/**
* Extract the declared type of a property/field from its AST definition node.
* Handles cross-language patterns:
* - TypeScript: `name: Type` → type_annotation child
* - Java: `Type name` → type child on field_declaration
* - C#: `Type Name { get; set; }` → type child on property_declaration
* - Go: `Name Type` → type child on field_declaration
* - Kotlin: `var name: Type` → variable_declaration child with type field
*
* Returns the normalized type name, or undefined if no type can be extracted.
*/
export const extractPropertyDeclaredType = (definitionNode: SyntaxNode | null): string | undefined => {
if (!definitionNode) return undefined;
// Strategy 1: Look for a `type` or `type_annotation` named field
const typeNode = definitionNode.childForFieldName?.('type');
if (typeNode) {
const typeName = extractSimpleTypeName(typeNode);
if (typeName) return typeName;
// Fallback: use the raw text (for complex types like User[] or List<User>)
const text = typeNode.text?.trim();
if (text && text.length < 100) return text;
}
// Strategy 2: Walk children looking for type_annotation (TypeScript pattern)
for (let i = 0; i < definitionNode.childCount; i++) {
const child = definitionNode.child(i);
if (!child) continue;
if (child.type === 'type_annotation') {
// Type annotation has the actual type as a child
for (let j = 0; j < child.childCount; j++) {
const typeChild = child.child(j);
if (typeChild && typeChild.type !== ':') {
const typeName = extractSimpleTypeName(typeChild);
if (typeName) return typeName;
const text = typeChild.text?.trim();
if (text && text.length < 100) return text;
}
}
}
}
// Strategy 3: For Java field_declaration, the type is a sibling of variable_declarator
// AST: (field_declaration type: (type_identifier) declarator: (variable_declarator ...))
const parentDecl = definitionNode.parent;
if (parentDecl) {
const parentType = parentDecl.childForFieldName?.('type');
if (parentType) {
const typeName = extractSimpleTypeName(parentType);
if (typeName) return typeName;
}
}
// Strategy 4: Kotlin property_declaration — type is nested inside variable_declaration child
// AST: (property_declaration (variable_declaration (simple_identifier) ":" (user_type (type_identifier))))
// Kotlin's variable_declaration has NO named 'type' field — children are all positional.
for (let i = 0; i < definitionNode.childCount; i++) {
const child = definitionNode.child(i);
if (child?.type === 'variable_declaration') {
// Try named field first (works for other languages sharing this strategy)
const varType = child.childForFieldName?.('type');
if (varType) {
const typeName = extractSimpleTypeName(varType);
if (typeName) return typeName;
const text = varType.text?.trim();
if (text && text.length < 100) return text;
}
// Fallback: walk unnamed children for user_type / type_identifier (Kotlin)
for (let j = 0; j < child.namedChildCount; j++) {
const varChild = child.namedChild(j);
if (varChild && (varChild.type === 'user_type' || varChild.type === 'type_identifier'
|| varChild.type === 'nullable_type' || varChild.type === 'generic_type')) {
const typeName = extractSimpleTypeName(varChild);
if (typeName) return typeName;
}
}
}
}
// Strategy 5: PHP @var PHPDoc — look for preceding comment with @var Type
// Handles pre-PHP-7.4 code: /** @var Address */ public $address;
const prevSibling = definitionNode.previousNamedSibling ?? definitionNode.parent?.previousNamedSibling;
if (prevSibling?.type === 'comment') {
const commentText = prevSibling.text;
const varMatch = commentText?.match(/@var\s+([A-Z][\w\\]*)/);
if (varMatch) {
// Strip namespace prefix: \App\Models\User → User
const raw = varMatch[1];
const base = raw.includes('\\') ? raw.split('\\').pop()! : raw;
if (base && /^[A-Z]\w*$/.test(base)) return base;
}
}
return undefined;
};

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