From b43cb5ee5552900fee9db7cfe78e0ff99b242471 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 20:17:03 +0000 Subject: [PATCH 01/17] chore(deps): bump softprops/action-gh-release from 2.5.0 to 3.0.0 Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2.5.0 to 3.0.0. - [Release notes](https://github.com/softprops/action-gh-release/releases) - [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md) - [Commits](https://github.com/softprops/action-gh-release/compare/a06a81a03ee405af7f2048a818ed3f03bbf83c7b...b4309332981a82ec1c5618f44dd2e27cc8bfbfda) --- updated-dependencies: - dependency-name: softprops/action-gh-release dependency-version: 3.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/publish.yml | 2 +- .github/workflows/release-candidate.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 03898a267..3d883425a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -91,7 +91,7 @@ jobs: fi - name: Create GitHub Release - uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2 + uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v2 with: body_path: ${{ steps.changelog.outputs.fallback == 'false' && '/tmp/release-notes.md' || '' }} generate_release_notes: ${{ steps.changelog.outputs.fallback == 'true' }} diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index cc22f4749..d4db75db0 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -346,7 +346,7 @@ jobs: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Create GitHub prerelease - uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2 + uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v2 with: tag_name: ${{ steps.reltag.outputs.vtag }} name: Release Candidate ${{ steps.reltag.outputs.vtag }} From df429ea60c896719edf297fab43201b52ceef96e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 20:17:08 +0000 Subject: [PATCH 02/17] chore(deps): bump actions/github-script from 7.0.1 to 9.0.0 Bumps [actions/github-script](https://github.com/actions/github-script) from 7.0.1 to 9.0.0. - [Release notes](https://github.com/actions/github-script/releases) - [Commits](https://github.com/actions/github-script/compare/v7.0.1...3a2844b7e9c422d3c10d287c895573f7108da1b3) --- updated-dependencies: - dependency-name: actions/github-script dependency-version: 9.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci-report.yml | 4 ++-- .github/workflows/claude-code-review.yml | 2 +- .github/workflows/claude.yml | 2 +- .github/workflows/pr-description-check.yml | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci-report.yml b/.github/workflows/ci-report.yml index a1fa33219..42088dc04 100644 --- a/.github/workflows/ci-report.yml +++ b/.github/workflows/ci-report.yml @@ -36,7 +36,7 @@ jobs: steps: # ── Download artifacts from the CI run ──────────────────────── - name: Download artifacts - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v7 with: script: | const fs = require('fs'); @@ -132,7 +132,7 @@ jobs: - name: Fetch base branch coverage if: steps.meta.outputs.skip != 'true' id: base-coverage - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v7 with: script: | const fs = require('fs'); diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index d9d9decf6..e5642cb3e 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -57,7 +57,7 @@ jobs: # For issue_comment triggers, resolve the PR number, head SHA, and fork repo - name: Resolve PR context id: pr - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v7 with: script: | let pr; diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index c4a2e9450..553d3ab0d 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -59,7 +59,7 @@ jobs: # For PR-related triggers, resolve the fork repo so we can checkout correctly. - name: Resolve PR context id: pr - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v7 with: script: | // Determine if this event is PR-related diff --git a/.github/workflows/pr-description-check.yml b/.github/workflows/pr-description-check.yml index de562fd35..cec32de0f 100644 --- a/.github/workflows/pr-description-check.yml +++ b/.github/workflows/pr-description-check.yml @@ -19,7 +19,7 @@ jobs: timeout-minutes: 5 steps: - name: Check PR description quality - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | const MIN_BODY_LENGTH = 50; From ed07c18b8d2bf66e6e230a795ebdf67b3adab7be Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 20:17:10 +0000 Subject: [PATCH 03/17] chore(deps): bump marocchino/sticky-pull-request-comment Bumps [marocchino/sticky-pull-request-comment](https://github.com/marocchino/sticky-pull-request-comment) from 2.9.4 to 3.0.4. - [Release notes](https://github.com/marocchino/sticky-pull-request-comment/releases) - [Commits](https://github.com/marocchino/sticky-pull-request-comment/compare/773744901bac0e8cbb5a0dc842800d45e9b2b405...0ea0beb66eb9baf113663a64ec522f60e49231c0) --- updated-dependencies: - dependency-name: marocchino/sticky-pull-request-comment dependency-version: 3.0.4 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci-report.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-report.yml b/.github/workflows/ci-report.yml index a1fa33219..9deb62746 100644 --- a/.github/workflows/ci-report.yml +++ b/.github/workflows/ci-report.yml @@ -416,7 +416,7 @@ jobs: - name: Comment on PR if: steps.meta.outputs.skip != 'true' - uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2 + uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v2 with: header: ci-report number: ${{ steps.meta.outputs.pr_number }} From 7001e8e4b4ea13ac28c59fcbe636e03d18fa4787 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 20:17:15 +0000 Subject: [PATCH 04/17] chore(deps): bump release-drafter/release-drafter from 6.0.0 to 7.2.0 Bumps [release-drafter/release-drafter](https://github.com/release-drafter/release-drafter) from 6.0.0 to 7.2.0. - [Release notes](https://github.com/release-drafter/release-drafter/releases) - [Commits](https://github.com/release-drafter/release-drafter/compare/3f0f87098bd6b5c5b9a36d49c41d998ea58f9348...5de93583980a40bd78603b6dfdcda5b4df377b32) --- updated-dependencies: - dependency-name: release-drafter/release-drafter dependency-version: 7.2.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/pr-labeler.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml index aab2fcaef..43aeba311 100644 --- a/.github/workflows/pr-labeler.yml +++ b/.github/workflows/pr-labeler.yml @@ -105,7 +105,7 @@ jobs: # Pinned to v6.0.0. Verify SHA via: # gh api repos/release-drafter/release-drafter/git/refs/tags/v6.0.0 # Note: dependabot will likely propose a bump to v6.x on first run. - - uses: release-drafter/release-drafter@3f0f87098bd6b5c5b9a36d49c41d998ea58f9348 # v6.0.0 + - uses: release-drafter/release-drafter@5de93583980a40bd78603b6dfdcda5b4df377b32 # v7.2.0 with: config-name: release-drafter.yml disable-releaser: true From 1d0fb782a3bb1ce7b429c42c53600d4d6e72fc23 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 20:17:22 +0000 Subject: [PATCH 05/17] chore(deps): bump amannn/action-semantic-pull-request Bumps [amannn/action-semantic-pull-request](https://github.com/amannn/action-semantic-pull-request) from 5.5.3 to 6.1.1. - [Release notes](https://github.com/amannn/action-semantic-pull-request/releases) - [Changelog](https://github.com/amannn/action-semantic-pull-request/blob/main/CHANGELOG.md) - [Commits](https://github.com/amannn/action-semantic-pull-request/compare/0723387faaf9b38adef4775cd42cfd5155ed6017...48f256284bd46cdaab1048c3721360e808335d50) --- updated-dependencies: - dependency-name: amannn/action-semantic-pull-request dependency-version: 6.1.1 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/pr-labeler.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml index aab2fcaef..3896fcb26 100644 --- a/.github/workflows/pr-labeler.yml +++ b/.github/workflows/pr-labeler.yml @@ -59,7 +59,7 @@ jobs: steps: # Pinned to v5.5.3. Verify SHA via: # gh api repos/amannn/action-semantic-pull-request/git/refs/tags/v5.5.3 - - uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017 # v5.5.3 + - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: From 54d02fcc221e878aaa6a4622d822a4a3793f559e Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Thu, 16 Apr 2026 07:48:21 +0100 Subject: [PATCH 06/17] fix(ci): replace removed disable-releaser with dry-run for release-drafter v7 release-drafter v7 (merged in #852) removed the `disable-releaser` input, causing the autolabel job to attempt creating a release and fail with "Resource not accessible by integration". Replace with `dry-run: true` which achieves the same label-only behavior. Also update stale version comments for release-drafter and action-semantic-pull-request to match the actual pinned versions. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/pr-labeler.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml index 384587a87..3c0c52725 100644 --- a/.github/workflows/pr-labeler.yml +++ b/.github/workflows/pr-labeler.yml @@ -12,7 +12,7 @@ name: PR Conventional Labeler # autolabel (on: pull_request_target) # Needs `pull-requests: write` to apply labels, so must be # pull_request_target. Uses `release-drafter/release-drafter` with -# `disable-releaser: true` to only run the autolabeler against the +# `dry-run: true` to only run the autolabeler against the # `.github/release-drafter.yml` config from the BASE ref (release- # drafter reads the config from the repository's default branch, NOT # the PR head — verify with `gh api repos/release-drafter/release-drafter/contents/...` @@ -57,8 +57,8 @@ jobs: permissions: pull-requests: read steps: - # Pinned to v5.5.3. Verify SHA via: - # gh api repos/amannn/action-semantic-pull-request/git/refs/tags/v5.5.3 + # Pinned to v6.1.1. Verify SHA via: + # gh api repos/amannn/action-semantic-pull-request/git/refs/tags/v6.1.1 - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -102,12 +102,12 @@ jobs: contents: read pull-requests: write steps: - # Pinned to v6.0.0. Verify SHA via: - # gh api repos/release-drafter/release-drafter/git/refs/tags/v6.0.0 - # Note: dependabot will likely propose a bump to v6.x on first run. + # Pinned to v7.2.0. Verify SHA via: + # gh api repos/release-drafter/release-drafter/git/refs/tags/v7.2.0 + # v7 removed `disable-releaser`; use `dry-run: true` to only autolabel. - uses: release-drafter/release-drafter@5de93583980a40bd78603b6dfdcda5b4df377b32 # v7.2.0 with: config-name: release-drafter.yml - disable-releaser: true + dry-run: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 0a4b31b3c52f94a9d20c456abfef3c8b50771dc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Thu, 16 Apr 2026 08:43:11 +0100 Subject: [PATCH 07/17] docs: optimize context files for LLM accuracy and token efficiency (#857) * docs: optimize context files for LLM accuracy and token efficiency Fix factual errors across all five root context files and optimize for LLM context window efficiency. Corrections: - Web UI: "runs entirely in WASM" -> thin client backed by HTTP API - Pre-commit hook: "typecheck + tests" -> formatting + typecheck only - MCP tools: 7 -> 16 (added api_impact, route_map, tool_map, shape_check, group_list/query/sync/contracts/status) - Default serve port: 3741 -> 4747 - E2E tests: "5 tests" -> 7 spec files - ESLint: "no config" -> eslint.config.mjs exists with TS/React rules - npm test: "vitest run test/unit" -> "vitest run" (full suite) - Removed nonexistent test:all script - ci-quality.yml: added missing format + lint job descriptions - Pipeline phase deps: added missing structure dep on mro/communities/processes - Ingestion entry: added missing run-analyze.ts intermediate orchestrator - Tools Quick Reference: added missing list_repos - Group tool examples: fixed param name (group -> name) - Removed stale vite-plugin-wasm gotcha - Added gitnexus-shared to repository layout tables New documentation: - ARCHITECTURE.md: language-agnostic graph feeding (provider pattern, unified capture tags, import resolution tiers, chunked parse, MRO) - ARCHITECTURE.md: full analysis flow (10 stages with progress %) - ARCHITECTURE.md: storage layout, LadybugDB schema, embeddings, search - ARCHITECTURE.md: DAG runner internals (Kahn's sort, dep isolation, error handling) Token optimization: - Removed filler prose, compressed descriptions into dense tables - Front-loaded key facts in every section - Eliminated redundancy between sections - AGENTS.md: 219 -> 201 lines. ARCHITECTURE.md: 192 -> 298 lines (more info in fewer tokens via tables and structure) * docs: optimize GUARDRAILS.md for LLM context efficiency Tighten prose without losing information: - Compressed intro, scope section, and Signs format labels - Shortened Sign headers (removed "Sign:" prefix) - Replaced verbose "Instruction/Reason" labels with "Do/Why" - Removed trailing whitespace and redundant emphasis --- AGENTS.md | 202 ++++++++++++++-------------- ARCHITECTURE.md | 349 ++++++++++++++++++++++++++++++++---------------- CONTRIBUTING.md | 2 +- GUARDRAILS.md | 89 ++++++------ TESTING.md | 13 +- 5 files changed, 384 insertions(+), 271 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 651657b02..f212fae0c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,117 +1,120 @@ - - + + -Last reviewed: 2026-04-13 +Last reviewed: 2026-04-16 **Project:** GitNexus · **Environment:** dev · **Maintainer:** repository maintainers (see GitHub) -This file uses a standard agent header (version, scope, model policy, reference docs, changelog), adapted for this **TypeScript/JavaScript monorepo**. - ## Scope -| | | -|--|--| -| **Reads** | Repository tree as needed for the task: `gitnexus/`, `gitnexus-web/`, `eval/`, plugin packages, `.github/`, `.gitnexus/` when present, and docs. | -| **Writes** | Only paths required for the requested change; keep diffs minimal. Update lockfiles when dependencies change. | -| **Executes** | `npm`, `npx`, `node` under `gitnexus/` and `gitnexus-web/`; `uv run` for Python under `eval/` when applicable; shell utilities for documented CI/dev workflows. | -| **Off-limits** | User secrets (e.g. real `.env`), production deployment credentials, unrelated repositories, destructive git history operations without explicit human confirmation. | +| Boundary | Rule | +|----------|------| +| **Reads** | `gitnexus/`, `gitnexus-web/`, `eval/`, plugin packages, `.github/`, `.gitnexus/`, docs. | +| **Writes** | Only paths required for the change; keep diffs minimal. Update lockfiles when deps change. | +| **Executes** | `npm`, `npx`, `node` under `gitnexus/` and `gitnexus-web/`; `uv run` for Python under `eval/`; documented CI/dev workflows. | +| **Off-limits** | Real `.env` / secrets, production credentials, unrelated repos, destructive git ops without confirmation. | ## Model Configuration -- **Primary:** Pin in **Cursor** (Settings → model). Use a **named** model (e.g. GPT-5.2, Claude Sonnet 4.x). Avoid relying on **Auto** when reproducibility or audit trail matters. -- **Fallback:** As configured in Cursor or your organization (do not encode `latest` or wildcards in automation configs). -- **Notes:** The open-source GitNexus CLI indexer does not call an LLM. Optional Nexus AI in the web UI uses end-user provider keys and models. +- **Primary:** Use a named model (e.g. Claude Sonnet 4.x). Avoid `Auto` or unversioned `latest` when reproducibility matters. +- **Notes:** The GitNexus CLI indexer does not call an LLM. ## Execution Sequence (complex tasks) -Long sessions dilute instructions. For **multi-step** work, state up front: - +For multi-step work, state up front: 1. Which rules in this file and **[GUARDRAILS.md](GUARDRAILS.md)** apply (and any relevant Signs). -2. Current **Scope** boundaries (Reads / Writes / Off-limits). -3. Which **validation commands** you will run (e.g. `cd gitnexus && npm test`, `npx tsc --noEmit`). +2. Current **Scope** boundaries. +3. Which **validation commands** you will run (`cd gitnexus && npm test`, `npx tsc --noEmit`). -On very long threads, the human may add *“Remember: apply all AGENTS.md rules”* to re-weight rule tokens against context dilution. +On long threads, *"Remember: apply all AGENTS.md rules"* re-weights these instructions against context dilution. ## Claude Code hooks -Hooks enforce gates that prompts cannot. In **Claude Code**, **PreToolUse** hooks can block tools such as `git_commit` until checks pass. Adapt to this repo: e.g. `cd gitnexus && npm test` before commit. +**PreToolUse** hooks can block tools (e.g. `git_commit`) until checks pass. Adapt to this repo: `cd gitnexus && npm test` before commit. -## Context budget (Cursor / standards) +## Context budget -Generic “core standards” playbooks are often long and stack-specific. For this monorepo, commands and gotchas live under **Cursor Cloud specific instructions** below and in **[CONTRIBUTING.md](CONTRIBUTING.md)**. If always-on rules grow, split domain rules into **`.cursor/rules/*.mdc`** (globs). **Cursor:** project-wide rules live in **`.cursor/index.mdc`** (YAML frontmatter with `alwaysApply: true`). **Claude Code:** optionally load a **`STANDARDS.md`** only when needed (e.g. *“When writing new code, read STANDARDS.md”*) to save context. +Commands and gotchas live under **Repo reference** below and in **[CONTRIBUTING.md](CONTRIBUTING.md)**. If always-on rules grow, split into **`.cursor/rules/*.mdc`** (globs). **Cursor:** project-wide rules in `.cursor/index.mdc`. **Claude Code:** load `STANDARDS.md` only when needed. -## Reference Documentation +## Reference docs -- **This repository:** **[ARCHITECTURE.md](ARCHITECTURE.md)**, **[CONTRIBUTING.md](CONTRIBUTING.md)**, **[GUARDRAILS.md](GUARDRAILS.md)**. -- **Cursor:** `.cursor/index.mdc` (always-on rules); optional `.cursor/rules/*.mdc` (glob-scoped). Legacy `.cursorrules` is deprecated — see `.cursor/index.mdc`. -- **Optional local files:** `NOTES.md` (short vendor-neutral project snapshot). For handoffs, keep notes local (e.g., a scratch file outside the repo) rather than committing `HANDOFF.md`. -- **GitNexus:** skills under `.claude/skills/gitnexus/`; machine-oriented rules in the `gitnexus:start` … `gitnexus:end` block below. +- **[ARCHITECTURE.md](ARCHITECTURE.md)**, **[CONTRIBUTING.md](CONTRIBUTING.md)**, **[GUARDRAILS.md](GUARDRAILS.md)** +- **Cursor:** `.cursor/index.mdc` (always-on); `.cursor/rules/*.mdc` (glob-scoped). Legacy `.cursorrules` deprecated. +- **GitNexus:** skills in `.claude/skills/gitnexus/`; MCP rules in `gitnexus:start` block below. ## Changelog | Date | Version | Change | |------|---------|--------| +| 2026-04-16 | 1.4.0 | Fixed: web UI description, pre-commit behavior, MCP tools (7->16), added gitnexus-shared, removed stale vite-plugin-wasm gotcha. | | 2026-04-13 | 1.3.0 | Updated GitNexus index stats after DAG refactor. | -| 2026-03-24 | 1.2.0 | Fixed gitnexus:start block duplication (was inlined in Reference Docs bullet). | -| 2026-03-23 | 1.1.0 | Updated agent instructions (sections, references, Cursor layout). | -| 2026-03-22 | 1.0.0 | Added structured agent header and changelog. | +| 2026-03-24 | 1.2.0 | Fixed gitnexus:start block duplication. | +| 2026-03-23 | 1.1.0 | Updated agent instructions, references, Cursor layout. | +| 2026-03-22 | 1.0.0 | Initial structured header and changelog. | --- # GitNexus — Code Intelligence -This project is indexed by GitNexus as **GitNexus** (4325 symbols, 10556 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +Indexed as **GitNexus** (4325 symbols, 10556 relationships, 300 execution flows). Use 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. +> If any tool warns the index is stale, run `npx gitnexus analyze` first. ## Always Do -- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. -- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. -- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits. -- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. -- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`. +- **MUST run impact analysis before editing any symbol.** `gitnexus_impact({target: "symbolName", direction: "upstream"})` — report blast radius to the user. +- **MUST run `gitnexus_detect_changes()` before committing** — verify only expected symbols and flows are affected. +- **MUST warn the user** if impact returns HIGH or CRITICAL risk. +- Explore unfamiliar code with `gitnexus_query({query: "concept"})` (process-grouped, ranked) instead of grepping. +- Full context on a symbol: `gitnexus_context({name: "symbolName"})`. ## When Debugging -1. `gitnexus_query({query: ""})` — find execution flows related to the issue -2. `gitnexus_context({name: ""})` — see all callers, callees, and process participation -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 +1. `gitnexus_query({query: ""})` — find related execution flows +2. `gitnexus_context({name: ""})` — callers, callees, process participation +3. `READ gitnexus://repo/GitNexus/process/{processName}` — trace flow step by step +4. Regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` ## When Refactoring -- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`. -- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code. -- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed. +- **Rename:** `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Graph edits are safe; text_search edits need manual review. +- **Extract/Split:** `gitnexus_context` (incoming/outgoing refs) then `gitnexus_impact` (upstream callers) before moving code. +- **After any refactor:** `gitnexus_detect_changes({scope: "all"})` to verify scope. ## Never Do -- NEVER edit a function, class, or method without first running `gitnexus_impact` on it. -- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis. -- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph. -- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope. +- Edit a symbol without running `gitnexus_impact` first. +- Ignore HIGH/CRITICAL risk warnings. +- Rename with find-and-replace — use `gitnexus_rename`. +- Commit without `gitnexus_detect_changes()`. ## Tools Quick Reference -| Tool | When to use | Command | +| Tool | When to use | Example | |------|-------------|---------| +| `list_repos` | Discover indexed repos | `gitnexus_list_repos({})` | | `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` | | `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` | | `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` | | `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` | | `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` | | `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` | +| `api_impact` | Pre-change API route impact | `gitnexus_api_impact({route: "/api/users", method: "GET"})` | +| `route_map` | Route → handler → consumer map | `gitnexus_route_map({})` | +| `tool_map` | MCP/RPC tool definitions | `gitnexus_tool_map({})` | +| `shape_check` | Response shape vs consumer access | `gitnexus_shape_check({route: "/api/users"})` | +| `group_list` | List repo groups | `gitnexus_group_list({})` | +| `group_query` | Cross-repo search in a group | `gitnexus_group_query({name: "myGroup", query: "auth"})` | +| `group_sync` | Rebuild group Contract Registry | `gitnexus_group_sync({name: "myGroup"})` | +| `group_contracts` | Inspect group contracts | `gitnexus_group_contracts({name: "myGroup"})` | +| `group_status` | Group staleness report | `gitnexus_group_status({name: "myGroup"})` | ## Impact Risk Levels | Depth | Meaning | Action | |-------|---------|--------| -| d=1 | WILL BREAK — direct callers/importers | MUST update these | +| d=1 | WILL BREAK — direct callers/importers | MUST update | | d=2 | LIKELY AFFECTED — indirect deps | Should test | | d=3 | MAY NEED TESTING — transitive | Test if critical path | @@ -119,87 +122,80 @@ This project is indexed by GitNexus as **GitNexus** (4325 symbols, 10556 relatio | Resource | Use for | |----------|---------| -| `gitnexus://repo/GitNexus/context` | Codebase overview, check index freshness | +| `gitnexus://repo/GitNexus/context` | Codebase overview, 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 -Before completing any code modification task, verify: 1. `gitnexus_impact` was run for all modified symbols -2. No HIGH/CRITICAL risk warnings were ignored -3. `gitnexus_detect_changes()` confirms changes match expected scope -4. All d=1 (WILL BREAK) dependents were updated +2. No HIGH/CRITICAL warnings were ignored +3. `gitnexus_detect_changes()` confirms expected scope +4. All d=1 dependents were updated ## Keeping the Index Fresh -After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it: - ```bash -npx gitnexus analyze +npx gitnexus analyze # basic refresh +npx gitnexus analyze --embeddings # preserve embeddings ``` -If the index previously included embeddings, preserve them by adding `--embeddings`: +Check `.gitnexus/meta.json` `stats.embeddings` (0 = none). Running without `--embeddings` deletes existing vectors. -```bash -npx gitnexus analyze --embeddings -``` +> Claude Code: PostToolUse hook handles this after `git commit` and `git merge`. -To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.** +## CLI Skills -> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`. - -## CLI - -| Task | Read this skill file | -|------|---------------------| -| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | -| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | -| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | -| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | -| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | -| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | +| Task | Skill file | +|------|-----------| +| Architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | +| Blast radius / "What breaks?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | +| Debugging / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | +| Refactoring | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | +| Tools/resources/schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | +| CLI commands (index, status, clean, wiki) | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | -## Cursor Cloud specific instructions +## Repo reference -### Repository structure +### Packages -This is a monorepo with two main products and supporting config packages: - -| Component | Path | Purpose | -|-----------|------|---------| -| **GitNexus CLI/Core** | `gitnexus/` | Main product — TypeScript CLI, indexing pipeline, MCP server. Published to npm. | -| **GitNexus Web UI** | `gitnexus-web/` | React/Vite browser app — graph explorer + AI chat. Runs entirely in WASM. | -| Claude Plugin | `gitnexus-claude-plugin/` | Static config for Claude marketplace (no build). | -| Cursor Integration | `gitnexus-cursor-integration/` | Static config for Cursor editor (no build). | -| SWE-bench Eval | `eval/` | Python evaluation harness (optional; needs Docker + LLM API keys). | +| Package | Path | Purpose | +|---------|------|---------| +| **CLI/Core** | `gitnexus/` | TypeScript CLI, indexing pipeline, MCP server. Published to npm. | +| **Web UI** | `gitnexus-web/` | React/Vite thin client. All queries via `gitnexus serve` HTTP API. | +| **Shared** | `gitnexus-shared/` | Shared TypeScript types and constants. | +| Claude Plugin | `gitnexus-claude-plugin/` | Static config for Claude marketplace. | +| Cursor Integration | `gitnexus-cursor-integration/` | Static config for Cursor editor. | +| Eval | `eval/` | Python evaluation harness (Docker + LLM API keys). | ### Running services -- **CLI/Core**: `cd gitnexus && npm run dev` (tsx watch mode) or `npm run build && node dist/cli/index.js ` -- **Web UI**: `cd gitnexus-web && npm run dev` (Vite on port 5173) -- **Backend mode**: `cd && node /workspace/gitnexus/dist/cli/index.js serve` (HTTP API on port 3741 by default) +```bash +cd gitnexus && npm run dev # CLI: tsx watch mode +cd gitnexus-web && npm run dev # Web UI: Vite on port 5173 +npx gitnexus serve # HTTP API on port 4747 (from any indexed repo) +``` ### Testing **CLI / Core (`gitnexus/`)** -- **Unit tests**: `cd gitnexus && npm test` (vitest, ~2000 tests) -- **Integration tests**: `cd gitnexus && npm run test:integration` (vitest, ~1850 tests). Two LadybugDB file-locking tests (`lbug-core-adapter`, `search-core`) may fail in containerized environments due to `/tmp` locking limitations — this is a known environment issue, not a code bug. -- **TypeScript check**: `cd gitnexus && npx tsc --noEmit` +- `npm test` — full vitest suite (~2000 tests) +- `npm run test:unit` — unit tests only +- `npm run test:integration` — integration (~1850 tests). LadybugDB file-locking tests may fail in containers (known env issue). +- `npx tsc --noEmit` — typecheck **Web UI (`gitnexus-web/`)** -- **Unit tests**: `cd gitnexus-web && npm test` (vitest, ~200 tests) -- **E2E tests**: `cd gitnexus-web && E2E=1 npx playwright test` (Playwright, 5 tests — requires `gitnexus serve` + `npm run dev` running) -- **TypeScript check**: `cd gitnexus-web && npx tsc -b --noEmit` +- `npm test` — vitest (~200 tests) +- `npm run test:e2e` — Playwright (7 spec files; requires `gitnexus serve` + `npm run dev`) +- `npx tsc -b --noEmit` — typecheck -No separate lint command is configured; TypeScript strict checking serves as the primary static analysis. +**Pre-commit hook** (`.husky/pre-commit`): formatting (prettier via lint-staged) + typecheck for staged packages. Tests do **not** run in pre-commit — CI only. ### Gotchas -- `npm install` in `gitnexus/` triggers `prepare` (builds via `tsc`) and `postinstall` (patches tree-sitter-swift). Native tree-sitter bindings require `python3`, `make`, and `g++` to be present. -- `tree-sitter-kotlin` and `tree-sitter-swift` are optional dependencies — install warnings for these are expected and non-blocking. -- The Web UI uses `vite-plugin-wasm` and requires `Cross-Origin-Opener-Policy`/`Cross-Origin-Embedder-Policy` headers for `SharedArrayBuffer` (handled automatically by Vite dev server). -- There is no ESLint/Prettier configuration in this repo. +- `npm install` in `gitnexus/` triggers `prepare` (builds via `tsc`) and `postinstall` (patches tree-sitter-swift, builds tree-sitter-proto). Native bindings need `python3`, `make`, `g++`. +- `tree-sitter-kotlin` and `tree-sitter-swift` are optional — install warnings expected. +- ESLint configured via `eslint.config.mjs` (TS, React Hooks, unused-imports). No `npm run lint` script; use `npx eslint .`. Prettier runs via lint-staged. CI checks both in `ci-quality.yml`. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ac4f46aef..1be9468c3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,99 +1,129 @@ # Architecture — GitNexus -This repository is a **monorepo** with two main products: the **CLI / MCP package** (`gitnexus/`) and the **browser UI** (`gitnexus-web/`). Supporting folders ship editor integrations and plugins without changing the core graph engine. +Monorepo: **CLI/MCP** (`gitnexus/`) + **browser UI** (`gitnexus-web/`). ## Repository layout | Path | Role | |------|------| -| `gitnexus/` | Published npm package `gitnexus`: CLI, MCP server (stdio), local HTTP API for bridge mode, ingestion pipeline, LadybugDB graph, embeddings (optional). | -| `gitnexus-web/` | Vite + React UI: in-browser indexing (WASM), graph visualization, optional connection to `gitnexus serve`. | -| `.claude/`, `gitnexus-claude-plugin/`, `gitnexus-cursor-integration/` | Packaged **skills** and plugin metadata so agents discover the same workflows as documented in `AGENTS.md`. | -| `eval/` | Evaluation harnesses and docs for benchmarking tool usage. | -| `.github/` | CI workflows (quality, unit, integration, E2E) and composite actions. | +| `gitnexus/` | npm package `gitnexus`: CLI, MCP server (stdio), HTTP API, ingestion pipeline, LadybugDB graph, embeddings. | +| `gitnexus-web/` | Vite + React thin client: graph explorer + AI chat. All queries via `gitnexus serve` HTTP API. | +| `gitnexus-shared/` | Shared TypeScript types and constants (consumed by CLI and Web). | +| `.claude/`, `gitnexus-claude-plugin/`, `gitnexus-cursor-integration/` | Agent skills and plugin metadata. | +| `eval/` | Evaluation harnesses for benchmarking tool usage. | +| `.github/` | CI workflows + composite actions (`setup-gitnexus/`, `setup-gitnexus-web/`). | ## End-to-end flow: index → graph → tools -1. **Ingestion** (`gitnexus analyze`) - - Entry: `gitnexus/src/cli/analyze.ts` → `runPipelineFromRepo` in `gitnexus/src/core/ingestion/pipeline.ts`. - - The pipeline is structured as a **DAG (Directed Acyclic Graph)** of named phases (see [Pipeline Phase DAG](#pipeline-phase-dag) below). - - Output is loaded into **LadybugDB** under **`.gitnexus/`** at the repo root (`lbug/`, `meta.json`, etc.). Optional **FTS** indexes and **embeddings** attach to the same store. - - The repo is registered in **`~/.gitnexus/registry.json`** so MCP can find it from any working directory. +1. **Ingestion** — `analyze.ts` → `runFullAnalysis` (`run-analyze.ts`) → `runPipelineFromRepo` (`pipeline.ts`). DAG of 12 phases builds a `KnowledgeGraph` in memory, then loads into LadybugDB under `.gitnexus/`. Repo registered in `~/.gitnexus/registry.json` for MCP discovery. -2. **Persistence & metadata** - - `gitnexus/src/storage/repo-manager.ts` — paths, registry, cleanup of legacy Kuzu artifacts. - - `gitnexus/src/core/lbug/lbug-adapter.ts` — graph load, queries, embedding restore batches. +2. **Persistence** — `repo-manager.ts` (paths, registry, KuzuDB cleanup). `lbug-adapter.ts` (graph load, queries, embedding batches). -3. **Query & agents** - - **MCP (stdio):** `gitnexus/src/cli/mcp.ts` → `startMCPServer` → `LocalBackend` (`gitnexus/src/mcp/local/local-backend.ts`) opens registered repos and serves **tools** from `gitnexus/src/mcp/tools.ts` and **resources** from `gitnexus/src/mcp/resources.ts`. - - **Bridge HTTP:** `gitnexus/src/cli/serve.ts` → Express app in `gitnexus/src/server/api.ts` (CORS-limited) exposes REST + MCP-over-HTTP for the web UI. - - **CLI tools (no MCP):** `gitnexus query`, `context`, `impact`, `cypher` in `gitnexus/src/cli/tool.ts` call the same backend for scripts and CI. +3. **Query layer** — three interfaces to the same backend: + - **MCP (stdio):** `mcp.ts` → `LocalBackend` → tools (`tools.ts`) + resources (`resources.ts`) + - **HTTP bridge:** `serve.ts` → Express (`api.ts`, `mcp-http.ts`) for web UI + - **CLI direct:** `gitnexus query|context|impact|cypher` in `tool.ts` -4. **Staleness** - - `gitnexus/src/mcp/staleness.ts` compares indexed `lastCommit` to `HEAD` and surfaces hints when the graph is behind git. +4. **Staleness** — `staleness.ts` compares indexed `lastCommit` to `HEAD`, surfaces hints. -## MCP tools (summary) +## MCP tools | Tool | Purpose | |------|---------| -| `list_repos` | Discover indexed repositories when more than one is registered. | -| `query` | Natural-language / keyword search over the graph (hybrid BM25 + optional vectors). | -| `cypher` | Ad hoc **Cypher** against the schema (see resource `gitnexus://repo/{name}/schema`). | -| `context` | Callers, callees, processes for one symbol (with disambiguation). | -| `impact` | Blast radius (upstream/downstream) with depth and risk summary. | -| `detect_changes` | Map git diffs to affected symbols and processes. | -| `rename` | Graph-assisted rename with `dry_run` preview (`graph` vs `text_search` confidence). | +| `list_repos` | Discover indexed repos | +| `query` | Hybrid BM25 + vector search over the graph | +| `cypher` | Ad hoc Cypher against the schema | +| `context` | Callers, callees, processes for one symbol | +| `impact` | Blast radius (upstream/downstream) with risk summary | +| `detect_changes` | Map git diffs to affected symbols and processes | +| `rename` | Graph-assisted multi-file rename with `dry_run` preview | +| `api_impact` | Pre-change impact report for an API route handler | +| `route_map` | API route → handler → consumer mappings | +| `tool_map` | MCP/RPC tool definitions and handlers | +| `shape_check` | Response shape vs consumer property access mismatches | +| `group_list` | List repo groups or details for one group | +| `group_query` | Cross-repo search in a group (reciprocal rank fusion) | +| `group_sync` | Rebuild group Contract Registry (`contracts.json`) | +| `group_contracts` | Inspect group contracts and cross-links | +| `group_status` | Index and Contract Registry staleness per repo in a group | ## Where to change what -| If you are changing… | Start in… | -|----------------------|-----------| -| CLI commands / flags | `gitnexus/src/cli/` (`index.ts`, per-command modules). | -| Parsing or graph construction | `gitnexus/src/core/ingestion/pipeline-phases/` (individual phase files), `pipeline.ts` (orchestrator). | -| Graph schema / DB access | `gitnexus/src/core/lbug/` (`schema.ts`, `lbug-adapter.ts`), `gitnexus/src/mcp/core/lbug-adapter.ts` if MCP-specific. | -| MCP protocol, tools, resources | `gitnexus/src/mcp/server.ts`, `tools.ts`, `resources.ts`. | -| Search ranking | `gitnexus/src/core/search/` (BM25, hybrid fusion). | -| Embeddings | `gitnexus/src/core/embeddings/`, phases in `analyze.ts`. | -| Wiki generation | `gitnexus/src/core/wiki/`. | -| Web UI behavior | `gitnexus-web/src/` (components, workers, graph client). | -| CI | `.github/workflows/*.yml`, `.github/actions/setup-gitnexus/`. | +| Concern | Start in | +|---------|----------| +| CLI commands/flags | `src/cli/` (`index.ts`, per-command modules) | +| Parsing/graph construction | `src/core/ingestion/pipeline-phases/` + `pipeline.ts` | +| Graph schema/DB | `src/core/lbug/` (`schema.ts`, `lbug-adapter.ts`) | +| MCP tools/resources | `src/mcp/server.ts`, `tools.ts`, `resources.ts` | +| Search ranking | `src/core/search/` (BM25, hybrid fusion) | +| Embeddings | `src/core/embeddings/` + `src/core/run-analyze.ts` | +| Wiki generation | `src/core/wiki/` | +| Language support | `src/core/ingestion/languages/` + `tree-sitter-queries.ts` + `gitnexus-shared/src/languages.ts` | +| Import resolution | `src/core/ingestion/import-processor.ts` + `model/resolution-context.ts` | +| Call resolution/MRO | `src/core/ingestion/call-processor.ts` + `model/resolve.ts` | +| Type extraction | `src/core/ingestion/type-extractors/` | +| Worker pool | `src/core/ingestion/workers/` | +| Web UI | `gitnexus-web/src/` | +| CI | `.github/workflows/*.yml`, `.github/actions/` | + +> Paths above are relative to `gitnexus/` unless they start with `gitnexus-web/` or `.github/`. + +--- ## Pipeline Phase DAG -The ingestion pipeline is a DAG of named phases. Each phase is defined in its own file under `gitnexus/src/core/ingestion/pipeline-phases/` with explicit dependencies, typed inputs, and typed outputs. +12 phases defined in `gitnexus/src/core/ingestion/pipeline-phases/`, each with explicit `deps` and typed output. ``` scan → structure → [markdown, cobol] → parse → [routes, tools, orm] → crossFile → mro → communities → processes ``` -### Phase files +| Phase | File | Deps | Output | +|-------|------|------|--------| +| `scan` | `scan.ts` | (root) | File paths + sizes | +| `structure` | `structure.ts` | `scan` | File/Folder nodes, CONTAINS edges, `allPathSet` | +| `markdown` | `markdown.ts` | `structure` | Section nodes, cross-link edges from .md/.mdx | +| `cobol` | `cobol.ts` | `structure` | COBOL program/paragraph/section nodes (regex, no tree-sitter) | +| `parse` | `parse.ts` + `parse-impl.ts` | `structure`, `markdown`, `cobol` | Symbol nodes, IMPORTS/CALLS/EXTENDS edges, extracted routes/tools/ORM queries | +| `routes` | `routes.ts` | `parse` | Route nodes + HANDLES_ROUTE edges (Next.js, Expo, PHP, decorators) | +| `tools` | `tools.ts` | `parse` | Tool nodes + HANDLES_TOOL edges | +| `orm` | `orm.ts` | `parse` | QUERIES edges (Prisma, Supabase) | +| `crossFile` | `cross-file.ts` + `cross-file-impl.ts` | `parse`, `routes`, `tools`, `orm` | Cross-file type propagation in topological import order | +| `mro` | `mro.ts` | `crossFile`, `structure` | METHOD_OVERRIDES + METHOD_IMPLEMENTS edges | +| `communities` | `communities.ts` | `mro`, `structure` | Community nodes + MEMBER_OF edges (Leiden algorithm) | +| `processes` | `processes.ts` | `communities`, `routes`, `tools`, `structure` | Process nodes + STEP_IN_PROCESS edges | -| Phase | File | Dependencies | What it does | -|-------|------|-------------|--------------| -| `scan` | `scan.ts` | (root) | Walk repo filesystem, collect paths + sizes | -| `structure` | `structure.ts` | `scan` | Build File/Folder nodes + CONTAINS edges | -| `markdown` | `markdown.ts` | `structure` | Extract headings and cross-links from .md/.mdx | -| `cobol` | `cobol.ts` | `structure` | Regex-based COBOL/JCL extraction | -| `parse` | `parse.ts` + `parse-impl.ts` | `structure`, `markdown`, `cobol` | Chunked tree-sitter parse, import/call/heritage resolution | -| `routes` | `routes.ts` | `parse` | Route registry (Next.js, Expo, PHP, decorator-based) | -| `tools` | `tools.ts` | `parse` | MCP/RPC tool detection | -| `orm` | `orm.ts` | `parse` | Prisma/Supabase ORM query edges | -| `crossFile` | `cross-file.ts` + `cross-file-impl.ts` | `parse`, `routes`, `tools`, `orm` | Cross-file type propagation in topological order | -| `mro` | `mro.ts` | `crossFile` | Method Resolution Order, METHOD_OVERRIDES edges | -| `communities` | `communities.ts` | `mro` | Leiden community detection | -| `processes` | `processes.ts` | `communities`, `routes`, `tools` | Execution flow detection, Route/Tool → Process links | +**Non-phase files in the same directory:** `parse-impl.ts`, `cross-file-impl.ts` (implementation), `wildcard-synthesis.ts` (whole-module import expansion), `orm-extraction.ts` (sequential ORM fallback), `types.ts`, `runner.ts`, `index.ts`. + +### DAG runner + +`runner.ts` — static phase graph, no plugins, compile-time type safety. + +1. **Validation** — Kahn's topological sort. Rejects on: duplicate names, missing deps, cycles (DFS traces the concrete cycle path, e.g., `A -> B -> C -> A`, plus count of transitively blocked dependents). + +2. **Execution** — sequential in topological order. Each phase receives: + - `ctx: PipelineContext` — shared mutable `KnowledgeGraph`, `repoPath`, progress callback, options + - `deps: ReadonlyMap` — **declared deps only** (runner filters the results map to prevent hidden coupling) + +3. **Error handling** — wraps phase errors with the phase name, emits terminal `error` progress event, swallows progress handler errors to preserve the original cause. + +4. **Timing** — per-phase `durationMs` in `PhaseResult`, dev-mode console logging. + +**Design patterns:** +- **Single graph accumulator** — all phases mutate the same `KnowledgeGraph` in `ctx`; the graph is the primary output. +- **Typed phase access** — `getPhaseOutput(deps, 'name')` for type-safe upstream results. +- **Binding accumulator lifecycle** — created in `parse`, disposed by `crossFile` (in `finally`). No other phase should take ownership. +- **Skippable phases** — `skipGraphPhases` omits MRO/communities/processes (faster tests). `skipWorkers` forces sequential parsing. ### How to add a new phase -1. Create a new file in `pipeline-phases/` (e.g. `my-phase.ts`) -2. Define a `PipelinePhase` object with `name`, `deps`, and `execute(ctx, deps)` -3. Export it from `pipeline-phases/index.ts` -4. Add it to the `buildPhaseList()` function in `pipeline.ts` +1. Create `pipeline-phases/my-phase.ts` with a `PipelinePhase` (name, deps, execute) +2. Export from `pipeline-phases/index.ts` +3. Add to `buildPhaseList()` in `pipeline.ts` ```typescript -// pipeline-phases/my-phase.ts -import type { PipelinePhase, PipelineContext, PhaseResult } from './types.js'; +import type { PipelinePhase, PhaseResult } from './types.js'; import { getPhaseOutput } from './types.js'; import type { ParseOutput } from './parse.js'; @@ -101,81 +131,168 @@ export interface MyPhaseOutput { /* ... */ } export const myPhase: PipelinePhase = { name: 'myPhase', - deps: ['parse'], // runs after parse completes + deps: ['parse'], async execute(ctx, deps) { const { allPaths } = getPhaseOutput(deps, 'parse'); - // ... do work, write to ctx.graph ... + // ... write to ctx.graph ... return { /* typed output */ }; }, }; ``` -### DAG runner +--- -The runner (`pipeline-phases/runner.ts`) validates the DAG at startup (detects cycles and missing deps via topological sort), then executes phases in dependency order. Each phase receives: -- `ctx: PipelineContext` — shared graph, repoPath, progress callback -- `deps: Map` — outputs from all upstream phases +## Language-agnostic graph feeding + +16 languages → single unified graph. Four abstraction layers: + +``` + Unified Graph Schema (44 node types, 21 relationship types) + ↑ + Unified Resolution (3-tier name lookup + MRO walk) + ↑ + Language Providers (import semantics, type config, export checker, MRO strategy) + ↑ + Tree-Sitter Queries (per-language S-expressions, unified capture tags) +``` + +### Language providers + +Each language implements `LanguageProvider` (`language-provider.ts`). Key fields: + +| Field | Purpose | +|-------|---------| +| `id`, `extensions` | Language identity and file matching | +| `treeSitterQueries` | S-expression queries for AST extraction | +| `importSemantics` | `named` / `wildcard-leaf` / `wildcard-transitive` / `namespace` | +| `importResolver` | Language-specific path → file resolution | +| `exportChecker` | Public/exported symbol detection | +| `typeConfig` | Type annotation extraction rules | +| `mroStrategy` | `first-wins` / `c3` / `none` | + +16 providers in `languages/index.ts` via `satisfies Record` — missing a language is a compile error. + +### Unified capture tags + +Per-language tree-sitter queries use different AST node names but produce the **same semantic capture tags**: `@definition.class`, `@definition.function`, `@call.name`, `@import.source`, `@heritage.extends`. Downstream extraction needs no language branching. Defined in `tree-sitter-queries.ts`. + +### Import resolution + +Unified 3-tier algorithm (`model/resolution-context.ts`), per-language `importSemantics` controls which tier activates: + +| Tier | Confidence | Mechanism | +|------|-----------|-----------| +| 1 — same-file | 0.95 | Symbol table for caller's file | +| 2 — import-scoped | 0.9 | `NamedImportMap` chains (named) or all files in `importMap` (wildcard) | +| 3 — global | 0.5 | O(1) index lookups: class, impl, callable. Fallback only | + +| Import strategy | Languages | Behavior | +|----------------|-----------|----------| +| `named` | TS, JS, Java, C#, Rust, PHP, Kotlin | Only explicitly imported names visible | +| `wildcard-leaf` | Go, Ruby, Swift, Dart | Whole-package import, no transitive re-exports | +| `wildcard-transitive` | C, C++ | `#include` closure chains through re-exports | +| `namespace` | Python | Module aliases resolved at call site | + +### Chunked parse-and-resolve + +`parse` processes files in ~20 MB byte-budget chunks to bound memory. Per chunk: +1. Worker pool dispatches files (or sequential fallback via `skipWorkers`) +2. Each worker: detect language → load grammar → run queries → return unified `ParseWorkerResult` +3. Synthesize wildcard bindings (`wildcard-synthesis.ts`) +4. Resolve imports and heritage +5. Collect `BindingAccumulator` entries for cross-file propagation + +Workers: `workers/worker-pool.ts`, `workers/parse-worker.ts`. + +### Heritage and MRO + +All languages emit unified `ExtractedHeritage` (child, parent, `EXTENDS`/`IMPLEMENTS`). MRO phase walks the heritage graph using per-language strategy: +- **`first-wins`** — Java, C#, C++, TS, Ruby, Go +- **`c3`** — Python (C3 linearization) +- **`none`** — single-inheritance languages + +Unified walk: `lookupMethodByOwnerWithMRO()` in `model/resolve.ts`. + +--- + +## Full analysis flow + +`runFullAnalysis` in `run-analyze.ts` orchestrates everything around the pipeline: + +``` +CLI (analyze.ts) → runFullAnalysis(repoPath, options, callbacks) + 1. Early exit if lastCommit == HEAD (unless --force) [0%] + 2. Cache existing embeddings from prior index [0%] + 3. runPipelineFromRepo() → KnowledgeGraph [0-60%] + 4. Clean up legacy KuzuDB files [60%] + 5. initLbug() → loadGraphToLbug() via CSV streaming [60-85%] + 6. Create FTS indexes (File, Function, Class, Method...) [85-90%] + 7. Restore cached embeddings (batch insert) [88%] + 8. Generate new embeddings if --embeddings [90-98%] + 9. Save metadata + register repo + update .gitignore [98-100%] + 10. Generate AI context files (AGENTS.md, CLAUDE.md) [100%] +``` + +**Options:** `--force` (rebuild regardless), `--embeddings` (opt-in, skipped if >50k nodes), `--skipGit`, `--noStats`. + +## Storage + +``` +/.gitnexus/ + ├── lbug # LadybugDB database + ├── lbug.wal # Write-ahead log + ├── lbug.lock # Single-writer lock + └── meta.json # lastCommit, indexedAt, stats + +~/.gitnexus/ + └── registry.json # Global repo registry (MCP discovery) +``` + +Managed by `repo-manager.ts`. + +## LadybugDB schema + +Defined in `lbug/schema.ts`. Separate node tables per type, single `CodeRelation` table. + +**Node tables:** File, Folder, Function, Class, Interface, Method, Constructor, CodeElement, Struct, Enum, Macro, Typedef, Union, Namespace, Trait, Impl, TypeAlias, Const, Static, Property, Record, Delegate, Annotation, Template, Module, Community, Process, Route, Tool, Section, Embedding. + +**Relation types** (`CodeRelation.type`): CONTAINS, DEFINES, CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, HAS_PROPERTY, ACCESSES, METHOD_OVERRIDES, METHOD_IMPLEMENTS, MEMBER_OF, STEP_IN_PROCESS, HANDLES_ROUTE, FETCHES, HANDLES_TOOL, ENTRY_POINT_OF. + +## Embeddings and search + +**Embeddings** (`src/core/embeddings/`): Snowflake arctic-embed-xs (384D). Embeddable: File, Function, Class, Method, Interface. Incremental via SHA1 content hash. Separate `Embedding` table. + +**Search** (`src/core/search/`): Hybrid BM25 + semantic vector, merged via Reciprocal Rank Fusion (K=60). ## Known limitations ### Overloaded method resolution -Method and Constructor node IDs include an arity suffix (`#`) to -disambiguate overloaded methods. Two overloads with different parameter counts -produce distinct graph nodes: `Method:file:Class.method#1` vs -`Method:file:Class.method#2`. +Node IDs use arity suffix (`#`): `Method:file:Class.method#1` vs `#2`. -**Same-arity overload disambiguation:** When two overloads share the same -parameter count but differ in types (e.g. `save(int)` vs `save(String)`), a -type-hash suffix `~type1,type2` is appended to produce distinct node IDs: -`Method:file:Class.save#1~int` vs `Method:file:Class.save#1~String`. The suffix -is only added when a same-arity collision is detected within a class and all -parameters have non-null type annotations. Languages without type info (Python, -Ruby, JS) fall back to arity-only IDs. TypeScript/JavaScript overload signatures -are intentionally excluded from type-hashing because they are declaration-only -contracts that should collapse to the implementation body's node ID. See issue -\#651. +**Same-arity disambiguation:** type-hash suffix `~type1,type2` when collision detected and type annotations present. Languages without types (Python, Ruby, JS) use arity-only. TS/JS overload signatures excluded (collapse to implementation body). See #651. -**C++ const-qualified overload disambiguation:** Methods overloaded by const -qualification (e.g. `begin()` vs `begin() const`) are disambiguated via an -`isConst` property and a `$const` ID suffix appended to the const-qualified -variant when a non-const collision exists. The `$const` suffix appears after the -type-hash suffix: e.g. `Method:file:Container.begin#0$const`. +**C++ const-qualified:** `$const` suffix after type-hash when non-const collision exists: `Method:file:Container.begin#0$const`. -**Generic/template type preservation in type-hash:** The type-hash suffix uses -`rawType` (full AST text including generic/template args) rather than the -simplified `type` from `extractSimpleTypeName`. This means C++ template overloads -like `process(vector)` vs `process(vector)` produce distinct IDs: -`~vector` vs `~vector`. Java generic overloads like -`process(List)` vs `process(List)` are a compile error due to -type erasure, so this gap is theoretical for Java. +**Generic/template types:** type-hash uses `rawType` (full AST text including generics): `~vector` vs `~vector`. -**ID stability on first overload:** Type and const tags are collision-only. When -a class has `save(int)` as its only `save` method, the ID is `save#1` (no tag). -Adding `save(String)` changes the original to `save#1~int`. This is correct for -fresh analysis but means IDs are not stable across overload additions. Future -incremental re-analysis should account for this. +**ID stability:** collision-only tags mean IDs change when overloads are added. `save#1` becomes `save#1~int` when `save(String)` is added. -**Variadic method matching:** When one side is variadic (`parameterCount` -undefined) and the other has a fixed count, `METHOD_IMPLEMENTS` edges are -emitted with confidence 0.7 instead of 1.0. Variadic methods like -`foo(String... args)` may superficially match `foo(String s)` by type but -are not guaranteed to be interchangeable across all languages (Java/Kotlin -accept this via varargs sugar; TypeScript, C#, Rust do not). +**Variadic matching:** confidence 0.7 when one side is variadic and the other has fixed count. -**Confidence tiering** for `METHOD_IMPLEMENTS` edges: +**METHOD_IMPLEMENTS confidence tiering:** -| Match quality | Confidence | When | -|---|---|---| -| Exact parameter types match | 1.0 | Both sides have `parameterTypes` arrays and they match | -| Arity (count) matches | 1.0 | Both sides have `parameterCount`, types unavailable | -| Variadic vs fixed | 0.7 | One side is variadic, other has fixed count | -| Lenient (insufficient info) | 0.7 | One or both sides lack type and count data | +| Match quality | Confidence | +|---|---| +| Exact parameter types match | 1.0 | +| Arity match, types unavailable | 1.0 | +| Variadic vs fixed | 0.7 | +| Insufficient info | 0.7 | ## Related docs -- [MIGRATION.md](MIGRATION.md) — breaking changes and migration guidance. -- [RUNBOOK.md](RUNBOOK.md) — operational commands and recovery. -- [GUARDRAILS.md](GUARDRAILS.md) — safety boundaries for humans and agents. -- [TESTING.md](TESTING.md) — how to run tests. -- `AGENTS.md` / `CLAUDE.md` — agent workflows and tool usage expectations for **this** repo when indexed by GitNexus. +- [MIGRATION.md](MIGRATION.md) — breaking changes and migration guidance +- [RUNBOOK.md](RUNBOOK.md) — operational commands and recovery +- [GUARDRAILS.md](GUARDRAILS.md) — safety boundaries for humans and agents +- [TESTING.md](TESTING.md) — how to run tests +- `AGENTS.md` / `CLAUDE.md` — agent workflows and tool usage diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d2d48f017..22104edb4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -62,7 +62,7 @@ Commits within a PR may use any style — only the **merged PR title** shows up - [ ] Typecheck passes: `npx tsc --noEmit` in `gitnexus/` and `npx tsc -b --noEmit` in `gitnexus-web/`. - [ ] No secrets, tokens, or machine-specific paths committed. - [ ] Documentation updated if behavior or public CLI/MCP contract changes. -- [ ] Pre-commit hook runs clean (`.husky/pre-commit` — typecheck + unit tests for staged packages). +- [ ] Pre-commit hook runs clean (`.husky/pre-commit` — formatting via lint-staged + typecheck for staged packages; tests run in CI only). ## Code review diff --git a/GUARDRAILS.md b/GUARDRAILS.md index 7401c79c5..ac48ab906 100644 --- a/GUARDRAILS.md +++ b/GUARDRAILS.md @@ -1,72 +1,69 @@ -# Guardrails — GitNexus (repo + agents) +# Guardrails — GitNexus -Rules for **human contributors** and **AI agents** working on this codebase or publishing artifacts. These complement `AGENTS.md` / `CLAUDE.md` (which focus on GitNexus-in-GitNexus workflows). +Rules for **human contributors** and **AI agents**. Complements `AGENTS.md` (workflows) and `CONTRIBUTING.md` (PR process). -## Scope (typical agent session) +## Scope (least privilege) -When automating changes in this repository, treat scope as **least privilege**: +- **Read:** Source, tests, docs, public config as needed. +- **Write:** Only files required for the fix or feature; no unrelated formatting or refactors. +- **Execute:** Tests, typecheck, documented CLI commands. No destructive commands on user data without approval. +- **Off-limits:** Other people's machines, production deployments you don't own, credentials you lack permission to use. -- **Read:** Source, tests, docs, public config as needed for the task. -- **Write:** Only files required for the requested fix or feature; avoid unrelated formatting or refactors. -- **Execute:** Tests, typecheck, and documented CLI commands; do not run destructive commands on user data outside the repo without explicit approval. -- **Off-limits:** Other people’s machines, production deployments you don’t own, and credentials you didn’t receive permission to use. - -Adjust explicitly if the maintainer defines a different scope for a task. +Maintainer may widen scope per task. --- ## Non-negotiables -1. **Never commit secrets** — API keys, tokens, `.env` with real values, private URLs, or session cookies. Use `.env.example` with placeholders only. -2. **Never rename symbols with blind find-and-replace** when working in a GitNexus-indexed project — use the **`rename` MCP tool** with **`dry_run: true` first**, then review `graph` vs `text_search` edits. (There is no separate `gitnexus rename` CLI; renaming goes through MCP or editor integration.) -3. **Run impact analysis before editing shared symbols** — use **`impact`** (upstream) for functions/classes/methods others call; do not ignore **HIGH** / **CRITICAL** risk without maintainer sign-off. -4. **Prefer `detect_changes` before commit** — confirm diffs map to expected symbols/processes when the graph is available. -5. **Preserve embeddings** — if `.gitnexus/meta.json` shows embeddings, run `npx gitnexus analyze --embeddings` when refreshing the index; plain `analyze` can drop them. +1. **Never commit secrets** — API keys, tokens, real `.env` values, private URLs, session cookies. Use `.env.example` with placeholders. +2. **Never rename with find-and-replace** in GitNexus-indexed projects — use `rename` MCP tool with `dry_run: true` first, review `graph` vs `text_search` edits. No separate `gitnexus rename` CLI exists. +3. **Run impact analysis before editing shared symbols** — `impact` (upstream) for functions/classes/methods others call. Do not ignore HIGH/CRITICAL without maintainer sign-off. +4. **Run `detect_changes` before commit** — confirm diffs map to expected symbols/processes when the graph is available. +5. **Preserve embeddings** — if `.gitnexus/meta.json` shows embeddings, use `npx gitnexus analyze --embeddings`; plain `analyze` drops them. --- ## Signs (recurring failure patterns) -Use this format: **Trigger → Instruction → Reason**. -Append new Signs here when the same mistake repeats (e.g. CI broken twice the same way). +Format: **Trigger → Instruction → Reason**. Append new Signs when the same mistake repeats. -### Sign: Stale graph after edits +### Stale graph after edits -- **Trigger:** MCP or resources warn the index is behind `HEAD`, or code search doesn’t match latest commit. -- **Instruction:** Run `npx gitnexus analyze` from the repo root (plus `--embeddings` if the project used them). -- **Reason:** Tools query LadybugDB built at last analyze; git changes are invisible until re-indexed. +- **Trigger:** MCP warns index is behind `HEAD`, or search doesn't match latest commit. +- **Do:** `npx gitnexus analyze` (plus `--embeddings` if used). +- **Why:** Tools query LadybugDB from last analyze; git changes are invisible until re-indexed. -### Sign: Embeddings vanished after analyze +### Embeddings vanished after analyze -- **Trigger:** Semantic search quality drops; `stats.embeddings` in `.gitnexus/meta.json` is 0 after a refresh. -- **Instruction:** Re-run `npx gitnexus analyze --embeddings` and confirm `meta.json` reflects stored embeddings. -- **Reason:** Embedding generation is opt-in; analyze without the flag does not preserve prior vectors. +- **Trigger:** Semantic search quality drops; `stats.embeddings` in `meta.json` is 0 after refresh. +- **Do:** `npx gitnexus analyze --embeddings`, confirm `meta.json` reflects stored embeddings. +- **Why:** Embedding generation is opt-in; analyze without the flag does not preserve prior vectors. -### Sign: MCP lists no repos +### MCP lists no repos -- **Trigger:** MCP stderr says no indexed repos. -- **Instruction:** Run `npx gitnexus analyze` in the target repository; verify `npx gitnexus list` shows it. -- **Reason:** The MCP server discovers repos via `~/.gitnexus/registry.json`, populated by analyze. +- **Trigger:** MCP stderr says no indexed repos. +- **Do:** `npx gitnexus analyze` in the target repo; verify `npx gitnexus list` shows it. +- **Why:** MCP discovers repos via `~/.gitnexus/registry.json`, populated by analyze. -### Sign: Wrong repo in multi-repo setups +### Wrong repo in multi-repo setups -- **Trigger:** Query/impact results clearly belong to another project. -- **Instruction:** Call `list_repos`, then pass **`repo`** on subsequent tools (or use per-workspace MCP config). -- **Reason:** Default target may be ambiguous when multiple repos are registered. +- **Trigger:** Query/impact results belong to another project. +- **Do:** Call `list_repos`, then pass `repo` on subsequent tools. +- **Why:** Default target is ambiguous when multiple repos are registered. -### Sign: LadybugDB lock / “database busy” +### LadybugDB lock / "database busy" -- **Trigger:** Errors opening `.gitnexus/lbug` while MCP and analyze both run. -- **Instruction:** Stop overlapping processes; one writer at a time. Retry analyze or restart MCP. -- **Reason:** Embedded DB expects single-process ownership of the store. +- **Trigger:** Errors opening `.gitnexus/lbug` while MCP and analyze both run. +- **Do:** Stop overlapping processes (one writer at a time). Retry analyze or restart MCP. +- **Why:** Embedded DB expects single-process ownership. --- ## Publishing & supply chain -- **npm:** Do not publish from unreviewed automation; follow maintainer release process. Bump version intentionally; tag releases to match `package.json`. -- **Dependencies:** Prefer minimal, auditable changes to `package.json`; run tests and CI after lockfile updates. -- **License:** This project ships under **PolyForm Noncommercial 1.0.0** — do not relicense or imply a different license in docs or metadata without maintainer approval. +- **npm:** Do not publish from unreviewed automation. Bump version intentionally; tag releases to match `package.json`. +- **Dependencies:** Minimal, auditable `package.json` changes; run tests and CI after lockfile updates. +- **License:** PolyForm Noncommercial 1.0.0 — do not relicense without maintainer approval. --- @@ -74,15 +71,15 @@ Append new Signs here when the same mistake repeats (e.g. CI broken twice the sa Stop and ask a **human maintainer** when: -- Impact analysis shows **HIGH** / **CRITICAL** risk and the task still requires the change. -- You need to alter **CI**, **release**, or **security-sensitive** config. -- Requirements conflict (e.g. “speed up analyze” vs “must keep all embeddings on huge repo”). +- Impact analysis shows HIGH/CRITICAL risk and the task still requires the change. +- You need to alter CI, release, or security-sensitive config. +- Requirements conflict (e.g. "speed up analyze" vs "must keep all embeddings on huge repo"). - You are unsure whether data loss is acceptable (`clean`, forced migrations, schema changes). --- ## Related docs -- [ARCHITECTURE.md](ARCHITECTURE.md) — components and data flow. -- [RUNBOOK.md](RUNBOOK.md) — commands for recovery. -- [CONTRIBUTING.md](CONTRIBUTING.md) — PR and commit expectations. +- [ARCHITECTURE.md](ARCHITECTURE.md) — components and data flow +- [RUNBOOK.md](RUNBOOK.md) — commands for recovery +- [CONTRIBUTING.md](CONTRIBUTING.md) — PR and commit expectations diff --git a/TESTING.md b/TESTING.md index 8d267983a..cf481d32b 100644 --- a/TESTING.md +++ b/TESTING.md @@ -20,9 +20,9 @@ From repository root, unless noted: cd gitnexus npm install npm run build -npm test # unit: vitest run test/unit +npm test # full suite: vitest run +npm run test:unit # unit only: vitest run test/unit npm run test:integration # integration suite -npm run test:all npm run test:coverage npx tsc --noEmit # typecheck (matches CI) ``` @@ -42,8 +42,11 @@ npm run test:e2e # Playwright (requires gitnexus serve + npm run dev) A husky pre-commit hook (`.husky/pre-commit`) runs automatically on every `git commit`: -- **`gitnexus-web/` files staged** → `tsc -b --noEmit` + `vitest run` -- **`gitnexus/` files staged** → `tsc --noEmit` + `vitest run --project default` +1. **Formatting** — `lint-staged` runs prettier on staged files +2. **`gitnexus-web/` files staged** → `tsc -b --noEmit` +3. **`gitnexus/` files staged** → `tsc --noEmit` + +Tests do **not** run in the pre-commit hook — they run in CI (`ci-tests.yml`) only. Skip with `git commit --no-verify` (use sparingly). @@ -77,7 +80,7 @@ Re-run the full relevant suite when: GitHub Actions (`.github/workflows/ci.yml`) orchestrate: -- **`ci-quality.yml`** — `tsc --noEmit` for `gitnexus/` + `tsc -b --noEmit` for `gitnexus-web/` +- **`ci-quality.yml`** — prettier format check, eslint lint, `tsc --noEmit` for `gitnexus/`, `tsc -b --noEmit` for `gitnexus-web/` - **`ci-tests.yml`** — `vitest run` with coverage (ubuntu) + cross-platform (macOS, Windows) - **`ci-e2e.yml`** — Playwright E2E tests, gated on `gitnexus-web/**` changes From d024119b337b011537e34b600bac5cb3022f91d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Thu, 16 Apr 2026 09:17:21 +0100 Subject: [PATCH 08/17] chore(deps): tree-sitter 0.25 upgrade readiness monitor with daily Dependabot (#847) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(deps): add tree-sitter aware Dependabot config and drift monitoring Two things Dependabot cannot see on its own: 1. ABI consistency. The tree-sitter runtime supports a known range of grammar ABIs. When a grammar bumps past that range, require() silently fails and fallback paths mask the regression in test coverage. 2. Vendored upstream drift. vendor/tree-sitter-proto is a snapshot of coder3101/tree-sitter-proto regenerated against a pinned cli version. Upstream keeps moving. Nothing notices until a maintainer remembers to look. Dependabot configuration - Added npm ecosystems for gitnexus, gitnexus-web, gitnexus-shared. - Grouped all tree-sitter-* grammar bumps into one PR (ecosystem moves in lockstep, one PR per grammar is noise). - Pinned the tree-sitter runtime itself. Bumping 0.21 to 0.22+ changes which grammar ABIs load and requires coordinated updates to the vendored proto grammar. That stays a deliberate human decision. - Pinned tree-sitter-cli for the same reason (it controls which ABI vendor/tree-sitter-proto/src/parser.c emits when regenerated). Drift check (.github/scripts/check-tree-sitter-drift.py) - Reads the tree-sitter runtime version from gitnexus/package.json. - Walks every installed tree-sitter-* grammar plus the vendored proto and reports its LANGUAGE_VERSION against the runtime's supported ABI range (table maintained in the script; extend when bumping runtime). - Fetches coder3101/tree-sitter-proto main parser.c and compares byte for byte to the vendored copy. Reports the upstream HEAD short SHA and the upstream ABI so a maintainer can act. - Prints a Markdown report; exits 0 when everything is in range and matches upstream, 1 otherwise. - Stdlib only, no external deps. Drift workflow (.github/workflows/tree-sitter-drift-check.yml) - Runs weekly (Mondays 09:00 UTC) to match Dependabot's cadence. - Also runs on PRs that touch the script or workflow itself, where it fails the PR check on drift so the drift gate cannot land broken. - On scheduled runs with drift, opens or updates a single tracking issue labeled tree-sitter-drift. On scheduled runs that come back clean, closes the open tracking issue (if any) with a comment. * refactor(deps): rewrite drift check as tree-sitter 0.25 upgrade readiness monitor Replace the ABI drift pass/fail gate with a daily upgrade readiness dashboard that tracks peer-dep compatibility of all 14 grammars with tree-sitter@0.25.0 and reports which are ready, unreleased, or blocking. Key changes: - Rename drift-check → upgrade-readiness (script, workflow, job id) - Fix P0: pass report via env var, not ${{ }} template interpolation - Fix P1: npm fetch failure now adds a blocker instead of false-green - Fix P1: pass GITHUB_TOKEN for authenticated GitHub API calls - Switch Dependabot to daily for tree-sitter grammars - Use dict for blockers (no prefix collision), derive TARGET_RUNTIME constant, reuse GRAMMARS parser_path, normalize CRLF in comparisons - Reduce per-call HTTP timeout from 15s to 8s for workflow budget - PR runs warn on blockers instead of hard-failing Co-Authored-By: Claude Opus 4.6 (1M context) * chore(ci): remove global-upgrade smoke test workflow The ci-global-upgrade.yml workflow tested npm global install upgrades over a specific release candidate (1.6.2-rc.8). That RC has shipped and the workflow is no longer needed. Remove it and all references from ci.yml (needs, env vars, gate check). Co-Authored-By: Claude Opus 4.6 (1M context) * feat(ci): add changelog comments to upgrade readiness tracking issue Each daily run now posts a comment summarizing what changed before updating the issue body. Comments include the ready/blocker counts and a diff of grammar status changes (e.g. tree-sitter-cpp: Unreleased -> Ready). Gives a timeline of how the upgrade unblocks. --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .github/dependabot.yml | 59 +++ .../check-tree-sitter-upgrade-readiness.py | 358 ++++++++++++++++++ .github/workflows/ci-global-upgrade.yml | 113 ------ .github/workflows/ci.yml | 16 +- .../tree-sitter-upgrade-readiness.yml | 185 +++++++++ 5 files changed, 604 insertions(+), 127 deletions(-) create mode 100644 .github/scripts/check-tree-sitter-upgrade-readiness.py delete mode 100644 .github/workflows/ci-global-upgrade.yml create mode 100644 .github/workflows/tree-sitter-upgrade-readiness.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 292cb435d..f3530e4d3 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -14,3 +14,62 @@ updates: labels: - dependencies - ci + + # Gitnexus npm deps — tree-sitter grammars checked daily so we catch + # new releases that unblock the tree-sitter 0.25 upgrade ASAP. Grammars + # are grouped so lockstep bumps produce a single PR. The tree-sitter + # RUNTIME is pinned — upgrade deliberately via the drift check workflow. + # See .github/scripts/check-tree-sitter-upgrade-readiness.py for + # the upgrade readiness tracker. + - package-ecosystem: npm + directory: /gitnexus + schedule: + interval: daily + open-pull-requests-limit: 10 + commit-message: + prefix: chore(deps) + include: scope + labels: + - dependencies + groups: + tree-sitter-grammars: + patterns: + - tree-sitter-* + exclude-patterns: + - tree-sitter + - tree-sitter-cli + ignore: + # Pin the tree-sitter runtime at 0.21.x until the drift check + # reports all grammars are peer-dep compatible with 0.25. + - dependency-name: tree-sitter + update-types: + - version-update:semver-major + - version-update:semver-minor + # tree-sitter-cli follows the runtime's version cadence. Bump when + # regenerating vendor/tree-sitter-proto/src/parser.c, not on a schedule. + - dependency-name: tree-sitter-cli + + # gitnexus-web (thin frontend client). + - package-ecosystem: npm + directory: /gitnexus-web + schedule: + interval: weekly + open-pull-requests-limit: 5 + commit-message: + prefix: chore(deps) + include: scope + labels: + - dependencies + - frontend + + # Shared types package. + - package-ecosystem: npm + directory: /gitnexus-shared + schedule: + interval: weekly + open-pull-requests-limit: 5 + commit-message: + prefix: chore(deps) + include: scope + labels: + - dependencies diff --git a/.github/scripts/check-tree-sitter-upgrade-readiness.py b/.github/scripts/check-tree-sitter-upgrade-readiness.py new file mode 100644 index 000000000..df21533a8 --- /dev/null +++ b/.github/scripts/check-tree-sitter-upgrade-readiness.py @@ -0,0 +1,358 @@ +#!/usr/bin/env python3 +"""Monitor tree-sitter 0.25 upgrade readiness. + +Tracks two things Dependabot cannot see: + + 1. Peer-dep compatibility. Each tree-sitter-* grammar declares a peer + dependency on the tree-sitter runtime. We want to know when every + grammar's *latest npm release* satisfies tree-sitter@0.25.0 so we + can upgrade without --legacy-peer-deps. + + 2. Vendored upstream drift. vendor/tree-sitter-proto/ is a snapshot of + coder3101/tree-sitter-proto's parser.c. When upstream moves, we want + to know whether we can pick it up. + +Invoked from .github/workflows/tree-sitter-upgrade-readiness.yml daily. +Runs locally too: + + python3 .github/scripts/check-tree-sitter-upgrade-readiness.py + +Outputs Markdown to stdout. Exit 0 when every grammar is upgrade-ready +and the vendored proto is in sync. Exit 1 when blockers remain (the +workflow uses this to open or update a tracking issue). + +No external deps -- stdlib only, so it runs on any vanilla runner. +""" + +from __future__ import annotations + +import json +import os +import pathlib +import re +import sys +import urllib.error +import urllib.request + +REPO_ROOT = pathlib.Path(__file__).resolve().parents[2] +GITNEXUS_DIR = REPO_ROOT / "gitnexus" +VENDOR_PROTO_DIR = GITNEXUS_DIR / "vendor" / "tree-sitter-proto" + +# ── Upgrade target ────────────────────────────────────────────────────── +# The runtime version we want to upgrade TO. Update this when the goal +# changes (e.g. once 0.25 lands and we target 0.26). +TARGET_RUNTIME = "0.25.0" +TARGET_RUNTIME_MAJOR_MINOR = ".".join(TARGET_RUNTIME.split(".")[:2]) + +# Tree-sitter runtime -> (min_abi, max_abi) it can load. Only the current +# and target entries matter; extend when changing TARGET_RUNTIME. +RUNTIME_ABI_RANGES: dict[str, tuple[int, int]] = { + "0.21": (13, 14), + "0.25": (13, 15), +} + +assert TARGET_RUNTIME_MAJOR_MINOR in RUNTIME_ABI_RANGES, ( + f"RUNTIME_ABI_RANGES has no entry for {TARGET_RUNTIME_MAJOR_MINOR!r}. " + f"Add the ABI range after auditing the upstream release notes." +) + +# Grammars we use. Values are the upstream GitHub repos to check for +# unreleased ABI bumps (owner/repo, branch, parser.c path). +GRAMMARS: dict[str, tuple[str, str, str]] = { + "tree-sitter-c": ("tree-sitter/tree-sitter-c", "master", "src/parser.c"), + "tree-sitter-c-sharp": ("tree-sitter/tree-sitter-c-sharp", "master", "src/parser.c"), + "tree-sitter-cpp": ("tree-sitter/tree-sitter-cpp", "master", "src/parser.c"), + "tree-sitter-dart": ("UserNobody14/tree-sitter-dart", "master", "src/parser.c"), + "tree-sitter-go": ("tree-sitter/tree-sitter-go", "master", "src/parser.c"), + "tree-sitter-java": ("tree-sitter/tree-sitter-java", "master", "src/parser.c"), + "tree-sitter-javascript": ("tree-sitter/tree-sitter-javascript", "master", "src/parser.c"), + "tree-sitter-kotlin": ("fwcd/tree-sitter-kotlin", "main", "src/parser.c"), + "tree-sitter-php": ("tree-sitter/tree-sitter-php", "master", "php/src/parser.c"), + "tree-sitter-python": ("tree-sitter/tree-sitter-python", "master", "src/parser.c"), + "tree-sitter-ruby": ("tree-sitter/tree-sitter-ruby", "master", "src/parser.c"), + "tree-sitter-rust": ("tree-sitter/tree-sitter-rust", "master", "src/parser.c"), + "tree-sitter-swift": ("alex-pinkus/tree-sitter-swift", "main", "src/parser.c"), + "tree-sitter-typescript": ("tree-sitter/tree-sitter-typescript", "master", "typescript/src/parser.c"), +} + +UPSTREAM_PROTO_OWNER = "coder3101" +UPSTREAM_PROTO_REPO = "tree-sitter-proto" +UPSTREAM_PROTO_BRANCH = "main" + + +# ── Helpers ───────────────────────────────────────────────────────────── + +def read_current_runtime() -> str: + """Return the tree-sitter runtime version pinned in package.json (e.g. '0.21').""" + pkg = json.loads((GITNEXUS_DIR / "package.json").read_text()) + raw = pkg["dependencies"]["tree-sitter"] + match = re.search(r"(\d+)\.(\d+)", raw) + if not match: + raise SystemExit(f"could not parse tree-sitter version: {raw!r}") + return f"{match.group(1)}.{match.group(2)}" + + +def npm_view_json(pkg: str) -> dict | None: + """Fetch package metadata from the npm registry via HTTPS. + + Uses the registry API directly so we don't depend on the npm CLI + being available (it's a batch file on Windows which complicates + subprocess calls). + """ + url = f"https://registry.npmjs.org/{pkg}/latest" + try: + req = urllib.request.Request(url, headers={"Accept": "application/json"}) + with urllib.request.urlopen(req, timeout=8) as resp: + return json.loads(resp.read().decode("utf-8")) + except (urllib.error.URLError, urllib.error.HTTPError, json.JSONDecodeError): + return None + + +def satisfies_target(peer_range: str | None, target: str) -> bool: + """Check if a semver range like '^0.22.4' or '^0.25.0' satisfies the target. + + Simple heuristic: extract the minimum version from the range and check + if target >= min. For caret ranges (^X.Y.Z), the upper bound is the + next major (for X>0) or next minor (for X==0). We check both bounds. + """ + if peer_range is None: + # No peer dep declared = no constraint = compatible. + return True + match = re.search(r"(\d+)\.(\d+)\.(\d+)", peer_range) + if not match: + return False + min_major, min_minor, min_patch = int(match.group(1)), int(match.group(2)), int(match.group(3)) + + t_match = re.search(r"(\d+)\.(\d+)\.(\d+)", target) + if not t_match: + return False + t_major, t_minor, t_patch = int(t_match.group(1)), int(t_match.group(2)), int(t_match.group(3)) + + # Target must be >= minimum. + target_tuple = (t_major, t_minor, t_patch) + min_tuple = (min_major, min_minor, min_patch) + if target_tuple < min_tuple: + return False + + # For caret ranges with major 0: ^0.X.Y allows [0.X.Y, 0.(X+1).0). + if peer_range.startswith("^") and min_major == 0: + if t_major != 0 or t_minor >= min_minor + 1: + return False + # For caret ranges with major >0: ^X.Y.Z allows [X.Y.Z, (X+1).0.0). + elif peer_range.startswith("^") and min_major > 0: + if t_major >= min_major + 1: + return False + + return True + + +_GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN") + + +def fetch_text(url: str, timeout: int = 8) -> str | None: + """Fetch a URL and return its text, or None on failure. + + Adds an Authorization header for github.com URLs when GITHUB_TOKEN is + set (raises the rate limit from 60 to 5 000 requests/hour). + """ + headers: dict[str, str] = {} + if _GITHUB_TOKEN and ("github.com" in url or "githubusercontent.com" in url): + headers["Authorization"] = f"Bearer {_GITHUB_TOKEN}" + try: + req = urllib.request.Request(url, headers=headers) + with urllib.request.urlopen(req, timeout=timeout) as resp: + return resp.read().decode("utf-8", errors="ignore") + except (urllib.error.URLError, urllib.error.HTTPError): + return None + + +def extract_abi_from_text(text: str) -> int | None: + """Extract LANGUAGE_VERSION from parser.c text.""" + match = re.search(r"#define\s+LANGUAGE_VERSION\s+(\d+)", text[:4096]) + return int(match.group(1)) if match else None + + +def extract_language_version(parser_c: pathlib.Path) -> int | None: + """Return the LANGUAGE_VERSION defined in a parser.c, or None if absent.""" + if not parser_c.is_file(): + return None + with parser_c.open("r", encoding="utf-8", errors="ignore") as fh: + head = fh.read(4096) + return extract_abi_from_text(head) + + +def md_h(text: str, level: int = 2) -> str: + return f"{'#' * level} {text}\n" + + +# ── Main ──────────────────────────────────────────────────────────────── + +def main() -> int: + blockers: dict[str, str] = {} + lines: list[str] = [] + lines.append(md_h("Tree-sitter 0.25 upgrade readiness", 1)) + lines.append("") + + current_runtime = read_current_runtime() + current_abi_range = RUNTIME_ABI_RANGES.get(current_runtime, (0, 0)) + target_abi_range = RUNTIME_ABI_RANGES.get(TARGET_RUNTIME_MAJOR_MINOR, (0, 0)) + + lines.append(f"- Current runtime: `tree-sitter@{current_runtime}.x` (ABI {current_abi_range[0]}..{current_abi_range[1]})") + lines.append(f"- Target runtime: `tree-sitter@{TARGET_RUNTIME}` (ABI {target_abi_range[0]}..{target_abi_range[1]})") + lines.append("") + + # ── Grammar peer-dep compatibility ─────────────────────────────── + lines.append(md_h("Grammar compatibility", 2)) + lines.append("| Grammar | npm latest | Peer dep | Satisfies 0.25? | ABI | Upstream ABI | Status |") + lines.append("|---|---|---|---|---|---|---|") + + ready_count = 0 + total_count = len(GRAMMARS) + + for name, (upstream_repo, upstream_branch, parser_path) in sorted(GRAMMARS.items()): + # Fetch latest npm metadata. + info = npm_view_json(name) + fetch_failed = info is None + npm_version = "?" + peer_range = None + peer_optional = True + if info: + npm_version = info.get("version", "?") + peers = info.get("peerDependencies") or {} + peer_range = peers.get("tree-sitter") + meta = info.get("peerDependenciesMeta") or {} + ts_meta = meta.get("tree-sitter") or {} + peer_optional = ts_meta.get("optional", False) if peer_range else True + + if fetch_failed: + peer_display = "? (fetch failed)" + compatible = False + else: + peer_display = peer_range or "none" + if peer_range and not peer_optional: + peer_display += " (required)" + compatible = satisfies_target(peer_range, TARGET_RUNTIME) + + # Check installed ABI using the same parser_path from GRAMMARS. + installed_parser = GITNEXUS_DIR / "node_modules" / name / parser_path + if not installed_parser.is_file(): + # Fallback to default location. + installed_parser = GITNEXUS_DIR / "node_modules" / name / "src" / "parser.c" + installed_abi = extract_language_version(installed_parser) + abi_display = str(installed_abi) if installed_abi else "?" + + # Check upstream (main/master branch) ABI for unreleased work. + upstream_url = ( + f"https://raw.githubusercontent.com/{upstream_repo}/" + f"{upstream_branch}/{parser_path}" + ) + upstream_text = fetch_text(upstream_url) + upstream_abi = extract_abi_from_text(upstream_text) if upstream_text else None + upstream_abi_display = str(upstream_abi) if upstream_abi else "?" + + # Determine status. + if fetch_failed: + status = "Unknown (fetch failed)" + blockers[name] = f"`{name}`: npm registry fetch failed — could not verify peer dep" + elif compatible: + status = "Ready" + ready_count += 1 + elif upstream_abi and upstream_abi >= 15: + status = "Unreleased (ABI 15 on main)" + blockers[name] = f"`{name}`: ABI 15 on `{upstream_repo}` main but not published to npm" + else: + status = "Blocking" + blockers[name] = f"`{name}@{npm_version}`: peer `{peer_display}` incompatible with 0.25" + + # Also check upstream package.json for relaxed peer dep. + if not compatible and not fetch_failed: + upstream_pkg_url = ( + f"https://raw.githubusercontent.com/{upstream_repo}/" + f"{upstream_branch}/package.json" + ) + upstream_pkg_text = fetch_text(upstream_pkg_url) + if upstream_pkg_text: + try: + upstream_pkg = json.loads(upstream_pkg_text) + upstream_peer = (upstream_pkg.get("peerDependencies") or {}).get("tree-sitter") + if upstream_peer and satisfies_target(upstream_peer, TARGET_RUNTIME): + status = "Unreleased (peer relaxed on main)" + blockers[name] = f"`{name}`: peer dep relaxed on `{upstream_repo}` main but not published to npm" + except json.JSONDecodeError: + pass + + compat_icon = "Yes" if compatible else "**No**" + lines.append( + f"| `{name}` | {npm_version} | {peer_display} | {compat_icon} | {abi_display} | {upstream_abi_display} | {status} |" + ) + + lines.append("") + lines.append(f"**{ready_count}/{total_count}** grammars ready for `tree-sitter@{TARGET_RUNTIME}`.") + lines.append("") + + # ── Vendored proto drift ───────────────────────────────────────── + lines.append(md_h("Vendored tree-sitter-proto", 2)) + vendored_abi = extract_language_version(VENDOR_PROTO_DIR / "src" / "parser.c") + + upstream_proto_url = ( + f"https://raw.githubusercontent.com/{UPSTREAM_PROTO_OWNER}/" + f"{UPSTREAM_PROTO_REPO}/{UPSTREAM_PROTO_BRANCH}/src/parser.c" + ) + upstream_proto_text = fetch_text(upstream_proto_url) + upstream_proto_abi = extract_abi_from_text(upstream_proto_text) if upstream_proto_text else None + + sha_url = ( + f"https://api.github.com/repos/{UPSTREAM_PROTO_OWNER}/" + f"{UPSTREAM_PROTO_REPO}/commits/{UPSTREAM_PROTO_BRANCH}" + ) + sha_text = fetch_text(sha_url) + upstream_sha = "?" + if sha_text: + try: + upstream_sha = json.loads(sha_text).get("sha", "?")[:12] + except json.JSONDecodeError: + pass + + local_proto_path = VENDOR_PROTO_DIR / "src" / "parser.c" + local_proto_text = local_proto_path.read_text(encoding="utf-8", errors="ignore") if local_proto_path.is_file() else "" + in_sync = bool( + upstream_proto_text + and local_proto_text.replace("\r\n", "\n") + == upstream_proto_text.replace("\r\n", "\n") + ) + + lines.append(f"- Upstream: `{UPSTREAM_PROTO_OWNER}/{UPSTREAM_PROTO_REPO}@{UPSTREAM_PROTO_BRANCH}` (HEAD `{upstream_sha}`)") + lines.append(f"- Upstream ABI: **{upstream_proto_abi}**") + lines.append(f"- Vendored ABI: **{vendored_abi}**") + lines.append(f"- In sync: {'yes' if in_sync else 'no — upstream has diverged'}") + + if upstream_proto_abi and vendored_abi and upstream_proto_abi > vendored_abi: + can_upgrade = upstream_proto_abi <= target_abi_range[1] + lines.append(f"- Upstream ABI {upstream_proto_abi} {'is' if can_upgrade else 'is NOT'} within target runtime range ({target_abi_range[0]}..{target_abi_range[1]})") + if can_upgrade: + lines.append(f"- **Action:** after upgrading to tree-sitter@{TARGET_RUNTIME}, regenerate vendored parser.c from upstream `{upstream_sha}`") + else: + lines.append(f"- **Action:** wait for runtime upgrade beyond {TARGET_RUNTIME} that supports ABI {upstream_proto_abi}") + blockers["vendored-proto-abi"] = f"vendored tree-sitter-proto: upstream ABI {upstream_proto_abi} outside target range" + elif not in_sync: + lines.append("- **Action:** review upstream changes; vendored copy may need updating") + blockers["vendored-proto-sync"] = "vendored tree-sitter-proto: out of sync with upstream" + + # ── Summary ────────────────────────────────────────────────────── + lines.append("") + lines.append(md_h("Summary", 2)) + if blockers: + lines.append(f"**{len(blockers)} blocker(s) remaining:**\n") + for b in blockers.values(): + lines.append(f"- {b}") + lines.append("") + lines.append("Upgrade to `tree-sitter@0.25` is **blocked**.") + else: + lines.append("All grammars are compatible. Upgrade to `tree-sitter@0.25` is **ready**.") + + print("\n".join(lines)) + return 1 if blockers else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/ci-global-upgrade.yml b/.github/workflows/ci-global-upgrade.yml deleted file mode 100644 index e181f46ad..000000000 --- a/.github/workflows/ci-global-upgrade.yml +++ /dev/null @@ -1,113 +0,0 @@ -name: Global Install Upgrade Smoke - -# Catches regressions where `npm install -g gitnexus@` fails to upgrade -# cleanly over a prior global install. Prior precedent: issue #836 and PR #843's -# incomplete fix slipped past CI because no global-upgrade test existed. -# -# Reusable workflow — only callable from ci.yml. Concurrency is governed by the -# caller (ci.yml), so no `concurrency:` block here. - -on: - workflow_call: - -jobs: - global-upgrade: - name: ${{ matrix.os }} / upgrade over ${{ matrix.prior }} - strategy: - fail-fast: false - matrix: - # macOS is the reporter's platform (issue #836) and the highest-risk - # surface for npm global-install rmdir behavior. Linux and Windows - # provide cross-platform regression coverage. - os: [macos-latest, ubuntu-latest, windows-latest] - # Prior version that must be upgraded OVER. Should be a published rc - # that preceded the fix. Bump when a known-bad version changes. - prior: ['1.6.2-rc.8'] - runs-on: ${{ matrix.os }} - timeout-minutes: 15 - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - uses: ./.github/actions/setup-gitnexus - with: - build: 'false' - - - name: Install prior published version globally - run: npm install -g gitnexus@${{ matrix.prior }} - - - name: Verify prior version installed - run: gitnexus --version - - - name: Pack current branch - working-directory: gitnexus - run: npm pack - shell: bash - - - name: Compute packed tarball path - id: tarball - working-directory: gitnexus - run: | - TARBALL=$(ls gitnexus-*.tgz | head -1) - echo "path=$(pwd)/$TARBALL" >> "$GITHUB_OUTPUT" - shell: bash - - - name: Upgrade over prior version (the actual regression test) - run: npm install -g "${{ steps.tarball.outputs.path }}" - shell: bash - - - name: Verify upgraded version runs - run: gitnexus --version - - - name: Verify vendor/ has no nested node_modules after install - shell: bash - run: | - # The original #836 bug was about vendor/tree-sitter-proto/node_modules/ - # blocking rmdir on upgrade. That is what the fix eliminates. A - # vendor/tree-sitter-proto/build/ directory can still appear because - # node-gyp-build compiles through the npm-created symlink; the - # contents are plain object files and .node binaries that rmdir - # handles fine, evidenced by this test getting past the upgrade step. - GLOBAL_PREFIX=$(npm root -g) - if [ -d "$GLOBAL_PREFIX/gitnexus/vendor/tree-sitter-proto" ]; then - echo "=== Contents of global vendor/tree-sitter-proto/ ===" - ls -la "$GLOBAL_PREFIX/gitnexus/vendor/tree-sitter-proto/" - if [ -d "$GLOBAL_PREFIX/gitnexus/vendor/tree-sitter-proto/node_modules" ]; then - echo "::error::vendor/tree-sitter-proto/node_modules/ was created — this is the #836 hazard" - exit 1 - fi - fi - - ignore-scripts: - name: ${{ matrix.os }} / --ignore-scripts degraded mode - strategy: - fail-fast: false - matrix: - os: [macos-latest, ubuntu-latest, windows-latest] - runs-on: ${{ matrix.os }} - timeout-minutes: 10 - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - uses: ./.github/actions/setup-gitnexus - with: - build: 'false' - - - name: Pack current branch - working-directory: gitnexus - run: npm pack - shell: bash - - - name: Compute packed tarball path - id: tarball - working-directory: gitnexus - run: | - TARBALL=$(ls gitnexus-*.tgz | head -1) - echo "path=$(pwd)/$TARBALL" >> "$GITHUB_OUTPUT" - shell: bash - - - name: Install globally with --ignore-scripts - run: npm install -g --ignore-scripts "${{ steps.tarball.outputs.path }}" - shell: bash - - - name: Verify CLI boots without postinstall (proto parsing may be unavailable) - run: gitnexus --version diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2eeeaab2..cf0c6d5c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,11 +48,6 @@ jobs: permissions: contents: read - global-upgrade: - uses: ./.github/workflows/ci-global-upgrade.yml - 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 @@ -61,7 +56,7 @@ jobs: save-pr-meta: name: Save PR Metadata if: always() && github.event_name == 'pull_request' - needs: [quality, tests, e2e, global-upgrade] + needs: [quality, tests, e2e] runs-on: ubuntu-latest timeout-minutes: 5 steps: @@ -72,7 +67,6 @@ jobs: QUALITY: ${{ needs.quality.result }} TESTS: ${{ needs.tests.result }} E2E: ${{ needs.e2e.result }} - GLOBAL_UPGRADE: ${{ needs.global-upgrade.result }} run: | mkdir -p pr-meta echo "$PR_NUMBER" > pr-meta/pr_number @@ -101,7 +95,7 @@ jobs: # Single required check for branch protection. ci-status: name: CI Gate - needs: [quality, tests, e2e, global-upgrade] + needs: [quality, tests, e2e] if: always() runs-on: ubuntu-latest timeout-minutes: 5 @@ -112,12 +106,10 @@ jobs: QUALITY: ${{ needs.quality.result }} TESTS: ${{ needs.tests.result }} E2E: ${{ needs.e2e.result }} - GLOBAL_UPGRADE: ${{ needs.global-upgrade.result }} run: | echo "Quality: $QUALITY" echo "Tests: $TESTS" echo "E2E: $E2E" - echo "Global upgrade: $GLOBAL_UPGRADE" if [[ "$QUALITY" != "success" ]] || [[ "$TESTS" != "success" ]]; then echo "::error::Quality or test jobs failed" @@ -127,7 +119,3 @@ jobs: echo "::error::E2E job failed" exit 1 fi - if [[ "$GLOBAL_UPGRADE" != "success" && "$GLOBAL_UPGRADE" != "skipped" ]]; then - echo "::error::Global upgrade smoke failed" - exit 1 - fi diff --git a/.github/workflows/tree-sitter-upgrade-readiness.yml b/.github/workflows/tree-sitter-upgrade-readiness.yml new file mode 100644 index 000000000..74ce72a27 --- /dev/null +++ b/.github/workflows/tree-sitter-upgrade-readiness.yml @@ -0,0 +1,185 @@ +name: Tree-sitter Upgrade Readiness + +# Monitors readiness for upgrading tree-sitter to 0.25.x. Tracks: +# 1. Peer-dep compatibility — can each grammar install cleanly with +# tree-sitter@0.25.0 without --legacy-peer-deps? +# 2. Vendored proto drift — has coder3101/tree-sitter-proto moved +# ahead of our vendored snapshot? +# See .github/scripts/check-tree-sitter-upgrade-readiness.py for the logic. +# +# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention". + +on: + schedule: + # Daily at 09:00 UTC. Matches Dependabot's daily cadence so drift + # and dep PRs surface together. + - cron: '0 9 * * *' + workflow_dispatch: + pull_request: + paths: + - '.github/scripts/check-tree-sitter-upgrade-readiness.py' + - '.github/workflows/tree-sitter-upgrade-readiness.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + readiness: + name: Check upgrade readiness + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + # Needed to open/update the tracking issue on scheduled runs. + issues: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: ./.github/actions/setup-gitnexus + with: + build: 'false' + + - name: Run upgrade readiness check + id: readiness + shell: bash + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set +e + python3 .github/scripts/check-tree-sitter-upgrade-readiness.py > drift-report.md + code=$? + set -e + echo "exit_code=$code" >> "$GITHUB_OUTPUT" + { + echo 'report<> "$GITHUB_OUTPUT" + echo "=== Report ===" + cat drift-report.md + + # On PR runs, the script validates that it runs correctly. Blockers + # are informational — the scheduled run opens a tracking issue. + - name: Annotate PR with readiness status + if: github.event_name == 'pull_request' && steps.readiness.outputs.exit_code != '0' + run: | + echo "::warning::Tree-sitter 0.25 upgrade has blockers. See job output for the full readiness report." + + - name: Upsert tracking issue on scheduled runs + if: > + github.event_name == 'schedule' && + steps.readiness.outputs.exit_code != '0' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + REPORT: ${{ steps.readiness.outputs.report }} + with: + script: | + const title = 'Tree-sitter 0.25 upgrade readiness'; + const report = process.env.REPORT; + const body = report + '\n\n' + + 'Generated daily by `.github/workflows/tree-sitter-upgrade-readiness.yml`. ' + + 'Closes automatically when all blockers are resolved.'; + const { data: open } = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + labels: 'tree-sitter-drift', + per_page: 10, + }); + const existing = open.find(i => i.title === title); + if (existing) { + // Extract ready/total count for the changelog comment. + const readyMatch = report.match(/\*\*(\d+)\/(\d+)\*\* grammars ready/); + const blockerMatch = report.match(/\*\*(\d+) blocker/); + const ready = readyMatch ? readyMatch[1] : '?'; + const total = readyMatch ? readyMatch[2] : '?'; + const blockers = blockerMatch ? blockerMatch[1] : '?'; + + // Find grammars whose status changed by diffing the old and + // new table rows. Each row looks like: + // | `tree-sitter-foo` | ... | Ready | + // | `tree-sitter-foo` | ... | Blocking | + const parseRows = (md) => { + const map = {}; + for (const m of md.matchAll(/\| `(tree-sitter-[^`]+)` \|.*?\| (\S+(?:\s\S+)*?) \|$/gm)) { + map[m[1]] = m[2].trim(); + } + return map; + }; + const oldRows = parseRows(existing.body || ''); + const newRows = parseRows(report); + const changes = []; + for (const [name, newStatus] of Object.entries(newRows)) { + const oldStatus = oldRows[name]; + if (oldStatus && oldStatus !== newStatus) { + changes.push(`\`${name}\`: ${oldStatus} → ${newStatus}`); + } + } + + const today = new Date().toISOString().slice(0, 10); + let comment = `**${today}:** ${ready}/${total} ready. ${blockers} blocker(s) remaining.`; + if (changes.length > 0) { + comment += '\n\nChanges:\n' + changes.map(c => `- ${c}`).join('\n'); + } else { + comment += ' No changes from previous run.'; + } + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: existing.number, + body: comment, + }); + + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: existing.number, + body, + }); + core.info(`Updated existing issue #${existing.number}`); + } else { + const { data: created } = await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title, + body, + labels: ['tree-sitter-drift', 'dependencies'], + }); + core.info(`Opened issue #${created.number}`); + } + + - name: Close tracking issue on clean scheduled runs + if: > + github.event_name == 'schedule' && + steps.readiness.outputs.exit_code == '0' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const title = 'Tree-sitter 0.25 upgrade readiness'; + const { data: open } = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + labels: 'tree-sitter-drift', + per_page: 10, + }); + const existing = open.find(i => i.title === title); + if (existing) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: existing.number, + body: 'All grammars are now compatible with tree-sitter@0.25. Upgrade is ready! Closing automatically.', + }); + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: existing.number, + state: 'closed', + }); + core.info(`Closed issue #${existing.number}`); + } From 06f18ada159ab88ab4f18bd72471a0c8928c1f3f Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Apr 2026 10:50:54 +0100 Subject: [PATCH 09/17] refactor(ingestion): move class extraction configs to configs/ subdirectory (#879) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * refactor(ingestion): move class extraction configs to configs/ subdirectory Extract inline ClassExtractionConfig objects from 13 language provider files into 11 config files under class-extractors/configs/, matching the pattern established by method-extractors/configs/ and field-extractors/configs/. Pure structural refactor — zero behavioral change. All class extraction tests pass unchanged. Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/8ca2bf18-46ea-41c3-9c7f-9eb3f5752ade Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix: resolve prettier formatting in c-cpp.ts import Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/1b1e3c43-fc0e-444c-b109-cc0c56ffb470 --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> --- .../class-extractors/configs/c-cpp.ts | 15 +++++++ .../class-extractors/configs/csharp.ts | 24 +++++++++++ .../class-extractors/configs/dart.ts | 10 +++++ .../ingestion/class-extractors/configs/go.ts | 21 ++++++++++ .../ingestion/class-extractors/configs/jvm.ts | 40 +++++++++++++++++++ .../ingestion/class-extractors/configs/php.ts | 10 +++++ .../class-extractors/configs/python.ts | 10 +++++ .../class-extractors/configs/ruby.ts | 10 +++++ .../class-extractors/configs/rust.ts | 10 +++++ .../class-extractors/configs/swift.ts | 17 ++++++++ .../configs/typescript-javascript.ts | 34 ++++++++++++++++ .../src/core/ingestion/languages/c-cpp.ts | 12 ++---- .../src/core/ingestion/languages/csharp.ts | 21 +--------- gitnexus/src/core/ingestion/languages/dart.ts | 7 +--- gitnexus/src/core/ingestion/languages/go.ts | 18 +-------- gitnexus/src/core/ingestion/languages/java.ts | 18 +-------- .../src/core/ingestion/languages/kotlin.ts | 12 +----- gitnexus/src/core/ingestion/languages/php.ts | 7 +--- .../src/core/ingestion/languages/python.ts | 7 +--- gitnexus/src/core/ingestion/languages/ruby.ts | 7 +--- gitnexus/src/core/ingestion/languages/rust.ts | 7 +--- .../src/core/ingestion/languages/swift.ts | 14 +------ .../core/ingestion/languages/typescript.ts | 28 +++---------- gitnexus/src/core/ingestion/languages/vue.ts | 17 +------- 24 files changed, 232 insertions(+), 144 deletions(-) create mode 100644 gitnexus/src/core/ingestion/class-extractors/configs/c-cpp.ts create mode 100644 gitnexus/src/core/ingestion/class-extractors/configs/csharp.ts create mode 100644 gitnexus/src/core/ingestion/class-extractors/configs/dart.ts create mode 100644 gitnexus/src/core/ingestion/class-extractors/configs/go.ts create mode 100644 gitnexus/src/core/ingestion/class-extractors/configs/jvm.ts create mode 100644 gitnexus/src/core/ingestion/class-extractors/configs/php.ts create mode 100644 gitnexus/src/core/ingestion/class-extractors/configs/python.ts create mode 100644 gitnexus/src/core/ingestion/class-extractors/configs/ruby.ts create mode 100644 gitnexus/src/core/ingestion/class-extractors/configs/rust.ts create mode 100644 gitnexus/src/core/ingestion/class-extractors/configs/swift.ts create mode 100644 gitnexus/src/core/ingestion/class-extractors/configs/typescript-javascript.ts diff --git a/gitnexus/src/core/ingestion/class-extractors/configs/c-cpp.ts b/gitnexus/src/core/ingestion/class-extractors/configs/c-cpp.ts new file mode 100644 index 000000000..fcc1a22bf --- /dev/null +++ b/gitnexus/src/core/ingestion/class-extractors/configs/c-cpp.ts @@ -0,0 +1,15 @@ +// gitnexus/src/core/ingestion/class-extractors/configs/c-cpp.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { ClassExtractionConfig } from '../../class-types.js'; + +export const cClassConfig: ClassExtractionConfig = { + language: SupportedLanguages.C, + typeDeclarationNodes: ['struct_specifier', 'enum_specifier'], +}; + +export const cppClassConfig: ClassExtractionConfig = { + language: SupportedLanguages.CPlusPlus, + typeDeclarationNodes: ['class_specifier', 'struct_specifier', 'enum_specifier'], + ancestorScopeNodeTypes: ['namespace_definition', 'class_specifier', 'struct_specifier'], +}; diff --git a/gitnexus/src/core/ingestion/class-extractors/configs/csharp.ts b/gitnexus/src/core/ingestion/class-extractors/configs/csharp.ts new file mode 100644 index 000000000..59b7be617 --- /dev/null +++ b/gitnexus/src/core/ingestion/class-extractors/configs/csharp.ts @@ -0,0 +1,24 @@ +// gitnexus/src/core/ingestion/class-extractors/configs/csharp.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { ClassExtractionConfig } from '../../class-types.js'; + +export const csharpClassConfig: ClassExtractionConfig = { + language: SupportedLanguages.CSharp, + typeDeclarationNodes: [ + 'class_declaration', + 'interface_declaration', + 'struct_declaration', + 'enum_declaration', + 'record_declaration', + ], + fileScopeNodeTypes: ['file_scoped_namespace_declaration'], + ancestorScopeNodeTypes: [ + 'namespace_declaration', + 'class_declaration', + 'interface_declaration', + 'struct_declaration', + 'enum_declaration', + 'record_declaration', + ], +}; diff --git a/gitnexus/src/core/ingestion/class-extractors/configs/dart.ts b/gitnexus/src/core/ingestion/class-extractors/configs/dart.ts new file mode 100644 index 000000000..c46c05533 --- /dev/null +++ b/gitnexus/src/core/ingestion/class-extractors/configs/dart.ts @@ -0,0 +1,10 @@ +// gitnexus/src/core/ingestion/class-extractors/configs/dart.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { ClassExtractionConfig } from '../../class-types.js'; + +export const dartClassConfig: ClassExtractionConfig = { + language: SupportedLanguages.Dart, + typeDeclarationNodes: ['class_definition', 'extension_declaration', 'enum_declaration'], + ancestorScopeNodeTypes: ['class_definition', 'extension_declaration', 'enum_declaration'], +}; diff --git a/gitnexus/src/core/ingestion/class-extractors/configs/go.ts b/gitnexus/src/core/ingestion/class-extractors/configs/go.ts new file mode 100644 index 000000000..58dade9fa --- /dev/null +++ b/gitnexus/src/core/ingestion/class-extractors/configs/go.ts @@ -0,0 +1,21 @@ +// gitnexus/src/core/ingestion/class-extractors/configs/go.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { ClassExtractionConfig } from '../../class-types.js'; + +export const goClassConfig: ClassExtractionConfig = { + language: SupportedLanguages.Go, + typeDeclarationNodes: ['type_declaration'], + fileScopeNodeTypes: ['package_clause'], + extractName(node) { + const typeSpec = node.namedChildren.find((child) => child.type === 'type_spec'); + return typeSpec?.childForFieldName('name')?.text; + }, + extractType(node) { + const typeSpec = node.namedChildren.find((child) => child.type === 'type_spec'); + const typeNode = typeSpec?.childForFieldName('type'); + if (typeNode?.type === 'struct_type') return 'Struct'; + if (typeNode?.type === 'interface_type') return 'Interface'; + return undefined; + }, +}; diff --git a/gitnexus/src/core/ingestion/class-extractors/configs/jvm.ts b/gitnexus/src/core/ingestion/class-extractors/configs/jvm.ts new file mode 100644 index 000000000..fbd22f545 --- /dev/null +++ b/gitnexus/src/core/ingestion/class-extractors/configs/jvm.ts @@ -0,0 +1,40 @@ +// gitnexus/src/core/ingestion/class-extractors/configs/jvm.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { ClassExtractionConfig } from '../../class-types.js'; + +// --------------------------------------------------------------------------- +// Java +// --------------------------------------------------------------------------- + +export const javaClassConfig: ClassExtractionConfig = { + language: SupportedLanguages.Java, + typeDeclarationNodes: [ + 'class_declaration', + 'interface_declaration', + 'enum_declaration', + 'record_declaration', + ], + fileScopeNodeTypes: ['package_declaration'], + ancestorScopeNodeTypes: [ + 'class_declaration', + 'interface_declaration', + 'enum_declaration', + 'record_declaration', + ], +}; + +// --------------------------------------------------------------------------- +// Kotlin +// --------------------------------------------------------------------------- + +export const kotlinClassConfig: ClassExtractionConfig = { + language: SupportedLanguages.Kotlin, + typeDeclarationNodes: ['class_declaration', 'object_declaration', 'companion_object'], + fileScopeNodeTypes: ['package_header'], + ancestorScopeNodeTypes: ['class_declaration', 'object_declaration', 'companion_object'], + extractType(node) { + if (node.type !== 'class_declaration') return undefined; + return node.children.some((child) => child?.text === 'interface') ? 'Interface' : 'Class'; + }, +}; diff --git a/gitnexus/src/core/ingestion/class-extractors/configs/php.ts b/gitnexus/src/core/ingestion/class-extractors/configs/php.ts new file mode 100644 index 000000000..850b415b4 --- /dev/null +++ b/gitnexus/src/core/ingestion/class-extractors/configs/php.ts @@ -0,0 +1,10 @@ +// gitnexus/src/core/ingestion/class-extractors/configs/php.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { ClassExtractionConfig } from '../../class-types.js'; + +export const phpClassConfig: ClassExtractionConfig = { + language: SupportedLanguages.PHP, + typeDeclarationNodes: ['class_declaration', 'interface_declaration', 'enum_declaration'], + ancestorScopeNodeTypes: ['namespace_definition'], +}; diff --git a/gitnexus/src/core/ingestion/class-extractors/configs/python.ts b/gitnexus/src/core/ingestion/class-extractors/configs/python.ts new file mode 100644 index 000000000..42761468e --- /dev/null +++ b/gitnexus/src/core/ingestion/class-extractors/configs/python.ts @@ -0,0 +1,10 @@ +// gitnexus/src/core/ingestion/class-extractors/configs/python.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { ClassExtractionConfig } from '../../class-types.js'; + +export const pythonClassConfig: ClassExtractionConfig = { + language: SupportedLanguages.Python, + typeDeclarationNodes: ['class_definition'], + ancestorScopeNodeTypes: ['class_definition'], +}; diff --git a/gitnexus/src/core/ingestion/class-extractors/configs/ruby.ts b/gitnexus/src/core/ingestion/class-extractors/configs/ruby.ts new file mode 100644 index 000000000..2c4c711bd --- /dev/null +++ b/gitnexus/src/core/ingestion/class-extractors/configs/ruby.ts @@ -0,0 +1,10 @@ +// gitnexus/src/core/ingestion/class-extractors/configs/ruby.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { ClassExtractionConfig } from '../../class-types.js'; + +export const rubyClassConfig: ClassExtractionConfig = { + language: SupportedLanguages.Ruby, + typeDeclarationNodes: ['class'], + ancestorScopeNodeTypes: ['module', 'class'], +}; diff --git a/gitnexus/src/core/ingestion/class-extractors/configs/rust.ts b/gitnexus/src/core/ingestion/class-extractors/configs/rust.ts new file mode 100644 index 000000000..7f3873802 --- /dev/null +++ b/gitnexus/src/core/ingestion/class-extractors/configs/rust.ts @@ -0,0 +1,10 @@ +// gitnexus/src/core/ingestion/class-extractors/configs/rust.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { ClassExtractionConfig } from '../../class-types.js'; + +export const rustClassConfig: ClassExtractionConfig = { + language: SupportedLanguages.Rust, + typeDeclarationNodes: ['struct_item', 'enum_item'], + ancestorScopeNodeTypes: ['mod_item', 'struct_item', 'enum_item'], +}; diff --git a/gitnexus/src/core/ingestion/class-extractors/configs/swift.ts b/gitnexus/src/core/ingestion/class-extractors/configs/swift.ts new file mode 100644 index 000000000..713a02496 --- /dev/null +++ b/gitnexus/src/core/ingestion/class-extractors/configs/swift.ts @@ -0,0 +1,17 @@ +// gitnexus/src/core/ingestion/class-extractors/configs/swift.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { ClassExtractionConfig } from '../../class-types.js'; + +export const swiftClassConfig: ClassExtractionConfig = { + language: SupportedLanguages.Swift, + typeDeclarationNodes: ['class_declaration', 'protocol_declaration'], + ancestorScopeNodeTypes: ['class_declaration', 'protocol_declaration'], + extractType(node) { + if (node.type === 'protocol_declaration') return 'Interface'; + if (node.type !== 'class_declaration') return undefined; + if (node.children.some((child) => child?.text === 'struct')) return 'Struct'; + if (node.children.some((child) => child?.text === 'enum')) return 'Enum'; + return 'Class'; + }, +}; diff --git a/gitnexus/src/core/ingestion/class-extractors/configs/typescript-javascript.ts b/gitnexus/src/core/ingestion/class-extractors/configs/typescript-javascript.ts new file mode 100644 index 000000000..b2262432d --- /dev/null +++ b/gitnexus/src/core/ingestion/class-extractors/configs/typescript-javascript.ts @@ -0,0 +1,34 @@ +// gitnexus/src/core/ingestion/class-extractors/configs/typescript-javascript.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { ClassExtractionConfig } from '../../class-types.js'; + +const shared: Omit = { + typeDeclarationNodes: [ + 'class_declaration', + 'abstract_class_declaration', + 'interface_declaration', + 'enum_declaration', + ], + ancestorScopeNodeTypes: [ + 'class_declaration', + 'abstract_class_declaration', + 'interface_declaration', + 'enum_declaration', + ], +}; + +export const typescriptClassConfig: ClassExtractionConfig = { + ...shared, + language: SupportedLanguages.TypeScript, +}; + +export const javascriptClassConfig: ClassExtractionConfig = { + ...shared, + language: SupportedLanguages.JavaScript, +}; + +export const vueClassConfig: ClassExtractionConfig = { + ...shared, + language: SupportedLanguages.Vue, +}; diff --git a/gitnexus/src/core/ingestion/languages/c-cpp.ts b/gitnexus/src/core/ingestion/languages/c-cpp.ts index 5b887634f..7a6703a7c 100644 --- a/gitnexus/src/core/ingestion/languages/c-cpp.ts +++ b/gitnexus/src/core/ingestion/languages/c-cpp.ts @@ -10,6 +10,7 @@ import { SupportedLanguages } from 'gitnexus-shared'; import { createClassExtractor } from '../class-extractors/generic.js'; +import { cClassConfig, cppClassConfig } from '../class-extractors/configs/c-cpp.js'; import { defineLanguage } from '../language-provider.js'; import { typeConfig as cCppConfig } from '../type-extractors/c-cpp.js'; import { cCppExportChecker } from '../export-detection.js'; @@ -145,16 +146,9 @@ const C_BUILT_INS: ReadonlySet = new Set([ 'put', ]); -const cClassExtractor = createClassExtractor({ - language: SupportedLanguages.C, - typeDeclarationNodes: ['struct_specifier', 'enum_specifier'], -}); +const cClassExtractor = createClassExtractor(cClassConfig); -const cppClassExtractor = createClassExtractor({ - language: SupportedLanguages.CPlusPlus, - typeDeclarationNodes: ['class_specifier', 'struct_specifier', 'enum_specifier'], - ancestorScopeNodeTypes: ['namespace_definition', 'class_specifier', 'struct_specifier'], -}); +const cppClassExtractor = createClassExtractor(cppClassConfig); /** * C/C++ function name extraction — unwraps pointer_declarator / reference_declarator / diff --git a/gitnexus/src/core/ingestion/languages/csharp.ts b/gitnexus/src/core/ingestion/languages/csharp.ts index 6ec3a6a8b..bf02412d0 100644 --- a/gitnexus/src/core/ingestion/languages/csharp.ts +++ b/gitnexus/src/core/ingestion/languages/csharp.ts @@ -8,6 +8,7 @@ import { SupportedLanguages } from 'gitnexus-shared'; import { createClassExtractor } from '../class-extractors/generic.js'; +import { csharpClassConfig } from '../class-extractors/configs/csharp.js'; import { defineLanguage } from '../language-provider.js'; import { typeConfig as csharpConfig } from '../type-extractors/csharp.js'; import { csharpExportChecker } from '../export-detection.js'; @@ -126,24 +127,6 @@ export const csharpProvider = defineLanguage({ mroStrategy: 'implements-split', fieldExtractor: createFieldExtractor(csharpFieldConfig), methodExtractor: createMethodExtractor(csharpMethodConfig), - classExtractor: createClassExtractor({ - language: SupportedLanguages.CSharp, - typeDeclarationNodes: [ - 'class_declaration', - 'interface_declaration', - 'struct_declaration', - 'enum_declaration', - 'record_declaration', - ], - fileScopeNodeTypes: ['file_scoped_namespace_declaration'], - ancestorScopeNodeTypes: [ - 'namespace_declaration', - 'class_declaration', - 'interface_declaration', - 'struct_declaration', - 'enum_declaration', - 'record_declaration', - ], - }), + classExtractor: createClassExtractor(csharpClassConfig), builtInNames: BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/languages/dart.ts b/gitnexus/src/core/ingestion/languages/dart.ts index 7dc6769c6..779592cdd 100644 --- a/gitnexus/src/core/ingestion/languages/dart.ts +++ b/gitnexus/src/core/ingestion/languages/dart.ts @@ -15,6 +15,7 @@ import type { NodeLabel } from 'gitnexus-shared'; import { FUNCTION_NODE_TYPES } from '../utils/ast-helpers.js'; import { SupportedLanguages } from 'gitnexus-shared'; import { createClassExtractor } from '../class-extractors/generic.js'; +import { dartClassConfig } from '../class-extractors/configs/dart.js'; import { defineLanguage } from '../language-provider.js'; import { typeConfig as dartConfig } from '../type-extractors/dart.js'; import { dartExportChecker } from '../export-detection.js'; @@ -93,11 +94,7 @@ export const dartProvider = defineLanguage({ importSemantics: 'wildcard-leaf', fieldExtractor: createFieldExtractor(dartFieldConfig), methodExtractor: createMethodExtractor(dartMethodConfig), - classExtractor: createClassExtractor({ - language: SupportedLanguages.Dart, - typeDeclarationNodes: ['class_definition', 'extension_declaration', 'enum_declaration'], - ancestorScopeNodeTypes: ['class_definition', 'extension_declaration', 'enum_declaration'], - }), + classExtractor: createClassExtractor(dartClassConfig), enclosingFunctionFinder: dartEnclosingFunctionFinder, builtInNames: BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/languages/go.ts b/gitnexus/src/core/ingestion/languages/go.ts index 2a2b35f50..5e75b3088 100644 --- a/gitnexus/src/core/ingestion/languages/go.ts +++ b/gitnexus/src/core/ingestion/languages/go.ts @@ -11,6 +11,7 @@ import { SupportedLanguages } from 'gitnexus-shared'; import { createClassExtractor } from '../class-extractors/generic.js'; +import { goClassConfig } from '../class-extractors/configs/go.js'; import { defineLanguage } from '../language-provider.js'; import { typeConfig as goConfig } from '../type-extractors/go.js'; import { goExportChecker } from '../export-detection.js'; @@ -31,20 +32,5 @@ export const goProvider = defineLanguage({ importSemantics: 'wildcard-leaf', fieldExtractor: createFieldExtractor(goFieldConfig), methodExtractor: createMethodExtractor(goMethodConfig), - classExtractor: createClassExtractor({ - language: SupportedLanguages.Go, - typeDeclarationNodes: ['type_declaration'], - fileScopeNodeTypes: ['package_clause'], - extractName(node) { - const typeSpec = node.namedChildren.find((child) => child.type === 'type_spec'); - return typeSpec?.childForFieldName('name')?.text; - }, - extractType(node) { - const typeSpec = node.namedChildren.find((child) => child.type === 'type_spec'); - const typeNode = typeSpec?.childForFieldName('type'); - if (typeNode?.type === 'struct_type') return 'Struct'; - if (typeNode?.type === 'interface_type') return 'Interface'; - return undefined; - }, - }), + classExtractor: createClassExtractor(goClassConfig), }); diff --git a/gitnexus/src/core/ingestion/languages/java.ts b/gitnexus/src/core/ingestion/languages/java.ts index b9fab77f1..297b5eea1 100644 --- a/gitnexus/src/core/ingestion/languages/java.ts +++ b/gitnexus/src/core/ingestion/languages/java.ts @@ -9,6 +9,7 @@ import { SupportedLanguages } from 'gitnexus-shared'; import { createClassExtractor } from '../class-extractors/generic.js'; +import { javaClassConfig } from '../class-extractors/configs/jvm.js'; import { defineLanguage } from '../language-provider.js'; import { javaTypeConfig } from '../type-extractors/jvm.js'; import { javaExportChecker } from '../export-detection.js'; @@ -32,20 +33,5 @@ export const javaProvider = defineLanguage({ mroStrategy: 'implements-split', fieldExtractor: createFieldExtractor(javaConfig), methodExtractor: createMethodExtractor(javaMethodConfig), - classExtractor: createClassExtractor({ - language: SupportedLanguages.Java, - typeDeclarationNodes: [ - 'class_declaration', - 'interface_declaration', - 'enum_declaration', - 'record_declaration', - ], - fileScopeNodeTypes: ['package_declaration'], - ancestorScopeNodeTypes: [ - 'class_declaration', - 'interface_declaration', - 'enum_declaration', - 'record_declaration', - ], - }), + classExtractor: createClassExtractor(javaClassConfig), }); diff --git a/gitnexus/src/core/ingestion/languages/kotlin.ts b/gitnexus/src/core/ingestion/languages/kotlin.ts index 94c47e7ab..1017e6aae 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin.ts @@ -9,6 +9,7 @@ import { SupportedLanguages } from 'gitnexus-shared'; import { createClassExtractor } from '../class-extractors/generic.js'; +import { kotlinClassConfig } from '../class-extractors/configs/jvm.js'; import { defineLanguage } from '../language-provider.js'; import { kotlinTypeConfig } from '../type-extractors/jvm.js'; import { kotlinExportChecker } from '../export-detection.js'; @@ -107,16 +108,7 @@ export const kotlinProvider = defineLanguage({ mroStrategy: 'implements-split', fieldExtractor: createFieldExtractor(kotlinConfig), methodExtractor: createMethodExtractor(kotlinMethodConfig), - classExtractor: createClassExtractor({ - language: SupportedLanguages.Kotlin, - typeDeclarationNodes: ['class_declaration', 'object_declaration', 'companion_object'], - fileScopeNodeTypes: ['package_header'], - ancestorScopeNodeTypes: ['class_declaration', 'object_declaration', 'companion_object'], - extractType(node) { - if (node.type !== 'class_declaration') return undefined; - return node.children.some((child) => child?.text === 'interface') ? 'Interface' : 'Class'; - }, - }), + classExtractor: createClassExtractor(kotlinClassConfig), builtInNames: BUILT_INS, labelOverride: (functionNode, defaultLabel) => { if (defaultLabel !== 'Function') return defaultLabel; diff --git a/gitnexus/src/core/ingestion/languages/php.ts b/gitnexus/src/core/ingestion/languages/php.ts index 642c6bd83..2f8be3f98 100644 --- a/gitnexus/src/core/ingestion/languages/php.ts +++ b/gitnexus/src/core/ingestion/languages/php.ts @@ -8,6 +8,7 @@ import { SupportedLanguages } from 'gitnexus-shared'; import { createClassExtractor } from '../class-extractors/generic.js'; +import { phpClassConfig } from '../class-extractors/configs/php.js'; import { defineLanguage } from '../language-provider.js'; import { typeConfig as phpConfig } from '../type-extractors/php.js'; import { phpExportChecker } from '../export-detection.js'; @@ -239,11 +240,7 @@ export const phpProvider = defineLanguage({ namedBindingExtractor: extractPhpNamedBindings, fieldExtractor: createFieldExtractor(phpFieldConfig), methodExtractor: createMethodExtractor(phpMethodConfig), - classExtractor: createClassExtractor({ - language: SupportedLanguages.PHP, - typeDeclarationNodes: ['class_declaration', 'interface_declaration', 'enum_declaration'], - ancestorScopeNodeTypes: ['namespace_definition'], - }), + classExtractor: createClassExtractor(phpClassConfig), descriptionExtractor: phpDescriptionExtractor, isRouteFile: isPhpRouteFile, builtInNames: BUILT_INS, diff --git a/gitnexus/src/core/ingestion/languages/python.ts b/gitnexus/src/core/ingestion/languages/python.ts index 8c776d4a0..70ca7e19c 100644 --- a/gitnexus/src/core/ingestion/languages/python.ts +++ b/gitnexus/src/core/ingestion/languages/python.ts @@ -12,6 +12,7 @@ import { SupportedLanguages } from 'gitnexus-shared'; import { createClassExtractor } from '../class-extractors/generic.js'; +import { pythonClassConfig } from '../class-extractors/configs/python.js'; import { defineLanguage } from '../language-provider.js'; import { typeConfig as pythonConfig } from '../type-extractors/python.js'; import { pythonExportChecker } from '../export-detection.js'; @@ -65,10 +66,6 @@ export const pythonProvider = defineLanguage({ mroStrategy: 'c3', fieldExtractor: createFieldExtractor(pythonFieldConfig), methodExtractor: createMethodExtractor(pythonMethodConfig), - classExtractor: createClassExtractor({ - language: SupportedLanguages.Python, - typeDeclarationNodes: ['class_definition'], - ancestorScopeNodeTypes: ['class_definition'], - }), + classExtractor: createClassExtractor(pythonClassConfig), builtInNames: BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/languages/ruby.ts b/gitnexus/src/core/ingestion/languages/ruby.ts index a15b5439c..7cb0e08da 100644 --- a/gitnexus/src/core/ingestion/languages/ruby.ts +++ b/gitnexus/src/core/ingestion/languages/ruby.ts @@ -10,6 +10,7 @@ import { SupportedLanguages } from 'gitnexus-shared'; import type { NodeLabel } from 'gitnexus-shared'; import { createClassExtractor } from '../class-extractors/generic.js'; +import { rubyClassConfig } from '../class-extractors/configs/ruby.js'; import { defineLanguage } from '../language-provider.js'; import type { SyntaxNode } from '../utils/ast-helpers.js'; import { typeConfig as rubyConfig } from '../type-extractors/ruby.js'; @@ -128,10 +129,6 @@ export const rubyProvider = defineLanguage({ ...rubyMethodConfig, extractFunctionName: rubyExtractFunctionName, }), - classExtractor: createClassExtractor({ - language: SupportedLanguages.Ruby, - typeDeclarationNodes: ['class'], - ancestorScopeNodeTypes: ['module', 'class'], - }), + classExtractor: createClassExtractor(rubyClassConfig), builtInNames: BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/languages/rust.ts b/gitnexus/src/core/ingestion/languages/rust.ts index 5e664ef8f..961233ff7 100644 --- a/gitnexus/src/core/ingestion/languages/rust.ts +++ b/gitnexus/src/core/ingestion/languages/rust.ts @@ -13,6 +13,7 @@ import { SupportedLanguages } from 'gitnexus-shared'; import type { NodeLabel } from 'gitnexus-shared'; import { createClassExtractor } from '../class-extractors/generic.js'; +import { rustClassConfig } from '../class-extractors/configs/rust.js'; import { defineLanguage } from '../language-provider.js'; import type { SyntaxNode } from '../utils/ast-helpers.js'; import { typeConfig as rustConfig } from '../type-extractors/rust.js'; @@ -125,10 +126,6 @@ export const rustProvider = defineLanguage({ ...rustMethodConfig, extractFunctionName: rustExtractFunctionName, }), - classExtractor: createClassExtractor({ - language: SupportedLanguages.Rust, - typeDeclarationNodes: ['struct_item', 'enum_item'], - ancestorScopeNodeTypes: ['mod_item', 'struct_item', 'enum_item'], - }), + classExtractor: createClassExtractor(rustClassConfig), builtInNames: BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/languages/swift.ts b/gitnexus/src/core/ingestion/languages/swift.ts index 314c27c1d..cc9706bbf 100644 --- a/gitnexus/src/core/ingestion/languages/swift.ts +++ b/gitnexus/src/core/ingestion/languages/swift.ts @@ -13,6 +13,7 @@ import { SupportedLanguages } from 'gitnexus-shared'; import type { NodeLabel } from 'gitnexus-shared'; import { createClassExtractor } from '../class-extractors/generic.js'; +import { swiftClassConfig } from '../class-extractors/configs/swift.js'; import { defineLanguage } from '../language-provider.js'; import { typeConfig as swiftConfig } from '../type-extractors/swift.js'; import { swiftExportChecker } from '../export-detection.js'; @@ -245,18 +246,7 @@ export const swiftProvider = defineLanguage({ ...swiftMethodConfig, extractFunctionName: swiftExtractFunctionName, }), - classExtractor: createClassExtractor({ - language: SupportedLanguages.Swift, - typeDeclarationNodes: ['class_declaration', 'protocol_declaration'], - ancestorScopeNodeTypes: ['class_declaration', 'protocol_declaration'], - extractType(node) { - if (node.type === 'protocol_declaration') return 'Interface'; - if (node.type !== 'class_declaration') return undefined; - if (node.children.some((child) => child?.text === 'struct')) return 'Struct'; - if (node.children.some((child) => child?.text === 'enum')) return 'Enum'; - return 'Class'; - }, - }), + classExtractor: createClassExtractor(swiftClassConfig), implicitImportWirer: wireSwiftImplicitImports, builtInNames: BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/languages/typescript.ts b/gitnexus/src/core/ingestion/languages/typescript.ts index b680c5aa1..5c8e315ff 100644 --- a/gitnexus/src/core/ingestion/languages/typescript.ts +++ b/gitnexus/src/core/ingestion/languages/typescript.ts @@ -11,7 +11,10 @@ import { SupportedLanguages } from 'gitnexus-shared'; import type { NodeLabel } from 'gitnexus-shared'; import { defineLanguage } from '../language-provider.js'; import { createClassExtractor } from '../class-extractors/generic.js'; -import type { ClassExtractionConfig } from '../class-types.js'; +import { + typescriptClassConfig, + javascriptClassConfig, +} from '../class-extractors/configs/typescript-javascript.js'; import type { SyntaxNode } from '../utils/ast-helpers.js'; import { typeConfig as typescriptConfig } from '../type-extractors/typescript.js'; import { tsExportChecker } from '../export-detection.js'; @@ -149,22 +152,6 @@ export const BUILT_INS: ReadonlySet = new Set([ 'valueOf', ]); -const tsJsClassConfig: ClassExtractionConfig = { - language: SupportedLanguages.TypeScript, - typeDeclarationNodes: [ - 'class_declaration', - 'abstract_class_declaration', - 'interface_declaration', - 'enum_declaration', - ], - ancestorScopeNodeTypes: [ - 'class_declaration', - 'abstract_class_declaration', - 'interface_declaration', - 'enum_declaration', - ], -}; - export const typescriptProvider = defineLanguage({ id: SupportedLanguages.TypeScript, extensions: ['.ts', '.tsx'], @@ -178,7 +165,7 @@ export const typescriptProvider = defineLanguage({ ...typescriptMethodConfig, extractFunctionName: tsExtractFunctionName, }), - classExtractor: createClassExtractor(tsJsClassConfig), + classExtractor: createClassExtractor(typescriptClassConfig), builtInNames: BUILT_INS, }); @@ -195,9 +182,6 @@ export const javascriptProvider = defineLanguage({ ...javascriptMethodConfig, extractFunctionName: tsExtractFunctionName, }), - classExtractor: createClassExtractor({ - ...tsJsClassConfig, - language: SupportedLanguages.JavaScript, - }), + classExtractor: createClassExtractor(javascriptClassConfig), builtInNames: BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/languages/vue.ts b/gitnexus/src/core/ingestion/languages/vue.ts index 20ccdfa18..231d9dcc5 100644 --- a/gitnexus/src/core/ingestion/languages/vue.ts +++ b/gitnexus/src/core/ingestion/languages/vue.ts @@ -13,6 +13,7 @@ import { SupportedLanguages } from 'gitnexus-shared'; import { createClassExtractor } from '../class-extractors/generic.js'; +import { vueClassConfig } from '../class-extractors/configs/typescript-javascript.js'; import { defineLanguage } from '../language-provider.js'; import { typeConfig as typescriptConfig } from '../type-extractors/typescript.js'; import { tsExportChecker } from '../export-detection.js'; @@ -56,21 +57,7 @@ const VUE_SPECIFIC_BUILT_INS = [ const VUE_BUILT_INS: ReadonlySet = new Set([...TS_BUILT_INS, ...VUE_SPECIFIC_BUILT_INS]); -const vueClassExtractor = createClassExtractor({ - language: SupportedLanguages.Vue, - typeDeclarationNodes: [ - 'class_declaration', - 'abstract_class_declaration', - 'interface_declaration', - 'enum_declaration', - ], - ancestorScopeNodeTypes: [ - 'class_declaration', - 'abstract_class_declaration', - 'interface_declaration', - 'enum_declaration', - ], -}); +const vueClassExtractor = createClassExtractor(vueClassConfig); export const vueProvider = defineLanguage({ id: SupportedLanguages.Vue, From 03821faf58db42a49bf77e90d4675e1f0ca3ac42 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Apr 2026 11:45:30 +0100 Subject: [PATCH 10/17] feat(ingestion): language-agnostic call extractor with config+factory pattern (#877) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * feat(ingestion): add call-types, call-extractors factory, per-language configs, and wire into providers Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/893afa77-5b34-4e6b-a1dc-03034261fb36 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * feat(ingestion): replace inline call extraction in parse-worker and call-processor, delete call-sites/ Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/893afa77-5b34-4e6b-a1dc-03034261fb36 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * test(ingestion): add unit tests for call extraction configs and factory Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/893afa77-5b34-4e6b-a1dc-03034261fb36 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * style: fix prettier formatting in call-extractor files Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/d4b06f56-03b6-4fa4-801f-7ddcc6e81f13 * fix: address review comments — doc comment, idempotency note, C# behavioral test Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/e53e650b-fae6-4551-ab25-cda28e4d647f * fix: rename misleading test title, remove stale code reference in comment Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/e53e650b-fae6-4551-ab25-cda28e4d647f --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> --- .../call-extractors/configs/c-cpp.ts | 12 + .../call-extractors/configs/csharp.ts | 9 + .../ingestion/call-extractors/configs/dart.ts | 8 + .../ingestion/call-extractors/configs/go.ts | 8 + .../ingestion/call-extractors/configs/jvm.ts | 59 ++ .../ingestion/call-extractors/configs/php.ts | 8 + .../call-extractors/configs/python.ts | 8 + .../ingestion/call-extractors/configs/ruby.ts | 8 + .../ingestion/call-extractors/configs/rust.ts | 8 + .../call-extractors/configs/swift.ts | 8 + .../configs/typescript-javascript.ts | 12 + .../core/ingestion/call-extractors/generic.ts | 86 +++ gitnexus/src/core/ingestion/call-processor.ts | 122 ++-- .../call-sites/extract-language-call-site.ts | 33 -- .../src/core/ingestion/call-sites/java.ts | 41 -- gitnexus/src/core/ingestion/call-types.ts | 80 +++ .../src/core/ingestion/language-provider.ts | 7 + .../src/core/ingestion/languages/c-cpp.ts | 4 + .../src/core/ingestion/languages/csharp.ts | 3 + gitnexus/src/core/ingestion/languages/dart.ts | 3 + gitnexus/src/core/ingestion/languages/go.ts | 3 + gitnexus/src/core/ingestion/languages/java.ts | 3 + .../src/core/ingestion/languages/kotlin.ts | 3 + gitnexus/src/core/ingestion/languages/php.ts | 3 + .../src/core/ingestion/languages/python.ts | 3 + gitnexus/src/core/ingestion/languages/ruby.ts | 3 + gitnexus/src/core/ingestion/languages/rust.ts | 3 + .../src/core/ingestion/languages/swift.ts | 3 + .../core/ingestion/languages/typescript.ts | 7 + gitnexus/src/core/ingestion/languages/vue.ts | 3 + .../core/ingestion/workers/parse-worker.ts | 383 +++++++------ gitnexus/test/unit/call-extraction.test.ts | 527 ++++++++++++++++++ 32 files changed, 1141 insertions(+), 330 deletions(-) create mode 100644 gitnexus/src/core/ingestion/call-extractors/configs/c-cpp.ts create mode 100644 gitnexus/src/core/ingestion/call-extractors/configs/csharp.ts create mode 100644 gitnexus/src/core/ingestion/call-extractors/configs/dart.ts create mode 100644 gitnexus/src/core/ingestion/call-extractors/configs/go.ts create mode 100644 gitnexus/src/core/ingestion/call-extractors/configs/jvm.ts create mode 100644 gitnexus/src/core/ingestion/call-extractors/configs/php.ts create mode 100644 gitnexus/src/core/ingestion/call-extractors/configs/python.ts create mode 100644 gitnexus/src/core/ingestion/call-extractors/configs/ruby.ts create mode 100644 gitnexus/src/core/ingestion/call-extractors/configs/rust.ts create mode 100644 gitnexus/src/core/ingestion/call-extractors/configs/swift.ts create mode 100644 gitnexus/src/core/ingestion/call-extractors/configs/typescript-javascript.ts create mode 100644 gitnexus/src/core/ingestion/call-extractors/generic.ts delete mode 100644 gitnexus/src/core/ingestion/call-sites/extract-language-call-site.ts delete mode 100644 gitnexus/src/core/ingestion/call-sites/java.ts create mode 100644 gitnexus/src/core/ingestion/call-types.ts create mode 100644 gitnexus/test/unit/call-extraction.test.ts diff --git a/gitnexus/src/core/ingestion/call-extractors/configs/c-cpp.ts b/gitnexus/src/core/ingestion/call-extractors/configs/c-cpp.ts new file mode 100644 index 000000000..02a6ed60f --- /dev/null +++ b/gitnexus/src/core/ingestion/call-extractors/configs/c-cpp.ts @@ -0,0 +1,12 @@ +// gitnexus/src/core/ingestion/call-extractors/configs/c-cpp.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { CallExtractionConfig } from '../../call-types.js'; + +export const cCallConfig: CallExtractionConfig = { + language: SupportedLanguages.C, +}; + +export const cppCallConfig: CallExtractionConfig = { + language: SupportedLanguages.CPlusPlus, +}; diff --git a/gitnexus/src/core/ingestion/call-extractors/configs/csharp.ts b/gitnexus/src/core/ingestion/call-extractors/configs/csharp.ts new file mode 100644 index 000000000..e2c0415c2 --- /dev/null +++ b/gitnexus/src/core/ingestion/call-extractors/configs/csharp.ts @@ -0,0 +1,9 @@ +// gitnexus/src/core/ingestion/call-extractors/configs/csharp.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { CallExtractionConfig } from '../../call-types.js'; + +export const csharpCallConfig: CallExtractionConfig = { + language: SupportedLanguages.CSharp, + typeAsReceiverHeuristic: true, +}; diff --git a/gitnexus/src/core/ingestion/call-extractors/configs/dart.ts b/gitnexus/src/core/ingestion/call-extractors/configs/dart.ts new file mode 100644 index 000000000..9d3c08def --- /dev/null +++ b/gitnexus/src/core/ingestion/call-extractors/configs/dart.ts @@ -0,0 +1,8 @@ +// gitnexus/src/core/ingestion/call-extractors/configs/dart.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { CallExtractionConfig } from '../../call-types.js'; + +export const dartCallConfig: CallExtractionConfig = { + language: SupportedLanguages.Dart, +}; diff --git a/gitnexus/src/core/ingestion/call-extractors/configs/go.ts b/gitnexus/src/core/ingestion/call-extractors/configs/go.ts new file mode 100644 index 000000000..870fc88e0 --- /dev/null +++ b/gitnexus/src/core/ingestion/call-extractors/configs/go.ts @@ -0,0 +1,8 @@ +// gitnexus/src/core/ingestion/call-extractors/configs/go.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { CallExtractionConfig } from '../../call-types.js'; + +export const goCallConfig: CallExtractionConfig = { + language: SupportedLanguages.Go, +}; diff --git a/gitnexus/src/core/ingestion/call-extractors/configs/jvm.ts b/gitnexus/src/core/ingestion/call-extractors/configs/jvm.ts new file mode 100644 index 000000000..51de04228 --- /dev/null +++ b/gitnexus/src/core/ingestion/call-extractors/configs/jvm.ts @@ -0,0 +1,59 @@ +// gitnexus/src/core/ingestion/call-extractors/configs/jvm.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { CallExtractionConfig, ExtractedCallSite } from '../../call-types.js'; +import type { SyntaxNode } from '../../utils/ast-helpers.js'; + +// --------------------------------------------------------------------------- +// Java method_reference (::) parsing — absorbs call-sites/java.ts +// --------------------------------------------------------------------------- + +/** + * Parse Java `method_reference` nodes (`expr::method`, `Type::new`, + * `this::m`, `super::m`). + */ +function parseJavaMethodReference(callNode: SyntaxNode): ExtractedCallSite | null { + if (callNode.type !== 'method_reference') return null; + + const recv = callNode.namedChild(0); + if (!recv) return null; + + // Type::new → constructor call + for (const c of callNode.children) { + if (c.type === 'new') { + if (recv.type !== 'identifier') return null; + return { calledName: recv.text, callForm: 'constructor' }; + } + } + + // expr::method → member call with receiver + const rhs = callNode.child(callNode.childCount - 1); + if (!rhs || rhs.type !== 'identifier') return null; + const methodName = rhs.text; + + if (recv.type === 'identifier') { + return { calledName: methodName, callForm: 'member', receiverName: recv.text }; + } + if (recv.type === 'this') { + return { calledName: methodName, callForm: 'member', receiverName: 'this' }; + } + if (recv.type === 'super') { + return { calledName: methodName, callForm: 'member', receiverName: 'super' }; + } + return null; +} + +// --------------------------------------------------------------------------- +// Configs +// --------------------------------------------------------------------------- + +export const javaCallConfig: CallExtractionConfig = { + language: SupportedLanguages.Java, + extractLanguageCallSite: parseJavaMethodReference, + typeAsReceiverHeuristic: true, +}; + +export const kotlinCallConfig: CallExtractionConfig = { + language: SupportedLanguages.Kotlin, + typeAsReceiverHeuristic: true, +}; diff --git a/gitnexus/src/core/ingestion/call-extractors/configs/php.ts b/gitnexus/src/core/ingestion/call-extractors/configs/php.ts new file mode 100644 index 000000000..25ed0b9ab --- /dev/null +++ b/gitnexus/src/core/ingestion/call-extractors/configs/php.ts @@ -0,0 +1,8 @@ +// gitnexus/src/core/ingestion/call-extractors/configs/php.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { CallExtractionConfig } from '../../call-types.js'; + +export const phpCallConfig: CallExtractionConfig = { + language: SupportedLanguages.PHP, +}; diff --git a/gitnexus/src/core/ingestion/call-extractors/configs/python.ts b/gitnexus/src/core/ingestion/call-extractors/configs/python.ts new file mode 100644 index 000000000..35ab87305 --- /dev/null +++ b/gitnexus/src/core/ingestion/call-extractors/configs/python.ts @@ -0,0 +1,8 @@ +// gitnexus/src/core/ingestion/call-extractors/configs/python.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { CallExtractionConfig } from '../../call-types.js'; + +export const pythonCallConfig: CallExtractionConfig = { + language: SupportedLanguages.Python, +}; diff --git a/gitnexus/src/core/ingestion/call-extractors/configs/ruby.ts b/gitnexus/src/core/ingestion/call-extractors/configs/ruby.ts new file mode 100644 index 000000000..d829c5c89 --- /dev/null +++ b/gitnexus/src/core/ingestion/call-extractors/configs/ruby.ts @@ -0,0 +1,8 @@ +// gitnexus/src/core/ingestion/call-extractors/configs/ruby.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { CallExtractionConfig } from '../../call-types.js'; + +export const rubyCallConfig: CallExtractionConfig = { + language: SupportedLanguages.Ruby, +}; diff --git a/gitnexus/src/core/ingestion/call-extractors/configs/rust.ts b/gitnexus/src/core/ingestion/call-extractors/configs/rust.ts new file mode 100644 index 000000000..03c48f781 --- /dev/null +++ b/gitnexus/src/core/ingestion/call-extractors/configs/rust.ts @@ -0,0 +1,8 @@ +// gitnexus/src/core/ingestion/call-extractors/configs/rust.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { CallExtractionConfig } from '../../call-types.js'; + +export const rustCallConfig: CallExtractionConfig = { + language: SupportedLanguages.Rust, +}; diff --git a/gitnexus/src/core/ingestion/call-extractors/configs/swift.ts b/gitnexus/src/core/ingestion/call-extractors/configs/swift.ts new file mode 100644 index 000000000..28f2c180c --- /dev/null +++ b/gitnexus/src/core/ingestion/call-extractors/configs/swift.ts @@ -0,0 +1,8 @@ +// gitnexus/src/core/ingestion/call-extractors/configs/swift.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { CallExtractionConfig } from '../../call-types.js'; + +export const swiftCallConfig: CallExtractionConfig = { + language: SupportedLanguages.Swift, +}; diff --git a/gitnexus/src/core/ingestion/call-extractors/configs/typescript-javascript.ts b/gitnexus/src/core/ingestion/call-extractors/configs/typescript-javascript.ts new file mode 100644 index 000000000..20a63cda9 --- /dev/null +++ b/gitnexus/src/core/ingestion/call-extractors/configs/typescript-javascript.ts @@ -0,0 +1,12 @@ +// gitnexus/src/core/ingestion/call-extractors/configs/typescript-javascript.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { CallExtractionConfig } from '../../call-types.js'; + +export const typescriptCallConfig: CallExtractionConfig = { + language: SupportedLanguages.TypeScript, +}; + +export const javascriptCallConfig: CallExtractionConfig = { + language: SupportedLanguages.JavaScript, +}; diff --git a/gitnexus/src/core/ingestion/call-extractors/generic.ts b/gitnexus/src/core/ingestion/call-extractors/generic.ts new file mode 100644 index 000000000..82e38457b --- /dev/null +++ b/gitnexus/src/core/ingestion/call-extractors/generic.ts @@ -0,0 +1,86 @@ +// gitnexus/src/core/ingestion/call-extractors/generic.ts + +/** + * Generic table-driven call extractor factory. + * + * Mirrors method-extractors/generic.ts and field-extractors/generic.ts — + * define a config per language and generate extractors from configs. + * + * The factory converts a declarative {@link CallExtractionConfig} into a + * runtime {@link CallExtractor} whose `extract()` method: + * 1. Tries `config.extractLanguageCallSite(callNode)` for non-standard shapes. + * 2. Falls through to the generic path using shared utilities from + * `utils/call-analysis.ts` (`inferCallForm`, `extractReceiverName`, etc.). + */ + +import type { SyntaxNode } from '../utils/ast-helpers.js'; +import { + inferCallForm, + extractReceiverName, + extractReceiverNode, + extractMixedChain, + countCallArguments, +} from '../utils/call-analysis.js'; +import type { CallExtractor, CallExtractionConfig, ExtractedCallSite } from '../call-types.js'; + +/** + * Create a CallExtractor from a declarative config. + */ +export function createCallExtractor(config: CallExtractionConfig): CallExtractor { + return { + language: config.language, + + extract(callNode: SyntaxNode, callNameNode: SyntaxNode | undefined): ExtractedCallSite | null { + // ── Path 1: Language-specific call site ────────────────────────── + // Non-standard call shapes (e.g. Java `::` method references) are + // handled entirely by the config hook. When it returns a result, + // the generic path is skipped — no argCount, no mixed chain. + // + // Note: `extractLanguageCallSite` is called on every `extract()` + // invocation — both `extract(callNode, undefined)` (parse-worker + // Path 1) and `extract(callNode, callNameNode)` (Path 2). + // Language hooks must therefore be idempotent and cheap (e.g. a + // single node-type check). + if (config.extractLanguageCallSite) { + const seed = config.extractLanguageCallSite(callNode); + if (seed) { + return { + ...seed, + ...(config.typeAsReceiverHeuristic ? { typeAsReceiverHeuristic: true } : {}), + }; + } + } + + // ── Path 2: Generic extraction via @call.name ──────────────────── + if (!callNameNode) return null; + + const calledName = callNameNode.text; + const callForm = inferCallForm(callNode, callNameNode); + let receiverName = callForm === 'member' ? extractReceiverName(callNameNode) : undefined; + let receiverMixedChain: ExtractedCallSite['receiverMixedChain']; + + // 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. + if (callForm === 'member' && receiverName === undefined) { + const receiverNode = extractReceiverNode(callNameNode); + if (receiverNode) { + const extracted = extractMixedChain(receiverNode); + if (extracted && extracted.chain.length > 0) { + receiverMixedChain = extracted.chain; + receiverName = extracted.baseReceiverName; + } + } + } + + return { + calledName, + ...(callForm !== undefined ? { callForm } : {}), + ...(receiverName !== undefined ? { receiverName } : {}), + argCount: countCallArguments(callNode), + ...(receiverMixedChain !== undefined ? { receiverMixedChain } : {}), + ...(config.typeAsReceiverHeuristic ? { typeAsReceiverHeuristic: true } : {}), + }; + }, + }; +} diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index ce0364a4d..bb7186697 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -48,7 +48,6 @@ import { extractTemplateComponents } from './vue-sfc-extractor.js'; import { extractReturnTypeName, stripNullable } from './type-extractors/shared.js'; import type { LiteralTypeInferrer } from './type-extractors/types.js'; import type { SyntaxNode } from './utils/ast-helpers.js'; -import { extractParsedCallSite } from './call-sites/extract-language-call-site.js'; import { lookupMethodByOwnerWithMRO } from './model/resolve.js'; /** Per-file resolved type bindings for exported symbols. @@ -910,74 +909,79 @@ export const processCalls = async ( if (!captureMap['call']) return; const callNode = captureMap['call']; - const languageSeed = extractParsedCallSite(language, callNode); - if (languageSeed) { - if (provider.isBuiltInName(languageSeed.calledName)) return; + const callExtractor = provider.callExtractor; - const sourceId = - findEnclosingFunction(callNode, file.path, ctx, provider) || - generateId('File', file.path); - const receiverName = - languageSeed.callForm === 'member' ? languageSeed.receiverName : undefined; - let receiverTypeName = - receiverName && typeEnv ? typeEnv.lookup(receiverName, callNode) : undefined; + // ── Language-specific call site (e.g. Java :: method references) ── + if (callExtractor) { + const langCallSite = callExtractor.extract(callNode, undefined); + if (langCallSite) { + if (provider.isBuiltInName(langCallSite.calledName)) return; - if ( - receiverName !== undefined && - receiverTypeName === undefined && - languageSeed.callForm === 'member' && - (language === 'java' || language === 'csharp' || language === 'kotlin') - ) { - const c0 = receiverName.charCodeAt(0); - if (c0 >= 65 && c0 <= 90) receiverTypeName = receiverName; - } + const sourceId = + findEnclosingFunction(callNode, file.path, ctx, provider) || + generateId('File', file.path); + const receiverName = + langCallSite.callForm === 'member' ? langCallSite.receiverName : undefined; + let receiverTypeName = + receiverName && typeEnv ? typeEnv.lookup(receiverName, callNode) : undefined; - const resolved = resolveCallTarget( - { - calledName: languageSeed.calledName, - callForm: languageSeed.callForm, - ...(receiverTypeName !== undefined ? { receiverTypeName } : {}), - ...(receiverName !== undefined ? { receiverName } : {}), - }, - file.path, - ctx, - undefined, - widenCache, - undefined, - heritageMap, - ); + if ( + langCallSite.typeAsReceiverHeuristic && + receiverName !== undefined && + receiverTypeName === undefined && + langCallSite.callForm === 'member' + ) { + const c0 = receiverName.charCodeAt(0); + if (c0 >= 65 && c0 <= 90) receiverTypeName = receiverName; + } - if (!resolved) return; - graph.addRelationship({ - id: generateId('CALLS', `${sourceId}:${languageSeed.calledName}->${resolved.nodeId}`), - sourceId, - targetId: resolved.nodeId, - type: 'CALLS', - confidence: resolved.confidence, - reason: resolved.reason, - }); - - if (heritageMap && languageSeed.callForm === 'member' && receiverTypeName) { - const implTargets = findInterfaceDispatchTargets( - languageSeed.calledName, - receiverTypeName, + const resolved = resolveCallTarget( + { + calledName: langCallSite.calledName, + callForm: langCallSite.callForm, + ...(receiverTypeName !== undefined ? { receiverTypeName } : {}), + ...(receiverName !== undefined ? { receiverName } : {}), + }, file.path, ctx, + undefined, + widenCache, + undefined, heritageMap, - resolved.nodeId, ); - for (const impl of implTargets) { - graph.addRelationship({ - id: generateId('CALLS', `${sourceId}:${languageSeed.calledName}->${impl.nodeId}`), - sourceId, - targetId: impl.nodeId, - type: 'CALLS', - confidence: impl.confidence, - reason: impl.reason, - }); + + if (!resolved) return; + graph.addRelationship({ + id: generateId('CALLS', `${sourceId}:${langCallSite.calledName}->${resolved.nodeId}`), + sourceId, + targetId: resolved.nodeId, + type: 'CALLS', + confidence: resolved.confidence, + reason: resolved.reason, + }); + + if (heritageMap && langCallSite.callForm === 'member' && receiverTypeName) { + const implTargets = findInterfaceDispatchTargets( + langCallSite.calledName, + receiverTypeName, + file.path, + ctx, + heritageMap, + resolved.nodeId, + ); + for (const impl of implTargets) { + graph.addRelationship({ + id: generateId('CALLS', `${sourceId}:${langCallSite.calledName}->${impl.nodeId}`), + sourceId, + targetId: impl.nodeId, + type: 'CALLS', + confidence: impl.confidence, + reason: impl.reason, + }); + } } + return; } - return; } const nameNode = captureMap['call.name']; diff --git a/gitnexus/src/core/ingestion/call-sites/extract-language-call-site.ts b/gitnexus/src/core/ingestion/call-sites/extract-language-call-site.ts deleted file mode 100644 index feed2cd70..000000000 --- a/gitnexus/src/core/ingestion/call-sites/extract-language-call-site.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** Non-generic @call shapes → { calledName, callForm, receiverName? } (used from call-processor / parse-worker). */ - -import { SupportedLanguages } from '../../../config/supported-languages.js'; -import type { SyntaxNode } from '../utils/ast-helpers.js'; -import { parseJavaMethodReference } from './java.js'; - -export type ParsedCallSite = { - calledName: string; - callForm: 'free' | 'member' | 'constructor'; - receiverName?: string; -}; - -/** Non-null → seed replaces @call.name; null → use @call.name + inferCallForm / extractReceiverName. */ -export function extractParsedCallSite( - language: SupportedLanguages, - callNode: SyntaxNode, -): ParsedCallSite | null { - switch (language) { - case SupportedLanguages.Java: - if (callNode.type === 'method_reference') { - const parsed = parseJavaMethodReference(callNode); - if (!parsed) return null; - return { - calledName: parsed.calledName, - callForm: parsed.callForm, - ...(parsed.receiverName !== undefined ? { receiverName: parsed.receiverName } : {}), - }; - } - return null; - default: - return null; - } -} diff --git a/gitnexus/src/core/ingestion/call-sites/java.ts b/gitnexus/src/core/ingestion/call-sites/java.ts deleted file mode 100644 index e22c71cca..000000000 --- a/gitnexus/src/core/ingestion/call-sites/java.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** Java `method_reference` (`::`) nodes (tree-sitter-java). `super::` still lacks TypeEnv receiver typing. */ - -import type { SyntaxNode } from '../utils/ast-helpers.js'; - -export type ParsedJavaMethodReference = { - calledName: string; - callForm: 'member' | 'constructor'; - receiverName?: string; -}; - -/** Parse `expr::method`, `Type::new`, `this::m`, `super::m`. */ -export const parseJavaMethodReference = ( - callNode: SyntaxNode, -): ParsedJavaMethodReference | null => { - if (callNode.type !== 'method_reference') return null; - - const recv = callNode.namedChild(0); - if (!recv) return null; - - for (const c of callNode.children) { - if (c.type === 'new') { - if (recv.type !== 'identifier') return null; - return { calledName: recv.text, callForm: 'constructor' }; - } - } - - const rhs = callNode.child(callNode.childCount - 1); - if (!rhs || rhs.type !== 'identifier') return null; - const methodName = rhs.text; - - if (recv.type === 'identifier') { - return { calledName: methodName, callForm: 'member', receiverName: recv.text }; - } - if (recv.type === 'this') { - return { calledName: methodName, callForm: 'member', receiverName: 'this' }; - } - if (recv.type === 'super') { - return { calledName: methodName, callForm: 'member', receiverName: 'super' }; - } - return null; -}; diff --git a/gitnexus/src/core/ingestion/call-types.ts b/gitnexus/src/core/ingestion/call-types.ts new file mode 100644 index 000000000..da175720d --- /dev/null +++ b/gitnexus/src/core/ingestion/call-types.ts @@ -0,0 +1,80 @@ +// gitnexus/src/core/ingestion/call-types.ts + +/** + * Types for the language-agnostic call extraction pipeline. + * + * Mirrors method-types.ts / field-types.ts: defines the domain interfaces + * consumed by createCallExtractor() and the per-language configs. + */ + +import type { SupportedLanguages } from 'gitnexus-shared'; +import type { SyntaxNode } from './utils/ast-helpers.js'; +import type { MixedChainStep } from './utils/call-analysis.js'; + +// --------------------------------------------------------------------------- +// Extracted result +// --------------------------------------------------------------------------- + +/** + * Per-node call extraction result. The parse worker enriches this with + * file-level context (filePath, sourceId, TypeEnv lookups, arg types) to + * produce the final `ExtractedCall` that enters the resolution pipeline. + */ +export interface ExtractedCallSite { + calledName: string; + callForm?: 'free' | 'member' | 'constructor'; + receiverName?: string; + argCount?: number; + /** Unified mixed chain for complex receivers (field + call chains). */ + receiverMixedChain?: MixedChainStep[]; + /** When true, the type-as-receiver heuristic applies: if receiverName + * starts with an uppercase letter and has no TypeEnv binding, treat it + * as a type name (e.g. Java `User::getName`). */ + typeAsReceiverHeuristic?: boolean; +} + +// --------------------------------------------------------------------------- +// Extractor interface (produced by createCallExtractor) +// --------------------------------------------------------------------------- + +export interface CallExtractor { + readonly language: SupportedLanguages; + /** + * Extract a call site from captured AST nodes. + * + * @param callNode The @call capture (call_expression, method_invocation, …) + * @param callNameNode The @call.name capture (identifier inside the call). + * May be undefined when the call shape has no name capture + * (e.g. Java method_reference via `::`). + * @returns Extracted call site, or null when no call can be derived. + */ + extract(callNode: SyntaxNode, callNameNode: SyntaxNode | undefined): ExtractedCallSite | null; +} + +// --------------------------------------------------------------------------- +// Config interface (one per language / language group) +// --------------------------------------------------------------------------- + +export interface CallExtractionConfig { + language: SupportedLanguages; + + /** + * Language-specific call site extraction. Called **before** the generic + * path. If it returns non-null, the generic `inferCallForm` / + * `extractReceiverName` path is skipped entirely. + * + * Use this for call shapes that don't follow the standard `@call` / + * `@call.name` pattern (e.g. Java `method_reference` via `::`). + */ + extractLanguageCallSite?: (callNode: SyntaxNode) => ExtractedCallSite | null; + + /** + * Whether the type-as-receiver heuristic applies for this language. + * When true and the receiver name starts with an uppercase letter, + * the receiver is treated as a type name when no TypeEnv binding exists. + * + * Applies to JVM and C# languages where `Type.method()` and `Type::method` + * are common patterns. + */ + typeAsReceiverHeuristic?: boolean; +} diff --git a/gitnexus/src/core/ingestion/language-provider.ts b/gitnexus/src/core/ingestion/language-provider.ts index 736c3b666..ef29c477c 100644 --- a/gitnexus/src/core/ingestion/language-provider.ts +++ b/gitnexus/src/core/ingestion/language-provider.ts @@ -12,6 +12,7 @@ import type { SupportedLanguages, MroStrategy } from 'gitnexus-shared'; import type { LanguageTypeConfig } from './type-extractors/types.js'; import type { CallRouter } from './call-routing.js'; +import type { CallExtractor } from './call-types.js'; import type { ClassExtractor } from './class-types.js'; import type { ExportChecker } from './export-detection.js'; import type { FieldExtractor } from './field-extractor.js'; @@ -155,6 +156,12 @@ interface LanguageProviderConfig { readonly mroStrategy?: MroStrategy; // ── Language-specific extraction hooks ──────────────────────────── + /** Call extractor for extracting call site information (calledName, callForm, + * receiverName, argCount, mixed chains) from @call / @call.name captures. + * Produced by createCallExtractor() with a per-language CallExtractionConfig. + * Default: undefined — if unset, no calls are extracted for this language. + * All tree-sitter providers MUST supply this. */ + readonly callExtractor?: CallExtractor; /** Field extractor for extracting field/property definitions from class/struct * declarations. Produces FieldInfo[] with name, type, visibility, static, * readonly metadata. Default: undefined (no field extraction). */ diff --git a/gitnexus/src/core/ingestion/languages/c-cpp.ts b/gitnexus/src/core/ingestion/languages/c-cpp.ts index 7a6703a7c..02ae4064e 100644 --- a/gitnexus/src/core/ingestion/languages/c-cpp.ts +++ b/gitnexus/src/core/ingestion/languages/c-cpp.ts @@ -38,6 +38,8 @@ import { } from '../field-extractors/configs/c-cpp.js'; import { createMethodExtractor } from '../method-extractors/generic.js'; import { cMethodConfig, cppMethodConfig } from '../method-extractors/configs/c-cpp.js'; +import { createCallExtractor } from '../call-extractors/generic.js'; +import { cCallConfig, cppCallConfig } from '../call-extractors/configs/c-cpp.js'; const C_BUILT_INS: ReadonlySet = new Set([ 'printf', @@ -316,6 +318,7 @@ export const cProvider = defineLanguage({ exportChecker: cCppExportChecker, importResolver: resolveCImport, importSemantics: 'wildcard-transitive', + callExtractor: createCallExtractor(cCallConfig), fieldExtractor: createFieldExtractor(cFieldConfig), methodExtractor: createMethodExtractor({ ...cMethodConfig, @@ -335,6 +338,7 @@ export const cppProvider = defineLanguage({ importResolver: resolveCppImport, importSemantics: 'wildcard-transitive', mroStrategy: 'leftmost-base', + callExtractor: createCallExtractor(cppCallConfig), fieldExtractor: createFieldExtractor(cppFieldConfig), methodExtractor: createMethodExtractor({ ...cppMethodConfig, diff --git a/gitnexus/src/core/ingestion/languages/csharp.ts b/gitnexus/src/core/ingestion/languages/csharp.ts index bf02412d0..08fccbc15 100644 --- a/gitnexus/src/core/ingestion/languages/csharp.ts +++ b/gitnexus/src/core/ingestion/languages/csharp.ts @@ -15,6 +15,8 @@ import { csharpExportChecker } from '../export-detection.js'; import { resolveCSharpImport } from '../import-resolvers/csharp.js'; import { extractCSharpNamedBindings } from '../named-bindings/csharp.js'; import { CSHARP_QUERIES } from '../tree-sitter-queries.js'; +import { createCallExtractor } from '../call-extractors/generic.js'; +import { csharpCallConfig } from '../call-extractors/configs/csharp.js'; import { createFieldExtractor } from '../field-extractors/generic.js'; import { csharpConfig as csharpFieldConfig } from '../field-extractors/configs/csharp.js'; import { createMethodExtractor } from '../method-extractors/generic.js'; @@ -125,6 +127,7 @@ export const csharpProvider = defineLanguage({ namedBindingExtractor: extractCSharpNamedBindings, interfaceNamePattern: /^I[A-Z]/, mroStrategy: 'implements-split', + callExtractor: createCallExtractor(csharpCallConfig), fieldExtractor: createFieldExtractor(csharpFieldConfig), methodExtractor: createMethodExtractor(csharpMethodConfig), classExtractor: createClassExtractor(csharpClassConfig), diff --git a/gitnexus/src/core/ingestion/languages/dart.ts b/gitnexus/src/core/ingestion/languages/dart.ts index 779592cdd..107f918a5 100644 --- a/gitnexus/src/core/ingestion/languages/dart.ts +++ b/gitnexus/src/core/ingestion/languages/dart.ts @@ -25,6 +25,8 @@ import { createFieldExtractor } from '../field-extractors/generic.js'; import { dartConfig as dartFieldConfig } from '../field-extractors/configs/dart.js'; import { createMethodExtractor } from '../method-extractors/generic.js'; import { dartMethodConfig } from '../method-extractors/configs/dart.js'; +import { createCallExtractor } from '../call-extractors/generic.js'; +import { dartCallConfig } from '../call-extractors/configs/dart.js'; /** * Resolve the enclosing function from a `function_body` node by looking at its @@ -92,6 +94,7 @@ export const dartProvider = defineLanguage({ exportChecker: dartExportChecker, importResolver: resolveDartImport, importSemantics: 'wildcard-leaf', + callExtractor: createCallExtractor(dartCallConfig), fieldExtractor: createFieldExtractor(dartFieldConfig), methodExtractor: createMethodExtractor(dartMethodConfig), classExtractor: createClassExtractor(dartClassConfig), diff --git a/gitnexus/src/core/ingestion/languages/go.ts b/gitnexus/src/core/ingestion/languages/go.ts index 5e75b3088..390d528df 100644 --- a/gitnexus/src/core/ingestion/languages/go.ts +++ b/gitnexus/src/core/ingestion/languages/go.ts @@ -21,6 +21,8 @@ import { createFieldExtractor } from '../field-extractors/generic.js'; import { goConfig as goFieldConfig } from '../field-extractors/configs/go.js'; import { createMethodExtractor } from '../method-extractors/generic.js'; import { goMethodConfig } from '../method-extractors/configs/go.js'; +import { createCallExtractor } from '../call-extractors/generic.js'; +import { goCallConfig } from '../call-extractors/configs/go.js'; export const goProvider = defineLanguage({ id: SupportedLanguages.Go, @@ -30,6 +32,7 @@ export const goProvider = defineLanguage({ exportChecker: goExportChecker, importResolver: resolveGoImport, importSemantics: 'wildcard-leaf', + callExtractor: createCallExtractor(goCallConfig), fieldExtractor: createFieldExtractor(goFieldConfig), methodExtractor: createMethodExtractor(goMethodConfig), classExtractor: createClassExtractor(goClassConfig), diff --git a/gitnexus/src/core/ingestion/languages/java.ts b/gitnexus/src/core/ingestion/languages/java.ts index 297b5eea1..b79322f8a 100644 --- a/gitnexus/src/core/ingestion/languages/java.ts +++ b/gitnexus/src/core/ingestion/languages/java.ts @@ -16,6 +16,8 @@ import { javaExportChecker } from '../export-detection.js'; import { resolveJavaImport } from '../import-resolvers/jvm.js'; import { extractJavaNamedBindings } from '../named-bindings/java.js'; import { JAVA_QUERIES } from '../tree-sitter-queries.js'; +import { createCallExtractor } from '../call-extractors/generic.js'; +import { javaCallConfig } from '../call-extractors/configs/jvm.js'; import { createFieldExtractor } from '../field-extractors/generic.js'; import { javaConfig } from '../field-extractors/configs/jvm.js'; import { createMethodExtractor } from '../method-extractors/generic.js'; @@ -31,6 +33,7 @@ export const javaProvider = defineLanguage({ namedBindingExtractor: extractJavaNamedBindings, interfaceNamePattern: /^I[A-Z]/, mroStrategy: 'implements-split', + callExtractor: createCallExtractor(javaCallConfig), fieldExtractor: createFieldExtractor(javaConfig), methodExtractor: createMethodExtractor(javaMethodConfig), classExtractor: createClassExtractor(javaClassConfig), diff --git a/gitnexus/src/core/ingestion/languages/kotlin.ts b/gitnexus/src/core/ingestion/languages/kotlin.ts index 1017e6aae..97dcddc38 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin.ts @@ -18,6 +18,8 @@ import { extractKotlinNamedBindings } from '../named-bindings/kotlin.js'; import { appendKotlinWildcard } from '../import-resolvers/jvm.js'; import { KOTLIN_QUERIES } from '../tree-sitter-queries.js'; import type { SyntaxNode } from '../utils/ast-helpers.js'; +import { createCallExtractor } from '../call-extractors/generic.js'; +import { kotlinCallConfig } from '../call-extractors/configs/jvm.js'; import { createFieldExtractor } from '../field-extractors/generic.js'; import { kotlinConfig } from '../field-extractors/configs/jvm.js'; import { createMethodExtractor } from '../method-extractors/generic.js'; @@ -106,6 +108,7 @@ export const kotlinProvider = defineLanguage({ namedBindingExtractor: extractKotlinNamedBindings, importPathPreprocessor: appendKotlinWildcard, mroStrategy: 'implements-split', + callExtractor: createCallExtractor(kotlinCallConfig), fieldExtractor: createFieldExtractor(kotlinConfig), methodExtractor: createMethodExtractor(kotlinMethodConfig), classExtractor: createClassExtractor(kotlinClassConfig), diff --git a/gitnexus/src/core/ingestion/languages/php.ts b/gitnexus/src/core/ingestion/languages/php.ts index 2f8be3f98..26c3a83af 100644 --- a/gitnexus/src/core/ingestion/languages/php.ts +++ b/gitnexus/src/core/ingestion/languages/php.ts @@ -21,6 +21,8 @@ import { createFieldExtractor } from '../field-extractors/generic.js'; import { phpConfig as phpFieldConfig } from '../field-extractors/configs/php.js'; import { createMethodExtractor } from '../method-extractors/generic.js'; import { phpMethodConfig } from '../method-extractors/configs/php.js'; +import { createCallExtractor } from '../call-extractors/generic.js'; +import { phpCallConfig } from '../call-extractors/configs/php.js'; const BUILT_INS: ReadonlySet = new Set([ 'echo', @@ -238,6 +240,7 @@ export const phpProvider = defineLanguage({ exportChecker: phpExportChecker, importResolver: resolvePhpImport, namedBindingExtractor: extractPhpNamedBindings, + callExtractor: createCallExtractor(phpCallConfig), fieldExtractor: createFieldExtractor(phpFieldConfig), methodExtractor: createMethodExtractor(phpMethodConfig), classExtractor: createClassExtractor(phpClassConfig), diff --git a/gitnexus/src/core/ingestion/languages/python.ts b/gitnexus/src/core/ingestion/languages/python.ts index 70ca7e19c..c2bf6119b 100644 --- a/gitnexus/src/core/ingestion/languages/python.ts +++ b/gitnexus/src/core/ingestion/languages/python.ts @@ -23,6 +23,8 @@ import { createFieldExtractor } from '../field-extractors/generic.js'; import { pythonConfig as pythonFieldConfig } from '../field-extractors/configs/python.js'; import { createMethodExtractor } from '../method-extractors/generic.js'; import { pythonMethodConfig } from '../method-extractors/configs/python.js'; +import { createCallExtractor } from '../call-extractors/generic.js'; +import { pythonCallConfig } from '../call-extractors/configs/python.js'; const BUILT_INS: ReadonlySet = new Set([ 'print', @@ -64,6 +66,7 @@ export const pythonProvider = defineLanguage({ namedBindingExtractor: extractPythonNamedBindings, importSemantics: 'namespace', mroStrategy: 'c3', + callExtractor: createCallExtractor(pythonCallConfig), fieldExtractor: createFieldExtractor(pythonFieldConfig), methodExtractor: createMethodExtractor(pythonMethodConfig), classExtractor: createClassExtractor(pythonClassConfig), diff --git a/gitnexus/src/core/ingestion/languages/ruby.ts b/gitnexus/src/core/ingestion/languages/ruby.ts index 7cb0e08da..139484b86 100644 --- a/gitnexus/src/core/ingestion/languages/ruby.ts +++ b/gitnexus/src/core/ingestion/languages/ruby.ts @@ -22,6 +22,8 @@ import { createFieldExtractor } from '../field-extractors/generic.js'; import { rubyConfig as rubyFieldConfig } from '../field-extractors/configs/ruby.js'; import { createMethodExtractor } from '../method-extractors/generic.js'; import { rubyMethodConfig } from '../method-extractors/configs/ruby.js'; +import { createCallExtractor } from '../call-extractors/generic.js'; +import { rubyCallConfig } from '../call-extractors/configs/ruby.js'; /** Ruby method/singleton_method: extract name from 'name' field, label as Method. */ const rubyExtractFunctionName = ( @@ -109,6 +111,7 @@ export const rubyProvider = defineLanguage({ importResolver: resolveRubyImport, callRouter: routeRubyCall, importSemantics: 'wildcard-leaf', + callExtractor: createCallExtractor(rubyCallConfig), resolveEnclosingOwner(node) { // Ruby singleton_class (class << self) should resolve to the enclosing // class or module for owner/container resolution (HAS_METHOD edges, class IDs). diff --git a/gitnexus/src/core/ingestion/languages/rust.ts b/gitnexus/src/core/ingestion/languages/rust.ts index 961233ff7..999e99eca 100644 --- a/gitnexus/src/core/ingestion/languages/rust.ts +++ b/gitnexus/src/core/ingestion/languages/rust.ts @@ -25,6 +25,8 @@ import { createFieldExtractor } from '../field-extractors/generic.js'; import { rustConfig as rustFieldConfig } from '../field-extractors/configs/rust.js'; import { createMethodExtractor } from '../method-extractors/generic.js'; import { rustMethodConfig } from '../method-extractors/configs/rust.js'; +import { createCallExtractor } from '../call-extractors/generic.js'; +import { rustCallConfig } from '../call-extractors/configs/rust.js'; /** Rust impl_item: find the function_item child and extract its name as a Method. */ const rustExtractFunctionName = ( @@ -121,6 +123,7 @@ export const rustProvider = defineLanguage({ importResolver: resolveRustImport, namedBindingExtractor: extractRustNamedBindings, mroStrategy: 'qualified-syntax', + callExtractor: createCallExtractor(rustCallConfig), fieldExtractor: createFieldExtractor(rustFieldConfig), methodExtractor: createMethodExtractor({ ...rustMethodConfig, diff --git a/gitnexus/src/core/ingestion/languages/swift.ts b/gitnexus/src/core/ingestion/languages/swift.ts index cc9706bbf..2c34e9047 100644 --- a/gitnexus/src/core/ingestion/languages/swift.ts +++ b/gitnexus/src/core/ingestion/languages/swift.ts @@ -25,6 +25,8 @@ import { createFieldExtractor } from '../field-extractors/generic.js'; import { swiftConfig as swiftFieldConfig } from '../field-extractors/configs/swift.js'; import { createMethodExtractor } from '../method-extractors/generic.js'; import { swiftMethodConfig } from '../method-extractors/configs/swift.js'; +import { createCallExtractor } from '../call-extractors/generic.js'; +import { swiftCallConfig } from '../call-extractors/configs/swift.js'; /** * Group Swift files by SPM target for implicit module visibility. @@ -241,6 +243,7 @@ export const swiftProvider = defineLanguage({ importResolver: resolveSwiftImport, importSemantics: 'wildcard-leaf', heritageDefaultEdge: 'IMPLEMENTS', + callExtractor: createCallExtractor(swiftCallConfig), fieldExtractor: createFieldExtractor(swiftFieldConfig), methodExtractor: createMethodExtractor({ ...swiftMethodConfig, diff --git a/gitnexus/src/core/ingestion/languages/typescript.ts b/gitnexus/src/core/ingestion/languages/typescript.ts index 5c8e315ff..318c2b37b 100644 --- a/gitnexus/src/core/ingestion/languages/typescript.ts +++ b/gitnexus/src/core/ingestion/languages/typescript.ts @@ -29,6 +29,11 @@ import { typescriptMethodConfig, javascriptMethodConfig, } from '../method-extractors/configs/typescript-javascript.js'; +import { createCallExtractor } from '../call-extractors/generic.js'; +import { + typescriptCallConfig, + javascriptCallConfig, +} from '../call-extractors/configs/typescript-javascript.js'; /** * TypeScript/JavaScript: arrow_function and function_expression get their name @@ -160,6 +165,7 @@ export const typescriptProvider = defineLanguage({ exportChecker: tsExportChecker, importResolver: resolveTypescriptImport, namedBindingExtractor: extractTsNamedBindings, + callExtractor: createCallExtractor(typescriptCallConfig), fieldExtractor: typescriptFieldExtractor, methodExtractor: createMethodExtractor({ ...typescriptMethodConfig, @@ -177,6 +183,7 @@ export const javascriptProvider = defineLanguage({ exportChecker: tsExportChecker, importResolver: resolveJavascriptImport, namedBindingExtractor: extractTsNamedBindings, + callExtractor: createCallExtractor(javascriptCallConfig), fieldExtractor: createFieldExtractor(javascriptConfig), methodExtractor: createMethodExtractor({ ...javascriptMethodConfig, diff --git a/gitnexus/src/core/ingestion/languages/vue.ts b/gitnexus/src/core/ingestion/languages/vue.ts index 231d9dcc5..77608870c 100644 --- a/gitnexus/src/core/ingestion/languages/vue.ts +++ b/gitnexus/src/core/ingestion/languages/vue.ts @@ -22,6 +22,8 @@ import { extractTsNamedBindings } from '../named-bindings/typescript.js'; import { TYPESCRIPT_QUERIES } from '../tree-sitter-queries.js'; import { typescriptFieldExtractor } from '../field-extractors/typescript.js'; import { BUILT_INS as TS_BUILT_INS } from './typescript.js'; +import { createCallExtractor } from '../call-extractors/generic.js'; +import { typescriptCallConfig } from '../call-extractors/configs/typescript-javascript.js'; const VUE_SPECIFIC_BUILT_INS = [ 'ref', @@ -67,6 +69,7 @@ export const vueProvider = defineLanguage({ exportChecker: tsExportChecker, importResolver: resolveVueImport, namedBindingExtractor: extractTsNamedBindings, + callExtractor: createCallExtractor(typescriptCallConfig), fieldExtractor: typescriptFieldExtractor, classExtractor: vueClassExtractor, builtInNames: VUE_BUILT_INS, diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index bcafa38d2..dc5f6a62d 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -53,16 +53,7 @@ import { CLASS_CONTAINER_TYPES, type SyntaxNode, } from '../utils/ast-helpers.js'; -import { - countCallArguments, - inferCallForm, - extractReceiverName, - extractReceiverNode, - extractMixedChain, - extractCallArgTypes, - type MixedChainStep, -} from '../utils/call-analysis.js'; -import { extractParsedCallSite } from '../call-sites/extract-language-call-site.js'; +import { extractCallArgTypes, type MixedChainStep } from '../utils/call-analysis.js'; import { buildTypeEnv } from '../type-env.js'; import type { ConstructorBinding } from '../type-env.js'; import { detectFrameworkFromAST } from '../framework-detection.js'; @@ -1656,109 +1647,137 @@ const processFileGroup = ( // Extract call sites if (captureMap['call']) { - const callNode0 = captureMap['call']; - const languageSeed = extractParsedCallSite(language, callNode0); - if (languageSeed) { - if (!provider.isBuiltInName(languageSeed.calledName)) { - const sourceId = - findEnclosingFunctionId(callNode0, file.path, provider) || - generateId('File', file.path); - const receiverName = - languageSeed.callForm === 'member' ? languageSeed.receiverName : undefined; - let receiverTypeName = receiverName - ? typeEnv.lookup(receiverName, callNode0) - : undefined; - // Type-as-receiver (e.g. Java `User::getName`): no TypeEnv binding for the class name - if ( - receiverName !== undefined && - receiverTypeName === undefined && - languageSeed.callForm === 'member' && - (language === SupportedLanguages.Java || - language === SupportedLanguages.CSharp || - language === SupportedLanguages.Kotlin) - ) { - const c0 = receiverName.charCodeAt(0); - if (c0 >= 65 && c0 <= 90) receiverTypeName = receiverName; - } - result.calls.push({ - filePath: file.path, - calledName: languageSeed.calledName, - sourceId, - callForm: languageSeed.callForm, - ...(receiverName !== undefined ? { receiverName } : {}), - ...(receiverTypeName !== undefined ? { receiverTypeName } : {}), - }); - } - continue; - } - + const callNode = captureMap['call']; const callNameNode = captureMap['call.name']; - if (callNameNode) { - const calledName = callNameNode.text; + const callExtractor = provider.callExtractor; - // Dispatch: route language-specific calls (heritage, properties, imports) - const routed = callRouter?.(calledName, captureMap['call']); - if (routed) { - if (routed.kind === 'skip') continue; - - if (routed.kind === 'import') { - result.imports.push({ - filePath: file.path, - rawImportPath: routed.importPath, - language, - }); - continue; - } - - if (routed.kind === 'heritage') { - for (const item of routed.items) { - result.heritage.push({ - filePath: file.path, - className: item.enclosingClass, - parentName: item.mixinName, - kind: item.heritageKind, - }); + if (callExtractor) { + // ── Path 1: Language-specific call site (bypasses routing) ──── + // Try language-specific extraction (e.g. Java `::` method references) + // without callNameNode. If successful, skip routing and the generic + // path entirely. + const langCallSite = callExtractor.extract(callNode, undefined); + if (langCallSite) { + if (!provider.isBuiltInName(langCallSite.calledName)) { + const sourceId = + findEnclosingFunctionId(callNode, file.path, provider) || + generateId('File', file.path); + const receiverName = + langCallSite.callForm === 'member' ? langCallSite.receiverName : undefined; + let receiverTypeName = receiverName + ? typeEnv.lookup(receiverName, callNode) + : undefined; + // Type-as-receiver heuristic (e.g. Java `User::getName`) + if ( + langCallSite.typeAsReceiverHeuristic && + receiverName !== undefined && + receiverTypeName === undefined && + langCallSite.callForm === 'member' + ) { + const c0 = receiverName.charCodeAt(0); + if (c0 >= 65 && c0 <= 90) receiverTypeName = receiverName; } - continue; + result.calls.push({ + filePath: file.path, + calledName: langCallSite.calledName, + sourceId, + callForm: langCallSite.callForm, + ...(receiverName !== undefined ? { receiverName } : {}), + ...(receiverTypeName !== undefined ? { receiverTypeName } : {}), + }); } + continue; + } - if (routed.kind === 'properties') { - const propEnclosingInfo = cachedFindEnclosingClassInfo( - captureMap['call'], - file.path, - provider.resolveEnclosingOwner, - ); - const propEnclosingClassId = propEnclosingInfo?.classId ?? null; - // Enrich routed properties with FieldExtractor metadata - let routedFieldMap: Map | undefined; - if (provider.fieldExtractor && typeEnv) { - const classNode = findEnclosingClassNode(captureMap['call']); - if (classNode) { - routedFieldMap = getFieldInfo(classNode, provider, { - typeEnv, - symbolTable: NOOP_SYMBOL_TABLE, + // ── Path 2: Generic extraction via @call.name ──────────────── + if (callNameNode) { + const calledName = callNameNode.text; + + // Dispatch: route language-specific calls (heritage, properties, imports) + const routed = callRouter?.(calledName, captureMap['call']); + if (routed) { + if (routed.kind === 'skip') continue; + + if (routed.kind === 'import') { + result.imports.push({ + filePath: file.path, + rawImportPath: routed.importPath, + language, + }); + continue; + } + + if (routed.kind === 'heritage') { + for (const item of routed.items) { + result.heritage.push({ filePath: file.path, - language, + className: item.enclosingClass, + parentName: item.mixinName, + kind: item.heritageKind, }); } + continue; } - for (const item of routed.items) { - const routedFieldInfo = routedFieldMap?.get(item.propName); - const propQualifiedName = propEnclosingInfo - ? `${propEnclosingInfo.className}.${item.propName}` - : item.propName; - const nodeId = generateId('Property', `${file.path}:${propQualifiedName}`); - result.nodes.push({ - id: nodeId, - label: 'Property', - properties: { - name: item.propName, + + if (routed.kind === 'properties') { + const propEnclosingInfo = cachedFindEnclosingClassInfo( + captureMap['call'], + file.path, + provider.resolveEnclosingOwner, + ); + const propEnclosingClassId = propEnclosingInfo?.classId ?? null; + // Enrich routed properties with FieldExtractor metadata + let routedFieldMap: Map | undefined; + if (provider.fieldExtractor && typeEnv) { + const classNode = findEnclosingClassNode(captureMap['call']); + if (classNode) { + routedFieldMap = getFieldInfo(classNode, provider, { + typeEnv, + symbolTable: NOOP_SYMBOL_TABLE, + filePath: file.path, + language, + }); + } + } + for (const item of routed.items) { + const routedFieldInfo = routedFieldMap?.get(item.propName); + const propQualifiedName = propEnclosingInfo + ? `${propEnclosingInfo.className}.${item.propName}` + : item.propName; + const nodeId = generateId('Property', `${file.path}:${propQualifiedName}`); + result.nodes.push({ + id: nodeId, + label: 'Property', + properties: { + name: item.propName, + filePath: file.path, + startLine: item.startLine, + endLine: item.endLine, + language, + isExported: true, + description: item.accessorType, + ...(item.declaredType + ? { declaredType: item.declaredType } + : routedFieldInfo?.type + ? { declaredType: routedFieldInfo.type } + : {}), + ...(routedFieldInfo?.visibility !== undefined + ? { visibility: routedFieldInfo.visibility } + : {}), + ...(routedFieldInfo?.isStatic !== undefined + ? { isStatic: routedFieldInfo.isStatic } + : {}), + ...(routedFieldInfo?.isReadonly !== undefined + ? { isReadonly: routedFieldInfo.isReadonly } + : {}), + }, + }); + result.symbols.push({ filePath: file.path, - startLine: item.startLine, - endLine: item.endLine, - language, - isExported: true, - description: item.accessorType, + name: item.propName, + nodeId, + type: 'Property', + ...(propEnclosingClassId ? { ownerId: propEnclosingClassId } : {}), ...(item.declaredType ? { declaredType: item.declaredType } : routedFieldInfo?.type @@ -1773,111 +1792,81 @@ const processFileGroup = ( ...(routedFieldInfo?.isReadonly !== undefined ? { isReadonly: routedFieldInfo.isReadonly } : {}), - }, - }); - result.symbols.push({ - filePath: file.path, - name: item.propName, - nodeId, - type: 'Property', - ...(propEnclosingClassId ? { ownerId: propEnclosingClassId } : {}), - ...(item.declaredType - ? { declaredType: item.declaredType } - : routedFieldInfo?.type - ? { declaredType: routedFieldInfo.type } - : {}), - ...(routedFieldInfo?.visibility !== undefined - ? { visibility: routedFieldInfo.visibility } - : {}), - ...(routedFieldInfo?.isStatic !== undefined - ? { isStatic: routedFieldInfo.isStatic } - : {}), - ...(routedFieldInfo?.isReadonly !== undefined - ? { isReadonly: routedFieldInfo.isReadonly } - : {}), - }); - const fileId = generateId('File', file.path); - const relId = generateId('DEFINES', `${fileId}->${nodeId}`); - result.relationships.push({ - id: relId, - sourceId: fileId, - targetId: nodeId, - type: 'DEFINES', - confidence: 1.0, - reason: '', - }); - if (propEnclosingClassId) { + }); + const fileId = generateId('File', file.path); + const relId = generateId('DEFINES', `${fileId}->${nodeId}`); result.relationships.push({ - id: generateId('HAS_PROPERTY', `${propEnclosingClassId}->${nodeId}`), - sourceId: propEnclosingClassId, + id: relId, + sourceId: fileId, targetId: nodeId, - type: 'HAS_PROPERTY', + type: 'DEFINES', confidence: 1.0, reason: '', }); - } - } - continue; - } - - // kind === 'call' — fall through to normal call processing below - } - - if (!provider.isBuiltInName(calledName)) { - const callNode = captureMap['call']; - const sourceId = - findEnclosingFunctionId(callNode, file.path, provider) || - generateId('File', file.path); - const callForm = inferCallForm(callNode, callNameNode); - let receiverName = - callForm === 'member' ? extractReceiverName(callNameNode) : undefined; - let receiverTypeName = receiverName - ? typeEnv.lookup(receiverName, callNode) - : undefined; - let receiverMixedChain: MixedChainStep[] | undefined; - - // 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) { - const extracted = extractMixedChain(receiverNode); - if (extracted && extracted.chain.length > 0) { - receiverMixedChain = extracted.chain; - receiverName = extracted.baseReceiverName; - // Try the type environment immediately for the base receiver - // (covers explicitly-typed locals and annotated parameters). - if (receiverName) { - receiverTypeName = typeEnv.lookup(receiverName, callNode); + if (propEnclosingClassId) { + result.relationships.push({ + id: generateId('HAS_PROPERTY', `${propEnclosingClassId}->${nodeId}`), + sourceId: propEnclosingClassId, + targetId: nodeId, + type: 'HAS_PROPERTY', + confidence: 1.0, + reason: '', + }); } } + continue; } + + // kind === 'call' — fall through to normal call processing below } - const inferLiteralType = provider.typeConfig?.inferLiteralType; - const argCountForOverloadHints = countCallArguments(callNode); - // Skip when no arg list / zero args: nothing to infer for overload typing; saves AST walks + payload size. - const argTypes = - inferLiteralType && - argCountForOverloadHints !== undefined && - argCountForOverloadHints > 0 - ? extractCallArgTypes(callNode, inferLiteralType, (varName, cn) => - typeEnv.lookup(varName, cn), - ) - : undefined; + if (!provider.isBuiltInName(calledName)) { + const callSite = callExtractor.extract(callNode, callNameNode); + if (callSite) { + const sourceId = + findEnclosingFunctionId(callNode, file.path, provider) || + generateId('File', file.path); + let receiverTypeName = callSite.receiverName + ? typeEnv.lookup(callSite.receiverName, callNode) + : undefined; - result.calls.push({ - filePath: file.path, - calledName, - sourceId, - argCount: countCallArguments(callNode), - ...(callForm !== undefined ? { callForm } : {}), - ...(receiverName !== undefined ? { receiverName } : {}), - ...(receiverTypeName !== undefined ? { receiverTypeName } : {}), - ...(receiverMixedChain !== undefined ? { receiverMixedChain } : {}), - ...(argTypes !== undefined ? { argTypes } : {}), - }); + // Type-as-receiver heuristic + if ( + callSite.typeAsReceiverHeuristic && + callSite.receiverName !== undefined && + receiverTypeName === undefined && + callSite.callForm === 'member' + ) { + const c0 = callSite.receiverName.charCodeAt(0); + if (c0 >= 65 && c0 <= 90) receiverTypeName = callSite.receiverName; + } + + const inferLiteralType = provider.typeConfig?.inferLiteralType; + // Skip when no arg list / zero args: nothing to infer for overload typing + const argTypes = + inferLiteralType && callSite.argCount !== undefined && callSite.argCount > 0 + ? extractCallArgTypes(callNode, inferLiteralType, (varName, cn) => + typeEnv.lookup(varName, cn), + ) + : undefined; + + result.calls.push({ + filePath: file.path, + calledName: callSite.calledName, + sourceId, + ...(callSite.argCount !== undefined ? { argCount: callSite.argCount } : {}), + ...(callSite.callForm !== undefined ? { callForm: callSite.callForm } : {}), + ...(callSite.receiverName !== undefined + ? { receiverName: callSite.receiverName } + : {}), + ...(receiverTypeName !== undefined ? { receiverTypeName } : {}), + ...(callSite.receiverMixedChain !== undefined + ? { receiverMixedChain: callSite.receiverMixedChain } + : {}), + ...(argTypes !== undefined ? { argTypes } : {}), + }); + } + } } } continue; diff --git a/gitnexus/test/unit/call-extraction.test.ts b/gitnexus/test/unit/call-extraction.test.ts new file mode 100644 index 000000000..85921f1f8 --- /dev/null +++ b/gitnexus/test/unit/call-extraction.test.ts @@ -0,0 +1,527 @@ +import { describe, it, expect } from 'vitest'; +import { createCallExtractor } from '../../src/core/ingestion/call-extractors/generic.js'; +import { + javaCallConfig, + kotlinCallConfig, +} from '../../src/core/ingestion/call-extractors/configs/jvm.js'; +import { csharpCallConfig } from '../../src/core/ingestion/call-extractors/configs/csharp.js'; +import { + typescriptCallConfig, + javascriptCallConfig, +} from '../../src/core/ingestion/call-extractors/configs/typescript-javascript.js'; +import { + cCallConfig, + cppCallConfig, +} from '../../src/core/ingestion/call-extractors/configs/c-cpp.js'; +import { pythonCallConfig } from '../../src/core/ingestion/call-extractors/configs/python.js'; +import { rubyCallConfig } from '../../src/core/ingestion/call-extractors/configs/ruby.js'; +import { rustCallConfig } from '../../src/core/ingestion/call-extractors/configs/rust.js'; +import { dartCallConfig } from '../../src/core/ingestion/call-extractors/configs/dart.js'; +import { phpCallConfig } from '../../src/core/ingestion/call-extractors/configs/php.js'; +import { swiftCallConfig } from '../../src/core/ingestion/call-extractors/configs/swift.js'; +import { goCallConfig } from '../../src/core/ingestion/call-extractors/configs/go.js'; +import type { CallExtractionConfig } from '../../src/core/ingestion/call-types.js'; +import type { SyntaxNode } from '../../src/core/ingestion/utils/ast-helpers.js'; +import { SupportedLanguages } from '../../src/config/supported-languages.js'; +import { getProvider } from '../../src/core/ingestion/languages/index.js'; +import Parser from 'tree-sitter'; +import TypeScript from 'tree-sitter-typescript'; +import Python from 'tree-sitter-python'; +import Java from 'tree-sitter-java'; +import CSharp from 'tree-sitter-c-sharp'; +import Go from 'tree-sitter-go'; +import Rust from 'tree-sitter-rust'; +import CPP from 'tree-sitter-cpp'; +import PHP from 'tree-sitter-php'; +import Ruby from 'tree-sitter-ruby'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Parse code with a tree-sitter language and run the language's query to find + * @call / @call.name captures. + */ +function extractCallCaptures( + parser: Parser, + code: string, + language: SupportedLanguages, +): Array<{ + callNode: SyntaxNode; + nameNode: SyntaxNode | undefined; + calledName: string | undefined; +}> { + const provider = getProvider(language); + const queryStr = provider.treeSitterQueries; + if (!queryStr) throw new Error(`No query for ${language}`); + + const tree = parser.parse(code); + const lang = parser.getLanguage(); + const query = new Parser.Query(lang, queryStr); + const matches = query.matches(tree.rootNode); + + const results: Array<{ + callNode: SyntaxNode; + nameNode: SyntaxNode | undefined; + calledName: string | undefined; + }> = []; + + for (const match of matches) { + const captureMap: Record = {}; + for (const c of match.captures) { + captureMap[c.name] = c.node; + } + if (captureMap['call']) { + results.push({ + callNode: captureMap['call'], + nameNode: captureMap['call.name'], + calledName: captureMap['call.name']?.text, + }); + } + } + + return results; +} + +// --------------------------------------------------------------------------- +// Factory construction tests +// --------------------------------------------------------------------------- + +describe('createCallExtractor', () => { + it('constructs all currently registered language configs', () => { + const configs: CallExtractionConfig[] = [ + javaCallConfig, + kotlinCallConfig, + csharpCallConfig, + typescriptCallConfig, + javascriptCallConfig, + cCallConfig, + cppCallConfig, + pythonCallConfig, + rubyCallConfig, + rustCallConfig, + dartCallConfig, + phpCallConfig, + swiftCallConfig, + goCallConfig, + ]; + for (const cfg of configs) { + expect( + () => createCallExtractor(cfg), + `config for ${cfg.language} must construct cleanly`, + ).not.toThrow(); + } + }); + + it('preserves language on the extractor', () => { + const extractor = createCallExtractor(javaCallConfig); + expect(extractor.language).toBe(SupportedLanguages.Java); + }); + + it('returns null when no callNameNode and no language seed', () => { + const extractor = createCallExtractor(typescriptCallConfig); + // A minimal stub SyntaxNode — extract should return null since + // there's no callNameNode and no language-specific hook + const stub = { type: 'call_expression' } as unknown as SyntaxNode; + expect(extractor.extract(stub, undefined)).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// LanguageProvider.callExtractor wiring +// --------------------------------------------------------------------------- + +describe('callExtractor on LanguageProvider', () => { + it('all tree-sitter providers have callExtractor defined', () => { + const languages: SupportedLanguages[] = [ + SupportedLanguages.TypeScript, + SupportedLanguages.JavaScript, + SupportedLanguages.Python, + SupportedLanguages.Java, + SupportedLanguages.Kotlin, + SupportedLanguages.Go, + SupportedLanguages.Rust, + SupportedLanguages.CSharp, + SupportedLanguages.C, + SupportedLanguages.CPlusPlus, + SupportedLanguages.PHP, + SupportedLanguages.Ruby, + SupportedLanguages.Swift, + SupportedLanguages.Dart, + SupportedLanguages.Vue, + ]; + for (const lang of languages) { + const provider = getProvider(lang); + expect(provider.callExtractor, `${lang} should have a callExtractor`).toBeDefined(); + } + }); +}); + +// --------------------------------------------------------------------------- +// Generic extraction via @call.name +// --------------------------------------------------------------------------- + +describe('generic call extraction', () => { + const parser = new Parser(); + + describe('TypeScript', () => { + const extractor = createCallExtractor(typescriptCallConfig); + + it('extracts free function call', () => { + parser.setLanguage(TypeScript.typescript); + const captures = extractCallCaptures(parser, 'doStuff()', SupportedLanguages.TypeScript); + const match = captures.find((c) => c.calledName === 'doStuff'); + expect(match).toBeDefined(); + const result = extractor.extract(match!.callNode, match!.nameNode!); + expect(result).not.toBeNull(); + expect(result!.calledName).toBe('doStuff'); + expect(result!.callForm).toBe('free'); + expect(result!.receiverName).toBeUndefined(); + }); + + it('extracts member call with receiver', () => { + parser.setLanguage(TypeScript.typescript); + const captures = extractCallCaptures(parser, 'user.save()', SupportedLanguages.TypeScript); + const match = captures.find((c) => c.calledName === 'save'); + expect(match).toBeDefined(); + const result = extractor.extract(match!.callNode, match!.nameNode!); + expect(result).not.toBeNull(); + expect(result!.calledName).toBe('save'); + expect(result!.callForm).toBe('member'); + expect(result!.receiverName).toBe('user'); + }); + + it('extracts constructor call', () => { + parser.setLanguage(TypeScript.typescript); + const captures = extractCallCaptures(parser, 'new User()', SupportedLanguages.TypeScript); + const match = captures.find((c) => c.calledName === 'User'); + expect(match).toBeDefined(); + const result = extractor.extract(match!.callNode, match!.nameNode!); + expect(result).not.toBeNull(); + expect(result!.calledName).toBe('User'); + expect(result!.callForm).toBe('constructor'); + }); + + it('extracts argCount', () => { + parser.setLanguage(TypeScript.typescript); + const captures = extractCallCaptures(parser, 'foo(a, b, c)', SupportedLanguages.TypeScript); + const match = captures.find((c) => c.calledName === 'foo'); + expect(match).toBeDefined(); + const result = extractor.extract(match!.callNode, match!.nameNode!); + expect(result).not.toBeNull(); + expect(result!.argCount).toBe(3); + }); + + it('does not set typeAsReceiverHeuristic', () => { + parser.setLanguage(TypeScript.typescript); + const captures = extractCallCaptures(parser, 'User.find()', SupportedLanguages.TypeScript); + const match = captures.find((c) => c.calledName === 'find'); + expect(match).toBeDefined(); + const result = extractor.extract(match!.callNode, match!.nameNode!); + expect(result!.typeAsReceiverHeuristic).toBeFalsy(); + }); + }); + + describe('Python', () => { + const extractor = createCallExtractor(pythonCallConfig); + + it('extracts free function call', () => { + parser.setLanguage(Python); + const captures = extractCallCaptures(parser, 'do_stuff()', SupportedLanguages.Python); + const match = captures.find((c) => c.calledName === 'do_stuff'); + expect(match).toBeDefined(); + const result = extractor.extract(match!.callNode, match!.nameNode!); + expect(result).not.toBeNull(); + expect(result!.calledName).toBe('do_stuff'); + expect(result!.callForm).toBe('free'); + }); + + it('extracts member call', () => { + parser.setLanguage(Python); + const captures = extractCallCaptures(parser, 'user.save()', SupportedLanguages.Python); + const match = captures.find((c) => c.calledName === 'save'); + expect(match).toBeDefined(); + const result = extractor.extract(match!.callNode, match!.nameNode!); + expect(result).not.toBeNull(); + expect(result!.callForm).toBe('member'); + expect(result!.receiverName).toBe('user'); + }); + }); + + describe('Java', () => { + const extractor = createCallExtractor(javaCallConfig); + + it('extracts free function call', () => { + parser.setLanguage(Java); + const captures = extractCallCaptures( + parser, + 'class A { void m() { doStuff(); } }', + SupportedLanguages.Java, + ); + const match = captures.find((c) => c.calledName === 'doStuff'); + expect(match).toBeDefined(); + const result = extractor.extract(match!.callNode, match!.nameNode!); + expect(result).not.toBeNull(); + expect(result!.calledName).toBe('doStuff'); + expect(result!.callForm).toBe('free'); + }); + + it('extracts member call with receiver', () => { + parser.setLanguage(Java); + const captures = extractCallCaptures( + parser, + 'class A { void m() { user.save(); } }', + SupportedLanguages.Java, + ); + const match = captures.find((c) => c.calledName === 'save'); + expect(match).toBeDefined(); + const result = extractor.extract(match!.callNode, match!.nameNode!); + expect(result).not.toBeNull(); + expect(result!.callForm).toBe('member'); + expect(result!.receiverName).toBe('user'); + }); + + it('sets typeAsReceiverHeuristic', () => { + parser.setLanguage(Java); + const captures = extractCallCaptures( + parser, + 'class A { void m() { User.find(); } }', + SupportedLanguages.Java, + ); + const match = captures.find((c) => c.calledName === 'find'); + expect(match).toBeDefined(); + const result = extractor.extract(match!.callNode, match!.nameNode!); + expect(result).not.toBeNull(); + expect(result!.typeAsReceiverHeuristic).toBe(true); + }); + }); + + describe('C#', () => { + const extractor = createCallExtractor(csharpCallConfig); + + it('extracts member call with receiver and typeAsReceiverHeuristic', () => { + parser.setLanguage(CSharp); + const captures = extractCallCaptures( + parser, + 'class A { void M() { Console.WriteLine(); } }', + SupportedLanguages.CSharp, + ); + const match = captures.find((c) => c.calledName === 'WriteLine'); + expect(match).toBeDefined(); + const result = extractor.extract(match!.callNode, match!.nameNode!); + expect(result).not.toBeNull(); + expect(result!.calledName).toBe('WriteLine'); + expect(result!.callForm).toBe('member'); + expect(result!.receiverName).toBe('Console'); + expect(result!.typeAsReceiverHeuristic).toBe(true); + }); + + it('sets typeAsReceiverHeuristic flag even for lowercase receivers', () => { + parser.setLanguage(CSharp); + const captures = extractCallCaptures( + parser, + 'class A { void M() { logger.Info(); } }', + SupportedLanguages.CSharp, + ); + const match = captures.find((c) => c.calledName === 'Info'); + expect(match).toBeDefined(); + const result = extractor.extract(match!.callNode, match!.nameNode!); + expect(result).not.toBeNull(); + // typeAsReceiverHeuristic is set on the config/extractor level (true for C#), + // but the uppercase check happens in parse-worker, not the extractor itself + expect(result!.typeAsReceiverHeuristic).toBe(true); + expect(result!.receiverName).toBe('logger'); + }); + }); + + describe('Go', () => { + const extractor = createCallExtractor(goCallConfig); + + it('extracts free function call', () => { + parser.setLanguage(Go); + const captures = extractCallCaptures( + parser, + 'package main\nfunc main() { doStuff() }', + SupportedLanguages.Go, + ); + const match = captures.find((c) => c.calledName === 'doStuff'); + expect(match).toBeDefined(); + const result = extractor.extract(match!.callNode, match!.nameNode!); + expect(result).not.toBeNull(); + expect(result!.calledName).toBe('doStuff'); + expect(result!.callForm).toBe('free'); + }); + }); + + describe('Rust', () => { + const extractor = createCallExtractor(rustCallConfig); + + it('extracts free function call', () => { + parser.setLanguage(Rust); + const captures = extractCallCaptures( + parser, + 'fn main() { do_stuff(); }', + SupportedLanguages.Rust, + ); + const match = captures.find((c) => c.calledName === 'do_stuff'); + expect(match).toBeDefined(); + const result = extractor.extract(match!.callNode, match!.nameNode!); + expect(result).not.toBeNull(); + expect(result!.calledName).toBe('do_stuff'); + }); + }); + + describe('C++', () => { + const extractor = createCallExtractor(cppCallConfig); + + it('extracts free function call', () => { + parser.setLanguage(CPP); + const captures = extractCallCaptures( + parser, + 'void f() { doStuff(); }', + SupportedLanguages.CPlusPlus, + ); + const match = captures.find((c) => c.calledName === 'doStuff'); + expect(match).toBeDefined(); + const result = extractor.extract(match!.callNode, match!.nameNode!); + expect(result).not.toBeNull(); + expect(result!.calledName).toBe('doStuff'); + expect(result!.callForm).toBe('free'); + }); + }); + + describe('PHP', () => { + const extractor = createCallExtractor(phpCallConfig); + + it('extracts free function call', () => { + parser.setLanguage(PHP.php); + const captures = extractCallCaptures(parser, '', SupportedLanguages.PHP); + const match = captures.find((c) => c.calledName === 'doStuff'); + expect(match).toBeDefined(); + const result = extractor.extract(match!.callNode, match!.nameNode!); + expect(result).not.toBeNull(); + expect(result!.calledName).toBe('doStuff'); + }); + }); + + describe('Ruby', () => { + const extractor = createCallExtractor(rubyCallConfig); + + it('extracts member call', () => { + parser.setLanguage(Ruby); + const captures = extractCallCaptures(parser, 'user.save()', SupportedLanguages.Ruby); + const match = captures.find((c) => c.calledName === 'save'); + expect(match).toBeDefined(); + const result = extractor.extract(match!.callNode, match!.nameNode!); + expect(result).not.toBeNull(); + expect(result!.callForm).toBe('member'); + expect(result!.receiverName).toBe('user'); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Language-specific call site extraction (Java :: method references) +// --------------------------------------------------------------------------- + +describe('Java method_reference extraction', () => { + const parser = new Parser(); + parser.setLanguage(Java); + const extractor = createCallExtractor(javaCallConfig); + + it('extracts Type::new as constructor', () => { + const captures = extractCallCaptures( + parser, + 'class A { void m() { stream.map(User::new); } }', + SupportedLanguages.Java, + ); + // The method_reference should be captured as @call + const match = captures.find((c) => c.callNode.type === 'method_reference'); + if (match) { + const result = extractor.extract(match.callNode, undefined); + expect(result).not.toBeNull(); + expect(result!.calledName).toBe('User'); + expect(result!.callForm).toBe('constructor'); + } + }); + + it('extracts Type::method as member call', () => { + const captures = extractCallCaptures( + parser, + 'class A { void m() { stream.map(User::getName); } }', + SupportedLanguages.Java, + ); + const match = captures.find((c) => c.callNode.type === 'method_reference'); + if (match) { + const result = extractor.extract(match.callNode, undefined); + expect(result).not.toBeNull(); + expect(result!.calledName).toBe('getName'); + expect(result!.callForm).toBe('member'); + expect(result!.receiverName).toBe('User'); + expect(result!.typeAsReceiverHeuristic).toBe(true); + } + }); + + it('extracts this::method as member call', () => { + const captures = extractCallCaptures( + parser, + 'class A { void m() { stream.map(this::process); } }', + SupportedLanguages.Java, + ); + const match = captures.find((c) => c.callNode.type === 'method_reference'); + if (match) { + const result = extractor.extract(match.callNode, undefined); + expect(result).not.toBeNull(); + expect(result!.calledName).toBe('process'); + expect(result!.callForm).toBe('member'); + expect(result!.receiverName).toBe('this'); + } + }); + + it('extractLanguageCallSite returns null for non-method_reference nodes', () => { + const captures = extractCallCaptures( + parser, + 'class A { void m() { doStuff(); } }', + SupportedLanguages.Java, + ); + const match = captures.find((c) => c.calledName === 'doStuff'); + expect(match).toBeDefined(); + // Language seed should be null for regular calls + const langSeed = extractor.extract(match!.callNode, undefined); + expect(langSeed).toBeNull(); + // But full extraction with callNameNode should work + const full = extractor.extract(match!.callNode, match!.nameNode!); + expect(full).not.toBeNull(); + expect(full!.calledName).toBe('doStuff'); + }); +}); + +// --------------------------------------------------------------------------- +// typeAsReceiverHeuristic config flag +// --------------------------------------------------------------------------- + +describe('typeAsReceiverHeuristic config', () => { + it('JVM configs set typeAsReceiverHeuristic', () => { + expect(javaCallConfig.typeAsReceiverHeuristic).toBe(true); + expect(kotlinCallConfig.typeAsReceiverHeuristic).toBe(true); + }); + + it('C# config sets typeAsReceiverHeuristic', () => { + expect(csharpCallConfig.typeAsReceiverHeuristic).toBe(true); + }); + + it('other configs do not set typeAsReceiverHeuristic', () => { + expect(typescriptCallConfig.typeAsReceiverHeuristic).toBeFalsy(); + expect(javascriptCallConfig.typeAsReceiverHeuristic).toBeFalsy(); + expect(pythonCallConfig.typeAsReceiverHeuristic).toBeFalsy(); + expect(rubyCallConfig.typeAsReceiverHeuristic).toBeFalsy(); + expect(goCallConfig.typeAsReceiverHeuristic).toBeFalsy(); + expect(rustCallConfig.typeAsReceiverHeuristic).toBeFalsy(); + expect(cCallConfig.typeAsReceiverHeuristic).toBeFalsy(); + expect(cppCallConfig.typeAsReceiverHeuristic).toBeFalsy(); + expect(phpCallConfig.typeAsReceiverHeuristic).toBeFalsy(); + expect(dartCallConfig.typeAsReceiverHeuristic).toBeFalsy(); + expect(swiftCallConfig.typeAsReceiverHeuristic).toBeFalsy(); + }); +}); From ed5a4220dd63bf9e2c0dc1a87faab9833c5176a4 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Apr 2026 13:57:25 +0100 Subject: [PATCH 11/17] feat(ingestion): language-agnostic variable extractor with config+factory pattern (#878) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * feat(ingestion): add variable extraction types, factory, configs, and wire into language providers - Create variable-types.ts with VariableInfo, VariableExtractionConfig, VariableExtractor interfaces - Create variable-extractors/generic.ts with createVariableExtractor() factory - Add variableExtractor field to LanguageProvider interface - Create per-language variable extraction configs for all 16 languages - Wire variableExtractor into all language providers - Add variable metadata enrichment to parse-worker for Const/Static/Variable labels Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/3cb85c68-1792-473e-9a46-ea2588da0e5e Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * feat(ingestion): add variable extraction tests and fix Python/TS config issues - Create test/unit/variable-extraction.test.ts with 29 tests covering TypeScript, JavaScript, Python, Go, Rust, C, C++, Ruby, and factory behavior - Fix isConst in generic factory to use config.isConst over node-type membership (TS let/const both use lexical_declaration) - Fix Python type extraction for annotated assignments at module scope - Fix Python dunder name visibility (e.g., __name__ is public, not protected) Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/3cb85c68-1792-473e-9a46-ea2588da0e5e Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix: address code review feedback — move imports, clarify scope comment, use shared test context Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/3cb85c68-1792-473e-9a46-ea2588da0e5e Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix: address review comments, fix prettier formatting and lint errors - Fix prettier formatting in 5 files (c-cpp, jvm, swift configs, test file) - Remove unused SyntaxNode imports in php.ts and ruby.ts (lint errors) - Remove unused constNodeSet/variableNodeSet variables in generic.ts (warnings) - Remove semantically wrong `methodProps.isReadonly = varInfo.isConst` (review) - Remove dead `nodeLabel === 'Variable'` guard in parse-worker (review) - Fix test guard: replace `if (declNode)` with `expect(declNode).toBeDefined()` (review) - Add comment about Python expression_statement broadness (review) Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/040edbbf-65b5-40e1-80c8-e98f7c4bb54a * feat(ingestion): add block-scoped variable extraction via tree-sitter queries Add @definition.const and @definition.variable tree-sitter query patterns for TypeScript, JavaScript, Python, Go, Java, C, C++, C#, PHP, Ruby, and Dart. Add parse-worker dedup logic to avoid duplicate nodes when variable captures overlap with existing function/property captures. Add 'Variable' label support in getLabelFromCaptures and DEFINITION_CAPTURE_KEYS. Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/9fa828c1-87b7-4482-8f26-d2079fb4c58a Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * test: add block-scoped variable extraction tests and query capture tests Add 6 tests for block-scoped variable extraction (TypeScript, Go, Rust, C, Python). Add 14 tests verifying @definition.const/@definition.variable query patterns exist in all language query strings. Import RUBY_QUERIES in test file. Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/9fa828c1-87b7-4482-8f26-d2079fb4c58a Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * test: add Python non-assignment expression statement rejection test Addresses code review feedback: verify that the Python variable extractor returns null for expression_statement nodes that contain function calls rather than assignments (e.g. `print("hello")`). Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/9fa828c1-87b7-4482-8f26-d2079fb4c58a Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix: Dart query node type, add Variable schema, update schema counts - Change `top_level_variable_declaration` → `declaration` in DART_QUERIES (the former doesn't exist in tree-sitter-dart grammar, causing all Dart integration tests to fail with TSQueryErrorNodeType) - Add VARIABLE_SCHEMA to schema.ts and register in initLbug() so that Variable-labeled nodes are persisted to LadybugDB (not silently dropped) - Add 'Variable' to MULTI_LANG_TYPES in csv-generator.ts - Update Dart variable config to remove invalid node type - Update schema test counts (30→31 node schemas, 32→33 total) Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/f79931d1-207f-4fbb-91da-259d44f7fd88 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix: address code review comment improvements - Clarify processedDefinitionNodes tracks start indices, not nodes - Improve Python variableNodeTypes comment wording Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/f79931d1-207f-4fbb-91da-259d44f7fd88 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix: add Variable to NODE_TABLES, RELATION_SCHEMA, update golden snapshot - Add 'Variable' to NODE_TABLES in gitnexus-shared so validTables.has('Variable') returns true and Variable graph edges are not silently dropped - Add FROM File TO Variable, FROM Variable TO Community, FROM Variable TO Process to RELATION_SCHEMA so KuzuDB can represent edges connecting Variable nodes - Update schema.test.ts: add Variable to multiLang list, fix count 30→31 - Regenerate pipeline-graph-golden snapshot for mini-repo fixture Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/e3aad558-e7bb-40d1-b53f-0a2c0132ca96 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix: isolate golden test from cli-e2e fixture pollution The pipeline-graph-golden test was non-deterministic because cli-e2e.test.ts creates AGENTS.md, CLAUDE.md, .claude/skills/, and .gitignore in the shared mini-repo fixture during analyze. These leftover files caused the golden test to find 9 files instead of 7 when tests ran in parallel. Fixes: - Golden test now copies the fixture to a temp dir before running, making it immune to concurrent test pollution - cli-e2e afterAll cleanup now removes ALL generated files (AGENTS.md, CLAUDE.md, .claude/, .gitignore) not just .git/ and .gitnexus/ - Golden snapshot regenerated from clean 7-file fixture Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/bd378e73-6f37-49c6-aed6-7fabf4dc6183 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> --- gitnexus-shared/src/lbug/schema-constants.ts | 1 + .../src/core/ingestion/language-provider.ts | 5 + .../src/core/ingestion/languages/c-cpp.ts | 4 + .../src/core/ingestion/languages/csharp.ts | 3 + gitnexus/src/core/ingestion/languages/dart.ts | 3 + gitnexus/src/core/ingestion/languages/go.ts | 3 + gitnexus/src/core/ingestion/languages/java.ts | 3 + .../src/core/ingestion/languages/kotlin.ts | 3 + gitnexus/src/core/ingestion/languages/php.ts | 3 + .../src/core/ingestion/languages/python.ts | 3 + gitnexus/src/core/ingestion/languages/ruby.ts | 3 + gitnexus/src/core/ingestion/languages/rust.ts | 3 + .../src/core/ingestion/languages/swift.ts | 3 + .../core/ingestion/languages/typescript.ts | 7 + gitnexus/src/core/ingestion/languages/vue.ts | 3 + .../src/core/ingestion/tree-sitter-queries.ts | 81 +++ .../src/core/ingestion/utils/ast-helpers.ts | 2 + .../variable-extractors/configs/c-cpp.ts | 93 +++ .../variable-extractors/configs/csharp.ts | 64 ++ .../variable-extractors/configs/dart.ts | 101 +++ .../variable-extractors/configs/go.ts | 91 +++ .../variable-extractors/configs/jvm.ts | 124 ++++ .../variable-extractors/configs/php.ts | 67 ++ .../variable-extractors/configs/python.ts | 109 +++ .../variable-extractors/configs/ruby.ts | 57 ++ .../variable-extractors/configs/rust.ts | 82 +++ .../variable-extractors/configs/swift.ts | 99 +++ .../configs/typescript-javascript.ts | 94 +++ .../ingestion/variable-extractors/generic.ts | 108 +++ gitnexus/src/core/ingestion/variable-types.ts | 91 +++ .../core/ingestion/workers/parse-worker.ts | 42 ++ gitnexus/src/core/lbug/csv-generator.ts | 1 + gitnexus/src/core/lbug/schema.ts | 5 + .../mini-repo/expected-graph.json | 18 +- gitnexus/test/integration/cli-e2e.test.ts | 8 +- .../integration/pipeline-graph-golden.test.ts | 18 +- gitnexus/test/unit/schema.test.ts | 11 +- .../test/unit/tree-sitter-queries.test.ts | 74 ++ .../test/unit/variable-extraction.test.ts | 633 ++++++++++++++++++ 39 files changed, 2103 insertions(+), 20 deletions(-) create mode 100644 gitnexus/src/core/ingestion/variable-extractors/configs/c-cpp.ts create mode 100644 gitnexus/src/core/ingestion/variable-extractors/configs/csharp.ts create mode 100644 gitnexus/src/core/ingestion/variable-extractors/configs/dart.ts create mode 100644 gitnexus/src/core/ingestion/variable-extractors/configs/go.ts create mode 100644 gitnexus/src/core/ingestion/variable-extractors/configs/jvm.ts create mode 100644 gitnexus/src/core/ingestion/variable-extractors/configs/php.ts create mode 100644 gitnexus/src/core/ingestion/variable-extractors/configs/python.ts create mode 100644 gitnexus/src/core/ingestion/variable-extractors/configs/ruby.ts create mode 100644 gitnexus/src/core/ingestion/variable-extractors/configs/rust.ts create mode 100644 gitnexus/src/core/ingestion/variable-extractors/configs/swift.ts create mode 100644 gitnexus/src/core/ingestion/variable-extractors/configs/typescript-javascript.ts create mode 100644 gitnexus/src/core/ingestion/variable-extractors/generic.ts create mode 100644 gitnexus/src/core/ingestion/variable-types.ts create mode 100644 gitnexus/test/unit/variable-extraction.test.ts diff --git a/gitnexus-shared/src/lbug/schema-constants.ts b/gitnexus-shared/src/lbug/schema-constants.ts index 0eca57286..656ffe552 100644 --- a/gitnexus-shared/src/lbug/schema-constants.ts +++ b/gitnexus-shared/src/lbug/schema-constants.ts @@ -30,6 +30,7 @@ export const NODE_TABLES = [ 'TypeAlias', 'Const', 'Static', + 'Variable', 'Property', 'Record', 'Delegate', diff --git a/gitnexus/src/core/ingestion/language-provider.ts b/gitnexus/src/core/ingestion/language-provider.ts index ef29c477c..298c7999f 100644 --- a/gitnexus/src/core/ingestion/language-provider.ts +++ b/gitnexus/src/core/ingestion/language-provider.ts @@ -17,6 +17,7 @@ import type { ClassExtractor } from './class-types.js'; import type { ExportChecker } from './export-detection.js'; import type { FieldExtractor } from './field-extractor.js'; import type { MethodExtractor } from './method-types.js'; +import type { VariableExtractor } from './variable-types.js'; import type { ImportResolverFn } from './import-resolvers/types.js'; import type { NamedBindingExtractorFn } from './named-bindings/types.js'; import type { SyntaxNode } from './utils/ast-helpers.js'; @@ -170,6 +171,10 @@ interface LanguageProviderConfig { * declarations. Produces MethodInfo[] with name, parameters, visibility, isAbstract, * isFinal, annotations metadata. Default: undefined (no method extraction). */ readonly methodExtractor?: MethodExtractor; + /** Variable extractor for extracting metadata from module/file-scoped variable, + * constant, and static declarations. Produces VariableInfo with type, visibility, + * isConst, isStatic, isMutable metadata. Default: undefined (no variable extraction). */ + readonly variableExtractor?: VariableExtractor; /** Class/type extractor for deriving canonical qualified names for class-like symbols. * Uses the same provider-driven strategy pattern as method/field extraction so * namespace/package/module rules stay language-specific. */ diff --git a/gitnexus/src/core/ingestion/languages/c-cpp.ts b/gitnexus/src/core/ingestion/languages/c-cpp.ts index 02ae4064e..b823cefcc 100644 --- a/gitnexus/src/core/ingestion/languages/c-cpp.ts +++ b/gitnexus/src/core/ingestion/languages/c-cpp.ts @@ -38,6 +38,8 @@ import { } from '../field-extractors/configs/c-cpp.js'; import { createMethodExtractor } from '../method-extractors/generic.js'; import { cMethodConfig, cppMethodConfig } from '../method-extractors/configs/c-cpp.js'; +import { createVariableExtractor } from '../variable-extractors/generic.js'; +import { cVariableConfig, cppVariableConfig } from '../variable-extractors/configs/c-cpp.js'; import { createCallExtractor } from '../call-extractors/generic.js'; import { cCallConfig, cppCallConfig } from '../call-extractors/configs/c-cpp.js'; @@ -324,6 +326,7 @@ export const cProvider = defineLanguage({ ...cMethodConfig, extractFunctionName: cCppExtractFunctionName, }), + variableExtractor: createVariableExtractor(cVariableConfig), classExtractor: cClassExtractor, labelOverride: cppLabelOverride, builtInNames: C_BUILT_INS, @@ -344,6 +347,7 @@ export const cppProvider = defineLanguage({ ...cppMethodConfig, extractFunctionName: cCppExtractFunctionName, }), + variableExtractor: createVariableExtractor(cppVariableConfig), classExtractor: cppClassExtractor, labelOverride: cppLabelOverride, builtInNames: C_BUILT_INS, diff --git a/gitnexus/src/core/ingestion/languages/csharp.ts b/gitnexus/src/core/ingestion/languages/csharp.ts index 08fccbc15..d7330fc87 100644 --- a/gitnexus/src/core/ingestion/languages/csharp.ts +++ b/gitnexus/src/core/ingestion/languages/csharp.ts @@ -21,6 +21,8 @@ import { createFieldExtractor } from '../field-extractors/generic.js'; import { csharpConfig as csharpFieldConfig } from '../field-extractors/configs/csharp.js'; import { createMethodExtractor } from '../method-extractors/generic.js'; import { csharpMethodConfig } from '../method-extractors/configs/csharp.js'; +import { createVariableExtractor } from '../variable-extractors/generic.js'; +import { csharpVariableConfig } from '../variable-extractors/configs/csharp.js'; const BUILT_INS: ReadonlySet = new Set([ 'Console', @@ -130,6 +132,7 @@ export const csharpProvider = defineLanguage({ callExtractor: createCallExtractor(csharpCallConfig), fieldExtractor: createFieldExtractor(csharpFieldConfig), methodExtractor: createMethodExtractor(csharpMethodConfig), + variableExtractor: createVariableExtractor(csharpVariableConfig), classExtractor: createClassExtractor(csharpClassConfig), builtInNames: BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/languages/dart.ts b/gitnexus/src/core/ingestion/languages/dart.ts index 107f918a5..1deb488b8 100644 --- a/gitnexus/src/core/ingestion/languages/dart.ts +++ b/gitnexus/src/core/ingestion/languages/dart.ts @@ -25,6 +25,8 @@ import { createFieldExtractor } from '../field-extractors/generic.js'; import { dartConfig as dartFieldConfig } from '../field-extractors/configs/dart.js'; import { createMethodExtractor } from '../method-extractors/generic.js'; import { dartMethodConfig } from '../method-extractors/configs/dart.js'; +import { createVariableExtractor } from '../variable-extractors/generic.js'; +import { dartVariableConfig } from '../variable-extractors/configs/dart.js'; import { createCallExtractor } from '../call-extractors/generic.js'; import { dartCallConfig } from '../call-extractors/configs/dart.js'; @@ -97,6 +99,7 @@ export const dartProvider = defineLanguage({ callExtractor: createCallExtractor(dartCallConfig), fieldExtractor: createFieldExtractor(dartFieldConfig), methodExtractor: createMethodExtractor(dartMethodConfig), + variableExtractor: createVariableExtractor(dartVariableConfig), classExtractor: createClassExtractor(dartClassConfig), enclosingFunctionFinder: dartEnclosingFunctionFinder, builtInNames: BUILT_INS, diff --git a/gitnexus/src/core/ingestion/languages/go.ts b/gitnexus/src/core/ingestion/languages/go.ts index 390d528df..ae92ff995 100644 --- a/gitnexus/src/core/ingestion/languages/go.ts +++ b/gitnexus/src/core/ingestion/languages/go.ts @@ -21,6 +21,8 @@ import { createFieldExtractor } from '../field-extractors/generic.js'; import { goConfig as goFieldConfig } from '../field-extractors/configs/go.js'; import { createMethodExtractor } from '../method-extractors/generic.js'; import { goMethodConfig } from '../method-extractors/configs/go.js'; +import { createVariableExtractor } from '../variable-extractors/generic.js'; +import { goVariableConfig } from '../variable-extractors/configs/go.js'; import { createCallExtractor } from '../call-extractors/generic.js'; import { goCallConfig } from '../call-extractors/configs/go.js'; @@ -35,5 +37,6 @@ export const goProvider = defineLanguage({ callExtractor: createCallExtractor(goCallConfig), fieldExtractor: createFieldExtractor(goFieldConfig), methodExtractor: createMethodExtractor(goMethodConfig), + variableExtractor: createVariableExtractor(goVariableConfig), classExtractor: createClassExtractor(goClassConfig), }); diff --git a/gitnexus/src/core/ingestion/languages/java.ts b/gitnexus/src/core/ingestion/languages/java.ts index b79322f8a..b8446b76a 100644 --- a/gitnexus/src/core/ingestion/languages/java.ts +++ b/gitnexus/src/core/ingestion/languages/java.ts @@ -22,6 +22,8 @@ import { createFieldExtractor } from '../field-extractors/generic.js'; import { javaConfig } from '../field-extractors/configs/jvm.js'; import { createMethodExtractor } from '../method-extractors/generic.js'; import { javaMethodConfig } from '../method-extractors/configs/jvm.js'; +import { createVariableExtractor } from '../variable-extractors/generic.js'; +import { javaVariableConfig } from '../variable-extractors/configs/jvm.js'; export const javaProvider = defineLanguage({ id: SupportedLanguages.Java, @@ -36,5 +38,6 @@ export const javaProvider = defineLanguage({ callExtractor: createCallExtractor(javaCallConfig), fieldExtractor: createFieldExtractor(javaConfig), methodExtractor: createMethodExtractor(javaMethodConfig), + variableExtractor: createVariableExtractor(javaVariableConfig), classExtractor: createClassExtractor(javaClassConfig), }); diff --git a/gitnexus/src/core/ingestion/languages/kotlin.ts b/gitnexus/src/core/ingestion/languages/kotlin.ts index 97dcddc38..7ab962cc5 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin.ts @@ -24,6 +24,8 @@ import { createFieldExtractor } from '../field-extractors/generic.js'; import { kotlinConfig } from '../field-extractors/configs/jvm.js'; import { createMethodExtractor } from '../method-extractors/generic.js'; import { kotlinMethodConfig } from '../method-extractors/configs/jvm.js'; +import { createVariableExtractor } from '../variable-extractors/generic.js'; +import { kotlinVariableConfig } from '../variable-extractors/configs/jvm.js'; /** Check if a Kotlin function_declaration capture is inside a class_body (i.e., a method). * Kotlin grammar uses function_declaration for both top-level functions and class methods. @@ -111,6 +113,7 @@ export const kotlinProvider = defineLanguage({ callExtractor: createCallExtractor(kotlinCallConfig), fieldExtractor: createFieldExtractor(kotlinConfig), methodExtractor: createMethodExtractor(kotlinMethodConfig), + variableExtractor: createVariableExtractor(kotlinVariableConfig), classExtractor: createClassExtractor(kotlinClassConfig), builtInNames: BUILT_INS, labelOverride: (functionNode, defaultLabel) => { diff --git a/gitnexus/src/core/ingestion/languages/php.ts b/gitnexus/src/core/ingestion/languages/php.ts index 26c3a83af..e3af7c62d 100644 --- a/gitnexus/src/core/ingestion/languages/php.ts +++ b/gitnexus/src/core/ingestion/languages/php.ts @@ -21,6 +21,8 @@ import { createFieldExtractor } from '../field-extractors/generic.js'; import { phpConfig as phpFieldConfig } from '../field-extractors/configs/php.js'; import { createMethodExtractor } from '../method-extractors/generic.js'; import { phpMethodConfig } from '../method-extractors/configs/php.js'; +import { createVariableExtractor } from '../variable-extractors/generic.js'; +import { phpVariableConfig } from '../variable-extractors/configs/php.js'; import { createCallExtractor } from '../call-extractors/generic.js'; import { phpCallConfig } from '../call-extractors/configs/php.js'; @@ -243,6 +245,7 @@ export const phpProvider = defineLanguage({ callExtractor: createCallExtractor(phpCallConfig), fieldExtractor: createFieldExtractor(phpFieldConfig), methodExtractor: createMethodExtractor(phpMethodConfig), + variableExtractor: createVariableExtractor(phpVariableConfig), classExtractor: createClassExtractor(phpClassConfig), descriptionExtractor: phpDescriptionExtractor, isRouteFile: isPhpRouteFile, diff --git a/gitnexus/src/core/ingestion/languages/python.ts b/gitnexus/src/core/ingestion/languages/python.ts index c2bf6119b..1e4f7e8c8 100644 --- a/gitnexus/src/core/ingestion/languages/python.ts +++ b/gitnexus/src/core/ingestion/languages/python.ts @@ -23,6 +23,8 @@ import { createFieldExtractor } from '../field-extractors/generic.js'; import { pythonConfig as pythonFieldConfig } from '../field-extractors/configs/python.js'; import { createMethodExtractor } from '../method-extractors/generic.js'; import { pythonMethodConfig } from '../method-extractors/configs/python.js'; +import { createVariableExtractor } from '../variable-extractors/generic.js'; +import { pythonVariableConfig } from '../variable-extractors/configs/python.js'; import { createCallExtractor } from '../call-extractors/generic.js'; import { pythonCallConfig } from '../call-extractors/configs/python.js'; @@ -69,6 +71,7 @@ export const pythonProvider = defineLanguage({ callExtractor: createCallExtractor(pythonCallConfig), fieldExtractor: createFieldExtractor(pythonFieldConfig), methodExtractor: createMethodExtractor(pythonMethodConfig), + variableExtractor: createVariableExtractor(pythonVariableConfig), classExtractor: createClassExtractor(pythonClassConfig), builtInNames: BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/languages/ruby.ts b/gitnexus/src/core/ingestion/languages/ruby.ts index 139484b86..6f3fe4488 100644 --- a/gitnexus/src/core/ingestion/languages/ruby.ts +++ b/gitnexus/src/core/ingestion/languages/ruby.ts @@ -22,6 +22,8 @@ import { createFieldExtractor } from '../field-extractors/generic.js'; import { rubyConfig as rubyFieldConfig } from '../field-extractors/configs/ruby.js'; import { createMethodExtractor } from '../method-extractors/generic.js'; import { rubyMethodConfig } from '../method-extractors/configs/ruby.js'; +import { createVariableExtractor } from '../variable-extractors/generic.js'; +import { rubyVariableConfig } from '../variable-extractors/configs/ruby.js'; import { createCallExtractor } from '../call-extractors/generic.js'; import { rubyCallConfig } from '../call-extractors/configs/ruby.js'; @@ -132,6 +134,7 @@ export const rubyProvider = defineLanguage({ ...rubyMethodConfig, extractFunctionName: rubyExtractFunctionName, }), + variableExtractor: createVariableExtractor(rubyVariableConfig), classExtractor: createClassExtractor(rubyClassConfig), builtInNames: BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/languages/rust.ts b/gitnexus/src/core/ingestion/languages/rust.ts index 999e99eca..21586d692 100644 --- a/gitnexus/src/core/ingestion/languages/rust.ts +++ b/gitnexus/src/core/ingestion/languages/rust.ts @@ -25,6 +25,8 @@ import { createFieldExtractor } from '../field-extractors/generic.js'; import { rustConfig as rustFieldConfig } from '../field-extractors/configs/rust.js'; import { createMethodExtractor } from '../method-extractors/generic.js'; import { rustMethodConfig } from '../method-extractors/configs/rust.js'; +import { createVariableExtractor } from '../variable-extractors/generic.js'; +import { rustVariableConfig } from '../variable-extractors/configs/rust.js'; import { createCallExtractor } from '../call-extractors/generic.js'; import { rustCallConfig } from '../call-extractors/configs/rust.js'; @@ -129,6 +131,7 @@ export const rustProvider = defineLanguage({ ...rustMethodConfig, extractFunctionName: rustExtractFunctionName, }), + variableExtractor: createVariableExtractor(rustVariableConfig), classExtractor: createClassExtractor(rustClassConfig), builtInNames: BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/languages/swift.ts b/gitnexus/src/core/ingestion/languages/swift.ts index 2c34e9047..a3139897c 100644 --- a/gitnexus/src/core/ingestion/languages/swift.ts +++ b/gitnexus/src/core/ingestion/languages/swift.ts @@ -25,6 +25,8 @@ import { createFieldExtractor } from '../field-extractors/generic.js'; import { swiftConfig as swiftFieldConfig } from '../field-extractors/configs/swift.js'; import { createMethodExtractor } from '../method-extractors/generic.js'; import { swiftMethodConfig } from '../method-extractors/configs/swift.js'; +import { createVariableExtractor } from '../variable-extractors/generic.js'; +import { swiftVariableConfig } from '../variable-extractors/configs/swift.js'; import { createCallExtractor } from '../call-extractors/generic.js'; import { swiftCallConfig } from '../call-extractors/configs/swift.js'; @@ -249,6 +251,7 @@ export const swiftProvider = defineLanguage({ ...swiftMethodConfig, extractFunctionName: swiftExtractFunctionName, }), + variableExtractor: createVariableExtractor(swiftVariableConfig), classExtractor: createClassExtractor(swiftClassConfig), implicitImportWirer: wireSwiftImplicitImports, builtInNames: BUILT_INS, diff --git a/gitnexus/src/core/ingestion/languages/typescript.ts b/gitnexus/src/core/ingestion/languages/typescript.ts index 318c2b37b..69b4ca84c 100644 --- a/gitnexus/src/core/ingestion/languages/typescript.ts +++ b/gitnexus/src/core/ingestion/languages/typescript.ts @@ -29,6 +29,11 @@ import { typescriptMethodConfig, javascriptMethodConfig, } from '../method-extractors/configs/typescript-javascript.js'; +import { createVariableExtractor } from '../variable-extractors/generic.js'; +import { + typescriptVariableConfig, + javascriptVariableConfig, +} from '../variable-extractors/configs/typescript-javascript.js'; import { createCallExtractor } from '../call-extractors/generic.js'; import { typescriptCallConfig, @@ -171,6 +176,7 @@ export const typescriptProvider = defineLanguage({ ...typescriptMethodConfig, extractFunctionName: tsExtractFunctionName, }), + variableExtractor: createVariableExtractor(typescriptVariableConfig), classExtractor: createClassExtractor(typescriptClassConfig), builtInNames: BUILT_INS, }); @@ -189,6 +195,7 @@ export const javascriptProvider = defineLanguage({ ...javascriptMethodConfig, extractFunctionName: tsExtractFunctionName, }), + variableExtractor: createVariableExtractor(javascriptVariableConfig), classExtractor: createClassExtractor(javascriptClassConfig), builtInNames: BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/languages/vue.ts b/gitnexus/src/core/ingestion/languages/vue.ts index 77608870c..4164f6b02 100644 --- a/gitnexus/src/core/ingestion/languages/vue.ts +++ b/gitnexus/src/core/ingestion/languages/vue.ts @@ -22,6 +22,8 @@ import { extractTsNamedBindings } from '../named-bindings/typescript.js'; import { TYPESCRIPT_QUERIES } from '../tree-sitter-queries.js'; import { typescriptFieldExtractor } from '../field-extractors/typescript.js'; import { BUILT_INS as TS_BUILT_INS } from './typescript.js'; +import { createVariableExtractor } from '../variable-extractors/generic.js'; +import { typescriptVariableConfig } from '../variable-extractors/configs/typescript-javascript.js'; import { createCallExtractor } from '../call-extractors/generic.js'; import { typescriptCallConfig } from '../call-extractors/configs/typescript-javascript.js'; @@ -71,6 +73,7 @@ export const vueProvider = defineLanguage({ namedBindingExtractor: extractTsNamedBindings, callExtractor: createCallExtractor(typescriptCallConfig), fieldExtractor: typescriptFieldExtractor, + variableExtractor: createVariableExtractor(typescriptVariableConfig), classExtractor: vueClassExtractor, builtInNames: VUE_BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index 99fd3b21c..18b163777 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -61,6 +61,22 @@ export const TYPESCRIPT_QUERIES = ` name: (identifier) @name value: (function_expression)))) @definition.function +; Variable/constant declarations (non-function values). +; Overlap with @definition.function patterns is handled by parse-worker dedup. +(lexical_declaration + (variable_declarator + name: (identifier) @name)) @definition.const + +(export_statement + declaration: (lexical_declaration + (variable_declarator + name: (identifier) @name))) @definition.const + +; var declarations (mutable, function-scoped) +(variable_declaration + (variable_declarator + name: (identifier) @name)) @definition.variable + (import_statement source: (string) @import.source) @import @@ -203,6 +219,22 @@ export const JAVASCRIPT_QUERIES = ` name: (identifier) @name value: (function_expression)))) @definition.function +; Variable/constant declarations (non-function values). +; Overlap with @definition.function patterns is handled by parse-worker dedup. +(lexical_declaration + (variable_declarator + name: (identifier) @name)) @definition.const + +(export_statement + declaration: (lexical_declaration + (variable_declarator + name: (identifier) @name))) @definition.const + +; var declarations (mutable, function-scoped) +(variable_declaration + (variable_declarator + name: (identifier) @name)) @definition.variable + (import_statement source: (string) @import.source) @import @@ -306,6 +338,12 @@ export const PYTHON_QUERIES = ` left: (identifier) @name type: (type)) @definition.property) +; Plain variable assignments without type annotation: x = 5, MAX_SIZE = 100 +; Overlap with @definition.property (typed) is handled by parse-worker dedup. +(expression_statement + (assignment + left: (identifier) @name)) @definition.variable + ; Heritage queries - Python class inheritance (class_definition name: (identifier) @heritage.class @@ -371,6 +409,11 @@ export const JAVA_QUERIES = ` ; Constructor calls: new Foo() (object_creation_expression type: (type_identifier) @call.name) @call +; Local variable declarations inside method bodies +(local_variable_declaration + declarator: (variable_declarator + name: (identifier) @name)) @definition.variable + ; Heritage - extends class (class_declaration name: (identifier) @heritage.class (superclass (type_identifier) @heritage.extends)) @heritage @@ -416,6 +459,11 @@ export const C_QUERIES = ` ; Calls (call_expression function: (identifier) @call.name) @call (call_expression function: (field_expression field: (field_identifier) @call.name)) @call + +; Variable declarations: int x = 5; or int x; +(declaration + declarator: (init_declarator + declarator: (identifier) @name)) @definition.variable `; // Go queries - works with tree-sitter-go @@ -450,6 +498,13 @@ export const GO_QUERIES = ` (call_expression function: (identifier) @call.name) @call (call_expression function: (selector_expression field: (field_identifier) @call.name)) @call +; Const/var declarations +(const_declaration (const_spec name: (identifier) @name)) @definition.const +(var_declaration (var_spec name: (identifier) @name)) @definition.variable + +; Short variable declaration: x := 5 +(short_var_declaration left: (expression_list (identifier) @name)) @definition.variable + ; Struct literal construction: User{Name: "Alice"} (composite_literal type: (type_identifier) @call.name) @call @@ -572,6 +627,11 @@ export const CPP_QUERIES = ` ; Constructor calls: new User() (new_expression type: (type_identifier) @call.name) @call +; Variable declarations: int x = 5; or auto x = 5; +(declaration + declarator: (init_declarator + declarator: (identifier) @name)) @definition.variable + ; Heritage (class_specifier name: (type_identifier) @heritage.class (base_class_clause (type_identifier) @heritage.extends)) @heritage @@ -634,6 +694,12 @@ export const CSHARP_QUERIES = ` ; Target-typed new (C# 9): User u = new("x", 5) (variable_declaration type: (identifier) @call.name (variable_declarator (implicit_object_creation_expression) @call)) +; Local variable declarations +(local_declaration_statement + (variable_declaration + (variable_declarator + (identifier) @name))) @definition.variable + ; Heritage (class_declaration name: (identifier) @heritage.class (base_list (identifier) @heritage.extends)) @heritage @@ -781,6 +847,11 @@ export const PHP_QUERIES = ` ; Constructor call: new User() (object_creation_expression (name) @call.name) @call +; Const declarations at class scope +(const_declaration + (const_element + (name) @name)) @definition.const + ; ── Heritage: extends ──────────────────────────────────────────────────────── (class_declaration name: (name) @heritage.class @@ -849,6 +920,10 @@ export const RUBY_QUERIES = ` (call method: (identifier) @call.name) @call +; ── Constant assignment: MAX_SIZE = 100, ITEMS = [...] ─────────────────────── +(assignment + left: (constant) @name) @definition.const + ; ── Bare calls without parens (identifiers at statement level are method calls) ─ ; NOTE: This may over-capture variable reads as calls (e.g. 'result' at ; statement level). Ruby's grammar makes bare identifiers ambiguous — they @@ -1123,6 +1198,12 @@ export const DART_QUERIES = ` (setter_signature name: (identifier) @name)) @definition.property +; ── Top-level variable declarations (const maxSize = 100, final x = 5, var y = 0) ── +(declaration + (initialized_identifier_list + (initialized_identifier + (identifier) @name))) @definition.variable + ; ── Imports ────────────────────────────────────────────────────────────────── (import_or_export (library_import diff --git a/gitnexus/src/core/ingestion/utils/ast-helpers.ts b/gitnexus/src/core/ingestion/utils/ast-helpers.ts index 2d4b794ae..a36ed103d 100644 --- a/gitnexus/src/core/ingestion/utils/ast-helpers.ts +++ b/gitnexus/src/core/ingestion/utils/ast-helpers.ts @@ -24,6 +24,7 @@ export const DEFINITION_CAPTURE_KEYS = [ 'definition.type', 'definition.const', 'definition.static', + 'definition.variable', 'definition.typedef', 'definition.macro', 'definition.union', @@ -190,6 +191,7 @@ export function getLabelFromCaptures( if (captureMap['definition.type']) return 'TypeAlias'; if (captureMap['definition.const']) return 'Const'; if (captureMap['definition.static']) return 'Static'; + if (captureMap['definition.variable']) return 'Variable'; if (captureMap['definition.typedef']) return 'Typedef'; if (captureMap['definition.macro']) return 'Macro'; if (captureMap['definition.union']) return 'Union'; diff --git a/gitnexus/src/core/ingestion/variable-extractors/configs/c-cpp.ts b/gitnexus/src/core/ingestion/variable-extractors/configs/c-cpp.ts new file mode 100644 index 000000000..29bf952ed --- /dev/null +++ b/gitnexus/src/core/ingestion/variable-extractors/configs/c-cpp.ts @@ -0,0 +1,93 @@ +// gitnexus/src/core/ingestion/variable-extractors/configs/c-cpp.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { VariableExtractionConfig } from '../../variable-types.js'; +import type { VariableVisibility } from '../../variable-types.js'; +import { hasKeyword } from '../../field-extractors/configs/helpers.js'; +import { extractSimpleTypeName } from '../../type-extractors/shared.js'; +import type { SyntaxNode } from '../../utils/ast-helpers.js'; + +/** + * C/C++ variable extraction config. + * + * Handles global/namespace-scoped variable declarations: + * - `int x = 5;` + * - `const int MAX = 100;` + * - `static int counter = 0;` + * - `constexpr int SIZE = 10;` (C++) + * - `extern int shared;` + * + * tree-sitter-c/cpp uses declaration for variable declarations. + */ + +function extractCVarName(node: SyntaxNode): string | undefined { + // declaration → declarator (init_declarator or identifier) + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'init_declarator') { + const declarator = child.childForFieldName('declarator'); + if (declarator?.type === 'identifier') return declarator.text; + if (declarator?.type === 'pointer_declarator') { + const inner = declarator.namedChildren.find((c: SyntaxNode) => c.type === 'identifier'); + return inner?.text; + } + } + if (child?.type === 'identifier') return child.text; + } + return undefined; +} + +function extractCVarType(node: SyntaxNode): string | undefined { + const typeNode = node.childForFieldName('type'); + if (typeNode) return extractSimpleTypeName(typeNode) ?? typeNode.text?.trim(); + // Fallback: first primitive_type or type_identifier child + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if ( + child?.type === 'primitive_type' || + child?.type === 'type_identifier' || + child?.type === 'sized_type_specifier' + ) { + return child.text?.trim(); + } + } + return undefined; +} + +const shared: Omit = { + constNodeTypes: [], + staticNodeTypes: [], + variableNodeTypes: ['declaration'], + + extractName: extractCVarName, + extractType: extractCVarType, + + extractVisibility(node): VariableVisibility { + // C/C++ visibility is file-scoped by default (static = file-private) + if (hasKeyword(node, 'static')) return 'private'; + if (hasKeyword(node, 'extern')) return 'public'; + return 'public'; + }, + + isConst(node) { + return hasKeyword(node, 'const') || hasKeyword(node, 'constexpr'); + }, + + isStatic(node) { + return hasKeyword(node, 'static'); + }, + + isMutable(node) { + return !hasKeyword(node, 'const') && !hasKeyword(node, 'constexpr'); + }, +}; + +export const cVariableConfig: VariableExtractionConfig = { + ...shared, + language: SupportedLanguages.C, +}; + +export const cppVariableConfig: VariableExtractionConfig = { + ...shared, + language: SupportedLanguages.CPlusPlus, +}; diff --git a/gitnexus/src/core/ingestion/variable-extractors/configs/csharp.ts b/gitnexus/src/core/ingestion/variable-extractors/configs/csharp.ts new file mode 100644 index 000000000..002207cca --- /dev/null +++ b/gitnexus/src/core/ingestion/variable-extractors/configs/csharp.ts @@ -0,0 +1,64 @@ +// gitnexus/src/core/ingestion/variable-extractors/configs/csharp.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { VariableExtractionConfig } from '../../variable-types.js'; +import type { VariableVisibility } from '../../variable-types.js'; +import { collectModifierTexts } from '../../field-extractors/configs/helpers.js'; +import { extractSimpleTypeName } from '../../type-extractors/shared.js'; + +/** + * C# variable extraction config. + * + * C# does not have true top-level variables (pre-C# 9). In C# 9+ top-level + * statements, local_declaration_statement can appear at program scope. + * Class-scoped fields are handled by the field extractor. + */ +export const csharpVariableConfig: VariableExtractionConfig = { + language: SupportedLanguages.CSharp, + constNodeTypes: [], + staticNodeTypes: [], + variableNodeTypes: ['local_declaration_statement'], + + extractName(node) { + // local_declaration_statement → variable_declaration → variable_declarator → identifier + const varDecl = node.namedChildren.find((c) => c.type === 'variable_declaration'); + if (!varDecl) return undefined; + const declarator = varDecl.namedChildren.find((c) => c.type === 'variable_declarator'); + const name = declarator?.childForFieldName('name'); + return name?.type === 'identifier' ? name.text : undefined; + }, + + extractType(node) { + const varDecl = node.namedChildren.find((c) => c.type === 'variable_declaration'); + if (!varDecl) return undefined; + const typeNode = varDecl.childForFieldName('type'); + if (typeNode) return extractSimpleTypeName(typeNode) ?? typeNode.text?.trim(); + return undefined; + }, + + extractVisibility(node): VariableVisibility { + const mods = collectModifierTexts(node); + if (mods.has('public')) return 'public'; + if (mods.has('private')) return 'private'; + if (mods.has('protected') && mods.has('internal')) return 'protected internal'; + if (mods.has('private') && mods.has('protected')) return 'private protected'; + if (mods.has('protected')) return 'protected'; + if (mods.has('internal')) return 'internal'; + return 'private'; + }, + + isConst(node) { + const mods = collectModifierTexts(node); + return mods.has('const'); + }, + + isStatic(node) { + const mods = collectModifierTexts(node); + return mods.has('static'); + }, + + isMutable(node) { + const mods = collectModifierTexts(node); + return !mods.has('const') && !mods.has('readonly'); + }, +}; diff --git a/gitnexus/src/core/ingestion/variable-extractors/configs/dart.ts b/gitnexus/src/core/ingestion/variable-extractors/configs/dart.ts new file mode 100644 index 000000000..a7e52afa4 --- /dev/null +++ b/gitnexus/src/core/ingestion/variable-extractors/configs/dart.ts @@ -0,0 +1,101 @@ +// gitnexus/src/core/ingestion/variable-extractors/configs/dart.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { VariableExtractionConfig } from '../../variable-types.js'; +import type { VariableVisibility } from '../../variable-types.js'; +import { extractSimpleTypeName } from '../../type-extractors/shared.js'; +import type { SyntaxNode } from '../../utils/ast-helpers.js'; + +/** + * Dart variable extraction config. + * + * Dart has top-level variable and constant declarations: + * - `const maxSize = 100;` + * - `final String name = "dart";` + * - `var counter = 0;` + * - `int x = 5;` + * + * tree-sitter-dart uses: + * - declaration (with initialized_identifier_list) for file-scope variables + */ + +function extractDartVarName(node: SyntaxNode): string | undefined { + // declaration → initialized_variable_definition → identifier + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'initialized_variable_definition') { + const name = child.childForFieldName('name'); + if (name) return name.text; + // Fallback: first identifier + for (let j = 0; j < child.namedChildCount; j++) { + const gc = child.namedChild(j); + if (gc?.type === 'identifier') return gc.text; + } + } + // declaration → initialized_identifier_list → initialized_identifier → identifier + if (child?.type === 'initialized_identifier_list') { + for (let j = 0; j < child.namedChildCount; j++) { + const gc = child.namedChild(j); + if (gc?.type === 'initialized_identifier') { + const ident = gc.namedChildren.find((c: SyntaxNode) => c.type === 'identifier'); + if (ident) return ident.text; + } + } + } + } + return undefined; +} + +function extractDartVarType(node: SyntaxNode): string | undefined { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'initialized_variable_definition') { + const typeNode = child.childForFieldName('type'); + if (typeNode) return extractSimpleTypeName(typeNode) ?? typeNode.text?.trim(); + } + } + // Look for type_identifier directly on the node + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'type_identifier') return child.text; + } + return undefined; +} + +function hasDartKeyword(node: SyntaxNode, keyword: string): boolean { + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child?.text === keyword) return true; + } + return false; +} + +export const dartVariableConfig: VariableExtractionConfig = { + language: SupportedLanguages.Dart, + constNodeTypes: [], + staticNodeTypes: [], + variableNodeTypes: ['declaration'], + + extractName: extractDartVarName, + extractType: extractDartVarType, + + extractVisibility(node): VariableVisibility { + const name = extractDartVarName(node); + if (!name) return 'public'; + // Dart convention: underscore prefix = library-private + return name.startsWith('_') ? 'private' : 'public'; + }, + + isConst(node) { + return hasDartKeyword(node, 'const') || hasDartKeyword(node, 'final'); + }, + + isStatic(_node) { + // Top-level Dart variables are not static + return false; + }, + + isMutable(node) { + return !hasDartKeyword(node, 'const') && !hasDartKeyword(node, 'final'); + }, +}; diff --git a/gitnexus/src/core/ingestion/variable-extractors/configs/go.ts b/gitnexus/src/core/ingestion/variable-extractors/configs/go.ts new file mode 100644 index 000000000..2277aecfc --- /dev/null +++ b/gitnexus/src/core/ingestion/variable-extractors/configs/go.ts @@ -0,0 +1,91 @@ +// gitnexus/src/core/ingestion/variable-extractors/configs/go.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { VariableExtractionConfig } from '../../variable-types.js'; +import type { VariableVisibility } from '../../variable-types.js'; +import { extractSimpleTypeName } from '../../type-extractors/shared.js'; +import type { SyntaxNode } from '../../utils/ast-helpers.js'; + +/** + * Go variable extraction config. + * + * Go has package-scoped var and const declarations: + * - `var x int = 5` + * - `const MaxSize = 100` + * - `var ( ... )` grouped declarations + * + * tree-sitter-go uses: + * - var_declaration → var_spec → identifier, type + * - const_declaration → const_spec → identifier, type + * + * Visibility: uppercase first letter = exported (public), lowercase = unexported (package). + */ + +function extractGoVarName(node: SyntaxNode): string | undefined { + // var_declaration/const_declaration → var_spec/const_spec → identifier + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'var_spec' || child?.type === 'const_spec') { + const name = child.childForFieldName('name'); + if (name) return name.text; + // Fallback: first identifier child + for (let j = 0; j < child.namedChildCount; j++) { + const gc = child.namedChild(j); + if (gc?.type === 'identifier') return gc.text; + } + } + } + // short_var_declaration: x := 5 → expression_list → identifier + if (node.type === 'short_var_declaration') { + const left = node.childForFieldName('left'); + if (left?.type === 'expression_list') { + const firstIdent = left.namedChildren.find((c: SyntaxNode) => c.type === 'identifier'); + if (firstIdent) return firstIdent.text; + } + } + return undefined; +} + +function extractGoVarType(node: SyntaxNode): string | undefined { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'var_spec' || child?.type === 'const_spec') { + const typeNode = child.childForFieldName('type'); + if (typeNode) return extractSimpleTypeName(typeNode) ?? typeNode.text?.trim(); + } + } + return undefined; +} + +export const goVariableConfig: VariableExtractionConfig = { + language: SupportedLanguages.Go, + constNodeTypes: ['const_declaration'], + staticNodeTypes: [], + variableNodeTypes: ['var_declaration', 'short_var_declaration'], + + extractName: extractGoVarName, + extractType: extractGoVarType, + + extractVisibility(node): VariableVisibility { + const name = extractGoVarName(node); + if (!name) return 'package'; + // Go visibility: uppercase first letter = exported + const firstChar = name.charAt(0); + return firstChar === firstChar.toUpperCase() && firstChar !== firstChar.toLowerCase() + ? 'public' + : 'package'; + }, + + isConst(node) { + return node.type === 'const_declaration'; + }, + + isStatic(_node) { + // Go does not have static declarations + return false; + }, + + isMutable(node) { + return node.type !== 'const_declaration'; + }, +}; diff --git a/gitnexus/src/core/ingestion/variable-extractors/configs/jvm.ts b/gitnexus/src/core/ingestion/variable-extractors/configs/jvm.ts new file mode 100644 index 000000000..6ebaa3024 --- /dev/null +++ b/gitnexus/src/core/ingestion/variable-extractors/configs/jvm.ts @@ -0,0 +1,124 @@ +// gitnexus/src/core/ingestion/variable-extractors/configs/jvm.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { VariableExtractionConfig } from '../../variable-types.js'; +import type { VariableVisibility } from '../../variable-types.js'; +import { hasModifier } from '../../field-extractors/configs/helpers.js'; +import { extractSimpleTypeName } from '../../type-extractors/shared.js'; +import type { SyntaxNode } from '../../utils/ast-helpers.js'; + +/** + * Java variable extraction config. + * + * Java does not have true module-level variables — all declarations are + * class-scoped. However, `static final` fields at class scope act like + * constants. These are already handled by the field extractor. This config + * covers any rare local_variable_declaration captures at file scope + * (e.g., in scripts or top-level code blocks in JShell). + */ +export const javaVariableConfig: VariableExtractionConfig = { + language: SupportedLanguages.Java, + constNodeTypes: [], + staticNodeTypes: [], + variableNodeTypes: ['local_variable_declaration'], + + extractName(node) { + const declarator = node.namedChildren.find((c) => c.type === 'variable_declarator'); + const name = declarator?.childForFieldName('name'); + return name?.type === 'identifier' ? name.text : undefined; + }, + + extractType(node) { + const typeNode = node.childForFieldName('type'); + if (typeNode) return extractSimpleTypeName(typeNode) ?? typeNode.text?.trim(); + return undefined; + }, + + extractVisibility(node): VariableVisibility { + if (hasModifier(node, 'modifiers', 'public')) return 'public'; + if (hasModifier(node, 'modifiers', 'private')) return 'private'; + if (hasModifier(node, 'modifiers', 'protected')) return 'protected'; + return 'package'; + }, + + isConst(node) { + return hasModifier(node, 'modifiers', 'final'); + }, + + isStatic(node) { + return hasModifier(node, 'modifiers', 'static'); + }, + + isMutable(node) { + return !hasModifier(node, 'modifiers', 'final'); + }, +}; + +/** + * Kotlin variable extraction config. + * + * Kotlin has true top-level val/var declarations outside classes. + * tree-sitter-kotlin uses 'property_declaration' for both. + */ +export const kotlinVariableConfig: VariableExtractionConfig = { + language: SupportedLanguages.Kotlin, + constNodeTypes: [], + staticNodeTypes: [], + variableNodeTypes: ['property_declaration'], + + extractName(node) { + // property_declaration → variable_declaration → simple_identifier + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'variable_declaration') { + const ident = child.namedChildren.find((c: SyntaxNode) => c.type === 'simple_identifier'); + return ident?.text; + } + } + return undefined; + }, + + extractType(node) { + // Look for type annotation in variable_declaration child + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'variable_declaration') { + const typeNode = child.namedChildren.find( + (c: SyntaxNode) => c.type === 'user_type' || c.type === 'nullable_type', + ); + if (typeNode) return extractSimpleTypeName(typeNode) ?? typeNode.text?.trim(); + } + } + return undefined; + }, + + extractVisibility(node): VariableVisibility { + if (hasModifier(node, 'modifiers', 'public')) return 'public'; + if (hasModifier(node, 'modifiers', 'private')) return 'private'; + if (hasModifier(node, 'modifiers', 'protected')) return 'protected'; + if (hasModifier(node, 'modifiers', 'internal')) return 'internal'; + return 'public'; // Kotlin default is public + }, + + isConst(node) { + // val is immutable; also check for `const` modifier + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child?.text === 'val') return true; + } + return hasModifier(node, 'modifiers', 'const'); + }, + + isStatic(_node) { + // Top-level Kotlin properties are not static in the Java sense + return false; + }, + + isMutable(node) { + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child?.text === 'var') return true; + } + return false; + }, +}; diff --git a/gitnexus/src/core/ingestion/variable-extractors/configs/php.ts b/gitnexus/src/core/ingestion/variable-extractors/configs/php.ts new file mode 100644 index 000000000..9c940d4f1 --- /dev/null +++ b/gitnexus/src/core/ingestion/variable-extractors/configs/php.ts @@ -0,0 +1,67 @@ +// gitnexus/src/core/ingestion/variable-extractors/configs/php.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { VariableExtractionConfig } from '../../variable-types.js'; +import type { VariableVisibility } from '../../variable-types.js'; +import { hasKeyword } from '../../field-extractors/configs/helpers.js'; + +/** + * PHP variable extraction config. + * + * PHP has const declarations at namespace/file scope and global variables: + * - `const MAX_SIZE = 100;` + * - `define('MAX_SIZE', 100);` + * - `$variable = value;` + * + * tree-sitter-php uses: + * - const_declaration at namespace/program scope + * - expression_statement containing assignment_expression for variables + */ +export const phpVariableConfig: VariableExtractionConfig = { + language: SupportedLanguages.PHP, + constNodeTypes: ['const_declaration'], + staticNodeTypes: [], + variableNodeTypes: ['expression_statement'], + + extractName(node) { + if (node.type === 'const_declaration') { + // const_declaration → const_element → name (identifier) + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'const_element') { + const name = child.childForFieldName('name'); + return name?.text; + } + } + return undefined; + } + // expression_statement → assignment_expression → variable_name + const inner = node.firstNamedChild; + if (inner?.type === 'assignment_expression') { + const left = inner.childForFieldName('left'); + if (left?.type === 'variable_name') return left.text; + } + return undefined; + }, + + extractType(_node) { + // PHP is dynamically typed — no inline type annotations for variables + return undefined; + }, + + extractVisibility(_node): VariableVisibility { + return 'public'; + }, + + isConst(node) { + return node.type === 'const_declaration'; + }, + + isStatic(node) { + return hasKeyword(node, 'static'); + }, + + isMutable(node) { + return node.type !== 'const_declaration'; + }, +}; diff --git a/gitnexus/src/core/ingestion/variable-extractors/configs/python.ts b/gitnexus/src/core/ingestion/variable-extractors/configs/python.ts new file mode 100644 index 000000000..d0f7722fb --- /dev/null +++ b/gitnexus/src/core/ingestion/variable-extractors/configs/python.ts @@ -0,0 +1,109 @@ +// gitnexus/src/core/ingestion/variable-extractors/configs/python.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { VariableExtractionConfig } from '../../variable-types.js'; +import type { VariableVisibility } from '../../variable-types.js'; +import { extractSimpleTypeName } from '../../type-extractors/shared.js'; +import type { SyntaxNode } from '../../utils/ast-helpers.js'; + +/** + * Python variable extraction config. + * + * Handles module-level assignments and annotated assignments: + * - `MAX_SIZE = 100` → const by UPPER_CASE convention + * - `name: str = "default"` → annotated assignment with type + * - `_private_var = 42` → protected by convention + * - `__private_var = 42` → private by convention + * + * tree-sitter-python uses: + * - expression_statement containing assignment or type nodes + */ + +function extractNameFromPython(node: SyntaxNode): string | undefined { + const inner = node.firstNamedChild; + if (!inner) return undefined; + + // Annotated assignment: name: str = "default" + // AST: expression_statement > type > identifier + if (inner.type === 'type') { + const ident = inner.childForFieldName('name') ?? inner.firstNamedChild; + return ident?.type === 'identifier' ? ident.text : undefined; + } + + // Plain assignment: x = 5 + if (inner.type === 'assignment') { + const left = inner.childForFieldName('left'); + if (left?.type === 'identifier') return left.text; + } + + return undefined; +} + +function extractTypeFromPython(node: SyntaxNode): string | undefined { + const inner = node.firstNamedChild; + if (!inner) return undefined; + + // Standalone annotated type without assignment: `name: str` + if (inner.type === 'type') { + const typeNode = inner.childForFieldName('type') ?? inner.namedChild(1); + if (typeNode) return extractSimpleTypeName(typeNode) ?? typeNode.text?.trim(); + } + + // Annotated assignment: `name: str = "hello"` + // AST: expression_statement > assignment > [identifier, type > identifier, ...] + if (inner.type === 'assignment') { + for (let i = 0; i < inner.childCount; i++) { + const child = inner.child(i); + if (child?.type === 'type') { + const typeId = child.firstNamedChild; + if (typeId) return extractSimpleTypeName(typeId) ?? typeId.text?.trim(); + } + } + } + + return undefined; +} + +function extractVisFromPython(node: SyntaxNode): VariableVisibility { + const name = extractNameFromPython(node); + if (!name) return 'public'; + // Dunder names (__name__, __all__) are public Python conventions + if (name.startsWith('__') && name.endsWith('__')) return 'public'; + // Double underscore prefix (name mangled) = private + if (name.startsWith('__')) return 'private'; + // Single underscore prefix = protected by convention + if (name.startsWith('_')) return 'protected'; + return 'public'; +} + +export const pythonVariableConfig: VariableExtractionConfig = { + language: SupportedLanguages.Python, + constNodeTypes: [], + staticNodeTypes: [], + // expression_statement is broad — isVariableDeclaration returns true for + // all expression_statement nodes, but extract() safely filters non-assignments + // by returning null when extractNameFromPython finds no assignment target. + variableNodeTypes: ['expression_statement'], + + extractName: extractNameFromPython, + extractType: extractTypeFromPython, + extractVisibility: extractVisFromPython, + + isConst(node) { + // Python convention: UPPER_CASE names are constants + const name = extractNameFromPython(node); + if (!name) return false; + return name === name.toUpperCase() && /^[A-Z][A-Z0-9_]*$/.test(name); + }, + + isStatic(_node) { + return false; + }, + + isMutable(node) { + const name = extractNameFromPython(node); + if (!name) return true; + // By convention, UPPER_CASE names are immutable constants + return !(name === name.toUpperCase() && /^[A-Z][A-Z0-9_]*$/.test(name)); + }, +}; diff --git a/gitnexus/src/core/ingestion/variable-extractors/configs/ruby.ts b/gitnexus/src/core/ingestion/variable-extractors/configs/ruby.ts new file mode 100644 index 000000000..4c57cca0d --- /dev/null +++ b/gitnexus/src/core/ingestion/variable-extractors/configs/ruby.ts @@ -0,0 +1,57 @@ +// gitnexus/src/core/ingestion/variable-extractors/configs/ruby.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { VariableExtractionConfig } from '../../variable-types.js'; +import type { VariableVisibility } from '../../variable-types.js'; + +/** + * Ruby variable extraction config. + * + * Ruby module-level constants use UPPER_CASE identifiers or start with + * an uppercase letter. Ruby uses: + * - assignment for variable declarations at module scope + * - Constants: `MAX_SIZE = 100` or `Config = ...` + * - Global variables: `$global = ...` + */ +export const rubyVariableConfig: VariableExtractionConfig = { + language: SupportedLanguages.Ruby, + constNodeTypes: [], + staticNodeTypes: [], + variableNodeTypes: ['assignment'], + + extractName(node) { + const left = node.childForFieldName('left'); + if (!left) return undefined; + if (left.type === 'identifier' || left.type === 'constant') return left.text; + if (left.type === 'global_variable') return left.text; + return undefined; + }, + + extractType(_node) { + // Ruby is dynamically typed — no type annotations at module level + return undefined; + }, + + extractVisibility(_node): VariableVisibility { + const left = _node.childForFieldName('left'); + if (!left) return 'public'; + // Constants (uppercase start) and global variables are effectively public + if (left.type === 'constant' || left.type === 'global_variable') return 'public'; + return 'private'; + }, + + isConst(node) { + const left = node.childForFieldName('left'); + return left?.type === 'constant'; + }, + + isStatic(_node) { + return false; + }, + + isMutable(node) { + const left = node.childForFieldName('left'); + // Constants are immutable by convention + return left?.type !== 'constant'; + }, +}; diff --git a/gitnexus/src/core/ingestion/variable-extractors/configs/rust.ts b/gitnexus/src/core/ingestion/variable-extractors/configs/rust.ts new file mode 100644 index 000000000..f296756a9 --- /dev/null +++ b/gitnexus/src/core/ingestion/variable-extractors/configs/rust.ts @@ -0,0 +1,82 @@ +// gitnexus/src/core/ingestion/variable-extractors/configs/rust.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { VariableExtractionConfig } from '../../variable-types.js'; +import type { VariableVisibility } from '../../variable-types.js'; +import { extractSimpleTypeName } from '../../type-extractors/shared.js'; +import type { SyntaxNode } from '../../utils/ast-helpers.js'; + +/** + * Rust variable extraction config. + * + * Rust has module-scoped const, static, and let declarations: + * - `const MAX_SIZE: usize = 100;` + * - `static COUNTER: AtomicUsize = AtomicUsize::new(0);` + * - `static mut BUFFER: Vec = Vec::new();` + * - `let x = 5;` (block-scoped, but included for completeness) + * + * tree-sitter-rust uses: + * - const_item → identifier, type + * - static_item → identifier, type + * - let_declaration → identifier, type + */ + +function hasVisibilityModifier(node: SyntaxNode): boolean { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'visibility_modifier') return true; + } + return false; +} + +function hasMutKeyword(node: SyntaxNode): boolean { + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child?.text === 'mut') return true; + } + return false; +} + +export const rustVariableConfig: VariableExtractionConfig = { + language: SupportedLanguages.Rust, + constNodeTypes: ['const_item'], + staticNodeTypes: ['static_item'], + variableNodeTypes: ['let_declaration'], + + extractName(node) { + const name = node.childForFieldName('name'); + if (name) return name.text; + // Fallback: first identifier child + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'identifier') return child.text; + } + return undefined; + }, + + extractType(node) { + const typeNode = node.childForFieldName('type'); + if (typeNode) return extractSimpleTypeName(typeNode) ?? typeNode.text?.trim(); + return undefined; + }, + + extractVisibility(node): VariableVisibility { + return hasVisibilityModifier(node) ? 'public' : 'private'; + }, + + isConst(node) { + return node.type === 'const_item'; + }, + + isStatic(node) { + return node.type === 'static_item'; + }, + + isMutable(node) { + if (node.type === 'const_item') return false; + if (node.type === 'static_item') return hasMutKeyword(node); + // let_declaration: check for mut keyword + if (node.type === 'let_declaration') return hasMutKeyword(node); + return true; + }, +}; diff --git a/gitnexus/src/core/ingestion/variable-extractors/configs/swift.ts b/gitnexus/src/core/ingestion/variable-extractors/configs/swift.ts new file mode 100644 index 000000000..018800670 --- /dev/null +++ b/gitnexus/src/core/ingestion/variable-extractors/configs/swift.ts @@ -0,0 +1,99 @@ +// gitnexus/src/core/ingestion/variable-extractors/configs/swift.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { VariableExtractionConfig } from '../../variable-types.js'; +import type { VariableVisibility } from '../../variable-types.js'; +import { extractSimpleTypeName } from '../../type-extractors/shared.js'; +import type { SyntaxNode } from '../../utils/ast-helpers.js'; + +/** + * Swift variable extraction config. + * + * Swift has top-level let/var declarations: + * - `let maxSize = 100` + * - `var counter = 0` + * - `public let apiKey: String = "..."` + * + * tree-sitter-swift uses: + * - property_declaration for both class and top-level declarations + */ + +function extractSwiftVarName(node: SyntaxNode): string | undefined { + // property_declaration → pattern → ... → simple_identifier / identifier + const pattern = node.namedChildren.find((c: SyntaxNode) => c.type === 'pattern'); + if (pattern) { + const ident = pattern.namedChildren.find( + (c: SyntaxNode) => c.type === 'simple_identifier' || c.type === 'identifier', + ); + if (ident) return ident.text; + } + // Fallback: look for simple_identifier directly + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'simple_identifier') return child.text; + } + return undefined; +} + +function extractSwiftVarType(node: SyntaxNode): string | undefined { + // Look for type_annotation child + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'type_annotation') { + const typeNode = child.firstNamedChild; + if (typeNode) return extractSimpleTypeName(typeNode) ?? typeNode.text?.trim(); + } + } + return undefined; +} + +function hasSwiftKeyword(node: SyntaxNode, keyword: string): boolean { + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child?.text === keyword) return true; + } + return false; +} + +const SWIFT_VISIBILITY = new Set(['public', 'private', 'internal', 'fileprivate', 'open']); + +export const swiftVariableConfig: VariableExtractionConfig = { + language: SupportedLanguages.Swift, + constNodeTypes: [], + staticNodeTypes: [], + variableNodeTypes: ['property_declaration'], + + extractName: extractSwiftVarName, + extractType: extractSwiftVarType, + + extractVisibility(node): VariableVisibility { + // Check modifiers for visibility keywords + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'modifiers') { + for (let j = 0; j < child.childCount; j++) { + const mod = child.child(j); + if (mod && SWIFT_VISIBILITY.has(mod.text)) return mod.text as VariableVisibility; + } + } + } + // Direct keyword check + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child && SWIFT_VISIBILITY.has(child.text)) return child.text as VariableVisibility; + } + return 'internal'; // Swift default visibility + }, + + isConst(node) { + return hasSwiftKeyword(node, 'let'); + }, + + isStatic(node) { + return hasSwiftKeyword(node, 'static') || hasSwiftKeyword(node, 'class'); + }, + + isMutable(node) { + return hasSwiftKeyword(node, 'var'); + }, +}; diff --git a/gitnexus/src/core/ingestion/variable-extractors/configs/typescript-javascript.ts b/gitnexus/src/core/ingestion/variable-extractors/configs/typescript-javascript.ts new file mode 100644 index 000000000..0477899a2 --- /dev/null +++ b/gitnexus/src/core/ingestion/variable-extractors/configs/typescript-javascript.ts @@ -0,0 +1,94 @@ +// gitnexus/src/core/ingestion/variable-extractors/configs/typescript-javascript.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { VariableExtractionConfig } from '../../variable-types.js'; +import type { VariableVisibility } from '../../variable-types.js'; +import { hasKeyword, typeFromAnnotation } from '../../field-extractors/configs/helpers.js'; +import type { SyntaxNode } from '../../utils/ast-helpers.js'; + +/** + * TypeScript/JavaScript variable extraction config. + * + * Handles module-scoped const/let/var declarations: + * - `export const X = ...` → public, const + * - `const X = ...` → private, const + * - `let x = ...` → private, mutable + * - `var x = ...` → private, mutable + * + * tree-sitter node structure: + * lexical_declaration (const/let) → variable_declarator → identifier (name) + * variable_declaration (var) → variable_declarator → identifier (name) + */ + +function extractNameFromDecl(node: SyntaxNode): string | undefined { + // lexical_declaration / variable_declaration → variable_declarator → name + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'variable_declarator') { + const name = child.childForFieldName('name'); + if (name?.type === 'identifier') return name.text; + } + } + return undefined; +} + +function extractTypeFromDecl(node: SyntaxNode): string | undefined { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'variable_declarator') { + return typeFromAnnotation(child); + } + } + return undefined; +} + +function extractVisFromDecl(node: SyntaxNode): VariableVisibility { + // Check parent for export_statement wrapper + const parent = node.parent; + if (parent?.type === 'export_statement') return 'public'; + // Check for 'export' keyword as direct child + if (hasKeyword(node, 'export')) return 'public'; + return 'private'; +} + +const shared: Omit = { + constNodeTypes: ['lexical_declaration'], + staticNodeTypes: [], + variableNodeTypes: ['variable_declaration'], + + extractName: extractNameFromDecl, + extractType: extractTypeFromDecl, + extractVisibility: extractVisFromDecl, + + isConst(node) { + // lexical_declaration with 'const' keyword + if (node.type === 'lexical_declaration') { + return hasKeyword(node, 'const'); + } + return false; + }, + + isStatic(_node) { + // JS/TS module-level variables are not static in the class sense + return false; + }, + + isMutable(node) { + // var or let declarations are mutable; const is not + if (node.type === 'variable_declaration') return true; + if (node.type === 'lexical_declaration') { + return hasKeyword(node, 'let'); + } + return false; + }, +}; + +export const typescriptVariableConfig: VariableExtractionConfig = { + ...shared, + language: SupportedLanguages.TypeScript, +}; + +export const javascriptVariableConfig: VariableExtractionConfig = { + ...shared, + language: SupportedLanguages.JavaScript, +}; diff --git a/gitnexus/src/core/ingestion/variable-extractors/generic.ts b/gitnexus/src/core/ingestion/variable-extractors/generic.ts new file mode 100644 index 000000000..1128805bd --- /dev/null +++ b/gitnexus/src/core/ingestion/variable-extractors/generic.ts @@ -0,0 +1,108 @@ +// gitnexus/src/core/ingestion/variable-extractors/generic.ts + +/** + * Generic table-driven variable extractor factory. + * + * Follows the same config+factory pattern as field-extractors/generic.ts. + * Define a VariableExtractionConfig per language and generate extractors + * from configs. The factory converts node type arrays to Sets at construction + * time for O(1) lookups. + */ + +import type { SyntaxNode } from '../utils/ast-helpers.js'; +import type { + VariableExtractionConfig, + VariableExtractor, + VariableExtractorContext, + VariableInfo, + VariableScope, +} from '../variable-types.js'; + +/** + * Create a VariableExtractor from a declarative config. + */ +export function createVariableExtractor(config: VariableExtractionConfig): VariableExtractor { + const staticNodeSet = new Set(config.staticNodeTypes); + // Combined set for fast isVariableDeclaration checks + const allNodeTypes = new Set([ + ...config.constNodeTypes, + ...config.staticNodeTypes, + ...config.variableNodeTypes, + ]); + + function determineScope(node: SyntaxNode): VariableScope { + // Walk up to determine scope: + // - 'module': node is inside a top-level program/module/source_file container + // - 'block': node is inside a function, method, or block scope + // - 'file': fallback when no recognizable container is found (e.g., standalone snippets) + let current = node.parent; + while (current) { + const t = current.type; + // Top-level program/module nodes indicate module/file scope + if ( + t === 'program' || + t === 'source_file' || + t === 'module' || + t === 'translation_unit' || + t === 'compilation_unit' + ) { + return 'module'; + } + // Function/method/block boundaries indicate block scope + if ( + t === 'function_declaration' || + t === 'function_definition' || + t === 'function_item' || + t === 'method_declaration' || + t === 'method_definition' || + t === 'arrow_function' || + t === 'function_expression' || + t === 'lambda' || + t === 'block' || + t === 'function_body' || + t === 'compound_statement' + ) { + return 'block'; + } + current = current.parent; + } + return 'file'; + } + + return { + language: config.language, + + isVariableDeclaration(node: SyntaxNode): boolean { + return allNodeTypes.has(node.type); + }, + + extract(node: SyntaxNode, context: VariableExtractorContext): VariableInfo | null { + if (!allNodeTypes.has(node.type)) return null; + + const name = config.extractName(node); + if (!name) return null; + + const type = config.extractType(node) ?? null; + const visibility = config.extractVisibility(node); + // isConst/isStatic: node type membership is a hint, but config.isConst/isStatic + // has final say. For languages where const and non-const share a node type + // (e.g., TS lexical_declaration for both const and let), config.isConst disambiguates. + const isConst = config.isConst(node); + const isStatic = staticNodeSet.has(node.type) || config.isStatic(node); + const isMutable = config.isMutable(node); + const scope = determineScope(node); + + return { + name, + type, + visibility, + isConst, + isStatic, + isMutable, + scope, + sourceFile: context.filePath, + line: node.startPosition.row + 1, + }; + }, + }; +} diff --git a/gitnexus/src/core/ingestion/variable-types.ts b/gitnexus/src/core/ingestion/variable-types.ts new file mode 100644 index 000000000..a2006080b --- /dev/null +++ b/gitnexus/src/core/ingestion/variable-types.ts @@ -0,0 +1,91 @@ +// gitnexus/src/core/ingestion/variable-types.ts + +import type { SupportedLanguages } from 'gitnexus-shared'; +import type { FieldVisibility } from './field-types.js'; +import type { SyntaxNode } from './utils/ast-helpers.js'; + +// Reuse FieldVisibility — same set of language visibility levels +export type VariableVisibility = FieldVisibility; + +/** + * Scope level for a variable declaration. + * - 'module': module/package scope (TypeScript `export const`, Go package-level) + * - 'file': file scope (C/C++ static file-scope, Python module-level) + * - 'block': block-scoped (JS `let`/`const` inside a function) + */ +export type VariableScope = 'module' | 'file' | 'block'; + +/** + * Represents a module/file-scoped variable, constant, or static declaration. + */ +export interface VariableInfo { + /** Variable name */ + name: string; + /** Declared type annotation (may be null if untyped) */ + type: string | null; + /** Visibility modifier */ + visibility: VariableVisibility; + /** Is this a constant (const, val, final)? */ + isConst: boolean; + /** Is this a static declaration? */ + isStatic: boolean; + /** Is this mutable (let, var vs const, val)? */ + isMutable: boolean; + /** Scope of the declaration */ + scope: VariableScope; + /** Source file path */ + sourceFile: string; + /** Line number (1-based) */ + line: number; +} + +/** + * Context for variable extraction. + */ +export interface VariableExtractorContext { + /** Current file path */ + filePath: string; + /** Language ID */ + language: SupportedLanguages; +} + +/** + * Variable extractor interface — extracts structured metadata from + * module/file-scoped variable, constant, and static declarations. + */ +export interface VariableExtractor { + /** Language this extractor handles */ + language: SupportedLanguages; + /** Extract variable metadata from a declaration node. + * Returns null if the node is not a recognized variable declaration. */ + extract(node: SyntaxNode, context: VariableExtractorContext): VariableInfo | null; + /** Check if a node is a recognized variable declaration type. */ + isVariableDeclaration(node: SyntaxNode): boolean; +} + +/** + * Declarative config for building a variable extractor via the factory. + * Follows the same pattern as FieldExtractionConfig and MethodExtractionConfig. + */ +export interface VariableExtractionConfig { + /** Language this config applies to */ + language: SupportedLanguages; + /** AST node types for const declarations (e.g., 'const_item', 'lexical_declaration') */ + constNodeTypes: string[]; + /** AST node types for static declarations (e.g., 'static_item') */ + staticNodeTypes: string[]; + /** AST node types for variable declarations (e.g., 'variable_declaration') */ + variableNodeTypes: string[]; + /** Extract the variable name from a declaration node */ + extractName: (node: SyntaxNode) => string | undefined; + /** Extract type annotation from a declaration node */ + extractType: (node: SyntaxNode) => string | undefined; + /** Extract visibility from a declaration node */ + extractVisibility: (node: SyntaxNode) => VariableVisibility; + /** Check if a declaration is const/immutable */ + isConst: (node: SyntaxNode) => boolean; + /** Check if a declaration is static */ + isStatic: (node: SyntaxNode) => boolean; + /** Check if a declaration is mutable */ + isMutable: (node: SyntaxNode) => boolean; +} diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index dc5f6a62d..130f489ac 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -68,6 +68,7 @@ import type { NamedBinding } from '../named-bindings/types.js'; import type { NodeLabel } from 'gitnexus-shared'; import type { FieldInfo, FieldExtractorContext } from '../field-types.js'; import type { MethodInfo, MethodExtractorContext } from '../method-types.js'; +import type { VariableExtractorContext } from '../variable-types.js'; import { buildMethodProps, arityForIdFromInfo, @@ -1462,6 +1463,11 @@ const processFileGroup = ( // Per-file map: decorator end-line → decorator info, for associating with definitions const fileDecorators = new Map(); + // Track start indices of definition nodes already processed by higher-priority captures + // (e.g. @definition.function) to avoid duplicate nodes when @definition.const/@definition.variable + // patterns overlap with the same source range. + const processedDefinitionNodes = new Set(); + for (const match of matches) { const captureMap: Record = {}; for (const c of match.captures) { @@ -1929,6 +1935,21 @@ const processFileGroup = ( }) : null; const nodeLabel = extractedClassSymbol?.type ?? defaultNodeLabel; + + // Dedup: variable captures (Const/Static/Variable) may overlap with higher-priority + // captures (e.g. `const fn = () => {}` matches both @definition.function and @definition.const). + // Skip variable captures whose definition node was already processed. + if ( + (nodeLabel === 'Const' || nodeLabel === 'Static' || nodeLabel === 'Variable') && + definitionNode && + processedDefinitionNodes.has(definitionNode.startIndex) + ) { + continue; + } + if (definitionNode) { + processedDefinitionNodes.add(definitionNode.startIndex); + } + // Synthesize name for constructors without explicit @name capture (e.g. Swift init) if (!nameNode && nodeLabel !== 'Constructor' && !extractedClassSymbol) continue; const nodeName = extractedClassSymbol?.name ?? (nameNode ? nameNode.text : 'init'); @@ -2111,6 +2132,27 @@ const processFileGroup = ( } } + // Variable/Const/Static metadata extraction via VariableExtractor + if ( + (nodeLabel === 'Const' || nodeLabel === 'Static' || nodeLabel === 'Variable') && + definitionNode && + provider.variableExtractor + ) { + const varCtx: VariableExtractorContext = { + filePath: file.path, + language, + }; + const varInfo = provider.variableExtractor.extract(definitionNode, varCtx); + if (varInfo) { + if (varInfo.type) declaredType = varInfo.type; + methodProps.visibility = varInfo.visibility; + methodProps.isStatic = varInfo.isStatic; + methodProps.isConst = varInfo.isConst; + methodProps.isMutable = varInfo.isMutable; + methodProps.scope = varInfo.scope; + } + } + result.nodes.push({ id: nodeId, label: nodeLabel, diff --git a/gitnexus/src/core/lbug/csv-generator.ts b/gitnexus/src/core/lbug/csv-generator.ts index 63a1bb947..616a00cac 100644 --- a/gitnexus/src/core/lbug/csv-generator.ts +++ b/gitnexus/src/core/lbug/csv-generator.ts @@ -292,6 +292,7 @@ export const streamAllCSVsToDisk = async ( 'TypeAlias', 'Const', 'Static', + 'Variable', 'Property', 'Record', 'Delegate', diff --git a/gitnexus/src/core/lbug/schema.ts b/gitnexus/src/core/lbug/schema.ts index c0ba10d4d..297fe44ce 100644 --- a/gitnexus/src/core/lbug/schema.ts +++ b/gitnexus/src/core/lbug/schema.ts @@ -166,6 +166,7 @@ export const IMPL_SCHEMA = CODE_ELEMENT_BASE('Impl'); export const TYPE_ALIAS_SCHEMA = CODE_ELEMENT_BASE('TypeAlias'); export const CONST_SCHEMA = CODE_ELEMENT_BASE('Const'); export const STATIC_SCHEMA = CODE_ELEMENT_BASE('Static'); +export const VARIABLE_SCHEMA = CODE_ELEMENT_BASE('Variable'); export const PROPERTY_SCHEMA = CODE_ELEMENT_BASE('Property'); export const RECORD_SCHEMA = CODE_ELEMENT_BASE('Record'); export const DELEGATE_SCHEMA = CODE_ELEMENT_BASE('Delegate'); @@ -234,6 +235,7 @@ CREATE REL TABLE ${REL_TABLE_NAME} ( FROM File TO \`TypeAlias\`, FROM File TO \`Const\`, FROM File TO \`Static\`, + FROM File TO \`Variable\`, FROM File TO \`Property\`, FROM File TO \`Record\`, FROM File TO \`Delegate\`, @@ -365,6 +367,7 @@ CREATE REL TABLE ${REL_TABLE_NAME} ( FROM \`TypeAlias\` TO Class, FROM \`Const\` TO Community, FROM \`Static\` TO Community, + FROM \`Variable\` TO Community, FROM \`Property\` TO Community, FROM \`Record\` TO Method, FROM \`Record\` TO \`Constructor\`, @@ -408,6 +411,7 @@ CREATE REL TABLE ${REL_TABLE_NAME} ( FROM \`Trait\` TO Process, FROM \`Const\` TO Process, FROM \`Static\` TO Process, + FROM \`Variable\` TO Process, FROM \`Property\` TO Process, FROM \`Record\` TO Process, FROM \`Delegate\` TO Process, @@ -488,6 +492,7 @@ export const NODE_SCHEMA_QUERIES = [ TYPE_ALIAS_SCHEMA, CONST_SCHEMA, STATIC_SCHEMA, + VARIABLE_SCHEMA, PROPERTY_SCHEMA, RECORD_SCHEMA, DELEGATE_SCHEMA, diff --git a/gitnexus/test/fixtures/pipeline-golden/mini-repo/expected-graph.json b/gitnexus/test/fixtures/pipeline-golden/mini-repo/expected-graph.json index 7c9818696..0d66fd3a7 100644 --- a/gitnexus/test/fixtures/pipeline-golden/mini-repo/expected-graph.json +++ b/gitnexus/test/fixtures/pipeline-golden/mini-repo/expected-graph.json @@ -1,29 +1,29 @@ { "capture": "initial capture (U8, post-U1–U7)", "fixture": "mini-repo", - "totalFileCount": 9, - "symbols": 57, - "relationships": 92, + "totalFileCount": 7, + "symbols": 37, + "relationships": 74, "processes": 4, "byType": { "Class": 1, "Community": 4, - "File": 9, + "Const": 4, + "File": 7, "Folder": 1, "Function": 12, "Interface": 3, "Method": 1, - "Process": 4, - "Section": 22 + "Process": 4 }, "byRelType": { "CALLS": 9, - "CONTAINS": 29, - "DEFINES": 17, + "CONTAINS": 7, + "DEFINES": 21, "HAS_METHOD": 1, "IMPORTS": 12, "MEMBER_OF": 12, "STEP_IN_PROCESS": 12 }, - "edgeDigest": "fdf012d4b4197377ab43e27217f9fe08ec46352945140f8b3406dacc3294b715" + "edgeDigest": "a418debec537cf959fe56fd1fbbbfb59a640398cdb3c61ce0bcb8056c1f45110" } diff --git a/gitnexus/test/integration/cli-e2e.test.ts b/gitnexus/test/integration/cli-e2e.test.ts index 678d8f271..7ee6f7a4e 100644 --- a/gitnexus/test/integration/cli-e2e.test.ts +++ b/gitnexus/test/integration/cli-e2e.test.ts @@ -52,9 +52,11 @@ beforeAll(() => { }); afterAll(() => { - // Clean up .git/ and .gitnexus/ directories created during the test - for (const dir of ['.git', '.gitnexus']) { - const fullPath = path.join(MINI_REPO, dir); + // Clean up all files/dirs created by analyze (git init, .gitnexus output, + // AI context files, skill files, .gitignore) so parallel tests like + // pipeline-graph-golden see a pristine fixture. + for (const entry of ['.git', '.gitnexus', '.claude', 'AGENTS.md', 'CLAUDE.md', '.gitignore']) { + const fullPath = path.join(MINI_REPO, entry); if (fs.existsSync(fullPath)) { fs.rmSync(fullPath, { recursive: true, force: true }); } diff --git a/gitnexus/test/integration/pipeline-graph-golden.test.ts b/gitnexus/test/integration/pipeline-graph-golden.test.ts index f008bdadb..1a8e9d238 100644 --- a/gitnexus/test/integration/pipeline-graph-golden.test.ts +++ b/gitnexus/test/integration/pipeline-graph-golden.test.ts @@ -18,14 +18,15 @@ * * Nothing path-dependent, time-dependent, or id-opaque leaks into the snapshot. */ -import { describe, it, expect, beforeAll } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import path from 'path'; import fs from 'fs'; +import os from 'os'; import crypto from 'crypto'; import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; import type { PipelineResult } from '../../src/types/pipeline.js'; -const MINI_REPO = path.resolve(__dirname, '..', 'fixtures', 'mini-repo'); +const FIXTURE_SRC = path.resolve(__dirname, '..', 'fixtures', 'mini-repo'); const GOLDEN_DIR = path.resolve(__dirname, '..', 'fixtures', 'pipeline-golden', 'mini-repo'); const GOLDEN_FILE = path.join(GOLDEN_DIR, 'expected-graph.json'); @@ -128,12 +129,23 @@ function diffCounts( describe('pipeline graph golden', () => { let result: PipelineResult; let snapshot: GraphSnapshot; + let tmpDir: string; beforeAll(async () => { - result = await runPipelineFromRepo(MINI_REPO, () => {}); + // Copy the fixture to a temp directory so parallel tests (cli-e2e) + // that create AGENTS.md / CLAUDE.md / .claude/ in the shared fixture + // don't pollute the golden snapshot. + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-golden-')); + fs.cpSync(FIXTURE_SRC, tmpDir, { recursive: true }); + + result = await runPipelineFromRepo(tmpDir, () => {}); snapshot = buildSnapshot(result); }, 60000); + afterAll(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + it('matches committed golden snapshot on mini-repo', () => { if (UPDATE || !fs.existsSync(GOLDEN_FILE)) { fs.mkdirSync(GOLDEN_DIR, { recursive: true }); diff --git a/gitnexus/test/unit/schema.test.ts b/gitnexus/test/unit/schema.test.ts index 78f0b6ccb..cab80984a 100644 --- a/gitnexus/test/unit/schema.test.ts +++ b/gitnexus/test/unit/schema.test.ts @@ -53,6 +53,7 @@ describe('LadybugDB Schema', () => { 'TypeAlias', 'Const', 'Static', + 'Variable', 'Property', 'Record', 'Delegate', @@ -67,8 +68,8 @@ describe('LadybugDB Schema', () => { }); it('has expected total count', () => { - // 9 core + 18 multi-language + Route + Tool = 30 - expect(NODE_TABLES).toHaveLength(30); + // 9 core + 19 multi-language + Route + Tool = 31 + expect(NODE_TABLES).toHaveLength(31); }); }); @@ -201,7 +202,7 @@ describe('LadybugDB Schema', () => { describe('schema query ordering', () => { it('NODE_SCHEMA_QUERIES has correct count', () => { - expect(NODE_SCHEMA_QUERIES).toHaveLength(30); + expect(NODE_SCHEMA_QUERIES).toHaveLength(31); }); it('REL_SCHEMA_QUERIES has one relation table', () => { @@ -209,8 +210,8 @@ describe('LadybugDB Schema', () => { }); it('SCHEMA_QUERIES includes all node + rel + embedding schemas', () => { - // 30 node + 1 rel + 1 embedding = 32 - expect(SCHEMA_QUERIES).toHaveLength(32); + // 31 node + 1 rel + 1 embedding = 33 + expect(SCHEMA_QUERIES).toHaveLength(33); }); it('node schemas come before relation schemas in SCHEMA_QUERIES', () => { diff --git a/gitnexus/test/unit/tree-sitter-queries.test.ts b/gitnexus/test/unit/tree-sitter-queries.test.ts index ffb207bdd..64212c25c 100644 --- a/gitnexus/test/unit/tree-sitter-queries.test.ts +++ b/gitnexus/test/unit/tree-sitter-queries.test.ts @@ -10,6 +10,7 @@ import { CSHARP_QUERIES, RUST_QUERIES, PHP_QUERIES, + RUBY_QUERIES, SWIFT_QUERIES, DART_QUERIES, } from '../../src/core/ingestion/tree-sitter-queries.js'; @@ -356,4 +357,77 @@ describe('tree-sitter queries', () => { expect(DART_QUERIES).toContain('function_expression_body'); }); }); + + // --------------------------------------------------------------------------- + // Variable/constant declaration capture tests + // --------------------------------------------------------------------------- + + describe('Variable/constant declaration captures', () => { + it('TypeScript captures const/let as @definition.const', () => { + expect(TYPESCRIPT_QUERIES).toContain('@definition.const'); + expect(TYPESCRIPT_QUERIES).toContain('lexical_declaration'); + }); + + it('TypeScript captures var as @definition.variable', () => { + expect(TYPESCRIPT_QUERIES).toContain('@definition.variable'); + expect(TYPESCRIPT_QUERIES).toContain('variable_declaration'); + }); + + it('JavaScript captures const/let as @definition.const', () => { + expect(JAVASCRIPT_QUERIES).toContain('@definition.const'); + }); + + it('JavaScript captures var as @definition.variable', () => { + expect(JAVASCRIPT_QUERIES).toContain('@definition.variable'); + }); + + it('Python captures plain assignments as @definition.variable', () => { + expect(PYTHON_QUERIES).toContain('@definition.variable'); + }); + + it('Go captures const_declaration and var_declaration', () => { + expect(GO_QUERIES).toContain('@definition.const'); + expect(GO_QUERIES).toContain('@definition.variable'); + expect(GO_QUERIES).toContain('short_var_declaration'); + }); + + it('Java captures local_variable_declaration', () => { + expect(JAVA_QUERIES).toContain('local_variable_declaration'); + expect(JAVA_QUERIES).toContain('@definition.variable'); + }); + + it('C captures init_declarator as @definition.variable', () => { + expect(C_QUERIES).toContain('init_declarator'); + expect(C_QUERIES).toContain('@definition.variable'); + }); + + it('C++ captures init_declarator as @definition.variable', () => { + expect(CPP_QUERIES).toContain('init_declarator'); + expect(CPP_QUERIES).toContain('@definition.variable'); + }); + + it('C# captures local_declaration_statement', () => { + expect(CSHARP_QUERIES).toContain('local_declaration_statement'); + expect(CSHARP_QUERIES).toContain('@definition.variable'); + }); + + it('Rust retains const_item and static_item captures', () => { + expect(RUST_QUERIES).toContain('@definition.const'); + expect(RUST_QUERIES).toContain('@definition.static'); + }); + + it('PHP captures const_declaration', () => { + expect(PHP_QUERIES).toContain('const_declaration'); + expect(PHP_QUERIES).toContain('@definition.const'); + }); + + it('Ruby captures constant assignments', () => { + expect(RUBY_QUERIES).toContain('@definition.const'); + }); + + it('Dart captures declaration as @definition.variable', () => { + expect(DART_QUERIES).toContain('(declaration'); + expect(DART_QUERIES).toContain('@definition.variable'); + }); + }); }); diff --git a/gitnexus/test/unit/variable-extraction.test.ts b/gitnexus/test/unit/variable-extraction.test.ts new file mode 100644 index 000000000..d40993e2c --- /dev/null +++ b/gitnexus/test/unit/variable-extraction.test.ts @@ -0,0 +1,633 @@ +import { describe, it, expect } from 'vitest'; +import { createVariableExtractor } from '../../src/core/ingestion/variable-extractors/generic.js'; +import { + typescriptVariableConfig, + javascriptVariableConfig, +} from '../../src/core/ingestion/variable-extractors/configs/typescript-javascript.js'; +import { pythonVariableConfig } from '../../src/core/ingestion/variable-extractors/configs/python.js'; +import { goVariableConfig } from '../../src/core/ingestion/variable-extractors/configs/go.js'; +import { rustVariableConfig } from '../../src/core/ingestion/variable-extractors/configs/rust.js'; +import { + cVariableConfig, + cppVariableConfig, +} from '../../src/core/ingestion/variable-extractors/configs/c-cpp.js'; +import { rubyVariableConfig } from '../../src/core/ingestion/variable-extractors/configs/ruby.js'; +import type { VariableExtractorContext } from '../../src/core/ingestion/variable-types.js'; +import { SupportedLanguages } from '../../src/config/supported-languages.js'; +import Parser from 'tree-sitter'; +import TypeScript from 'tree-sitter-typescript'; +import Python from 'tree-sitter-python'; +import Go from 'tree-sitter-go'; +import Rust from 'tree-sitter-rust'; +import Cpp from 'tree-sitter-cpp'; +import C from 'tree-sitter-c'; +import Ruby from 'tree-sitter-ruby'; + +const parser = new Parser(); + +// --------------------------------------------------------------------------- +// TypeScript config +// --------------------------------------------------------------------------- + +describe('VariableExtractor — TypeScript', () => { + const extractor = createVariableExtractor(typescriptVariableConfig); + const ctx: VariableExtractorContext = { + filePath: 'test.ts', + language: SupportedLanguages.TypeScript, + }; + + it('extracts const declaration', () => { + parser.setLanguage(TypeScript.typescript); + const tree = parser.parse('const MAX_SIZE = 100;'); + const node = tree.rootNode.child(0)!; + expect(extractor.isVariableDeclaration(node)).toBe(true); + + const info = extractor.extract(node, ctx); + expect(info).not.toBeNull(); + expect(info!.name).toBe('MAX_SIZE'); + expect(info!.isConst).toBe(true); + expect(info!.isMutable).toBe(false); + expect(info!.visibility).toBe('private'); + }); + + it('extracts let declaration as mutable', () => { + parser.setLanguage(TypeScript.typescript); + const tree = parser.parse('let counter = 0;'); + const node = tree.rootNode.child(0)!; + expect(extractor.isVariableDeclaration(node)).toBe(true); + + const info = extractor.extract(node, ctx); + expect(info).not.toBeNull(); + expect(info!.name).toBe('counter'); + expect(info!.isConst).toBe(false); + expect(info!.isMutable).toBe(true); + }); + + it('extracts typed const declaration', () => { + parser.setLanguage(TypeScript.typescript); + const tree = parser.parse('const name: string = "hello";'); + const node = tree.rootNode.child(0)!; + + const info = extractor.extract(node, ctx); + expect(info).not.toBeNull(); + expect(info!.name).toBe('name'); + expect(info!.type).toBe('string'); + expect(info!.isConst).toBe(true); + }); + + it('detects export as public visibility', () => { + parser.setLanguage(TypeScript.typescript); + const tree = parser.parse('export const API_KEY = "abc";'); + // export_statement wraps lexical_declaration + const exportStatement = tree.rootNode.child(0)!; + // The lexical_declaration is the child of export_statement + const declNode = exportStatement.namedChildren.find((c) => c.type === 'lexical_declaration'); + expect(declNode).toBeDefined(); + const info = extractor.extract(declNode!, ctx); + expect(info).not.toBeNull(); + expect(info!.name).toBe('API_KEY'); + expect(info!.visibility).toBe('public'); + }); + + it('rejects non-variable nodes', () => { + parser.setLanguage(TypeScript.typescript); + const tree = parser.parse('function foo() {}'); + const node = tree.rootNode.child(0)!; + expect(extractor.isVariableDeclaration(node)).toBe(false); + expect(extractor.extract(node, ctx)).toBeNull(); + }); + + it('extracts var declaration as mutable variable', () => { + parser.setLanguage(TypeScript.typescript); + const tree = parser.parse('var x = 5;'); + const node = tree.rootNode.child(0)!; + + const info = extractor.extract(node, ctx); + expect(info).not.toBeNull(); + expect(info!.name).toBe('x'); + expect(info!.isMutable).toBe(true); + expect(info!.isConst).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// JavaScript config +// --------------------------------------------------------------------------- + +describe('VariableExtractor — JavaScript', () => { + const extractor = createVariableExtractor(javascriptVariableConfig); + const ctx: VariableExtractorContext = { + filePath: 'test.js', + language: SupportedLanguages.JavaScript, + }; + + it('extracts const declaration', () => { + parser.setLanguage(TypeScript.typescript); // JS subset of TS grammar + const tree = parser.parse('const PORT = 3000;'); + const node = tree.rootNode.child(0)!; + + const info = extractor.extract(node, ctx); + expect(info).not.toBeNull(); + expect(info!.name).toBe('PORT'); + expect(info!.isConst).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Python config +// --------------------------------------------------------------------------- + +describe('VariableExtractor — Python', () => { + const extractor = createVariableExtractor(pythonVariableConfig); + const ctx: VariableExtractorContext = { + filePath: 'test.py', + language: SupportedLanguages.Python, + }; + + it('extracts UPPER_CASE constant', () => { + parser.setLanguage(Python); + const tree = parser.parse('MAX_SIZE = 100'); + const node = tree.rootNode.child(0)!; + expect(extractor.isVariableDeclaration(node)).toBe(true); + + const info = extractor.extract(node, ctx); + expect(info).not.toBeNull(); + expect(info!.name).toBe('MAX_SIZE'); + expect(info!.isConst).toBe(true); + expect(info!.isMutable).toBe(false); + expect(info!.visibility).toBe('public'); + }); + + it('extracts regular assignment as mutable', () => { + parser.setLanguage(Python); + const tree = parser.parse('counter = 0'); + const node = tree.rootNode.child(0)!; + + const info = extractor.extract(node, ctx); + expect(info).not.toBeNull(); + expect(info!.name).toBe('counter'); + expect(info!.isConst).toBe(false); + expect(info!.isMutable).toBe(true); + }); + + it('extracts annotated assignment with type', () => { + parser.setLanguage(Python); + const tree = parser.parse('name: str = "hello"'); + const node = tree.rootNode.child(0)!; + + const info = extractor.extract(node, ctx); + expect(info).not.toBeNull(); + expect(info!.name).toBe('name'); + expect(info!.type).toBe('str'); + }); + + it('detects underscore prefix as protected', () => { + parser.setLanguage(Python); + const tree = parser.parse('_internal = 42'); + const node = tree.rootNode.child(0)!; + + const info = extractor.extract(node, ctx); + expect(info).not.toBeNull(); + expect(info!.visibility).toBe('protected'); + }); + + it('detects double underscore prefix as private', () => { + parser.setLanguage(Python); + const tree = parser.parse('__secret = 42'); + const node = tree.rootNode.child(0)!; + + const info = extractor.extract(node, ctx); + expect(info).not.toBeNull(); + expect(info!.visibility).toBe('private'); + }); + + it('does not treat dunder names as private', () => { + parser.setLanguage(Python); + const tree = parser.parse('__name__ = "main"'); + const node = tree.rootNode.child(0)!; + + const info = extractor.extract(node, ctx); + expect(info).not.toBeNull(); + expect(info!.visibility).toBe('public'); + }); +}); + +// --------------------------------------------------------------------------- +// Go config +// --------------------------------------------------------------------------- + +describe('VariableExtractor — Go', () => { + const extractor = createVariableExtractor(goVariableConfig); + const ctx: VariableExtractorContext = { + filePath: 'test.go', + language: SupportedLanguages.Go, + }; + + it('extracts const declaration', () => { + parser.setLanguage(Go); + const tree = parser.parse('package main\nconst MaxSize = 100'); + // Find const_declaration + let constNode = null; + for (let i = 0; i < tree.rootNode.namedChildCount; i++) { + const child = tree.rootNode.namedChild(i); + if (child?.type === 'const_declaration') { + constNode = child; + break; + } + } + expect(constNode).not.toBeNull(); + expect(extractor.isVariableDeclaration(constNode!)).toBe(true); + + const info = extractor.extract(constNode!, ctx); + expect(info).not.toBeNull(); + expect(info!.name).toBe('MaxSize'); + expect(info!.isConst).toBe(true); + expect(info!.isMutable).toBe(false); + expect(info!.visibility).toBe('public'); // uppercase = exported + }); + + it('extracts var declaration', () => { + parser.setLanguage(Go); + const tree = parser.parse('package main\nvar counter int = 0'); + let varNode = null; + for (let i = 0; i < tree.rootNode.namedChildCount; i++) { + const child = tree.rootNode.namedChild(i); + if (child?.type === 'var_declaration') { + varNode = child; + break; + } + } + expect(varNode).not.toBeNull(); + + const info = extractor.extract(varNode!, ctx); + expect(info).not.toBeNull(); + expect(info!.name).toBe('counter'); + expect(info!.isConst).toBe(false); + expect(info!.isMutable).toBe(true); + expect(info!.type).toBe('int'); + }); + + it('detects lowercase as package-private', () => { + parser.setLanguage(Go); + const tree = parser.parse('package main\nconst maxSize = 100'); + let constNode = null; + for (let i = 0; i < tree.rootNode.namedChildCount; i++) { + const child = tree.rootNode.namedChild(i); + if (child?.type === 'const_declaration') { + constNode = child; + break; + } + } + expect(constNode).not.toBeNull(); + + const info = extractor.extract(constNode!, ctx); + expect(info).not.toBeNull(); + expect(info!.visibility).toBe('package'); + }); +}); + +// --------------------------------------------------------------------------- +// Rust config +// --------------------------------------------------------------------------- + +describe('VariableExtractor — Rust', () => { + const extractor = createVariableExtractor(rustVariableConfig); + const ctx: VariableExtractorContext = { + filePath: 'test.rs', + language: SupportedLanguages.Rust, + }; + + it('extracts const_item', () => { + parser.setLanguage(Rust); + const tree = parser.parse('const MAX_SIZE: usize = 100;'); + const node = tree.rootNode.child(0)!; + expect(extractor.isVariableDeclaration(node)).toBe(true); + + const info = extractor.extract(node, ctx); + expect(info).not.toBeNull(); + expect(info!.name).toBe('MAX_SIZE'); + expect(info!.isConst).toBe(true); + expect(info!.isStatic).toBe(false); + expect(info!.isMutable).toBe(false); + expect(info!.type).toBe('usize'); + expect(info!.visibility).toBe('private'); + }); + + it('extracts static_item', () => { + parser.setLanguage(Rust); + const tree = parser.parse('static COUNTER: i32 = 0;'); + const node = tree.rootNode.child(0)!; + + const info = extractor.extract(node, ctx); + expect(info).not.toBeNull(); + expect(info!.name).toBe('COUNTER'); + expect(info!.isStatic).toBe(true); + expect(info!.isConst).toBe(false); + expect(info!.isMutable).toBe(false); + }); + + it('extracts pub const as public', () => { + parser.setLanguage(Rust); + const tree = parser.parse('pub const API_VERSION: &str = "v1";'); + const node = tree.rootNode.child(0)!; + + const info = extractor.extract(node, ctx); + expect(info).not.toBeNull(); + expect(info!.visibility).toBe('public'); + expect(info!.isConst).toBe(true); + }); + + it('extracts static mut as mutable', () => { + parser.setLanguage(Rust); + const tree = parser.parse('static mut BUFFER: Vec = Vec::new();'); + const node = tree.rootNode.child(0)!; + + const info = extractor.extract(node, ctx); + expect(info).not.toBeNull(); + expect(info!.isStatic).toBe(true); + expect(info!.isMutable).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// C/C++ config +// --------------------------------------------------------------------------- + +describe('VariableExtractor — C', () => { + const extractor = createVariableExtractor(cVariableConfig); + const ctx: VariableExtractorContext = { + filePath: 'test.c', + language: SupportedLanguages.C, + }; + + it('extracts const declaration', () => { + parser.setLanguage(C); + const tree = parser.parse('const int MAX_SIZE = 100;'); + const node = tree.rootNode.child(0)!; + expect(extractor.isVariableDeclaration(node)).toBe(true); + + const info = extractor.extract(node, ctx); + expect(info).not.toBeNull(); + expect(info!.name).toBe('MAX_SIZE'); + expect(info!.isConst).toBe(true); + expect(info!.isMutable).toBe(false); + }); + + it('extracts static variable', () => { + parser.setLanguage(C); + const tree = parser.parse('static int counter = 0;'); + const node = tree.rootNode.child(0)!; + + const info = extractor.extract(node, ctx); + expect(info).not.toBeNull(); + expect(info!.name).toBe('counter'); + expect(info!.isStatic).toBe(true); + expect(info!.visibility).toBe('private'); // static = file-private + }); +}); + +describe('VariableExtractor — C++', () => { + const extractor = createVariableExtractor(cppVariableConfig); + const ctx: VariableExtractorContext = { + filePath: 'test.cpp', + language: SupportedLanguages.CPlusPlus, + }; + + it('extracts constexpr declaration', () => { + parser.setLanguage(Cpp); + const tree = parser.parse('constexpr int SIZE = 10;'); + const node = tree.rootNode.child(0)!; + + const info = extractor.extract(node, ctx); + expect(info).not.toBeNull(); + expect(info!.name).toBe('SIZE'); + expect(info!.isConst).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Ruby config +// --------------------------------------------------------------------------- + +describe('VariableExtractor — Ruby', () => { + const extractor = createVariableExtractor(rubyVariableConfig); + const ctx: VariableExtractorContext = { + filePath: 'test.rb', + language: SupportedLanguages.Ruby, + }; + + it('extracts Ruby constant assignment', () => { + parser.setLanguage(Ruby); + const tree = parser.parse('MAX_SIZE = 100'); + const node = tree.rootNode.child(0)!; + + const info = extractor.extract(node, ctx); + expect(info).not.toBeNull(); + expect(info!.name).toBe('MAX_SIZE'); + expect(info!.isConst).toBe(true); + expect(info!.isMutable).toBe(false); + expect(info!.visibility).toBe('public'); + }); + + it('extracts regular variable assignment', () => { + parser.setLanguage(Ruby); + const tree = parser.parse('counter = 0'); + const node = tree.rootNode.child(0)!; + + const info = extractor.extract(node, ctx); + expect(info).not.toBeNull(); + expect(info!.name).toBe('counter'); + expect(info!.isConst).toBe(false); + expect(info!.isMutable).toBe(true); + expect(info!.visibility).toBe('private'); + }); +}); + +// --------------------------------------------------------------------------- +// Factory generic tests +// --------------------------------------------------------------------------- + +describe('createVariableExtractor — factory', () => { + const factoryCtx: VariableExtractorContext = { + filePath: 'test.ts', + language: SupportedLanguages.TypeScript, + }; + + it('creates extractor with correct language', () => { + const extractor = createVariableExtractor(typescriptVariableConfig); + expect(extractor.language).toBe(SupportedLanguages.TypeScript); + }); + + it('returns null for non-variable nodes', () => { + const extractor = createVariableExtractor(typescriptVariableConfig); + parser.setLanguage(TypeScript.typescript); + const tree = parser.parse('class Foo {}'); + const node = tree.rootNode.child(0)!; + expect(extractor.extract(node, factoryCtx)).toBeNull(); + }); + + it('line number is 1-based', () => { + const extractor = createVariableExtractor(typescriptVariableConfig); + parser.setLanguage(TypeScript.typescript); + const tree = parser.parse('const x = 1;'); + const node = tree.rootNode.child(0)!; + const info = extractor.extract(node, factoryCtx); + expect(info).not.toBeNull(); + expect(info!.line).toBe(1); + expect(info!.sourceFile).toBe('test.ts'); + }); + + it('sets scope to module for top-level declarations', () => { + const extractor = createVariableExtractor(typescriptVariableConfig); + parser.setLanguage(TypeScript.typescript); + const tree = parser.parse('const x = 1;'); + const node = tree.rootNode.child(0)!; + const info = extractor.extract(node, factoryCtx); + expect(info).not.toBeNull(); + // rootNode is 'program' → module scope + expect(info!.scope).toBe('module'); + }); +}); + +// --------------------------------------------------------------------------- +// Block-scoped variable extraction tests +// --------------------------------------------------------------------------- + +describe('VariableExtractor — block-scoped declarations', () => { + it('TypeScript: detects block scope for const inside function', () => { + const extractor = createVariableExtractor(typescriptVariableConfig); + const ctx: VariableExtractorContext = { + filePath: 'test.ts', + language: SupportedLanguages.TypeScript, + }; + parser.setLanguage(TypeScript.typescript); + const tree = parser.parse('function foo() { const x = 5; }'); + // program > function_declaration > statement_block > lexical_declaration + const fnBody = tree.rootNode.child(0)!.childForFieldName('body')!; + const constDecl = fnBody.namedChildren.find((c) => c.type === 'lexical_declaration'); + expect(constDecl).toBeDefined(); + const info = extractor.extract(constDecl!, ctx); + expect(info).not.toBeNull(); + expect(info!.name).toBe('x'); + expect(info!.scope).toBe('block'); + expect(info!.isConst).toBe(true); + expect(info!.isMutable).toBe(false); + }); + + it('TypeScript: detects block scope for let inside arrow function', () => { + const extractor = createVariableExtractor(typescriptVariableConfig); + const ctx: VariableExtractorContext = { + filePath: 'test.ts', + language: SupportedLanguages.TypeScript, + }; + parser.setLanguage(TypeScript.typescript); + const tree = parser.parse('const fn = () => { let y = 10; };'); + // program > lexical_declaration > variable_declarator > arrow_function > statement_block + const lexDecl = tree.rootNode.child(0)!; + const varDeclarator = lexDecl.namedChildren.find((c) => c.type === 'variable_declarator')!; + const arrowFn = varDeclarator.childForFieldName('value')!; + const body = arrowFn.childForFieldName('body')!; + const letDecl = body.namedChildren.find((c) => c.type === 'lexical_declaration'); + expect(letDecl).toBeDefined(); + const info = extractor.extract(letDecl!, ctx); + expect(info).not.toBeNull(); + expect(info!.name).toBe('y'); + expect(info!.scope).toBe('block'); + expect(info!.isMutable).toBe(true); + expect(info!.isConst).toBe(false); + }); + + it('Go: detects block scope for short var declaration inside function', () => { + const extractor = createVariableExtractor(goVariableConfig); + const ctx: VariableExtractorContext = { + filePath: 'test.go', + language: SupportedLanguages.Go, + }; + parser.setLanguage(Go); + const tree = parser.parse('package main\nfunc foo() { x := 5 }'); + // source_file > function_declaration > block > short_var_declaration + const funcDecl = tree.rootNode.namedChildren.find((c) => c.type === 'function_declaration')!; + const body = funcDecl.childForFieldName('body')!; + const shortVarDecl = body.namedChildren.find((c) => c.type === 'short_var_declaration'); + expect(shortVarDecl).toBeDefined(); + const info = extractor.extract(shortVarDecl!, ctx); + expect(info).not.toBeNull(); + expect(info!.name).toBe('x'); + expect(info!.scope).toBe('block'); + expect(info!.isMutable).toBe(true); + }); + + it('Rust: detects block scope for let inside function', () => { + const extractor = createVariableExtractor(rustVariableConfig); + const ctx: VariableExtractorContext = { + filePath: 'test.rs', + language: SupportedLanguages.Rust, + }; + parser.setLanguage(Rust); + const tree = parser.parse('fn foo() { let mut x = 5; }'); + // source_file > function_item > block > let_declaration + const funcItem = tree.rootNode.namedChildren.find((c) => c.type === 'function_item')!; + const body = funcItem.childForFieldName('body')!; + const letDecl = body.namedChildren.find((c) => c.type === 'let_declaration'); + expect(letDecl).toBeDefined(); + const info = extractor.extract(letDecl!, ctx); + expect(info).not.toBeNull(); + expect(info!.name).toBe('x'); + expect(info!.scope).toBe('block'); + expect(info!.isMutable).toBe(true); + }); + + it('C: detects block scope for declaration inside function', () => { + const extractor = createVariableExtractor(cVariableConfig); + const ctx: VariableExtractorContext = { + filePath: 'test.c', + language: SupportedLanguages.C, + }; + parser.setLanguage(C); + const tree = parser.parse('void foo() { int x = 5; }'); + // translation_unit > function_definition > compound_statement > declaration + const funcDef = tree.rootNode.namedChildren.find((c) => c.type === 'function_definition')!; + const body = funcDef.childForFieldName('body')!; + const decl = body.namedChildren.find((c) => c.type === 'declaration'); + expect(decl).toBeDefined(); + const info = extractor.extract(decl!, ctx); + expect(info).not.toBeNull(); + expect(info!.name).toBe('x'); + expect(info!.scope).toBe('block'); + expect(info!.isMutable).toBe(true); + }); + + it('Python: detects block scope for assignment inside function', () => { + const extractor = createVariableExtractor(pythonVariableConfig); + const ctx: VariableExtractorContext = { + filePath: 'test.py', + language: SupportedLanguages.Python, + }; + parser.setLanguage(Python); + const tree = parser.parse('def foo():\n x = 5'); + // module > function_definition > block > expression_statement > assignment + const funcDef = tree.rootNode.namedChildren.find((c) => c.type === 'function_definition')!; + const body = funcDef.childForFieldName('body')!; + const exprStmt = body.namedChildren.find((c) => c.type === 'expression_statement'); + expect(exprStmt).toBeDefined(); + const info = extractor.extract(exprStmt!, ctx); + expect(info).not.toBeNull(); + expect(info!.name).toBe('x'); + expect(info!.scope).toBe('block'); + }); + + it('Python: rejects non-assignment expression statements (e.g. function calls)', () => { + const extractor = createVariableExtractor(pythonVariableConfig); + const ctx: VariableExtractorContext = { + filePath: 'test.py', + language: SupportedLanguages.Python, + }; + parser.setLanguage(Python); + const tree = parser.parse('print("hello")'); + const exprStmt = tree.rootNode.child(0)!; + expect(exprStmt.type).toBe('expression_statement'); + // extract() should return null because this is a call, not an assignment + const info = extractor.extract(exprStmt, ctx); + expect(info).toBeNull(); + }); +}); From a32f5b6adb8f36ea266b29bf5766be33204c32ad Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:45:44 +0100 Subject: [PATCH 12/17] refactor(SM-20): wire SemanticModel as first-class resolution input (#885) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * refactor(SM-20): fix O(n²) BFS in gatherAncestors, complete barrel exports, consolidate imports Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/1246d49e-6c67-4c79-935a-4732394b9a7a --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- gitnexus/src/core/ingestion/call-processor.ts | 12 +++++++----- gitnexus/src/core/ingestion/field-types.ts | 2 +- gitnexus/src/core/ingestion/model/index.ts | 16 +++++++++++++--- gitnexus/src/core/ingestion/model/resolve.ts | 8 ++++++-- gitnexus/src/core/ingestion/parsing-processor.ts | 3 +-- gitnexus/src/core/ingestion/type-env.ts | 2 +- 6 files changed, 29 insertions(+), 14 deletions(-) diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index bb7186697..6c9191970 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -1,7 +1,12 @@ import { KnowledgeGraph } from '../graph/types.js'; import { ASTCache } from './ast-cache.js'; -import type { SymbolDefinition, SymbolTableReader } from './model/symbol-table.js'; -import { CLASS_TYPES, CALL_TARGET_TYPES } from './model/symbol-table.js'; +import type { + SymbolDefinition, + SymbolTableReader, + HeritageMap, + ExtractedHeritage, +} from './model/index.js'; +import { CLASS_TYPES, CALL_TARGET_TYPES, lookupMethodByOwnerWithMRO } from './model/index.js'; import Parser from 'tree-sitter'; import type { ResolutionContext } from './model/resolution-context.js'; import { TIER_CONFIDENCE, type ResolutionTier } from './model/resolution-context.js'; @@ -32,7 +37,6 @@ import { } from './utils/call-analysis.js'; import { buildTypeEnv, isSubclassOf } from './type-env.js'; import type { ConstructorBinding, TypeEnvironment } from './type-env.js'; -import type { HeritageMap } from './model/heritage-map.js'; import type { BindingAccumulator } from './binding-accumulator.js'; import { getTreeSitterBufferSize } from './constants.js'; import type { @@ -42,13 +46,11 @@ import type { ExtractedFetchCall, FileConstructorBindings, } from './workers/parse-worker.js'; -import type { ExtractedHeritage } from './model/heritage-map.js'; import { normalizeFetchURL, routeMatches } from './route-extractors/nextjs.js'; import { extractTemplateComponents } from './vue-sfc-extractor.js'; import { extractReturnTypeName, stripNullable } from './type-extractors/shared.js'; import type { LiteralTypeInferrer } from './type-extractors/types.js'; import type { SyntaxNode } from './utils/ast-helpers.js'; -import { lookupMethodByOwnerWithMRO } from './model/resolve.js'; /** Per-file resolved type bindings for exported symbols. * Populated during call processing, consumed by Phase 14 re-resolution pass. */ diff --git a/gitnexus/src/core/ingestion/field-types.ts b/gitnexus/src/core/ingestion/field-types.ts index 8dd1a9f6c..7867ae8a6 100644 --- a/gitnexus/src/core/ingestion/field-types.ts +++ b/gitnexus/src/core/ingestion/field-types.ts @@ -1,7 +1,7 @@ // gitnexus/src/core/ingestion/field-types.ts import type { TypeEnvironment } from './type-env.js'; -import type { SymbolTableReader } from './model/symbol-table.js'; +import type { SymbolTableReader } from './model/index.js'; import { SupportedLanguages } from 'gitnexus-shared'; /** diff --git a/gitnexus/src/core/ingestion/model/index.ts b/gitnexus/src/core/ingestion/model/index.ts index 27f939171..49f200285 100644 --- a/gitnexus/src/core/ingestion/model/index.ts +++ b/gitnexus/src/core/ingestion/model/index.ts @@ -26,6 +26,15 @@ export { type SymbolTableReader, type SymbolTableWriter, createSymbolTable, + type SymbolDefinition, + type AddMetadata, + CLASS_TYPES, + CLASS_TYPES_TUPLE, + type ClassLikeLabel, + FREE_CALLABLE_TYPES, + FREE_CALLABLE_TUPLE, + type FreeCallableLabel, + CALL_TARGET_TYPES, } from './symbol-table.js'; // Type registry (classes, structs, interfaces, enums, records, impls) @@ -63,11 +72,12 @@ export { isFileInPackageDir, } from './resolution-context.js'; -// Heritage types. `buildHeritageMap` + `resolveExtendsType` are exported -// directly from `heritage-map.ts` and are not re-surfaced here to keep -// the barrel narrow. +// Heritage types and builder. `buildHeritageMap` + `resolveExtendsType` are +// exported directly from `heritage-map.ts` and are not re-surfaced here to +// keep the barrel narrow. export { type ExtractedHeritage, + type HeritageMap, type HeritageResolutionStrategy, type HeritageStrategyLookup, } from './heritage-map.js'; diff --git a/gitnexus/src/core/ingestion/model/resolve.ts b/gitnexus/src/core/ingestion/model/resolve.ts index 0fcf1e8b3..746a2b2de 100644 --- a/gitnexus/src/core/ingestion/model/resolve.ts +++ b/gitnexus/src/core/ingestion/model/resolve.ts @@ -22,14 +22,18 @@ import type { MroStrategy } from 'gitnexus-shared'; /** * Gather all ancestor IDs in BFS / topological order. * Returns the linearized list of ancestor IDs (excluding the class itself). + * + * Uses a head-pointer BFS (`queue[head++]`) instead of `Array.shift()` to + * avoid O(n) per-dequeue re-indexing — matching `buildParentMapFromHeritage`. */ function gatherAncestors(classId: string, parentMap: Map): string[] { const visited = new Set(); const order: string[] = []; const queue: string[] = [...(parentMap.get(classId) ?? [])]; + let head = 0; - while (queue.length > 0) { - const id = queue.shift()!; + while (head < queue.length) { + const id = queue[head++]!; if (visited.has(id)) continue; visited.add(id); order.push(id); diff --git a/gitnexus/src/core/ingestion/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index a13d66818..90186c659 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -4,7 +4,7 @@ import Parser from 'tree-sitter'; import { loadParser, loadLanguage, isLanguageAvailable } from '../tree-sitter/parser-loader.js'; import { getProvider } from './languages/index.js'; import { generateId } from '../../lib/utils.js'; -import type { SymbolTableReader, SymbolTableWriter } from './model/symbol-table.js'; +import type { SymbolTableReader, SymbolTableWriter, ExtractedHeritage } from './model/index.js'; // SymbolTableReader is used for the FieldExtractorContext stub; the // parsing functions themselves need Writer because they call .add(). import { ASTCache } from './ast-cache.js'; @@ -46,7 +46,6 @@ import type { FileScopeBindings, ExtractedORMQuery, } from './workers/parse-worker.js'; -import type { ExtractedHeritage } from './model/heritage-map.js'; import { getTreeSitterBufferSize, TREE_SITTER_MAX_BUFFER } from './constants.js'; export type FileProgressCallback = (current: number, total: number, filePath: string) => void; diff --git a/gitnexus/src/core/ingestion/type-env.ts b/gitnexus/src/core/ingestion/type-env.ts index 368de762b..998e7a59e 100644 --- a/gitnexus/src/core/ingestion/type-env.ts +++ b/gitnexus/src/core/ingestion/type-env.ts @@ -21,7 +21,7 @@ import { stripNullable, extractReturnTypeName, } from './type-extractors/shared.js'; -import type { SemanticModel } from './model/semantic-model.js'; +import type { SemanticModel } from './model/index.js'; import type { NodeLabel } from 'gitnexus-shared'; /** From f221f933417abf2a081862cc0cd10be39e21e499 Mon Sep 17 00:00:00 2001 From: azizur100389 Date: Thu, 16 Apr 2026 18:36:24 +0100 Subject: [PATCH 13/17] feat(extractors): detect jQuery $.ajax/$.get/$.post and axios object-form as HTTP consumers (#887) * feat(extractors): detect jQuery $.ajax/$.get/$.post and axios object-form as HTTP consumers The JS/TS HTTP consumer extractor currently recognises fetch() and axios.() but misses three patterns extremely common in Laravel and legacy frontends: - jQuery shorthand: $.get(url), $.post(url, data) - jQuery ajax form: $.ajax({ url, method }) / $.ajax({ url, type }) - axios object form: axios({ method, url }) Missing them means the frontend->backend cross-link disappears from `group sync`, breaking impact analysis for whole classes of repos. Implementation (node.ts): - 3 new PatternSpecs alongside the existing FETCH_/AXIOS_ specs - NodePatternBundle extended with jqueryShorthand / jqueryAjax / axiosObject slots, compiled for JS / TS / TSX grammars - readStringProp() helper walks object-literal `pair` children and resolves `url` / `method` / `type` keys independent of order, sidestepping the positional S-expression constraint on the query form proposed in the issue - 3 new scan loops in scanBundle() emit HttpDetection with framework 'jquery' (new) or 'axios' (existing), confidence 0.7 to match the existing source-scan consumers, defaulting method to GET when absent (matches both jQuery and axios runtime) Tests (http-route-extractor.test.ts): 4 new cases -- 3 positive (shorthand, ajax with method:/type: and default GET, object-form with swapped key order and default GET) plus 1 negative control that asserts unrelated \$.fn.extend / \$.each / non-axios helper calls with {url, method} literals produce zero consumer contracts. Closes #828 * test(extractors): cover jQuery $.ajax with template-literal URL Extend the existing $.ajax fixture with `url: \`/api/orders/\${id}\`` and assert the consumer is emitted as http::GET::/api/orders/{param}. This makes jQuery + template-URL explicit rather than implicit via the axios object test (readStringProp already accepts template_string for both; this is coverage, not new behaviour). Addresses the single non-blocking finding on PR #887. --- .../group/extractors/http-patterns/node.ts | 129 ++++++++++++++++++ .../unit/group/http-route-extractor.test.ts | 118 ++++++++++++++++ 2 files changed, 247 insertions(+) diff --git a/gitnexus/src/core/group/extractors/http-patterns/node.ts b/gitnexus/src/core/group/extractors/http-patterns/node.ts index 587f48e8c..fbf988665 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/node.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/node.ts @@ -17,6 +17,9 @@ import type { HttpDetection, HttpLanguagePlugin } from './types.js'; * - Express `router.get(...)` / `app.post(...)` providers * - `fetch(url)` / `fetch(url, { method: 'POST' })` consumers * - `axios.get(url)` / `axios.delete(url)` consumers + * - `axios({ method, url })` object-form consumers + * - jQuery `$.get(url)` / `$.post(url, ...)` shorthand consumers + * - jQuery `$.ajax({ url, method | type })` consumers * * Because the JavaScript and TypeScript tree-sitter grammars share * node type names for every construct we query, pattern sources are @@ -103,6 +106,48 @@ const AXIOS_SPEC: PatternSpec> = { `, }; +// ─── Consumer: jQuery shorthand $.get(url) / $.post(url, ...) ──────── +// `$` is a valid JS identifier, so tree-sitter parses `$.get(...)` as a +// call_expression whose function is a member_expression on identifier `$`. +const JQUERY_SHORTHAND_SPEC: PatternSpec> = { + meta: {}, + query: ` + (call_expression + function: (member_expression + object: (identifier) @obj (#eq? @obj "$") + property: (property_identifier) @http_method (#match? @http_method "^(get|post)$")) + arguments: (arguments . [(string) (template_string)] @path)) + `, +}; + +// ─── Consumer: jQuery $.ajax({ url, method|type }) ─────────────────── +// The query captures the options object only; key/value pairs are read +// programmatically via `readStringProp` below, which tolerates any key +// order and accepts either `method:` or `type:` (jQuery supports both). +const JQUERY_AJAX_SPEC: PatternSpec> = { + meta: {}, + query: ` + (call_expression + function: (member_expression + object: (identifier) @obj (#eq? @obj "$") + property: (property_identifier) @fn (#eq? @fn "ajax")) + arguments: (arguments (object) @options)) + `, +}; + +// ─── Consumer: axios({ method, url }) object form ──────────────────── +// Distinct from AXIOS_SPEC above because the call target is an identifier +// (`axios`) rather than a member expression (`axios.get`). As with the +// jQuery ajax form, option keys are resolved programmatically. +const AXIOS_OBJECT_SPEC: PatternSpec> = { + meta: {}, + query: ` + (call_expression + function: (identifier) @fn (#eq? @fn "axios") + arguments: (arguments (object) @options)) + `, +}; + interface NodePatternBundle { controller: CompiledPatterns>; methodDecorator: CompiledPatterns>; @@ -110,6 +155,9 @@ interface NodePatternBundle { fetchNoOptions: CompiledPatterns>; fetchWithOptions: CompiledPatterns>; axios: CompiledPatterns>; + jqueryShorthand: CompiledPatterns>; + jqueryAjax: CompiledPatterns>; + axiosObject: CompiledPatterns>; } function compileBundle(language: unknown, name: string): NodePatternBundle { @@ -126,6 +174,9 @@ function compileBundle(language: unknown, name: string): NodePatternBundle { fetchNoOptions: mk(FETCH_NO_OPTIONS_SPEC, 'fetch-no-options'), fetchWithOptions: mk(FETCH_WITH_OPTIONS_SPEC, 'fetch-with-options'), axios: mk(AXIOS_SPEC, 'axios'), + jqueryShorthand: mk(JQUERY_SHORTHAND_SPEC, 'jquery-shorthand'), + jqueryAjax: mk(JQUERY_AJAX_SPEC, 'jquery-ajax'), + axiosObject: mk(AXIOS_OBJECT_SPEC, 'axios-object'), }; } @@ -160,6 +211,28 @@ function joinPath(prefix: string, sub: string): string { return `/${cleanPrefix}/${cleanSub}`; } +/** + * Walk `pair` children of an `object` literal and return the unquoted + * string/template_string value for the first pair whose key matches one + * of `keyNames`. Returns null when no matching pair is present or the + * value is not a string literal. Used by the jQuery ajax / axios object + * consumers to resolve `url` / `method` / `type` keys in any order. + */ +function readStringProp(objectNode: Parser.SyntaxNode, keyNames: readonly string[]): string | null { + for (let i = 0; i < objectNode.namedChildCount; i++) { + const pair = objectNode.namedChild(i); + if (!pair || pair.type !== 'pair') continue; + const keyNode = pair.childForFieldName('key'); + const valueNode = pair.childForFieldName('value'); + if (!keyNode || !valueNode) continue; + if (!keyNames.includes(keyNode.text)) continue; + if (valueNode.type !== 'string' && valueNode.type !== 'template_string') continue; + const lit = unquoteLiteral(valueNode.text); + if (lit !== null) return lit; + } + return null; +} + /** * For a standalone `decorator` node (child of class_body / program), * find the related `class_declaration` node that it decorates. In @@ -351,6 +424,62 @@ function scanBundle(bundle: NodePatternBundle, tree: Parser.Tree): HttpDetection }); } + // Consumer: jQuery shorthand $.get(url) / $.post(url, ...) + for (const match of runCompiledPatterns(bundle.jqueryShorthand, tree)) { + const methodNode = match.captures.http_method; + const pathNode = match.captures.path; + if (!methodNode || !pathNode) continue; + const path = unquoteLiteral(pathNode.text); + if (path === null) continue; + out.push({ + role: 'consumer', + framework: 'jquery', + method: methodNode.text.toUpperCase(), + path, + name: null, + confidence: 0.7, + }); + } + + // Consumer: jQuery $.ajax({ url, method|type }). jQuery accepts either + // `method:` or `type:`; both default to GET when absent. + for (const match of runCompiledPatterns(bundle.jqueryAjax, tree)) { + const optionsNode = match.captures.options; + if (!optionsNode) continue; + const path = readStringProp(optionsNode, ['url']); + if (path === null) continue; + const rawMethod = readStringProp(optionsNode, ['method', 'type']); + const method = (rawMethod ?? 'GET').toUpperCase(); + out.push({ + role: 'consumer', + framework: 'jquery', + method, + path, + name: null, + confidence: 0.7, + }); + } + + // Consumer: axios({ method, url }) object form. Structurally distinct + // from axios.(url) (identifier vs member_expression call), so no + // dedup against the member-form loop above is required. + for (const match of runCompiledPatterns(bundle.axiosObject, tree)) { + const optionsNode = match.captures.options; + if (!optionsNode) continue; + const path = readStringProp(optionsNode, ['url']); + if (path === null) continue; + const rawMethod = readStringProp(optionsNode, ['method']); + const method = (rawMethod ?? 'GET').toUpperCase(); + out.push({ + role: 'consumer', + framework: 'axios', + method, + path, + name: null, + confidence: 0.7, + }); + } + return out; } diff --git a/gitnexus/test/unit/group/http-route-extractor.test.ts b/gitnexus/test/unit/group/http-route-extractor.test.ts index 653b4952c..8ff914fcc 100644 --- a/gitnexus/test/unit/group/http-route-extractor.test.ts +++ b/gitnexus/test/unit/group/http-route-extractor.test.ts @@ -290,6 +290,124 @@ export const deleteUser = (id: string) => axios.delete(\`/api/users/\${id}\`); ).toBeDefined(); }); + it('extracts jQuery $.get and $.post shorthand', async () => { + const dir = path.join(tmpDir, 'jquery-shorthand'); + fs.mkdirSync(path.join(dir, 'public/js'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'public/js/users.js'), + ` +function loadUsers() { + $.get('/api/users', function (data) { console.log(data); }); +} + +function createUser(payload) { + $.post('/api/users', payload); +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + const getRoute = consumers.find((c) => c.contractId === 'http::GET::/api/users'); + expect(getRoute).toBeDefined(); + expect(getRoute?.meta.framework).toBe('jquery'); + + const postRoute = consumers.find((c) => c.contractId === 'http::POST::/api/users'); + expect(postRoute).toBeDefined(); + expect(postRoute?.meta.framework).toBe('jquery'); + }); + + it('extracts jQuery $.ajax with method: and type: keys and default GET', async () => { + const dir = path.join(tmpDir, 'jquery-ajax'); + fs.mkdirSync(path.join(dir, 'public/js'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'public/js/orders.js'), + ` +$.ajax({ url: '/api/orders', method: 'PUT', data: {} }); +$.ajax({ url: '/api/items', type: 'DELETE' }); +$.ajax({ url: '/api/default' }); + +function reloadOrder(id) { + return $.ajax({ url: \`/api/orders/\${id}\`, method: 'GET' }); +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers.find((c) => c.contractId === 'http::PUT::/api/orders')).toBeDefined(); + expect(consumers.find((c) => c.contractId === 'http::DELETE::/api/items')).toBeDefined(); + expect(consumers.find((c) => c.contractId === 'http::GET::/api/default')).toBeDefined(); + // Template-literal URL inside $.ajax is normalized to {param} the same + // way the fetch/axios paths do — confirms readStringProp accepts + // template_string values for jQuery ajax, not just for axios object form. + expect( + consumers.find((c) => c.contractId === 'http::GET::/api/orders/{param}'), + ).toBeDefined(); + }); + + it('extracts axios({ method, url }) object form regardless of key order', async () => { + const dir = path.join(tmpDir, 'axios-object'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src/orders.ts'), + ` +import axios from 'axios'; + +export function createOrder(data: unknown) { + return axios({ method: 'POST', url: '/api/orders', data }); +} + +export function updateUser(id: string, data: unknown) { + return axios({ url: \`/api/users/\${id}\`, method: 'PUT', data }); +} + +export function listDefaults() { + return axios({ url: '/api/defaults' }); +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers.find((c) => c.contractId === 'http::POST::/api/orders')).toBeDefined(); + expect(consumers.find((c) => c.contractId === 'http::PUT::/api/users/{param}')).toBeDefined(); + expect(consumers.find((c) => c.contractId === 'http::GET::/api/defaults')).toBeDefined(); + }); + + it('does not emit consumers for unrelated object-literal calls (negative control)', async () => { + const dir = path.join(tmpDir, 'jquery-axios-negative'); + fs.mkdirSync(path.join(dir, 'public/js'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'public/js/misc.js'), + ` +// jQuery but not an ajax/get/post call +$.fn.extend({ url: '/nope', method: 'POST' }); +$.each([1, 2, 3], function (i, v) { return v; }); + +// Not axios and not $ — unrelated helper that happens to take { url, method } +function myHelper(opts) { return opts; } +myHelper({ url: '/nope', method: 'POST' }); + +// Bare object literal, not a call argument at all +const cfg = { url: '/nope', method: 'POST' }; +console.log(cfg); +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + // None of the above should have produced any HTTP consumer contracts. + const nopeConsumers = consumers.filter( + (c) => typeof c.meta.path === 'string' && c.meta.path.includes('/nope'), + ); + expect(nopeConsumers).toHaveLength(0); + }); + it('extracts Python requests calls', async () => { const dir = path.join(tmpDir, 'python-consumer'); fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); From 43098784cfbcbdc44ae5893109c4fc2e23f45c29 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Apr 2026 19:31:44 +0100 Subject: [PATCH 14/17] refactor(ingestion): split ImportSemantics into per-strategy hooks (Strategies 1-4) (#886) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * refactor(ingestion): split ImportSemantics into per-strategy hooks - Add ImportResolverStrategy and ImportResolutionConfig types - Create createImportResolver factory (resolver-factory.ts) - Add createStandardStrategy to standard.ts - Extract per-language strategies from existing resolvers: goPackageStrategy, javaJvmStrategy, kotlinJvmStrategy, rustModuleStrategy, pythonImportStrategy, csharpNamespaceStrategy, phpPsr4Strategy, swiftPackageStrategy, dartPackageStrategy, dartRelativeStrategy, rubyRequireStrategy - Create per-language config files in import-resolvers/configs/ - Update all 15 language providers to use createImportResolver(config) - Add 38 unit tests for factory and strategy composition - All 3640+ existing tests pass, tsc --noEmit passes Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/c3aec32d-2155-4808-88df-9cd6b2384174 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * chore: remove unused resolver imports from language providers Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/c3aec32d-2155-4808-88df-9cd6b2384174 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * docs: add error propagation note to createImportResolver JSDoc Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/c3aec32d-2155-4808-88df-9cd6b2384174 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * refactor: consolidate strategies into configs, remove legacy resolvers - Move all strategies from per-language files into their config files - Remove swift.ts and vue.ts (no shared helpers needed) - Remove legacy monolithic resolver functions from all per-language files - Remove unused legacy wrapper functions from standard.ts - Per-language files now only contain shared internal helpers - Fix lint warning in languages/php.ts (no-non-null-assertion) - Update test imports to reference configs/ instead of per-language files - All 3262+ tests pass, tsc --noEmit passes, zero lint errors Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/f8da6bc2-957c-4d20-87ba-402fa223c6c8 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * refactor: address review feedback — remove dart.ts shim, JSDoc language field, update ARCHITECTURE.md - Add JSDoc to ImportResolutionConfig.language clarifying it's documentation-only metadata not used by the factory - Remove dart.ts legacy shim (was only kept for backward-compat tests) - Rewrite dart-import-resolver.test.ts to test production strategies (dartPackageStrategy/dartRelativeStrategy) directly, including full factory composition via dartImportConfig - Fix lint warning (no-explicit-any) by using buildSuffixIndex in makeCtx - Update ARCHITECTURE.md to mention import-resolvers/configs/ as the extension point for per-language import resolution Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/53f09a4f-1ff1-4a3e-a29c-fda9cdb4c4ef Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * fix: address review comments — revert php.ts, tighten dart test assertion - Revert php.ts: restore stack.pop()! (the while guard guarantees non-empty) - Tighten dart relative import test to assert exact result instead of permissive null-or-files check Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/53f09a4f-1ff1-4a3e-a29c-fda9cdb4c4ef Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * refactor(ingestion): strengthen import-resolver tests and document Vue config intent Address non-blocking follow-ups from PR #886 review: - Add inline comment to vueImportConfig explaining intentional language: Vue / TypeScript-strategy mismatch (Vue SFCs are preprocessed into TS upstream of import resolution). - Replace 11 tautological typeof === 'function' assertions with behavioral tests for goPackageStrategy, kotlinJvmStrategy, and csharpNamespaceStrategy, including full-chain strategy-order guards via createImportResolver(config). - Apply prettier formatting to sibling configs touched during factory introduction. Test: 37 passed (previously 26), tsc --noEmit clean. * test(ingestion): tighten import-resolver assertions and close coverage gaps Apply ce-review findings on commit f4be87fb: - Tighten dirSuffix assertions from toContain() to exact toEqual() shape, catching format regressions (slash normalization, prefix trimming) the loose matcher would miss. - Collapse 'if (result?.kind === "package") { expect(dirSuffix)... }' conditional-dead-branch pattern into single toEqual() assertions. - Add goPackageStrategy fall-through test: module prefix matches but package directory contains no .go files -> null (documented branch in configs/go.ts:27 had no coverage). - Honestly relabel kotlinImportConfig full-chain test as a behavioral smoke test rather than a strategy-order guard — standard.ts:137 returns null for '.*' imports so reordering is not observable via wildcard inputs. Added Kotlin member-import test for extra coverage. - Add behavioral tests for javaJvmStrategy, rustModuleStrategy, phpPsr4Strategy, swiftPackageStrategy, rubyRequireStrategy (10 tests across 5 describe blocks) so strategy unwiring would be caught. - Extend makeCtx() with optional overrides: Partial parameter for declarative per-test config setup. Test: 50 passed (previously 37), tsc --noEmit clean. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> Co-authored-by: Gergo Magyar --- ARCHITECTURE.md | 4 +- .../import-resolvers/configs/c-cpp.ts | 18 + .../import-resolvers/configs/csharp.ts | 36 ++ .../import-resolvers/configs/dart.ts | 58 ++ .../ingestion/import-resolvers/configs/go.ts | 35 ++ .../ingestion/import-resolvers/configs/jvm.ts | 115 ++++ .../ingestion/import-resolvers/configs/php.ts | 26 + .../import-resolvers/configs/python.ts | 30 + .../import-resolvers/configs/ruby.ts | 20 + .../import-resolvers/configs/rust.ts | 56 ++ .../import-resolvers/{ => configs}/swift.ts | 22 +- .../configs/typescript-javascript.ts | 28 + .../core/ingestion/import-resolvers/csharp.ts | 35 +- .../core/ingestion/import-resolvers/dart.ts | 50 -- .../src/core/ingestion/import-resolvers/go.ts | 34 +- .../core/ingestion/import-resolvers/jvm.ts | 115 +--- .../core/ingestion/import-resolvers/php.ts | 24 +- .../core/ingestion/import-resolvers/python.ts | 26 +- .../import-resolvers/resolver-factory.ts | 35 ++ .../core/ingestion/import-resolvers/ruby.ts | 22 +- .../core/ingestion/import-resolvers/rust.ts | 56 +- .../ingestion/import-resolvers/standard.ts | 23 +- .../core/ingestion/import-resolvers/types.ts | 26 + .../core/ingestion/import-resolvers/vue.ts | 13 - .../src/core/ingestion/languages/c-cpp.ts | 7 +- .../src/core/ingestion/languages/csharp.ts | 5 +- gitnexus/src/core/ingestion/languages/dart.ts | 5 +- gitnexus/src/core/ingestion/languages/go.ts | 5 +- gitnexus/src/core/ingestion/languages/java.ts | 5 +- .../src/core/ingestion/languages/kotlin.ts | 5 +- gitnexus/src/core/ingestion/languages/php.ts | 5 +- .../src/core/ingestion/languages/python.ts | 5 +- gitnexus/src/core/ingestion/languages/ruby.ts | 5 +- gitnexus/src/core/ingestion/languages/rust.ts | 5 +- .../src/core/ingestion/languages/swift.ts | 5 +- .../core/ingestion/languages/typescript.ts | 10 +- gitnexus/src/core/ingestion/languages/vue.ts | 5 +- .../test/unit/dart-import-resolver.test.ts | 128 ++++- .../test/unit/import-resolver-factory.test.ts | 542 ++++++++++++++++++ 39 files changed, 1219 insertions(+), 430 deletions(-) create mode 100644 gitnexus/src/core/ingestion/import-resolvers/configs/c-cpp.ts create mode 100644 gitnexus/src/core/ingestion/import-resolvers/configs/csharp.ts create mode 100644 gitnexus/src/core/ingestion/import-resolvers/configs/dart.ts create mode 100644 gitnexus/src/core/ingestion/import-resolvers/configs/go.ts create mode 100644 gitnexus/src/core/ingestion/import-resolvers/configs/jvm.ts create mode 100644 gitnexus/src/core/ingestion/import-resolvers/configs/php.ts create mode 100644 gitnexus/src/core/ingestion/import-resolvers/configs/python.ts create mode 100644 gitnexus/src/core/ingestion/import-resolvers/configs/ruby.ts create mode 100644 gitnexus/src/core/ingestion/import-resolvers/configs/rust.ts rename gitnexus/src/core/ingestion/import-resolvers/{ => configs}/swift.ts (53%) create mode 100644 gitnexus/src/core/ingestion/import-resolvers/configs/typescript-javascript.ts delete mode 100644 gitnexus/src/core/ingestion/import-resolvers/dart.ts create mode 100644 gitnexus/src/core/ingestion/import-resolvers/resolver-factory.ts delete mode 100644 gitnexus/src/core/ingestion/import-resolvers/vue.ts create mode 100644 gitnexus/test/unit/import-resolver-factory.test.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1be9468c3..76fd3bcb6 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -59,7 +59,7 @@ Monorepo: **CLI/MCP** (`gitnexus/`) + **browser UI** (`gitnexus-web/`). | Embeddings | `src/core/embeddings/` + `src/core/run-analyze.ts` | | Wiki generation | `src/core/wiki/` | | Language support | `src/core/ingestion/languages/` + `tree-sitter-queries.ts` + `gitnexus-shared/src/languages.ts` | -| Import resolution | `src/core/ingestion/import-processor.ts` + `model/resolution-context.ts` | +| Import resolution | `src/core/ingestion/import-processor.ts` + `import-resolvers/configs/` + `model/resolution-context.ts` | | Call resolution/MRO | `src/core/ingestion/call-processor.ts` + `model/resolve.ts` | | Type extraction | `src/core/ingestion/type-extractors/` | | Worker pool | `src/core/ingestion/workers/` | @@ -178,6 +178,8 @@ Per-language tree-sitter queries use different AST node names but produce the ** ### Import resolution +Per-language import resolution uses the **configs + factory** pattern (like call/method/class extractors). Each language declares an `ImportResolutionConfig` in `import-resolvers/configs/`, listing an ordered chain of `ImportResolverStrategy` functions. `createImportResolver()` (in `resolver-factory.ts`) composes them: first non-null result wins. Low-level helpers shared across strategies live alongside the configs in `import-resolvers/` (e.g. `go.ts`, `rust.ts`, `python.ts`). + Unified 3-tier algorithm (`model/resolution-context.ts`), per-language `importSemantics` controls which tier activates: | Tier | Confidence | Mechanism | diff --git a/gitnexus/src/core/ingestion/import-resolvers/configs/c-cpp.ts b/gitnexus/src/core/ingestion/import-resolvers/configs/c-cpp.ts new file mode 100644 index 000000000..bcfddee0b --- /dev/null +++ b/gitnexus/src/core/ingestion/import-resolvers/configs/c-cpp.ts @@ -0,0 +1,18 @@ +/** + * C / C++ import resolution configs. + * Both use standard resolution for #include directives. + */ + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { ImportResolutionConfig } from '../types.js'; +import { createStandardStrategy } from '../standard.js'; + +export const cImportConfig: ImportResolutionConfig = { + language: SupportedLanguages.C, + strategies: [createStandardStrategy(SupportedLanguages.C)], +}; + +export const cppImportConfig: ImportResolutionConfig = { + language: SupportedLanguages.CPlusPlus, + strategies: [createStandardStrategy(SupportedLanguages.CPlusPlus)], +}; diff --git a/gitnexus/src/core/ingestion/import-resolvers/configs/csharp.ts b/gitnexus/src/core/ingestion/import-resolvers/configs/csharp.ts new file mode 100644 index 000000000..cb5f77145 --- /dev/null +++ b/gitnexus/src/core/ingestion/import-resolvers/configs/csharp.ts @@ -0,0 +1,36 @@ +/** + * C# import resolution config. + * Namespace-based strategy via .csproj configs, then standard fallback. + */ + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { ImportResolutionConfig, ImportResolverStrategy } from '../types.js'; +import { createStandardStrategy } from '../standard.js'; +import { resolveCSharpImportInternal, resolveCSharpNamespaceDir } from '../csharp.js'; + +/** C# namespace-based resolution strategy via .csproj configs. */ +export const csharpNamespaceStrategy: ImportResolverStrategy = (rawImportPath, _filePath, ctx) => { + const csharpConfigs = ctx.configs.csharpConfigs; + if (csharpConfigs.length > 0) { + const resolvedFiles = resolveCSharpImportInternal( + rawImportPath, + csharpConfigs, + ctx.normalizedFileList, + ctx.allFileList, + ctx.index, + ); + if (resolvedFiles.length > 1) { + const dirSuffix = resolveCSharpNamespaceDir(rawImportPath, csharpConfigs); + if (dirSuffix) { + return { kind: 'package', files: resolvedFiles, dirSuffix }; + } + } + if (resolvedFiles.length > 0) return { kind: 'files', files: resolvedFiles }; + } + return null; +}; + +export const csharpImportConfig: ImportResolutionConfig = { + language: SupportedLanguages.CSharp, + strategies: [csharpNamespaceStrategy, createStandardStrategy(SupportedLanguages.CSharp)], +}; diff --git a/gitnexus/src/core/ingestion/import-resolvers/configs/dart.ts b/gitnexus/src/core/ingestion/import-resolvers/configs/dart.ts new file mode 100644 index 000000000..06dcea98a --- /dev/null +++ b/gitnexus/src/core/ingestion/import-resolvers/configs/dart.ts @@ -0,0 +1,58 @@ +/** + * Dart import resolution config. + * SDK/package strategy first, then relative import strategy (with ./ prepending). + */ + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { ImportResolutionConfig, ImportResolverStrategy } from '../types.js'; +import { resolveStandard } from '../standard.js'; + +/** + * Dart SDK and package: import strategy. + * Absorbs dart: SDK imports and external packages (returns empty result to stop chain). + * Returns null for relative imports to let the next strategy handle them. + */ +export const dartPackageStrategy: ImportResolverStrategy = (rawImportPath, _filePath, ctx) => { + // Strip surrounding quotes from configurable_uri capture + const stripped = rawImportPath.replace(/^['"]|['"]$/g, ''); + + // Skip dart: SDK imports (dart:async, dart:io, etc.) + if (stripped.startsWith('dart:')) return { kind: 'files', files: [] }; + + // Local package: imports → resolve to lib/ + if (stripped.startsWith('package:')) { + const slashIdx = stripped.indexOf('/'); + if (slashIdx === -1) return { kind: 'files', files: [] }; + const relPath = stripped.slice(slashIdx + 1); + const candidates = [`lib/${relPath}`, relPath]; + const files: string[] = []; + for (const candidate of candidates) { + for (const fp of ctx.allFileList) { + if (fp.endsWith('/' + candidate) || fp === candidate) { + files.push(fp); + break; + } + } + if (files.length > 0) break; + } + if (files.length > 0) return { kind: 'files', files }; + return { kind: 'files', files: [] }; // external package + } + + return null; +}; + +/** + * Dart relative import strategy — prepends "./" for bare relative paths, + * then delegates to standard resolution. + */ +export const dartRelativeStrategy: ImportResolverStrategy = (rawImportPath, filePath, ctx) => { + const stripped = rawImportPath.replace(/^['"]|['"]$/g, ''); + const relPath = stripped.startsWith('.') ? stripped : './' + stripped; + return resolveStandard(relPath, filePath, ctx, SupportedLanguages.Dart); +}; + +export const dartImportConfig: ImportResolutionConfig = { + language: SupportedLanguages.Dart, + strategies: [dartPackageStrategy, dartRelativeStrategy], +}; diff --git a/gitnexus/src/core/ingestion/import-resolvers/configs/go.ts b/gitnexus/src/core/ingestion/import-resolvers/configs/go.ts new file mode 100644 index 000000000..7eee17988 --- /dev/null +++ b/gitnexus/src/core/ingestion/import-resolvers/configs/go.ts @@ -0,0 +1,35 @@ +/** + * Go import resolution config. + * Go-specific package strategy (go.mod), then standard fallback. + */ + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { ImportResolutionConfig, ImportResolverStrategy } from '../types.js'; +import { createStandardStrategy } from '../standard.js'; +import { resolveGoPackageDir, resolveGoPackage } from '../go.js'; + +/** Go-specific package resolution strategy — resolves go.mod-based package imports. */ +export const goPackageStrategy: ImportResolverStrategy = (rawImportPath, _filePath, ctx) => { + const goModule = ctx.configs.goModule; + if (goModule && rawImportPath.startsWith(goModule.modulePath)) { + const pkgSuffix = resolveGoPackageDir(rawImportPath, goModule); + if (pkgSuffix) { + const pkgFiles = resolveGoPackage( + rawImportPath, + goModule, + ctx.normalizedFileList, + ctx.allFileList, + ); + if (pkgFiles.length > 0) { + return { kind: 'package', files: pkgFiles, dirSuffix: pkgSuffix }; + } + } + // Fall through if no files found (package might be external) + } + return null; +}; + +export const goImportConfig: ImportResolutionConfig = { + language: SupportedLanguages.Go, + strategies: [goPackageStrategy, createStandardStrategy(SupportedLanguages.Go)], +}; diff --git a/gitnexus/src/core/ingestion/import-resolvers/configs/jvm.ts b/gitnexus/src/core/ingestion/import-resolvers/configs/jvm.ts new file mode 100644 index 000000000..47e590fe8 --- /dev/null +++ b/gitnexus/src/core/ingestion/import-resolvers/configs/jvm.ts @@ -0,0 +1,115 @@ +/** + * Java / Kotlin import resolution configs. + * JVM-specific wildcard/member strategy, then standard fallback. + */ + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { ImportResolutionConfig, ImportResolverStrategy } from '../types.js'; +import { createStandardStrategy } from '../standard.js'; +import { resolveJvmWildcard, resolveJvmMemberImport, KOTLIN_EXTENSIONS } from '../jvm.js'; + +/** Java JVM resolution strategy — wildcard and member import resolution. */ +export const javaJvmStrategy: ImportResolverStrategy = (rawImportPath, _filePath, ctx) => { + if (rawImportPath.endsWith('.*')) { + const matchedFiles = resolveJvmWildcard( + rawImportPath, + ctx.normalizedFileList, + ctx.allFileList, + ['.java'], + ctx.index, + ); + if (matchedFiles.length > 0) return { kind: 'files', files: matchedFiles }; + } else { + const memberResolved = resolveJvmMemberImport( + rawImportPath, + ctx.normalizedFileList, + ctx.allFileList, + ['.java'], + ctx.index, + ); + if (memberResolved) return { kind: 'files', files: [memberResolved] }; + } + return null; +}; + +/** + * Kotlin JVM resolution strategy — wildcard/member with Java-interop + top-level function imports. + */ +export const kotlinJvmStrategy: ImportResolverStrategy = (rawImportPath, _filePath, ctx) => { + if (rawImportPath.endsWith('.*')) { + const matchedFiles = resolveJvmWildcard( + rawImportPath, + ctx.normalizedFileList, + ctx.allFileList, + KOTLIN_EXTENSIONS, + ctx.index, + ); + if (matchedFiles.length === 0) { + const javaMatches = resolveJvmWildcard( + rawImportPath, + ctx.normalizedFileList, + ctx.allFileList, + ['.java'], + ctx.index, + ); + if (javaMatches.length > 0) return { kind: 'files', files: javaMatches }; + } + if (matchedFiles.length > 0) return { kind: 'files', files: matchedFiles }; + } else { + let memberResolved = resolveJvmMemberImport( + rawImportPath, + ctx.normalizedFileList, + ctx.allFileList, + KOTLIN_EXTENSIONS, + ctx.index, + ); + if (!memberResolved) { + memberResolved = resolveJvmMemberImport( + rawImportPath, + ctx.normalizedFileList, + ctx.allFileList, + ['.java'], + ctx.index, + ); + } + if (memberResolved) return { kind: 'files', files: [memberResolved] }; + + // Kotlin: top-level function imports (e.g. import models.getUser) have only 2 segments, + // which resolveJvmMemberImport skips (requires >=3). Fall back to package-directory scan + // for lowercase last segments (function/property imports). Uppercase last segments + // (class imports like models.User) fall through to standard suffix resolution. + const segments = rawImportPath.split('.'); + const lastSeg = segments[segments.length - 1]; + if (segments.length >= 2 && lastSeg[0] && lastSeg[0] === lastSeg[0].toLowerCase()) { + const pkgWildcard = segments.slice(0, -1).join('.') + '.*'; + let dirFiles = resolveJvmWildcard( + pkgWildcard, + ctx.normalizedFileList, + ctx.allFileList, + KOTLIN_EXTENSIONS, + ctx.index, + ); + if (dirFiles.length === 0) { + dirFiles = resolveJvmWildcard( + pkgWildcard, + ctx.normalizedFileList, + ctx.allFileList, + ['.java'], + ctx.index, + ); + } + if (dirFiles.length > 0) return { kind: 'files', files: dirFiles }; + } + } + return null; +}; + +export const javaImportConfig: ImportResolutionConfig = { + language: SupportedLanguages.Java, + strategies: [javaJvmStrategy, createStandardStrategy(SupportedLanguages.Java)], +}; + +export const kotlinImportConfig: ImportResolutionConfig = { + language: SupportedLanguages.Kotlin, + strategies: [kotlinJvmStrategy, createStandardStrategy(SupportedLanguages.Kotlin)], +}; diff --git a/gitnexus/src/core/ingestion/import-resolvers/configs/php.ts b/gitnexus/src/core/ingestion/import-resolvers/configs/php.ts new file mode 100644 index 000000000..5a8446f25 --- /dev/null +++ b/gitnexus/src/core/ingestion/import-resolvers/configs/php.ts @@ -0,0 +1,26 @@ +/** + * PHP import resolution config. + * PSR-4 strategy via composer.json — no standard fallback (PSR-4 includes its own suffix matching). + */ + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { ImportResolutionConfig, ImportResolverStrategy } from '../types.js'; +import { resolvePhpImportInternal } from '../php.js'; + +/** PHP PSR-4 resolution strategy via composer.json autoload mappings. */ +export const phpPsr4Strategy: ImportResolverStrategy = (rawImportPath, _filePath, ctx) => { + const resolved = resolvePhpImportInternal( + rawImportPath, + ctx.configs.composerConfig, + ctx.allFilePaths, + ctx.normalizedFileList, + ctx.allFileList, + ctx.index, + ); + return resolved ? { kind: 'files', files: [resolved] } : null; +}; + +export const phpImportConfig: ImportResolutionConfig = { + language: SupportedLanguages.PHP, + strategies: [phpPsr4Strategy], +}; diff --git a/gitnexus/src/core/ingestion/import-resolvers/configs/python.ts b/gitnexus/src/core/ingestion/import-resolvers/configs/python.ts new file mode 100644 index 000000000..85a17c594 --- /dev/null +++ b/gitnexus/src/core/ingestion/import-resolvers/configs/python.ts @@ -0,0 +1,30 @@ +/** + * Python import resolution config. + * PEP 328 relative + proximity-based strategy, then standard fallback. + */ + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { ImportResolutionConfig, ImportResolverStrategy } from '../types.js'; +import { createStandardStrategy } from '../standard.js'; +import { resolvePythonImportInternal } from '../python.js'; + +/** + * Python import resolution strategy — PEP 328 relative + proximity-based bare imports. + * Returns null to continue chain for non-relative imports. + * Absorbs unresolved relative imports (returns empty result to stop the chain). + */ +export const pythonImportStrategy: ImportResolverStrategy = (rawImportPath, filePath, ctx) => { + const resolved = resolvePythonImportInternal(filePath, rawImportPath, ctx.allFilePaths); + if (resolved) { + ctx.resolveCache.set(`${filePath}::${rawImportPath}`, resolved); + return { kind: 'files', files: [resolved] }; + } + // PEP 328: unresolved relative imports should not fall through to suffix matching + if (rawImportPath.startsWith('.')) return { kind: 'files', files: [] }; + return null; +}; + +export const pythonImportConfig: ImportResolutionConfig = { + language: SupportedLanguages.Python, + strategies: [pythonImportStrategy, createStandardStrategy(SupportedLanguages.Python)], +}; diff --git a/gitnexus/src/core/ingestion/import-resolvers/configs/ruby.ts b/gitnexus/src/core/ingestion/import-resolvers/configs/ruby.ts new file mode 100644 index 000000000..bf2507f70 --- /dev/null +++ b/gitnexus/src/core/ingestion/import-resolvers/configs/ruby.ts @@ -0,0 +1,20 @@ +/** + * Ruby import resolution config. + * Require/require_relative suffix matching — no standard fallback. + */ + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { ImportResolutionConfig, ImportResolverStrategy } from '../types.js'; +import { suffixResolve } from '../utils.js'; + +/** Ruby require/require_relative resolution strategy. */ +export const rubyRequireStrategy: ImportResolverStrategy = (rawImportPath, _filePath, ctx) => { + const pathParts = rawImportPath.replace(/^\.\//, '').split('/').filter(Boolean); + const resolved = suffixResolve(pathParts, ctx.normalizedFileList, ctx.allFileList, ctx.index); + return resolved ? { kind: 'files', files: [resolved] } : null; +}; + +export const rubyImportConfig: ImportResolutionConfig = { + language: SupportedLanguages.Ruby, + strategies: [rubyRequireStrategy], +}; diff --git a/gitnexus/src/core/ingestion/import-resolvers/configs/rust.ts b/gitnexus/src/core/ingestion/import-resolvers/configs/rust.ts new file mode 100644 index 000000000..daa2173e7 --- /dev/null +++ b/gitnexus/src/core/ingestion/import-resolvers/configs/rust.ts @@ -0,0 +1,56 @@ +/** + * Rust import resolution config. + * Rust module strategy (grouped imports, crate/super/self paths), then standard fallback. + */ + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { ImportResolutionConfig, ImportResolverStrategy } from '../types.js'; +import { createStandardStrategy } from '../standard.js'; +import { resolveRustImportInternal } from '../rust.js'; + +/** Rust module resolution strategy — handles grouped imports and crate/super/self paths. */ +export const rustModuleStrategy: ImportResolverStrategy = (rawImportPath, filePath, ctx) => { + // Top-level grouped: use {crate::a, crate::b} + if (rawImportPath.startsWith('{') && rawImportPath.endsWith('}')) { + const inner = rawImportPath.slice(1, -1); + const parts = inner + .split(',') + .map((p) => p.trim()) + .filter(Boolean); + const resolved: string[] = []; + for (const part of parts) { + const r = resolveRustImportInternal(filePath, part, ctx.allFilePaths); + if (r) resolved.push(r); + } + return resolved.length > 0 ? { kind: 'files', files: resolved } : null; + } + + // Scoped grouped: use crate::models::{User, Repo} + const braceIdx = rawImportPath.indexOf('::{'); + if (braceIdx !== -1 && rawImportPath.endsWith('}')) { + const pathPrefix = rawImportPath.substring(0, braceIdx); + const braceContent = rawImportPath.substring(braceIdx + 3, rawImportPath.length - 1); + const items = braceContent + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + const resolved: string[] = []; + for (const item of items) { + // Handle `use crate::models::{User, Repo as R}` — strip alias for resolution + const itemName = item.includes(' as ') ? item.split(' as ')[0].trim() : item; + const r = resolveRustImportInternal(filePath, `${pathPrefix}::${itemName}`, ctx.allFilePaths); + if (r) resolved.push(r); + } + if (resolved.length > 0) return { kind: 'files', files: resolved }; + // Fallback: resolve the prefix path itself (e.g. crate::models -> models.rs) + const prefixResult = resolveRustImportInternal(filePath, pathPrefix, ctx.allFilePaths); + if (prefixResult) return { kind: 'files', files: [prefixResult] }; + } + + return null; +}; + +export const rustImportConfig: ImportResolutionConfig = { + language: SupportedLanguages.Rust, + strategies: [rustModuleStrategy, createStandardStrategy(SupportedLanguages.Rust)], +}; diff --git a/gitnexus/src/core/ingestion/import-resolvers/swift.ts b/gitnexus/src/core/ingestion/import-resolvers/configs/swift.ts similarity index 53% rename from gitnexus/src/core/ingestion/import-resolvers/swift.ts rename to gitnexus/src/core/ingestion/import-resolvers/configs/swift.ts index 7ccc10458..f7d9ec195 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/swift.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/configs/swift.ts @@ -1,16 +1,13 @@ /** - * Swift module import resolution. - * Handles module imports via Package.swift target map. + * Swift import resolution config. + * Package.swift target map strategy — no standard fallback (unresolved = external framework). */ -import type { ImportResult, ResolveCtx } from './types.js'; +import { SupportedLanguages } from 'gitnexus-shared'; +import type { ImportResolutionConfig, ImportResolverStrategy } from '../types.js'; -/** Swift: module imports via Package.swift target map. */ -export function resolveSwiftImport( - rawImportPath: string, - _filePath: string, - ctx: ResolveCtx, -): ImportResult { +/** Swift Package.swift target map resolution strategy. */ +export const swiftPackageStrategy: ImportResolverStrategy = (rawImportPath, _filePath, ctx) => { const swiftPackageConfig = ctx.configs.swiftPackageConfig; if (swiftPackageConfig) { const targetDir = swiftPackageConfig.targets.get(rawImportPath); @@ -29,4 +26,9 @@ export function resolveSwiftImport( } } return null; // External framework (Foundation, UIKit, etc.) -} +}; + +export const swiftImportConfig: ImportResolutionConfig = { + language: SupportedLanguages.Swift, + strategies: [swiftPackageStrategy], +}; diff --git a/gitnexus/src/core/ingestion/import-resolvers/configs/typescript-javascript.ts b/gitnexus/src/core/ingestion/import-resolvers/configs/typescript-javascript.ts new file mode 100644 index 000000000..b86b365fd --- /dev/null +++ b/gitnexus/src/core/ingestion/import-resolvers/configs/typescript-javascript.ts @@ -0,0 +1,28 @@ +/** + * TypeScript / JavaScript / Vue import resolution configs. + * All use standard resolution — TS/JS with tsconfig path aliases, + * Vue delegates to TypeScript's resolver. + */ + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { ImportResolutionConfig } from '../types.js'; +import { createStandardStrategy } from '../standard.js'; + +export const typescriptImportConfig: ImportResolutionConfig = { + language: SupportedLanguages.TypeScript, + strategies: [createStandardStrategy(SupportedLanguages.TypeScript)], +}; + +export const javascriptImportConfig: ImportResolutionConfig = { + language: SupportedLanguages.JavaScript, + strategies: [createStandardStrategy(SupportedLanguages.JavaScript)], +}; + +// Vue SFCs are preprocessed into TypeScript upstream of import resolution, +// so the resolver intentionally runs as TypeScript. `language: Vue` here is +// documentation-only metadata (see `ImportResolutionConfig.language` JSDoc +// and ARCHITECTURE.md §Vue); it is not consumed by `createImportResolver`. +export const vueImportConfig: ImportResolutionConfig = { + language: SupportedLanguages.Vue, + strategies: [createStandardStrategy(SupportedLanguages.TypeScript)], +}; diff --git a/gitnexus/src/core/ingestion/import-resolvers/csharp.ts b/gitnexus/src/core/ingestion/import-resolvers/csharp.ts index 33cc9c828..79548d7f6 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/csharp.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/csharp.ts @@ -1,13 +1,12 @@ /** - * C# namespace import resolution. - * Handles using-directive resolution via .csproj root namespace stripping. + * C# namespace import resolution — internal helpers. + * + * Strategy lives in configs/csharp.ts. + * This file contains shared helpers for namespace-based resolution. */ import type { SuffixIndex } from './utils.js'; import { suffixResolve } from './utils.js'; -import { SupportedLanguages } from 'gitnexus-shared'; -import type { ImportResult, ResolveCtx } from './types.js'; -import { resolveStandard } from './standard.js'; import type { CSharpProjectConfig } from '../language-config.js'; /** @@ -126,29 +125,3 @@ export function resolveCSharpNamespaceDir( return null; } - -/** C#: namespace-based resolution via .csproj configs, with suffix-match fallback. */ -export function resolveCSharpImport( - rawImportPath: string, - filePath: string, - ctx: ResolveCtx, -): ImportResult { - const csharpConfigs = ctx.configs.csharpConfigs; - if (csharpConfigs.length > 0) { - const resolvedFiles = resolveCSharpImportInternal( - rawImportPath, - csharpConfigs, - ctx.normalizedFileList, - ctx.allFileList, - ctx.index, - ); - if (resolvedFiles.length > 1) { - const dirSuffix = resolveCSharpNamespaceDir(rawImportPath, csharpConfigs); - if (dirSuffix) { - return { kind: 'package', files: resolvedFiles, dirSuffix }; - } - } - if (resolvedFiles.length > 0) return { kind: 'files', files: resolvedFiles }; - } - return resolveStandard(rawImportPath, filePath, ctx, SupportedLanguages.CSharp); -} diff --git a/gitnexus/src/core/ingestion/import-resolvers/dart.ts b/gitnexus/src/core/ingestion/import-resolvers/dart.ts deleted file mode 100644 index 2a3cf3f6b..000000000 --- a/gitnexus/src/core/ingestion/import-resolvers/dart.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Dart import resolution. - * Handles package: imports (local packages) and relative imports. - * SDK imports (dart:*) and external packages are skipped. - */ - -import type { ImportResult, ResolveCtx } from './types.js'; -import { resolveStandard } from './standard.js'; -import { SupportedLanguages } from 'gitnexus-shared'; - -export function resolveDartImport( - rawImportPath: string, - filePath: string, - ctx: ResolveCtx, -): ImportResult { - // Strip surrounding quotes from configurable_uri capture - const stripped = rawImportPath.replace(/^['"]|['"]$/g, ''); - - // Skip dart: SDK imports (dart:async, dart:io, etc.) - if (stripped.startsWith('dart:')) return null; - - // Local package: imports → resolve to lib/ - if (stripped.startsWith('package:')) { - const slashIdx = stripped.indexOf('/'); - if (slashIdx === -1) return null; - const relPath = stripped.slice(slashIdx + 1); - const candidates = [`lib/${relPath}`, relPath]; - const files: string[] = []; - for (const candidate of candidates) { - for (const fp of ctx.allFileList) { - if (fp.endsWith('/' + candidate) || fp === candidate) { - files.push(fp); - break; - } - } - if (files.length > 0) break; - } - if (files.length > 0) return { kind: 'files', files }; - return null; - } - - // Relative imports — use standard resolution. - // Dart relative imports don't require a leading "./" (e.g. `import 'models.dart'`). - // The standard resolver only recognises paths starting with "." as relative, so - // prepend "./" when the path doesn't already start with "." to ensure correct - // same-directory resolution (without this, "models.dart" would be mangled by the - // generic dot-to-slash conversion intended for Java-style package imports). - const relPath = stripped.startsWith('.') ? stripped : './' + stripped; - return resolveStandard(relPath, filePath, ctx, SupportedLanguages.Dart); -} diff --git a/gitnexus/src/core/ingestion/import-resolvers/go.ts b/gitnexus/src/core/ingestion/import-resolvers/go.ts index 1b1eb3ebc..c33c46422 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/go.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/go.ts @@ -1,11 +1,10 @@ /** - * Go package import resolution. - * Handles Go module path-based package imports. + * Go package import resolution — internal helpers. + * + * Strategy lives in configs/go.ts. + * This file contains the shared helpers used by the strategy. */ -import { SupportedLanguages } from 'gitnexus-shared'; -import type { ImportResult, ResolveCtx } from './types.js'; -import { resolveStandard } from './standard.js'; import type { GoModuleConfig } from '../language-config.js'; /** @@ -56,28 +55,3 @@ export function resolveGoPackage( return matches; } - -/** Go: package-level imports via go.mod module path. */ -export function resolveGoImport( - rawImportPath: string, - filePath: string, - ctx: ResolveCtx, -): ImportResult { - const goModule = ctx.configs.goModule; - if (goModule && rawImportPath.startsWith(goModule.modulePath)) { - const pkgSuffix = resolveGoPackageDir(rawImportPath, goModule); - if (pkgSuffix) { - const pkgFiles = resolveGoPackage( - rawImportPath, - goModule, - ctx.normalizedFileList, - ctx.allFileList, - ); - if (pkgFiles.length > 0) { - return { kind: 'package', files: pkgFiles, dirSuffix: pkgSuffix }; - } - } - // Fall through if no files found (package might be external) - } - return resolveStandard(rawImportPath, filePath, ctx, SupportedLanguages.Go); -} diff --git a/gitnexus/src/core/ingestion/import-resolvers/jvm.ts b/gitnexus/src/core/ingestion/import-resolvers/jvm.ts index 2dd7636ab..194cfdac8 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/jvm.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/jvm.ts @@ -1,13 +1,13 @@ /** - * JVM import resolution (Java + Kotlin). - * Handles wildcard imports, member/static imports, and Kotlin-specific patterns. + * JVM import resolution — internal helpers (Java + Kotlin). + * + * Strategies live in configs/jvm.ts. + * This file contains shared helpers for wildcard/member resolution + * and the Kotlin wildcard preprocessor. */ import type { SuffixIndex } from './utils.js'; import type { SyntaxNode } from '../utils/ast-helpers.js'; -import { SupportedLanguages } from 'gitnexus-shared'; -import type { ImportResult, ResolveCtx } from './types.js'; -import { resolveStandard } from './standard.js'; /** Kotlin file extensions for JVM resolver reuse */ export const KOTLIN_EXTENSIONS: readonly string[] = ['.kt', '.kts']; @@ -125,108 +125,3 @@ export function resolveJvmMemberImport( return null; } - -/** Java: JVM wildcard -> member import -> standard fallthrough */ -export function resolveJavaImport( - rawImportPath: string, - filePath: string, - ctx: ResolveCtx, -): ImportResult { - if (rawImportPath.endsWith('.*')) { - const matchedFiles = resolveJvmWildcard( - rawImportPath, - ctx.normalizedFileList, - ctx.allFileList, - ['.java'], - ctx.index, - ); - if (matchedFiles.length > 0) return { kind: 'files', files: matchedFiles }; - } else { - const memberResolved = resolveJvmMemberImport( - rawImportPath, - ctx.normalizedFileList, - ctx.allFileList, - ['.java'], - ctx.index, - ); - if (memberResolved) return { kind: 'files', files: [memberResolved] }; - } - return resolveStandard(rawImportPath, filePath, ctx, SupportedLanguages.Java); -} - -/** - * Kotlin: JVM wildcard/member with Java-interop fallback -> top-level function imports -> standard. - * Kotlin can import from .kt/.kts files OR from .java files (Java interop). - */ -export function resolveKotlinImport( - rawImportPath: string, - filePath: string, - ctx: ResolveCtx, -): ImportResult { - if (rawImportPath.endsWith('.*')) { - const matchedFiles = resolveJvmWildcard( - rawImportPath, - ctx.normalizedFileList, - ctx.allFileList, - KOTLIN_EXTENSIONS, - ctx.index, - ); - if (matchedFiles.length === 0) { - const javaMatches = resolveJvmWildcard( - rawImportPath, - ctx.normalizedFileList, - ctx.allFileList, - ['.java'], - ctx.index, - ); - if (javaMatches.length > 0) return { kind: 'files', files: javaMatches }; - } - if (matchedFiles.length > 0) return { kind: 'files', files: matchedFiles }; - } else { - let memberResolved = resolveJvmMemberImport( - rawImportPath, - ctx.normalizedFileList, - ctx.allFileList, - KOTLIN_EXTENSIONS, - ctx.index, - ); - if (!memberResolved) { - memberResolved = resolveJvmMemberImport( - rawImportPath, - ctx.normalizedFileList, - ctx.allFileList, - ['.java'], - ctx.index, - ); - } - if (memberResolved) return { kind: 'files', files: [memberResolved] }; - - // Kotlin: top-level function imports (e.g. import models.getUser) have only 2 segments, - // which resolveJvmMemberImport skips (requires >=3). Fall back to package-directory scan - // for lowercase last segments (function/property imports). Uppercase last segments - // (class imports like models.User) fall through to standard suffix resolution. - const segments = rawImportPath.split('.'); - const lastSeg = segments[segments.length - 1]; - if (segments.length >= 2 && lastSeg[0] && lastSeg[0] === lastSeg[0].toLowerCase()) { - const pkgWildcard = segments.slice(0, -1).join('.') + '.*'; - let dirFiles = resolveJvmWildcard( - pkgWildcard, - ctx.normalizedFileList, - ctx.allFileList, - KOTLIN_EXTENSIONS, - ctx.index, - ); - if (dirFiles.length === 0) { - dirFiles = resolveJvmWildcard( - pkgWildcard, - ctx.normalizedFileList, - ctx.allFileList, - ['.java'], - ctx.index, - ); - } - if (dirFiles.length > 0) return { kind: 'files', files: dirFiles }; - } - } - return resolveStandard(rawImportPath, filePath, ctx, SupportedLanguages.Kotlin); -} diff --git a/gitnexus/src/core/ingestion/import-resolvers/php.ts b/gitnexus/src/core/ingestion/import-resolvers/php.ts index 20517bbed..303bf5546 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/php.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/php.ts @@ -1,11 +1,12 @@ /** - * PHP PSR-4 import resolution. - * Handles use-statement resolution via composer.json autoload mappings. + * PHP PSR-4 import resolution — internal helpers. + * + * Strategy lives in configs/php.ts. + * This file contains the shared helper for PSR-4 resolution via composer.json. */ import type { SuffixIndex } from './utils.js'; import { suffixResolve } from './utils.js'; -import type { ImportResult, ResolveCtx } from './types.js'; import type { ComposerConfig } from '../language-config.js'; /** Get or compute the sorted PSR-4 entries (cached after first call). */ @@ -91,20 +92,3 @@ export function resolvePhpImportInternal( const pathParts = normalized.split('/').filter(Boolean); return suffixResolve(pathParts, normalizedFileList, allFileList, index); } - -/** PHP: namespace-based resolution via composer.json PSR-4. */ -export function resolvePhpImport( - rawImportPath: string, - _filePath: string, - ctx: ResolveCtx, -): ImportResult { - const resolved = resolvePhpImportInternal( - rawImportPath, - ctx.configs.composerConfig, - ctx.allFilePaths, - ctx.normalizedFileList, - ctx.allFileList, - ctx.index, - ); - return resolved ? { kind: 'files', files: [resolved] } : null; -} diff --git a/gitnexus/src/core/ingestion/import-resolvers/python.ts b/gitnexus/src/core/ingestion/import-resolvers/python.ts index 4e9c4bbb3..264103a09 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/python.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/python.ts @@ -1,12 +1,12 @@ /** * Python import resolution — PEP 328 relative imports and proximity-based bare imports. * Import system spec: PEP 302 (original), PEP 451 (current). + * + * Strategy lives in configs/python.ts. + * This file contains the shared internal helper used by the strategy and tests. */ import { tryResolveWithExtensions } from './utils.js'; -import { SupportedLanguages } from 'gitnexus-shared'; -import type { ImportResult, ResolveCtx } from './types.js'; -import { resolveStandard } from './standard.js'; /** * Resolve a Python import to a file path (low-level helper). @@ -74,23 +74,3 @@ export function resolvePythonImportInternal( return null; } - -/** - * Python: relative imports (PEP 328) + proximity-based bare imports. - * Falls through to standard suffix resolution when proximity finds no match. - */ -export function resolvePythonImport( - rawImportPath: string, - filePath: string, - ctx: ResolveCtx, -): ImportResult { - const resolved = resolvePythonImportInternal(filePath, rawImportPath, ctx.allFilePaths); - if (resolved) { - // Store in resolveCache so other files importing the same module skip the - // ancestor walk. The cache key matches resolveStandard's convention. - ctx.resolveCache.set(`${filePath}::${rawImportPath}`, resolved); - return { kind: 'files', files: [resolved] }; - } - if (rawImportPath.startsWith('.')) return null; // relative but unresolved -- don't suffix-match - return resolveStandard(rawImportPath, filePath, ctx, SupportedLanguages.Python); -} diff --git a/gitnexus/src/core/ingestion/import-resolvers/resolver-factory.ts b/gitnexus/src/core/ingestion/import-resolvers/resolver-factory.ts new file mode 100644 index 000000000..4caf748a7 --- /dev/null +++ b/gitnexus/src/core/ingestion/import-resolvers/resolver-factory.ts @@ -0,0 +1,35 @@ +/** + * Import resolver factory — creates a composable import resolver from + * an ordered list of strategies. + * + * Mirrors the method-extractors/generic.ts and call-extractors/generic.ts + * pattern: declare a config per language, produce a runtime resolver via factory. + * + * Each strategy is tried in order. The first non-null result wins. + * A result with an empty `files` array is treated as "handled but unresolved" + * (stops the chain without producing import edges). + */ + +import type { ImportResolverFn, ImportResolutionConfig } from './types.js'; + +/** + * Create an ImportResolverFn from a declarative config. + * + * Chains strategies in declaration order — first non-null result wins. + * Returns null only if every strategy returns null. + * + * Error behaviour: if a strategy throws, the error propagates immediately + * and remaining strategies are not tried. Strategies are expected to be + * pure data transforms that never throw; any unexpected exception indicates + * a bug in the strategy implementation. + */ +export function createImportResolver(config: ImportResolutionConfig): ImportResolverFn { + const { strategies } = config; + return (rawImportPath, filePath, ctx) => { + for (const strategy of strategies) { + const result = strategy(rawImportPath, filePath, ctx); + if (result) return result; + } + return null; + }; +} diff --git a/gitnexus/src/core/ingestion/import-resolvers/ruby.ts b/gitnexus/src/core/ingestion/import-resolvers/ruby.ts index b8ada4cf4..4bf47d31f 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/ruby.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/ruby.ts @@ -1,11 +1,12 @@ /** - * Ruby require/require_relative import resolution. - * Handles path resolution for Ruby's require and require_relative calls. + * Ruby require/require_relative import resolution — internal helpers. + * + * Strategy lives in configs/ruby.ts. + * This file only contains the low-level helper used by the strategy. */ import type { SuffixIndex } from './utils.js'; import { suffixResolve } from './utils.js'; -import type { ImportResult, ResolveCtx } from './types.js'; /** * Resolve a Ruby require/require_relative path to a matching .rb file (low-level helper). @@ -22,18 +23,3 @@ export function resolveRubyImportInternal( const pathParts = importPath.replace(/^\.\//, '').split('/').filter(Boolean); return suffixResolve(pathParts, normalizedFileList, allFileList, index); } - -/** Ruby: require / require_relative. */ -export function resolveRubyImport( - rawImportPath: string, - _filePath: string, - ctx: ResolveCtx, -): ImportResult { - const resolved = resolveRubyImportInternal( - rawImportPath, - ctx.normalizedFileList, - ctx.allFileList, - ctx.index, - ); - return resolved ? { kind: 'files', files: [resolved] } : null; -} diff --git a/gitnexus/src/core/ingestion/import-resolvers/rust.ts b/gitnexus/src/core/ingestion/import-resolvers/rust.ts index 632e19352..2d1dd864a 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/rust.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/rust.ts @@ -1,12 +1,10 @@ /** - * Rust module import resolution. - * Handles crate::, super::, self:: prefix paths and :: separators. + * Rust module import resolution — internal helpers. + * + * Strategy lives in configs/rust.ts. + * This file contains shared helpers used by the strategy and standard.ts. */ -import { SupportedLanguages } from 'gitnexus-shared'; -import type { ImportResult, ResolveCtx } from './types.js'; -import { resolveStandard } from './standard.js'; - /** * Resolve Rust use-path to a file (low-level helper). * Handles crate::, super::, self:: prefixes and :: path separators. @@ -84,49 +82,3 @@ export function tryRustModulePath(modulePath: string, allFiles: Set): st return null; } - -/** Rust: expand grouped imports: use {crate::a, crate::b} and use crate::models::{User, Repo}. */ -export function resolveRustImport( - rawImportPath: string, - filePath: string, - ctx: ResolveCtx, -): ImportResult { - // Top-level grouped: use {crate::a, crate::b} - if (rawImportPath.startsWith('{') && rawImportPath.endsWith('}')) { - const inner = rawImportPath.slice(1, -1); - const parts = inner - .split(',') - .map((p) => p.trim()) - .filter(Boolean); - const resolved: string[] = []; - for (const part of parts) { - const r = resolveRustImportInternal(filePath, part, ctx.allFilePaths); - if (r) resolved.push(r); - } - return resolved.length > 0 ? { kind: 'files', files: resolved } : null; - } - - // Scoped grouped: use crate::models::{User, Repo} - const braceIdx = rawImportPath.indexOf('::{'); - if (braceIdx !== -1 && rawImportPath.endsWith('}')) { - const pathPrefix = rawImportPath.substring(0, braceIdx); - const braceContent = rawImportPath.substring(braceIdx + 3, rawImportPath.length - 1); - const items = braceContent - .split(',') - .map((s) => s.trim()) - .filter(Boolean); - const resolved: string[] = []; - for (const item of items) { - // Handle `use crate::models::{User, Repo as R}` — strip alias for resolution - const itemName = item.includes(' as ') ? item.split(' as ')[0].trim() : item; - const r = resolveRustImportInternal(filePath, `${pathPrefix}::${itemName}`, ctx.allFilePaths); - if (r) resolved.push(r); - } - if (resolved.length > 0) return { kind: 'files', files: resolved }; - // Fallback: resolve the prefix path itself (e.g. crate::models -> models.rs) - const prefixResult = resolveRustImportInternal(filePath, pathPrefix, ctx.allFilePaths); - if (prefixResult) return { kind: 'files', files: [prefixResult] }; - } - - return resolveStandard(rawImportPath, filePath, ctx, SupportedLanguages.Rust); -} diff --git a/gitnexus/src/core/ingestion/import-resolvers/standard.ts b/gitnexus/src/core/ingestion/import-resolvers/standard.ts index a29810a2f..f8aae9625 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/standard.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/standard.ts @@ -8,7 +8,7 @@ import type { SuffixIndex } from './utils.js'; import { tryResolveWithExtensions, suffixResolve } from './utils.js'; import { resolveRustImportInternal } from './rust.js'; import { SupportedLanguages } from 'gitnexus-shared'; -import type { ImportResult, ImportResolverFn, ResolveCtx } from './types.js'; +import type { ImportResult, ImportResolverStrategy, ResolveCtx } from './types.js'; import type { TsconfigPaths } from '../language-config.js'; /** Max entries in the resolve cache. Beyond this, entries are evicted. @@ -174,18 +174,11 @@ export function resolveStandard( return resolvedPath ? { kind: 'files', files: [resolvedPath] } : null; } -/** JavaScript: standard single-file resolution. */ -export const resolveJavascriptImport: ImportResolverFn = (raw, fp, ctx) => - resolveStandard(raw, fp, ctx, SupportedLanguages.JavaScript); +// ============================================================================ +// Strategy factory — composable hook for ImportResolutionConfig +// ============================================================================ -/** TypeScript: standard single-file resolution. */ -export const resolveTypescriptImport: ImportResolverFn = (raw, fp, ctx) => - resolveStandard(raw, fp, ctx, SupportedLanguages.TypeScript); - -/** C: standard single-file resolution for #include directives. */ -export const resolveCImport: ImportResolverFn = (raw, fp, ctx) => - resolveStandard(raw, fp, ctx, SupportedLanguages.C); - -/** C++: standard single-file resolution for #include directives. */ -export const resolveCppImport: ImportResolverFn = (raw, fp, ctx) => - resolveStandard(raw, fp, ctx, SupportedLanguages.CPlusPlus); +/** Create a reusable standard-resolution strategy for a given language. */ +export function createStandardStrategy(language: SupportedLanguages): ImportResolverStrategy { + return (raw, fp, ctx) => resolveStandard(raw, fp, ctx, language); +} diff --git a/gitnexus/src/core/ingestion/import-resolvers/types.ts b/gitnexus/src/core/ingestion/import-resolvers/types.ts index 3e68c38b2..66e23a79b 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/types.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/types.ts @@ -12,6 +12,7 @@ import type { } from '../language-config.js'; import type { SwiftPackageConfig } from '../language-config.js'; import type { SuffixIndex } from './utils.js'; +import type { SupportedLanguages } from 'gitnexus-shared'; /** * Result of resolving an import via language-specific dispatch. @@ -53,3 +54,28 @@ export type ImportResolverFn = ( filePath: string, resolveCtx: ResolveCtx, ) => ImportResult; + +/** + * A single import resolution strategy — one step in a composable chain. + * Same signature as ImportResolverFn. Returns null to let the next strategy + * in the chain try; returns a result (even with empty files) to stop the chain. + */ +export type ImportResolverStrategy = ImportResolverFn; + +/** + * Declarative config for composable import resolution — mirrors the + * MethodExtractionConfig / CallExtractionConfig pattern. + * + * Each language declares an ordered list of strategies to try. + * The factory (`createImportResolver`) chains them: first non-null result wins. + */ +export interface ImportResolutionConfig { + /** + * Documentation-only metadata identifying which language this config serves. + * **Not used by `createImportResolver`** — the factory only iterates `strategies`. + * Useful for logging, debugging, and compile-time exhaustiveness checks when + * mapping `SupportedLanguages → ImportResolutionConfig` in language providers. + */ + readonly language: SupportedLanguages; + readonly strategies: readonly ImportResolverStrategy[]; +} diff --git a/gitnexus/src/core/ingestion/import-resolvers/vue.ts b/gitnexus/src/core/ingestion/import-resolvers/vue.ts deleted file mode 100644 index c46e3f725..000000000 --- a/gitnexus/src/core/ingestion/import-resolvers/vue.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Vue import resolver — delegates to TypeScript's standard resolver. - * - * Vue