Compare commits

...
16 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
208 changed files with 8307 additions and 1547 deletions
-198
View File
@@ -1,198 +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 LadybugDB native C++ addon.
# 3 OS x 4 groups = 12 parallel jobs.
#
# Groups:
# lbug-db — 7 files using withTestLbugDB / lbug-adapter (native addon)
# Each file runs as its own `vitest run` invocation for full
# process isolation. LadybugDB'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 — 4 files: child-process only (spawnSync), no in-process lbug
# standalone — 4 files: pure logic, no lbug, 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: [lbug-db, pipeline, e2e, standalone]
include:
- test-group: lbug-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/integration/resolvers/ruby.test.ts
test/integration/resolvers/swift.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/integration/ignore-and-skip-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'
# lbug-db: run each file in its own vitest process for full isolation.
# LadybugDB's native addon hangs fork workers on Linux — process isolation
# is the only reliable fix boundary.
- name: Run integration tests — lbug-db (process-isolated)
if: matrix.test-group == 'lbug-db'
working-directory: gitnexus
shell: bash
run: |
set -e
files=(
test/integration/lbug-core-adapter.test.ts
test/integration/lbug-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-lbug groups: run all files in a single vitest invocation
- name: Run integration tests — ${{ matrix.test-group }}
if: matrix.test-group != 'lbug-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-lbug integration tests with coverage enabled so the PR report
# can merge integration + unit coverage for a combined view.
# lbug-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/ignore-and-skip-e2e.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/integration/resolvers/ruby.test.ts
test/integration/resolvers/swift.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
-522
View File
@@ -1,522 +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
# ── Fetch base branch coverage for delta reporting ───────────
- name: Fetch base branch coverage
if: steps.meta.outputs.skip != 'true'
id: base-coverage
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
with:
script: |
const fs = require('fs');
const path = require('path');
// Find the latest successful CI run on main
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');
core.info('No successful main branch CI runs found');
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');
core.info('No test-reports artifact on main branch');
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.meta.outputs.skip != 'true' && steps.base-coverage.outputs.found == 'true'
shell: bash
run: |
cd "${{ steps.base-coverage.outputs.dir }}"
unzip -o base.zip -d base
# ── 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 }}
BASE_FOUND: ${{ steps.base-coverage.outputs.found }}
BASE_DIR: ${{ steps.base-coverage.outputs.dir }}
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 ──
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 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"
# ── Read base branch coverage (main) ──
BASE_SUMMARY=""
if [ "$BASE_FOUND" = "true" ] && [ -n "$BASE_DIR" ]; then
BASE_SUMMARY=$(find "$BASE_DIR/base" -name "coverage-summary.json" -type f 2>/dev/null | head -1)
fi
read_cov "B" "$BASE_SUMMARY"
# ── Locate test results ──
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))
# ── Status helpers ──
status_icon() {
case "$1" in
success) echo "✅" ;;
failure) echo "❌" ;;
cancelled) echo "⏭️" ;;
*) echo "❓" ;;
esac
}
cov_delta() {
local pct=$1 base=$2
if [ "$pct" = "N/A" ] || [ "$base" = "N/A" ]; then echo "—"; return; fi
local diff
diff=$(awk "BEGIN { printf \"%.1f\", $pct - $base }")
if [ "$(awk "BEGIN { print ($pct > $base) ? 1 : 0 }")" = "1" ]; then
echo "📈 +${diff}"
elif [ "$(awk "BEGIN { print ($pct < $base) ? 1 : 0 }")" = "1" ]; then
echo "📉 ${diff}"
else
echo "= ${diff}"
fi
}
cov_bar() {
local pct=$1 base=$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
# Green if >= base (or base unavailable), red if dropped
if [ "$base" = "N/A" ] || [ "$(awk "BEGIN { print ($pct >= $base) ? 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 ""
echo "| Suite | Tests | Passed | Failed | Skipped | Duration |"
echo "|-------|-------|--------|--------|---------|----------|"
if [ "$U_TOTAL" -gt 0 ] 2>/dev/null; then
echo "| Unit | ${U_TOTAL} | ${U_PASSED} | ${U_FAILED} | ${U_SKIPPED} | ${U_DURATION}s |"
fi
if [ "$I_TOTAL" -gt 0 ] 2>/dev/null; then
echo "| Integration | ${I_TOTAL} | ${I_PASSED} | ${I_FAILED} | ${I_SKIPPED} | ${I_DURATION}s |"
fi
echo "| **Total** | **${TOTAL}** | **${PASSED}** | **${FAILED}** | **${SKIPPED}** | **$((U_DURATION + I_DURATION))s** |"
echo ""
if [ "$FAILED" = "0" ]; then
echo "✅ All **${PASSED}** tests passed"
else
echo "❌ **${FAILED}** failed / **${PASSED}** passed"
fi
if [ "$SKIPPED" != "0" ]; then
echo ""
echo "<details>"
echo "<summary>${SKIPPED} test(s) skipped — expand for details</summary>"
echo ""
# Extract skipped test names from integration results
if [ -n "$INTEG_RESULTS" ] && [ "$I_SKIPPED" -gt 0 ] 2>/dev/null; then
echo "**Integration:**"
jq -r '
.testResults[]
| .assertionResults[]?
| select(.status == "pending" or .status == "skipped")
| "- \(.ancestorTitles | join(" > ")) > \(.title)"
' "$INTEG_RESULTS" 2>/dev/null || echo "- _(unable to parse skipped test details)_"
fi
# Extract skipped test names from unit results
if [ -n "$RESULTS_FILE" ] && [ "$U_SKIPPED" -gt 0 ] 2>/dev/null; then
echo ""
echo "**Unit:**"
jq -r '
.testResults[]
| .assertionResults[]?
| select(.status == "pending" or .status == "skipped")
| "- \(.ancestorTitles | join(" > ")) > \(.title)"
' "$RESULTS_FILE" 2>/dev/null || echo "- _(unable to parse skipped test details)_"
fi
echo ""
echo "</details>"
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 bs=$1 bb=$2 bf=$3 bl=$4
echo "#### ${label}"
echo ""
echo "| Metric | Coverage | Covered | Base | Delta | Status |"
echo "|--------|----------|---------|------|-------|--------|"
echo "| Statements | **${s}%** | ${sc} | ${bs}% | $(cov_delta "$s" "$bs") | $(cov_bar "$s" "$bs") |"
echo "| Branches | **${b}%** | ${bc} | ${bb}% | $(cov_delta "$b" "$bb") | $(cov_bar "$b" "$bb") |"
echo "| Functions | **${f}%** | ${fc} | ${bf}% | $(cov_delta "$f" "$bf") | $(cov_bar "$f" "$bf") |"
echo "| Lines | **${l}%** | ${lc} | ${bl}% | $(cov_delta "$l" "$bl") | $(cov_bar "$l" "$bl") |"
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" \
"$B_STMTS" "$B_BRANCH" "$B_FUNCS" "$B_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" \
"$B_STMTS" "$B_BRANCH" "$B_FUNCS" "$B_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" \
"$B_STMTS" "$B_BRANCH" "$B_FUNCS" "$B_LINES"
fi
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" \
"$B_STMTS" "$B_BRANCH" "$B_FUNCS" "$B_LINES"
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,
});
}
+1
View File
@@ -12,6 +12,7 @@ jobs:
uses: ./.github/workflows/ci.yml
permissions:
contents: read
actions: read
pull-requests: write
publish:
+6 -6
View File
@@ -1,7 +1,7 @@
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **feat-phase7-type-resolution** (2075 symbols, 4935 relationships, 157 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
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.
@@ -17,7 +17,7 @@ This project is indexed by GitNexus as **feat-phase7-type-resolution** (2075 sym
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
3. `READ gitnexus://repo/feat-phase7-type-resolution/process/{processName}` — trace the full execution flow step by step
3. `READ gitnexus://repo/GitNexus/process/{processName}` — trace the full execution flow step by step
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
## When Refactoring
@@ -56,10 +56,10 @@ This project is indexed by GitNexus as **feat-phase7-type-resolution** (2075 sym
| Resource | Use for |
|----------|---------|
| `gitnexus://repo/feat-phase7-type-resolution/context` | Codebase overview, check index freshness |
| `gitnexus://repo/feat-phase7-type-resolution/clusters` | All functional areas |
| `gitnexus://repo/feat-phase7-type-resolution/processes` | All execution flows |
| `gitnexus://repo/feat-phase7-type-resolution/process/{name}` | Step-by-step execution trace |
| `gitnexus://repo/GitNexus/context` | Codebase overview, check index freshness |
| `gitnexus://repo/GitNexus/clusters` | All functional areas |
| `gitnexus://repo/GitNexus/processes` | All execution flows |
| `gitnexus://repo/GitNexus/process/{name}` | Step-by-step execution trace |
## Self-Check Before Finishing
+6 -6
View File
@@ -1,7 +1,7 @@
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **feat-phase7-type-resolution** (2075 symbols, 4935 relationships, 157 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
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.
@@ -17,7 +17,7 @@ This project is indexed by GitNexus as **feat-phase7-type-resolution** (2075 sym
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
3. `READ gitnexus://repo/feat-phase7-type-resolution/process/{processName}` — trace the full execution flow step by step
3. `READ gitnexus://repo/GitNexus/process/{processName}` — trace the full execution flow step by step
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
## When Refactoring
@@ -56,10 +56,10 @@ This project is indexed by GitNexus as **feat-phase7-type-resolution** (2075 sym
| Resource | Use for |
|----------|---------|
| `gitnexus://repo/feat-phase7-type-resolution/context` | Codebase overview, check index freshness |
| `gitnexus://repo/feat-phase7-type-resolution/clusters` | All functional areas |
| `gitnexus://repo/feat-phase7-type-resolution/processes` | All execution flows |
| `gitnexus://repo/feat-phase7-type-resolution/process/{name}` | Step-by-step execution trace |
| `gitnexus://repo/GitNexus/context` | Codebase overview, check index freshness |
| `gitnexus://repo/GitNexus/clusters` | All functional areas |
| `gitnexus://repo/GitNexus/processes` | All execution flows |
| `gitnexus://repo/GitNexus/process/{name}` | Step-by-step execution trace |
## Self-Check Before Finishing
+1 -1
View File
@@ -33,7 +33,7 @@
"graphology-layout-noverlap": "^0.4.2",
"isomorphic-git": "^1.36.1",
"jszip": "^3.10.1",
"@ladybugdb/wasm-core": "^0.15.1",
"@ladybugdb/wasm-core": "^0.15.2",
"langchain": "^1.2.10",
"lru-cache": "^11.2.4",
"lucide-react": "^0.562.0",
@@ -10,5 +10,6 @@ export enum SupportedLanguages {
Rust = 'rust',
PHP = 'php',
Ruby = 'ruby',
Kotlin = 'kotlin',
Swift = 'swift',
}
@@ -27,7 +27,7 @@ export type CallRouter = (
const noRouting: CallRouter = () => null;
/** Per-language call routing. noRouting = no special routing (normal call processing) */
export const callRouters: Record<SupportedLanguages, CallRouter> = {
export const callRouters = {
[SupportedLanguages.JavaScript]: noRouting,
[SupportedLanguages.TypeScript]: noRouting,
[SupportedLanguages.Python]: noRouting,
@@ -40,7 +40,8 @@ export const callRouters: Record<SupportedLanguages, CallRouter> = {
[SupportedLanguages.CPlusPlus]: noRouting,
[SupportedLanguages.C]: noRouting,
[SupportedLanguages.Ruby]: routeRubyCall,
};
[SupportedLanguages.Kotlin]: noRouting,
} satisfies Record<SupportedLanguages, CallRouter>;
// ── Result types ────────────────────────────────────────────────────────────
@@ -495,6 +495,7 @@ export const LANGUAGE_QUERIES: Record<SupportedLanguages, string> = {
[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,
};
@@ -41,6 +41,7 @@ const getWasmPath = (language: SupportedLanguages, filePath?: string): string =>
[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',
};
+27
View File
@@ -2,6 +2,33 @@
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
+64 -7
View File
@@ -1,17 +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.1",
"@ladybugdb/core": "^0.15.2",
"@modelcontextprotocol/sdk": "^1.0.0",
"cli-progress": "^3.12.0",
"commander": "^12.0.0",
@@ -36,7 +36,6 @@
"tree-sitter-python": "^0.21.0",
"tree-sitter-ruby": "^0.23.1",
"tree-sitter-rust": "^0.21.0",
"tree-sitter-swift": "^0.6.0",
"tree-sitter-typescript": "^0.21.0",
"uuid": "^13.0.0"
},
@@ -1151,16 +1150,74 @@
}
},
"node_modules/@ladybugdb/core": {
"version": "0.15.1",
"resolved": "https://registry.npmjs.org/@ladybugdb/core/-/core-0.15.1.tgz",
"integrity": "sha512-a+jhzIlS2+57Y2YWXlta7Dq5A3577dQ8YO7DzPCFZxozeiGIZn0K9v0ROO+ws4PW9BwuQYI5BXQxTEtaa1Otlg==",
"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",
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "gitnexus",
"version": "1.4.6",
"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,9 +39,9 @@
"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",
@@ -59,7 +59,7 @@
"graphology": "^0.25.4",
"graphology-indices": "^0.17.0",
"graphology-utils": "^2.3.0",
"@ladybugdb/core": "^0.15.1",
"@ladybugdb/core": "^0.15.2",
"ignore": "^7.0.5",
"lru-cache": "^11.0.0",
"mnemonist": "^0.39.0",
+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,
+365 -124
View File
@@ -20,15 +20,25 @@ import {
extractReceiverNode,
findEnclosingClassId,
CALL_EXPRESSION_TYPES,
MAX_CHAIN_DEPTH,
extractCallChain,
extractMixedChain,
type MixedChainStep,
} from './utils.js';
import { buildTypeEnv } from './type-env.js';
import type { ConstructorBinding } from './type-env.js';
import { getTreeSitterBufferSize } from './constants.js';
import type { ExtractedCall, ExtractedHeritage, ExtractedRoute, FileConstructorBindings } from './workers/parse-worker.js';
import type { ExtractedCall, ExtractedAssignment, ExtractedHeritage, ExtractedRoute, FileConstructorBindings } from './workers/parse-worker.js';
import { callRouters } from './call-routing.js';
import { extractReturnTypeName } from './type-extractors/shared.js';
import { extractReturnTypeName, stripNullable } from './type-extractors/shared.js';
// Stdlib methods that preserve the receiver's type identity. When TypeEnv already
// strips nullable wrappers (Option<User> → User), these chain steps are no-ops
// for type resolution — the current type passes through unchanged.
const TYPE_PRESERVING_METHODS = new Set([
'unwrap', 'expect', 'unwrap_or', 'unwrap_or_default', 'unwrap_or_else', // Rust Option/Result
'clone', 'to_owned', 'as_ref', 'as_mut', 'borrow', 'borrow_mut', // Rust clone/borrow
'get', // Kotlin/Java Optional.get()
'orElseThrow', // Java Optional
]);
/**
* Walk up the AST from a node to find the enclosing function/method.
@@ -128,6 +138,7 @@ export const processCalls = async (
): Promise<ExtractedHeritage[]> => {
const parser = await loadParser();
const collectedHeritage: ExtractedHeritage[] = [];
const pendingWrites: { receiverTypeName: string; propertyName: string; filePath: string; srcId: string }[] = [];
const logSkipped = isVerboseIngestionEnabled();
const skippedByLang = logSkipped ? new Map<string, number>() : null;
@@ -178,12 +189,50 @@ export const processCalls = async (
const verifiedReceivers = typeEnv && typeEnv.constructorBindings.length > 0
? verifyConstructorBindings(typeEnv.constructorBindings, file.path, ctx)
: new Map<string, string>();
const receiverIndex = buildReceiverTypeIndex(verifiedReceivers);
ctx.enableCache(file.path);
matches.forEach(match => {
const captureMap: Record<string, any> = {};
match.captures.forEach(c => captureMap[c.name] = c.node);
// ── Write access: emit ACCESSES {reason: 'write'} for assignments to member fields ──
if (captureMap['assignment'] && captureMap['assignment.receiver'] && captureMap['assignment.property']) {
const receiverNode = captureMap['assignment.receiver'];
const propertyName: string = captureMap['assignment.property'].text;
// Resolve receiver type: simple identifier → TypeEnv lookup or class resolution
let receiverTypeName: string | undefined;
const receiverText = receiverNode.text;
if (receiverText && typeEnv) {
receiverTypeName = typeEnv.lookup(receiverText, captureMap['assignment']);
}
// Fall back to verified constructor bindings (mirrors CALLS resolution tier 2)
if (!receiverTypeName && receiverText && receiverIndex.size > 0) {
const enclosing = findEnclosingFunction(captureMap['assignment'], file.path, ctx);
const funcName = enclosing ? extractFuncNameFromSourceId(enclosing) : '';
receiverTypeName = lookupReceiverType(receiverIndex, funcName, receiverText);
}
if (!receiverTypeName && receiverText) {
const resolved = ctx.resolve(receiverText, file.path);
if (resolved?.candidates.some(d =>
d.type === 'Class' || d.type === 'Struct' || d.type === 'Interface'
|| d.type === 'Enum' || d.type === 'Record' || d.type === 'Impl',
)) {
receiverTypeName = receiverText;
}
}
if (receiverTypeName) {
const enclosing = findEnclosingFunction(captureMap['assignment'], file.path, ctx);
const srcId = enclosing || generateId('File', file.path);
// Defer resolution: Ruby attr_accessor properties are registered during
// this same loop, so cross-file lookups fail if the declaring file hasn't
// been processed yet. Collect now, resolve after all files are done.
pendingWrites.push({ receiverTypeName, propertyName, filePath: file.path, srcId });
}
// Assignment-only capture (no @call sibling): skip the rest of this
// forEach iteration — this acts as a `continue` in the match loop.
if (!captureMap['call']) return;
}
if (!captureMap['call']) return;
@@ -225,8 +274,10 @@ export const processCalls = async (
description: item.accessorType,
},
});
ctx.symbols.add(file.path, item.propName, nodeId, 'Property',
propEnclosingClassId ? { ownerId: propEnclosingClassId } : undefined);
ctx.symbols.add(file.path, item.propName, nodeId, 'Property', {
...(propEnclosingClassId ? { ownerId: propEnclosingClassId } : {}),
...(item.declaredType ? { declaredType: item.declaredType } : {}),
});
const relId = generateId('DEFINES', `${fileId}->${nodeId}`);
graph.addRelationship({
id: relId, sourceId: fileId, targetId: nodeId,
@@ -234,9 +285,9 @@ export const processCalls = async (
});
if (propEnclosingClassId) {
graph.addRelationship({
id: generateId('HAS_METHOD', `${propEnclosingClassId}->${nodeId}`),
id: generateId('HAS_PROPERTY', `${propEnclosingClassId}->${nodeId}`),
sourceId: propEnclosingClassId, targetId: nodeId,
type: 'HAS_METHOD', confidence: 1.0, reason: '',
type: 'HAS_PROPERTY', confidence: 1.0, reason: '',
});
}
}
@@ -255,10 +306,10 @@ export const processCalls = async (
const receiverName = callForm === 'member' ? extractReceiverName(nameNode) : undefined;
let receiverTypeName = receiverName && typeEnv ? typeEnv.lookup(receiverName, callNode) : undefined;
// Fall back to verified constructor bindings for return type inference
if (!receiverTypeName && receiverName && verifiedReceivers.size > 0) {
if (!receiverTypeName && receiverName && receiverIndex.size > 0) {
const enclosingFunc = findEnclosingFunction(callNode, file.path, ctx);
const funcName = enclosingFunc ? extractFuncNameFromSourceId(enclosingFunc) : '';
receiverTypeName = lookupReceiverType(verifiedReceivers, funcName, receiverName);
receiverTypeName = lookupReceiverType(receiverIndex, funcName, receiverName);
}
// Fall back to class-as-receiver for static method calls (e.g. UserService.find_user()).
// When the receiver name is not a variable in TypeEnv but resolves to a Class/Struct/Interface
@@ -271,32 +322,39 @@ export const processCalls = async (
receiverTypeName = receiverName;
}
}
// Fall back to chained call resolution when the receiver is a call expression
// (e.g. svc.getUser().save() — receiver of save() is getUser(), not a simple identifier).
// Hoist sourceId so it's available for ACCESSES edge emission during chain walk.
const enclosingFuncId = findEnclosingFunction(callNode, file.path, ctx);
const sourceId = enclosingFuncId || generateId('File', file.path);
// Fall back to mixed chain resolution when the receiver is a complex expression
// (field chain, call chain, or interleaved — e.g. user.address.city.save() or
// svc.getUser().address.save()). Handles all cases with a single unified walk.
if (callForm === 'member' && !receiverTypeName && !receiverName) {
const receiverNode = extractReceiverNode(nameNode);
if (receiverNode && CALL_EXPRESSION_TYPES.has(receiverNode.type)) {
const extracted = extractCallChain(receiverNode);
if (extracted) {
// Resolve the base receiver type if possible
let baseType = extracted.baseReceiverName && typeEnv
if (receiverNode) {
const extracted = extractMixedChain(receiverNode);
if (extracted && extracted.chain.length > 0) {
let currentType = extracted.baseReceiverName && typeEnv
? typeEnv.lookup(extracted.baseReceiverName, callNode)
: undefined;
if (!baseType && extracted.baseReceiverName && verifiedReceivers.size > 0) {
const enclosingFunc = findEnclosingFunction(callNode, file.path, ctx);
const funcName = enclosingFunc ? extractFuncNameFromSourceId(enclosingFunc) : '';
baseType = lookupReceiverType(verifiedReceivers, funcName, extracted.baseReceiverName);
if (!currentType && extracted.baseReceiverName && receiverIndex.size > 0) {
const funcName = enclosingFuncId ? extractFuncNameFromSourceId(enclosingFuncId) : '';
currentType = lookupReceiverType(receiverIndex, funcName, extracted.baseReceiverName);
}
// Class-as-receiver for chain base (e.g. UserService.find_user().save())
if (!baseType && extracted.baseReceiverName) {
if (!currentType && extracted.baseReceiverName) {
const cr = ctx.resolve(extracted.baseReceiverName, file.path);
if (cr?.candidates.some(d =>
d.type === 'Class' || d.type === 'Interface' || d.type === 'Struct' || d.type === 'Enum',
)) {
baseType = extracted.baseReceiverName;
currentType = extracted.baseReceiverName;
}
}
receiverTypeName = resolveChainedReceiver(extracted.chain, baseType, file.path, ctx);
if (currentType) {
receiverTypeName = walkMixedChain(
extracted.chain, currentType, file.path, ctx,
makeAccessEmitter(graph, sourceId),
);
}
}
}
}
@@ -309,9 +367,6 @@ export const processCalls = async (
}, file.path, ctx);
if (!resolved) return;
const enclosingFuncId = findEnclosingFunction(callNode, file.path, ctx);
const sourceId = enclosingFuncId || generateId('File', file.path);
const relId = generateId('CALLS', `${sourceId}:${calledName}->${resolved.nodeId}`);
graph.addRelationship({
@@ -327,6 +382,22 @@ export const processCalls = async (
ctx.clearCache();
}
// ── Resolve deferred write-access edges ──
// All properties (including Ruby attr_accessor) are now registered.
for (const pw of pendingWrites) {
const fieldOwner = resolveFieldOwnership(pw.receiverTypeName, pw.propertyName, pw.filePath, ctx);
if (fieldOwner) {
graph.addRelationship({
id: generateId('ACCESSES', `${pw.srcId}:${fieldOwner.nodeId}:write`),
sourceId: pw.srcId,
targetId: fieldOwner.nodeId,
type: 'ACCESSES',
confidence: 1.0,
reason: 'write',
});
}
}
if (skippedByLang && skippedByLang.size > 0) {
for (const [lang, count] of skippedByLang.entries()) {
console.warn(
@@ -345,6 +416,7 @@ interface ResolveResult {
nodeId: string;
confidence: number;
reason: string;
returnType?: string;
}
const CALLABLE_SYMBOL_TYPES = new Set([
@@ -394,48 +466,9 @@ const toResolveResult = (
nodeId: definition.nodeId,
confidence: TIER_CONFIDENCE[tier],
reason: tier === 'same-file' ? 'same-file' : tier === 'import-scoped' ? 'import-resolved' : 'global',
returnType: definition.returnType,
});
/**
* Resolve a chain of intermediate method calls to find the receiver type for a
* final member call. Called when the receiver of a call is itself a call
* expression (e.g. `svc.getUser().save()`).
*
* @param chainNames Ordered list of method names from outermost to innermost
* intermediate call (e.g. ['getUser'] for `svc.getUser().save()`).
* @param baseReceiverTypeName The already-resolved type of the base receiver
* (e.g. 'UserService' for `svc`), or undefined.
* @param currentFile The file path for resolution context.
* @param ctx The resolution context for symbol lookup.
* @returns The type name of the final intermediate call's return type, or undefined
* if resolution fails at any step.
*/
function resolveChainedReceiver(
chainNames: string[],
baseReceiverTypeName: string | undefined,
currentFile: string,
ctx: ResolutionContext,
): string | undefined {
let currentType = baseReceiverTypeName;
for (const name of chainNames) {
const resolved = resolveCallTarget(
{ calledName: name, callForm: 'member', receiverTypeName: currentType },
currentFile,
ctx,
);
if (!resolved) return undefined;
const candidates = ctx.symbols.lookupFuzzy(name);
const symDef = candidates.find(c => c.nodeId === resolved.nodeId);
if (!symDef?.returnType) return undefined;
const returnTypeName = extractReturnTypeName(symDef.returnType);
if (!returnTypeName) return undefined;
currentType = returnTypeName;
}
return currentType;
}
/**
* Resolve a function call to its target node ID using priority strategy:
@@ -529,48 +562,196 @@ const receiverKey = (scope: string, varName: string): string =>
`${scope}\0${varName}`;
/**
* Look up a receiver type from a verified receiver map.
* The map is keyed by `scope\0varName` (full scope with @startIndex).
* Since the lookup side only has `funcName` (no startIndex), we scan for
* all entries whose key starts with `funcName@` and has the matching varName.
* If exactly one unique type is found, return it. If multiple distinct types
* exist (true overload collision), return undefined (refuse to guess).
* Falls back to the file-level scope key `\0varName` (empty funcName).
* Pre-built secondary index for O(1) receiver type lookups.
* Built once per file from the verified receiver map, keyed by funcName → varName.
*/
type ReceiverTypeEntry =
| { readonly kind: 'resolved'; readonly value: string }
| { readonly kind: 'ambiguous' };
type ReceiverTypeIndex = Map<string, Map<string, ReceiverTypeEntry>>;
/**
* Build a two-level secondary index from the verified receiver map.
* The verified map is keyed by `scope\0varName` where scope is either
* "funcName@startIndex" (inside a function) or "" (file level).
* Index structure: Map<funcName, Map<varName, ReceiverTypeEntry>>
*/
const buildReceiverTypeIndex = (map: Map<string, string>): ReceiverTypeIndex => {
const index: ReceiverTypeIndex = new Map();
for (const [key, typeName] of map) {
const nul = key.indexOf('\0');
if (nul < 0) continue;
const scope = key.slice(0, nul);
const varName = key.slice(nul + 1);
if (!varName) continue;
if (scope !== '' && !scope.includes('@')) continue;
const funcName = scope === '' ? '' : scope.slice(0, scope.indexOf('@'));
let varMap = index.get(funcName);
if (!varMap) { varMap = new Map(); index.set(funcName, varMap); }
const existing = varMap.get(varName);
if (existing === undefined) {
varMap.set(varName, { kind: 'resolved', value: typeName });
} else if (existing.kind === 'resolved' && existing.value !== typeName) {
varMap.set(varName, { kind: 'ambiguous' });
}
}
return index;
};
/**
* O(1) receiver type lookup using the pre-built secondary index.
* Returns the unique type name if unambiguous. Falls back to file-level scope.
*/
const lookupReceiverType = (
map: Map<string, string>,
index: ReceiverTypeIndex,
funcName: string,
varName: string,
): string | undefined => {
// Fast path: file-level scope (empty funcName — used as fallback)
const fileLevelKey = receiverKey('', varName);
const prefix = `${funcName}@`;
const suffix = `\0${varName}`;
let found: string | undefined;
let ambiguous = false;
for (const [key, value] of map) {
if (key === fileLevelKey) continue; // handled separately below
if (key.startsWith(prefix) && key.endsWith(suffix)) {
// Verify the key is exactly "funcName@<digits>\0varName" with no extra chars.
// The part between prefix and suffix should be the startIndex (digits only),
// but we accept any non-empty segment to be forward-compatible.
const middle = key.slice(prefix.length, key.length - suffix.length);
if (middle.length === 0) continue; // malformed key — skip
if (found === undefined) {
found = value;
} else if (found !== value) {
ambiguous = true;
break;
}
const funcBucket = index.get(funcName);
if (funcBucket) {
const entry = funcBucket.get(varName);
if (entry?.kind === 'resolved') return entry.value;
if (entry?.kind === 'ambiguous') {
// Ambiguous in this function scope — try file-level fallback
const fileEntry = index.get('')?.get(varName);
return fileEntry?.kind === 'resolved' ? fileEntry.value : undefined;
}
}
// Fallback: file-level scope (funcName "")
if (funcName !== '') {
const fileEntry = index.get('')?.get(varName);
if (fileEntry?.kind === 'resolved') return fileEntry.value;
}
return undefined;
};
if (!ambiguous && found !== undefined) return found;
interface FieldResolution {
typeName: string; // resolved declared type (continues chain threading)
fieldNodeId: string; // nodeId of the Property symbol (for ACCESSES edge target)
}
// Fallback: file-level scope (bindings outside any function)
return map.get(fileLevelKey);
/**
* Resolve the type that results from accessing `receiverName.fieldName`.
* Requires declaredType on the Property node (needed for chain walking continuation).
*/
const resolveFieldAccessType = (
receiverName: string,
fieldName: string,
filePath: string,
ctx: ResolutionContext,
): FieldResolution | undefined => {
const fieldDef = resolveFieldOwnership(receiverName, fieldName, filePath, ctx);
if (!fieldDef?.declaredType) return undefined;
// Use stripNullable (not extractReturnTypeName) — field types like List<User>
// should be preserved as-is, not unwrapped to User. Only strip nullable wrappers.
return {
typeName: stripNullable(fieldDef.declaredType),
fieldNodeId: fieldDef.nodeId,
};
};
/**
* Resolve a field's Property node given a receiver type name and field name.
* Does NOT require declaredType — used by write-access tracking where only the
* fieldNodeId is needed (no chain continuation).
*/
const resolveFieldOwnership = (
receiverName: string,
fieldName: string,
filePath: string,
ctx: ResolutionContext,
): { nodeId: string; declaredType?: string } | undefined => {
const typeResolved = ctx.resolve(receiverName, filePath);
if (!typeResolved) return undefined;
const classDef = typeResolved.candidates.find(
d => d.type === 'Class' || d.type === 'Struct' || d.type === 'Interface'
|| d.type === 'Enum' || d.type === 'Record' || d.type === 'Impl',
);
if (!classDef) return undefined;
return ctx.symbols.lookupFieldByOwner(classDef.nodeId, fieldName) ?? undefined;
};
/**
* Create a deduplicated ACCESSES edge emitter for a single source node.
* Each (sourceId, fieldNodeId) pair is emitted at most once per source.
*/
const makeAccessEmitter = (
graph: KnowledgeGraph,
sourceId: string,
): OnFieldResolved => {
const emitted = new Set<string>();
return (fieldNodeId: string): void => {
const key = `${sourceId}\0${fieldNodeId}`;
if (emitted.has(key)) return;
emitted.add(key);
graph.addRelationship({
id: generateId('ACCESSES', `${sourceId}:${fieldNodeId}:read`),
sourceId,
targetId: fieldNodeId,
type: 'ACCESSES',
confidence: 1.0,
reason: 'read',
});
};
};
/**
* Walk a pre-built mixed chain of field/call steps, threading the current type
* through each step and returning the final resolved type.
*
* Returns `undefined` if any step cannot be resolved (chain is broken).
* The caller is responsible for seeding `startType` from its own context
* (TypeEnv, constructor bindings, or static-class fallback).
*/
type OnFieldResolved = (fieldNodeId: string) => void;
const walkMixedChain = (
chain: MixedChainStep[],
startType: string,
filePath: string,
ctx: ResolutionContext,
onFieldResolved?: OnFieldResolved,
): string | undefined => {
let currentType: string | undefined = startType;
for (const step of chain) {
if (!currentType) break;
if (step.kind === 'field') {
const resolved = resolveFieldAccessType(currentType, step.name, filePath, ctx);
if (!resolved) { currentType = undefined; break; }
onFieldResolved?.(resolved.fieldNodeId);
currentType = resolved.typeName;
} else {
// Ruby/Python: property access is syntactically identical to method calls.
// Try field resolution first — if the name is a known property with declaredType,
// use that type directly. Otherwise fall back to method call resolution.
const fieldResolved = resolveFieldAccessType(currentType, step.name, filePath, ctx);
if (fieldResolved) {
onFieldResolved?.(fieldResolved.fieldNodeId);
currentType = fieldResolved.typeName;
continue;
}
const resolved = resolveCallTarget(
{ calledName: step.name, callForm: 'member', receiverTypeName: currentType },
filePath,
ctx,
);
if (!resolved) {
// Stdlib passthrough: unwrap(), clone(), etc. preserve the receiver type
if (TYPE_PRESERVING_METHODS.has(step.name)) continue;
currentType = undefined; break;
}
if (!resolved.returnType) { currentType = undefined; break; }
const retType = extractReturnTypeName(resolved.returnType);
if (!retType) { currentType = undefined; break; }
currentType = retType;
}
}
return currentType;
};
/**
@@ -587,12 +768,12 @@ export const processCallsFromExtracted = async (
// Scope-aware receiver types: keyed by filePath → "funcName\0varName" → typeName.
// The scope dimension prevents collisions when two functions in the same file
// have same-named locals pointing to different constructor types.
const fileReceiverTypes = new Map<string, Map<string, string>>();
const fileReceiverTypes = new Map<string, ReceiverTypeIndex>();
if (constructorBindings) {
for (const { filePath, bindings } of constructorBindings) {
const verified = verifyConstructorBindings(bindings, filePath, ctx, graph);
if (verified.size > 0) {
fileReceiverTypes.set(filePath, verified);
fileReceiverTypes.set(filePath, buildReceiverTypeIndex(verified));
}
}
}
@@ -638,29 +819,32 @@ export const processCallsFromExtracted = async (
}
}
// Step 2: if the call has a receiver call chain (e.g. svc.getUser().save()),
// resolve the chain to determine the final receiver type.
// This runs whenever receiverCallChain is present — even when Step 1 set a
// receiverTypeName, that type is the BASE receiver (e.g. UserService for svc),
// and the chain must be walked to produce the FINAL receiver (e.g. User from
// getUser() : User).
if (effectiveCall.receiverCallChain?.length) {
// Step 1 may have resolved the base receiver type (e.g. svc → UserService).
// Use it as the starting point for chain resolution.
let baseType = effectiveCall.receiverTypeName;
// If Step 1 didn't resolve it, try the receiver map directly.
if (!baseType && effectiveCall.receiverName && receiverMap) {
// Step 1c: mixed chain resolution (field, call, or interleaved — e.g. svc.getUser().address.save()).
// Runs whenever receiverMixedChain is present. Steps 1/1b may have resolved the base receiver
// type already; that type is used as the chain's starting point.
if (effectiveCall.receiverMixedChain?.length) {
// Use the already-resolved base type (from Steps 1/1b) or look it up now.
let currentType: string | undefined = effectiveCall.receiverTypeName;
if (!currentType && effectiveCall.receiverName && receiverMap) {
const callFuncName = extractFuncNameFromSourceId(effectiveCall.sourceId);
baseType = lookupReceiverType(receiverMap, callFuncName, effectiveCall.receiverName);
currentType = lookupReceiverType(receiverMap, callFuncName, effectiveCall.receiverName);
}
const chainedType = resolveChainedReceiver(
effectiveCall.receiverCallChain,
baseType,
effectiveCall.filePath,
ctx,
);
if (chainedType) {
effectiveCall = { ...effectiveCall, receiverTypeName: chainedType };
if (!currentType && effectiveCall.receiverName) {
const typeResolved = ctx.resolve(effectiveCall.receiverName, effectiveCall.filePath);
if (typeResolved?.candidates.some(d =>
d.type === 'Class' || d.type === 'Interface' || d.type === 'Struct' || d.type === 'Enum',
)) {
currentType = effectiveCall.receiverName;
}
}
if (currentType) {
const walkedType = walkMixedChain(
effectiveCall.receiverMixedChain, currentType, effectiveCall.filePath, ctx,
makeAccessEmitter(graph, effectiveCall.sourceId),
);
if (walkedType) {
effectiveCall = { ...effectiveCall, receiverTypeName: walkedType };
}
}
}
@@ -684,6 +868,63 @@ export const processCallsFromExtracted = async (
onProgress?.(totalFiles, totalFiles);
};
/**
* Resolve pre-extracted field write assignments to ACCESSES {reason: 'write'} edges.
* Accepts optional constructorBindings for return-type-aware receiver inference,
* mirroring processCallsFromExtracted's verified binding lookup.
*/
export const processAssignmentsFromExtracted = (
graph: KnowledgeGraph,
assignments: ExtractedAssignment[],
ctx: ResolutionContext,
constructorBindings?: FileConstructorBindings[],
): void => {
// Build per-file receiver type indexes from verified constructor bindings
const fileReceiverTypes = new Map<string, ReceiverTypeIndex>();
if (constructorBindings) {
for (const { filePath, bindings } of constructorBindings) {
const verified = verifyConstructorBindings(bindings, filePath, ctx, graph);
if (verified.size > 0) {
fileReceiverTypes.set(filePath, buildReceiverTypeIndex(verified));
}
}
}
for (const asn of assignments) {
// Resolve the receiver type
let receiverTypeName = asn.receiverTypeName;
// Tier 2: verified constructor bindings (return-type inference)
if (!receiverTypeName && fileReceiverTypes.size > 0) {
const receiverMap = fileReceiverTypes.get(asn.filePath);
if (receiverMap) {
const funcName = extractFuncNameFromSourceId(asn.sourceId);
receiverTypeName = lookupReceiverType(receiverMap, funcName, asn.receiverText);
}
}
// Tier 3: static class-as-receiver fallback
if (!receiverTypeName) {
const resolved = ctx.resolve(asn.receiverText, asn.filePath);
if (resolved?.candidates.some(d =>
d.type === 'Class' || d.type === 'Struct' || d.type === 'Interface'
|| d.type === 'Enum' || d.type === 'Record' || d.type === 'Impl',
)) {
receiverTypeName = asn.receiverText;
}
}
if (!receiverTypeName) continue;
const fieldOwner = resolveFieldOwnership(receiverTypeName, asn.propertyName, asn.filePath, ctx);
if (!fieldOwner) continue;
graph.addRelationship({
id: generateId('ACCESSES', `${asn.sourceId}:${fieldOwner.nodeId}:write`),
sourceId: asn.sourceId,
targetId: fieldOwner.nodeId,
type: 'ACCESSES',
confidence: 1.0,
reason: 'write',
});
}
};
/**
* Resolve pre-extracted Laravel routes to CALLS edges from route files to controller methods.
*/
+24 -2
View File
@@ -27,7 +27,7 @@ export type CallRouter = (
const noRouting: CallRouter = () => null;
/** Per-language call routing. noRouting = no special routing (normal call processing) */
export const callRouters: Record<SupportedLanguages, CallRouter> = {
export const callRouters = {
[SupportedLanguages.JavaScript]: noRouting,
[SupportedLanguages.TypeScript]: noRouting,
[SupportedLanguages.Python]: noRouting,
@@ -41,7 +41,7 @@ export const callRouters: Record<SupportedLanguages, CallRouter> = {
[SupportedLanguages.CPlusPlus]: noRouting,
[SupportedLanguages.C]: noRouting,
[SupportedLanguages.Ruby]: routeRubyCall,
};
} satisfies Record<SupportedLanguages, CallRouter>;
// ── Result types ────────────────────────────────────────────────────────────
@@ -65,6 +65,8 @@ export interface RubyPropertyItem {
accessorType: RubyAccessorType;
startLine: number;
endLine: number;
/** YARD @return [Type] annotation preceding the attr_accessor call */
declaredType?: string;
}
// ── Pre-allocated singletons for common return values ────────────────────────
@@ -129,6 +131,25 @@ export function routeRubyCall(calledName: string, callNode: any): RubyCallRoutin
// ── 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 ?? [])) {
@@ -138,6 +159,7 @@ export function routeRubyCall(calledName: string, callNode: any): RubyCallRoutin
accessorType: calledName as RubyAccessorType,
startLine: arg.startPosition.row,
endLine: arg.endPosition.row,
...(yardType ? { declaredType: yardType } : {}),
});
}
}
@@ -1,16 +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, 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, FileConstructorBindings } 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;
@@ -18,6 +20,7 @@ export type FileProgressCallback = (current: number, total: number, filePath: st
export interface WorkerExtractedData {
imports: ExtractedImport[];
calls: ExtractedCall[];
assignments: ExtractedAssignment[];
heritage: ExtractedHeritage[];
routes: ExtractedRoute[];
constructorBindings: FileConstructorBindings[];
@@ -46,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: [], constructorBindings: [] };
if (parseableFiles.length === 0) return { imports: [], calls: [], assignments: [], heritage: [], routes: [], constructorBindings: [] };
const total = files.length;
@@ -61,6 +64,7 @@ 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[] = [];
@@ -81,12 +85,14 @@ const processParsingWithWorkers = async (
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);
@@ -108,7 +114,7 @@ const processParsingWithWorkers = async (
// Final progress
onFileProgress?.(total, total, 'done');
return { imports: allImports, calls: allCalls, heritage: allHeritage, routes: allRoutes, constructorBindings: allConstructorBindings };
return { imports: allImports, calls: allCalls, assignments: allAssignments, heritage: allHeritage, routes: allRoutes, constructorBindings: allConstructorBindings };
};
// ============================================================================
@@ -198,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';
@@ -275,9 +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,
});
@@ -296,13 +323,14 @@ 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: '',
});
+5 -1
View File
@@ -6,7 +6,7 @@ import {
processImportsFromExtracted,
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';
@@ -290,6 +290,10 @@ export const runPipelineFromRepo = async (
},
),
]);
// 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, ctx, undefined, repoPath, allPaths);
sequentialChunkPaths.push(chunkPaths);
+44 -11
View File
@@ -1,11 +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;
/** Raw return type text extracted from AST (e.g. 'User', 'Promise<User>') */
returnType?: string;
/** Links Method/Constructor to owning Class/Struct/Trait nodeId */
/** 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;
}
@@ -17,8 +21,8 @@ export interface SymbolTable {
filePath: string,
name: string,
nodeId: string,
type: string,
metadata?: { parameterCount?: number; returnType?: string; ownerId?: string }
type: NodeLabel,
metadata?: { parameterCount?: number; returnType?: string; declaredType?: string; ownerId?: string }
) => void;
/**
@@ -45,7 +49,14 @@ export interface SymbolTable {
* 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
*/
@@ -71,14 +82,18 @@ export const createSymbolTable = (): SymbolTable => {
// 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; returnType?: string; ownerId?: string }
type: NodeLabel,
metadata?: { parameterCount?: number; returnType?: string; declaredType?: string; ownerId?: string }
) => {
const def: SymbolDefinition = {
nodeId,
@@ -86,6 +101,7 @@ export const createSymbolTable = (): SymbolTable => {
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 } : {}),
};
@@ -95,14 +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);
// Invalidate the lazy callable index — it will be rebuilt on next use
callableIndex = null;
// 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 => {
@@ -129,6 +157,10 @@ export const createSymbolTable = (): SymbolTable => {
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
@@ -138,7 +170,8 @@ export const createSymbolTable = (): SymbolTable => {
fileIndex.clear();
globalIndex.clear();
callableIndex = null;
fieldByOwner.clear();
};
return { add, lookupExact, lookupExactFull, lookupFuzzy, lookupFuzzyCallable, 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
@@ -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
@@ -383,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
@@ -414,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)
@@ -457,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
@@ -501,6 +649,20 @@ 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
@@ -546,6 +708,20 @@ export const RUBY_QUERIES = `
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)
@@ -582,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
@@ -626,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
@@ -684,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> = {
+90 -34
View File
@@ -2,7 +2,7 @@ import type { SyntaxNode } 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 } from './type-extractors/types.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';
@@ -364,6 +364,47 @@ const SKIP_SUBTREE_TYPES = new Set([
'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,
@@ -403,12 +444,10 @@ export const buildTypeEnv = (
TYPED_PARAMETER_TYPES.forEach(t => interestingNodeTypes.add(t));
config.declarationNodeTypes.forEach(t => interestingNodeTypes.add(t));
config.forLoopNodeTypes?.forEach(t => interestingNodeTypes.add(t));
// Tier 2: copy-propagation (`const b = a`) and call-result propagation (`const b = foo()`)
const pendingCopies: Array<{ scope: string; lhs: string; rhs: string }> = [];
// NOTE: Infrastructure-ready — no language extractor currently returns { kind: 'callResult' }
// from extractPendingAssignment. When one does, this array will bind variables to their
// function return types at TypeEnv build time. See PendingAssignment in types.ts.
const pendingCallResults: Array<{ scope: string; lhs: string; callee: string }> = [];
// 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`).
@@ -439,7 +478,9 @@ export const buildTypeEnv = (
let typeNode = node.childForFieldName('type');
if (typeNode) {
const nameNode = node.childForFieldName('name')
?? node.childForFieldName('pattern');
?? 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}`)) {
@@ -609,11 +650,7 @@ export const buildTypeEnv = (
if (scopeEnv) {
const pending = config.extractPendingAssignment(node, scopeEnv);
if (pending) {
if (pending.kind === 'copy') {
pendingCopies.push({ scope, lhs: pending.lhs, rhs: pending.rhs });
} else {
pendingCallResults.push({ scope, lhs: pending.lhs, callee: pending.callee });
}
pendingItems.push({ scope, ...pending });
}
}
}
@@ -639,28 +676,47 @@ export const buildTypeEnv = (
walk(tree.rootNode, FILE_SCOPE);
// Tier 2a: copy-propagation — `const b = a` where `a` has a known type from Tier 0/1.
// Multi-hop chains resolve when forward-declared (a→b→c in source order);
// reverse-order assignments are depth-1 only. No fixpoint iteration —
// this covers 95%+ of real-world patterns.
for (const { scope, lhs, rhs } of pendingCopies) {
const scopeEnv = env.get(scope);
if (!scopeEnv || scopeEnv.has(lhs)) continue;
const rhsType = scopeEnv.get(rhs) ?? env.get(FILE_SCOPE)?.get(rhs);
if (rhsType) scopeEnv.set(lhs, rhsType);
}
// 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; }
// Tier 2b: call-result propagation — `const b = foo()` where `foo` has a declared return type.
// Uses ReturnTypeLookup which is backed by SymbolTable.lookupFuzzyCallable.
// Conservative: only binds when exactly one callable matches (avoids overload ambiguity).
// NOTE: Currently dormant — no extractPendingAssignment implementation emits 'callResult' yet.
// The loop is structurally complete and will activate when any language extractor starts
// returning { kind: 'callResult', lhs, callee } from extractPendingAssignment.
for (const { scope, lhs, callee } of pendingCallResults) {
const scopeEnv = env.get(scope);
if (!scopeEnv || scopeEnv.has(lhs)) continue;
const typeName = returnTypeLookup.lookupReturnType(callee);
if (typeName) scopeEnv.set(lhs, typeName);
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;
}
return {
@@ -171,7 +171,7 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) =>
const declarator = node.childForFieldName('declarator');
if (!declarator || declarator.type !== 'init_declarator') return undefined;
const value = declarator.childForFieldName('value');
if (!value || value.type !== 'identifier') return undefined;
if (!value) return undefined;
const nameNode = declarator.childForFieldName('declarator');
if (!nameNode) return undefined;
const finalName = nameNode.type === 'pointer_declarator' || nameNode.type === 'reference_declarator'
@@ -179,7 +179,31 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) =>
if (!finalName) return undefined;
const lhs = extractVarName(finalName);
if (!lhs || scopeEnv.has(lhs)) return undefined;
return { kind: 'copy', lhs, rhs: value.text };
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 ---
@@ -327,6 +327,46 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) =>
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;
};
@@ -397,6 +397,29 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) =>
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') {
@@ -422,6 +445,28 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) =>
}
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;
@@ -199,6 +199,31 @@ const extractJavaPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv
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;
};
@@ -541,7 +566,7 @@ const extractKotlinPendingAssignment: PendingAssignmentExtractor = (node, scopeE
if (!nameNode || nameNode.type !== 'simple_identifier') return undefined;
const lhs = nameNode.text;
if (scopeEnv.has(lhs)) return undefined;
// Find the RHS: a simple_identifier sibling after the "=" token
// Find the RHS after the "=" token
let foundEq = false;
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
@@ -550,6 +575,31 @@ const extractKotlinPendingAssignment: PendingAssignmentExtractor = (node, scopeE
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;
}
@@ -560,8 +610,7 @@ const extractKotlinPendingAssignment: PendingAssignmentExtractor = (node, scopeE
if (!nameNode) return undefined;
const lhs = nameNode.text;
if (scopeEnv.has(lhs)) return undefined;
// Look for RHS simple_identifier after "=" in the parent (property_declaration)
// variable_declaration itself doesn't contain "=" — it's in the parent
// Look for RHS after "=" in the parent (property_declaration)
const parent = node.parent;
if (!parent) return undefined;
let foundEq = false;
@@ -572,6 +621,28 @@ const extractKotlinPendingAssignment: PendingAssignmentExtractor = (node, scopeE
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;
}
@@ -351,11 +351,37 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) =>
const left = node.childForFieldName('left');
const right = node.childForFieldName('right');
if (!left || !right) return undefined;
if (left.type !== 'variable_name' || right.type !== 'variable_name') return undefined;
if (left.type !== 'variable_name') return undefined;
const lhs = left.text;
const rhs = right.text;
if (!lhs || !rhs || scopeEnv.has(lhs)) return undefined;
return { kind: 'copy', lhs, rhs };
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([
@@ -62,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;
@@ -219,6 +223,37 @@ const findPyParamElementType = (iterableName: string, startNode: SyntaxNode, pos
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.
*
@@ -228,32 +263,43 @@ const findPyParamElementType = (iterableName: string, startNode: SyntaxNode, pos
* 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;
// The iterable is the `right` field — may be identifier, attribute, or call.
const rightNode = node.childForFieldName('right');
let iterableName: string | undefined;
let methodName: string | undefined;
let callExprElementType: string | undefined;
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') {
// data.items() → call > function: attribute > identifier('data') + identifier('items')
// get_users() → call > function: identifier (Phase 7.3 — return-type path)
const fn = rightNode.childForFieldName('function');
if (fn?.type === 'attribute') {
const obj = fn.firstNamedChild;
if (obj?.type === 'identifier') iterableName = obj.text;
// Extract method name: items, keys, values
const method = fn.lastNamedChild;
if (method?.type === 'identifier' && method !== obj) methodName = method.text;
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()
// 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);
}
@@ -278,11 +324,12 @@ const extractForLoopBinding: ForLoopExtractor = (node, { scopeEnv, declarationTy
const leftNode = node.childForFieldName('left');
if (!leftNode) return;
// Handle tuple unpacking: for key, value in data.items()
if (leftNode.type === 'pattern_list') {
const lastChild = leftNode.lastNamedChild;
if (lastChild?.type === 'identifier') {
scopeEnv.set(lastChild.text, elementType);
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;
}
@@ -311,6 +358,29 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) =>
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;
};
@@ -389,8 +389,22 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) =>
const varName = lhsNode.text;
if (scopeEnv.has(varName)) return undefined;
const rhsNode = node.childForFieldName('right');
if (!rhsNode || rhsNode.type !== 'identifier') return undefined;
return { kind: 'copy', lhs: varName, rhs: rhsNode.text };
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 = {
@@ -95,7 +95,7 @@ const extractDeclaration: TypeBindingExtractor = (node: SyntaxNode, env: Map<str
};
/** 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 => {
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');
@@ -116,6 +116,13 @@ const extractInitializer: InitializerExtractor = (node: SyntaxNode, env: Map<str
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;
@@ -190,7 +197,34 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) =>
if (!pattern || !value) return undefined;
const lhs = extractVarName(pattern);
if (!lhs || scopeEnv.has(lhs)) return undefined;
if (value.type === 'identifier') return { kind: 'copy', lhs, rhs: value.text };
// 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;
};
@@ -263,8 +263,14 @@ export const extractSimpleTypeName = (typeNode: SyntaxNode, depth = 0): string |
// 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, depth + 1);
// 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
@@ -745,3 +751,102 @@ export const extractReturnTypeName = (raw: string, depth = 0): string | undefine
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;
};
@@ -53,15 +53,20 @@ export interface ForLoopExtractorContext {
export type ForLoopExtractor = (node: SyntaxNode, ctx: ForLoopExtractorContext) => void;
/** Discriminated union for pending Tier-2 propagation items.
* - `copy` — `const b = a` (identifier alias, propagate a's type to b)
* - `callResult` — `const b = foo()` (bind b to foo's declared return type) */
* - `copy` — `const b = a` (identifier alias, propagate a's type to b)
* - `callResult` — `const b = foo()` (bind b to foo's declared return type)
* - `fieldAccess` — `const b = a.field` (bind b to field's declaredType on a's type)
* - `methodCallResult` — `const b = a.method()` (bind b to method's returnType on a's type) */
export type PendingAssignment =
| { kind: 'copy'; lhs: string; rhs: string }
| { kind: 'callResult'; lhs: string; callee: string };
| { kind: 'callResult'; lhs: string; callee: string }
| { kind: 'fieldAccess'; lhs: string; receiver: string; field: string }
| { kind: 'methodCallResult'; lhs: string; receiver: string; method: string };
/** Extracts a pending assignment for Tier 2 propagation.
* Returns a PendingAssignment when the RHS is a bare identifier (`copy`) or a
* call expression (`callResult`) and the LHS has no resolved type yet.
* Returns a PendingAssignment when the RHS is a bare identifier (`copy`), a
* call expression (`callResult`), a field access (`fieldAccess`), or a
* method call with receiver (`methodCallResult`) and the LHS has no resolved type yet.
* Returns undefined if the node is not a matching assignment. */
export type PendingAssignmentExtractor = (
node: SyntaxNode,
@@ -440,6 +440,34 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) =>
const lhs = nameNode.text;
if (scopeEnv.has(lhs)) continue;
if (valueNode.type === 'identifier') return { kind: 'copy', lhs, rhs: valueNode.text };
// member_expression RHS → fieldAccess (a.field, this.field)
if (valueNode.type === 'member_expression') {
const obj = valueNode.childForFieldName('object');
const prop = valueNode.childForFieldName('property');
if (obj && prop?.type === 'property_identifier' &&
(obj.type === 'identifier' || obj.type === 'this')) {
return { kind: 'fieldAccess', lhs, receiver: obj.text, field: prop.text };
}
continue;
}
// Unwrap await: `const user = await fetchUser()` or `await a.getC()`
const callNode = unwrapAwait(valueNode);
if (!callNode || callNode.type !== 'call_expression') continue;
const funcNode = callNode.childForFieldName('function');
if (!funcNode) continue;
// Simple call → callResult: getUser()
if (funcNode.type === 'identifier') {
return { kind: 'callResult', lhs, callee: funcNode.text };
}
// Method call with receiver → methodCallResult: a.getC()
if (funcNode.type === 'member_expression') {
const obj = funcNode.childForFieldName('object');
const prop = funcNode.childForFieldName('property');
if (obj && prop?.type === 'property_identifier' &&
(obj.type === 'identifier' || obj.type === 'this')) {
return { kind: 'methodCallResult', lhs, receiver: obj.text, method: prop.text };
}
}
}
return undefined;
};
+155 -1
View File
@@ -264,7 +264,7 @@ export 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',
'impl_item', 'trait_item', 'struct_item', 'enum_item',
'class_definition',
'trait_declaration',
'protocol_declaration',
@@ -286,6 +286,8 @@ export const CONTAINER_TYPE_TO_LABEL: Record<string, string> = {
class_definition: 'Class',
impl_item: 'Impl',
trait_item: 'Trait',
struct_item: 'Struct',
enum_item: 'Enum',
trait_declaration: 'Trait',
record_declaration: 'Record',
protocol_declaration: 'Interface',
@@ -318,6 +320,21 @@ export const findEnclosingClassId = (node: any, filePath: string): string | null
}
}
}
// Go: type_declaration wrapping a struct_type (type User struct { ... })
// field_declaration → field_declaration_list → struct_type → type_spec → type_declaration
if (current.type === 'type_declaration') {
const typeSpec = current.children?.find((c: any) => c.type === 'type_spec');
if (typeSpec) {
const typeBody = typeSpec.childForFieldName?.('type');
if (typeBody?.type === 'struct_type' || typeBody?.type === 'interface_type') {
const nameNode = typeSpec.childForFieldName?.('name');
if (nameNode) {
const label = typeBody.type === 'struct_type' ? 'Struct' : 'Interface';
return generateId(label, `${filePath}:${nameNode.text}`);
}
}
}
}
if (CLASS_CONTAINER_TYPES.has(current.type)) {
// Rust impl_item: for `impl Trait for Struct {}`, pick the type after `for`
if (current.type === 'impl_item') {
@@ -1156,6 +1173,143 @@ export function extractCallChain(
return chain.length > 0 ? { chain, baseReceiverName: undefined } : undefined;
}
/** Node types representing member/field access across languages. */
const FIELD_ACCESS_NODE_TYPES = new Set([
'member_expression', // TS/JS
'member_access_expression', // C#
'selector_expression', // Go
'field_expression', // Rust/C++
'field_access', // Java
'attribute', // Python
'navigation_expression', // Kotlin/Swift
'member_binding_expression', // C# null-conditional (user?.Address)
]);
/** One step in a mixed receiver chain. */
export type MixedChainStep = { kind: 'field' | 'call'; name: string };
/**
* Walk a receiver AST node that may interleave field accesses and method calls,
* building a unified chain of steps up to MAX_CHAIN_DEPTH.
*
* For `svc.getUser().address.save()`, called with the receiver of `save`
* (`svc.getUser().address`, a field access node):
* returns { chain: [{ kind:'call', name:'getUser' }, { kind:'field', name:'address' }],
* baseReceiverName: 'svc' }
*
* For `user.getAddress().city.getName()`, called with receiver of `getName`
* (`user.getAddress().city`):
* returns { chain: [{ kind:'call', name:'getAddress' }, { kind:'field', name:'city' }],
* baseReceiverName: 'user' }
*
* Pure field chains and pure call chains are special cases (all steps same kind).
*/
export function extractMixedChain(
receiverNode: SyntaxNode,
): { chain: MixedChainStep[]; baseReceiverName: string | undefined } | undefined {
const chain: MixedChainStep[] = [];
let current: SyntaxNode = receiverNode;
while (chain.length < MAX_CHAIN_DEPTH) {
if (CALL_EXPRESSION_TYPES.has(current.type)) {
// ── Call expression: extract method name + inner receiver ────────────
const funcNode = current.childForFieldName?.('function')
?? current.childForFieldName?.('name')
?? current.childForFieldName?.('method');
let methodName: string | undefined;
let innerReceiver: SyntaxNode | null = null;
if (funcNode) {
methodName = funcNode.lastNamedChild?.text ?? funcNode.text;
}
// Kotlin/Swift: call_expression → navigation_expression
if (!funcNode && current.type === 'call_expression') {
const callee = current.firstNamedChild;
if (callee?.type === 'navigation_expression') {
const suffix = callee.lastNamedChild;
if (suffix?.type === 'navigation_suffix') {
methodName = suffix.lastNamedChild?.text;
for (let i = 0; i < callee.namedChildCount; i++) {
const child = callee.namedChild(i);
if (child && child.type !== 'navigation_suffix') { innerReceiver = child; break; }
}
}
}
}
if (!methodName) break;
chain.unshift({ kind: 'call', name: methodName });
if (!innerReceiver && funcNode) {
innerReceiver = funcNode.childForFieldName?.('object')
?? funcNode.childForFieldName?.('value')
?? funcNode.childForFieldName?.('operand')
?? funcNode.childForFieldName?.('argument') // C/C++ field_expression
?? funcNode.childForFieldName?.('expression')
?? null;
}
if (!innerReceiver && current.type === 'method_invocation') {
innerReceiver = current.childForFieldName?.('object') ?? null;
}
if (!innerReceiver && (current.type === 'member_call_expression' || current.type === 'nullsafe_member_call_expression')) {
innerReceiver = current.childForFieldName?.('object') ?? null;
}
if (!innerReceiver && current.type === 'call') {
innerReceiver = current.childForFieldName?.('receiver') ?? null;
}
if (!innerReceiver) break;
if (CALL_EXPRESSION_TYPES.has(innerReceiver.type) || FIELD_ACCESS_NODE_TYPES.has(innerReceiver.type)) {
current = innerReceiver;
} else {
return { chain, baseReceiverName: innerReceiver.text || undefined };
}
} else if (FIELD_ACCESS_NODE_TYPES.has(current.type)) {
// ── Field/member access: extract property name + inner object ─────────
let propertyName: string | undefined;
let innerObject: SyntaxNode | null = null;
if (current.type === 'navigation_expression') {
for (const child of current.children ?? []) {
if (child.type === 'navigation_suffix') {
for (const sc of child.children ?? []) {
if (sc.isNamed && sc.type !== '.') { propertyName = sc.text; break; }
}
} else if (child.isNamed && !innerObject) {
innerObject = child;
}
}
} else if (current.type === 'attribute') {
innerObject = current.childForFieldName?.('object') ?? null;
propertyName = current.childForFieldName?.('attribute')?.text;
} else {
innerObject = current.childForFieldName?.('object')
?? current.childForFieldName?.('value')
?? current.childForFieldName?.('operand')
?? current.childForFieldName?.('argument') // C/C++ field_expression
?? current.childForFieldName?.('expression')
?? null;
propertyName = (current.childForFieldName?.('property')
?? current.childForFieldName?.('field')
?? current.childForFieldName?.('name'))?.text;
}
if (!propertyName) break;
chain.unshift({ kind: 'field', name: propertyName });
if (!innerObject) break;
if (CALL_EXPRESSION_TYPES.has(innerObject.type) || FIELD_ACCESS_NODE_TYPES.has(innerObject.type)) {
current = innerObject;
} else {
return { chain, baseReceiverName: innerObject.text || undefined };
}
} else {
// Simple identifier — this is the base receiver
return chain.length > 0
? { chain, baseReceiverName: current.text || undefined }
: undefined;
}
}
return chain.length > 0 ? { chain, baseReceiverName: undefined } : undefined;
}
@@ -36,8 +36,8 @@ import {
inferCallForm,
extractReceiverName,
extractReceiverNode,
CALL_EXPRESSION_TYPES,
extractCallChain,
extractMixedChain,
type MixedChainStep,
} from '../utils.js';
import { buildTypeEnv } from '../type-env.js';
import type { ConstructorBinding } from '../type-env.js';
@@ -48,6 +48,8 @@ import { generateId } from '../../../lib/utils.js';
import { extractNamedBindings } from '../named-binding-extraction.js';
import { appendKotlinWildcard } from '../resolvers/index.js';
import { callRouters } from '../call-routing.js';
import { extractPropertyDeclaredType } from '../type-extractors/shared.js';
import type { NodeLabel } from '../../graph/types.js';
// ============================================================================
// Types for serializable results
@@ -75,7 +77,7 @@ interface ParsedRelationship {
id: string;
sourceId: string;
targetId: string;
type: 'DEFINES' | 'HAS_METHOD';
type: 'DEFINES' | 'HAS_METHOD' | 'HAS_PROPERTY';
confidence: number;
reason: string;
}
@@ -84,9 +86,10 @@ interface ParsedSymbol {
filePath: string;
name: string;
nodeId: string;
type: string;
type: NodeLabel;
parameterCount?: number;
returnType?: string;
declaredType?: string;
ownerId?: string;
}
@@ -111,13 +114,26 @@ export interface ExtractedCall {
/** Resolved type name of the receiver (e.g., 'User' for user.save() when user: User) */
receiverTypeName?: string;
/**
* Chained call names when the receiver is itself a call expression.
* For `svc.getUser().save()`, the `save` ExtractedCall gets receiverCallChain = ['getUser']
* with receiverName = 'svc'. The chain is ordered outermost-last, e.g.:
* `a.b().c().d()` → calledName='d', receiverCallChain=['b','c'], receiverName='a'
* Unified mixed chain when the receiver is a chain of field accesses and/or method calls.
* Steps are ordered base-first (innermost to outermost). Examples:
* `svc.getUser().save()` → chain=[{kind:'call',name:'getUser'}], receiverName='svc'
* `user.address.save()` → chain=[{kind:'field',name:'address'}], receiverName='user'
* `svc.getUser().address.save()` → chain=[{kind:'call',name:'getUser'},{kind:'field',name:'address'}]
* Length is capped at MAX_CHAIN_DEPTH (3).
*/
receiverCallChain?: string[];
receiverMixedChain?: MixedChainStep[];
}
export interface ExtractedAssignment {
filePath: string;
/** generateId of enclosing function, or generateId('File', filePath) for top-level */
sourceId: string;
/** Receiver text (e.g., 'user' from user.address = value) */
receiverText: string;
/** Property name being written (e.g., 'address') */
propertyName: string;
/** Resolved type name of the receiver if available from TypeEnv */
receiverTypeName?: string;
}
export interface ExtractedHeritage {
@@ -151,6 +167,7 @@ export interface ParseWorkerResult {
symbols: ParsedSymbol[];
imports: ExtractedImport[];
calls: ExtractedCall[];
assignments: ExtractedAssignment[];
heritage: ExtractedHeritage[];
routes: ExtractedRoute[];
constructorBindings: FileConstructorBindings[];
@@ -233,7 +250,7 @@ const findEnclosingFunctionId = (node: any, filePath: string): string | null =>
// Label detection from capture map
// ============================================================================
const getLabelFromCaptures = (captureMap: Record<string, any>): string | null => {
const getLabelFromCaptures = (captureMap: Record<string, any>): NodeLabel | null => {
// Skip imports (handled separately) and calls
if (captureMap['import'] || captureMap['call']) return null;
if (!captureMap['name']) return null;
@@ -277,6 +294,7 @@ const processBatch = (files: ParseWorkerInput[], onProgress?: (filesProcessed: n
symbols: [],
imports: [],
calls: [],
assignments: [],
heritage: [],
routes: [],
constructorBindings: [],
@@ -910,6 +928,28 @@ const processFileGroup = (
continue;
}
// Extract assignment sites (field write access)
if (captureMap['assignment'] && captureMap['assignment.receiver'] && captureMap['assignment.property']) {
const receiverText = captureMap['assignment.receiver'].text;
const propertyName = captureMap['assignment.property'].text;
if (receiverText && propertyName) {
const srcId = findEnclosingFunctionId(captureMap['assignment'], file.path)
|| generateId('File', file.path);
let receiverTypeName: string | undefined;
if (typeEnv) {
receiverTypeName = typeEnv.lookup(receiverText, captureMap['assignment']) ?? undefined;
}
result.assignments.push({
filePath: file.path,
sourceId: srcId,
receiverText,
propertyName,
...(receiverTypeName ? { receiverTypeName } : {}),
});
}
if (!captureMap['call']) continue;
}
// Extract call sites
if (captureMap['call']) {
const callNameNode = captureMap['call.name'];
@@ -965,6 +1005,7 @@ const processFileGroup = (
nodeId,
type: 'Property',
...(propEnclosingClassId ? { ownerId: propEnclosingClassId } : {}),
...(item.declaredType ? { declaredType: item.declaredType } : {}),
});
const fileId = generateId('File', file.path);
const relId = generateId('DEFINES', `${fileId}->${nodeId}`);
@@ -978,10 +1019,10 @@ const processFileGroup = (
});
if (propEnclosingClassId) {
result.relationships.push({
id: generateId('HAS_METHOD', `${propEnclosingClassId}->${nodeId}`),
id: generateId('HAS_PROPERTY', `${propEnclosingClassId}->${nodeId}`),
sourceId: propEnclosingClassId,
targetId: nodeId,
type: 'HAS_METHOD',
type: 'HAS_PROPERTY',
confidence: 1.0,
reason: '',
});
@@ -1000,27 +1041,20 @@ const processFileGroup = (
const callForm = inferCallForm(callNode, callNameNode);
let receiverName = callForm === 'member' ? extractReceiverName(callNameNode) : undefined;
let receiverTypeName = receiverName ? typeEnv.lookup(receiverName, callNode) : undefined;
let receiverCallChain: string[] | undefined;
let receiverMixedChain: MixedChainStep[] | undefined;
// When the receiver is a call_expression (e.g. svc.getUser().save()),
// extractReceiverName returns undefined because it refuses complex expressions.
// Instead, walk the receiver node to build a call chain for deferred resolution.
// We capture the base receiver name so processCallsFromExtracted can look it up
// from constructor bindings. receiverTypeName is intentionally left unset here —
// the chain resolver in processCallsFromExtracted needs the base type as input and
// produces the final receiver type as output.
// When the receiver is a complex expression (call chain, field chain, or mixed),
// extractReceiverName returns undefined. Walk the receiver node to build a unified
// mixed chain for deferred resolution in processCallsFromExtracted.
if (callForm === 'member' && receiverName === undefined && !receiverTypeName) {
const receiverNode = extractReceiverNode(callNameNode);
if (receiverNode && CALL_EXPRESSION_TYPES.has(receiverNode.type)) {
const extracted = extractCallChain(receiverNode);
if (extracted) {
receiverCallChain = extracted.chain;
// Set receiverName to the base object so Step 1 in processCallsFromExtracted
// can resolve it via constructor bindings to a base type for the chain.
if (receiverNode) {
const extracted = extractMixedChain(receiverNode);
if (extracted && extracted.chain.length > 0) {
receiverMixedChain = extracted.chain;
receiverName = extracted.baseReceiverName;
// Also try the type environment immediately (covers explicitly-typed locals
// and annotated parameters like `fn process(svc: &UserService)`).
// This sets a base type that chain resolution (Step 2) will use as input.
// Try the type environment immediately for the base receiver
// (covers explicitly-typed locals and annotated parameters).
if (receiverName) {
receiverTypeName = typeEnv.lookup(receiverName, callNode);
}
@@ -1036,7 +1070,7 @@ const processFileGroup = (
...(callForm !== undefined ? { callForm } : {}),
...(receiverName !== undefined ? { receiverName } : {}),
...(receiverTypeName !== undefined ? { receiverTypeName } : {}),
...(receiverCallChain !== undefined ? { receiverCallChain } : {}),
...(receiverMixedChain !== undefined ? { receiverMixedChain } : {}),
});
}
}
@@ -1086,6 +1120,23 @@ const processFileGroup = (
const nodeLabel = getLabelFromCaptures(captureMap);
if (!nodeLabel) continue;
// 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) &&
nodeLabel === 'Function'
) {
let ancestor = captureMap['definition.function']?.parent;
while (ancestor) {
if (ancestor.type === 'class_specifier' || ancestor.type === 'struct_specifier') {
break; // inside a class body — duplicate of @definition.method
}
ancestor = ancestor.parent;
}
if (ancestor) continue; // found a class/struct ancestor → skip
}
const nameNode = captureMap['name'];
// Synthesize name for constructors without explicit @name capture (e.g. Swift init)
if (!nameNode && nodeLabel !== 'Constructor') continue;
@@ -1109,6 +1160,7 @@ const processFileGroup = (
let parameterCount: number | undefined;
let returnType: string | undefined;
let declaredType: string | undefined;
if (nodeLabel === 'Function' || nodeLabel === 'Method' || nodeLabel === 'Constructor') {
const sig = extractMethodSignature(definitionNode);
parameterCount = sig.parameterCount;
@@ -1123,6 +1175,10 @@ const processFileGroup = (
if (docReturn) returnType = docReturn;
}
}
} else if (nodeLabel === 'Property' && definitionNode) {
// Extract the declared type for property/field nodes.
// Walk the definition node for type annotation children.
declaredType = extractPropertyDeclaredType(definitionNode);
}
result.nodes.push({
@@ -1157,6 +1213,7 @@ const processFileGroup = (
type: nodeLabel,
...(parameterCount !== undefined ? { parameterCount } : {}),
...(returnType !== undefined ? { returnType } : {}),
...(declaredType !== undefined ? { declaredType } : {}),
...(enclosingClassId ? { ownerId: enclosingClassId } : {}),
});
@@ -1171,13 +1228,14 @@ const processFileGroup = (
reason: '',
});
// ── 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';
result.relationships.push({
id: generateId('HAS_METHOD', `${enclosingClassId}->${nodeId}`),
id: generateId(memberEdgeType, `${enclosingClassId}->${nodeId}`),
sourceId: enclosingClassId,
targetId: nodeId,
type: 'HAS_METHOD',
type: memberEdgeType,
confidence: 1.0,
reason: '',
});
@@ -1199,7 +1257,7 @@ const processFileGroup = (
/** Accumulated result across sub-batches */
let accumulated: ParseWorkerResult = {
nodes: [], relationships: [], symbols: [],
imports: [], calls: [], heritage: [], routes: [], constructorBindings: [], skippedLanguages: {}, fileCount: 0,
imports: [], calls: [], assignments: [], heritage: [], routes: [], constructorBindings: [], skippedLanguages: {}, fileCount: 0,
};
let cumulativeProcessed = 0;
@@ -1209,6 +1267,7 @@ const mergeResult = (target: ParseWorkerResult, src: ParseWorkerResult) => {
target.symbols.push(...src.symbols);
target.imports.push(...src.imports);
target.calls.push(...src.calls);
target.assignments.push(...src.assignments);
target.heritage.push(...src.heritage);
target.routes.push(...src.routes);
target.constructorBindings.push(...src.constructorBindings);
@@ -1236,7 +1295,7 @@ parentPort!.on('message', (msg: any) => {
if (msg && msg.type === 'flush') {
parentPort!.postMessage({ type: 'result', data: accumulated });
// Reset for potential reuse
accumulated = { nodes: [], relationships: [], symbols: [], imports: [], calls: [], heritage: [], routes: [], constructorBindings: [], skippedLanguages: {}, fileCount: 0 };
accumulated = { nodes: [], relationships: [], symbols: [], imports: [], calls: [], assignments: [], heritage: [], routes: [], constructorBindings: [], skippedLanguages: {}, fileCount: 0 };
cumulativeProcessed = 0;
return;
}
+3
View File
@@ -18,6 +18,9 @@ let conn: lbug.Connection | null = null;
let currentDbPath: string | null = null;
let ftsLoaded = false;
/** Expose the current Database for pool adapter reuse in tests. */
export const getDatabase = (): lbug.Database | null => db;
// Global session lock for operations that touch module-level lbug globals.
// This guarantees no DB switch can happen while an operation is running.
let sessionLock: Promise<void> = Promise.resolve();
+1 -1
View File
@@ -26,7 +26,7 @@ export type NodeTableName = typeof NODE_TABLES[number];
export const REL_TABLE_NAME = 'CodeRelation';
// Valid relation types
export const REL_TYPES = ['CONTAINS', 'DEFINES', 'IMPORTS', 'CALLS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'OVERRIDES', 'MEMBER_OF', 'STEP_IN_PROCESS'] as const;
export const REL_TYPES = ['CONTAINS', 'DEFINES', 'IMPORTS', 'CALLS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'HAS_PROPERTY', 'ACCESSES', 'OVERRIDES', 'MEMBER_OF', 'STEP_IN_PROCESS'] as const;
export type RelType = typeof REL_TYPES[number];
// ============================================================================
+188 -24
View File
@@ -27,6 +27,8 @@ interface PoolEntry {
waiters: Array<(conn: lbug.Connection) => void>;
lastUsed: number;
dbPath: string;
/** Set to true when the pool entry is closed — checkin will close orphaned connections */
closed: boolean;
}
const pool = new Map<string, PoolEntry>();
@@ -40,6 +42,8 @@ interface SharedDB {
db: lbug.Database;
refCount: number;
ftsLoaded: boolean;
/** When true, closeOne skips db.close() — the Database is owned externally. */
external?: boolean;
}
const dbCache = new Map<string, SharedDB>();
@@ -49,14 +53,14 @@ const MAX_POOL_SIZE = 5;
const IDLE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
/** Max connections per repo (caps concurrent queries per repo) */
const MAX_CONNS_PER_REPO = 8;
/** Connections created eagerly on init */
const INITIAL_CONNS_PER_REPO = 2;
let idleTimer: ReturnType<typeof setInterval> | null = null;
/** Saved real stdout.write — used to silence LadybugDB native output without race conditions */
const realStdoutWrite = process.stdout.write.bind(process.stdout);
export const realStdoutWrite = process.stdout.write.bind(process.stdout);
let stdoutSilenceCount = 0;
/** True while pre-warming connections — prevents watchdog from prematurely restoring stdout */
let preWarmActive = false;
/**
* Start the idle cleanup timer (runs every 60s)
@@ -96,21 +100,47 @@ function evictLRU(): void {
}
/**
* Remove a repo from the pool and release its shared Database ref.
*
* LadybugDB's native .closeSync() triggers N-API destructor hooks that
* segfault on Linux/macOS. Pool databases are opened read-only, so
* there is no WAL to flush — just deleting the pool entry and letting
* the GC (or process exit) reclaim native resources is safe.
* Remove a repo from the pool, close its connections, and release its
* shared Database ref. Only closes the Database when no other repoIds
* reference it (refCount === 0).
*/
function closeOne(repoId: string): void {
const entry = pool.get(repoId);
if (entry) {
const shared = dbCache.get(entry.dbPath);
if (shared && shared.refCount > 0) {
shared.refCount--;
if (!entry) return;
entry.closed = true;
// Close available connections — fire-and-forget with .catch() to prevent
// unhandled rejections. Native close() returns Promise<void> but can crash
// the N-API destructor on macOS/Windows; deferring to process exit lets
// dangerouslyIgnoreUnhandledErrors absorb the crash.
for (const conn of entry.available) {
conn.close().catch(() => {});
}
entry.available.length = 0;
// Checked-out connections can't be closed here — they're in-flight.
// The checkin() function detects entry.closed and closes them on return.
// Only close the Database when no other repoIds reference it.
// External databases (injected via initLbugWithDb) are never closed here —
// the core adapter owns them and handles their lifecycle.
const shared = dbCache.get(entry.dbPath);
if (shared) {
shared.refCount--;
if (shared.refCount === 0) {
if (shared.external) {
// External databases are owned by the core adapter — don't close
// or remove from cache. Keep the entry so future initLbug() calls
// for the same dbPath reuse it instead of hitting a file lock.
shared.refCount = 0;
} else {
shared.db.close().catch(() => {});
dbCache.delete(entry.dbPath);
}
}
}
pool.delete(repoId);
}
@@ -131,6 +161,15 @@ function restoreStdout(): void {
}
}
// Safety watchdog: restore stdout if it gets stuck silenced (e.g. native crash
// inside createConnection before restoreStdout runs).
setInterval(() => {
if (stdoutSilenceCount > 0 && !preWarmActive) {
stdoutSilenceCount = 0;
process.stdout.write = realStdoutWrite;
}
}, 1000).unref();
function createConnection(db: lbug.Database): lbug.Connection {
silenceStdout();
try {
@@ -148,9 +187,15 @@ const WAITER_TIMEOUT_MS = 15_000;
const LOCK_RETRY_ATTEMPTS = 3;
const LOCK_RETRY_DELAY_MS = 2000;
/** Deduplicates concurrent initLbug calls for the same repoId */
const initPromises = new Map<string, Promise<void>>();
/**
* Initialize (or reuse) a Database + connection pool for a specific repo.
* Retries on lock errors (e.g., when `gitnexus analyze` is running).
*
* Concurrent calls for the same repoId are deduplicated — the second caller
* awaits the first's in-progress init rather than starting a redundant one.
*/
export const initLbug = async (repoId: string, dbPath: string): Promise<void> => {
const existing = pool.get(repoId);
@@ -159,6 +204,27 @@ export const initLbug = async (repoId: string, dbPath: string): Promise<void> =>
return;
}
// Deduplicate concurrent init calls for the same repoId —
// prevents double-init race when multiple parallel tool calls
// trigger initialization for the same repo simultaneously.
const pending = initPromises.get(repoId);
if (pending) return pending;
const promise = doInitLbug(repoId, dbPath);
initPromises.set(repoId, promise);
try {
await promise;
} finally {
initPromises.delete(repoId);
}
};
/**
* Internal init — creates DB, pre-warms connections, loads FTS, then registers pool.
* Pool entry is registered LAST so concurrent executeQuery calls see either
* "not initialized" (and throw) or a fully ready pool — never a half-built one.
*/
async function doInitLbug(repoId: string, dbPath: string): Promise<void> {
// Check if database exists
try {
await fs.stat(dbPath);
@@ -210,16 +276,22 @@ export const initLbug = async (repoId: string, dbPath: string): Promise<void> =>
shared.refCount++;
const db = shared.db;
// Pre-create a small pool of connections
// Pre-create the full pool upfront so createConnection() (which silences
// stdout) is never called lazily during active query execution.
// Mark preWarmActive so the watchdog timer doesn't interfere.
preWarmActive = true;
const available: lbug.Connection[] = [];
for (let i = 0; i < INITIAL_CONNS_PER_REPO; i++) {
available.push(createConnection(db));
try {
for (let i = 0; i < MAX_CONNS_PER_REPO; i++) {
available.push(createConnection(db));
}
} finally {
preWarmActive = false;
}
pool.set(repoId, { db, available, checkedOut: 0, waiters: [], lastUsed: Date.now(), dbPath });
ensureIdleTimer();
// Load FTS extension once per shared Database
// Load FTS extension once per shared Database.
// Done BEFORE pool registration so no concurrent checkout can grab
// the connection while the async FTS load is in progress.
if (!shared.ftsLoaded) {
try {
await available[0].query('LOAD EXTENSION fts');
@@ -228,7 +300,75 @@ export const initLbug = async (repoId: string, dbPath: string): Promise<void> =>
// Extension may not be installed — FTS queries will fail gracefully
}
}
};
// Register pool entry only after all connections are pre-warmed and FTS is
// loaded. Concurrent executeQuery calls see either "not initialized"
// (and throw cleanly) or a fully ready pool — never a half-built one.
pool.set(repoId, { db, available, checkedOut: 0, waiters: [], lastUsed: Date.now(), dbPath, closed: false });
ensureIdleTimer();
}
/**
* Initialize a pool entry from a pre-existing Database object.
*
* Used in tests to avoid the writable→close→read-only cycle that crashes
* on macOS due to N-API destructor segfaults. The pool adapter reuses
* the core adapter's writable Database instead of opening a new read-only one.
*
* The Database is registered in the shared dbCache so closeOne() decrements
* the refCount correctly. If the Database is already cached (e.g. another
* repoId already injected it), the existing entry is reused.
*/
export async function initLbugWithDb(
repoId: string,
existingDb: lbug.Database,
dbPath: string,
): Promise<void> {
const existing = pool.get(repoId);
if (existing) {
existing.lastUsed = Date.now();
return;
}
// Register in dbCache with external: true so other initLbug() calls
// for the same dbPath reuse this Database instead of trying to open
// a new one (which would fail with a file lock error).
// closeOne() respects the external flag and skips db.close().
let shared = dbCache.get(dbPath);
if (!shared) {
shared = { db: existingDb, refCount: 0, ftsLoaded: false, external: true };
dbCache.set(dbPath, shared);
}
shared.refCount++;
const available: lbug.Connection[] = [];
preWarmActive = true;
try {
for (let i = 0; i < MAX_CONNS_PER_REPO; i++) {
available.push(createConnection(existingDb));
}
} finally {
preWarmActive = false;
}
// Load FTS extension if not already loaded on this Database
try {
await available[0].query('LOAD EXTENSION fts');
} catch {
// Extension may already be loaded or not installed
}
pool.set(repoId, {
db: existingDb,
available,
checkedOut: 0,
waiters: [],
lastUsed: Date.now(),
dbPath,
closed: false
});
ensureIdleTimer();
}
/**
* Checkout a connection from the pool.
@@ -242,11 +382,16 @@ function checkout(entry: PoolEntry): Promise<lbug.Connection> {
return Promise.resolve(entry.available.pop()!);
}
// Grow the pool if under the cap
// Pool was pre-warmed to MAX_CONNS_PER_REPO during init. If we're here
// with fewer total connections, something leaked — surface the bug rather
// than silently creating a connection (which would silence stdout mid-query).
const totalConns = entry.available.length + entry.checkedOut;
if (totalConns < MAX_CONNS_PER_REPO) {
entry.checkedOut++;
return Promise.resolve(createConnection(entry.db));
throw new Error(
`Connection pool integrity error: expected ${MAX_CONNS_PER_REPO} ` +
`connections but found ${totalConns} (${entry.available.length} available, ` +
`${entry.checkedOut} checked out)`
);
}
// At capacity — queue the caller with a timeout.
@@ -266,10 +411,17 @@ function checkout(entry: PoolEntry): Promise<lbug.Connection> {
/**
* Return a connection to the pool after use.
* If the pool entry was closed while the connection was checked out (e.g.
* LRU eviction), close the orphaned connection instead of returning it.
* If there are queued waiters, hand the connection directly to the next one
* instead of putting it back in the available array (avoids race conditions).
*/
function checkin(entry: PoolEntry, conn: lbug.Connection): void {
if (entry.closed) {
// Pool entry was deleted during checkout — close the orphaned connection
conn.close().catch(() => {});
return;
}
if (entry.waiters.length > 0) {
// Hand directly to the next waiter — no intermediate available state
const waiter = entry.waiters.shift()!;
@@ -299,6 +451,10 @@ export const executeQuery = async (repoId: string, cypher: string): Promise<any[
throw new Error(`LadybugDB not initialized for repo "${repoId}". Call initLbug first.`);
}
if (isWriteQuery(cypher)) {
throw new Error('Write operations are not allowed. The pool adapter is read-only.');
}
entry.lastUsed = Date.now();
const conn = await checkout(entry);
@@ -370,3 +526,11 @@ export const closeLbug = async (repoId?: string): Promise<void> => {
* Check if a specific repo's pool is active
*/
export const isLbugReady = (repoId: string): boolean => pool.has(repoId);
/** Regex to detect write operations in user-supplied Cypher queries */
export const CYPHER_WRITE_RE = /\b(CREATE|DELETE|SET|MERGE|REMOVE|DROP|ALTER|COPY|DETACH)\b/i;
/** Check if a Cypher query contains write operations */
export function isWriteQuery(query: string): boolean {
return CYPHER_WRITE_RE.test(query);
}
+3 -3
View File
@@ -47,7 +47,7 @@ export const VALID_NODE_LABELS = new Set([
]);
/** Valid relation types for impact analysis filtering */
export const VALID_RELATION_TYPES = new Set(['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'OVERRIDES']);
export const VALID_RELATION_TYPES = new Set(['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'HAS_PROPERTY', 'OVERRIDES', 'ACCESSES']);
/** Regex to detect write operations in user-supplied Cypher queries */
export const CYPHER_WRITE_RE = /\b(CREATE|DELETE|SET|MERGE|REMOVE|DROP|ALTER|COPY|DETACH)\b/i;
@@ -898,7 +898,7 @@ export class LocalBackend {
// Categorized incoming refs
const incomingRows = await executeParameterized(repo.id, `
MATCH (caller)-[r:CodeRelation]->(n {id: $symId})
WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS']
WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'HAS_PROPERTY', 'OVERRIDES', 'ACCESSES']
RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller)[0] AS kind
LIMIT 30
`, { symId });
@@ -906,7 +906,7 @@ export class LocalBackend {
// Categorized outgoing refs
const outgoingRows = await executeParameterized(repo.id, `
MATCH (n {id: $symId})-[r:CodeRelation]->(target)
WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS']
WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'HAS_PROPERTY', 'OVERRIDES', 'ACCESSES']
RETURN r.type AS relType, target.id AS uid, target.name AS name, target.filePath AS filePath, labels(target)[0] AS kind
LIMIT 30
`, { symId });
+11
View File
@@ -321,6 +321,13 @@ nodes:
additional_node_types: "Multi-language: Struct, Enum, Macro, Typedef, Union, Namespace, Trait, Impl, TypeAlias, Const, Static, Property, Record, Delegate, Annotation, Constructor, Template, Module (use backticks in queries: \`Struct\`, \`Enum\`, etc.)"
node_properties:
common: "name (STRING), filePath (STRING), startLine (INT32), endLine (INT32)"
Method: "parameterCount (INT32), returnType (STRING), isVariadic (BOOL)"
Function: "parameterCount (INT32), returnType (STRING), isVariadic (BOOL)"
Property: "declaredType (STRING) — the field's type annotation (e.g., 'Address', 'City'). Used for field-access chain resolution."
Constructor: "parameterCount (INT32)"
relationships:
- CONTAINS: File/Folder contains child
- DEFINES: File defines a symbol
@@ -328,6 +335,10 @@ relationships:
- IMPORTS: Module imports
- EXTENDS: Class inheritance
- IMPLEMENTS: Interface implementation
- HAS_METHOD: Class/Struct/Interface owns a Method
- HAS_PROPERTY: Class/Struct/Interface owns a Property (field)
- ACCESSES: Function/Method reads or writes a Property (reason: 'read' or 'write')
- OVERRIDES: Method overrides another Method (MRO)
- MEMBER_OF: Symbol belongs to community
- STEP_IN_PROCESS: Symbol is step N in process
+26 -4
View File
@@ -24,6 +24,7 @@ import {
GetPromptRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import { GITNEXUS_TOOLS } from './tools.js';
import { realStdoutWrite } from './core/lbug-adapter.js';
import type { LocalBackend } from './local/local-backend.js';
import { getResourceDefinitions, getResourceTemplates, readResource } from './resources.js';
@@ -276,24 +277,45 @@ Follow these steps:
export async function startMCPServer(backend: LocalBackend): Promise<void> {
const server = createMCPServer(backend);
// Connect to stdio transport
const transport = new CompatibleStdioServerTransport();
// Use the shared stdout reference captured at module-load time by the
// lbug-adapter. Avoids divergence if anything patches stdout between
// module load and server start.
const _safeStdout = new Proxy(process.stdout, {
get(target, prop, receiver) {
if (prop === 'write') return realStdoutWrite;
const val = Reflect.get(target, prop, receiver);
return typeof val === 'function' ? val.bind(target) : val;
}
});
const transport = new CompatibleStdioServerTransport(process.stdin, _safeStdout);
await server.connect(transport);
// Graceful shutdown helper
let shuttingDown = false;
const shutdown = async () => {
const shutdown = async (exitCode = 0) => {
if (shuttingDown) return;
shuttingDown = true;
try { await backend.disconnect(); } catch {}
try { await server.close(); } catch {}
process.exit(0);
process.exit(exitCode);
};
// Handle graceful shutdown
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
// Log crashes to stderr so they aren't silently lost.
// uncaughtException is fatal — shut down.
// unhandledRejection is logged but kept non-fatal (availability-first):
// killing the server for one missed catch would be worse than logging it.
process.on('uncaughtException', (err) => {
process.stderr.write(`GitNexus MCP uncaughtException: ${err?.stack || err}\n`);
shutdown(1);
});
process.on('unhandledRejection', (reason: any) => {
process.stderr.write(`GitNexus MCP unhandledRejection: ${reason?.stack || reason}\n`);
});
// Handle stdio errors — stdin close means the parent process is gone
process.stdin.on('end', shutdown);
process.stdin.on('error', () => shutdown());
+15 -5
View File
@@ -78,7 +78,7 @@ SCHEMA:
- Nodes: File, Folder, Function, Class, Interface, Method, CodeElement, Community, Process
- Multi-language nodes (use backticks): \`Struct\`, \`Enum\`, \`Trait\`, \`Impl\`, etc.
- All edges via single CodeRelation table with 'type' property
- Edge types: CONTAINS, DEFINES, CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, OVERRIDES, MEMBER_OF, STEP_IN_PROCESS
- Edge types: CONTAINS, DEFINES, CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, HAS_PROPERTY, ACCESSES, OVERRIDES, MEMBER_OF, STEP_IN_PROCESS
- Edge properties: type (STRING), confidence (DOUBLE), reason (STRING), step (INT32)
EXAMPLES:
@@ -94,6 +94,12 @@ EXAMPLES:
• Find all methods of a class:
MATCH (c:Class {name: "UserService"})-[r:CodeRelation {type: 'HAS_METHOD'}]->(m:Method) RETURN m.name, m.parameterCount, m.returnType
• Find all properties of a class:
MATCH (c:Class {name: "User"})-[r:CodeRelation {type: 'HAS_PROPERTY'}]->(p:Property) RETURN p.name, p.declaredType
• Find all writers of a field:
MATCH (f:Function)-[r:CodeRelation {type: 'ACCESSES', reason: 'write'}]->(p:Property) WHERE p.name = "address" RETURN f.name, f.filePath
• Find method overrides (MRO resolution):
MATCH (winner:Method)-[r:CodeRelation {type: 'OVERRIDES'}]->(loser:Method) RETURN winner.name, winner.filePath, loser.filePath, r.reason
@@ -119,12 +125,14 @@ TIPS:
{
name: 'context',
description: `360-degree view of a single code symbol.
Shows categorized incoming/outgoing references (calls, imports, extends, implements), process participation, and file location.
Shows categorized incoming/outgoing references (calls, imports, extends, implements, methods, properties, overrides), process participation, and file location.
WHEN TO USE: After query() to understand a specific symbol in depth. When you need to know all callers, callees, and what execution flows a symbol participates in.
AFTER THIS: Use impact() if planning changes, or READ gitnexus://repo/{name}/process/{processName} for full execution trace.
Handles disambiguation: if multiple symbols share the same name, returns candidates for you to pick from. Use uid param for zero-ambiguity lookup from prior results.`,
Handles disambiguation: if multiple symbols share the same name, returns candidates for you to pick from. Use uid param for zero-ambiguity lookup from prior results.
NOTE: ACCESSES edges (field read/write tracking) are included in context results with reason 'read' or 'write'. CALLS edges resolve through field access chains and method-call chains (e.g., user.address.getCity().save() produces CALLS edges at each step).`,
inputSchema: {
type: 'object',
properties: {
@@ -200,7 +208,9 @@ Depth groups:
- d=2: LIKELY AFFECTED (indirect)
- d=3: MAY NEED TESTING (transitive)
EdgeType: CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, OVERRIDES
TIP: Default traversal uses CALLS/IMPORTS/EXTENDS/IMPLEMENTS. For class members, include HAS_METHOD and HAS_PROPERTY in relationTypes. For field access analysis, include ACCESSES in relationTypes.
EdgeType: CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, HAS_PROPERTY, OVERRIDES, ACCESSES
Confidence: 1.0 = certain, <0.8 = fuzzy match`,
inputSchema: {
type: 'object',
@@ -208,7 +218,7 @@ Confidence: 1.0 = certain, <0.8 = fuzzy match`,
target: { type: 'string', description: 'Name of function, class, or file to analyze' },
direction: { type: 'string', description: 'upstream (what depends on this) or downstream (what this depends on)' },
maxDepth: { type: 'number', description: 'Max relationship depth (default: 3)', default: 3 },
relationTypes: { type: 'array', items: { type: 'string' }, description: 'Filter: CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, OVERRIDES (default: usage-based)' },
relationTypes: { type: 'array', items: { type: 'string' }, description: 'Filter: CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, HAS_PROPERTY, OVERRIDES, ACCESSES (default: usage-based, ACCESSES excluded by default)' },
includeTests: { type: 'boolean', description: 'Include test files (default: false)' },
minConfidence: { type: 'number', description: 'Minimum confidence 0-1 (default: 0.7)' },
repo: { type: 'string', description: 'Repository name or path. Omit if only one repo is indexed.' },
@@ -0,0 +1,6 @@
#include "user.h"
void processUser() {
auto user = getUser("alice");
user.save();
}
@@ -0,0 +1,14 @@
#pragma once
#include <string>
class User {
public:
User(const std::string& n) : name_(n) {}
bool save() { return true; }
private:
std::string name_;
};
User getUser(const std::string& name) {
return User(name);
}
@@ -1,4 +1,5 @@
#include "service.h"
#include "user.h"
#include "repo.h"
void processUser() {
@@ -0,0 +1,30 @@
#pragma once
class City {
public:
std::string zipCode;
std::string getName() {
return "city";
}
};
class Address {
public:
City city;
std::string street;
void save() {
// persist address
}
};
class User {
public:
std::string name;
Address address;
std::string greet() {
return name;
}
};
@@ -0,0 +1,9 @@
#include "models.h"
void processUser(User user) {
// 2-level chain: user.address → Address, then .save() → Address#save
user.address.save();
// 3-level chain: user.address → Address, .city → City, .getName() → City#getName
user.address.city.getName();
}
@@ -0,0 +1,20 @@
#pragma once
class Address {
public:
std::string city;
void save() {
// persist address
}
};
class User {
public:
std::string name;
Address address;
std::string greet() {
return name;
}
};
@@ -0,0 +1,6 @@
#include "models.h"
void processUser(User user) {
// Field-access chain: user.address → Address, then .save() → Address#save
user.address.save();
}
@@ -0,0 +1,8 @@
#include "models.h"
void processChain() {
auto user = getUser();
auto addr = user.address;
auto city = addr.getCity();
city.save();
}
@@ -0,0 +1,26 @@
#pragma once
#include <string>
class City {
public:
std::string name;
City(const std::string& n) : name(n) {}
bool save() { return true; }
};
class Address {
public:
City city;
Address(const City& c) : city(c) {}
City getCity() { return city; }
};
class User {
public:
Address address;
User(const Address& a) : address(a) {}
};
User getUser() {
return User(Address(City("NYC")));
}
@@ -0,0 +1,21 @@
#pragma once
class Address {
public:
std::string city;
void save() {
// persist address
}
};
class User {
public:
Address* address; // raw pointer member field
Address& ref_address; // reference member field
std::string name;
std::string greet() {
return name;
}
};
@@ -0,0 +1,6 @@
#include "models.h"
void processUser(User user) {
// Pointer member field access: user.address->save()
user.address->save();
}
@@ -0,0 +1,12 @@
#pragma once
class Address {
public:
std::string city;
};
class User {
public:
std::string name;
Address address;
};
@@ -0,0 +1,7 @@
#include "models.h"
void updateUser(User& user) {
user.name = "Alice";
user.address = Address();
user.name += " Smith";
}
@@ -0,0 +1,22 @@
class User {
public string Name { get; set; }
public User(string name) {
Name = name;
}
public bool Save() {
return true;
}
}
class App {
static User GetUser(string name) {
return new User(name);
}
void ProcessUser() {
var user = GetUser("alice");
user.Save();
}
}
@@ -0,0 +1,33 @@
namespace DeepFieldChain;
public class City
{
public string ZipCode { get; set; }
public string GetName()
{
return "city";
}
}
public class Address
{
public City City { get; set; }
public string Street { get; set; }
public void Save()
{
// persist address
}
}
public class User
{
public string Name { get; set; }
public Address Address { get; set; }
public string Greet()
{
return Name;
}
}
@@ -0,0 +1,13 @@
namespace DeepFieldChain;
public class Service
{
public static void ProcessUser(User user)
{
// 2-level chain: user.Address → Address, then .Save() → Address#Save
user.Address.Save();
// 3-level chain: user.Address → Address, .City → City, .GetName() → City#GetName
user.Address.City.GetName();
}
}
@@ -0,0 +1,22 @@
namespace FieldTypes;
public class Address
{
public string City { get; set; }
public void Save()
{
// persist address
}
}
public class User
{
public string Name { get; set; }
public Address Address { get; set; }
public string Greet()
{
return Name;
}
}
@@ -0,0 +1,10 @@
namespace FieldTypes;
public class Service
{
public static void ProcessUser(User user)
{
// Field-access chain: user.Address → Address, then .Save() → Address#Save
user.Address.Save();
}
}
@@ -0,0 +1,29 @@
class City {
public string Name { get; set; }
public City(string name) { Name = name; }
public bool Save() { return true; }
}
class Address {
public City City { get; set; }
public Address(City city) { City = city; }
public City GetCity() { return City; }
}
class User {
public Address Address { get; set; }
public User(Address address) { Address = address; }
}
class App {
static User GetUser() {
return new User(new Address(new City("NYC")));
}
void ProcessChain() {
var user = GetUser();
var addr = user.Address;
var city = addr.GetCity();
city.Save();
}
}
@@ -0,0 +1,8 @@
public class Address {
public string City { get; set; }
}
public class User {
public string Name { get; set; }
public Address Address { get; set; }
}
@@ -0,0 +1,6 @@
public class UserService {
public void UpdateUser(User user) {
user.Name = "Alice";
user.Address = new Address();
}
}
@@ -0,0 +1,24 @@
export class Address {
city: string;
save(): void {
// persist address
}
}
export class User {
name: string;
address: Address;
greet(): string {
return this.name;
}
}
export class Config {
static DEFAULT: Config = new Config();
validate(): boolean {
return true;
}
}
@@ -0,0 +1,11 @@
import { User, Config } from './models';
function processUser(user: User) {
// Field-access chain: user.address resolves to Address, then .save() resolves to Address#save
user.address.save();
}
function validateConfig() {
// Static field access: Config.DEFAULT resolves to Config, then .validate() resolves to Config#validate
Config.DEFAULT.validate();
}
@@ -0,0 +1,12 @@
package main
import "example.com/callresult/models"
func GetUser(name string) *models.User {
return &models.User{Name: name}
}
func processUser() {
user := GetUser("alice")
user.Save()
}
@@ -0,0 +1,3 @@
module example.com/callresult
go 1.21
@@ -0,0 +1,9 @@
package models
type User struct {
Name string
}
func (u *User) Save() bool {
return true
}
@@ -0,0 +1,11 @@
package main
import "example.com/go-deep-field-chain/models"
func processUser(user models.User) {
// 2-level chain: user.Address → Address, then .Save() → Address#Save
user.Address.Save()
// 3-level chain: user.Address → Address, .City → City, .GetName() → City#GetName
user.Address.City.GetName()
}
@@ -0,0 +1,3 @@
module example.com/go-deep-field-chain
go 1.21
@@ -0,0 +1,27 @@
package models
type City struct {
ZipCode string
}
func (c *City) GetName() string {
return "city"
}
type Address struct {
City City
Street string
}
func (a *Address) Save() bool {
return true
}
type User struct {
Name string
Address Address
}
func (u *User) Greet() string {
return u.Name
}
@@ -0,0 +1,8 @@
package main
import "example.com/go-field-types/models"
func processUser(user models.User) {
// Field-access chain: user.Address → Address, then .Save() → Address#Save
user.Address.Save()
}
@@ -0,0 +1,3 @@
module example.com/go-field-types
go 1.21
@@ -0,0 +1,18 @@
package models
type Address struct {
City string
}
func (a *Address) Save() bool {
return true
}
type User struct {
Name string
Address Address
}
func (u *User) Greet() string {
return u.Name
}
@@ -0,0 +1,14 @@
package main
import "example.com/methodchain/models"
func GetUser() *models.User {
return &models.User{}
}
func processChain() {
user := GetUser()
addr := user.Address
city := addr.GetCity()
city.Save()
}
@@ -0,0 +1,3 @@
module example.com/methodchain
go 1.21
@@ -0,0 +1,21 @@
package models
type City struct {
Name string
}
func (c *City) Save() bool {
return true
}
type Address struct {
City City
}
func (a *Address) GetCity() *City {
return &a.City
}
type User struct {
Address Address
}
@@ -0,0 +1,11 @@
package main
import "example.com/go-mixed-chain/models"
func processWithService(svc *models.UserService) {
svc.GetUser().Address.Save()
}
func processWithUser(user *models.User) {
user.GetAddress().City.GetName()
}
@@ -0,0 +1,3 @@
module example.com/go-mixed-chain
go 1.21
@@ -0,0 +1,32 @@
package models
type City struct {
Name string
}
func (c *City) GetName() string {
return c.Name
}
type Address struct {
City City
Street string
}
func (a *Address) Save() {
}
type User struct {
Name string
Address Address
}
func (u *User) GetAddress() *Address {
return &u.Address
}
type UserService struct{}
func (s *UserService) GetUser() *User {
return &User{}
}
@@ -0,0 +1,15 @@
package main
type Address struct {
City string
}
type User struct {
Name string
Address Address
}
func updateUser(user *User) {
user.Name = "Alice"
user.Address = Address{City: "NYC"}
}
@@ -0,0 +1,10 @@
public class App {
static User getUser(String name) {
return new User(name);
}
void processUser() {
var user = getUser("alice");
user.save();
}
}
@@ -0,0 +1,11 @@
public class User {
private String name;
public User(String name) {
this.name = name;
}
public boolean save() {
return true;
}
}
@@ -0,0 +1,11 @@
import models.User;
public class App {
public static void processUser(User user) {
// 2-level chain: user.address → Address, then .save() → Address#save
user.address.save();
// 3-level chain: user.address → Address, .city → City, .getName() → City#getName
user.address.city.getName();
}
}
@@ -0,0 +1,10 @@
package models;
public class Address {
public City city;
public String street;
public void save() {
// persist address
}
}
@@ -0,0 +1,9 @@
package models;
public class City {
public String zipCode;
public String getName() {
return "city";
}
}
@@ -0,0 +1,10 @@
package models;
public class User {
public String name;
public Address address;
public String greet() {
return this.name;
}
}
@@ -0,0 +1,8 @@
import models.User;
public class App {
public static void processUser(User user) {
// Field-access chain: user.address → Address, then .save() → Address#save
user.address.save();
}
}
@@ -0,0 +1,9 @@
package models;
public class Address {
public String city;
public void save() {
// persist address
}
}
@@ -0,0 +1,10 @@
package models;
public class User {
public String name;
public Address address;
public String greet() {
return this.name;
}
}
@@ -0,0 +1,12 @@
class App {
static User getUser() {
return new User(new Address(new City("NYC")));
}
void processChain() {
var user = getUser();
var addr = user.address;
var city = addr.getCity();
city.save();
}
}
@@ -0,0 +1,16 @@
class City {
String name;
City(String name) { this.name = name; }
boolean save() { return true; }
}
class Address {
City city;
Address(City city) { this.city = city; }
City getCity() { return city; }
}
class User {
Address address;
User(Address address) { this.address = address; }
}
@@ -0,0 +1,12 @@
import services.UserService;
import models.User;
public class App {
public static void processWithService(UserService svc) {
svc.getUser().address.save();
}
public static void processWithUser(User user) {
user.getAddress().city.getName();
}
}
@@ -0,0 +1,8 @@
package models;
public class Address {
public City city;
public void save() {
}
}
@@ -0,0 +1,7 @@
package models;
public class City {
public String getName() {
return "city";
}
}
@@ -0,0 +1,9 @@
package models;
public class User {
public Address address;
public Address getAddress() {
return this.address;
}
}
@@ -0,0 +1,9 @@
package services;
import models.User;
public class UserService {
public User getUser() {
return new User();
}
}
@@ -0,0 +1,9 @@
import models.User;
import models.Address;
public class App {
public static void updateUser(User user) {
user.name = "Alice";
user.address = new Address();
}
}
@@ -0,0 +1,5 @@
package models;
public class Address {
public String city;
}

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