diff --git a/.cursor/.gitignore b/.cursor/.gitignore new file mode 100644 index 000000000..8bf7cc27a --- /dev/null +++ b/.cursor/.gitignore @@ -0,0 +1 @@ +plans/ diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..e09631a0a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,19 @@ +.git +.gitignore +.DS_Store + +node_modules +**/node_modules + +dist +**/dist +coverage +**/coverage + +.env +.env.local +.env.*.local + +.gitnexus +gitnexus-web/playwright-report +gitnexus-web/test-results diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..ec967c79e --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# Images (signed Cosign keyless on every push from main / vX.Y.Z tags) +SERVER_IMAGE=ghcr.io/abhigyanpatwari/gitnexus:latest +WEB_IMAGE=ghcr.io/abhigyanpatwari/gitnexus-web:latest + +# Container names +SERVER_CONTAINER_NAME=gitnexus-server +WEB_CONTAINER_NAME=gitnexus-web + +# Host ports — the web UI expects the server on http://localhost:4747 by default. +SERVER_HOST_PORT=4747 +WEB_HOST_PORT=4173 + +# Optional read-only mount, exposed to the server as /workspace. +# Override with the directory that contains the repos you want to index. +WORKSPACE_DIR=./ 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/scripts/check-workflow-concurrency.py b/.github/scripts/check-workflow-concurrency.py index 300cc7f4b..0d7a49291 100644 --- a/.github/scripts/check-workflow-concurrency.py +++ b/.github/scripts/check-workflow-concurrency.py @@ -11,10 +11,14 @@ Rules: `concurrency:` block. 2. Reusable workflows (on: workflow_call ONLY) do NOT declare one. 3. The `concurrency.group` expression MUST reference either - `${{ github.workflow }}` or a literal `CI-` prefix (the documented - ci.yml reusable-workflow-safe exception). This is checked by substring - containment rather than prefix match because ci.yml's group is a - conditional expression that resolves to a `CI-…` literal at runtime. + `${{ github.workflow }}` or one of the approved hardcoded literal prefixes + for workflows that are simultaneously entry-points AND reusable (on: push/ + workflow_call). Two such exceptions are currently approved: + - `CI-` for ci.yml (the original canonical form) + - `docker-build-push-` for docker.yml + This is checked by substring containment rather than prefix match because + the group value is a conditional expression that resolves to a `CI-…` or + `docker-build-push-…` literal at runtime. We deliberately do not use a YAML library — keeps the script dependency-free on any vanilla runner. `on:` block parsing is line-based and handles both the @@ -28,7 +32,7 @@ import re import sys -REQUIRED_TOKENS = ("${{ github.workflow }}", "CI-") +REQUIRED_TOKENS = ("${{ github.workflow }}", "CI-", "docker-build-push-") def is_reusable(lines: list[str]) -> bool: @@ -150,8 +154,10 @@ def check(workflows_dir: pathlib.Path) -> int: if not any(token in group for token in REQUIRED_TOKENS): print( f"::error file={path}::concurrency.group `{group}` must " - f"reference one of {REQUIRED_TOKENS}. See CONTRIBUTING.md -> " - "GitHub Actions — Concurrency Convention." + f"reference one of {REQUIRED_TOKENS} (use ${{{{ github.workflow }}}} " + "for normal entry-point workflows; use an approved literal prefix " + "only for workflows that are both entry-points AND reusable — " + "see CONTRIBUTING.md -> GitHub Actions — Concurrency Convention)." ) fail = 1 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-report.yml b/.github/workflows/ci-report.yml index a1fa33219..ab8d8362c 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'); @@ -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 }} diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 27eb75383..7010ee6f3 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -41,6 +41,9 @@ jobs: --outputFile=web-test-results.json working-directory: gitnexus-web + - name: Run docker-server integration tests + run: node --test docker-server.test.mjs + - name: Upload test reports if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 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/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/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 000000000..1b83a1e4b --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,199 @@ +name: Docker Build & Push + +on: + push: + tags: + - 'v*' + # No workflow_dispatch: publishing is exclusively tag-driven so that every + # signed image corresponds 1:1 to a published `gitnexus@X.Y.Z` on npm. A + # manual run from a branch ref would fail the version check below anyway. + workflow_call: + inputs: + tag: + description: >- + The full v-prefixed tag to build (e.g. v1.2.3-rc.1). + The tag must already exist in the repo and its tree must contain + a gitnexus/package.json whose version matches the tag. + required: true + type: string + +# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention". +# Tag refs are unique per release, so distinct tags run in parallel. +# Re-pushes of the same tag serialize. cancel-in-progress: false — never cancel a publish mid-flight. +# Hardcoded `docker-build-push-` prefix (not `${{ github.workflow }}`) when invoked as a reusable +# workflow: in called-workflow context `github.workflow` is ambiguous and could resolve to the +# caller's name, sharing a concurrency group with the caller → deadlock. +# Direct tag-push invocations use `docker-build-push-`; workflow_call invocations get a +# per-run-unique group (they are already serialized by the caller's own concurrency group). +concurrency: + group: ${{ (github.event_name == 'push') && format('docker-build-push-{0}', github.ref) || format('docker-build-push-nested-{0}', github.run_id) }} + cancel-in-progress: false + +jobs: + build-push: + name: Build & Push ${{ matrix.image.name }} + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: read + packages: write + # Required for Cosign keyless signing via the OIDC token exchange, + # and for build provenance / SBOM attestations. + id-token: write + attestations: write + + strategy: + fail-fast: false + matrix: + image: + # Static UI bundle. Small, fast image. Drop-in replacement for the + # legacy single-image setup at the same `gitnexus` repository slug + # is intentionally avoided — the UI now lives at `gitnexus-web` and + # the CLI/server takes the canonical `gitnexus` slug below. + - name: gitnexus-web + dockerfile: Dockerfile.web + slug: gitnexus-web + # CLI / `gitnexus serve` backend. Heavy native deps (tree-sitter, + # onnxruntime-node) live only in this image. + - name: gitnexus + dockerfile: Dockerfile.cli + slug: gitnexus + + steps: + - name: Validate tag input + if: github.event_name == 'workflow_call' + shell: bash + env: + TAG_INPUT: ${{ inputs.tag }} + run: | + if [ -z "${TAG_INPUT}" ]; then + echo "::error::No tag provided to docker.yml — refusing to build/push." + exit 1 + fi + + # When triggered by workflow_call the caller passes the RC tag as an input; + # we check out that tag so the Dockerfile and package.json match the built image. + # For tag-push events github.ref is already the tag ref — no override needed. + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ inputs.tag || github.ref }} + + # ── Lock the docker image version to the npm package version ────────── + # Mirrors the check in publish.yml: refuse to build unless the git tag + # exactly matches `gitnexus/package.json`'s version. This guarantees + # `ghcr.io//gitnexus:X.Y.Z` always corresponds to the same + # `gitnexus@X.Y.Z` published to npm — no drift, no surprises. + - name: Verify tag matches gitnexus/package.json version + id: version + shell: bash + env: + # For workflow_call the tag comes from the caller input; for push events + # it is derived from GITHUB_REF (set to empty so the else-branch fires). + INPUT_TAG: ${{ inputs.tag }} + run: | + if [ -n "$INPUT_TAG" ]; then + TAG_VERSION="${INPUT_TAG#v}" + else + TAG_VERSION="${GITHUB_REF#refs/tags/v}" + fi + if ! [[ "$TAG_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then + echo "::error::Tag does not follow semver: v$TAG_VERSION" + exit 1 + fi + PKG_VERSION=$(node -p "require('./gitnexus/package.json').version") + if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then + echo "::error::Tag version (v$TAG_VERSION) does not match gitnexus/package.json version ($PKG_VERSION)" + exit 1 + fi + echo "version=$PKG_VERSION" >> "$GITHUB_OUTPUT" + echo "Version verified: $PKG_VERSION" + + # Required for multi-platform (linux/arm64) emulation. + - name: Set up QEMU + uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - name: Install Cosign + uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Computes image tags and labels from the verified semver tag: + # v1.2.3 → :1.2.3, :1.2, :1, :latest (auto, only for non-prerelease) + # v1.2.3-rc.1 → :1.2.3-rc.1 only (prereleases never become :latest) + # `:latest` is only emitted for tag pushes thanks to `flavor: latest=auto`, + # ensuring it always points at a real npm-published version. + # + # For workflow_call invocations github.ref is the caller's branch ref, so + # the type=semver patterns would not match. In that case we add an explicit + # type=raw tag using the version already verified above, so the same + # image-naming rules apply regardless of how the workflow was triggered. + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 + with: + images: ghcr.io/${{ github.repository_owner }}/${{ matrix.image.slug }} + flavor: latest=auto + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=raw,value=${{ steps.version.outputs.version }},enable=${{ github.event_name == 'workflow_call' }} + + - name: Build and push + id: build + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + with: + context: . + file: ${{ matrix.image.dockerfile }} + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha,scope=${{ matrix.image.slug }} + cache-to: type=gha,mode=max,scope=${{ matrix.image.slug }} + provenance: mode=max + sbom: true + + # Cosign keyless signing. Each pushed tag is signed by the workflow's + # OIDC identity, so consumers can verify the image with the strict, + # fully-anchored identity regex (kept in sync with README.md and + # deploy/kubernetes/cluster-image-policy.yaml — update all three together). + # NOTE: `${...}` expression syntax is NOT evaluated inside YAML comments, so + # the example below uses literal `/` placeholders that consumers + # substitute themselves; the canonical, fully-rendered command lives in README.md. + # cosign verify ghcr.io//: \ + # --certificate-identity-regexp '^https://github\.com///\.github/workflows/docker\.yml@refs/tags/v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$' \ + # --certificate-oidc-issuer https://token.actions.githubusercontent.com + # Do NOT relax to `@.*` — that accepts signatures from any ref, including + # unprotected branches and PRs, and defeats the supply-chain guarantee. + - name: Sign image with Cosign (keyless) + env: + # Cosign v2 (installed by sigstore/cosign-installer above) makes + # keyless the default. COSIGN_EXPERIMENTAL is a v1-only opt-in flag + # that is now deprecated/no-op, so it is intentionally omitted. + DIGEST: ${{ steps.build.outputs.digest }} + TAGS: ${{ steps.meta.outputs.tags }} + run: | + # Sign every tag at the same digest so consumers can verify by tag or by digest. + # Use `while read` instead of `for $TAGS` to be robust against tags that + # could ever contain whitespace (the metadata-action output is newline- + # separated, not space-separated). + while IFS= read -r tag; do + [[ -n "$tag" ]] && cosign sign --yes "${tag}@${DIGEST}" + done <<< "$TAGS" + + # Attach the SBOM produced by buildx as a verifiable attestation on the digest. + - name: Generate build provenance attestation + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + with: + subject-name: ghcr.io/${{ github.repository_owner }}/${{ matrix.image.slug }} + subject-digest: ${{ steps.build.outputs.digest }} + push-to-registry: true 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; diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml index aab2fcaef..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,9 +57,9 @@ 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 - - uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017 # 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 }} with: @@ -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. - - uses: release-drafter/release-drafter@3f0f87098bd6b5c5b9a36d49c41d998ea58f9348 # v6.0.0 + # 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 }} 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..61782da1c 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -125,6 +125,8 @@ jobs: permissions: contents: write # push rc tag + marker id-token: write # npm provenance + outputs: + vtag: ${{ steps.reltag.outputs.vtag }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -346,7 +348,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 }} @@ -364,3 +366,23 @@ jobs: Release candidates are pre-stable builds intended for early testing. Stable releases remain on the `latest` dist-tag. + + # ── Build & push RC Docker images ──────────────────────────────────── + # Calls docker.yml as a reusable workflow so that the build, signing, and + # attestation logic stays in one place. The publish job exposes `vtag` + # (e.g. `v1.2.3-rc.1`) as an output so we can pass it as the tag input. + # RC images are signed with Cosign keyless signing; the OIDC identity + # will be `docker.yml@refs/heads/main` (the caller's ref) rather than a + # tag ref — see README.md § Docker for the correct verify command for RCs. + docker: + name: Build & Push RC Docker images + needs: [guard, publish] + if: needs.guard.outputs.should_run == 'true' && needs.publish.outputs.vtag != '' + uses: ./.github/workflows/docker.yml + permissions: + contents: read + packages: write + id-token: write + attestations: write + with: + tag: ${{ needs.publish.outputs.vtag }} 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}`); + } diff --git a/.gitignore b/.gitignore index 88419e481..92027b529 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ Thumbs.db .env .env.local .env.*.local +docker/.env # Logs *.log @@ -104,4 +105,4 @@ local_docs/ # Local agent scratch / review prompts (never commit) .tmp/ -.agents/ \ No newline at end of file +.agents/ diff --git a/AGENTS.md b/AGENTS.md index 651657b02..f4fbcadc0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,117 +1,122 @@ - - + + -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)** +- **Call-resolution DAG:** See ARCHITECTURE.md § Call-Resolution DAG. Typed 6-stage DAG inside the `parse` phase; language-specific behavior behind `inferImplicitReceiver` / `selectDispatch` hooks on `LanguageProvider`. Shared code in `gitnexus/src/core/ingestion/` must not name languages. Types: `gitnexus/src/core/ingestion/call-types.ts`. +- **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()`. +- Add language-specific behavior to shared ingestion code (`gitnexus/src/core/ingestion/`) — use a `LanguageProvider` hook. Seeing `provider.mroStrategy === 'xxx'` or an import from `languages/xxx.ts` in shared code means stop and add a hook. ## 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 +124,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..d934cd8b6 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` + `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/` | +| 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,235 @@ 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 +## Call-Resolution DAG + +Typed 6-stage pipeline in `call-processor.ts` (inside the `parse` phase) that resolves method/function calls and emits CALLS edges. Language behavior plugs in at two `LanguageProvider` hook points (stages 3–4); shared code names no languages. Scope: call resolution only — import resolution, type extraction, heritage, and symbol-table population live in other phases. + +### Stages + +``` +extract-call ──▶ classify-form ──▶ infer-receiver ──▶ select-dispatch ──▶ resolve-target ──▶ emit-edge + (1) (2) (3) [hook] (4) [hook] (5) (6) +``` + +| Stage | Produces | Location | +|-------|----------|----------| +| **extract-call** | `ExtractedCallSite` (name, form, receiver, argCount) | `call-extractors/` (per-language); runs in worker | +| **classify-form** | callForm (`free`/`member`/`constructor`) + arity | `call-analysis.ts` → `inferCallForm`; shared, runs in worker | +| **infer-receiver** | `ReceiverEnriched` (receiver type finalized) | `call-processor.ts`; shared default chain, then `inferImplicitReceiver` hook | +| **select-dispatch** | `DispatchDecision` (primary, fallback, ancestryView) | `selectDispatch` hook, falls back to shared default | +| **resolve-target** | `TieredCandidates` | `model/resolve.ts` → `lookupMethodByOwnerWithMRO` (MRO walk) | +| **emit-edge** | CALLS edge in graph | `call-processor.ts`; writes edge with confidence tier | + +### Provider hooks + +Both hooks are optional on `LanguageProvider`. Ruby is the only current implementer. + +**`inferImplicitReceiver`** — called after shared infer-receiver defaults. Returns `ImplicitReceiverOverride | null`. + +| | | +|---|---| +| Inputs | `calledName`, `callForm`, `receiverName`, `receiverTypeName`, `callNode` (AST), `filePath` | +| Non-null fields | `callForm`, `receiverName`, `receiverTypeName` (required); `receiverSource: 'implicit-self'` (fixed); `hint?` (opaque, passed to `selectDispatch`) | +| Null | Keep existing `ReceiverEnriched` state | + +**`selectDispatch`** — called after infer-receiver (including hook). Returns `DispatchDecision | null`; null uses shared default (constructor → `primary:'constructor'`; typed receiver → `primary:'owner-scoped'`; else → `primary:'free'`). + +| | | +|---|---| +| Inputs | `calledName`, `callForm`, `receiverName`, `receiverTypeName`, `receiverSource`, `hint` | +| Non-null fields | `primary: 'owner-scoped' \| 'free' \| 'constructor'`; `fallback?: 'free-arity-narrowed'`; `ancestryView?: 'instance' \| 'singleton'`; `hint?` | + +**`DispatchDecision` field semantics:** +- `primary: 'owner-scoped'` — MRO walk from receiver's type; used when receiver type is known. +- `fallback: 'free-arity-narrowed'` — after owner-scoped miss, search free-call candidates by arity only (Ruby uses this for implicit-self calls that miss their owner's MRO). +- `ancestryView: 'singleton'` — walk singleton/class ancestry instead of instance ancestry (Ruby `def self.foo` bodies, so `extend`-ed methods are found). + +### Adding language behavior + +1. **Implicit receivers** — implement `inferImplicitReceiver`: return null if call already has a receiver; otherwise use `findEnclosingClassInfo` (`ast-helpers.ts`) to find the enclosing context, return `ImplicitReceiverOverride` with `receiverSource: 'implicit-self'`, and optionally set `hint` for `selectDispatch`. +2. **Custom dispatch** — implement `selectDispatch`: inspect `receiverSource` and `hint`, return `DispatchDecision` with `primary`, optional `fallback`, optional `ancestryView`; return null to keep shared defaults. +3. **MRO strategy** — confirm `mroStrategy` is `'first-wins'`, `'c3'`, `'ruby-mixin'`, or `'none'`; consumed by `lookupMethodByOwnerWithMRO`. + +**Ruby example** (`languages/ruby.ts` + `utils/ruby-self-call.ts`): `inferImplicitReceiver` rewrites bare-identifier calls to `self.method` and sets `hint` to `'instance'`/`'singleton'`; `selectDispatch` uses hint for `ancestryView` and adds `fallback: 'free-arity-narrowed'` for implicit-self calls. + +### Code references + +| Module | Purpose | +|--------|---------| +| `core/ingestion/call-types.ts` | DAG types: `ReceiverEnriched`, `DispatchDecision`, `ImplicitReceiverOverride` | +| `core/ingestion/language-provider.ts` | Hook signatures: `inferImplicitReceiver`, `selectDispatch` | +| `core/ingestion/call-processor.ts` | `processCalls`: stages 3–6 | +| `core/ingestion/model/resolve.ts` | `lookupMethodByOwnerWithMRO`: stage 5 MRO walk | +| `core/ingestion/languages/ruby.ts` | Both hooks + `mroStrategy: 'ruby-mixin'` | +| `core/ingestion/utils/ruby-self-call.ts` | Bare-call rewrite for `inferImplicitReceiver` | + +--- + +## 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 + +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 | +|------|-----------|-----------| +| 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/CLAUDE.md b/CLAUDE.md index 8b6f74ab8..af4069fcd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,6 +35,7 @@ If always-on instructions grow, load deep conventions via conditional reads (e.g ## Reference Documentation - **This repository:** [AGENTS.md](AGENTS.md) (Cursor + monorepo notes), [ARCHITECTURE.md](ARCHITECTURE.md), [CONTRIBUTING.md](CONTRIBUTING.md), [GUARDRAILS.md](GUARDRAILS.md). +- **Call-resolution DAG:** See ARCHITECTURE.md § Call-Resolution DAG. Shared pipeline code in `gitnexus/src/core/ingestion/` must not name languages — use `LanguageProvider` hooks instead (see AGENTS.md). - **GitNexus:** `.claude/skills/gitnexus/`; MCP and indexed-repo rules live only in [AGENTS.md](AGENTS.md) (`gitnexus:start` … `gitnexus:end`). See **GitNexus rules** below. ## Changelog @@ -50,206 +51,4 @@ If always-on instructions grow, load deep conventions via conditional reads (e.g ## GitNexus rules -GitNexus MCP rules are in the ` -# 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. - -> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal 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"})`. - -## 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 - -## 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. - -## 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. - -## Tools Quick Reference - -| Tool | When to use | Command | -|------|-------------|---------| -| `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 ..."})` | - -## Impact Risk Levels - -| Depth | Meaning | Action | -|-------|---------|--------| -| d=1 | WILL BREAK — direct callers/importers | MUST update these | -| d=2 | LIKELY AFFECTED — indirect deps | Should test | -| d=3 | MAY NEED TESTING — transitive | Test if critical path | - -## Resources - -| Resource | Use for | -|----------|---------| -| `gitnexus://repo/GitNexus/context` | Codebase overview, check index freshness | -| `gitnexus://repo/GitNexus/clusters` | All functional areas | -| `gitnexus://repo/GitNexus/processes` | All execution flows | -| `gitnexus://repo/GitNexus/process/{name}` | Step-by-step execution trace | - -## 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 - -## Keeping the Index Fresh - -After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it: - -```bash -npx gitnexus analyze -``` - -If the index previously included embeddings, preserve them by adding `--embeddings`: - -```bash -npx gitnexus analyze --embeddings -``` - -To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.** - -> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`. - -## CLI - -| Task | Read this skill file | -|------|---------------------| -| 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` | - -` block in **[AGENTS.md](AGENTS.md)** — load that section when working with MCP tools or the graph index. - - -# GitNexus — Code Intelligence - -This project is indexed by GitNexus as **GitNexus** (3298 symbols, 7954 relationships, 185 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. - -> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. - -## 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"})`. - -## 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 - -## 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. - -## 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. - -## Tools Quick Reference - -| Tool | When to use | Command | -|------|-------------|---------| -| `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 ..."})` | - -## Impact Risk Levels - -| Depth | Meaning | Action | -|-------|---------|--------| -| d=1 | WILL BREAK — direct callers/importers | MUST update these | -| d=2 | LIKELY AFFECTED — indirect deps | Should test | -| d=3 | MAY NEED TESTING — transitive | Test if critical path | - -## Resources - -| Resource | Use for | -|----------|---------| -| `gitnexus://repo/GitNexus/context` | Codebase overview, check index freshness | -| `gitnexus://repo/GitNexus/clusters` | All functional areas | -| `gitnexus://repo/GitNexus/processes` | All execution flows | -| `gitnexus://repo/GitNexus/process/{name}` | Step-by-step execution trace | - -## 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 - -## Keeping the Index Fresh - -After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it: - -```bash -npx gitnexus analyze -``` - -If the index previously included embeddings, preserve them by adding `--embeddings`: - -```bash -npx gitnexus analyze --embeddings -``` - -To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.** - -> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`. - -## CLI - -| Task | Read this skill file | -|------|---------------------| -| 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` | - - +See the `` block in **[AGENTS.md](AGENTS.md)** for the canonical MCP tools, impact analysis rules, and index instructions. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d2d48f017..7f797f9a0 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 @@ -77,7 +77,7 @@ Every workflow under `.github/workflows/` MUST declare a top-level `concurrency: - Per-PR scope (for `issue_comment`, `pull_request_review*`, `pull_request` meta events): `${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number }}` - `workflow_run` scope (e.g. `ci-report.yml`): `${{ github.workflow }}-${{ github.event.workflow_run.pull_requests[0].number || format('{0}/{1}', github.event.workflow_run.head_repository.full_name, github.event.workflow_run.head_branch) }}` — the fork fallback must be stable across reruns (never `workflow_run.id`, which is per-run-unique and defeats serialization). - Global single-slot (manual dispatch utilities): `${{ github.workflow }}` - - **Reusable workflows invoked via `workflow_call`:** do NOT use `${{ github.workflow }}` in the group key — in called-workflow context its evaluation is ambiguous and can resolve to the caller's name, which would deadlock against the caller's own group. Use a hardcoded literal prefix and a `github.event_name`-aware expression that falls through to `github.run_id` for reusable invocations (see `ci.yml` for the canonical form). + - **Reusable workflows invoked via `workflow_call`:** do NOT use `${{ github.workflow }}` in the group key — in called-workflow context its evaluation is ambiguous and can resolve to the caller's name, which would deadlock against the caller's own group. Use a hardcoded literal prefix and a `github.event_name`-aware expression that falls through to `github.run_id` for reusable invocations (see `ci.yml` for the canonical form). Approved literal prefixes: `CI-` (`ci.yml`) and `docker-build-push-` (`docker.yml`). The `check-workflow-concurrency.py` validation script must be updated whenever a new approved literal prefix is added. - **Merge queue (`merge_group`)**: when this event is added, use `${{ github.workflow }}-${{ github.event.merge_group.head_ref }}` with `cancel-in-progress: false` (every queue entry is a distinct ref; never cancel). - **`cancel-in-progress` policy:** @@ -127,6 +127,11 @@ Two publish workflows ship `gitnexus` to npm: the cycle from `latest`. - `N` is auto-incremented against existing `X.Y.Z-rc.*` entries on the registry. First rc for a given base is `rc.1`. + - After the npm publish succeeds, the workflow calls `docker.yml` as a + reusable workflow to build and push the corresponding RC Docker images + (e.g. `ghcr.io/abhigyanpatwari/gitnexus:1.7.0-rc.1`). The images are + signed with Cosign; the OIDC identity is `docker.yml@refs/heads/main` + (the caller's ref — see README.md § Docker for the verify command). Idempotency: the workflow pushes an `rc/` marker tag and a `v` release tag **atomically, before** calling `npm publish`. The guard @@ -140,6 +145,27 @@ Two publish workflows ship `gitnexus` to npm: # then redispatch the workflow with force: true ``` + **Docker-only partial failure:** if `publish` succeeds (npm tarball + tags + are live) but the `docker` job subsequently fails (e.g. GHCR flakiness), + the npm RC is already published and the `rc/` marker is in place. + Re-running `release-candidate.yml` with `force: true` will abort at the + "Version already exists on npm" guard. To recover without cutting a new RC: + + ```bash + # 1. Manually trigger only the docker workflow, passing the existing RC tag: + gh workflow run docker.yml --ref main -f tag=v + # (requires a workflow_dispatch trigger on docker.yml — see note below) + ``` + + Because `docker.yml` intentionally has no `workflow_dispatch` (images are + tag-driven by design), the practical recovery options are: + - Wait for the next commit on `main`, which will cut a new RC that includes + the Docker build. + - Manually run `docker build` + `docker push` locally and sign with Cosign + against the same digest. + - Delete `rc/` and `v` tags, then redispatch with `force: + true` to re-run the full RC pipeline (cuts a new RC number). + The rc workflow never moves `latest`. To verify after a change, inspect dist-tags: ```bash diff --git a/Dockerfile.cli b/Dockerfile.cli new file mode 100644 index 000000000..d1d4f45a4 --- /dev/null +++ b/Dockerfile.cli @@ -0,0 +1,57 @@ +ARG BUILDPLATFORM +ARG TARGETPLATFORM + +# ── Builder ──────────────────────────────────────────────────────────── +# Native modules (tree-sitter-*, onnxruntime-node, node-gyp builds for +# tree-sitter-proto / tree-sitter-swift) require python3 + a C/C++ toolchain. +FROM node:22-alpine AS builder + +WORKDIR /app + +# Toolchain for node-gyp / native builds. +RUN apk add --no-cache python3 make g++ git + +# Build gitnexus-shared first — gitnexus depends on it as a workspace. +COPY gitnexus-shared/package.json gitnexus-shared/package-lock.json ./gitnexus-shared/ +RUN npm ci --prefix gitnexus-shared +COPY gitnexus-shared ./gitnexus-shared +RUN npm run build --prefix gitnexus-shared + +# Copy the full gitnexus package before installing — `npm ci` triggers +# `postinstall` (patches tree-sitter-swift, builds the vendored +# tree-sitter-proto) and `prepare` (compiles TypeScript via scripts/build.js), +# both of which need the source tree. +COPY gitnexus ./gitnexus +RUN npm ci --prefix gitnexus + +# Drop dev dependencies for a smaller runtime layer. +RUN npm prune --omit=dev --prefix gitnexus + +# ── Runtime ──────────────────────────────────────────────────────────── +FROM node:22-alpine AS runtime + +# curl for the healthcheck; git so `gitnexus` can clone repos at runtime. +RUN apk add --no-cache curl git + +WORKDIR /app + +# Pre-create the data directory and hand it to the unprivileged `node` user +# so the bind-mounted volume is writable without root. +RUN mkdir -p /data/gitnexus && chown -R node:node /data + +COPY --from=builder --chown=node:node /app/gitnexus/dist ./gitnexus/dist +COPY --from=builder --chown=node:node /app/gitnexus/node_modules ./gitnexus/node_modules +COPY --from=builder --chown=node:node /app/gitnexus/package.json ./gitnexus/package.json +COPY --from=builder --chown=node:node /app/gitnexus/vendor ./gitnexus/vendor + +USER node + +# The web UI defaults to http://localhost:4747 — keep that contract. +ENV GITNEXUS_HOME=/data/gitnexus \ + NODE_ENV=production \ + PORT=4747 + +EXPOSE 4747 + +# Bind to 0.0.0.0 so the server is reachable from the host's mapped port. +CMD ["node", "gitnexus/dist/cli/index.js", "serve", "--host", "0.0.0.0", "--port", "4747"] diff --git a/Dockerfile.web b/Dockerfile.web new file mode 100644 index 000000000..d4f342509 --- /dev/null +++ b/Dockerfile.web @@ -0,0 +1,35 @@ +ARG BUILDPLATFORM +ARG TARGETPLATFORM + +FROM --platform=$BUILDPLATFORM node:22-alpine AS builder + +WORKDIR /app + +COPY gitnexus-shared/package.json gitnexus-shared/package-lock.json ./gitnexus-shared/ +RUN npm ci --prefix gitnexus-shared + +COPY gitnexus-shared ./gitnexus-shared +RUN npm run build --prefix gitnexus-shared + +COPY gitnexus-web/package.json gitnexus-web/package-lock.json ./gitnexus-web/ +RUN npm ci --prefix gitnexus-web + +COPY gitnexus-web ./gitnexus-web +RUN npm run build --prefix gitnexus-web + +FROM node:22-alpine AS runtime + +RUN apk add --no-cache curl + +WORKDIR /app + +COPY --from=builder /app/gitnexus-web/dist ./dist +COPY docker-server.mjs ./docker-server.mjs + +RUN chown -R node:node /app + +USER node + +EXPOSE 4173 + +CMD ["node", "docker-server.mjs"] 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/MIGRATION.md b/MIGRATION.md index ec9fdabc2..88488b0ae 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,5 +1,49 @@ # Migration Guide +## `impact` tool may now return `{ status: 'ambiguous' }` (PR #888, issue #470) + +Before this change the `impact` MCP tool silently picked the first match +when the `target` name hit multiple symbols (Class → Interface → Function +→ Method → Constructor priority UNION). This often produced analysis for +the wrong symbol with no signal back to the caller. + +After this change, when the resolver finds more than one viable match +and the caller supplied none of `target_uid` / `file_path` / `kind`, +`impact` returns a disambiguation response shaped like: + +```json +{ + "status": "ambiguous", + "message": "Found N symbols matching ''. Use target_uid, file_path, or kind to disambiguate.", + "target": { "name": "" }, + "direction": "upstream", + "impactedCount": 0, + "risk": "UNKNOWN", + "candidates": [ + { "uid": "...", "name": "...", "kind": "Function", "filePath": "...", "line": 42, "score": 0.76 } + ] +} +``` + +### Do I need to migrate? + +**Probably not, but check for assumptions.** Callers that unconditionally +read `result.byDepth` / `result.summary` / `result.affected_processes` +without first checking `result.status` will now see `undefined` in the +ambiguous case. The fix is to branch on `result.status === 'ambiguous'` +first and follow up with `target_uid` (preferred) or `file_path` / `kind`. + +The `context` tool's ambiguous response is a strict superset of the +existing shape — every candidate gains a `score` field, no existing field +has changed. No migration required for `context` callers. + +### What happens on re-index? + +Nothing — this is an MCP-surface change only. The graph schema, indexer, +and stored data are untouched. + +--- + ## OVERRIDES → METHOD_OVERRIDES (PR #642) The `OVERRIDES` relationship type has been renamed to `METHOD_OVERRIDES` for diff --git a/README.md b/README.md index 65ac7d332..4c273ab47 100644 --- a/README.md +++ b/README.md @@ -335,6 +335,155 @@ cd ../gitnexus-web && npm install npm run dev ``` +## Docker + +The official Docker setup ships **two signed images** orchestrated by `docker-compose.yaml`: + +| Image | Purpose | +| -------------------------------------------------- | ---------------------------------------------------------------------- | +| `ghcr.io/abhigyanpatwari/gitnexus:latest` | CLI / `gitnexus serve` backend (HTTP API on port `4747`, MCP, indexer) | +| `ghcr.io/abhigyanpatwari/gitnexus-web:latest` | Static web UI (port `4173`) | + +> **Heads-up — image rename.** Earlier releases published the web UI under +> `ghcr.io/abhigyanpatwari/gitnexus`. Starting with the introduction of the +> bundled backend, that slug now hosts the CLI/server image and the UI moved +> to `ghcr.io/abhigyanpatwari/gitnexus-web`. The previous tags remain +> available for pulling, but new versions are only published under the new +> slugs. Update your `docker run` / compose files accordingly (or just adopt +> the bundled compose). + +### One-command setup + +```bash +docker compose up -d +``` + +This starts the server on `http://localhost:4747` and the web UI on +`http://localhost:4173`. The UI auto-detects the server because the browser +runs on the host and reaches the container via the mapped port. + +A named volume (`gitnexus-data`) persists the global registry, indexes, and +cloned repos at `/data/gitnexus` inside the server container. To make repos on +your host machine indexable, set `WORKSPACE_DIR` before bringing the stack up: + +```bash +WORKSPACE_DIR=$HOME/code docker compose up -d +# Inside the server container the directory is mounted read-only at /workspace. +docker compose exec gitnexus-server gitnexus index /workspace/my-repo +``` + +### Direct `docker run` + +```bash +# Server +docker run --rm -d \ + --name gitnexus-server \ + -p 4747:4747 \ + -v gitnexus-data:/data/gitnexus \ + ghcr.io/abhigyanpatwari/gitnexus:latest + +# Web UI +docker run --rm -d \ + --name gitnexus-web \ + -p 4173:4173 \ + ghcr.io/abhigyanpatwari/gitnexus-web:latest +``` + +Optional env file (override image tags, container names, ports, workspace dir): + +```bash +cp .env.example .env +docker compose --env-file .env up -d +``` + +### Versioning & supply-chain protection + +The Docker images are version-locked to the npm package: + +- Stable images are **only published from `vX.Y.Z` git tags** (via `docker.yml` + triggered directly by the tag push), and the workflow refuses to build unless + the tag exactly matches `gitnexus/package.json`'s version. So + `ghcr.io/abhigyanpatwari/gitnexus:1.6.2` is byte-for-byte the same release + as `npm install gitnexus@1.6.2` — no drift, no floating builds from `main`. +- Release-candidate images (e.g. `:1.7.0-rc.1`) are published alongside each + RC npm release. They are built by `release-candidate.yml` calling `docker.yml` + as a reusable workflow after the RC tag is created and pushed. +- `:latest` is auto-promoted only from non-prerelease tags by the Docker + metadata action, so it always points at a real, npm-published version. + +Both images are signed with [Cosign keyless signing][cosign-keyless] using the +workflow's GitHub OIDC identity, and shipped with build provenance and SBOM +attestations. **This is your protection against supply-chain attacks**: even if +an attacker republishes a same-named image elsewhere (or somehow pushes to a +typo-squatted registry), they cannot forge a Cosign signature tied to +`abhigyanpatwari/GitNexus`'s `docker.yml`. Always verify before pulling into +sensitive environments: + +**Stable releases** — signed from the `v*` tag ref: + +```bash +cosign verify ghcr.io/abhigyanpatwari/gitnexus:1.6.2 \ + --certificate-identity-regexp '^https://github\.com/abhigyanpatwari/GitNexus/\.github/workflows/docker\.yml@refs/tags/v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$' \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com +``` + +The regex pins the certificate identity to this repo's `docker.yml` workflow +**run from a `v*` tag** — rejecting unsigned images, images signed by other +workflows, and images signed from unprotected refs. + +**Release candidates** — signed from `refs/heads/main` (the caller's ref when +`release-candidate.yml` invokes `docker.yml` as a reusable workflow): + +```bash +cosign verify ghcr.io/abhigyanpatwari/gitnexus:1.7.0-rc.1 \ + --certificate-identity 'https://github.com/abhigyanpatwari/GitNexus/.github/workflows/docker.yml@refs/heads/main' \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com +``` + +You can also inspect the build provenance and SBOM: + +```bash +cosign download attestation ghcr.io/abhigyanpatwari/gitnexus:1.6.2 \ + --predicate-type https://slsa.dev/provenance/v1 +``` + +#### Kubernetes: enforce signatures at admission + +For Kubernetes deployments, ship the bundled +[`ClusterImagePolicy`](deploy/kubernetes/cluster-image-policy.yaml) so the +[Sigstore policy-controller][policy-controller] rejects any GitNexus pod whose +image is not signed by this repo's `docker.yml` running from a `vX.Y.Z` tag — +the same identity the `cosign verify` snippet above pins. + +```bash +# 1. Install the controller (one-time, cluster-wide) +helm repo add sigstore https://sigstore.github.io/helm-charts && helm repo update +helm install policy-controller -n cosign-system --create-namespace \ + sigstore/policy-controller + +# 2. Opt your namespace in +kubectl label namespace policy.sigstore.dev/include=true + +# 3. Apply the policy +kubectl apply -f deploy/kubernetes/cluster-image-policy.yaml +``` + +After this, attempting to deploy an unsigned image — or one signed by anything +other than `abhigyanpatwari/GitNexus`'s `docker.yml` at a `v*` tag — fails the +admission webhook before a pod is ever created. This turns the verifiable +signature into an enforced policy, which is the supply-chain control most +clusters actually need. + +[cosign-keyless]: https://docs.sigstore.dev/cosign/signing/overview/ +[policy-controller]: https://docs.sigstore.dev/policy-controller/overview/ + +### Files + +- [Dockerfile.web](Dockerfile.web) — builds `gitnexus-shared` and `gitnexus-web`, then serves the production frontend. +- [Dockerfile.cli](Dockerfile.cli) — builds the CLI/server (with its native deps) and runs `gitnexus serve --host 0.0.0.0`. +- [docker-compose.yaml](docker-compose.yaml) — starts both signed images side by side. +- [.env.example](.env.example) — overrides for image names, container names, ports, and the workspace mount. + The web UI uses the same indexing pipeline as the CLI but runs entirely in WebAssembly (Tree-sitter WASM, LadybugDB WASM, in-browser embeddings). It's great for quick exploration but limited by browser memory for larger repos. **Local Backend Mode:** Run `gitnexus serve` and open the web UI locally — it auto-detects the server and shows all your indexed repos, with full AI chat support. No need to re-upload or re-index. The agent's tools (Cypher queries, search, code navigation) route through the backend HTTP API automatically. 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 diff --git a/deploy/kubernetes/cluster-image-policy.yaml b/deploy/kubernetes/cluster-image-policy.yaml new file mode 100644 index 000000000..9287a3c45 --- /dev/null +++ b/deploy/kubernetes/cluster-image-policy.yaml @@ -0,0 +1,64 @@ +# Sigstore policy-controller ClusterImagePolicy for GitNexus container images. +# +# This enforces — at admission time — that every Pod pulling a +# `ghcr.io/abhigyanpatwari/gitnexus` or `gitnexus-web` image is using a build +# that was Cosign-keyless-signed by this repository's `docker.yml` workflow +# running from a `vX.Y.Z` git tag. Unsigned images, images signed by other +# workflows, and images signed from unprotected refs (e.g. `main`, PR branches) +# are rejected. +# +# Prerequisites +# ------------- +# 1. Install the Sigstore policy-controller in your cluster (Helm): +# +# helm repo add sigstore https://sigstore.github.io/helm-charts +# helm repo update +# helm install policy-controller -n cosign-system --create-namespace \ +# sigstore/policy-controller +# +# 2. Opt namespaces in to verification: +# +# kubectl label namespace policy.sigstore.dev/include=true +# +# 3. Apply this policy: +# +# kubectl apply -f deploy/kubernetes/cluster-image-policy.yaml +# +# After this, `kubectl run --image=ghcr.io/abhigyanpatwari/gitnexus:` in +# any opted-in namespace will only succeed if the image carries a valid +# Sigstore signature with the pinned identity. +# +# References +# - https://docs.sigstore.dev/policy-controller/overview/ +# - https://github.com/sigstore/policy-controller +apiVersion: policy.sigstore.dev/v1beta1 +kind: ClusterImagePolicy +metadata: + name: gitnexus-signed-images +spec: + # Apply to both published GitNexus images on GHCR. Image references always + # carry a tag or digest at admission time, so these two globs cover every + # `gitnexus:`, `gitnexus@sha256:...`, `gitnexus-web:`, and + # `gitnexus-web@sha256:...` reference. + images: + - glob: 'ghcr.io/abhigyanpatwari/gitnexus*' + authorities: + - name: gitnexus-cosign-keyless + keyless: + # Public-good Sigstore Fulcio root. + url: https://fulcio.sigstore.dev + identities: + # Pin both the OIDC issuer (GitHub Actions) AND the exact workflow + # path running from a `vX.Y.Z` (or `vX.Y.Z-prerelease`) tag. Same + # regex the README's `cosign verify` example uses; it rejects: + # * unsigned images + # * signatures from any other repo / workflow + # * signatures from non-tag refs (main, PRs, release branches) + # * signatures from arbitrary non-semver tags + - issuer: https://token.actions.githubusercontent.com + subjectRegExp: ^https://github\.com/abhigyanpatwari/GitNexus/\.github/workflows/docker\.yml@refs/tags/v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ + # Cross-check the signature against the public Rekor transparency log, + # so an attacker who briefly compromised Fulcio cannot retroactively + # mint a signature without leaving a public, append-only audit record. + ctlog: + url: https://rekor.sigstore.dev diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 000000000..d17f012db --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,45 @@ +services: + gitnexus-server: + image: ${SERVER_IMAGE:-ghcr.io/abhigyanpatwari/gitnexus:latest} + container_name: ${SERVER_CONTAINER_NAME:-gitnexus-server} + # Map the server to the same host port the web UI expects by default + # (http://localhost:4747). The browser runs on the host, so the UI's + # built-in default works without any reconfiguration. + ports: + - '${SERVER_HOST_PORT:-4747}:4747' + volumes: + # Persist the global registry, indexes, and cloned repos across runs. + - gitnexus-data:/data/gitnexus + # Optional: mount a host workspace so `gitnexus index ` can see + # repos you already have on disk. The default points at an empty + # `./workspace/` sibling that compose will create on first start — + # it intentionally does NOT bind-mount the repo root, which would + # expose `.git`, `.env`, and CI secrets to the container. + # Override with `WORKSPACE_DIR=/abs/path/to/your/repos`. + - ${WORKSPACE_DIR:-./workspace}:/workspace:ro + restart: unless-stopped + healthcheck: + test: ['CMD', 'curl', '-fsS', 'http://localhost:4747/api/heartbeat'] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s + + gitnexus-web: + image: ${WEB_IMAGE:-ghcr.io/abhigyanpatwari/gitnexus-web:latest} + container_name: ${WEB_CONTAINER_NAME:-gitnexus-web} + ports: + - '${WEB_HOST_PORT:-4173}:4173' + depends_on: + gitnexus-server: + condition: service_healthy + restart: unless-stopped + healthcheck: + test: ['CMD', 'curl', '-f', 'http://localhost:4173/'] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + +volumes: + gitnexus-data: diff --git a/docker-server.mjs b/docker-server.mjs new file mode 100644 index 000000000..adf84a07b --- /dev/null +++ b/docker-server.mjs @@ -0,0 +1,81 @@ +import { createReadStream } from 'node:fs'; +import { stat } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { extname, join, normalize, sep } from 'node:path'; + +const host = '0.0.0.0'; +const port = Number(process.env.PORT || '4173'); +const root = join(process.cwd(), 'dist'); + +const contentTypes = { + '.css': 'text/css; charset=utf-8', + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.map': 'application/json; charset=utf-8', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.txt': 'text/plain; charset=utf-8', + '.woff': 'font/woff', + '.woff2': 'font/woff2', +}; + +function resolvePath(urlPath) { + let decoded; + try { + decoded = decodeURIComponent(urlPath); + } catch { + return null; + } + if (decoded.includes('\0')) return null; + const cleanPath = normalize(decoded.replace(/^\/+/, '')); + const candidate = join(root, cleanPath); + if (candidate !== root && !candidate.startsWith(root + sep)) return null; + return candidate; +} + +const server = createServer(async (req, res) => { + const requestPath = req.url?.split('?')[0] || '/'; + let filePath = resolvePath(requestPath); + + if (!filePath) { + res.writeHead(400); + res.end('Bad request'); + return; + } + + try { + const fileStat = await stat(filePath).catch(() => null); + if (fileStat?.isDirectory()) { + filePath = join(filePath, 'index.html'); + } else if (!fileStat?.isFile()) { + filePath = join(root, 'index.html'); + } + + const finalStat = await stat(filePath).catch(() => null); + if (!finalStat?.isFile()) { + res.writeHead(404); + res.end('Not found'); + return; + } + + res.writeHead(200, { + 'Cache-Control': filePath.includes('/assets/') + ? 'public, max-age=31536000, immutable' + : 'no-cache', + 'Content-Type': contentTypes[extname(filePath)] || 'application/octet-stream', + 'Cross-Origin-Opener-Policy': 'same-origin', + 'Cross-Origin-Embedder-Policy': 'require-corp', + }); + const stream = createReadStream(filePath); + stream.on('error', () => res.destroy()); + stream.pipe(res); + } catch (error) { + res.writeHead(500); + res.end(error instanceof Error ? error.message : 'Internal server error'); + } +}); + +server.listen(port, host, () => { + console.log(`gitnexus-web listening on http://${host}:${port}`); +}); diff --git a/docker-server.test.mjs b/docker-server.test.mjs new file mode 100644 index 000000000..a1005b0e4 --- /dev/null +++ b/docker-server.test.mjs @@ -0,0 +1,107 @@ +import { mkdir, mkdtemp, rm, unlink, writeFile } from 'node:fs/promises'; +import http, { createServer } from 'node:http'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { after, before, it } from 'node:test'; +import assert from 'node:assert/strict'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const serverScript = join(__dirname, 'docker-server.mjs'); + +function getFreePort() { + return new Promise((resolve) => { + const s = createServer(); + s.listen(0, '127.0.0.1', () => { + const { port } = s.address(); + s.close(() => resolve(port)); + }); + }); +} + +function rawGet(port, path) { + return new Promise((resolve, reject) => { + const req = http.request({ host: '127.0.0.1', port, path }, (res) => { + let body = ''; + res.setEncoding('utf8'); + res.on('data', (chunk) => { + body += chunk; + }); + res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body })); + }); + req.on('error', reject); + req.end(); + }); +} + +async function waitForServer(port, retries = 30) { + for (let i = 0; i < retries; i++) { + try { + await rawGet(port, '/'); + return; + } catch { + await new Promise((r) => setTimeout(r, 100)); + } + } + throw new Error('Server did not start in time'); +} + +let tmpDir, serverPort, child; + +before(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'gitnexus-docker-test-')); + const distDir = join(tmpDir, 'dist'); + const assetsDir = join(distDir, 'assets'); + await mkdir(assetsDir, { recursive: true }); + await writeFile(join(distDir, 'index.html'), 'spa'); + await writeFile(join(assetsDir, 'app.abc123.js'), 'console.log("app")'); + + serverPort = await getFreePort(); + child = spawn(process.execPath, [serverScript], { + cwd: tmpDir, + env: { ...process.env, PORT: String(serverPort) }, + stdio: 'pipe', + }); + child.on('error', (err) => { + throw err; + }); + + await waitForServer(serverPort); +}); + +after(async () => { + child?.kill(); + if (tmpDir) await rm(tmpDir, { recursive: true, force: true }); +}); + +it('serves a valid asset with immutable cache header', async () => { + const res = await rawGet(serverPort, '/assets/app.abc123.js'); + assert.equal(res.status, 200); + assert.match(res.headers['cache-control'], /immutable/); + assert.equal(res.headers['cross-origin-opener-policy'], 'same-origin'); + assert.equal(res.headers['cross-origin-embedder-policy'], 'require-corp'); +}); + +it('serves SPA fallback for unknown routes', async () => { + const res = await rawGet(serverPort, '/some/unknown/route'); + assert.equal(res.status, 200); + assert.match(res.body, /spa/); + assert.match(res.headers['cache-control'], /no-cache/); +}); + +it('rejects path traversal with 400', async () => { + const res = await rawGet(serverPort, '/../../../etc/passwd'); + assert.equal(res.status, 400); +}); + +it('rejects percent-encoded null bytes with 400', async () => { + const res = await rawGet(serverPort, '/foo%00bar'); + assert.equal(res.status, 400); +}); + +it('returns 404 when dist/index.html is missing', async () => { + await unlink(join(tmpDir, 'dist', 'index.html')); + const res = await rawGet(serverPort, '/nonexistent-page'); + assert.equal(res.status, 404); +}); diff --git a/gitnexus-shared/src/graph/types.ts b/gitnexus-shared/src/graph/types.ts index 49762d145..d3dc81625 100644 --- a/gitnexus-shared/src/graph/types.ts +++ b/gitnexus-shared/src/graph/types.ts @@ -131,4 +131,20 @@ export interface GraphRelationship { confidence: number; reason: string; step?: number; + /** + * Per-signal evidence trace for edges emitted by the scope-based + * resolution pipeline (RFC #909 Ring 2 PKG #925). Populated by + * `emit-references.ts` when draining `ReferenceIndex` into the graph + * so downstream query / audit tools can inspect *why* a given edge + * was emitted with its confidence value. + * + * Optional and additive — every existing edge emitter ignores this + * field, and every existing query continues to work whether or not + * an edge carries it. + */ + evidence?: readonly { + readonly kind: string; + readonly weight: number; + readonly note?: string; + }[]; } diff --git a/gitnexus-shared/src/index.ts b/gitnexus-shared/src/index.ts index 4024bf070..d4efe8a89 100644 --- a/gitnexus-shared/src/index.ts +++ b/gitnexus-shared/src/index.ts @@ -23,3 +23,128 @@ export type { MroStrategy } from './mro-strategy.js'; // Pipeline progress export type { PipelinePhase, PipelineProgress } from './pipeline.js'; + +// ─── Scope-based resolution — RFC #909 (Ring 1 #910) ──────────────────────── +// Data model (RFC §2) +export type { SymbolDefinition } from './scope-resolution/symbol-definition.js'; +export type { + ScopeId, + DefId, + ScopeKind, + Range, + Capture, + CaptureMatch, + BindingRef, + ImportEdge, + TypeRef, + Scope, + ResolutionEvidence, + Resolution, + Reference, + ReferenceIndex, + LookupParams, + RegistryContributor, + ParsedImport, + ParsedTypeBinding, + WorkspaceIndex, + Callsite, + ScopeLookup, +} from './scope-resolution/types.js'; + +// Evidence + tie-break constants (RFC Appendix A, Appendix B) +export { EvidenceWeights, typeBindingWeightAtDepth } from './scope-resolution/evidence-weights.js'; +export { ORIGIN_PRIORITY } from './scope-resolution/origin-priority.js'; +export type { OriginForTieBreak } from './scope-resolution/origin-priority.js'; + +// Language classification (RFC §6.1 Ring 3/4 governance) +export { + LanguageClassifications, + isProductionLanguage, +} from './scope-resolution/language-classification.js'; +export type { LanguageClassification } from './scope-resolution/language-classification.js'; + +// Core indexes over per-file artifacts (RFC §3.1; Ring 2 SHARED #913) +export { buildDefIndex } from './scope-resolution/def-index.js'; +export type { DefIndex } from './scope-resolution/def-index.js'; +export { buildModuleScopeIndex } from './scope-resolution/module-scope-index.js'; +export type { ModuleScopeIndex, ModuleScopeEntry } from './scope-resolution/module-scope-index.js'; +export { buildQualifiedNameIndex } from './scope-resolution/qualified-name-index.js'; +export type { QualifiedNameIndex } from './scope-resolution/qualified-name-index.js'; + +// Strict type-reference resolver (RFC §4.6; Ring 2 SHARED #916) +// `ScopeLookup` is defined in `./scope-resolution/types.js` and exported +// from the type-export block above — not from this module. +export { resolveTypeRef } from './scope-resolution/resolve-type-ref.js'; +export type { ResolveTypeRefContext } from './scope-resolution/resolve-type-ref.js'; + +// ScopeExtractor output contracts (RFC §3.2 Phase 1; Ring 2 PKG #919) +export type { ParsedFile } from './scope-resolution/parsed-file.js'; +export type { ReferenceSite, ReferenceKind, CallForm } from './scope-resolution/reference-site.js'; + +// Method-dispatch materialized view over HeritageMap (RFC §3.1; Ring 2 SHARED #914) +export { buildMethodDispatchIndex } from './scope-resolution/method-dispatch-index.js'; +export type { + MethodDispatchIndex, + MethodDispatchInput, +} from './scope-resolution/method-dispatch-index.js'; + +// SCC-aware cross-file finalize (RFC §3.2 Phase 2; Ring 2 SHARED #915) +export { finalize } from './scope-resolution/finalize-algorithm.js'; +export type { + FinalizeInput, + FinalizeFile, + FinalizeHooks, + FinalizeOutput, + FinalizedScc, + FinalizeStats, +} from './scope-resolution/finalize-algorithm.js'; + +// Scope-aware registries + 7-step lookup (RFC §4; Ring 2 SHARED #917) +export { buildClassRegistry } from './scope-resolution/registries/class-registry.js'; +export type { ClassRegistry } from './scope-resolution/registries/class-registry.js'; +export { buildMethodRegistry } from './scope-resolution/registries/method-registry.js'; +export type { + MethodRegistry, + MethodLookupOptions, +} from './scope-resolution/registries/method-registry.js'; +export { buildFieldRegistry } from './scope-resolution/registries/field-registry.js'; +export type { + FieldRegistry, + FieldLookupOptions, +} from './scope-resolution/registries/field-registry.js'; +export { lookupCore } from './scope-resolution/registries/lookup-core.js'; +export type { CoreLookupParams } from './scope-resolution/registries/lookup-core.js'; +export { lookupQualified } from './scope-resolution/registries/lookup-qualified.js'; +export type { LookupQualifiedParams } from './scope-resolution/registries/lookup-qualified.js'; +export { composeEvidence, confidenceFromEvidence } from './scope-resolution/registries/evidence.js'; +export type { RawSignals } from './scope-resolution/registries/evidence.js'; +export { + compareByConfidenceWithTiebreaks, + CONFIDENCE_EPSILON, +} from './scope-resolution/registries/tie-breaks.js'; +export type { TieBreakKey } from './scope-resolution/registries/tie-breaks.js'; +export { CLASS_KINDS, METHOD_KINDS, FIELD_KINDS } from './scope-resolution/registries/context.js'; +export type { + RegistryContext, + RegistryProviders, + OwnerScopedContributor, + ArityVerdict, +} from './scope-resolution/registries/context.js'; + +// Scope tree spine + position lookup (RFC §2.2 + §3.1; Ring 2 SHARED #912) +export { makeScopeId, clearScopeIdInternPool } from './scope-resolution/scope-id.js'; +export type { ScopeIdInput } from './scope-resolution/scope-id.js'; +export { buildScopeTree, ScopeTreeInvariantError } from './scope-resolution/scope-tree.js'; +export type { ScopeTree } from './scope-resolution/scope-tree.js'; +export { buildPositionIndex } from './scope-resolution/position-index.js'; +export type { PositionIndex } from './scope-resolution/position-index.js'; + +// Shadow-mode diff + aggregation (RFC §6.3; Ring 2 SHARED #918) +export { diffResolutions } from './scope-resolution/shadow/diff.js'; +export type { + ShadowAgreement, + ShadowCallsite, + ShadowDiff, +} from './scope-resolution/shadow/diff.js'; +export { aggregateDiffs } from './scope-resolution/shadow/aggregate.js'; +export type { LanguageParityRow, ShadowParityReport } from './scope-resolution/shadow/aggregate.js'; 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-shared/src/mro-strategy.ts b/gitnexus-shared/src/mro-strategy.ts index 6168c67c0..7ace9306f 100644 --- a/gitnexus-shared/src/mro-strategy.ts +++ b/gitnexus-shared/src/mro-strategy.ts @@ -1,23 +1,46 @@ /** - * MRO (Method Resolution Order) strategy — shared between CLI and any - * future consumer that reasons about multiple-inheritance semantics. + * MRO (Method Resolution Order) strategy — shared canonical definition. * - * Lives in `gitnexus-shared` so the low-level resolution module - * (`core/ingestion/model/resolve.ts`) does not need to import from - * `languages/` — keeping the `model/` layer free of language-registry - * coupling. + * Lives in `gitnexus-shared` so `model/resolve.ts` and `mro-processor.ts` share + * the type without importing the language registry (avoids circular coupling). * - * Strategy semantics: - * - `first-wins`: BFS ancestor walk, first match wins (default). - * - `leftmost-base`: BFS ancestor walk, leftmost base wins (C++). - * - `c3`: C3-linearized ancestor order, first match wins (Python). - * - `implements-split`: BFS walk, first match wins (Java/C#/Kotlin) — full - * interface-default ambiguity is handled at graph level. - * - `qualified-syntax`: No auto-resolution (Rust — requires `::m`). + * `first-wins` (default, Java/C#/Kotlin/Go/Swift/Dart): + * BFS ancestor walk in declaration order; first match wins. + * + * `leftmost-base` (C++): + * BFS walk; HeritageMap preserves source insertion order, so BFS naturally + * picks the leftmost base in diamond inheritance. + * + * `c3` (Python): + * C3-linearization; falls back to BFS on cyclic/inconsistent hierarchy. + * See model/resolve.ts § c3Linearize. + * + * `implements-split` (Java/C#/Kotlin): + * Low-level lookup is BFS; graph-level mro-processor detects and warns on + * interface-default method ambiguity. + * + * `qualified-syntax` (Rust): + * No auto-resolution — `lookupMethodByOwnerWithMRO` returns undefined immediately. + * Rust requires explicit `::method` syntax. + * + * `ruby-mixin` (Ruby): + * Kind-aware walk that does NOT short-circuit on direct owner first (`prepend` + * must beat the class's own method). Walk order: + * 1. Prepend providers (reverse declaration — last-prepended wins) + * 2. Direct owner's own methods + * 3. Include providers (reverse declaration) + * 4. Transitive ancestors (BFS fallback) + * Singleton dispatch: caller passes `ancestryOverride` (extend providers only); + * becomes a simple left-to-right scan. Miss NEVER falls through to file-scoped + * lookup — null-routes or honors `fallback`. + * + * @see model/resolve.ts § lookupMethodByOwnerWithMRO + * @see languages/ruby.ts § selectDispatch */ export type MroStrategy = | 'first-wins' | 'c3' | 'leftmost-base' | 'implements-split' - | 'qualified-syntax'; + | 'qualified-syntax' + | 'ruby-mixin'; diff --git a/gitnexus-shared/src/scope-resolution/def-index.ts b/gitnexus-shared/src/scope-resolution/def-index.ts new file mode 100644 index 000000000..a34eab961 --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/def-index.ts @@ -0,0 +1,62 @@ +/** + * `DefIndex` — O(1) `DefId → SymbolDefinition` materialization. + * + * The global "what is this id?" lookup. Every per-kind registry (ClassRegistry, + * MethodRegistry, FieldRegistry) returns `DefId[]` and resolves them back to + * full `SymbolDefinition` records through this index — one central hash map, + * one allocation per def. + * + * Part of RFC #909 Ring 2 SHARED — #913. + * + * Consumed by: #917 (`Registry.lookup` implementations), #915 (SCC finalize). + */ + +import type { SymbolDefinition } from './symbol-definition.js'; +import type { DefId } from './types.js'; + +export interface DefIndex { + readonly byId: ReadonlyMap; + readonly size: number; + get(id: DefId): SymbolDefinition | undefined; + has(id: DefId): boolean; +} + +/** + * Build a `DefIndex` from a flat list of `SymbolDefinition` records. + * + * **Collision policy: first-write-wins.** `DefId` is meant to be unique + * (`nodeId` is the stable graph identifier), so a collision indicates an + * upstream bug — most likely the same symbol parsed twice or a duplicate + * commit into the pipeline. Rather than silently overwriting with a later + * definition that may be partial or wrong, the first record wins and + * subsequent records for the same id are dropped. Pipeline bugs surface + * later as `has(id) === true` but the def looking older than expected, + * which is easier to debug than a silent overwrite. + * + * Pure function — safe to call repeatedly; no side effects. + */ +export function buildDefIndex(defs: readonly SymbolDefinition[]): DefIndex { + const byId = new Map(); + for (const def of defs) { + if (byId.has(def.nodeId)) continue; // first-write-wins + byId.set(def.nodeId, def); + } + return wrapIndex(byId); +} + +// ─── Internal ─────────────────────────────────────────────────────────────── + +function wrapIndex(byId: Map): DefIndex { + return { + byId, + get size() { + return byId.size; + }, + get(id: DefId): SymbolDefinition | undefined { + return byId.get(id); + }, + has(id: DefId): boolean { + return byId.has(id); + }, + }; +} diff --git a/gitnexus-shared/src/scope-resolution/evidence-weights.ts b/gitnexus-shared/src/scope-resolution/evidence-weights.ts new file mode 100644 index 000000000..d1f46dabb --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/evidence-weights.ts @@ -0,0 +1,90 @@ +/** + * `EvidenceWeights` — RFC Appendix A (authoritative values). + * + * Starting calibration for scope-based resolution. Shadow-first rollout + * tunes these against legacy DAG parity. Every `ResolutionEvidence.weight` + * value in the codebase MUST reference this map; inline magic numbers are a + * lint violation. Extends issue #429 (centralize hardcoded confidence values). + * + * Evidence composes additively inside `composeEvidence`; the sum is capped + * at 1.0 in `Resolution.confidence`. + */ + +/** + * Authoritative weight map. Keys are a mix of `ResolutionEvidence.kind` + * values and special modifiers (scope-chain depth, MRO depth decay, + * unlinked-import multiplicative cap). + */ +export const EvidenceWeights = { + // ─── Where-found signals (visibility) ───────────────────────────────────── + /** `BindingRef.origin === 'local'` */ + local: 0.55, + /** `BindingRef.origin === 'import'` */ + import: 0.45, + /** `BindingRef.origin === 'reexport'` */ + reexport: 0.4, + /** `BindingRef.origin === 'namespace'` */ + namespace: 0.4, + /** `BindingRef.origin === 'wildcard'` */ + wildcard: 0.3, + + // ─── Scope-chain deduction (per-hop) ────────────────────────────────────── + /** Deducted per parent-hop taken (depth-0 = 0, depth-1 = −0.02, …). */ + scopeChainPerDepth: -0.02, + + // ─── Receiver-type-binding signal (decays by MRO depth) ─────────────────── + /** + * Weight applied when the receiver's type binding resolves to a class that + * declares the candidate as a method/field. Decays by MRO depth: direct + * class = index 0; 1 parent hop = index 1; etc. Falls back to the last + * value for depths beyond the table. + */ + typeBindingByMroDepth: [0.5, 0.42, 0.36, 0.32, 0.3] as const, + + // ─── Corroborating signals ──────────────────────────────────────────────── + /** `def.ownerId === resolvedReceiver.def.id` (exact owner match). */ + ownerMatch: 0.2, + /** Explanatory only — retained for debuggability. Never discriminates + * because surviving candidates already passed `acceptedKinds`. */ + kindMatch: 0.0, + + // ─── Arity compatibility (from `provider.arityCompatibility`) ───────────── + /** `provider.arityCompatibility(...) === 'compatible'` */ + arityMatchCompatible: 0.1, + /** `provider.arityCompatibility(...) === 'unknown'` */ + arityMatchUnknown: 0.0, + /** `provider.arityCompatibility(...) === 'incompatible'` — penalizes; + * candidates filtered only when a compatible candidate exists. */ + arityMatchIncompatible: -0.15, + + // ─── Global fallback (only when nothing lexically visible) ──────────────── + /** Hit via `QualifiedNameIndex.byQualifiedName`. */ + globalQualified: 0.35, + /** Fallback hit in a `byName` index (and nothing was lexically visible). */ + globalName: 0.1, + + // ─── Degraded signals ───────────────────────────────────────────────────── + /** Call/reference flowing through a `dynamic-unresolved` edge. */ + dynamicImportUnresolved: 0.02, + + // ─── Unresolved-import cap (multiplicative, applied per-signal) ─────────── + /** + * Multiplicative cap on the edge-derived evidence signal + * (`import`/`wildcard`/`reexport`/`namespace`) when + * `ImportEdge.linkStatus === 'unresolved'`. Independent corroborating + * signals on the same candidate (`owner-match`, `arity-match`, + * `type-binding`) are NOT penalized. + */ + unlinkedImportMultiplier: 0.5, +} as const; + +/** + * Look up the `type-binding` signal weight for a given MRO depth, falling + * back to the last tabulated value for depths beyond the table. + */ +export function typeBindingWeightAtDepth(mroDepth: number): number { + const table = EvidenceWeights.typeBindingByMroDepth; + if (mroDepth < 0) return table[0]; + if (mroDepth >= table.length) return table[table.length - 1]; + return table[mroDepth]; +} diff --git a/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts b/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts new file mode 100644 index 000000000..6f6de8ba6 --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts @@ -0,0 +1,663 @@ +/** + * `finalize` — cross-file finalize algorithm for the SemanticModel + * (RFC §3.2 Phase 2; Ring 2 SHARED #915). + * + * Pure logic that takes per-file parse output (`ParsedImport[]` + + * `SymbolDefinition[]`) and returns: + * + * - Linked `ImportEdge[]` per module scope, with `targetModuleScope` and + * `targetDefId` filled where resolvable; edges that could not be + * resolved within the hard fixpoint cap are marked + * `linkStatus: 'unresolved'`. + * - Materialized `bindings` per module scope — local defs merged with + * imported / wildcard-expanded / re-exported names via the provider's + * `mergeBindings` precedence. + * - The SCC condensation of the import graph, exposed so disjoint SCCs + * can be processed in parallel by callers that want that. + * + * The algorithm is **SCC-aware**: it runs Tarjan SCC over the file-level + * import graph, processes SCCs in reverse-topological order (leaves + * first), and within each SCC runs a bounded fixpoint link pass capped at + * `N = |edges in SCC|`. Cyclic imports finalize without hanging; malformed + * inputs are bounded by the cap. + * + * **No language-specific logic.** Target resolution, wildcard expansion, + * and binding precedence all go through caller-supplied hooks + * (`resolveImportTarget`, `expandsWildcardTo`, `mergeBindings`) that + * match the LanguageProvider surface from #911. + * + * **Dynamic imports rule.** `kind === 'dynamic-unresolved'` passes through + * as an `ImportEdge { kind: 'dynamic-unresolved', targetFile: null }` + * with no `BindingRef`. They are parse-time signals, not linkable targets. + */ + +import type { SymbolDefinition } from './symbol-definition.js'; +import type { BindingRef, ImportEdge, ParsedImport, ScopeId, WorkspaceIndex } from './types.js'; + +// ─── Public contracts ─────────────────────────────────────────────────────── + +/** Per-file input for the finalize pass. */ +export interface FinalizeFile { + readonly filePath: string; + /** The module scope id for this file; owns the finalized imports + bindings. */ + readonly moduleScope: ScopeId; + readonly parsedImports: readonly ParsedImport[]; + /** + * Defs exported from this file — the "what other files can import by name" + * surface. Typically those with `isExported: true` (the module's own + * declarations) plus, for multi-hop re-export chains, the re-exported + * names the parser chose to surface here. + * + * **Multi-hop re-export contract.** `finalize` resolves an edge + * `A → B (importedName: 'X')` by looking up `X` in `B.localDefs`. If B + * only has `export { X } from './C'` and the parser *does not* include + * `X` in `B.localDefs`, A's edge hits the fixpoint cap and is marked + * `linkStatus: 'unresolved'`. The fixpoint does NOT mutate `localDefs` + * across iterations — it is static input. + * + * Parsers that want multi-hop re-export chains to settle end-to-end must + * include re-exported names in the intermediate file's `localDefs` (with + * the original `DefId` of the source symbol). This keeps the algorithm + * O(1) per lookup and avoids graph-crawl during finalize. + */ + readonly localDefs: readonly SymbolDefinition[]; +} + +/** Input to `finalize`. */ +export interface FinalizeInput { + readonly files: readonly FinalizeFile[]; + /** Opaque workspace context forwarded to provider hooks. */ + readonly workspaceIndex: WorkspaceIndex; +} + +/** + * Provider-supplied hooks. Mirror the optional LanguageProvider scope- + * resolution hooks declared in #911; `finalize` calls them pure-ly and + * expects pure answers. + */ +export interface FinalizeHooks { + /** + * Resolve a raw import target to the concrete file path that owns it. + * Return `null` when no target file is resolvable (e.g., `np.foo` when + * `numpy` is external to the workspace). + */ + resolveImportTarget( + targetRaw: string, + fromFile: string, + workspaceIndex: WorkspaceIndex, + ): string | null; + + /** + * For a wildcard `import * from M`, return the names visible in the + * exporting module scope `M`. The finalize pass looks each name up in + * `M`'s local defs to produce a concrete `BindingRef`; names with no + * matching export are dropped. + */ + expandsWildcardTo(targetModuleScope: ScopeId, workspaceIndex: WorkspaceIndex): readonly string[]; + + /** + * Merge `incoming` bindings into `existing` for a given name. Called + * once per name at each scope. Typical rules: + * - Python: local > imported > wildcard (last-write-wins within tier). + * - Rust: explicit `use` > glob; `pub use` overrides. + * Return value replaces the bucket entirely — no implicit append. + */ + mergeBindings( + existing: readonly BindingRef[], + incoming: readonly BindingRef[], + scope: ScopeId, + ): readonly BindingRef[]; +} + +/** One SCC in the file-level import graph. */ +export interface FinalizedScc { + readonly files: readonly string[]; + /** True iff this SCC has ≥ 2 files OR a single file that self-imports. */ + readonly isCycle: boolean; +} + +/** + * Counters reported by `finalize`. + * + * **Counting granularity** — all edge counters are **per-`ParsedImport`**, + * not per-materialized-`ImportEdge`. A single `wildcard` ParsedImport that + * expands to N exports counts as one linked edge in these stats; the + * materialized output (`FinalizeOutput.imports`) will have N edges for + * that input. `dynamic-unresolved` ParsedImports count as linked (they + * pass through with no `linkStatus`), so `linkedEdges` ≠ "has a + * BindingRef" — use the `bindings` map for that. + * + * In other words: `totalEdges === input.parsedImports.length` summed + * across files, and `linkedEdges + unresolvedEdges === totalEdges`. + */ +export interface FinalizeStats { + readonly totalFiles: number; + /** Total `ParsedImport` records seen across all files. */ + readonly totalEdges: number; + /** + * `ParsedImport`s whose finalized edge does NOT carry + * `linkStatus: 'unresolved'`. Includes `dynamic-unresolved` pass-throughs. + */ + readonly linkedEdges: number; + /** `ParsedImport`s whose finalized edge carries `linkStatus: 'unresolved'`. */ + readonly unresolvedEdges: number; + readonly sccCount: number; + readonly largestSccSize: number; +} + +export interface FinalizeOutput { + /** Linked `ImportEdge[]` per module scope, in original input order. */ + readonly imports: ReadonlyMap; + /** Materialized bindings per module scope. */ + readonly bindings: ReadonlyMap>; + /** SCCs in reverse-topological order (leaves first). */ + readonly sccs: readonly FinalizedScc[]; + readonly stats: FinalizeStats; +} + +// ─── Entry point ─────────────────────────────────────────────────────────── + +export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOutput { + const byFilePath = new Map(); + for (const f of input.files) byFilePath.set(f.filePath, f); + + // ── Phase 0: pre-resolve raw import targets (one syscall-equivalent per + // (file, parsedImport)). Edges with no resolvable target become + // `linkStatus: 'unresolved'` or, for dynamic-unresolved, pass through + // with `targetFile: null`. + const edgeIndex = new Map(); // filePath → drafts + let totalEdges = 0; + + for (const file of input.files) { + const drafts: ImportEdgeDraft[] = []; + for (const parsed of file.parsedImports) { + const draft = makeEdgeDraft(parsed, file, hooks, input.workspaceIndex); + drafts.push(draft); + totalEdges++; + } + edgeIndex.set(file.filePath, drafts); + } + + // ── Phase 1: build file-level import graph (only resolvable edges form + // graph edges; unresolvable ones are terminal and contribute no + // fixpoint obligation). + const graph = new Map>(); + for (const file of input.files) { + graph.set(file.filePath, new Set()); + } + for (const [fromFile, drafts] of edgeIndex) { + const edges = graph.get(fromFile)!; + for (const d of drafts) { + if (d.targetFile !== null && byFilePath.has(d.targetFile)) { + edges.add(d.targetFile); + } + } + } + + // ── Phase 2: Tarjan SCC → reverse-topological list of SCCs. + const sccs = tarjanSccs(graph); + + // ── Phase 3: process SCCs in reverse-topological order (leaves first). + // Within each SCC, run a bounded fixpoint that resolves intra-SCC edges. + // Edges leaving the SCC are already resolved (their target SCC is + // already finalized); edges inside the SCC may need multiple passes. + const linkedByScope = new Map(); + let linkedEdges = 0; + + for (const scc of sccs) { + const sccFiles = new Set(scc.files); + const capacity = countEdgesWithin(edgeIndex, sccFiles); + + // Run the fixpoint up to `capacity` iterations. Each iteration tries to + // resolve every still-unlinked edge in the SCC; stops early if a pass + // makes no progress. + let progressed = true; + let iterations = 0; + while (progressed && iterations < capacity) { + progressed = false; + iterations++; + for (const filePath of scc.files) { + const drafts = edgeIndex.get(filePath)!; + for (const draft of drafts) { + if (draft.finalized !== null) continue; + const finalized = tryFinalize(draft, byFilePath); + if (finalized !== null) { + draft.finalized = finalized; + progressed = true; + } + } + } + } + + // Any drafts still not finalized within this SCC hit the cap → unresolved. + for (const filePath of scc.files) { + const drafts = edgeIndex.get(filePath)!; + for (const draft of drafts) { + if (draft.finalized !== null) continue; + draft.finalized = { + ...draft.base, + linkStatus: 'unresolved' as const, + }; + } + } + } + + // ── Phase 4: collect finalized `ImportEdge[]` per module scope, preserving + // input order within each file, and wildcard-expand where applicable. + for (const file of input.files) { + const drafts = edgeIndex.get(file.filePath)!; + const finalized: ImportEdge[] = []; + for (const d of drafts) { + const edge = d.finalized!; + if (d.source.kind === 'wildcard' && edge.linkStatus !== 'unresolved') { + // Produce one `wildcard-expanded` ImportEdge per exported name. + const expanded = expandWildcard(edge, byFilePath, hooks, input.workspaceIndex); + for (const e of expanded) finalized.push(e); + } else { + finalized.push(edge); + } + if (edge.linkStatus !== 'unresolved') linkedEdges++; + } + linkedByScope.set(file.moduleScope, Object.freeze(finalized)); + } + + // ── Phase 5: materialize module-scope bindings (local + imports + wildcards), + // delegating precedence to `provider.mergeBindings`. + const bindingsByScope = materializeBindings(input.files, linkedByScope, hooks); + + // ── Stats. + const sccCount = sccs.length; + let largestSccSize = 0; + for (const scc of sccs) { + if (scc.files.length > largestSccSize) largestSccSize = scc.files.length; + } + const stats: FinalizeStats = { + totalFiles: input.files.length, + totalEdges, + linkedEdges, + unresolvedEdges: totalEdges - linkedEdges, + sccCount, + largestSccSize, + }; + + return Object.freeze({ + imports: linkedByScope, + bindings: bindingsByScope, + sccs, + stats, + }); +} + +// ─── Internal: edge drafting (phase 0) ────────────────────────────────────── + +interface ImportEdgeDraft { + readonly source: ParsedImport; + readonly fromFile: string; + readonly fromScope: ScopeId; + readonly targetFile: string | null; + readonly base: ImportEdge; + finalized: ImportEdge | null; +} + +function makeEdgeDraft( + parsed: ParsedImport, + file: FinalizeFile, + hooks: FinalizeHooks, + workspace: WorkspaceIndex, +): ImportEdgeDraft { + // Dynamic-unresolved passes through — no `BindingRef`, no target file. + if (parsed.kind === 'dynamic-unresolved') { + const base: ImportEdge = { + localName: parsed.localName, + targetFile: null, + targetExportedName: '', + kind: 'dynamic-unresolved', + }; + return { + source: parsed, + fromFile: file.filePath, + fromScope: file.moduleScope, + targetFile: null, + base, + finalized: base, // already fully finalized + }; + } + + const targetFile = hooks.resolveImportTarget(parsed.targetRaw ?? '', file.filePath, workspace); + + // Edge is unresolvable at the file level — mark unresolved now. + if (targetFile === null) { + const edgeKind = parsed.kind === 'wildcard' ? 'wildcard-expanded' : parsed.kind; + const localName = parsed.kind === 'wildcard' ? '' : parsed.localName; + const targetExportedName = extractExportedName(parsed); + const base: ImportEdge = { + localName, + targetFile: null, + targetExportedName, + kind: edgeKind, + linkStatus: 'unresolved', + }; + return { + source: parsed, + fromFile: file.filePath, + fromScope: file.moduleScope, + targetFile: null, + base, + finalized: base, + }; + } + + // Resolvable at the file level; intra-SCC fixpoint may still fail to fill + // in `targetDefId` (e.g., symbol not exported from target). + const edgeKind = parsed.kind === 'wildcard' ? 'wildcard-expanded' : parsed.kind; + const localName = parsed.kind === 'wildcard' ? '' : parsed.localName; + const targetExportedName = extractExportedName(parsed); + const base: ImportEdge = { + localName, + targetFile, + targetExportedName, + kind: edgeKind, + }; + return { + source: parsed, + fromFile: file.filePath, + fromScope: file.moduleScope, + targetFile, + base, + finalized: null, + }; +} + +function extractExportedName(parsed: ParsedImport): string { + switch (parsed.kind) { + case 'named': + case 'alias': + case 'namespace': + case 'reexport': + return parsed.importedName; + case 'wildcard': + case 'dynamic-unresolved': + return ''; + } +} + +// ─── Internal: per-edge finalization (phase 3) ───────────────────────────── + +function tryFinalize( + draft: ImportEdgeDraft, + byFilePath: Map, +): ImportEdge | null { + const targetFile = draft.targetFile; + if (targetFile === null) return draft.base; // already terminal + + const targetModule = byFilePath.get(targetFile); + if (targetModule === undefined) return draft.base; // external target — leave as-is + + // Wildcards finalize at the file level; their per-name expansion happens + // in phase 4. At this stage we just record the target module scope. + if (draft.source.kind === 'wildcard') { + return { + ...draft.base, + targetModuleScope: targetModule.moduleScope, + }; + } + + // Namespace imports alias the target *module*; they don't name a + // specific export. Link the module scope unconditionally. If the target + // also exposes a def whose simple name matches `importedName` (some + // languages emit a synthetic module-def), pick it up as the `targetDefId` + // so consumers can reach the module as a symbol — but its absence is not + // a failure. + if (draft.source.kind === 'namespace') { + const moduleDef = findExportByName(targetModule.localDefs, extractExportedName(draft.source)); + return { + ...draft.base, + targetModuleScope: targetModule.moduleScope, + ...(moduleDef !== undefined ? { targetDefId: moduleDef.nodeId } : {}), + }; + } + + // named / alias / reexport: look up the imported name in the target's + // local defs. Multi-hop re-export chains settle iteratively — each hop + // resolves once its prior hop is finalized. + const importedName = extractExportedName(draft.source); + const exported = findExportByName(targetModule.localDefs, importedName); + + if (exported === undefined) { + // Target resolvable but the name isn't exported — keep trying in case a + // re-export inside the target's SCC surfaces it in a later iteration. + return null; + } + + const transitiveVia = draft.source.kind === 'reexport' ? Object.freeze([targetFile]) : undefined; + + return { + ...draft.base, + targetModuleScope: targetModule.moduleScope, + targetDefId: exported.nodeId, + ...(transitiveVia !== undefined ? { transitiveVia } : {}), + }; +} + +/** + * The "simple" (unqualified) name of a def, for import-name matching. + * + * Canonical source: `def.qualifiedName` — the tail after the last `.` (or + * the whole string if no dot). Defs without a qualifiedName can't be + * resolved by name here and return `null`; callers treat that as "name + * not exported" and either retry in a later fixpoint iteration or mark + * the edge unresolved. + */ +function deriveSimpleName(def: SymbolDefinition): string | null { + const q = def.qualifiedName; + if (q === undefined || q.length === 0) return null; + const dot = q.lastIndexOf('.'); + return dot === -1 ? q : q.slice(dot + 1); +} + +function findExportByName( + defs: readonly SymbolDefinition[], + name: string, +): SymbolDefinition | undefined { + for (const d of defs) { + if (deriveSimpleName(d) === name) return d; + } + return undefined; +} + +function countEdgesWithin(edgeIndex: Map, files: Set): number { + let n = 0; + for (const filePath of files) { + const drafts = edgeIndex.get(filePath); + if (drafts === undefined) continue; + for (const d of drafts) { + if (d.targetFile !== null && files.has(d.targetFile)) n++; + } + } + // Guarantee at least one pass even for a trivial SCC (ensures deterministic + // fixpoint termination even when a single-file SCC has zero intra-SCC edges + // but still needs one settle pass). + return Math.max(n, 1); +} + +// ─── Internal: wildcard expansion (phase 4) ──────────────────────────────── + +function expandWildcard( + edge: ImportEdge, + byFilePath: Map, + hooks: FinalizeHooks, + workspace: WorkspaceIndex, +): readonly ImportEdge[] { + if (edge.targetModuleScope === undefined || edge.targetFile === null) { + return [edge]; // unresolvable wildcard survives as a single unlinked edge + } + const target = byFilePath.get(edge.targetFile); + if (target === undefined) return [edge]; + + const names = hooks.expandsWildcardTo(edge.targetModuleScope, workspace); + if (names.length === 0) return []; + + const expanded: ImportEdge[] = []; + for (const name of names) { + const def = findExportByName(target.localDefs, name); + if (def === undefined) continue; + expanded.push({ + localName: name, + targetFile: edge.targetFile, + targetExportedName: name, + kind: 'wildcard-expanded', + targetModuleScope: edge.targetModuleScope, + targetDefId: def.nodeId, + }); + } + return expanded; +} + +// ─── Internal: bindings materialization (phase 5) ─────────────────────────── + +function materializeBindings( + files: readonly FinalizeFile[], + linkedByScope: ReadonlyMap, + hooks: FinalizeHooks, +): ReadonlyMap> { + const out = new Map>(); + + for (const file of files) { + const scopeBindings = new Map(); + + // Start with local defs as `origin: 'local'` bindings. + for (const def of file.localDefs) { + const name = deriveSimpleName(def); + if (name === null) continue; + const incoming: BindingRef[] = [{ def, origin: 'local' }]; + const existing = scopeBindings.get(name) ?? []; + scopeBindings.set(name, hooks.mergeBindings(existing, incoming, file.moduleScope)); + } + + // Layer in finalized imports. + const imports = linkedByScope.get(file.moduleScope) ?? []; + for (const edge of imports) { + if (edge.targetDefId === undefined || edge.linkStatus === 'unresolved') continue; + // Every def the importing file needs to reach is in some other file's + // `localDefs`; walk all files to find it. In practice we could index + // this, but at finalize-time N(files) is small per workspace pass. + const def = findDefById(files, edge.targetDefId); + if (def === undefined) continue; + + const origin: BindingRef['origin'] = + edge.kind === 'namespace' + ? 'namespace' + : edge.kind === 'wildcard-expanded' + ? 'wildcard' + : edge.kind === 'reexport' + ? 'reexport' + : 'import'; + const fallback = deriveSimpleName(def); + const name = edge.localName.length > 0 ? edge.localName : fallback; + if (name === null) continue; + const incoming: BindingRef[] = [{ def, origin, via: edge }]; + const existing = scopeBindings.get(name) ?? []; + scopeBindings.set(name, hooks.mergeBindings(existing, incoming, file.moduleScope)); + } + + // Freeze nested buckets for immutability. + const frozen = new Map(); + for (const [name, refs] of scopeBindings) { + frozen.set(name, Object.freeze(refs.slice())); + } + out.set(file.moduleScope, frozen); + } + + return out; +} + +function findDefById(files: readonly FinalizeFile[], defId: string): SymbolDefinition | undefined { + for (const f of files) { + for (const d of f.localDefs) { + if (d.nodeId === defId) return d; + } + } + return undefined; +} + +// ─── Internal: Tarjan SCC ────────────────────────────────────────────────── + +/** + * Iterative Tarjan SCC. Returns SCCs in **reverse-topological** order + * (leaves first — a property Tarjan gives for free, and the order + * `finalize` wants so leaves are fully resolved before their dependents). + */ +function tarjanSccs(graph: ReadonlyMap>): FinalizedScc[] { + const index = new Map(); + const lowlink = new Map(); + const onStack = new Set(); + const stack: string[] = []; + const sccs: FinalizedScc[] = []; + let idx = 0; + + // Iterative DFS to avoid stack overflow on deep import chains. + const allNodes = Array.from(graph.keys()).sort(); // deterministic order + const iterStack: Array<{ node: string; children: Iterator; entered: boolean }> = []; + + for (const root of allNodes) { + if (index.has(root)) continue; + iterStack.push({ + node: root, + children: (graph.get(root) ?? new Set()).values(), + entered: false, + }); + while (iterStack.length > 0) { + const frame = iterStack[iterStack.length - 1]!; + + if (!frame.entered) { + frame.entered = true; + index.set(frame.node, idx); + lowlink.set(frame.node, idx); + idx++; + stack.push(frame.node); + onStack.add(frame.node); + } + + const nextChild = frame.children.next(); + if (nextChild.done) { + // Post-visit: compute SCC membership if frame.node is a root. + if (lowlink.get(frame.node) === index.get(frame.node)) { + const scc: string[] = []; + let selfInCycle = false; + while (true) { + const w = stack.pop()!; + onStack.delete(w); + scc.push(w); + // A single-file self-loop counts as a cycle. + if (w === frame.node) { + selfInCycle = (graph.get(w) ?? new Set()).has(w); + break; + } + } + const isCycle = scc.length > 1 || selfInCycle; + sccs.push({ files: Object.freeze(scc), isCycle }); + } + iterStack.pop(); + // Propagate lowlink to parent. + if (iterStack.length > 0) { + const parent = iterStack[iterStack.length - 1]!; + lowlink.set(parent.node, Math.min(lowlink.get(parent.node)!, lowlink.get(frame.node)!)); + } + continue; + } + + const child = nextChild.value; + if (!index.has(child)) { + iterStack.push({ + node: child, + children: (graph.get(child) ?? new Set()).values(), + entered: false, + }); + } else if (onStack.has(child)) { + lowlink.set(frame.node, Math.min(lowlink.get(frame.node)!, index.get(child)!)); + } + } + } + + return sccs; +} diff --git a/gitnexus-shared/src/scope-resolution/language-classification.ts b/gitnexus-shared/src/scope-resolution/language-classification.ts new file mode 100644 index 000000000..10c556cda --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/language-classification.ts @@ -0,0 +1,49 @@ +/** + * `LanguageClassification` — RFC §6.1 Ring 3 / Ring 4 governance. + * + * Classifies each `SupportedLanguages` member for the rollout. Ring 4 (DAG + * retirement) is gated on *all production languages* being registry-primary + * and stable for one release cycle; `experimental` and `quarantined` + * languages do not block. + * + * Initial classification (locked in Ring 1 #910): + * - production: javascript, typescript, python, java, c, cpp, csharp, go, + * ruby, rust, php, kotlin, swift, dart + * - experimental: vue (embedded-language / SFC complexity), + * cobol (regex-provider path) + * - quarantined: (none) + */ + +import { SupportedLanguages } from '../languages.js'; + +export type LanguageClassification = 'production' | 'experimental' | 'quarantined'; + +/** + * The canonical classification for each supported language. Governance + * changes (promote `experimental` → `production`, quarantine a language, …) + * update this map in a dedicated PR. + */ +export const LanguageClassifications: Readonly> = + { + [SupportedLanguages.JavaScript]: 'production', + [SupportedLanguages.TypeScript]: 'production', + [SupportedLanguages.Python]: 'production', + [SupportedLanguages.Java]: 'production', + [SupportedLanguages.C]: 'production', + [SupportedLanguages.CPlusPlus]: 'production', + [SupportedLanguages.CSharp]: 'production', + [SupportedLanguages.Go]: 'production', + [SupportedLanguages.Ruby]: 'production', + [SupportedLanguages.Rust]: 'production', + [SupportedLanguages.PHP]: 'production', + [SupportedLanguages.Kotlin]: 'production', + [SupportedLanguages.Swift]: 'production', + [SupportedLanguages.Dart]: 'production', + [SupportedLanguages.Vue]: 'experimental', + [SupportedLanguages.Cobol]: 'experimental', + }; + +/** Convenience predicate: is this language gating Ring 4 retirement? */ +export function isProductionLanguage(lang: SupportedLanguages): boolean { + return LanguageClassifications[lang] === 'production'; +} diff --git a/gitnexus-shared/src/scope-resolution/method-dispatch-index.ts b/gitnexus-shared/src/scope-resolution/method-dispatch-index.ts new file mode 100644 index 000000000..d09e8fa89 --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/method-dispatch-index.ts @@ -0,0 +1,145 @@ +/** + * `MethodDispatchIndex` — materialized view of class hierarchies keyed by + * `DefId` (RFC §3.1; Ring 2 SHARED #914). + * + * Two O(1)-access maps used by `Registry.lookupMethod` and interface- + * dispatch callers: + * + * - `mroByOwnerDefId` : owner class → full MRO ancestor chain + * (excludes the owner itself, in per-language + * strategy order). + * - `implsByInterfaceDefId` : interface/trait → classes that implement it. + * + * **Not an MRO implementation.** The build function is a pure aggregator: it + * asks the caller (via `computeMro` and `implementsOf` callbacks) for the + * per-language answers and materializes the two-way index. MRO strategies + * live where they already do today (`model/resolve.ts § c3Linearize`, + * `languages/ruby.ts § selectDispatch`, etc.) — this index does not + * reimplement them. + * + * Why callbacks and not a shared strategy registry: the five strategies + * (Python C3, Ruby kind-aware, Java/Kotlin linear, Rust qualified-syntax, + * COBOL none) already exist in the CLI package and depend on the CLI's + * `HeritageMap` + `SemanticModel`. Pulling them into `gitnexus-shared` would + * require migrating both — out of scope for #914. Callbacks let the shared + * build stay pure while honoring existing strategies verbatim. + * + * Consumed by: #917 (`Registry.lookupMethod` MRO fast path, interface + * dispatch resolver). + */ + +import type { DefId } from './types.js'; + +// ─── Public contracts ─────────────────────────────────────────────────────── + +export interface MethodDispatchIndex { + /** + * Full MRO ancestor chain per owner class (excludes the owner itself). + * Order reflects the per-language strategy used by `computeMro`. + */ + readonly mroByOwnerDefId: ReadonlyMap; + /** Interfaces / traits → classes that implement them. */ + readonly implsByInterfaceDefId: ReadonlyMap; + + /** `mroByOwnerDefId.get`, with an empty frozen array on miss. */ + mroFor(ownerDefId: DefId): readonly DefId[]; + /** `implsByInterfaceDefId.get`, with an empty frozen array on miss. */ + implementorsOf(interfaceDefId: DefId): readonly DefId[]; +} + +export interface MethodDispatchInput { + /** + * Owner defs to index (classes, structs, traits, interfaces — any kind + * that can appear on the owner side of a method-dispatch graph). + */ + readonly owners: readonly DefId[]; + /** + * Return the full MRO ancestor chain for `ownerDefId`, **excluding the + * owner itself**, in the order dictated by the owner's language-specific + * MRO strategy. + * + * Contract: + * - Pure (no side effects). + * - Deterministic per input. + * - `undefined` not allowed — return `[]` when the owner has no parents. + */ + readonly computeMro: (ownerDefId: DefId) => readonly DefId[]; + /** + * Return the set of interface/trait defs that `ownerDefId` implements. + * Transitive inclusion (e.g., `implements` on a parent class) is the + * caller's choice — the build function simply inverts whatever is + * returned. + * + * Repeated IDs in the output are deduplicated automatically. + * + * **Call-count contract.** `implementsOf` is invoked **once per + * occurrence** of an owner in `input.owners`, not once per unique + * owner. Duplicate owners therefore re-invoke it; dedup happens at + * the bucket layer (after the callback returns). Callers with + * expensive `implementsOf` implementations should pass a deduplicated + * `owners` list. `computeMro`, by contrast, is memoized by the first- + * write-wins policy and fires at most once per unique owner. + */ + readonly implementsOf: (ownerDefId: DefId) => readonly DefId[]; +} + +// ─── Builder ──────────────────────────────────────────────────────────────── + +export function buildMethodDispatchIndex(input: MethodDispatchInput): MethodDispatchIndex { + const mroByOwnerDefId = new Map(); + const implsBuilding = new Map(); + const implsSeen = new Map>(); + + for (const ownerId of input.owners) { + // First-write-wins on duplicate owner ids: a stable policy consistent + // with sibling indexes (#913 DefIndex / ModuleScopeIndex). + if (!mroByOwnerDefId.has(ownerId)) { + const chain = input.computeMro(ownerId); + mroByOwnerDefId.set(ownerId, Object.freeze(chain.slice())); + } + + for (const ifaceId of input.implementsOf(ownerId)) { + let seen = implsSeen.get(ifaceId); + if (seen === undefined) { + seen = new Set(); + implsSeen.set(ifaceId, seen); + } + if (seen.has(ownerId)) continue; + seen.add(ownerId); + + let bucket = implsBuilding.get(ifaceId); + if (bucket === undefined) { + bucket = []; + implsBuilding.set(ifaceId, bucket); + } + bucket.push(ownerId); + } + } + + const implsByInterfaceDefId = new Map(); + for (const [ifaceId, owners] of implsBuilding) { + implsByInterfaceDefId.set(ifaceId, Object.freeze(owners.slice())); + } + + return wrapIndex(mroByOwnerDefId, implsByInterfaceDefId); +} + +// ─── Internal ─────────────────────────────────────────────────────────────── + +const EMPTY: readonly DefId[] = Object.freeze([]); + +function wrapIndex( + mroByOwnerDefId: Map, + implsByInterfaceDefId: Map, +): MethodDispatchIndex { + return { + mroByOwnerDefId, + implsByInterfaceDefId, + mroFor(ownerDefId: DefId): readonly DefId[] { + return mroByOwnerDefId.get(ownerDefId) ?? EMPTY; + }, + implementorsOf(interfaceDefId: DefId): readonly DefId[] { + return implsByInterfaceDefId.get(interfaceDefId) ?? EMPTY; + }, + }; +} diff --git a/gitnexus-shared/src/scope-resolution/module-scope-index.ts b/gitnexus-shared/src/scope-resolution/module-scope-index.ts new file mode 100644 index 000000000..a57c02d27 --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/module-scope-index.ts @@ -0,0 +1,73 @@ +/** + * `ModuleScopeIndex` — O(1) `filePath → moduleScopeId` lookup. + * + * Every file parsed produces exactly one `Module` scope at its root. The + * finalize algorithm needs to resolve `ImportEdge.targetFile` to a concrete + * module scope id in constant time during the link pass; this index is that + * mapping. + * + * Part of RFC #909 Ring 2 SHARED — #913. + * + * Consumed by: #915 (SCC finalize link pass), #923 (shadow harness when + * resolving callsite file → enclosing module). + */ + +import type { ScopeId } from './types.js'; + +export interface ModuleScopeIndex { + readonly byFilePath: ReadonlyMap; + readonly size: number; + get(filePath: string): ScopeId | undefined; + has(filePath: string): boolean; +} + +export interface ModuleScopeEntry { + readonly filePath: string; + readonly moduleScopeId: ScopeId; +} + +/** + * Build a `ModuleScopeIndex` from a flat list of `{ filePath, moduleScopeId }` + * pairs. + * + * **Collision policy: first-write-wins.** A file should appear exactly once + * in a single ingestion run; collisions indicate the same file was parsed + * twice or a `filePath` normalization bug upstream. Dropping the later + * entry preserves the first-stable id the rest of the pipeline may already + * have registered against. + * + * **Caller contract: filePath keys must be pre-normalized.** This index + * keys on the raw `filePath` string and does NOT canonicalize separators, + * case, or trailing slashes. Callers upstream of this function must agree + * on a canonical form (typically repo-root-relative, POSIX separators, + * no trailing slash) before constructing entries — otherwise `C:\foo\bar.ts`, + * `C:/foo/bar.ts`, and `foo/bar.ts` will all hash to distinct buckets and + * `get()` will miss. + * + * Pure function — safe to call repeatedly; no side effects. + */ +export function buildModuleScopeIndex(entries: readonly ModuleScopeEntry[]): ModuleScopeIndex { + const byFilePath = new Map(); + for (const { filePath, moduleScopeId } of entries) { + if (byFilePath.has(filePath)) continue; // first-write-wins + byFilePath.set(filePath, moduleScopeId); + } + return wrapIndex(byFilePath); +} + +// ─── Internal ─────────────────────────────────────────────────────────────── + +function wrapIndex(byFilePath: Map): ModuleScopeIndex { + return { + byFilePath, + get size() { + return byFilePath.size; + }, + get(filePath: string): ScopeId | undefined { + return byFilePath.get(filePath); + }, + has(filePath: string): boolean { + return byFilePath.has(filePath); + }, + }; +} diff --git a/gitnexus-shared/src/scope-resolution/origin-priority.ts b/gitnexus-shared/src/scope-resolution/origin-priority.ts new file mode 100644 index 000000000..c5068c274 --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/origin-priority.ts @@ -0,0 +1,30 @@ +/** + * `ORIGIN_PRIORITY` — RFC Appendix B (authoritative values). + * + * Tie-break ordering applied inside `Registry.lookup` Step 7 when + * `|Δconfidence| < 0.001` between two `Resolution` candidates. Lower number + * = stronger (wins the tie). + * + * Full tie-break order (§4.2 Step 7): + * confidence DESC → scope depth ASC → MRO depth ASC → ORIGIN_PRIORITY ASC + * → DefId.localeCompare + */ + +export type OriginForTieBreak = + | 'local' + | 'import' + | 'reexport' + | 'namespace' + | 'wildcard' + | 'global-qualified' + | 'global-name'; + +export const ORIGIN_PRIORITY: Readonly> = { + local: 0, + import: 1, + reexport: 2, + namespace: 3, + wildcard: 4, + 'global-qualified': 5, + 'global-name': 6, +}; diff --git a/gitnexus-shared/src/scope-resolution/parsed-file.ts b/gitnexus-shared/src/scope-resolution/parsed-file.ts new file mode 100644 index 000000000..ddd8afccd --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/parsed-file.ts @@ -0,0 +1,65 @@ +/** + * `ParsedFile` — the per-file artifact produced by `ScopeExtractor` + * (RFC §3.2 Phase 1; Ring 2 PKG #919). + * + * The boundary between Phase 1 (extraction, per-file, parallelizable) and + * Phase 2 (finalize, cross-file). One `ParsedFile` is emitted per source + * file; the finalize orchestrator (#921) collects them into a workspace- + * wide set and feeds them to the shared `finalize` algorithm (#915). + * + * ## Shape + * + * - `scopes` — every `Scope` created for this file, in tree- + * topological order (module first, then children). + * `Scope.bindings` carry **local-only** bindings at + * this stage; finalize merges imports/wildcards on top. + * - `parsedImports` — raw `ParsedImport[]` for this file; finalize + * resolves each to a concrete `ImportEdge`. + * - `localDefs` — defs structurally declared in this file. A + * superset of every `Scope.ownedDefs` union. + * Listed separately so `finalize` can dedup-index + * without re-walking scopes. + * - `referenceSites` — pre-resolution usage facts; populated by the + * resolution phase into `ReferenceIndex`. + * + * ## What `ParsedFile` deliberately does NOT carry + * + * - Linked `ImportEdge`s. Those are finalize output. + * - A `ScopeTree` instance. Callers build one from `scopes` (cheap — + * `buildScopeTree(parsedFile.scopes)`). Keeping the ParsedFile flat + * makes IPC serialization from worker threads straightforward. + * - Merged module-scope bindings. Finalize owns that materialization. + * + * ## Compatibility with `FinalizeFile` + * + * `FinalizeFile` (defined in `./finalize-algorithm.ts`) is a structural + * subset of `ParsedFile` — `filePath`, `moduleScope`, `parsedImports`, + * `localDefs`. A `ParsedFile` is trivially convertible to a `FinalizeFile` + * by picking those four fields, so the finalize orchestrator threads + * ParsedFile through to the shared algorithm without shape-shifting. + */ + +import type { Scope, ScopeId } from './types.js'; +import type { ParsedImport } from './types.js'; +import type { SymbolDefinition } from './symbol-definition.js'; +import type { ReferenceSite } from './reference-site.js'; + +export interface ParsedFile { + readonly filePath: string; + /** `Scope.id` of the file's root `Module` scope. */ + readonly moduleScope: ScopeId; + /** + * All scopes in this file, typically emitted in tree-topological order. + * Caller reconstructs a `ScopeTree` via `buildScopeTree(scopes)` when + * navigation or invariant re-validation is needed. + */ + readonly scopes: readonly Scope[]; + readonly parsedImports: readonly ParsedImport[]; + /** + * All defs structurally declared in this file (classes, methods, fields, + * variables). Mirrors the union of `Scope.ownedDefs` across `scopes`, + * pre-flattened for O(N) consumption by finalize. + */ + readonly localDefs: readonly SymbolDefinition[]; + readonly referenceSites: readonly ReferenceSite[]; +} diff --git a/gitnexus-shared/src/scope-resolution/position-index.ts b/gitnexus-shared/src/scope-resolution/position-index.ts new file mode 100644 index 000000000..a2fd80828 --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/position-index.ts @@ -0,0 +1,166 @@ +/** + * `PositionIndex` — O(log N_file) scope-at-position lookup + * (RFC §3.1; Ring 2 SHARED #912). + * + * Per-file sorted array of `(range, scopeId)` entries, sorted by start + * position ASC (`startLine`, then `startCol`). `atPosition(filePath, line, + * col)` binary-searches for the last entry whose start ≤ (line, col), then + * scans backward through the sorted prefix and returns the first entry + * whose range contains the query position. + * + * **Why this works.** `ScopeTree`'s invariants (parent strictly contains + * child; siblings don't overlap) guarantee that the scopes containing a + * given point form an **ancestor chain**. When scanning backward through + * entries sorted by start position ASC, the first scope we find that + * contains the query is the innermost one — any deeper-starting scope + * that also contained the query would appear *later* in the sorted array, + * but we're only scanning entries with start ≤ query, so anything later + * necessarily starts after the query and can't contain it. + * + * Expected complexity: `O(log N_file + D)` where `D` is the lexical depth + * at the query position (typically ≤ 10). Worst-case degrades to `O(N_file)` + * only under pathological inputs (many scopes starting at the same line). + * + * **Line/column conventions.** Matches `Range` in `types.ts`: lines are + * 1-based, columns are 0-based. Ranges are **inclusive on both ends** — + * a scope whose `endLine:endCol` equals the query position still contains + * it. That matches how tree-sitter captures bodies (closing brace + * included) and how closed PR #902's `enclosingFunctions` behaved. + */ + +import type { Range, Scope, ScopeId } from './types.js'; + +export interface PositionIndex { + /** Total scope entries indexed across all files. */ + readonly size: number; + /** + * Innermost scope containing `(line, col)` in `filePath`, or `undefined` + * when nothing contains it (position before file start, after file end, + * or filePath not indexed). + * + * **Touching-boundary semantics.** Ranges are inclusive on both ends. + * When two sibling scopes share a boundary point — e.g. + * `[5:0, 10:0]` and `[10:0, 15:0]`, which is legal under `ScopeTree`'s + * non-overlap invariant — a query at the shared point `(10, 0)` is + * contained by **both**. The innermost-wins tie-break rule applies as + * usual: since neither is nested inside the other, the one that + * **starts latest** wins, i.e. the **right** sibling. The mechanism + * is the backward scan through the start-position-sorted array (see + * `findLastStartLteIndex` below) — both siblings land before the + * upper-bound cursor, and the right sibling is scanned first. Queries at non-boundary positions between them naturally + * fall to the unique containing scope. + */ + atPosition(filePath: string, line: number, col: number): ScopeId | undefined; +} + +/** + * Build a `PositionIndex` from a flat list of `Scope` records. + * + * Duplicate `id`s are tolerated and deduplicated — the caller's + * `ScopeTree.buildScopeTree` is the authoritative validator of scope + * identity, and the position index does not need to re-check that + * invariant. + */ +export function buildPositionIndex(scopes: readonly Scope[]): PositionIndex { + const entriesByFile = new Map(); + const seen = new Set(); + + for (const scope of scopes) { + if (seen.has(scope.id)) continue; + seen.add(scope.id); + + let bucket = entriesByFile.get(scope.filePath); + if (bucket === undefined) { + bucket = []; + entriesByFile.set(scope.filePath, bucket); + } + bucket.push({ id: scope.id, range: scope.range }); + } + + for (const bucket of entriesByFile.values()) { + bucket.sort(compareEntry); + } + + return wrapIndex(entriesByFile, seen.size); +} + +// ─── Internals ────────────────────────────────────────────────────────────── + +interface Entry { + readonly id: ScopeId; + readonly range: Range; +} + +/** + * Sort by start position ASC, breaking ties by end position DESC so that + * larger (outer) scopes appear before their smaller (inner) co-starting + * siblings in the array. Makes the backward-scan contract crisp: the + * first containing hit from the end of the scanned prefix is the + * innermost scope. + */ +function compareEntry(a: Entry, b: Entry): number { + if (a.range.startLine !== b.range.startLine) return a.range.startLine - b.range.startLine; + if (a.range.startCol !== b.range.startCol) return a.range.startCol - b.range.startCol; + if (a.range.endLine !== b.range.endLine) return b.range.endLine - a.range.endLine; + return b.range.endCol - a.range.endCol; +} + +/** Whether `(line, col)` is at or after `range`'s start. */ +function startIsAtOrBefore(range: Range, line: number, col: number): boolean { + if (range.startLine < line) return true; + if (range.startLine > line) return false; + return range.startCol <= col; +} + +/** Whether `(line, col)` is at or before `range`'s end (inclusive). */ +function endIsAtOrAfter(range: Range, line: number, col: number): boolean { + if (range.endLine > line) return true; + if (range.endLine < line) return false; + return range.endCol >= col; +} + +/** + * Return the largest index `i` in `arr` where `arr[i].range` starts at or + * before `(line, col)`. Returns `-1` if no entry starts ≤ the query. + * + * Classic "upper bound - 1" binary search: find the first entry that + * starts *after* the query, then step back one. + */ +function findLastStartLteIndex(arr: readonly Entry[], line: number, col: number): number { + let lo = 0; + let hi = arr.length; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + if (startIsAtOrBefore(arr[mid]!.range, line, col)) { + lo = mid + 1; + } else { + hi = mid; + } + } + return lo - 1; +} + +function wrapIndex(entriesByFile: Map, size: number): PositionIndex { + return { + get size() { + return size; + }, + atPosition(filePath: string, line: number, col: number): ScopeId | undefined { + const bucket = entriesByFile.get(filePath); + if (bucket === undefined || bucket.length === 0) return undefined; + + const endIdx = findLastStartLteIndex(bucket, line, col); + if (endIdx < 0) return undefined; + + // Scan backward; first containing hit is innermost (see file header). + for (let i = endIdx; i >= 0; i--) { + const entry = bucket[i]!; + if (endIsAtOrAfter(entry.range, line, col)) { + // `startIsAtOrBefore` is guaranteed true by the binary search. + return entry.id; + } + } + return undefined; + }, + }; +} diff --git a/gitnexus-shared/src/scope-resolution/qualified-name-index.ts b/gitnexus-shared/src/scope-resolution/qualified-name-index.ts new file mode 100644 index 000000000..e231b01d5 --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/qualified-name-index.ts @@ -0,0 +1,92 @@ +/** + * `QualifiedNameIndex` — O(1) `qualifiedName → DefId[]` lookup across all kinds. + * + * Cross-kind fast path for qualified-name resolution + * (`lookupQualified(qname, scope, params)` in RFC §4.5). Class, method, + * field, and namespace defs all contribute to a single index here; consumers + * filter the returned `DefId[]` by `p.acceptedKinds` at the call site. + * + * Returns `DefId[]` (not a single `DefId`) because multiple defs can legally + * share a qualified name — partial classes in C#, method overloads, or + * accidental cross-kind collisions. The lookup caller filters to the expected + * kind(s) and ranks the survivors. + * + * Part of RFC #909 Ring 2 SHARED — #913. + * + * Consumed by: #917 (`Registry.lookup` qualified fast path, `resolveTypeRef` + * dotted fallback via #916). + */ + +import type { SymbolDefinition } from './symbol-definition.js'; +import type { DefId } from './types.js'; + +export interface QualifiedNameIndex { + readonly byQualifiedName: ReadonlyMap; + readonly size: number; + /** Returns all `DefId`s registered under this qualified name; empty frozen + * array on miss so callers can iterate without null checks. */ + get(qualifiedName: string): readonly DefId[]; + has(qualifiedName: string): boolean; +} + +/** + * Build a `QualifiedNameIndex` from a flat list of `SymbolDefinition` records. + * + * Only defs with a non-empty `qualifiedName` contribute; defs without one are + * silently skipped (not every kind carries a qualified name — anonymous or + * top-level symbols, dynamic-unresolved imports, etc.). + * + * **Duplicate policy: appended in input order.** Each unique `(qname, DefId)` + * pair contributes at most once — repeated entries for the same pair are + * deduplicated. Distinct `DefId`s sharing a `qname` accumulate in insertion + * order (stable output for deterministic lookup ranking at the call site). + * + * Pure function — safe to call repeatedly; no side effects. + */ +export function buildQualifiedNameIndex(defs: readonly SymbolDefinition[]): QualifiedNameIndex { + const byQualifiedName = new Map(); + const seenPairs = new Set(); + + for (const def of defs) { + const qname = def.qualifiedName; + if (qname === undefined || qname.length === 0) continue; + + const pairKey = `${qname}\0${def.nodeId}`; + if (seenPairs.has(pairKey)) continue; + seenPairs.add(pairKey); + + const bucket = byQualifiedName.get(qname); + if (bucket === undefined) { + byQualifiedName.set(qname, [def.nodeId]); + } else { + bucket.push(def.nodeId); + } + } + + // Freeze bucket arrays so consumers can't mutate the index. + const frozen = new Map(); + for (const [k, v] of byQualifiedName) { + frozen.set(k, Object.freeze(v.slice())); + } + + return wrapIndex(frozen); +} + +// ─── Internal ─────────────────────────────────────────────────────────────── + +const EMPTY: readonly DefId[] = Object.freeze([]); + +function wrapIndex(byQualifiedName: Map): QualifiedNameIndex { + return { + byQualifiedName, + get size() { + return byQualifiedName.size; + }, + get(qualifiedName: string): readonly DefId[] { + return byQualifiedName.get(qualifiedName) ?? EMPTY; + }, + has(qualifiedName: string): boolean { + return byQualifiedName.has(qualifiedName); + }, + }; +} diff --git a/gitnexus-shared/src/scope-resolution/reference-site.ts b/gitnexus-shared/src/scope-resolution/reference-site.ts new file mode 100644 index 000000000..c9abdef7e --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/reference-site.ts @@ -0,0 +1,74 @@ +/** + * `ReferenceSite` — a pre-resolution usage fact collected by `ScopeExtractor` + * (RFC §3.2 Phase 1; Ring 2 PKG #919). + * + * One record per `@reference.*` capture. The extractor records: + * - the name being referenced (method/field/class name), + * - the source range, + * - the innermost lexical scope containing the reference, + * - the reference kind (call, read, write, inherits, etc.), + * - optional call-form classification from `provider.classifyCallForm`, + * - optional explicit-receiver hint for dotted calls (`user.save()`), + * - optional arity for call sites. + * + * Reference sites are consumed by the resolution phase (RFC §3.2 Phase 4) + * which routes each through `Registry.lookup` / `resolveTypeRef` and + * emits the final `Reference` record into `ReferenceIndex`. + * + * **Pre-resolution only.** `ReferenceSite` intentionally carries no + * `toDef`, `confidence`, or `evidence`. Those are populated by the + * resolution step that reads this record and produces a `Reference` + * (defined in `./types.ts`). + */ + +import type { Range, ScopeId } from './types.js'; + +/** + * What kind of usage this reference represents — the graph-edge kind + * emitted after resolution (`CALLS`, `READS`, `WRITES`, etc.). + * + * Matches the `kind` field on `Reference` in `./types.ts` so the + * resolution phase can pass it through without re-classification. + */ +export type ReferenceKind = + | 'call' + | 'read' + | 'write' + | 'type-reference' + | 'inherits' + | 'import-use'; + +/** + * How a call site binds its target. Informs `Registry.lookup` Step 2 + * (type-binding path): + * - `'free'` — bare call (no receiver); resolution via lexical chain. + * - `'member'` — dotted call (`x.foo()`); resolution via receiver type. + * - `'constructor'` — `new Foo()`; receiver is the class itself. + * - `'index'` — index expression (`arr[0]`); rare as a dispatch site. + * + * Only meaningful for `kind === 'call'`; ignored for reads/writes. + */ +export type CallForm = 'free' | 'member' | 'constructor' | 'index'; + +export interface ReferenceSite { + /** The name being referenced (e.g., `'save'`, `'User'`, `'count'`). */ + readonly name: string; + /** Source-text range of this reference. */ + readonly atRange: Range; + /** + * Innermost lexical scope that contains `atRange`. Resolved by the + * extractor via position lookup and frozen here so the resolution + * phase doesn't re-compute it per call. + */ + readonly inScope: ScopeId; + readonly kind: ReferenceKind; + /** Set when `kind === 'call'`. */ + readonly callForm?: CallForm; + /** + * Explicit receiver for dotted calls (`user.save()` → `{ name: 'user' }`). + * Passed through to `Registry.lookup.explicitReceiver`. + */ + readonly explicitReceiver?: { readonly name: string }; + /** Argument count at the call site; used by `provider.arityCompatibility`. */ + readonly arity?: number; +} diff --git a/gitnexus-shared/src/scope-resolution/registries/class-registry.ts b/gitnexus-shared/src/scope-resolution/registries/class-registry.ts new file mode 100644 index 000000000..20a08e2b8 --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/registries/class-registry.ts @@ -0,0 +1,41 @@ +/** + * `ClassRegistry` — scope-aware lookup for class-like symbols + * (RFC §4.4; Ring 2 SHARED #917). + * + * Thin wrapper over `lookupCore`, specialized for class kinds: + * + * - `acceptedKinds` = Class / Interface / Enum / Struct / Union / + * Trait / TypeAlias / Typedef / Record / Delegate / Annotation / + * Template / Namespace. + * - `useReceiverTypeBinding` is **false** — classes are resolved by + * name through the lexical chain + global qualified fallback, not + * via a receiver type. + * - Arity filter is not applicable (classes are not called with + * argument counts at lookup time). + */ + +import type { Resolution, ScopeId } from '../types.js'; +import { lookupCore, type CoreLookupParams } from './lookup-core.js'; +import { CLASS_KINDS, type RegistryContext } from './context.js'; + +export interface ClassRegistry { + /** + * Look up a class-like symbol by simple or dotted name anchored at + * `scope`. Returns a confidence-ranked `Resolution[]`; consume `[0]` + * for the best answer. + */ + lookup(name: string, scope: ScopeId): readonly Resolution[]; +} + +export function buildClassRegistry(ctx: RegistryContext): ClassRegistry { + const params: CoreLookupParams = { + acceptedKinds: CLASS_KINDS, + useReceiverTypeBinding: false, + ownerScopedContributor: null, + }; + return { + lookup(name: string, scope: ScopeId) { + return lookupCore(name, scope, params, ctx); + }, + }; +} diff --git a/gitnexus-shared/src/scope-resolution/registries/context.ts b/gitnexus-shared/src/scope-resolution/registries/context.ts new file mode 100644 index 000000000..9adbbda2e --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/registries/context.ts @@ -0,0 +1,110 @@ +/** + * `RegistryContext` — the injected state required by the scope-aware + * registry lookups (RFC §4; Ring 2 SHARED #917). + * + * Bundles every Ring 2 index + every provider hook the 7-step algorithm + * might consult. Threaded through `lookupCore` and the three public + * registries unchanged; construction is the caller's responsibility + * (typically once per workspace-indexing pass in Ring 2 PKG). + * + * The design intent is **pure-logic in `gitnexus-shared`, data + hooks + * supplied by the caller**. Nothing here loads files, parses AST, or + * reaches into the CLI package. + */ + +import type { NodeLabel } from '../../graph/types.js'; +import type { SymbolDefinition } from '../symbol-definition.js'; +import type { Callsite, DefId } from '../types.js'; +import type { DefIndex } from '../def-index.js'; +import type { QualifiedNameIndex } from '../qualified-name-index.js'; +import type { ModuleScopeIndex } from '../module-scope-index.js'; +import type { ScopeTree } from '../scope-tree.js'; +import type { MethodDispatchIndex } from '../method-dispatch-index.js'; + +// ─── Provider hooks consumed by the registries ───────────────────────────── + +export interface RegistryProviders { + /** + * Language-specific arity compatibility between a callsite and a candidate + * `def`. Mirrors `LanguageProvider.arityCompatibility` from #911. Optional: + * when absent, every candidate receives `'unknown'` (neutral signal). + */ + arityCompatibility?(callsite: Callsite, def: SymbolDefinition): ArityVerdict; +} + +export type ArityVerdict = 'compatible' | 'unknown' | 'incompatible'; + +// ─── Owner-scoped contributor (concrete shape for `RegistryContributor`) ──── + +/** + * Per-owner membership view plugged into `LookupParams.ownerScopedContributor`. + * + * When the caller knows a receiver is of type `Owner` (e.g., after + * resolving an explicit receiver or via `self`), it can supply the + * `Owner`'s own member bucket here. `lookupCore` treats hits from this + * contributor as `origin: 'local'` inside the owner's body scope — + * strongest-visibility evidence, unaffected by the scope-chain hop + * deduction that punishes outer-scope hits. + * + * Ring 1's `RegistryContributor = unknown` opaque placeholder is narrowed + * to this concrete shape here in Ring 2 SHARED (#917). + */ +export interface OwnerScopedContributor { + /** The owner (class/struct/trait/interface) that bounds this view. */ + readonly ownerDefId: DefId; + /** + * Methods / fields directly declared on the owner, keyed by simple name. + * Return empty array on miss; implementations should NOT walk the MRO — + * that's `MethodDispatchIndex`'s job, handled in the type-binding step. + */ + byName(name: string): readonly SymbolDefinition[]; +} + +// ─── Top-level context threaded through every lookup ─────────────────────── + +export interface RegistryContext { + readonly scopes: ScopeTree; + readonly defs: DefIndex; + readonly qualifiedNames: QualifiedNameIndex; + readonly moduleScopes: ModuleScopeIndex; + /** + * Method-dispatch index; required for method/field registries that + * honor `useReceiverTypeBinding`. Omit for class-only lookups. + */ + readonly methodDispatch?: MethodDispatchIndex; + readonly providers: RegistryProviders; +} + +// ─── Per-kind default `acceptedKinds` sets ───────────────────────────────── +// +// Exported so the three public registries stay declarative (each one just +// points at the right constant + passes it to `lookupCore`). + +export const CLASS_KINDS: readonly NodeLabel[] = Object.freeze([ + 'Class', + 'Interface', + 'Enum', + 'Struct', + 'Union', + 'Trait', + 'TypeAlias', + 'Typedef', + 'Record', + 'Delegate', + 'Annotation', + 'Template', + 'Namespace', +]); + +export const METHOD_KINDS: readonly NodeLabel[] = Object.freeze([ + 'Method', + 'Function', + 'Constructor', +]); + +export const FIELD_KINDS: readonly NodeLabel[] = Object.freeze([ + 'Variable', + 'Property', + 'Const', + 'Static', +]); diff --git a/gitnexus-shared/src/scope-resolution/registries/evidence.ts b/gitnexus-shared/src/scope-resolution/registries/evidence.ts new file mode 100644 index 000000000..cabeb6a95 --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/registries/evidence.ts @@ -0,0 +1,196 @@ +/** + * `composeEvidence` — translate accumulated raw signals per candidate + * into a `ResolutionEvidence[]` using the authoritative `EvidenceWeights` + * map (RFC §4.3 + Appendix A; Ring 2 SHARED #917). + * + * Each `RawSignals` record describes what was observed about a candidate + * during the 7-step walk: where it was found, at what depth, whether + * anything corroborates it. This module turns those raw facts into the + * typed evidence list attached to the outgoing `Resolution`. + * + * **Every weight comes from `EvidenceWeights`.** No inline magic numbers. + * Extends issue #429 (centralize hardcoded confidence values). + * + * **Confidence compose rule.** Signals add; the sum is capped at 1.0 at + * the call site (inside `lookupCore`). This module only emits the list; + * it does NOT compute the capped sum so callers can inspect per-signal + * contributions for debugging. + */ + +import type { BindingRef, ResolutionEvidence } from '../types.js'; +import { EvidenceWeights, typeBindingWeightAtDepth } from '../evidence-weights.js'; + +/** + * Raw signals observed for a single candidate during the 7-step walk. + * Optional fields encode "this signal did not fire"; presence encodes + * "emit an evidence record". + */ +export interface RawSignals { + // ── Where-found ──────────────────────────────────────────────────────── + /** Visibility origin of the binding that produced this candidate. */ + readonly origin?: BindingRef['origin'] | 'global-qualified' | 'global-name'; + /** Depth at which the binding was found (hops up from start scope). */ + readonly scopeChainDepth?: number; + /** `ImportEdge` that brought the name in; present when origin is a non-local. */ + readonly viaUnlinkedImport?: boolean; + + // ── Type-binding path ────────────────────────────────────────────────── + /** Set when the candidate came via the receiver's type-binding MRO walk. */ + readonly typeBindingMroDepth?: number; + + // ── Corroborators ────────────────────────────────────────────────────── + /** `def.ownerId === resolvedReceiver.def.nodeId`. */ + readonly ownerMatch?: boolean; + /** Always fires for candidates that pass `acceptedKinds`; weight 0. */ + readonly kindMatch: true; + + // ── Arity ────────────────────────────────────────────────────────────── + readonly arityVerdict?: 'compatible' | 'unknown' | 'incompatible'; + + // ── Dynamic-unresolved passthrough ───────────────────────────────────── + /** Candidate flows through a `kind: 'dynamic-unresolved'` ImportEdge. */ + readonly dynamicUnresolved?: boolean; +} + +/** + * Compose the raw signals into a stable `ResolutionEvidence[]` list. + * + * Emission order mirrors the `EvidenceWeights` layout: where-found → + * type-binding → corroborators → arity → degraded. Stable order makes + * the per-signal contributions easy to reason about in tests and in the + * shadow-mode parity dashboard. + */ +export function composeEvidence(signals: RawSignals): readonly ResolutionEvidence[] { + const out: ResolutionEvidence[] = []; + + // ── Where-found visibility ───────────────────────────────────────────── + if (signals.origin !== undefined) { + const baseWeight = getOriginWeight(signals.origin); + const capped = signals.viaUnlinkedImport + ? baseWeight * EvidenceWeights.unlinkedImportMultiplier + : baseWeight; + const evidenceKind = whereFoundEvidenceKind(signals.origin); + out.push({ + kind: evidenceKind, + weight: capped, + ...(signals.viaUnlinkedImport + ? { note: `via unresolved import (${EvidenceWeights.unlinkedImportMultiplier}× cap)` } + : {}), + }); + } + + // ── Scope-chain depth deduction (per-hop, only meaningful for lexical + // hits where scopeChainDepth ≥ 1). Depth 0 = no deduction; depth N ≥ 1 + // emits a single `scope-chain` evidence with the accumulated penalty. + if (signals.scopeChainDepth !== undefined && signals.scopeChainDepth > 0) { + out.push({ + kind: 'scope-chain', + weight: EvidenceWeights.scopeChainPerDepth * signals.scopeChainDepth, + note: `depth=${signals.scopeChainDepth}`, + }); + } + + // ── Type-binding / MRO path ──────────────────────────────────────────── + if (signals.typeBindingMroDepth !== undefined) { + out.push({ + kind: 'type-binding', + weight: typeBindingWeightAtDepth(signals.typeBindingMroDepth), + note: `mroDepth=${signals.typeBindingMroDepth}`, + }); + } + + // ── Owner match (explanatory for debug) ──────────────────────────────── + if (signals.ownerMatch === true) { + out.push({ + kind: 'owner-match', + weight: EvidenceWeights.ownerMatch, + }); + } + + // ── Kind match (always present; weight 0; retained for debuggability) ── + out.push({ + kind: 'kind-match', + weight: EvidenceWeights.kindMatch, + }); + + // ── Arity ────────────────────────────────────────────────────────────── + if (signals.arityVerdict !== undefined) { + const weight = + signals.arityVerdict === 'compatible' + ? EvidenceWeights.arityMatchCompatible + : signals.arityVerdict === 'incompatible' + ? EvidenceWeights.arityMatchIncompatible + : EvidenceWeights.arityMatchUnknown; + out.push({ + kind: 'arity-match', + weight, + note: signals.arityVerdict, + }); + } + + // ── Dynamic-unresolved (degraded signal) ─────────────────────────────── + if (signals.dynamicUnresolved === true) { + out.push({ + kind: 'dynamic-import-unresolved', + weight: EvidenceWeights.dynamicImportUnresolved, + }); + } + + return out; +} + +/** + * Sum evidence weights and clamp to `[0, 1]`. Separate from `composeEvidence` + * so tests and the parity dashboard can inspect the raw evidence list. + */ +export function confidenceFromEvidence(evidence: readonly ResolutionEvidence[]): number { + let sum = 0; + for (const e of evidence) sum += e.weight; + if (sum < 0) return 0; + if (sum > 1) return 1; + return sum; +} + +// ─── Internal ─────────────────────────────────────────────────────────────── + +function getOriginWeight(origin: NonNullable): number { + switch (origin) { + case 'local': + return EvidenceWeights.local; + case 'import': + return EvidenceWeights.import; + case 'reexport': + return EvidenceWeights.reexport; + case 'namespace': + return EvidenceWeights.namespace; + case 'wildcard': + return EvidenceWeights.wildcard; + case 'global-qualified': + return EvidenceWeights.globalQualified; + case 'global-name': + // Reserved for Ring 3 byName global index. `lookupCore` today only + // emits `'global-qualified'` (via `lookupQualified`, dotted-name + // fallback); no code path constructs `origin: 'global-name'` yet. + // Kept here so the Appendix A weight stays live and `composeEvidence` + // remains exhaustive over the origin union. + return EvidenceWeights.globalName; + } +} + +function whereFoundEvidenceKind( + origin: NonNullable, +): ResolutionEvidence['kind'] { + switch (origin) { + case 'local': + return 'local'; + case 'import': + case 'reexport': + case 'namespace': + case 'wildcard': + return 'import'; + case 'global-qualified': + return 'global-qualified'; + case 'global-name': + return 'global-name'; + } +} diff --git a/gitnexus-shared/src/scope-resolution/registries/field-registry.ts b/gitnexus-shared/src/scope-resolution/registries/field-registry.ts new file mode 100644 index 000000000..9e6a7aa0f --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/registries/field-registry.ts @@ -0,0 +1,43 @@ +/** + * `FieldRegistry` — scope-aware lookup for field / property / variable + * access (RFC §4.4; Ring 2 SHARED #917). + * + * Thin wrapper over `lookupCore`, specialized for data-member kinds: + * + * - `acceptedKinds` = Variable / Property / Const / Static. + * - `useReceiverTypeBinding` is **true** — fields are resolved against + * the receiver type's MRO first, then via the lexical chain for + * free variables. + * - `callsite` is not meaningful for field access (no arity), but the + * `explicitReceiver` and `ownerScopedContributor` knobs are. + */ + +import type { Resolution, ScopeId } from '../types.js'; +import { lookupCore, type CoreLookupParams } from './lookup-core.js'; +import type { OwnerScopedContributor, RegistryContext } from './context.js'; +import { FIELD_KINDS } from './context.js'; + +export interface FieldLookupOptions { + readonly explicitReceiver?: { readonly name: string }; + readonly ownerScopedContributor?: OwnerScopedContributor; +} + +export interface FieldRegistry { + lookup(name: string, scope: ScopeId, options?: FieldLookupOptions): readonly Resolution[]; +} + +export function buildFieldRegistry(ctx: RegistryContext): FieldRegistry { + return { + lookup(name: string, scope: ScopeId, options: FieldLookupOptions = {}) { + const params: CoreLookupParams = { + acceptedKinds: FIELD_KINDS, + useReceiverTypeBinding: true, + ownerScopedContributor: options.ownerScopedContributor ?? null, + ...(options.explicitReceiver !== undefined + ? { explicitReceiver: options.explicitReceiver } + : {}), + }; + return lookupCore(name, scope, params, ctx); + }, + }; +} diff --git a/gitnexus-shared/src/scope-resolution/registries/lookup-core.ts b/gitnexus-shared/src/scope-resolution/registries/lookup-core.ts new file mode 100644 index 000000000..a18ad4930 --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/registries/lookup-core.ts @@ -0,0 +1,461 @@ +/** + * `lookupCore` — the shared 7-step canonical resolution algorithm + * (RFC §4.2; Ring 2 SHARED #917). + * + * Pure function. Given a name, a starting scope, and per-kind parameters, + * walks lexical scopes + optional type-binding MRO + optional owner + * contributor + global qualified-name fallback, and returns a ranked + * `Resolution[]` with per-candidate evidence. + * + * All three public registries (`ClassRegistry` / `MethodRegistry` / + * `FieldRegistry`) dispatch into this function, differing only in the + * parameters they pass. The CHOICE of which steps fire is expressed + * through `LookupParams`, not through different algorithms per kind. + * + * ## Algorithm (RFC §4.2, verbatim names) + * + * **Step 1 — Lexical scope-chain walk.** From `startScope`, walk + * parent-ward. At each scope, consult `scope.bindings.get(name)`: + * - Filter candidates whose `def.type ∈ acceptedKinds`. + * - For each surviving candidate, record a raw signal with the + * binding's origin + the current scope-chain depth. + * - **Hard shadow.** If `bindings.get(name)` is non-empty (including + * non-kind-matching candidates), stop walking. The name is + * lexically bound here; outer scopes are not consulted. + * + * **Step 2 — Type-binding resolution.** When `useReceiverTypeBinding` + * is true, resolve the receiver's type at `startScope` (from + * `scope.typeBindings`), then walk the MRO via + * `MethodDispatchIndex.mroFor(ownerDefId)`. Membership per owner comes + * through `RegistryContext.methodDispatch` + owner lookups into + * `scope.ownedDefs`; each hit records a raw signal with the owner's + * MRO depth. + * + * **Step 3 — Owner-scoped contributor.** When + * `params.ownerScopedContributor` is present, merge its `byName(name)` + * hits with `origin: 'local'` (they are declared directly on the + * receiver). Distinct from Step 2 — Step 2 walks the MRO; Step 3 only + * looks at the directly-declared owner members. + * + * **Step 4 — Kind filter (emit `kind-match` evidence).** Already + * applied during Steps 1-3; this step just adds a `kind-match` signal + * at weight 0 to every candidate for debuggability (so the evidence + * array is self-describing). + * + * **Step 5 — Arity filter.** Call `providers.arityCompatibility(callsite, + * def)` per surviving candidate. Verdicts: `compatible` / `unknown` / + * `incompatible`. If at least one candidate is `compatible`, drop + * `incompatible` ones. Otherwise keep all (the penalty weight alone + * will rank them lower but they remain in the result). + * + * **Step 6 — Global fallback.** When Steps 1-3 produced **no** + * candidates and the name contains a `.`, consult the + * `QualifiedNameIndex` via `lookupQualified` — see §4.5. The `scope` + * argument is NOT passed here because global lookup is scope-agnostic. + * + * **Step 7 — Rank + tie-break.** Compose evidence, compute confidence + * (sum capped at 1.0), sort by the RFC Appendix B cascade. + * + * ## What this module does NOT do + * + * - No AST reads (pure data in, pure data out). + * - No `gitnexus/` imports. + * - No language switches. Language-specific behavior flows exclusively + * through `providers.*` and the `params` object. + * - No caching. Callers that want memoization can wrap this function. + */ + +import type { NodeLabel } from '../../graph/types.js'; +import type { SymbolDefinition } from '../symbol-definition.js'; +import type { + BindingRef, + Callsite, + DefId, + LookupParams, + Resolution, + Scope, + ScopeId, +} from '../types.js'; +import type { OriginForTieBreak } from '../origin-priority.js'; +import { composeEvidence, confidenceFromEvidence, type RawSignals } from './evidence.js'; +import { compareByConfidenceWithTiebreaks, type TieBreakKey } from './tie-breaks.js'; +import { lookupQualified } from './lookup-qualified.js'; +import type { ArityVerdict, OwnerScopedContributor, RegistryContext } from './context.js'; + +// ─── Public entry point ───────────────────────────────────────────────────── + +/** Extended `LookupParams` narrowing `ownerScopedContributor` to the concrete shape. */ +export interface CoreLookupParams extends Omit { + readonly ownerScopedContributor: OwnerScopedContributor | null; + /** Call-site description forwarded to `arityCompatibility`. Optional — for non-call lookups. */ + readonly callsite?: Callsite; +} + +/** + * Run the 7-step lookup. Returns a non-empty `Resolution[]` when any + * candidate was found; an empty array otherwise. Callers consume `[0]` + * for the best answer and optionally inspect the rest for alternates. + */ +export function lookupCore( + name: string, + startScope: ScopeId, + params: CoreLookupParams, + ctx: RegistryContext, +): readonly Resolution[] { + const acceptedKinds = new Set(params.acceptedKinds); + const perCandidate = new Map(); + + // ── Step 1: lexical scope-chain walk ────────────────────────────────── + const lexicalShadowed = walkLexicalChain(name, startScope, acceptedKinds, ctx, perCandidate); + + // ── Step 2: type-binding / MRO walk (methods/fields) ────────────────── + if (params.useReceiverTypeBinding && ctx.methodDispatch !== undefined) { + walkReceiverTypeBinding(name, startScope, acceptedKinds, params, ctx, perCandidate); + } + + // ── Step 3: owner-scoped contributor ────────────────────────────────── + if (params.ownerScopedContributor !== null) { + seedFromOwnerScopedContributor( + name, + params.ownerScopedContributor, + acceptedKinds, + perCandidate, + ); + } + + // ── Step 4: kind-match evidence (emitted by composeEvidence directly) ── + // Handled inside `composeEvidence`. + + // ── Step 5: arity filter ────────────────────────────────────────────── + if (params.callsite !== undefined) { + applyArityFilter(params.callsite, perCandidate, ctx); + } + + // ── Step 6: global fallback (only when Steps 1-3 produced nothing) ── + if (perCandidate.size === 0 && !lexicalShadowed && name.includes('.')) { + const globals = lookupQualified(name, { acceptedKinds: params.acceptedKinds }, ctx); + if (globals.length > 0) return globals; + } + + if (perCandidate.size === 0) return EMPTY; + + // ── Step 7: compose evidence + rank ────────────────────────────────── + return rankCandidates(perCandidate); +} + +// ─── Internal state ──────────────────────────────────────────────────────── + +interface CandidateState { + readonly def: SymbolDefinition; + readonly signals: MutableRawSignals; + readonly tieBreakKey: MutableTieBreakKey; +} + +interface MutableRawSignals { + origin?: BindingRef['origin'] | 'global-qualified' | 'global-name'; + scopeChainDepth?: number; + viaUnlinkedImport?: boolean; + typeBindingMroDepth?: number; + ownerMatch?: boolean; + kindMatch: true; + arityVerdict?: ArityVerdict; + dynamicUnresolved?: boolean; +} + +interface MutableTieBreakKey { + scopeDepth: number; + mroDepth: number; + origin: OriginForTieBreak; +} + +function ensureCandidate( + perCandidate: Map, + def: SymbolDefinition, +): CandidateState { + const existing = perCandidate.get(def.nodeId); + if (existing !== undefined) return existing; + const fresh: CandidateState = { + def, + signals: { kindMatch: true }, + tieBreakKey: { scopeDepth: 0, mroDepth: 0, origin: 'local' }, + }; + perCandidate.set(def.nodeId, fresh); + return fresh; +} + +// ─── Step 1 implementation ───────────────────────────────────────────────── + +/** + * Walk the lexical scope chain from `startScope` upward. Returns `true` + * iff a scope with any `bindings.get(name)` entries was found — the + * caller uses this to decide whether to run the global fallback. + */ +function walkLexicalChain( + name: string, + startScope: ScopeId, + acceptedKinds: ReadonlySet, + ctx: RegistryContext, + perCandidate: Map, +): boolean { + let currentId: ScopeId | null = startScope; + let depth = 0; + const visited = new Set(); + + while (currentId !== null) { + if (visited.has(currentId)) return false; + visited.add(currentId); + + const scope: Scope | undefined = ctx.scopes.getScope(currentId); + if (scope === undefined) return false; + + const bindings = scope.bindings.get(name); + if (bindings !== undefined && bindings.length > 0) { + for (const binding of bindings) { + if (!acceptedKinds.has(binding.def.type)) continue; + recordLexicalHit(perCandidate, binding, depth); + } + return true; // hard shadow regardless of kind-filter survivorship + } + + currentId = scope.parent; + depth++; + } + + return false; +} + +function recordLexicalHit( + perCandidate: Map, + binding: BindingRef, + scopeChainDepth: number, +): void { + const state = ensureCandidate(perCandidate, binding.def); + state.signals.origin = binding.origin; + state.signals.scopeChainDepth = scopeChainDepth; + if (binding.via?.linkStatus === 'unresolved') { + state.signals.viaUnlinkedImport = true; + } + if (binding.via?.kind === 'dynamic-unresolved') { + state.signals.dynamicUnresolved = true; + } + state.tieBreakKey.scopeDepth = scopeChainDepth; + state.tieBreakKey.origin = binding.origin as OriginForTieBreak; +} + +// ─── Step 2 implementation ───────────────────────────────────────────────── + +function walkReceiverTypeBinding( + name: string, + startScope: ScopeId, + acceptedKinds: ReadonlySet, + params: CoreLookupParams, + ctx: RegistryContext, + perCandidate: Map, +): void { + const ownerDefId = resolveReceiverOwner(startScope, params, ctx); + if (ownerDefId === undefined) return; + + if (ctx.methodDispatch === undefined) return; + + const ownerDef = ctx.defs.get(ownerDefId); + if (ownerDef === undefined) return; + + // Walk the owner itself at depth 0, then its MRO chain. + const walk: DefId[] = [ownerDefId, ...ctx.methodDispatch.mroFor(ownerDefId)]; + + for (let mroDepth = 0; mroDepth < walk.length; mroDepth++) { + const currentOwnerId = walk[mroDepth]!; + const members = collectOwnedMembers(currentOwnerId, name, ctx); + for (const def of members) { + if (!acceptedKinds.has(def.type)) continue; + recordTypeBindingHit(perCandidate, def, mroDepth, ownerDefId); + } + } +} + +function resolveReceiverOwner( + startScope: ScopeId, + params: CoreLookupParams, + ctx: RegistryContext, +): DefId | undefined { + // Explicit receiver: consult the callsite scope's typeBindings for the + // named receiver; the attached TypeRef identifies the owner. Without a + // ready resolveTypeRef call (that module is separate), we do a direct + // lookup and trust the caller to have populated the binding. + if (params.explicitReceiver !== undefined) { + return lookupReceiverType(startScope, params.explicitReceiver.name, ctx); + } + + // Implicit `self` / `this` — the scope's typeBindings should carry it. + for (const implicitName of IMPLICIT_RECEIVERS) { + const owner = lookupReceiverType(startScope, implicitName, ctx); + if (owner !== undefined) return owner; + } + return undefined; +} + +const IMPLICIT_RECEIVERS: readonly string[] = Object.freeze(['self', 'this']); + +function lookupReceiverType( + startScope: ScopeId, + receiverName: string, + ctx: RegistryContext, +): DefId | undefined { + let currentId: ScopeId | null = startScope; + const visited = new Set(); + while (currentId !== null) { + if (visited.has(currentId)) return undefined; + visited.add(currentId); + + const scope = ctx.scopes.getScope(currentId); + if (scope === undefined) return undefined; + + const typeRef = scope.typeBindings.get(receiverName); + if (typeRef !== undefined) { + // rawName must resolve to a def via qualifiedNames; if it doesn't, we + // can't claim the receiver type. No fallback — that's what + // `resolveTypeRef` would do, but we keep this path lean and let + // callers pre-resolve if they want the richer semantics. + const candidateIds = ctx.qualifiedNames.get(typeRef.rawName); + if (candidateIds.length === 1) return candidateIds[0]; + // Ambiguous (≥ 2) or missing (0) — caller must pre-resolve via + // `resolveTypeRef` (#916) if they want the richer semantics. We + // intentionally do NOT re-implement a simple-name fallback here. + return undefined; + } + currentId = scope.parent; + } + return undefined; +} + +function collectOwnedMembers( + ownerDefId: DefId, + memberName: string, + ctx: RegistryContext, +): readonly SymbolDefinition[] { + // An owner's members are defs whose `ownerId === ownerDefId` and whose + // simple name matches `memberName`. We iterate `defs.byId` — O(D) per + // call today. A future by-owner index would make this O(K); tracked as + // a follow-up optimization before Ring 3 flips go production. + const out: SymbolDefinition[] = []; + for (const def of ctx.defs.byId.values()) { + if (def.ownerId !== ownerDefId) continue; + if (simpleNameOf(def) !== memberName) continue; + out.push(def); + } + return out; +} + +function simpleNameOf(def: SymbolDefinition): string | undefined { + if (def.qualifiedName === undefined || def.qualifiedName.length === 0) return undefined; + const dot = def.qualifiedName.lastIndexOf('.'); + return dot === -1 ? def.qualifiedName : def.qualifiedName.slice(dot + 1); +} + +function recordTypeBindingHit( + perCandidate: Map, + def: SymbolDefinition, + mroDepth: number, + receiverOwner: DefId, +): void { + const state = ensureCandidate(perCandidate, def); + const existingMroDepth = state.signals.typeBindingMroDepth; + const firstHit = existingMroDepth === undefined; + // Only replace if this hit is shallower (smaller MRO depth). The local + // const lets TS narrow to `number` in the `else` branch so no `!` + // assertion is needed. + if (firstHit || mroDepth < existingMroDepth) { + state.signals.typeBindingMroDepth = mroDepth; + state.tieBreakKey.mroDepth = mroDepth; + } + if (def.ownerId === receiverOwner) { + state.signals.ownerMatch = true; + } + // Pure type-binding candidates (no lexical hit) would otherwise keep the + // `ensureCandidate` default `tieBreakKey.origin === 'local'`, making the + // Appendix B cascade lump them with local-origin candidates. Demote them + // to `'import'` — the strongest non-local origin — only when no earlier + // phase set an origin for this candidate. Lexical hits from Step 1 set + // `signals.origin` before Step 2 runs, so the guard skips them; Step 3 + // (`seedFromOwnerScopedContributor`) runs AFTER Step 2 and unconditionally + // overrides `tieBreakKey.origin` back to `'local'` for direct-owner + // members, so any same-def overlap still ends up ranked correctly. + if (firstHit && state.signals.origin === undefined) { + state.tieBreakKey.origin = 'import'; + } +} + +// ─── Step 3 implementation ───────────────────────────────────────────────── + +function seedFromOwnerScopedContributor( + name: string, + contributor: OwnerScopedContributor, + acceptedKinds: ReadonlySet, + perCandidate: Map, +): void { + for (const def of contributor.byName(name)) { + if (!acceptedKinds.has(def.type)) continue; + const state = ensureCandidate(perCandidate, def); + // Treat the contributor's direct membership as `origin: 'local'` — + // strongest visibility, no scope-chain penalty. + state.signals.origin = 'local'; + state.signals.scopeChainDepth = 0; + state.signals.ownerMatch = def.ownerId === contributor.ownerDefId; + state.tieBreakKey.origin = 'local'; + } +} + +// ─── Step 5 implementation ───────────────────────────────────────────────── + +function applyArityFilter( + callsite: Callsite, + perCandidate: Map, + ctx: RegistryContext, +): void { + const arityFn = ctx.providers.arityCompatibility; + if (arityFn === undefined) { + // No provider → record 'unknown' for every candidate; keeps signal + // shape uniform for composeEvidence. + for (const state of perCandidate.values()) { + state.signals.arityVerdict = 'unknown'; + } + return; + } + + let anyCompatible = false; + for (const state of perCandidate.values()) { + const verdict = arityFn(callsite, state.def); + state.signals.arityVerdict = verdict; + if (verdict === 'compatible') anyCompatible = true; + } + + if (!anyCompatible) return; + + // Filter: when at least one compatible candidate exists, drop incompatibles. + for (const [defId, state] of perCandidate) { + if (state.signals.arityVerdict === 'incompatible') { + perCandidate.delete(defId); + } + } +} + +// ─── Step 7 implementation ───────────────────────────────────────────────── + +function rankCandidates(perCandidate: Map): readonly Resolution[] { + const resolutions: Resolution[] = []; + const tieKeys = new Map(); + + for (const state of perCandidate.values()) { + const evidence = composeEvidence(state.signals as RawSignals); + const confidence = confidenceFromEvidence(evidence); + resolutions.push({ def: state.def, confidence, evidence }); + tieKeys.set(state.def.nodeId, { ...state.tieBreakKey }); + } + + resolutions.sort((a, b) => compareByConfidenceWithTiebreaks(a, b, tieKeys)); + return Object.freeze(resolutions); +} + +// ─── Constants ────────────────────────────────────────────────────────────── + +const EMPTY: readonly Resolution[] = Object.freeze([]); diff --git a/gitnexus-shared/src/scope-resolution/registries/lookup-qualified.ts b/gitnexus-shared/src/scope-resolution/registries/lookup-qualified.ts new file mode 100644 index 000000000..21b630486 --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/registries/lookup-qualified.ts @@ -0,0 +1,71 @@ +/** + * `lookupQualified` — qualified-name fast path (RFC §4.5; Ring 2 SHARED #917). + * + * Consults `QualifiedNameIndex` directly, filters by `acceptedKinds`, and + * returns `Resolution[]` with `origin: 'global-qualified'` evidence. Used by: + * + * - `resolveTypeRef` dotted fallback (#916) + * - `Registry.lookup` Step 6 when no lexical candidate survived + * - Explicit dotted identifiers in Cypher / MCP tools where the caller + * knows the target's canonical qualified name + * + * **Strict + deterministic.** No receiver-type resolution, no scope walk. + * Every surviving candidate gets the same base confidence (from + * `EvidenceWeights.globalQualified`), then the tie-break cascade + * disambiguates. + */ + +import type { NodeLabel } from '../../graph/types.js'; +import type { Resolution } from '../types.js'; +import { composeEvidence, confidenceFromEvidence } from './evidence.js'; +import { compareByConfidenceWithTiebreaks, type TieBreakKey } from './tie-breaks.js'; +import type { RegistryContext } from './context.js'; + +export interface LookupQualifiedParams { + readonly acceptedKinds: readonly NodeLabel[]; +} + +/** + * Look up a canonical qualified name (e.g., `app.models.User`) across all + * defs, filtered by `acceptedKinds`. Returns an empty array when the name + * is not indexed or no candidate matches the kind filter. + * + * Callers consume `[0]` for the strict single-return answer; the remainder + * carries alternate candidates (partial classes, overloads, accidental + * cross-kind hits) ordered by the tie-break cascade. + */ +export function lookupQualified( + qualifiedName: string, + params: LookupQualifiedParams, + ctx: RegistryContext, +): readonly Resolution[] { + const defIds = ctx.qualifiedNames.get(qualifiedName); + if (defIds.length === 0) return EMPTY; + + const acceptedKinds = new Set(params.acceptedKinds); + + const resolutions: Resolution[] = []; + const tieKeys = new Map(); + + for (const defId of defIds) { + const def = ctx.defs.get(defId); + if (def === undefined) continue; + if (!acceptedKinds.has(def.type)) continue; + + const evidence = composeEvidence({ origin: 'global-qualified', kindMatch: true }); + const confidence = confidenceFromEvidence(evidence); + resolutions.push({ def, confidence, evidence }); + tieKeys.set(def.nodeId, { + scopeDepth: 0, + mroDepth: 0, + origin: 'global-qualified', + }); + } + + if (resolutions.length === 0) return EMPTY; + + resolutions.sort((a, b) => compareByConfidenceWithTiebreaks(a, b, tieKeys)); + return Object.freeze(resolutions); +} + +const EMPTY: readonly Resolution[] = Object.freeze([]); diff --git a/gitnexus-shared/src/scope-resolution/registries/method-registry.ts b/gitnexus-shared/src/scope-resolution/registries/method-registry.ts new file mode 100644 index 000000000..ed206d164 --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/registries/method-registry.ts @@ -0,0 +1,54 @@ +/** + * `MethodRegistry` — scope-aware lookup for method / function / constructor + * dispatch (RFC §4.4; Ring 2 SHARED #917). + * + * Thin wrapper over `lookupCore`, specialized for callable kinds: + * + * - `acceptedKinds` = Method / Function / Constructor. + * - `useReceiverTypeBinding` is **true** — the type-binding + MRO walk + * (Step 2) is the primary evidence path for receiver-dispatched calls. + * - `callsite.arity` flows through to `provider.arityCompatibility` + * when provided. When the provider is absent, arity evidence is + * `unknown` (neutral signal). + */ + +import type { Callsite, Resolution, ScopeId } from '../types.js'; +import { lookupCore, type CoreLookupParams } from './lookup-core.js'; +import type { OwnerScopedContributor, RegistryContext } from './context.js'; +import { METHOD_KINDS } from './context.js'; + +/** + * Extra per-call parameters that vary across call sites but NOT across + * registries. Kept as a separate shape so `MethodRegistry.lookup` stays + * concise while still exposing the explicit-receiver + owner-contributor + + * arity knobs the RFC algorithm needs. + */ +export interface MethodLookupOptions { + /** Call-site arity for `provider.arityCompatibility`. */ + readonly callsite?: Callsite; + /** Explicit receiver (e.g., `user` in `user.save()`). See §4.1. */ + readonly explicitReceiver?: { readonly name: string }; + /** Optional per-owner contributor (Step 3). */ + readonly ownerScopedContributor?: OwnerScopedContributor; +} + +export interface MethodRegistry { + lookup(name: string, scope: ScopeId, options?: MethodLookupOptions): readonly Resolution[]; +} + +export function buildMethodRegistry(ctx: RegistryContext): MethodRegistry { + return { + lookup(name: string, scope: ScopeId, options: MethodLookupOptions = {}) { + const params: CoreLookupParams = { + acceptedKinds: METHOD_KINDS, + useReceiverTypeBinding: true, + ownerScopedContributor: options.ownerScopedContributor ?? null, + ...(options.callsite !== undefined ? { callsite: options.callsite } : {}), + ...(options.explicitReceiver !== undefined + ? { explicitReceiver: options.explicitReceiver } + : {}), + }; + return lookupCore(name, scope, params, ctx); + }, + }; +} diff --git a/gitnexus-shared/src/scope-resolution/registries/tie-breaks.ts b/gitnexus-shared/src/scope-resolution/registries/tie-breaks.ts new file mode 100644 index 000000000..9d6f0dee9 --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/registries/tie-breaks.ts @@ -0,0 +1,76 @@ +/** + * `compareByConfidenceWithTiebreaks` — the RFC §4.2 Step 7 total order + * over `Resolution` candidates (Ring 2 SHARED #917). + * + * Primary key is confidence (DESC). Remaining ties within `CONFIDENCE_EPSILON` + * fall through a deterministic cascade so the same inputs always produce + * the same winner, independent of insertion order. + * + * Tie-break cascade (per RFC Appendix B): + * + * 1. confidence DESC (primary) + * 2. scope depth ASC (nearer lexical scope wins) + * 3. MRO depth ASC (nearer class in hierarchy wins) + * 4. `ORIGIN_PRIORITY` ASC (local > import > … > global-name) + * 5. DefId.localeCompare (final deterministic tiebreaker) + * + * The per-candidate inputs needed beyond `Resolution.confidence` — + * `scopeDepth`, `mroDepth`, `origin` — are supplied via a sidecar + * `TieBreakKey` so the comparator stays pure and `Resolution` itself + * doesn't need to carry book-keeping fields. + */ + +import { ORIGIN_PRIORITY, type OriginForTieBreak } from '../origin-priority.js'; +import type { Resolution } from '../types.js'; + +export const CONFIDENCE_EPSILON = 0.001; + +/** Side-information per candidate used for secondary tie-breaks. */ +export interface TieBreakKey { + readonly scopeDepth: number; + readonly mroDepth: number; + readonly origin: OriginForTieBreak; +} + +/** + * Pure comparator suitable for `Array.prototype.sort`. Return value follows + * the JavaScript convention: negative → `a` wins, positive → `b` wins. + * + * **Important:** `keys` is keyed by `Resolution.def.nodeId`, not by array + * index — stable across reorderings. Missing keys fall back to neutral + * values (`scopeDepth: 0`, `mroDepth: 0`, `origin: 'local'`), which means + * the tie-break degrades gracefully to defId-lexicographic ordering when + * side-info is unavailable. That keeps the total order deterministic + * even on malformed inputs. + */ +export function compareByConfidenceWithTiebreaks( + a: Resolution, + b: Resolution, + keys: ReadonlyMap, +): number { + // Primary: confidence DESC, treating values within epsilon as equal. + const delta = b.confidence - a.confidence; + if (Math.abs(delta) >= CONFIDENCE_EPSILON) return delta < 0 ? -1 : 1; + + const ka = keys.get(a.def.nodeId) ?? DEFAULT_KEY; + const kb = keys.get(b.def.nodeId) ?? DEFAULT_KEY; + + // Secondary: scope depth ASC. + if (ka.scopeDepth !== kb.scopeDepth) return ka.scopeDepth - kb.scopeDepth; + + // Tertiary: MRO depth ASC. + if (ka.mroDepth !== kb.mroDepth) return ka.mroDepth - kb.mroDepth; + + // Quaternary: ORIGIN_PRIORITY ASC. + const po = ORIGIN_PRIORITY[ka.origin] - ORIGIN_PRIORITY[kb.origin]; + if (po !== 0) return po; + + // Final: DefId lexicographic, locale-aware for deterministic cross-platform output. + return a.def.nodeId.localeCompare(b.def.nodeId); +} + +const DEFAULT_KEY: TieBreakKey = Object.freeze({ + scopeDepth: 0, + mroDepth: 0, + origin: 'local', +}); diff --git a/gitnexus-shared/src/scope-resolution/resolve-type-ref.ts b/gitnexus-shared/src/scope-resolution/resolve-type-ref.ts new file mode 100644 index 000000000..2f8ba7bd5 --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/resolve-type-ref.ts @@ -0,0 +1,148 @@ +/** + * `resolveTypeRef` — strict single-return resolver for `TypeRef`s + * (RFC §4.6; Ring 2 SHARED #916). + * + * Narrower contract than `Registry.lookup`: no name-only global fallback, no + * confidence ranking, no arity check. Used by `Registry.lookup` Step 2 (type- + * binding propagation) and by any caller that wants the single best type- + * target for an annotation without paying for the full evidence pipeline. + * + * **Algorithm (strict).** Walk the scope chain from `ref.declaredAtScope`: + * + * 1. At each scope, inspect `bindings.get(ref.rawName)`: + * - If one of the bindings is a **type-kind** def with a **strict origin** + * (`'local' | 'import' | 'namespace' | 'reexport'`), return it. + * - If any binding for this name exists at this scope but none qualifies + * (e.g., a local variable named `User` shadows an outer import of class + * `User`), return `null`. The nearer binding shadows; we do NOT fall + * through to the global qualified-name index. + * - Otherwise continue to the parent scope. + * 2. If the raw name is a dotted path (e.g., `'models.User'`) and the scope + * walk produced no match, consult `QualifiedNameIndex.byQualifiedName`. + * Only accept **exactly one** type-kind hit — anything ambiguous returns + * `null` rather than a guess. + * 3. Return `null`. + * + * **What `'strict' origins' means.** `'wildcard'` is intentionally excluded. + * A wildcard-expanded name (`from x import *`) is too loose to use as an + * anchor for type resolution — it gives no signal about whether the name was + * actually imported. `Registry.lookup` may accept wildcard bindings at its + * own discretion (with lower evidence weight); `resolveTypeRef` does not. + * + * **What 'type-kind' means.** The subset of `NodeLabel` that a type annotation + * may legitimately reference: class-like, interface-like, enum-like, and + * alias-like kinds. See `TYPE_KINDS` below. + * + * Pure function — safe to call repeatedly; no side effects. + */ + +import type { NodeLabel } from '../graph/types.js'; +import type { SymbolDefinition } from './symbol-definition.js'; +import type { BindingRef, ScopeId, ScopeLookup, TypeRef } from './types.js'; +import type { DefIndex } from './def-index.js'; +import type { QualifiedNameIndex } from './qualified-name-index.js'; + +// ─── Public contracts ─────────────────────────────────────────────────────── + +/** + * All inputs `resolveTypeRef` needs from the semantic model. Bundled into a + * context object so the call site stays short and the interface is stable as + * additional indexes get threaded through in later rings. + */ +export interface ResolveTypeRefContext { + readonly scopes: ScopeLookup; + readonly defIndex: DefIndex; + readonly qualifiedNameIndex: QualifiedNameIndex; +} + +// ─── Strict policy constants ──────────────────────────────────────────────── + +/** `'wildcard'` is deliberately absent. See file header. */ +const STRICT_ORIGINS: ReadonlySet = new Set([ + 'local', + 'import', + 'namespace', + 'reexport', +]); + +/** + * `NodeLabel` values that may appear on the RHS of a type annotation. + * + * Includes the usual class-like and interface-like kinds plus the alias-like + * ones (`TypeAlias`, `Typedef`). `Namespace` is excluded — it is a scope + * container, not a value type. `Function` / `Method` / `Variable` are + * excluded by design: a `rawName` bound to them at a strict origin is a + * *shadowing* binding, which the algorithm short-circuits to `null`. + * + * `'Type'` (the generic `NodeLabel` value) is also excluded — verified + * against `gitnexus/src/core/ingestion/` at the time of writing, no + * production extractor emits `type: 'Type'` for annotation-relevant + * symbols. Should a future extractor start emitting it, add `'Type'` + * here and add a test asserting the new path. + */ +const TYPE_KINDS: ReadonlySet = new Set([ + 'Class', + 'Interface', + 'Enum', + 'Struct', + 'Union', + 'Trait', + 'TypeAlias', + 'Typedef', + 'Record', + 'Delegate', + 'Annotation', + 'Template', +]); + +// ─── Main entry point ────────────────────────────────────────────────────── + +export function resolveTypeRef(ref: TypeRef, ctx: ResolveTypeRefContext): SymbolDefinition | null { + // Phase 1: scope-chain walk anchored at the declaration site. + let currentId: ScopeId | null = ref.declaredAtScope; + const visited = new Set(); + + while (currentId !== null) { + // Cycle guard — a well-formed scope tree never loops, but a bug in the + // construction path should fail fast here rather than hanging. + if (visited.has(currentId)) return null; + visited.add(currentId); + + const scope = ctx.scopes.getScope(currentId); + if (scope === undefined) return null; // broken chain = unresolvable + + const bindings = scope.bindings.get(ref.rawName); + if (bindings !== undefined && bindings.length > 0) { + // At least one binding exists at this scope → it is the shadowing site. + // Either one of them qualifies, or the name is shadowed by a non-type. + for (const binding of bindings) { + if (!STRICT_ORIGINS.has(binding.origin)) continue; + if (TYPE_KINDS.has(binding.def.type)) { + return binding.def; + } + } + // Shadowed by a non-type / non-strict-origin binding. Fail fast — no + // global fallback, no walk to the parent. + return null; + } + + currentId = scope.parent; + } + + // Phase 2: dotted fallback via `QualifiedNameIndex`. Only accept a unique + // type-kind hit; anything ambiguous returns null (strict: no guesses). + if (ref.rawName.includes('.')) { + const candidates = ctx.qualifiedNameIndex.get(ref.rawName); + let onlyTypeDef: SymbolDefinition | null = null; + for (const defId of candidates) { + const def = ctx.defIndex.get(defId); + if (def === undefined) continue; + if (!TYPE_KINDS.has(def.type)) continue; + if (onlyTypeDef !== null) return null; // ambiguous + onlyTypeDef = def; + } + if (onlyTypeDef !== null) return onlyTypeDef; + } + + return null; +} diff --git a/gitnexus-shared/src/scope-resolution/scope-id.ts b/gitnexus-shared/src/scope-resolution/scope-id.ts new file mode 100644 index 000000000..b682468cc --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/scope-id.ts @@ -0,0 +1,57 @@ +/** + * `ScopeId` canonical constructor + string intern pool + * (RFC §2.2; Ring 2 SHARED #912). + * + * `ScopeId` is a deterministic string derived from the scope's file path, + * byte range, and kind: + * + * scope:{filePath}#{startLine}:{startCol}-{endLine}:{endCol}:{kind} + * + * Two scopes produced by reparsing the same file at the same positions are + * `===`-equal as strings. Beyond the canonical shape, `makeScopeId` also + * **interns** the string through a process-local pool, so repeated calls + * with structurally identical inputs return the same string reference — + * making `Map` lookups and cache keys identity-fast. + * + * The intern pool is unbounded. The number of distinct `ScopeId`s across a + * single indexing run is O(total scopes in workspace), which is bounded by + * source-text size and already in memory; interning adds no asymptotic + * pressure. `clearScopeIdInternPool` is exported for test isolation. + */ + +import type { Range } from './types.js'; +import type { ScopeId, ScopeKind } from './types.js'; + +/** Inputs required to construct a canonical `ScopeId`. */ +export interface ScopeIdInput { + readonly filePath: string; + readonly range: Range; + readonly kind: ScopeKind; +} + +/** + * Build a canonical `ScopeId` from its structural parts and intern it. + * + * Pure + referentially transparent: given the same input shape, always + * returns the same string reference for the lifetime of the pool. + */ +export function makeScopeId(input: ScopeIdInput): ScopeId { + const raw = `scope:${input.filePath}#${input.range.startLine}:${input.range.startCol}-${input.range.endLine}:${input.range.endCol}:${input.kind}`; + const existing = INTERN_POOL.get(raw); + if (existing !== undefined) return existing; + INTERN_POOL.set(raw, raw); + return raw; +} + +/** + * Drop the intern pool. Intended for test setup/teardown — production code + * should not need this, since the pool's memory usage is bounded by the + * number of live scopes and cleaning it mid-run would break identity + * equality for existing scope ids. + */ +export function clearScopeIdInternPool(): void { + INTERN_POOL.clear(); +} + +/** Internal: shared intern pool (process-local). */ +const INTERN_POOL = new Map(); diff --git a/gitnexus-shared/src/scope-resolution/scope-tree.ts b/gitnexus-shared/src/scope-resolution/scope-tree.ts new file mode 100644 index 000000000..7f2b54684 --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/scope-tree.ts @@ -0,0 +1,254 @@ +/** + * `ScopeTree` — the lexical-scope spine of the `SemanticModel` + * (RFC §2.2 + §3.1; Ring 2 SHARED #912). + * + * Generalizes the `enclosingFunctions` pattern from closed PR #902 to + * arbitrary `ScopeKind`s. Owns the (parent ↔ children) relationship + * derived from each `Scope.parent` pointer, and validates the structural + * invariants a well-formed scope tree must satisfy. + * + * Invariants enforced at build time (throw on violation): + * + * - Every non-`Module` scope has a non-null parent. + * - Every parent pointer references a scope that was also supplied to + * `buildScopeTree`. + * - Parent range **strictly contains** child range. + * - Sibling ranges under the same parent do not overlap. + * - Parent and child live in the same `filePath`. (Cross-file parent + * pointers would be a category error — a `File` scope is not the + * parent of another file's scopes; imports do that job.) + * + * Satisfies the `ScopeLookup` contract (defined in `./types.js`), so + * `resolveTypeRef` (#916) and the scope-aware registries (#917) can take a + * `ScopeTree` directly without adapters. + * + * Immutable surface: `byId` is a `ReadonlyMap`; children arrays are + * `Object.freeze`d; miss lookups return a shared frozen empty array. + */ + +import type { Scope, ScopeId, ScopeLookup, Range } from './types.js'; + +// ─── Public contract ──────────────────────────────────────────────────────── + +export interface ScopeTree extends ScopeLookup { + readonly size: number; + readonly byId: ReadonlyMap; + + getScope(id: ScopeId): Scope | undefined; + getParent(id: ScopeId): Scope | undefined; + /** Child `ScopeId`s of `id`, in input order. Frozen empty array on miss. */ + getChildren(id: ScopeId): readonly ScopeId[]; + /** + * Ancestor chain from the immediate parent up to (and including) the + * root module scope. Excludes the starting scope itself. Frozen empty + * array on miss / for a root scope. + */ + getAncestors(id: ScopeId): readonly ScopeId[]; + has(id: ScopeId): boolean; +} + +// ─── Build errors ─────────────────────────────────────────────────────────── + +/** + * Thrown by `buildScopeTree` when the input violates a structural + * invariant. Carries the offending ids + the invariant name so failed + * extraction pipelines can report actionable diagnostics. + */ +export class ScopeTreeInvariantError extends Error { + constructor( + readonly invariant: + | 'non-module-requires-parent' + | 'parent-not-found' + | 'parent-must-contain-child' + | 'sibling-ranges-overlap' + | 'parent-must-share-filepath' + | 'duplicate-scope-id', + message: string, + ) { + super(message); + this.name = 'ScopeTreeInvariantError'; + } +} + +// ─── Builder ─────────────────────────────────────────────────────────────── + +/** + * Build an immutable `ScopeTree` from a flat list of `Scope` records. + * + * Throws `ScopeTreeInvariantError` on the first invariant violation; a + * malformed tree is a bug in the extraction pipeline, not a data case for + * consumers to handle, so fail-fast is the correct posture. + */ +export function buildScopeTree(scopes: readonly Scope[]): ScopeTree { + const byId = new Map(); + const childrenById = new Map(); + + // ── Pass 1: collect by id + duplicate check ─────────────────────────── + for (const scope of scopes) { + if (byId.has(scope.id)) { + throw new ScopeTreeInvariantError( + 'duplicate-scope-id', + `Two scopes share id '${scope.id}'. Scope ids must be unique per tree.`, + ); + } + byId.set(scope.id, scope); + } + + // ── Pass 2: validate parent pointers + build children buckets ───────── + for (const scope of scopes) { + if (scope.parent === null) { + if (scope.kind !== 'Module') { + throw new ScopeTreeInvariantError( + 'non-module-requires-parent', + `Scope '${scope.id}' has kind '${scope.kind}' but no parent. Only 'Module' scopes may be root-level.`, + ); + } + continue; + } + + const parent = byId.get(scope.parent); + if (parent === undefined) { + throw new ScopeTreeInvariantError( + 'parent-not-found', + `Scope '${scope.id}' references parent '${scope.parent}' which is not part of this tree.`, + ); + } + if (parent.filePath !== scope.filePath) { + throw new ScopeTreeInvariantError( + 'parent-must-share-filepath', + `Scope '${scope.id}' (${scope.filePath}) has parent '${parent.id}' in a different file (${parent.filePath}). Parent/child scopes must share filePath.`, + ); + } + if (!rangeStrictlyContains(parent.range, scope.range)) { + throw new ScopeTreeInvariantError( + 'parent-must-contain-child', + `Parent scope '${parent.id}' at ${formatRange(parent.range)} does not strictly contain child '${scope.id}' at ${formatRange(scope.range)}.`, + ); + } + + let bucket = childrenById.get(parent.id); + if (bucket === undefined) { + bucket = []; + childrenById.set(parent.id, bucket); + } + bucket.push(scope.id); + } + + // ── Pass 3: sibling-overlap check ───────────────────────────────────── + for (const [parentId, childIds] of childrenById) { + if (childIds.length < 2) continue; + // Sort siblings by (startLine, startCol) for an O(n log n) pairwise + // scan instead of O(n²) all-pairs. + const children = childIds.map((id) => byId.get(id)!).slice(); + children.sort((a, b) => comparePosition(a.range, b.range)); + for (let i = 1; i < children.length; i++) { + const prev = children[i - 1]!; + const curr = children[i]!; + if (rangesOverlap(prev.range, curr.range)) { + throw new ScopeTreeInvariantError( + 'sibling-ranges-overlap', + `Sibling scopes under parent '${parentId}' overlap: '${prev.id}' ${formatRange(prev.range)} and '${curr.id}' ${formatRange(curr.range)}.`, + ); + } + } + } + + // Freeze children arrays so the surface is truly read-only. + const frozenChildren = new Map(); + for (const [parentId, childIds] of childrenById) { + frozenChildren.set(parentId, Object.freeze(childIds.slice())); + } + + return freezeTree(byId, frozenChildren); +} + +// ─── Internals ────────────────────────────────────────────────────────────── + +const EMPTY_CHILDREN: readonly ScopeId[] = Object.freeze([]); + +function freezeTree( + byId: Map, + childrenById: Map, +): ScopeTree { + return { + byId, + get size() { + return byId.size; + }, + getScope(id: ScopeId): Scope | undefined { + return byId.get(id); + }, + getParent(id: ScopeId): Scope | undefined { + const scope = byId.get(id); + if (scope === undefined || scope.parent === null) return undefined; + return byId.get(scope.parent); + }, + getChildren(id: ScopeId): readonly ScopeId[] { + return childrenById.get(id) ?? EMPTY_CHILDREN; + }, + getAncestors(id: ScopeId): readonly ScopeId[] { + const start = byId.get(id); + if (start === undefined || start.parent === null) return EMPTY_CHILDREN; + const out: ScopeId[] = []; + const visited = new Set([id]); + let cursor: ScopeId | null = start.parent; + while (cursor !== null && !visited.has(cursor)) { + visited.add(cursor); + out.push(cursor); + const next = byId.get(cursor); + cursor = next === undefined ? null : next.parent; + } + return Object.freeze(out); + }, + has(id: ScopeId): boolean { + return byId.has(id); + }, + }; +} + +/** + * `outer` strictly contains `inner` when `outer`'s start is at or before + * `inner`'s start, `outer`'s end is at or after `inner`'s end, and they are + * not the exact same range. Equal ranges are rejected — a child cannot + * occupy the exact same span as its parent. + */ +function rangeStrictlyContains(outer: Range, inner: Range): boolean { + if ( + outer.startLine === inner.startLine && + outer.startCol === inner.startCol && + outer.endLine === inner.endLine && + outer.endCol === inner.endCol + ) { + return false; + } + const outerStartsAtOrBefore = + outer.startLine < inner.startLine || + (outer.startLine === inner.startLine && outer.startCol <= inner.startCol); + const outerEndsAtOrAfter = + outer.endLine > inner.endLine || + (outer.endLine === inner.endLine && outer.endCol >= inner.endCol); + return outerStartsAtOrBefore && outerEndsAtOrAfter; +} + +/** + * Two ranges overlap when neither finishes before the other begins. Ranges + * that merely touch at a single boundary point (`a.end === b.start`) do + * NOT overlap — this matches tree-sitter's half-open-like range semantics + * and the typical "sibling blocks meet but don't overlap" pattern. + */ +function rangesOverlap(a: Range, b: Range): boolean { + const aEndsBeforeB = + a.endLine < b.startLine || (a.endLine === b.startLine && a.endCol <= b.startCol); + const bEndsBeforeA = + b.endLine < a.startLine || (b.endLine === a.startLine && b.endCol <= a.startCol); + return !(aEndsBeforeB || bEndsBeforeA); +} + +function comparePosition(a: Range, b: Range): number { + if (a.startLine !== b.startLine) return a.startLine - b.startLine; + return a.startCol - b.startCol; +} + +function formatRange(r: Range): string { + return `${r.startLine}:${r.startCol}-${r.endLine}:${r.endCol}`; +} diff --git a/gitnexus-shared/src/scope-resolution/shadow/aggregate.ts b/gitnexus-shared/src/scope-resolution/shadow/aggregate.ts new file mode 100644 index 000000000..27c24ff92 --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/shadow/aggregate.ts @@ -0,0 +1,188 @@ +/** + * Shadow-mode aggregation — per-language parity %, per-evidence-kind + * breakdown of divergences. Consumed by the parity dashboard (RING2-PKG-5). + * + * Pure functions; no I/O. The harness persists per-run JSON; the dashboard + * reads `.gitnexus/shadow-parity/latest.json` and renders. + * + * Related types — `ShadowAgreement`, `ShadowCallsite`, `ShadowDiff` — are + * defined alongside `diffResolutions` in `./diff.ts` and re-exported + * through the top-level `gitnexus-shared` barrel. Consumers import all + * three from `gitnexus-shared`, not from this module. + * + * Part of RFC #909 Ring 2 SHARED — #918. + */ + +import type { SupportedLanguages } from '../../languages.js'; +import type { ResolutionEvidence } from '../types.js'; +import type { ShadowAgreement, ShadowDiff } from './diff.js'; + +// ─── Aggregated report shape ──────────────────────────────────────────────── + +export interface LanguageParityRow { + readonly language: SupportedLanguages; + readonly totalCalls: number; + readonly bothAgree: number; + readonly onlyLegacy: number; + readonly onlyNew: number; + readonly bothDisagree: number; + readonly bothEmpty: number; + /** + * Fraction in [0, 1]. Numerator = `bothAgree`; denominator = "calls where + * at least one side resolved" = `totalCalls - bothEmpty`. + * + * When the denominator is 0 (all calls for this language were + * `both-empty`), returns 0. Callers rendering the dashboard should treat + * a 0 parity alongside `totalCalls === bothEmpty` as "no signal" rather + * than "total disagreement". + */ + readonly parity: number; + /** + * Divergence signals broken down by `ResolutionEvidence.kind`. Sourced + * from `ShadowDiff.evidenceDelta` on non-agreeing rows only — `both-agree` + * and `both-empty` do not contribute. + */ + readonly evidenceBreakdown: ReadonlyMap; +} + +export interface ShadowParityReport { + readonly generatedAt: string; // ISO 8601 + readonly perLanguage: readonly LanguageParityRow[]; + readonly overall: Omit; +} + +// ─── Public API ───────────────────────────────────────────────────────────── + +/** + * Aggregate a stream of `ShadowDiff` records into a `ShadowParityReport`, + * bucketed by language. Pure function. + * + * - `perLanguage` rows are sorted alphabetically by `SupportedLanguages` + * value for stable JSON output (the dashboard reads + * `.gitnexus/shadow-parity/latest.json` and diffing snapshots is useful). + * - `overall` is the column-wise sum across languages. + * - `generatedAt` is injected via the `now` parameter so tests stay + * deterministic; production callers let it default to `new Date()`. + */ +export function aggregateDiffs( + diffs: readonly { readonly language: SupportedLanguages; readonly diff: ShadowDiff }[], + now: Date = new Date(), +): ShadowParityReport { + const perLanguageMap = new Map(); + + for (const { language, diff } of diffs) { + let counts = perLanguageMap.get(language); + if (!counts) { + counts = makeEmptyCounts(); + perLanguageMap.set(language, counts); + } + tallyDiff(counts, diff); + } + + const perLanguage: LanguageParityRow[] = Array.from(perLanguageMap.entries()) + .map(([language, counts]) => buildRow(language, counts)) + .sort((a, b) => a.language.localeCompare(b.language)); + + const overall = buildOverallRow(perLanguage); + + return { + generatedAt: now.toISOString(), + perLanguage, + overall, + }; +} + +// ─── Internal helpers ─────────────────────────────────────────────────────── + +interface MutableCounts { + totalCalls: number; + bothAgree: number; + onlyLegacy: number; + onlyNew: number; + bothDisagree: number; + bothEmpty: number; + evidenceBreakdown: Map; +} + +function makeEmptyCounts(): MutableCounts { + return { + totalCalls: 0, + bothAgree: 0, + onlyLegacy: 0, + onlyNew: 0, + bothDisagree: 0, + bothEmpty: 0, + evidenceBreakdown: new Map(), + }; +} + +function tallyDiff(counts: MutableCounts, diff: ShadowDiff): void { + counts.totalCalls += 1; + incrementAgreement(counts, diff.agreement); + if (diff.agreement === 'both-agree' || diff.agreement === 'both-empty') return; + for (const ev of diff.evidenceDelta) { + counts.evidenceBreakdown.set(ev.kind, (counts.evidenceBreakdown.get(ev.kind) ?? 0) + 1); + } +} + +function incrementAgreement(counts: MutableCounts, agreement: ShadowAgreement): void { + switch (agreement) { + case 'both-agree': + counts.bothAgree += 1; + return; + case 'only-legacy': + counts.onlyLegacy += 1; + return; + case 'only-new': + counts.onlyNew += 1; + return; + case 'both-disagree': + counts.bothDisagree += 1; + return; + case 'both-empty': + counts.bothEmpty += 1; + return; + } +} + +function buildRow(language: SupportedLanguages, counts: MutableCounts): LanguageParityRow { + const resolved = counts.totalCalls - counts.bothEmpty; + const parity = resolved > 0 ? counts.bothAgree / resolved : 0; + return { + language, + totalCalls: counts.totalCalls, + bothAgree: counts.bothAgree, + onlyLegacy: counts.onlyLegacy, + onlyNew: counts.onlyNew, + bothDisagree: counts.bothDisagree, + bothEmpty: counts.bothEmpty, + parity, + // Freeze via `new Map` on a sorted-kind copy so downstream consumers + // can't mutate the aggregator's internal state. + evidenceBreakdown: new Map( + Array.from(counts.evidenceBreakdown.entries()).sort(([a], [b]) => a.localeCompare(b)), + ), + }; +} + +function buildOverallRow( + perLanguage: readonly LanguageParityRow[], +): Omit { + let totalCalls = 0; + let bothAgree = 0; + let onlyLegacy = 0; + let onlyNew = 0; + let bothDisagree = 0; + let bothEmpty = 0; + for (const row of perLanguage) { + totalCalls += row.totalCalls; + bothAgree += row.bothAgree; + onlyLegacy += row.onlyLegacy; + onlyNew += row.onlyNew; + bothDisagree += row.bothDisagree; + bothEmpty += row.bothEmpty; + } + const resolved = totalCalls - bothEmpty; + const parity = resolved > 0 ? bothAgree / resolved : 0; + return { totalCalls, bothAgree, onlyLegacy, onlyNew, bothDisagree, bothEmpty, parity }; +} diff --git a/gitnexus-shared/src/scope-resolution/shadow/diff.ts b/gitnexus-shared/src/scope-resolution/shadow/diff.ts new file mode 100644 index 000000000..a1c8755c6 --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/shadow/diff.ts @@ -0,0 +1,126 @@ +/** + * Shadow-mode diff logic — RFC §6.3. + * + * Pure comparison logic for shadow mode. Takes two `Resolution[]` (legacy + * DAG result + new scope-based registry result) and produces a structured + * diff record for the parity dashboard. + * + * Consumed by the Ring 2 PKG shadow harness (#923), which dual-runs each + * call through legacy + new paths, diffs results, and persists per-run JSON + * for the parity dashboard. + * + * Part of RFC #909 Ring 2 SHARED — #918. + */ + +import type { Resolution, ResolutionEvidence } from '../types.js'; + +// ─── Diff record shape ────────────────────────────────────────────────────── + +export type ShadowAgreement = + | 'both-agree' // top match identical (same DefId) + | 'only-legacy' // legacy resolved; new did not + | 'only-new' // new resolved; legacy did not + | 'both-disagree' // both resolved, but to different targets + | 'both-empty'; // both returned empty + +export interface ShadowDiff { + readonly callsite: ShadowCallsite; + readonly legacy: Resolution | null; + readonly newResult: Resolution | null; + readonly agreement: ShadowAgreement; + /** + * Symmetric difference of the two top resolutions' `evidence` arrays, + * keyed on `ResolutionEvidence.kind`. + * + * - For `'both-agree'` and `'both-empty'` agreements, always empty. + * - For `'both-disagree'`, contains evidence kinds present on exactly one + * side (not in both). + * - For `'only-legacy'`, contains all of legacy's top evidence. + * - For `'only-new'`, contains all of new's top evidence. + */ + readonly evidenceDelta: readonly ResolutionEvidence[]; +} + +export interface ShadowCallsite { + readonly filePath: string; + readonly line: number; + readonly col: number; + readonly calledName: string; +} + +// ─── Public API ───────────────────────────────────────────────────────────── + +/** + * Compare two `Resolution[]` arrays (top matches at `[0]`) and produce a + * `ShadowDiff`. Pure function. + * + * Agreement rules: + * - both arrays empty → `'both-empty'`, `evidenceDelta: []` + * - legacy empty, new non-empty → `'only-new'`, `evidenceDelta` = new's top evidence + * - legacy non-empty, new empty → `'only-legacy'`, `evidenceDelta` = legacy's top evidence + * - both non-empty, same top `def.nodeId` → `'both-agree'`, `evidenceDelta: []` + * - both non-empty, different top `def.nodeId` → `'both-disagree'`, + * `evidenceDelta` = symmetric difference by `ResolutionEvidence.kind` + * (first occurrence of a kind-only-on-legacy then kind-only-on-new; order + * preserved from input arrays) + * + * Evidence-delta rationale: callers aggregating divergences want to know + * which signal kinds explain a disagreement. Keying on `kind` (not full + * equality over `weight`/`note`) avoids spurious deltas when the same + * signal fires with slightly different calibration weights on each side. + */ +export function diffResolutions( + callsite: ShadowCallsite, + legacy: readonly Resolution[], + newResult: readonly Resolution[], +): ShadowDiff { + const legacyTop: Resolution | null = legacy.length > 0 ? legacy[0] : null; + const newTop: Resolution | null = newResult.length > 0 ? newResult[0] : null; + + const agreement: ShadowAgreement = (() => { + if (legacyTop === null && newTop === null) return 'both-empty'; + if (legacyTop === null) return 'only-new'; + if (newTop === null) return 'only-legacy'; + return legacyTop.def.nodeId === newTop.def.nodeId ? 'both-agree' : 'both-disagree'; + })(); + + const evidenceDelta = computeEvidenceDelta(legacyTop, newTop, agreement); + + return { + callsite, + legacy: legacyTop, + newResult: newTop, + agreement, + evidenceDelta, + }; +} + +// ─── Internal helpers ─────────────────────────────────────────────────────── + +/** + * Symmetric difference of two evidence arrays, keyed on + * `ResolutionEvidence.kind`. Preserves input order: legacy-only signals + * first (in legacy's original order), then new-only signals (in new's order). + * + * For `'both-agree'` / `'both-empty'` the delta is empty by contract. For + * `'only-legacy'` / `'only-new'` one side's evidence is the delta (nothing to + * subtract against). + */ +function computeEvidenceDelta( + legacy: Resolution | null, + newResult: Resolution | null, + agreement: ShadowAgreement, +): readonly ResolutionEvidence[] { + if (agreement === 'both-agree' || agreement === 'both-empty') return []; + if (agreement === 'only-legacy') return legacy!.evidence; + if (agreement === 'only-new') return newResult!.evidence; + + // both-disagree: symmetric difference keyed on `kind` + const legacyKinds = new Set(legacy!.evidence.map((e) => e.kind)); + const newKinds = new Set(newResult!.evidence.map((e) => e.kind)); + + const onlyInLegacy = legacy!.evidence.filter((e) => !newKinds.has(e.kind)); + const onlyInNew = newResult!.evidence.filter((e) => !legacyKinds.has(e.kind)); + + return [...onlyInLegacy, ...onlyInNew]; +} diff --git a/gitnexus-shared/src/scope-resolution/symbol-definition.ts b/gitnexus-shared/src/scope-resolution/symbol-definition.ts new file mode 100644 index 000000000..d07dbf38b --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/symbol-definition.ts @@ -0,0 +1,35 @@ +/** + * `SymbolDefinition` — the canonical shape of an indexed symbol record. + * + * Historically defined in `gitnexus/src/core/ingestion/model/symbol-table.ts`; + * moved into `gitnexus-shared` as part of RFC #909 Ring 1 (#910) so the + * scope-resolution types that reference it can live in the shared package + * alongside their consumers (`gitnexus/` and `gitnexus-web/`). + * + * Shape is unchanged from the prior local definition. + */ + +import type { NodeLabel } from '../graph/types.js'; + +export interface SymbolDefinition { + nodeId: string; + filePath: string; + type: NodeLabel; + /** Canonical dot-separated qualified type name for class-like symbols + * (e.g. `App.Models.User`). Falls back to the simple symbol name when no + * package/namespace/module scope exists or no explicit qualified metadata is provided. */ + qualifiedName?: string; + parameterCount?: number; + /** Number of required (non-optional, non-default) parameters. + * Enables range-based arity filtering: argCount >= requiredParameterCount && argCount <= parameterCount. */ + requiredParameterCount?: number; + /** Per-parameter type names for overload disambiguation (e.g. ['int', 'String']). + * Populated when parameter types are resolvable from AST (any typed language). */ + parameterTypes?: string[]; + /** Raw return type text extracted from AST (e.g. 'User', 'Promise') */ + returnType?: string; + /** Declared type for non-callable symbols — fields/properties (e.g. 'Address', 'List') */ + declaredType?: string; + /** Links Method/Constructor/Property to owning Class/Struct/Trait nodeId */ + ownerId?: string; +} diff --git a/gitnexus-shared/src/scope-resolution/types.ts b/gitnexus-shared/src/scope-resolution/types.ts new file mode 100644 index 000000000..3e1611593 --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/types.ts @@ -0,0 +1,432 @@ +/** + * Scope-resolution type definitions — RFC §2 data model (authoritative source). + * + * See: https://www.notion.so/346dc50b6ed281cfaacbe480bf231d50 + * + * Anti-drift rule: every type, interface, and enum defined here is the single + * source of truth. Later code that references these names must import them + * from `gitnexus-shared`; it must not re-define them locally. + * + * Lifecycle contract (RFC §2.8): scopes are **constructed during extraction, + * linked during finalize, immutable after finalize**. All fields are + * `readonly` at the type level; `Object.freeze` is applied at runtime in dev + * builds. `ReferenceIndex` is the sole structure populated after freeze — by + * resolution, before emission. + */ + +import type { NodeLabel } from '../graph/types.js'; +import type { SymbolDefinition } from './symbol-definition.js'; + +// ─── §2.1 Type aliases ────────────────────────────────────────────────────── + +/** Stable per-(file, range, kind) scope identifier; interned for identity-fast equality. */ +export type ScopeId = string; + +/** Stable symbol-definition identifier (graph nodeId). */ +export type DefId = string; + +/** Kinds of lexical scope a `Scope` node can represent. */ +export type ScopeKind = + | 'Module' // file root + | 'Namespace' // C++ namespace, C# namespace, Kotlin package-object, Rust mod + | 'Class' // class/struct/trait/interface body + | 'Function' // function/method/closure/lambda body + | 'Block' // { ... }, if-body, for-body, with-body, match arms + | 'Expression'; // comprehensions, for-init, pattern bindings, lambda param lists + +// ─── Range + Capture (parser-agnostic) ────────────────────────────────────── + +/** Source-text range. 1-based `startLine`/`endLine`; 0-based `startCol`/`endCol`. */ +export interface Range { + readonly startLine: number; + readonly startCol: number; + readonly endLine: number; + readonly endCol: number; +} + +/** + * Tagged capture emitted by a LanguageProvider's `emitScopeCaptures` hook. + * + * Parser-agnostic: tree-sitter queries and COBOL's regex tagger both produce + * `Capture[]`. The central `ScopeExtractor` consumes captures without + * knowing which parser produced them. + */ +export interface Capture { + /** Capture name, including leading `@` (e.g., `'@scope.module'`, `'@declaration.class'`). */ + readonly name: string; + readonly range: Range; + /** The captured source text. */ + readonly text: string; +} + +/** + * A grouping of `Capture`s that came from a single query match (e.g., one + * `@import.statement` match carries `@import.source`, `@import.name`, + * `@import.alias?` as child captures). Keyed by capture name for O(1) + * child access. + */ +export type CaptureMatch = Readonly>; + +// ─── Hook input/output types (RFC §5.2) ───────────────────────────────────── + +/** + * Provider-interpreted raw import, consumed by finalize (Phase 2) to produce + * linked `ImportEdge[]`. The provider's `interpretImport` hook turns a + * `CaptureMatch` for an `@import.statement` into one of these; the central + * finalize algorithm resolves `targetRaw` to a concrete file via + * `resolveImportTarget` and materializes the final `ImportEdge`. + * + * Discriminated union — each variant carries only the fields that make sense + * for its kind. Invalid shapes (e.g., a `namespace` import with an alias-like + * `importedName` mismatch) are compile errors, not latent bugs. `'wildcard- + * expanded'` is deliberately NOT a variant: that kind is finalize output only, + * produced when `expandsWildcardTo` materializes a wildcard against target + * exports — a provider must never emit it at parse time. + */ +export type ParsedImport = + /** + * Per-name import without rename. + * + * Examples: + * - Python `from foo import X` → `{ kind: 'named', localName: 'X', importedName: 'X', targetRaw: 'foo' }` + * - TS `import { X } from './foo'` → `{ kind: 'named', localName: 'X', importedName: 'X', targetRaw: './foo' }` + * - Java `import foo.bar.X` → `{ kind: 'named', localName: 'X', importedName: 'X', targetRaw: 'foo.bar' }` + */ + | { + readonly kind: 'named'; + readonly localName: string; + readonly importedName: string; + readonly targetRaw: string; + } + /** + * Per-name import with rename. + * + * Examples: + * - Python `from foo import X as Y` → `{ kind: 'alias', localName: 'Y', importedName: 'X', alias: 'Y', targetRaw: 'foo' }` + * - TS `import { X as Y } from './foo'` → `{ kind: 'alias', localName: 'Y', importedName: 'X', alias: 'Y', targetRaw: './foo' }` + */ + | { + readonly kind: 'alias'; + readonly localName: string; + readonly importedName: string; + readonly alias: string; + readonly targetRaw: string; + } + /** + * Qualified module handle, with or without rename. `importedName` is the + * module being aliased; `localName` is the scope-visible handle (often the + * same unless renamed). + * + * Examples: + * - Python `import numpy` → `{ kind: 'namespace', localName: 'numpy', importedName: 'numpy', targetRaw: 'numpy' }` + * - Python `import numpy as np` → `{ kind: 'namespace', localName: 'np', importedName: 'numpy', targetRaw: 'numpy' }` + * - TS `import * as np from 'numpy'` → `{ kind: 'namespace', localName: 'np', importedName: 'numpy', targetRaw: 'numpy' }` + * - Go `import foo "pkg/bar"` → `{ kind: 'namespace', localName: 'foo', importedName: 'bar', targetRaw: 'pkg/bar' }` + */ + | { + readonly kind: 'namespace'; + /** Scope-visible handle (e.g. `np` in `import numpy as np`; `numpy` when unaliased). */ + readonly localName: string; + /** Module being aliased (e.g. `numpy` in `import numpy as np`). */ + readonly importedName: string; + readonly targetRaw: string; + } + /** + * Syntactically-detectable parse-time re-export. Finalize may still produce + * `ImportEdge { kind: 'reexport', transitiveVia }` when flattening chains; + * this variant preserves the *parse-time* signal so finalize doesn't have + * to re-derive it from scratch. + * + * Examples: + * - TS `export { X } from './y'` → `{ kind: 'reexport', localName: 'X', importedName: 'X', targetRaw: './y' }` + * - TS `export { X as Y } from './y'` → `{ kind: 'reexport', localName: 'Y', importedName: 'X', alias: 'Y', targetRaw: './y' }` + * - Rust `pub use foo::bar` → `{ kind: 'reexport', localName: 'bar', importedName: 'bar', targetRaw: 'foo' }` + */ + | { + readonly kind: 'reexport'; + /** Name as re-exported in the current module. */ + readonly localName: string; + /** Name in the source module. */ + readonly importedName: string; + readonly targetRaw: string; + /** Set when the re-export renames the symbol (e.g. `export { X as Y } from './y'`). */ + readonly alias?: string; + } + /** + * Wildcard import — brings every exported name from the target module into + * the importing scope. The finalize algorithm expands this into one + * `BindingRef` per exported name via the provider's `expandsWildcardTo` + * hook, producing the finalize-only `ImportEdge` kind `'wildcard-expanded'`. + * + * Examples: + * - Python `from foo import *` → `{ kind: 'wildcard', targetRaw: 'foo' }` + * - JS `export * from './foo'` → `{ kind: 'wildcard', targetRaw: './foo' }` + * - Rust `pub use foo::*` → `{ kind: 'wildcard', targetRaw: 'foo' }` + */ + | { + readonly kind: 'wildcard'; + readonly targetRaw: string; + } + /** + * Runtime-computed target — the import path is not a static literal at + * parse time. Providers SHOULD emit the unresolvable expression's source + * text as `targetRaw` to aid diagnostics; `null` only when no string form + * exists. + * + * Examples: + * - JS `await import(expr)` → `{ kind: 'dynamic-unresolved', localName: '', targetRaw: 'expr' }` + * - Python `importlib.import_module(f'pkg.{name}')` → `{ kind: 'dynamic-unresolved', localName: '', targetRaw: "f'pkg.{name}'" }` + */ + | { + readonly kind: 'dynamic-unresolved'; + readonly localName: string; + /** Source text of the unresolved expression when available; `null` otherwise. */ + readonly targetRaw: string | null; + }; + +/** + * Provider-interpreted type binding. The provider's `interpretTypeBinding` + * hook turns a `CaptureMatch` (e.g., `@type-binding.parameter`) into one of + * these; the central extractor attaches the resulting `TypeRef` to the + * appropriate scope's `typeBindings` map. + */ +export interface ParsedTypeBinding { + /** The name being bound (parameter name, `self`, assignment LHS, …). */ + readonly boundName: string; + /** The raw type name as written in source (`'User'`, `'models.User'`, …). */ + readonly rawTypeName: string; + readonly source: TypeRef['source']; +} + +/** + * Cross-file workspace index consumed by finalize-phase hooks + * (`resolveImportTarget`, `expandsWildcardTo`). Opaque placeholder in Ring 1; + * concretely typed in Ring 2 SHARED (#915). + */ +export type WorkspaceIndex = unknown; + +// `ScopeTree` is exported from `./scope-tree.js` as of Ring 2 SHARED (#912). +// The former opaque placeholder lived here during Ring 1; removed now that +// the concrete type exists. Consumers import from `gitnexus-shared` directly. + +/** + * Minimal scope-lookup contract: map a `ScopeId` back to its `Scope` record. + * + * Lives in the data-model layer so both `ScopeTree` (§3.1) and + * `resolveTypeRef` / `Registry.lookup` (§4) can depend on it without + * inverting each other. `ScopeTree` is the canonical implementation; + * tests and future alternative containers may supply their own. + */ +export interface ScopeLookup { + getScope(id: ScopeId): Scope | undefined; +} + +/** Call-site description passed to `arityCompatibility`. */ +export interface Callsite { + /** Number of arguments at the call site. */ + readonly arity: number; +} + +// ─── §2.4 ImportEdge ──────────────────────────────────────────────────────── + +/** + * A cross-file import edge attached to a module/namespace scope. + * + * Raw (unlinked) edges are emitted during parse (Phase 1); `targetModuleScope` + * and `targetDefId` are filled in during finalize (Phase 2) via SCC-aware + * bounded-fixpoint linking (RFC §3.2). + */ +export interface ImportEdge { + /** How this scope sees the imported name (after alias). */ + readonly localName: string; + /** Exporting file; `null` only when `kind === 'dynamic-unresolved'`. */ + readonly targetFile: string | null; + /** The name under which the target exports this symbol. */ + readonly targetExportedName: string; + /** Pre-resolved at finalize: the module scope of the exporting file. */ + readonly targetModuleScope?: ScopeId; + /** Pre-resolved at finalize: the exported symbol's `DefId`. */ + readonly targetDefId?: DefId; + readonly kind: + | 'named' + | 'alias' + | 'namespace' + | 'wildcard-expanded' + | 'reexport' + | 'dynamic-unresolved'; + /** Re-export chain, for provenance (e.g., `['./y']` when re-exported via `./y`). */ + readonly transitiveVia?: readonly string[]; + /** Set to `'unresolved'` when the SCC fixpoint could not link this edge. */ + readonly linkStatus?: 'unresolved'; +} + +// ─── §2.3 BindingRef ──────────────────────────────────────────────────────── + +/** + * A name binding visible at a scope, with provenance. + * + * Provenance stays at the visibility layer — a name being visible because it + * is local vs imported vs wildcard-expanded vs re-exported is a property of + * the binding itself. This keeps evidence emission and `import-use` reference + * stamping first-class instead of reconstructing provenance from a side table. + */ +export interface BindingRef { + readonly def: SymbolDefinition; + readonly origin: 'local' | 'import' | 'namespace' | 'wildcard' | 'reexport'; + /** Non-null for non-local origins; carries the `ImportEdge` that brought the name into this scope. */ + readonly via?: ImportEdge; +} + +// ─── §2.5 TypeRef ─────────────────────────────────────────────────────────── + +/** + * A reference to a named type, anchored at its declaration site. + * + * Design choice: raw name + declaration-site scope, resolved at lookup time. + * Pre-resolution would invert the extraction/resolution wall. Deferred thunks + * add no capability. Structured type systems are months of work per language. + * This shape keeps V1 tractable while preserving correctness for aliases, + * re-exports, and nested modules. Generics deferred to V2 via `typeArgs`. + */ +export interface TypeRef { + /** The name as written in source (e.g., `'User'`, `'models.User'`, `'List'`). */ + readonly rawName: string; + /** Anchor for resolving `rawName` — the scope where the annotation/inference was written. */ + readonly declaredAtScope: ScopeId; + readonly source: + | 'annotation' + | 'parameter-annotation' + | 'return-annotation' + | 'self' + | 'assignment-inferred' + | 'constructor-inferred' + | 'receiver-propagated'; + /** Reserved for V2+: generic type arguments (`List` → `[TypeRef('User')]`). V1 ignores. */ + readonly typeArgs?: readonly TypeRef[]; +} + +// ─── §2.2 Scope ───────────────────────────────────────────────────────────── + +/** + * The canonical lexical-scope node. Forms the spine of the SemanticModel. + * + * ScopeId shape (RFC §2.2): `scope:{filePath}#{startLine}:{startCol}-{endLine}:{endCol}:{kind}` + * — deterministic, stable across reparses of the same source, interned. + */ +export interface Scope { + readonly id: ScopeId; + readonly parent: ScopeId | null; + readonly kind: ScopeKind; + readonly range: Range; + readonly filePath: string; + + /** Names visible from this scope. Provenance preserved via `BindingRef.origin`. */ + readonly bindings: ReadonlyMap; + + /** Defs structurally owned by this scope (e.g., methods owned by a class body scope). */ + readonly ownedDefs: readonly SymbolDefinition[]; + + /** Import edges attached to this scope. Mostly module/namespace scopes, but some + * languages allow local imports (Python `def f(): from x import Y`, Rust + * fn-local `use`, TS dynamic `import()`). */ + readonly imports: readonly ImportEdge[]; + + /** Local type facts visible from this scope (parameter annotations, `self` binding, etc.). */ + readonly typeBindings: ReadonlyMap; +} + +// ─── §2.6 Resolution + ResolutionEvidence ─────────────────────────────────── + +/** + * One piece of evidence for a `Resolution`. Multiple signals corroborate a + * single match; their weights compose additively to produce `confidence`. + * + * Weights come from `EvidenceWeights` (see `./evidence-weights.ts`). + */ +export interface ResolutionEvidence { + readonly kind: + | 'local' + | 'scope-chain' + | 'import' + | 'type-binding' + | 'owner-match' + | 'kind-match' + | 'arity-match' + | 'global-name' + | 'global-qualified' + | 'dynamic-import-unresolved'; + /** Signal weight, sourced from `EvidenceWeights`. Additive; sum capped at 1.0. */ + readonly weight: number; + /** Optional debug annotation (e.g., `'matched via self: User'`). */ + readonly note?: string; +} + +/** + * A ranked resolution candidate returned by `ClassRegistry.lookup` / + * `MethodRegistry.lookup` / `FieldRegistry.lookup`. Evidence composes + * additively; callers read `[0]` for the one-shot answer or inspect the + * evidence trace for debugging. + */ +export interface Resolution { + readonly def: SymbolDefinition; + /** Σ of `evidence[].weight`, capped at 1.0. */ + readonly confidence: number; + readonly evidence: readonly ResolutionEvidence[]; + /** Optional debug trace: scopes walked to reach `def`. */ + readonly path?: readonly ScopeId[]; +} + +// ─── §2.7 Reference + ReferenceIndex ──────────────────────────────────────── + +/** + * A post-resolution usage fact: some code at `atRange` inside `fromScope` + * references `toDef` with the given confidence/evidence. Materialized by the + * resolution phase; emitted as graph edges (`CALLS`/`READS`/`WRITES`/etc.) + * during the emit phase. + */ +export interface Reference { + /** Innermost lexical scope containing `atRange`. */ + readonly fromScope: ScopeId; + readonly toDef: DefId; + /** Location of the reference in source. */ + readonly atRange: Range; + readonly kind: 'call' | 'read' | 'write' | 'type-reference' | 'inherits' | 'import-use'; + readonly confidence: number; + readonly evidence: readonly ResolutionEvidence[]; +} + +/** + * Two-way index over `Reference` records, populated during the resolution + * phase. Scopes stay immutable after finalize; references accumulate here. + */ +export interface ReferenceIndex { + readonly bySourceScope: ReadonlyMap; + readonly byTargetDef: ReadonlyMap; +} + +// ─── §4.1 LookupParams ────────────────────────────────────────────────────── + +/** + * Opaque placeholder for the per-kind registry passed as the owner-scoped + * contributor. Typed concretely in Ring 2 SHARED (#917); kept as `unknown` + * here so Ring 1 can ship without pulling in the registry implementation. + */ +export type RegistryContributor = unknown; + +/** + * Parameters accepted by `Registry.lookup`. Three registries (Class/Method/ + * Field) run the same 7-step algorithm with different parameter tuples; see + * RFC §4.4 for per-registry specializations. + */ +export interface LookupParams { + readonly acceptedKinds: readonly NodeLabel[]; + /** Class lookups: false. Method/Field lookups: true. */ + readonly useReceiverTypeBinding: boolean; + readonly ownerScopedContributor: RegistryContributor | null; + /** Optional arity hint fed to `provider.arityCompatibility`. */ + readonly arityHint?: number; + /** Explicit receiver name (e.g., `'user'` in `user.save()`). When present, + * the receiver's type binding at the callsite scope is used; otherwise + * the enclosing method's implicit `self`/`this` is consulted. See §4.1. */ + readonly explicitReceiver?: { readonly name: string }; +} diff --git a/gitnexus-web/e2e/multi-repo-scoping.spec.ts b/gitnexus-web/e2e/multi-repo-scoping.spec.ts index 67ee06b08..a60c847d2 100644 --- a/gitnexus-web/e2e/multi-repo-scoping.spec.ts +++ b/gitnexus-web/e2e/multi-repo-scoping.spec.ts @@ -61,13 +61,22 @@ test.beforeAll(async () => { } }); +// Auto-connect downloads the full graph from the backend; under parallel +// workers in CI the same backend serves multiple downloads concurrently, so +// reaching the "Ready" state can take noticeably longer than a single-worker +// run. Match the 45s budget used by waitForGraphLoaded() in +// server-connect.spec.ts which has been stable on the same backend. +const READY_TIMEOUT_MS = 45_000; + test.describe('Multi-Repo Scoping', () => { test('auto-connect via ?server= sets ?project= in URL', async ({ page }) => { // Navigate with ?server= param (the bookmarkable shortcut) await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`); // Wait for graph to load - await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 }); + await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ + timeout: READY_TIMEOUT_MS, + }); // URL should now contain ?project= with the repo name const url = new URL(page.url()); @@ -77,8 +86,14 @@ test.describe('Multi-Repo Scoping', () => { }); test('?server= is preserved in URL for F5 recovery', async ({ page }) => { + // Two sequential auto-connects (initial + reload), each up to READY_TIMEOUT_MS, + // can exceed the default 60s test timeout under parallel workers. + test.slow(); + await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`); - await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 }); + await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ + timeout: READY_TIMEOUT_MS, + }); // URL should still have ?server= const url = new URL(page.url()); @@ -86,12 +101,16 @@ test.describe('Multi-Repo Scoping', () => { // F5 should reconnect (not show onboarding) await page.reload(); - await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 }); + await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ + timeout: READY_TIMEOUT_MS, + }); }); test('node count in status bar matches backend data', async ({ page }) => { await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`); - await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 }); + await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ + timeout: READY_TIMEOUT_MS, + }); // Fetch expected node count from backend const res = await fetch(`${BACKEND_URL}/api/repo?repo=${encodeURIComponent(firstRepoName)}`); diff --git a/gitnexus-web/e2e/onboarding.spec.ts b/gitnexus-web/e2e/onboarding.spec.ts index da92ffb70..5b70899c6 100644 --- a/gitnexus-web/e2e/onboarding.spec.ts +++ b/gitnexus-web/e2e/onboarding.spec.ts @@ -26,7 +26,10 @@ async function enterExploringView(page: import('@playwright/test').Page) { // Landing screen may not appear (e.g. ?server auto-connect) } - await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 }); + // Match the 45s budget used by waitForGraphLoaded() in + // server-connect.spec.ts; under parallel CI workers, downloading the full + // graph can occasionally exceed 30s. + await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 45_000 }); } // ── Flow 1: Onboarding (no server running) ───────────────────────────────── @@ -244,6 +247,10 @@ test.describe('Flow 3: Analyze form', () => { test.describe('Flow 4: Repo dropdown in exploring view', () => { const SKIP_MSG = 'Requires running gitnexus server with indexed repos'; + // enterExploringView() can take up to ~45s under parallel CI workers; combined + // with the dropdown interactions this can exceed the default 60s test budget. + test.slow(); + test.beforeAll(async () => { if (process.env.E2E) return; try { diff --git a/gitnexus-web/e2e/repo-switching.spec.ts b/gitnexus-web/e2e/repo-switching.spec.ts index 4cf6bf8c8..802f44e78 100644 --- a/gitnexus-web/e2e/repo-switching.spec.ts +++ b/gitnexus-web/e2e/repo-switching.spec.ts @@ -84,11 +84,20 @@ test.describe('Hold-queue timeout error', () => { // ── 2. ?project= URL persistence ───────────────────────────────────────────── +// Auto-connect downloads the full graph from the backend; under parallel +// workers in CI the same backend serves multiple downloads concurrently, so +// reaching the "Ready" state can take noticeably longer than a single-worker +// run. Match the 45s budget used by waitForGraphLoaded() in +// server-connect.spec.ts which has been stable on the same backend. +const READY_TIMEOUT_MS = 45_000; + test.describe('?project= URL persistence', () => { test('?project= is set in URL after connecting via ?server=', async ({ page }) => { await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`); - await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 }); + await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ + timeout: READY_TIMEOUT_MS, + }); const url = new URL(page.url()); const project = url.searchParams.get('project'); @@ -98,12 +107,20 @@ test.describe('?project= URL persistence', () => { }); test('?project= is still present after F5 reload', async ({ page }) => { + // Two sequential auto-connects (initial + reload), each up to READY_TIMEOUT_MS, + // can exceed the default 60s test timeout under parallel workers. + test.slow(); + await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`); - await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 }); + await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ + timeout: READY_TIMEOUT_MS, + }); // After connect, URL has ?server=&project= — F5 re-uses both params await page.reload(); - await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 }); + await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ + timeout: READY_TIMEOUT_MS, + }); const url = new URL(page.url()); expect(url.searchParams.get('project')).toBeTruthy(); @@ -122,7 +139,9 @@ test.describe('?project= auto-connect', () => { `/?server=${encodeURIComponent(BACKEND_URL)}&project=${encodeURIComponent(firstRepoName)}`, ); - await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 }); + await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ + timeout: READY_TIMEOUT_MS, + }); // ?project= in URL should match what we passed in const url = new URL(page.url()); @@ -155,7 +174,9 @@ test.describe('Windows path normalization', () => { await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`); - await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 }); + await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ + timeout: READY_TIMEOUT_MS, + }); // URL ?project= must be the short basename, NOT the full Windows path const url = new URL(page.url()); diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index 2190e27a6..b62d3eddc 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -29,7 +29,7 @@ "langchain": "^1.2.10", "lru-cache": "^11.2.4", "lucide-react": "^0.562.0", - "mermaid": "^11.12.2", + "mermaid": "^11.14.0", "mnemonist": "^0.39.0", "pandemonium": "^2.4.0", "react": "^18.3.1", @@ -39,7 +39,7 @@ "react-zoom-pan-pinch": "^3.7.0", "remark-gfm": "^4.0.1", "sigma": "^3.0.2", - "tailwindcss": "^4.1.18", + "tailwindcss": "^4.2.2", "uuid": "^13.0.0", "zod": "^3.25.76" }, @@ -57,12 +57,12 @@ "@vercel/node": "^5.5.16", "@vitejs/plugin-react": "^5.1.0", "@vitest/coverage-v8": "^3.2.4", - "jsdom": "^29.0.0", + "jsdom": "^29.0.2", "tree-sitter-wasms": "^0.1.13", "typescript": "^5.4.5", "vite": "^5.2.0", "vitest": "^3.2.4", - "wait-on": "^8.0.5" + "wait-on": "^9.0.5" }, "engines": { "node": ">=20.0.0" @@ -129,39 +129,49 @@ } }, "node_modules/@asamuzakjp/css-color": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", - "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==", + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", "dev": true, "license": "MIT", "dependencies": { - "@csstools/css-calc": "^3.1.1", - "@csstools/css-color-parser": "^4.0.2", + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0", - "lru-cache": "^11.2.6" + "@csstools/css-tokenizer": "^4.0.0" }, "engines": { "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, "node_modules/@asamuzakjp/dom-selector": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.3.tgz", - "integrity": "sha512-Q6mU0Z6bfj6YvnX2k9n0JxiIwrCFN59x/nWmYQnAqP000ruX/yV+5bp/GRcF5T8ncvfwJQ7fgfP74DlpKExILA==", + "version": "7.0.10", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.10.tgz", + "integrity": "sha512-KyOb19eytNSELkmdqzZZUXWCU25byIlOld5qVFg0RYdS0T3tt7jeDByxk9hIAC73frclD8GKrHttr0SUjKCCdQ==", "dev": true, "license": "MIT", "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.2.1", - "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.7" + "is-potential-custom-element-name": "^1.0.1" }, "engines": { "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/@asamuzakjp/nwsapi": { "version": "2.3.9", "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", @@ -533,54 +543,40 @@ "license": "MIT" }, "node_modules/@chevrotain/cst-dts-gen": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.0.3.tgz", - "integrity": "sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==", + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-12.0.0.tgz", + "integrity": "sha512-fSL4KXjTl7cDgf0B5Rip9Q05BOrYvkJV/RrBTE/bKDN096E4hN/ySpcBK5B24T76dlQ2i32Zc3PAE27jFnFrKg==", "license": "Apache-2.0", "dependencies": { - "@chevrotain/gast": "11.0.3", - "@chevrotain/types": "11.0.3", - "lodash-es": "4.17.21" + "@chevrotain/gast": "12.0.0", + "@chevrotain/types": "12.0.0" } }, - "node_modules/@chevrotain/cst-dts-gen/node_modules/lodash-es": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", - "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", - "license": "MIT" - }, "node_modules/@chevrotain/gast": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.0.3.tgz", - "integrity": "sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q==", + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-12.0.0.tgz", + "integrity": "sha512-1ne/m3XsIT8aEdrvT33so0GUC+wkctpUPK6zU9IlOyJLUbR0rg4G7ZiApiJbggpgPir9ERy3FRjT6T7lpgetnQ==", "license": "Apache-2.0", "dependencies": { - "@chevrotain/types": "11.0.3", - "lodash-es": "4.17.21" + "@chevrotain/types": "12.0.0" } }, - "node_modules/@chevrotain/gast/node_modules/lodash-es": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", - "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", - "license": "MIT" - }, "node_modules/@chevrotain/regexp-to-ast": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.0.3.tgz", - "integrity": "sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA==", + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-12.0.0.tgz", + "integrity": "sha512-p+EW9MaJwgaHguhoqwOtx/FwuGr+DnNn857sXWOi/mClXIkPGl3rn7hGNWvo31HA3vyeQxjqe+H36yZJwYU8cA==", "license": "Apache-2.0" }, "node_modules/@chevrotain/types": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.0.3.tgz", - "integrity": "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ==", + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-12.0.0.tgz", + "integrity": "sha512-S+04vjFQKeuYw0/eW3U52LkAHQsB1ASxsPGsLPUyQgrZ2iNNibQrsidruDzjEX2JYfespXMG0eZmXlhA6z7nWA==", "license": "Apache-2.0" }, "node_modules/@chevrotain/utils": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.0.3.tgz", - "integrity": "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==", + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-12.0.0.tgz", + "integrity": "sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA==", "license": "Apache-2.0" }, "node_modules/@cspotcode/source-map-support": { @@ -628,9 +624,9 @@ } }, "node_modules/@csstools/css-calc": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", - "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz", + "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==", "dev": true, "funding": [ { @@ -652,9 +648,9 @@ } }, "node_modules/@csstools/css-color-parser": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", - "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz", + "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==", "dev": true, "funding": [ { @@ -669,7 +665,7 @@ "license": "MIT", "dependencies": { "@csstools/color-helpers": "^6.0.2", - "@csstools/css-calc": "^3.1.1" + "@csstools/css-calc": "^3.2.0" }, "engines": { "node": ">=20.19.0" @@ -1764,12 +1760,12 @@ } }, "node_modules/@mermaid-js/parser": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-0.6.3.tgz", - "integrity": "sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.0.tgz", + "integrity": "sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw==", "license": "MIT", "dependencies": { - "langium": "3.3.1" + "langium": "^4.0.0" } }, "node_modules/@napi-rs/wasm-runtime": { @@ -2224,257 +2220,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@swc/core": { - "version": "1.15.8", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.8.tgz", - "integrity": "sha512-T8keoJjXaSUoVBCIjgL6wAnhADIb09GOELzKg10CjNg+vLX48P93SME6jTfte9MZIm5m+Il57H3rTSk/0kzDUw==", - "dev": true, - "hasInstallScript": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "@swc/counter": "^0.1.3", - "@swc/types": "^0.1.25" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/swc" - }, - "optionalDependencies": { - "@swc/core-darwin-arm64": "1.15.8", - "@swc/core-darwin-x64": "1.15.8", - "@swc/core-linux-arm-gnueabihf": "1.15.8", - "@swc/core-linux-arm64-gnu": "1.15.8", - "@swc/core-linux-arm64-musl": "1.15.8", - "@swc/core-linux-x64-gnu": "1.15.8", - "@swc/core-linux-x64-musl": "1.15.8", - "@swc/core-win32-arm64-msvc": "1.15.8", - "@swc/core-win32-ia32-msvc": "1.15.8", - "@swc/core-win32-x64-msvc": "1.15.8" - }, - "peerDependencies": { - "@swc/helpers": ">=0.5.17" - }, - "peerDependenciesMeta": { - "@swc/helpers": { - "optional": true - } - } - }, - "node_modules/@swc/core-darwin-arm64": { - "version": "1.15.8", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.8.tgz", - "integrity": "sha512-M9cK5GwyWWRkRGwwCbREuj6r8jKdES/haCZ3Xckgkl8MUQJZA3XB7IXXK1IXRNeLjg6m7cnoMICpXv1v1hlJOg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-darwin-x64": { - "version": "1.15.8", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.8.tgz", - "integrity": "sha512-j47DasuOvXl80sKJHSi2X25l44CMc3VDhlJwA7oewC1nV1VsSzwX+KOwE5tLnfORvVJJyeiXgJORNYg4jeIjYQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.15.8", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.8.tgz", - "integrity": "sha512-siAzDENu2rUbwr9+fayWa26r5A9fol1iORG53HWxQL1J8ym4k7xt9eME0dMPXlYZDytK5r9sW8zEA10F2U3Xwg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.15.8", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.8.tgz", - "integrity": "sha512-o+1y5u6k2FfPYbTRUPvurwzNt5qd0NTumCTFscCNuBksycloXY16J8L+SMW5QRX59n4Hp9EmFa3vpvNHRVv1+Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.15.8", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.8.tgz", - "integrity": "sha512-koiCqL09EwOP1S2RShCI7NbsQuG6r2brTqUYE7pV7kZm9O17wZ0LSz22m6gVibpwEnw8jI3IE1yYsQTVpluALw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.15.8", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.8.tgz", - "integrity": "sha512-4p6lOMU3bC+Vd5ARtKJ/FxpIC5G8v3XLoPEZ5s7mLR8h7411HWC/LmTXDHcrSXRC55zvAVia1eldy6zDLz8iFQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-x64-musl": { - "version": "1.15.8", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.8.tgz", - "integrity": "sha512-z3XBnbrZAL+6xDGAhJoN4lOueIxC/8rGrJ9tg+fEaeqLEuAtHSW2QHDHxDwkxZMjuF/pZ6MUTjHjbp8wLbuRLA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.15.8", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.8.tgz", - "integrity": "sha512-djQPJ9Rh9vP8GTS/Df3hcc6XP6xnG5c8qsngWId/BLA9oX6C7UzCPAn74BG/wGb9a6j4w3RINuoaieJB3t+7iQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.15.8", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.8.tgz", - "integrity": "sha512-/wfAgxORg2VBaUoFdytcVBVCgf1isWZIEXB9MZEUty4wwK93M/PxAkjifOho9RN3WrM3inPLabICRCEgdHpKKQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.15.8", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.8.tgz", - "integrity": "sha512-GpMePrh9Sl4d61o4KAHOOv5is5+zt6BEXCOCgs/H0FLGeii7j9bWDE8ExvKFy2GRRZVNR1ugsnzaGWHKM6kuzA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/counter": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", - "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true - }, - "node_modules/@swc/types": { - "version": "0.1.25", - "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.25.tgz", - "integrity": "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "@swc/counter": "^0.1.3" - } - }, - "node_modules/@swc/wasm": { - "version": "1.15.8", - "resolved": "https://registry.npmjs.org/@swc/wasm/-/wasm-1.15.8.tgz", - "integrity": "sha512-RG2BxGbbsjtddFCo1ghKH6A/BMXbY1eMBfpysV0lJMCpI4DZOjW1BNBnxvBt7YsYmlJtmy5UXIg9/4ekBTFFaQ==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true - }, "node_modules/@tailwindcss/node": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", @@ -2490,6 +2235,12 @@ "tailwindcss": "4.1.18" } }, + "node_modules/@tailwindcss/node/node_modules/tailwindcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", + "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", + "license": "MIT" + }, "node_modules/@tailwindcss/oxide": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", @@ -2732,6 +2483,12 @@ "vite": "^5.2.0 || ^6 || ^7" } }, + "node_modules/@tailwindcss/vite/node_modules/tailwindcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", + "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", + "license": "MIT" + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -3358,6 +3115,16 @@ "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "license": "ISC" }, + "node_modules/@upsetjs/venn.js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", + "integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==", + "license": "MIT", + "optionalDependencies": { + "d3-selection": "^3.0.0", + "d3-transition": "^3.0.1" + } + }, "node_modules/@vercel/build-utils": { "version": "13.2.11", "resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-13.2.11.tgz", @@ -3829,14 +3596,14 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", - "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", - "proxy-from-env": "^1.1.0" + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" } }, "node_modules/bail": { @@ -4129,37 +3896,33 @@ } }, "node_modules/chevrotain": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz", - "integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==", + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-12.0.0.tgz", + "integrity": "sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==", "license": "Apache-2.0", "dependencies": { - "@chevrotain/cst-dts-gen": "11.0.3", - "@chevrotain/gast": "11.0.3", - "@chevrotain/regexp-to-ast": "11.0.3", - "@chevrotain/types": "11.0.3", - "@chevrotain/utils": "11.0.3", - "lodash-es": "4.17.21" + "@chevrotain/cst-dts-gen": "12.0.0", + "@chevrotain/gast": "12.0.0", + "@chevrotain/regexp-to-ast": "12.0.0", + "@chevrotain/types": "12.0.0", + "@chevrotain/utils": "12.0.0" + }, + "engines": { + "node": ">=22.0.0" } }, "node_modules/chevrotain-allstar": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.3.1.tgz", - "integrity": "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.4.1.tgz", + "integrity": "sha512-PvVJm3oGqrveUVW2Vt/eZGeiAIsJszYweUcYwcskg9e+IubNYKKD+rHHem7A6XVO22eDAL+inxNIGAzZ/VIWlA==", "license": "MIT", "dependencies": { "lodash-es": "^4.17.21" }, "peerDependencies": { - "chevrotain": "^11.0.0" + "chevrotain": "^12.0.0" } }, - "node_modules/chevrotain/node_modules/lodash-es": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", - "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", - "license": "MIT" - }, "node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -4830,9 +4593,9 @@ } }, "node_modules/dagre-d3-es": { - "version": "7.0.13", - "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.13.tgz", - "integrity": "sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q==", + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz", + "integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==", "license": "MIT", "dependencies": { "d3": "^7.9.0", @@ -6064,9 +5827,9 @@ } }, "node_modules/joi": { - "version": "18.0.2", - "resolved": "https://registry.npmjs.org/joi/-/joi-18.0.2.tgz", - "integrity": "sha512-RuCOQMIt78LWnktPoeBL0GErkNaJPTBGcYuyaBvUOQSpcpcLfWrHPPihYdOGbV5pam9VTWbeoF7TsGiHugcjGA==", + "version": "18.1.2", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.1.2.tgz", + "integrity": "sha512-rF5MAmps5esSlhCA+N1b6IYHDw9j/btzGaqfgie522jS02Ju/HXBxamlXVlKEHAxoMKQL77HWI8jlqWsFuekZA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -6076,7 +5839,7 @@ "@hapi/pinpoint": "^2.0.1", "@hapi/tlds": "^1.1.1", "@hapi/topo": "^6.0.2", - "@standard-schema/spec": "^1.0.0" + "@standard-schema/spec": "^1.1.0" }, "engines": { "node": ">= 20" @@ -6098,14 +5861,14 @@ "license": "MIT" }, "node_modules/jsdom": { - "version": "29.0.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.0.tgz", - "integrity": "sha512-9FshNB6OepopZ08unmmGpsF7/qCjxGPbo3NbgfJAnPeHXnsODE9WWffXZtRFRFe0ntzaAOcSKNJFz8wiyvF1jQ==", + "version": "29.0.2", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.2.tgz", + "integrity": "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==", "dev": true, "license": "MIT", "dependencies": { - "@asamuzakjp/css-color": "^5.0.1", - "@asamuzakjp/dom-selector": "^7.0.2", + "@asamuzakjp/css-color": "^5.1.5", + "@asamuzakjp/dom-selector": "^7.0.6", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.1", "@exodus/bytes": "^1.15.0", @@ -6119,7 +5882,7 @@ "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", - "undici": "^7.24.3", + "undici": "^7.24.5", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", @@ -6152,9 +5915,9 @@ } }, "node_modules/jsdom/node_modules/undici": { - "version": "7.24.3", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.3.tgz", - "integrity": "sha512-eJdUmK/Wrx2d+mnWWmwwLRyA7OQCkLap60sk3dOK4ViZR7DKwwptwuIvFBg2HaiP9ESaEdhtpSymQPvytpmkCA==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", "dev": true, "license": "MIT", "engines": { @@ -6295,19 +6058,21 @@ } }, "node_modules/langium": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/langium/-/langium-3.3.1.tgz", - "integrity": "sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/langium/-/langium-4.2.2.tgz", + "integrity": "sha512-JUshTRAfHI4/MF9dH2WupvjSXyn8JBuUEWazB8ZVJUtXutT0doDlAv1XKbZ1Pb5sMexa8FF4CFBc0iiul7gbUQ==", "license": "MIT", "dependencies": { - "chevrotain": "~11.0.3", - "chevrotain-allstar": "~0.3.0", + "@chevrotain/regexp-to-ast": "~12.0.0", + "chevrotain": "~12.0.0", + "chevrotain-allstar": "~0.4.1", "vscode-languageserver": "~9.0.1", "vscode-languageserver-textdocument": "~1.0.11", - "vscode-uri": "~3.0.8" + "vscode-uri": "~3.1.0" }, "engines": { - "node": ">=16.0.0" + "node": ">=20.10.0", + "npm": ">=10.2.3" } }, "node_modules/langsmith": { @@ -6613,16 +6378,16 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "dev": true, "license": "MIT" }, "node_modules/lodash-es": { - "version": "4.17.22", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.22.tgz", - "integrity": "sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", "license": "MIT" }, "node_modules/longest-streak": { @@ -7072,27 +6837,28 @@ } }, "node_modules/mermaid": { - "version": "11.12.2", - "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.12.2.tgz", - "integrity": "sha512-n34QPDPEKmaeCG4WDMGy0OT6PSyxKCfy2pJgShP+Qow2KLrvWjclwbc3yXfSIf4BanqWEhQEpngWwNp/XhZt6w==", + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.14.0.tgz", + "integrity": "sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g==", "license": "MIT", "dependencies": { "@braintree/sanitize-url": "^7.1.1", - "@iconify/utils": "^3.0.1", - "@mermaid-js/parser": "^0.6.3", + "@iconify/utils": "^3.0.2", + "@mermaid-js/parser": "^1.1.0", "@types/d3": "^7.4.3", - "cytoscape": "^3.29.3", + "@upsetjs/venn.js": "^2.0.0", + "cytoscape": "^3.33.1", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", - "dagre-d3-es": "7.0.13", - "dayjs": "^1.11.18", - "dompurify": "^3.2.5", - "katex": "^0.16.22", + "dagre-d3-es": "7.0.14", + "dayjs": "^1.11.19", + "dompurify": "^3.3.1", + "katex": "^0.16.25", "khroma": "^2.1.0", - "lodash-es": "^4.17.21", - "marked": "^16.2.1", + "lodash-es": "^4.17.23", + "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", @@ -8317,10 +8083,13 @@ } }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/punycode": { "version": "2.3.1", @@ -9006,9 +8775,9 @@ "license": "MIT" }, "node_modules/tailwindcss": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", - "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", "license": "MIT" }, "node_modules/tapable": { @@ -10270,9 +10039,9 @@ "license": "MIT" }, "node_modules/vscode-uri": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.8.tgz", - "integrity": "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", "license": "MIT" }, "node_modules/w3c-xmlserializer": { @@ -10289,15 +10058,15 @@ } }, "node_modules/wait-on": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-8.0.5.tgz", - "integrity": "sha512-J3WlS0txVHkhLRb2FsmRg3dkMTCV1+M6Xra3Ho7HzZDHpE7DCOnoSoCJsZotrmW3uRMhvIJGSKUKrh/MeF4iag==", + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.5.tgz", + "integrity": "sha512-qgnbHDfDTRIp73ANEJNRW/7kn8CrDUcvZz18xotJQku/P4saTGkbIzvnMZebPmVvVNUiRq1qWAPyqCH+W4H8KA==", "dev": true, "license": "MIT", "dependencies": { - "axios": "^1.12.1", - "joi": "^18.0.1", - "lodash": "^4.17.21", + "axios": "^1.15.0", + "joi": "^18.1.2", + "lodash": "^4.18.1", "minimist": "^1.2.8", "rxjs": "^7.8.2" }, @@ -10305,7 +10074,7 @@ "wait-on": "bin/wait-on" }, "engines": { - "node": ">=12.0.0" + "node": ">=20.0.0" } }, "node_modules/webidl-conversions": { diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json index 9616845d1..08b2eed7d 100644 --- a/gitnexus-web/package.json +++ b/gitnexus-web/package.json @@ -39,7 +39,7 @@ "langchain": "^1.2.10", "lru-cache": "^11.2.4", "lucide-react": "^0.562.0", - "mermaid": "^11.12.2", + "mermaid": "^11.14.0", "mnemonist": "^0.39.0", "pandemonium": "^2.4.0", "react": "^18.3.1", @@ -49,7 +49,7 @@ "react-zoom-pan-pinch": "^3.7.0", "remark-gfm": "^4.0.1", "sigma": "^3.0.2", - "tailwindcss": "^4.1.18", + "tailwindcss": "^4.2.2", "uuid": "^13.0.0", "zod": "^3.25.76" }, @@ -67,11 +67,11 @@ "@vercel/node": "^5.5.16", "@vitejs/plugin-react": "^5.1.0", "@vitest/coverage-v8": "^3.2.4", - "jsdom": "^29.0.0", + "jsdom": "^29.0.2", "tree-sitter-wasms": "^0.1.13", "typescript": "^5.4.5", "vite": "^5.2.0", "vitest": "^3.2.4", - "wait-on": "^8.0.5" + "wait-on": "^9.0.5" } } diff --git a/gitnexus-web/vite.config.ts b/gitnexus-web/vite.config.ts index b177f6804..a62b9e586 100644 --- a/gitnexus-web/vite.config.ts +++ b/gitnexus-web/vite.config.ts @@ -16,6 +16,7 @@ export default defineConfig({ alias: { '@': path.resolve(__dirname, './src'), '@shared': path.resolve(__dirname, '../shared'), + 'gitnexus-shared': path.resolve(__dirname, '../gitnexus-shared/src/index.ts'), // Fix for Rollup failing to resolve this deep import from @langchain/anthropic '@anthropic-ai/sdk/lib/transform-json-schema': path.resolve( __dirname, diff --git a/gitnexus/CHANGELOG.md b/gitnexus/CHANGELOG.md index 80096345d..203089d93 100644 --- a/gitnexus/CHANGELOG.md +++ b/gitnexus/CHANGELOG.md @@ -2,6 +2,46 @@ All notable changes to GitNexus will be documented in this file. +## [1.6.2] - 2026-04-18 + +### Added + +- **Docker support** — containerized ingestion and MCP serving for reproducible runs on CI and container platforms (#848) +- **Language-agnostic heritage extractor** — config+factory pattern for class-heritage extraction (EXTENDS / IMPLEMENTS), completing the extractor refactor alongside method/field/call/variable (#890) +- **Language-agnostic call extractor** — config+factory pattern that collapses ~225 lines of inline parse-worker logic into declarative per-language configs (#877) +- **Language-agnostic variable extractor** — structured metadata for `Const` / `Static` / `Variable` nodes via config+factory pattern (#878) +- **AST-aware embedding chunking** — offset-based splitting preserves symbol boundaries, improving semantic search precision on large files (#889) +- **HTTP consumer detection for jQuery and axios object-form** — `$.ajax` / `$.get` / `$.post` and `axios({ url, method })` now recognized as HTTP call sites (#887) + +### Fixed + +- **Python external dotted imports** — avoid spurious same-file matches when an import path like `foo.bar.baz` refers to a third-party module (#899) +- **Worker warnings no longer terminate ingestion** — non-fatal parser warnings keep the pipeline running instead of aborting the run (#900, #261) +- **Global-install upgrade `ENOTEMPTY`** — devendored `tree-sitter-proto` install lifecycle + preinstall cleanup so `npm i -g gitnexus@latest` succeeds on top of an older install (#843, #846) +- **`env.cacheDir`** now defaults to a user-writable location, unblocking ingestion on systems where the install directory is read-only (#845) +- **Content-hash staleness detection for embeddings** — zero-node rebuilds no longer skip vector-index creation, fixing semantic search after selective re-analysis (#831) +- **`tree-sitter-c-sharp` version pin** — locked to 0.23.1 to avoid a breaking change in a transitive prerelease (#834) +- **`release-drafter` v7 CI** — replaced the removed `disable-releaser` flag with `dry-run` so release-note drafts still work +- **`npm arborist` crash from `tree-sitter-dart`** — switched the dependency URL format so `npm install` no longer crashes on clean installs +- **Service-group `ManifestExtractor`** — `config.links` now wires the manifest extractor properly, restoring cross-link discovery that had silently dropped to zero + +### Changed + +- **SemanticModel wired as a first-class resolution input (SM-20)** — `call-processor`, `resolution-context`, `type-env`, and `heritage-map` now consult `table.model.*` directly; 37 internal call sites migrated off the SymbolTable wrapper (#885) +- **Per-strategy `ImportSemantics` hooks** — `named` / `wildcard-transitive` / `wildcard-leaf` / `namespace` strategies split into composable hooks, replacing the monolithic conditional (Strategies 1–4 of #886) +- **Class extraction configs moved to `configs/` subdirectory** — per-language class configs now co-locate with the other extractor configs, completing the extractor layer's directory convention (#879) +- **CLI AI-context trimmed** — duplicated CLAUDE.md block removed from the shipped context, reducing token usage in LLM-consuming workflows (#904) +- **LLM context files optimized** — AI-consumed documentation tuned for accuracy and token efficiency (#857) +- **Workflow concurrency standardized** — all CI workflows adopt the consistent concurrency key pattern documented in CONTRIBUTING.md; release-note labeling automated (#837) +- **E2E status-ready timeout raised** — 45s accommodates parallel-worker startup variance on CI (#908) + +### Chore / Dependencies + +- **tree-sitter 0.25 upgrade readiness** — daily Dependabot monitor for the upcoming major-version bump (#847) +- Dependency bumps: `glob` 11.1.0 → 13.0.6 (#867), `commander` 12.1.0 → 14.0.3 (#868), `@huggingface/transformers` (#869), `@modelcontextprotocol/sdk` (#866), `lru-cache` 11.2.7 → 11.3.5 (#870), `mnemonist` 0.39.8 → 0.40.3 (#871), `@ladybugdb/core` (#873) +- gitnexus-web dependency bumps: `mermaid` 11.12.2 → 11.14.0 (#860), `tailwindcss` (#861), `jsdom` 29.0.0 → 29.0.2 (#863), `wait-on` 8.0.5 → 9.0.5 (#859), `@vitest/coverage-v8` (#864) +- GitHub Actions bumps: `actions/checkout` 4.3.1 → 6.0.2 (#842), `actions/upload-artifact` 4.6.2 → 7.0.1 (#838), `actions/setup-node` 4.4.0 → 6.3.0 (#841), `actions/cache` 5.0.4 → 5.0.5 (#840), `actions/github-script` 7.0.1 → 9.0.0 (#850), `dorny/paths-filter` 3.0.2 → 4.0.1 (#839), `amannn/action-semantic-pull-request` 6.1.1 (#853), `release-drafter/release-drafter` 6.0.0 → 7.2.0 (#852), `marocchino/sticky-pull-request-comment` 3.0.4 (#851), `softprops/action-gh-release` 2.5.0 → 3.0.0 (#849) + ## [1.6.1] - 2026-04-13 ### Added diff --git a/gitnexus/Dockerfile.test b/gitnexus/Dockerfile.test index b2d22384f..7cafbe2c1 100644 --- a/gitnexus/Dockerfile.test +++ b/gitnexus/Dockerfile.test @@ -1,6 +1,6 @@ FROM node:20-bookworm WORKDIR /app -RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/* +RUN apt-get -o Acquire::Check-Valid-Until=false -o Acquire::Check-Date=false update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/* COPY . . RUN npm ci --ignore-scripts \ && node scripts/patch-tree-sitter-swift.cjs \ diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index def88c93c..76afc1fde 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -1,31 +1,31 @@ { "name": "gitnexus", - "version": "1.6.1", + "version": "1.6.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitnexus", - "version": "1.6.1", + "version": "1.6.2", "hasInstallScript": true, "license": "PolyForm-Noncommercial-1.0.0", "dependencies": { - "@huggingface/transformers": "^3.0.0", + "@huggingface/transformers": "^4.1.0", "@ladybugdb/core": "^0.15.2", "@modelcontextprotocol/sdk": "^1.0.0", "@scarf/scarf": "^1.4.0", "cli-progress": "^3.12.0", - "commander": "^12.0.0", + "commander": "^14.0.3", "cors": "^2.8.5", "express": "^4.19.2", - "glob": "^11.0.0", + "glob": "^13.0.6", "graphology": "^0.25.4", "graphology-indices": "^0.17.0", "graphology-utils": "^2.3.0", "ignore": "^7.0.5", "js-yaml": "^4.1.1", "lru-cache": "^11.0.0", - "mnemonist": "^0.39.0", + "mnemonist": "^0.40.3", "onnxruntime-node": "^1.24.0", "pandemonium": "^2.4.0", "tree-sitter": "^0.21.1", @@ -138,14 +138,14 @@ } }, "node_modules/@emnapi/core": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", - "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.0", + "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, @@ -160,9 +160,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", - "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "dev": true, "license": "MIT", "optional": true, @@ -633,16 +633,23 @@ "node": ">=18" } }, + "node_modules/@huggingface/tokenizers": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@huggingface/tokenizers/-/tokenizers-0.1.3.tgz", + "integrity": "sha512-8rF/RRT10u+kn7YuUbUg0OF30K8rjTc78aHpxT+qJ1uWSqxT1MHi8+9ltwYfkFYJzT/oS+qw3JVfHtNMGAdqyA==", + "license": "Apache-2.0" + }, "node_modules/@huggingface/transformers": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/@huggingface/transformers/-/transformers-3.8.1.tgz", - "integrity": "sha512-tsTk4zVjImqdqjS8/AOZg2yNLd1z9S5v+7oUPpXaasDRwEDhB+xnglK1k5cad26lL5/ZIaeREgWWy0bs9y9pPA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@huggingface/transformers/-/transformers-4.1.0.tgz", + "integrity": "sha512-WiMf9eyvF6V2pj4gs12A7GQV3svyFIBtB/W+Hn5lT5E5DyqWUno1ZrWoAfJv69X1RNv/0GoOo6DFmL6NOYd+rg==", "license": "Apache-2.0", "dependencies": { - "@huggingface/jinja": "^0.5.3", - "onnxruntime-node": "1.21.0", - "onnxruntime-web": "1.22.0-dev.20250409-89f8206ba4", - "sharp": "^0.34.1" + "@huggingface/jinja": "^0.5.6", + "@huggingface/tokenizers": "^0.1.3", + "onnxruntime-node": "1.24.3", + "onnxruntime-web": "1.26.0-dev.20260410-5e55544225", + "sharp": "^0.34.5" } }, "node_modules/@img/colour": { @@ -1110,15 +1117,6 @@ "url": "https://opencollective.com/libvips" } }, - "node_modules/@isaacs/cliui": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", - "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -1160,9 +1158,9 @@ } }, "node_modules/@ladybugdb/core": { - "version": "0.15.2", - "resolved": "https://registry.npmjs.org/@ladybugdb/core/-/core-0.15.2.tgz", - "integrity": "sha512-DpseEj9CM/QTV0z+rvBk6nB2mOoG4GVhnKKLiXChGTVddgpH6R/Pv2YiDZB7rUIDnFpJxVQNbQaYEkZ7i1h1KA==", + "version": "0.15.3", + "resolved": "https://registry.npmjs.org/@ladybugdb/core/-/core-0.15.3.tgz", + "integrity": "sha512-Xa8VmWhMTvTCWmApnqm9FJtyxxV+CiMCokl1p9vEfXNuBz3SWXWGDmHlzKikswtQbUe9tTV3J9MxPdVFVE6/yg==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -1170,16 +1168,16 @@ "node-addon-api": "^6.0.0" }, "optionalDependencies": { - "@ladybugdb/core-darwin-arm64": "0.15.2", - "@ladybugdb/core-linux-arm64": "0.15.2", - "@ladybugdb/core-linux-x64": "0.15.2", - "@ladybugdb/core-win32-x64": "0.15.2" + "@ladybugdb/core-darwin-arm64": "0.15.3", + "@ladybugdb/core-linux-arm64": "0.15.3", + "@ladybugdb/core-linux-x64": "0.15.3", + "@ladybugdb/core-win32-x64": "0.15.3" } }, "node_modules/@ladybugdb/core-darwin-arm64": { - "version": "0.15.2", - "resolved": "https://registry.npmjs.org/@ladybugdb/core-darwin-arm64/-/core-darwin-arm64-0.15.2.tgz", - "integrity": "sha512-ifLyUTPzlh2zR1IqkUT5AfldX+X4zfWBzwakmGTgMPxyrEiRNDwUKfnNxHeLQ/TJTOS/nfzYxxLLt5CZf2/FhA==", + "version": "0.15.3", + "resolved": "https://registry.npmjs.org/@ladybugdb/core-darwin-arm64/-/core-darwin-arm64-0.15.3.tgz", + "integrity": "sha512-+bqAb3wbbmxPSeNQjbVd6Ek5K8GbHr1KlDr09YkNqZ7XWhKqWxbs097xAG9bynLcZh9oxok2PGCoK4w5YHs11w==", "cpu": [ "arm64" ], @@ -1190,9 +1188,9 @@ ] }, "node_modules/@ladybugdb/core-linux-arm64": { - "version": "0.15.2", - "resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-arm64/-/core-linux-arm64-0.15.2.tgz", - "integrity": "sha512-9537UbHOiuSr/BaTfjcoBsHxEKF4uEXWyXEjm/AQCGXQFocX3nQDVNDYJzuDYjKZ51oJRJ0oSuesAStOCwjolA==", + "version": "0.15.3", + "resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-arm64/-/core-linux-arm64-0.15.3.tgz", + "integrity": "sha512-Z8Ur6YbC5y6pgtKh/7b1/xdeRHy69sGhsoVJm1tc9xp9Zrar6G2A71bEdjOdDJ/mDRt6RtY0zdhUgIgQXYQtbQ==", "cpu": [ "arm64" ], @@ -1203,9 +1201,9 @@ ] }, "node_modules/@ladybugdb/core-linux-x64": { - "version": "0.15.2", - "resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-x64/-/core-linux-x64-0.15.2.tgz", - "integrity": "sha512-1+xLoapjbMQzDHxcPpMPt8Suuvms3nhOIZFNGPDcWz90NwEmLAjWNFQZZHeg8DRz0vG2j8UY292bvGORVcxs8g==", + "version": "0.15.3", + "resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-x64/-/core-linux-x64-0.15.3.tgz", + "integrity": "sha512-DT9xBc91tuxzjRu1dJ3xGt/K/uR1Q8bX5+8tCtj66UbVIVvp1RWAAE9phq7eahcF/3zBuFRonkxW/tTyQdQIlQ==", "cpu": [ "x64" ], @@ -1216,9 +1214,9 @@ ] }, "node_modules/@ladybugdb/core-win32-x64": { - "version": "0.15.2", - "resolved": "https://registry.npmjs.org/@ladybugdb/core-win32-x64/-/core-win32-x64-0.15.2.tgz", - "integrity": "sha512-+LIJVKBNSrf2bGruJO4l0ihrLKZkv5+lNitK8xc3T7gC1bcc+FaYtRMvlgZP6Qh2rEHAjqfbaSKVrdw0M2EXTw==", + "version": "0.15.3", + "resolved": "https://registry.npmjs.org/@ladybugdb/core-win32-x64/-/core-win32-x64-0.15.3.tgz", + "integrity": "sha512-ymHC8nHGIT7M9aditBQFIystxW+WoqvI3xklz22BHaFpU9CrTNtdU20K6cuRZvqEA2//Edu7kMoP9OwLkIleCg==", "cpu": [ "x64" ], @@ -1235,9 +1233,9 @@ "license": "MIT" }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.28.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.28.0.tgz", - "integrity": "sha512-gmloF+i+flI8ouQK7MWW4mOwuMh4RePBuPFAEPC6+pdqyWOUMDOixb6qZ69owLJpz6XmyllCouc4t8YWO+E2Nw==", + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", "license": "MIT", "dependencies": { "@hono/node-server": "^1.19.9", @@ -1537,26 +1535,28 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", - "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, "node_modules/@oxc-project/types": { - "version": "0.122.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", - "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", + "version": "0.124.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz", + "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==", "dev": true, "license": "MIT", "funding": { @@ -1628,9 +1628,9 @@ "license": "BSD-3-Clause" }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", - "integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==", "cpu": [ "arm64" ], @@ -1645,9 +1645,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz", - "integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==", "cpu": [ "arm64" ], @@ -1662,9 +1662,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz", - "integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==", "cpu": [ "x64" ], @@ -1679,9 +1679,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz", - "integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==", "cpu": [ "x64" ], @@ -1696,9 +1696,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz", - "integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz", + "integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==", "cpu": [ "arm" ], @@ -1713,9 +1713,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==", "cpu": [ "arm64" ], @@ -1730,9 +1730,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz", - "integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==", "cpu": [ "arm64" ], @@ -1747,9 +1747,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==", "cpu": [ "ppc64" ], @@ -1764,9 +1764,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==", "cpu": [ "s390x" ], @@ -1781,9 +1781,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==", "cpu": [ "x64" ], @@ -1798,9 +1798,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz", - "integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==", "cpu": [ "x64" ], @@ -1815,9 +1815,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz", - "integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==", "cpu": [ "arm64" ], @@ -1832,9 +1832,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz", - "integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz", + "integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==", "cpu": [ "wasm32" ], @@ -1842,16 +1842,29 @@ "license": "MIT", "optional": true, "dependencies": { - "@napi-rs/wasm-runtime": "^1.1.1" + "@emnapi/core": "1.9.2", + "@emnapi/runtime": "1.9.2", + "@napi-rs/wasm-runtime": "^1.1.3" }, "engines": { "node": ">=14.0.0" } }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz", - "integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==", "cpu": [ "arm64" ], @@ -1866,9 +1879,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz", - "integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==", "cpu": [ "x64" ], @@ -1883,9 +1896,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz", - "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", + "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==", "dev": true, "license": "MIT" }, @@ -2091,14 +2104,14 @@ "license": "MIT" }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.2.tgz", - "integrity": "sha512-sPK//PHO+kAkScb8XITeB1bf7fsk85Km7+rt4eeuRR3VS1/crD47cmV5wicisJmjNdfeokTZwjMk4Mj2d58Mgg==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.4.tgz", + "integrity": "sha512-x7FptB5oDruxNPDNY2+S8tCh0pcq7ymCe1gTHcsp733jYjrJl8V1gMUlVysuCD9Kz46Xz9t1akkv08dPcYDs1w==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.2", + "@vitest/utils": "4.1.4", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -2112,8 +2125,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.2", - "vitest": "4.1.2" + "@vitest/browser": "4.1.4", + "vitest": "4.1.4" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -2122,16 +2135,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz", - "integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz", + "integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.2", - "@vitest/utils": "4.1.2", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -2140,13 +2153,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz", - "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz", + "integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.2", + "@vitest/spy": "4.1.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -2167,9 +2180,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz", - "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz", + "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==", "dev": true, "license": "MIT", "dependencies": { @@ -2180,13 +2193,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz", - "integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz", + "integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.2", + "@vitest/utils": "4.1.4", "pathe": "^2.0.3" }, "funding": { @@ -2194,14 +2207,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz", - "integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz", + "integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.2", - "@vitest/utils": "4.1.2", + "@vitest/pretty-format": "4.1.4", + "@vitest/utils": "4.1.4", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -2210,9 +2223,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz", - "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz", + "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==", "dev": true, "license": "MIT", "funding": { @@ -2220,13 +2233,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz", - "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz", + "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.2", + "@vitest/pretty-format": "4.1.4", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -2554,12 +2567,12 @@ "license": "MIT" }, "node_modules/commander": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", - "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", "license": "MIT", "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/content-disposition": { @@ -3115,22 +3128,6 @@ "integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==", "license": "Apache-2.0" }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -3251,24 +3248,17 @@ "link": true }, "node_modules/glob": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", - "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", "license": "BlueOak-1.0.0", "dependencies": { - "foreground-child": "^3.3.1", - "jackspeak": "^4.1.1", - "minimatch": "^10.1.1", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^2.0.0" - }, - "bin": { - "glob": "dist/esm/bin.mjs" + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -3351,6 +3341,15 @@ "graphology-types": ">=0.20.0" } }, + "node_modules/graphology-indices/node_modules/mnemonist": { + "version": "0.39.8", + "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.8.tgz", + "integrity": "sha512-vyWo2K3fjrUw8YeeZ1zF0fy6Mu59RHokURlld8ymdUPjMlD9EC9ov1/YPqTgqRvUN9nTr3Gqfz29LYAmu0PHPQ==", + "license": "MIT", + "dependencies": { + "obliterator": "^2.0.1" + } + }, "node_modules/graphology-types": { "version": "0.24.8", "resolved": "https://registry.npmjs.org/graphology-types/-/graphology-types-0.24.8.tgz", @@ -3569,21 +3568,6 @@ "node": ">=8" } }, - "node_modules/jackspeak": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", - "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^9.0.0" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/jose": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", @@ -3910,9 +3894,9 @@ "license": "Apache-2.0" }, "node_modules/lru-cache": { - "version": "11.2.7", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", - "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -4083,12 +4067,12 @@ } }, "node_modules/mnemonist": { - "version": "0.39.8", - "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.8.tgz", - "integrity": "sha512-vyWo2K3fjrUw8YeeZ1zF0fy6Mu59RHokURlld8ymdUPjMlD9EC9ov1/YPqTgqRvUN9nTr3Gqfz29LYAmu0PHPQ==", + "version": "0.40.3", + "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.3.tgz", + "integrity": "sha512-Vjyr90sJ23CKKH/qPAgUKicw/v6pRoamxIEDFOF8uSgFME7DqPRpHgRTejWVjkdGg5dXj0/NyxZHZ9bcjH+2uQ==", "license": "MIT", "dependencies": { - "obliterator": "^2.0.1" + "obliterator": "^2.0.4" } }, "node_modules/ms": { @@ -4243,31 +4227,25 @@ } }, "node_modules/onnxruntime-web": { - "version": "1.22.0-dev.20250409-89f8206ba4", - "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.22.0-dev.20250409-89f8206ba4.tgz", - "integrity": "sha512-0uS76OPgH0hWCPrFKlL8kYVV7ckM7t/36HfbgoFw6Nd0CZVVbQC4PkrR8mBX8LtNUFZO25IQBqV2Hx2ho3FlbQ==", + "version": "1.26.0-dev.20260410-5e55544225", + "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.26.0-dev.20260410-5e55544225.tgz", + "integrity": "sha512-hHd9n8DzIfGSAjM4Dvslesc8i6h9HEEcl8qt7X3LfhUxMgls6FBJ32j2xrDtJjKJFEehFeJmyB/pvad1I8KS8w==", "license": "MIT", "dependencies": { "flatbuffers": "^25.1.24", "guid-typescript": "^1.0.9", "long": "^5.2.3", - "onnxruntime-common": "1.22.0-dev.20250409-89f8206ba4", + "onnxruntime-common": "1.24.0-dev.20251116-b39e144322", "platform": "^1.3.6", "protobufjs": "^7.2.4" } }, "node_modules/onnxruntime-web/node_modules/onnxruntime-common": { - "version": "1.22.0-dev.20250409-89f8206ba4", - "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.22.0-dev.20250409-89f8206ba4.tgz", - "integrity": "sha512-vDJMkfCfb0b1A836rgHj+ORuZf4B4+cc2bASQtpeoJLueuFc5DuYwjIZUBrSvx/fO5IrLjLz+oTrB3pcGlhovQ==", + "version": "1.24.0-dev.20251116-b39e144322", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.0-dev.20251116-b39e144322.tgz", + "integrity": "sha512-BOoomdHYmNRL5r4iQ4bMvsl2t0/hzVQ3OM3PHD0gxeXu1PmggqBv3puZicEUVOA3AtHHYmqZtjMj9FOfGrATTw==", "license": "MIT" }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "license": "BlueOak-1.0.0" - }, "node_modules/pandemonium": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/pandemonium/-/pandemonium-2.4.1.tgz", @@ -4277,6 +4255,15 @@ "mnemonist": "^0.39.2" } }, + "node_modules/pandemonium/node_modules/mnemonist": { + "version": "0.39.8", + "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.8.tgz", + "integrity": "sha512-vyWo2K3fjrUw8YeeZ1zF0fy6Mu59RHokURlld8ymdUPjMlD9EC9ov1/YPqTgqRvUN9nTr3Gqfz29LYAmu0PHPQ==", + "license": "MIT", + "dependencies": { + "obliterator": "^2.0.1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -4360,9 +4347,9 @@ "license": "MIT" }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", "dev": true, "funding": [ { @@ -4389,9 +4376,9 @@ } }, "node_modules/protobufjs": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", - "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.5.tgz", + "integrity": "sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { @@ -4541,14 +4528,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", - "integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz", + "integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.122.0", - "@rolldown/pluginutils": "1.0.0-rc.12" + "@oxc-project/types": "=0.124.0", + "@rolldown/pluginutils": "1.0.0-rc.15" }, "bin": { "rolldown": "bin/cli.mjs" @@ -4557,21 +4544,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.12", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", - "@rolldown/binding-darwin-x64": "1.0.0-rc.12", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" + "@rolldown/binding-android-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-x64": "1.0.0-rc.15", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" } }, "node_modules/router": { @@ -4863,18 +4850,6 @@ "dev": true, "license": "ISC" }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -4996,14 +4971,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -5498,16 +5473,16 @@ } }, "node_modules/vite": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", - "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz", + "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", - "rolldown": "1.0.0-rc.12", + "rolldown": "1.0.0-rc.15", "tinyglobby": "^0.2.15" }, "bin": { @@ -5525,7 +5500,7 @@ "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", - "esbuild": "^0.27.0", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", @@ -5576,19 +5551,19 @@ } }, "node_modules/vitest": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz", - "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz", + "integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.2", - "@vitest/mocker": "4.1.2", - "@vitest/pretty-format": "4.1.2", - "@vitest/runner": "4.1.2", - "@vitest/snapshot": "4.1.2", - "@vitest/spy": "4.1.2", - "@vitest/utils": "4.1.2", + "@vitest/expect": "4.1.4", + "@vitest/mocker": "4.1.4", + "@vitest/pretty-format": "4.1.4", + "@vitest/runner": "4.1.4", + "@vitest/snapshot": "4.1.4", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -5616,10 +5591,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.2", - "@vitest/browser-preview": "4.1.2", - "@vitest/browser-webdriverio": "4.1.2", - "@vitest/ui": "4.1.2", + "@vitest/browser-playwright": "4.1.4", + "@vitest/browser-preview": "4.1.4", + "@vitest/browser-webdriverio": "4.1.4", + "@vitest/coverage-istanbul": "4.1.4", + "@vitest/coverage-v8": "4.1.4", + "@vitest/ui": "4.1.4", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -5643,6 +5620,12 @@ "@vitest/browser-webdriverio": { "optional": true }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, "@vitest/ui": { "optional": true }, diff --git a/gitnexus/package.json b/gitnexus/package.json index ad075041d..d8f2c126a 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -1,6 +1,6 @@ { "name": "gitnexus", - "version": "1.6.1", + "version": "1.6.2", "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.", "author": "Abhigyan Patwari", "license": "PolyForm-Noncommercial-1.0.0", @@ -51,22 +51,22 @@ "prepack": "node scripts/build.js" }, "dependencies": { - "@huggingface/transformers": "^3.0.0", + "@huggingface/transformers": "^4.1.0", "@ladybugdb/core": "^0.15.2", "@modelcontextprotocol/sdk": "^1.0.0", "@scarf/scarf": "^1.4.0", "cli-progress": "^3.12.0", - "commander": "^12.0.0", + "commander": "^14.0.3", "cors": "^2.8.5", "express": "^4.19.2", - "glob": "^11.0.0", + "glob": "^13.0.6", "graphology": "^0.25.4", "graphology-indices": "^0.17.0", "graphology-utils": "^2.3.0", "ignore": "^7.0.5", "js-yaml": "^4.1.1", "lru-cache": "^11.0.0", - "mnemonist": "^0.39.0", + "mnemonist": "^0.40.3", "onnxruntime-node": "^1.24.0", "pandemonium": "^2.4.0", "tree-sitter": "^0.21.1", diff --git a/gitnexus/shadow-parity-dashboard/index.html b/gitnexus/shadow-parity-dashboard/index.html new file mode 100644 index 000000000..104d7b026 --- /dev/null +++ b/gitnexus/shadow-parity-dashboard/index.html @@ -0,0 +1,291 @@ + + + + + + GitNexus — Shadow Parity Dashboard + + + + +
+

Shadow Parity — RFC #909

+
loading latest.json
+
+ + + + + + + + + + + + + + +
LanguageTotalAgreeOnly legacyOnly newDisagreeBoth emptyParity
+ +
+ + + diff --git a/gitnexus/src/cli/ai-context.ts b/gitnexus/src/cli/ai-context.ts index fd7e3cc29..984a16f7b 100644 --- a/gitnexus/src/cli/ai-context.ts +++ b/gitnexus/src/cli/ai-context.ts @@ -101,19 +101,6 @@ This project is indexed by GitNexus as **${projectName}**${noStats ? '' : ` (${s - 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"})\`. -## 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/${projectName}/process/{processName}\` — trace the full execution flow step by step -4. For regressions: \`gitnexus_detect_changes({scope: "compare", base_ref: "main"})\` — see what your branch changed - -## When Refactoring - -- **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. - ## Never Do - NEVER edit a function, class, or method without first running \`gitnexus_impact\` on it. @@ -121,25 +108,6 @@ This project is indexed by GitNexus as **${projectName}**${noStats ? '' : ` (${s - 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. -## Tools Quick Reference - -| Tool | When to use | Command | -|------|-------------|---------| -| \`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 ..."})\` | - -## Impact Risk Levels - -| Depth | Meaning | Action | -|-------|---------|--------| -| d=1 | WILL BREAK — direct callers/importers | MUST update these | -| d=2 | LIKELY AFFECTED — indirect deps | Should test | -| d=3 | MAY NEED TESTING — transitive | Test if critical path | - ## Resources | Resource | Use for | @@ -149,32 +117,6 @@ This project is indexed by GitNexus as **${projectName}**${noStats ? '' : ` (${s | \`gitnexus://repo/${projectName}/processes\` | All execution flows | | \`gitnexus://repo/${projectName}/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 - -## Keeping the Index Fresh - -After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it: - -\`\`\`bash -npx gitnexus analyze -\`\`\` - -If the index previously included embeddings, preserve them by adding \`--embeddings\`: - -\`\`\`bash -npx gitnexus analyze --embeddings -\`\`\` - -To check whether embeddings exist, inspect \`.gitnexus/meta.json\` — the \`stats.embeddings\` field shows the count (0 means no embeddings). **Running analyze without \`--embeddings\` will delete any previously generated embeddings.** - -> Claude Code users: A PostToolUse hook handles this automatically after \`git commit\` and \`git merge\`. - ${ groupNames && groupNames.length > 0 ? `## Cross-Repo Groups diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index 26d1ae8c6..46cedc434 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -13,7 +13,11 @@ import { execFileSync } from 'child_process'; import v8 from 'v8'; import cliProgress from 'cli-progress'; import { closeLbug } from '../core/lbug/lbug-adapter.js'; -import { getStoragePaths, getGlobalRegistryPath } from '../storage/repo-manager.js'; +import { + getStoragePaths, + getGlobalRegistryPath, + RegistryNameCollisionError, +} from '../storage/repo-manager.js'; import { getGitRoot, hasGitDir } from '../storage/git.js'; import { runFullAnalysis } from '../core/run-analyze.js'; import fs from 'fs/promises'; @@ -59,6 +63,21 @@ export interface AnalyzeOptions { noStats?: boolean; /** Index the folder even when no .git directory is present. */ skipGit?: boolean; + /** + * Override the default basename-derived registry `name` with a + * user-supplied alias (#829). Disambiguates repos whose paths share a + * basename. Persisted — subsequent re-analyses of the same path without + * `--name` preserve the alias. + */ + name?: string; + /** + * Allow registration even when another path already uses the same + * `--name` alias (#829). Intentionally a distinct flag from `--force` + * because the user may want to coexist under the same name WITHOUT + * paying the cost of a pipeline re-index. Maps to registerRepo's + * `allowDuplicateName` option end-to-end. + */ + allowDuplicateName?: boolean; } export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOptions) => { @@ -147,9 +166,11 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption const origLog = console.log.bind(console); const origWarn = console.warn.bind(console); const origError = console.error.bind(console); + let barCurrentValue = 0; const barLog = (...args: any[]) => { process.stdout.write('\x1b[2K\r'); origLog(args.map((a) => (typeof a === 'string' ? a : String(a))).join(' ')); + bar.update(barCurrentValue); }; console.log = barLog; console.warn = barLog; @@ -160,6 +181,7 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption let phaseStart = Date.now(); const updateBar = (value: number, phaseLabel: string) => { + barCurrentValue = value; if (phaseLabel !== lastPhaseLabel) { lastPhaseLabel = phaseLabel; phaseStart = Date.now(); @@ -183,11 +205,20 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption const result = await runFullAnalysis( repoPath, { + // Pipeline re-index — OR'd with --skills because skill generation + // needs a fresh pipelineResult. Has no bearing on the registry + // collision guard (see allowDuplicateName below). force: options?.force || options?.skills, embeddings: options?.embeddings, skipGit: options?.skipGit, skipAgentsMd: options?.skipAgentsMd, noStats: options?.noStats, + registryName: options?.name, + // Registry-collision bypass — its own CLI flag, intentionally NOT + // overloading --force. A user who hits the collision guard should + // be able to accept the duplicate name without also paying the + // cost of a full pipeline re-index. See #829 review round 2. + allowDuplicateName: options?.allowDuplicateName, }, { onProgress: (_phase, percent, message) => { @@ -295,6 +326,22 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption bar.stop(); const msg = err.message || String(err); + + // Registry name-collision from --name (#829) — surface as an + // actionable error rather than a generic stack-trace. + if (err instanceof RegistryNameCollisionError) { + console.error(`\n Registry name collision:\n`); + console.error(` "${err.registryName}" is already used by "${err.existingPath}".\n`); + console.error(` Options:`); + console.error(` • Pick a different alias: gitnexus analyze --name `); + console.error( + ` • Allow the duplicate: gitnexus analyze --allow-duplicate-name (leaves "-r ${err.registryName}" ambiguous)`, + ); + console.error(''); + process.exitCode = 1; + return; + } + console.error(`\n Analysis failed: ${msg}\n`); // Provide helpful guidance for known failure modes diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index 02581ae47..dca5983e0 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -28,6 +28,16 @@ program .option('--skip-agents-md', 'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md') .option('--no-stats', 'Omit volatile file/symbol counts from AGENTS.md and CLAUDE.md') .option('--skip-git', 'Index a folder without requiring a .git directory') + .option( + '--name ', + 'Register this repo under a custom name in ~/.gitnexus/registry.json ' + + '(disambiguates repos whose paths share a basename, e.g. two different .../app folders)', + ) + .option( + '--allow-duplicate-name', + 'Register this repo even if another path already uses the same --name alias. ' + + 'Leaves `-r ` ambiguous for the two paths; use -r to disambiguate.', + ) .option('-v, --verbose', 'Enable verbose ingestion warnings (default: false)') .addHelpText( 'after', diff --git a/gitnexus/src/cli/list.ts b/gitnexus/src/cli/list.ts index 722c8ad59..5da9a86f0 100644 --- a/gitnexus/src/cli/list.ts +++ b/gitnexus/src/cli/list.ts @@ -17,12 +17,23 @@ export const listCommand = async () => { console.log(`\n Indexed Repositories (${entries.length})\n`); + // Count occurrences of each name so colliding entries can be + // disambiguated in the header (#829). Unique-name entries render + // identically to pre-#829 output; only collisions gain a suffix. + const nameCounts = new Map(); + for (const e of entries) { + const key = e.name.toLowerCase(); + nameCounts.set(key, (nameCounts.get(key) ?? 0) + 1); + } + for (const entry of entries) { const indexedDate = new Date(entry.indexedAt).toLocaleString(); const stats = entry.stats || {}; const commitShort = entry.lastCommit?.slice(0, 7) || 'unknown'; + const hasCollision = (nameCounts.get(entry.name.toLowerCase()) ?? 0) > 1; + const header = hasCollision ? `${entry.name} (${entry.path})` : entry.name; - console.log(` ${entry.name}`); + console.log(` ${header}`); console.log(` Path: ${entry.path}`); console.log(` Indexed: ${indexedDate}`); console.log(` Commit: ${commitShort}`); diff --git a/gitnexus/src/core/embeddings/ast-utils.ts b/gitnexus/src/core/embeddings/ast-utils.ts new file mode 100644 index 000000000..fb51ac052 --- /dev/null +++ b/gitnexus/src/core/embeddings/ast-utils.ts @@ -0,0 +1,112 @@ +/** + * Shared AST utilities for the embedding pipeline. + * Centralizes parser caching and tree-sitter node lookups + * used by both chunker.ts and structural-extractor.ts. + */ + +import { getLanguageFromFilename } from 'gitnexus-shared'; +import { + createParserForLanguage, + isLanguageAvailable, + resolveLanguageKey, +} from '../tree-sitter/parser-loader.js'; + +const parserCache = new Map(); + +/** + * Ensure parser is initialized and language is loaded, then parse content. + * Returns null if language is unavailable or parsing fails. + */ +export const ensureAndParse = async (content: string, filePath: string): Promise => { + const language = getLanguageFromFilename(filePath); + if (!language) return null; + if (!isLanguageAvailable(language)) return null; + + const parserKey = resolveLanguageKey(language, filePath); + let parserInstance = parserCache.get(parserKey); + if (!parserInstance) { + parserInstance = await createParserForLanguage(language, filePath); + parserCache.set(parserKey, parserInstance); + } + + return parserInstance.parse(content); +}; + +const FUNCTION_LIKE_TYPES = new Set([ + 'function_declaration', + 'function_definition', + 'method_declaration', + 'method_definition', + 'function_item', + 'function_signature_item', + 'arrow_function', + 'function_expression', + 'generator_function_declaration', + 'generator_function', + 'async_function_declaration', + 'async_arrow_function', + 'constructor_declaration', + 'constructor_definition', + 'compact_constructor_declaration', + 'short_function_declaration', + 'proc_declaration', + 'func_literal', + 'local_function_statement', + 'anonymous_function', + 'lambda_literal', + 'init_declaration', + 'deinit_declaration', +]); + +/** + * Find the first function/method-like declaration in a snippet AST. + * Used by the chunker when parsing node.content where absolute line + * numbers don't apply. + */ +export const findFunctionNode = (root: any): any | null => { + if (FUNCTION_LIKE_TYPES.has(root.type)) return root; + + for (let i = 0; i < root.namedChildCount; i++) { + const child = root.namedChild(i); + if (!child) continue; + if (FUNCTION_LIKE_TYPES.has(child.type)) return child; + const found = findFunctionNode(child); + if (found) return found; + } + + return null; +}; + +/** + * Find the first class/struct/interface/enum-like declaration in an AST. + * Used when parsing node.content (a snippet, not a full file) where + * absolute line numbers don't apply. + */ +export const findDeclarationNode = (root: any): any | null => { + const CLASS_LIKE_TYPES = new Set([ + 'class_declaration', + 'class_definition', + 'struct_declaration', + 'struct_item', + 'interface_declaration', + 'interface_definition', + 'enum_declaration', + 'enum_item', + 'type_declaration', // Go: type X struct + 'declaration', // Go: type X struct + 'object_declaration', // Kotlin: object + 'impl_item', // Rust: impl + ]); + + if (CLASS_LIKE_TYPES.has(root.type)) return root; + + for (let i = 0; i < root.namedChildCount; i++) { + const child = root.namedChild(i); + if (!child) continue; + if (CLASS_LIKE_TYPES.has(child.type)) return child; + const found = findDeclarationNode(child); + if (found) return found; + } + + return null; +}; diff --git a/gitnexus/src/core/embeddings/character-chunk.ts b/gitnexus/src/core/embeddings/character-chunk.ts new file mode 100644 index 000000000..e4f6f949c --- /dev/null +++ b/gitnexus/src/core/embeddings/character-chunk.ts @@ -0,0 +1,63 @@ +/** + * Character-based sliding window chunking (pure, no tree-sitter dependency) + */ + +import { buildLineIndex, resolveChunkLines } from './line-index.js'; + +export interface Chunk { + text: string; + chunkIndex: number; + startOffset: number; + endOffset: number; + startLine: number; + endLine: number; +} + +export const characterChunk = ( + content: string, + startLine: number, + endLine: number, + chunkSize: number = 1200, + overlap: number = 120, +): Chunk[] => { + if (content.length <= chunkSize) { + return [ + { + text: content, + chunkIndex: 0, + startOffset: 0, + endOffset: content.length, + startLine, + endLine, + }, + ]; + } + + const chunks: Chunk[] = []; + let offset = 0; + const lineOffsets = buildLineIndex(content); + + while (offset < content.length) { + const end = Math.min(offset + chunkSize, content.length); + const chunkText = content.slice(offset, end); + const lineRange = resolveChunkLines(lineOffsets, offset, end, startLine); + + chunks.push({ + text: chunkText, + chunkIndex: chunks.length, + startOffset: offset, + endOffset: end, + startLine: lineRange.startLine, + endLine: lineRange.endLine, + }); + + offset = end - overlap; + if (offset >= content.length) break; + if (end >= content.length) break; + if (offset <= (chunks.length > 1 ? end - chunkSize : 0)) { + offset = end; + } + } + + return chunks; +}; diff --git a/gitnexus/src/core/embeddings/chunker.ts b/gitnexus/src/core/embeddings/chunker.ts new file mode 100644 index 000000000..114a5f68c --- /dev/null +++ b/gitnexus/src/core/embeddings/chunker.ts @@ -0,0 +1,363 @@ +/** + * Chunker Module + * + * Splits code nodes into chunks for embedding. + * - Function/Method: AST-aware chunking by statement boundaries + * - Other types: character-based sliding window fallback + * - Short content (≤ chunkSize): no chunking + */ + +export { type Chunk, characterChunk } from './character-chunk.js'; + +import { characterChunk } from './character-chunk.js'; +import type { Chunk } from './character-chunk.js'; +import { ensureAndParse, findDeclarationNode, findFunctionNode } from './ast-utils.js'; +import { buildLineIndex, resolveChunkLines } from './line-index.js'; + +/** + * Main chunkNode function: dispatches by label + */ +export const chunkNode = async ( + label: string, + content: string, + filePath: string, + startLine: number, + endLine: number, + chunkSize: number = 1200, + overlap: number = 120, +): Promise => { + // Content fits in one chunk — no splitting needed + if (content.length <= chunkSize) { + return [ + { + text: content, + chunkIndex: 0, + startOffset: 0, + endOffset: content.length, + startLine, + endLine, + }, + ]; + } + + // Only function-like labels get AST chunking + if (label === 'Function' || label === 'Method' || label === 'Constructor') { + try { + const astChunks = await astChunk(content, filePath, startLine, endLine, chunkSize, overlap); + if (astChunks.length > 0) return astChunks; + } catch { + // AST parsing failed — fall through to character fallback + } + } + + if (label === 'Class' || label === 'Interface') { + try { + const declarationChunks = await declarationChunk( + label, + content, + filePath, + startLine, + endLine, + chunkSize, + overlap, + ); + if (declarationChunks.length > 0) return declarationChunks; + } catch { + // AST parsing failed — fall through to character fallback + } + } + + // Character-based fallback for everything else + return characterChunk(content, startLine, endLine, chunkSize, overlap); +}; + +/** + * AST-based chunking for Function/Method + * Parse snippet content, locate the function declaration node, + * split body by statement boundaries. + */ +const astChunk = async ( + content: string, + filePath: string, + startLine: number, + endLine: number, + chunkSize: number, + overlap: number, +): Promise => { + const tree = await ensureAndParse(content, filePath); + if (!tree) return []; + + const root = tree.rootNode; + const lineOffsets = buildLineIndex(content); + + // Find the function/method declaration in the snippet AST. + // tree-sitter parses node.content (a snippet), so rows are relative (0-based). + const targetNode = findFunctionNode(root); + if (!targetNode) return []; + + // Get the body (statements) via childForFieldName('body') + const bodyNode = targetNode.childForFieldName('body'); + if (!bodyNode) return []; + + // Extract individual statements + const statements: Array<{ startIndex: number; endIndex: number }> = []; + for (let i = 0; i < bodyNode.namedChildCount; i++) { + const child = bodyNode.namedChild(i); + if (!child) continue; + statements.push({ + startIndex: child.startIndex, + endIndex: child.endIndex, + }); + } + + if (statements.length === 0) return []; + + return chunkByUnits( + content, + lineOffsets, + startLine, + chunkSize, + overlap, + statements, + targetNode.startIndex, + targetNode.endIndex, + true, + true, + ); +}; + +const DECLARATION_BODY_NODE_TYPES = new Set([ + 'class_body', + 'object_type', + 'declaration_list', + 'interface_body', +]); + +const FIELD_LIKE_MEMBER_TYPES = new Set([ + 'field_definition', + 'public_field_definition', + 'property_definition', + 'property_signature', + 'variable_declarator', + 'lexical_declaration', + 'pair', + 'enum_assignment', +]); + +const declarationChunk = async ( + label: 'Class' | 'Interface', + content: string, + filePath: string, + startLine: number, + endLine: number, + chunkSize: number, + overlap: number, +): Promise => { + const tree = await ensureAndParse(content, filePath); + if (!tree) return []; + + const targetNode = findDeclarationNode(tree.rootNode); + if (!targetNode) return []; + + const bodyNode = getDeclarationBodyNode(targetNode); + if (!bodyNode) return []; + + const members = collectDeclarationUnits(bodyNode, label); + if (members.length === 0) return []; + + return chunkByUnits( + content, + buildLineIndex(content), + startLine, + chunkSize, + overlap, + members, + targetNode.startIndex, + targetNode.endIndex, + false, + false, + ); +}; + +const buildChunk = ( + content: string, + lineOffsets: Int32Array, + chunkIndex: number, + startOffset: number, + endOffset: number, + baseStartLine: number, +): Chunk => { + const lineRange = resolveChunkLines(lineOffsets, startOffset, endOffset, baseStartLine); + return { + text: content.slice(startOffset, endOffset), + chunkIndex, + startOffset, + endOffset, + startLine: lineRange.startLine, + endLine: lineRange.endLine, + }; +}; + +const chunkByUnits = ( + content: string, + lineOffsets: Int32Array, + baseStartLine: number, + chunkSize: number, + overlap: number, + units: Array<{ startIndex: number; endIndex: number }>, + containerStartOffset: number, + containerEndOffset: number, + includeContainerPrefixOnFirstChunk: boolean, + includeContainerSuffixOnLastChunk: boolean, +): Chunk[] => { + const chunks: Chunk[] = []; + let chunkStartUnitIdx = 0; + + while (chunkStartUnitIdx < units.length) { + const chunkStartOffset = + chunkStartUnitIdx === 0 && includeContainerPrefixOnFirstChunk + ? containerStartOffset + : units[chunkStartUnitIdx].startIndex; + + let chunkEndUnitIdx = chunkStartUnitIdx; + let candidateEndOffset = + chunkEndUnitIdx === units.length - 1 && includeContainerSuffixOnLastChunk + ? containerEndOffset + : units[chunkEndUnitIdx].endIndex; + + while (chunkEndUnitIdx + 1 < units.length) { + const nextEndOffset = + chunkEndUnitIdx + 1 === units.length - 1 && includeContainerSuffixOnLastChunk + ? containerEndOffset + : units[chunkEndUnitIdx + 1].endIndex; + if (nextEndOffset - chunkStartOffset > chunkSize) break; + chunkEndUnitIdx += 1; + candidateEndOffset = nextEndOffset; + } + + if (candidateEndOffset - chunkStartOffset > chunkSize) { + const oversizedUnit = units[chunkStartUnitIdx]; + const oversizedLineRange = resolveChunkLines( + lineOffsets, + oversizedUnit.startIndex, + oversizedUnit.endIndex, + baseStartLine, + ); + const oversizedChunks = characterChunk( + content.slice(oversizedUnit.startIndex, oversizedUnit.endIndex), + oversizedLineRange.startLine, + oversizedLineRange.endLine, + chunkSize, + overlap, + ).map((chunk, offsetIdx) => ({ + ...chunk, + chunkIndex: chunks.length + offsetIdx, + startOffset: chunk.startOffset + oversizedUnit.startIndex, + endOffset: chunk.endOffset + oversizedUnit.startIndex, + })); + chunks.push(...oversizedChunks); + chunkStartUnitIdx += 1; + continue; + } + + chunks.push( + buildChunk( + content, + lineOffsets, + chunks.length, + chunkStartOffset, + candidateEndOffset, + baseStartLine, + ), + ); + + if (chunkEndUnitIdx === units.length - 1) { + break; + } + + const nextChunkStartUnitIdx = findOverlapStartIndex( + units, + chunkStartUnitIdx, + chunkEndUnitIdx, + overlap, + ); + if (nextChunkStartUnitIdx <= chunkStartUnitIdx) { + chunkStartUnitIdx = chunkEndUnitIdx + 1; + } else { + chunkStartUnitIdx = nextChunkStartUnitIdx; + } + } + + return chunks; +}; + +const findOverlapStartIndex = ( + statements: Array<{ startIndex: number; endIndex: number }>, + chunkStartStmtIdx: number, + chunkEndStmtIdx: number, + overlapSize: number, +): number => { + if (overlapSize <= 0) return chunkEndStmtIdx + 1; + + let overlapStartIdx = chunkEndStmtIdx; + while (overlapStartIdx > chunkStartStmtIdx) { + const overlapLength = + statements[chunkEndStmtIdx].endIndex - statements[overlapStartIdx - 1].startIndex; + if (overlapLength > overlapSize) break; + overlapStartIdx -= 1; + } + + return overlapStartIdx; +}; + +const getDeclarationBodyNode = (node: any): any | null => { + const bodyNode = node.childForFieldName?.('body'); + if (bodyNode) return bodyNode; + + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (!child) continue; + if (DECLARATION_BODY_NODE_TYPES.has(child.type)) return child; + } + + return null; +}; + +const collectDeclarationUnits = ( + bodyNode: any, + label: 'Class' | 'Interface', +): Array<{ startIndex: number; endIndex: number }> => { + const members: Array<{ startIndex: number; endIndex: number; groupable: boolean }> = []; + + for (let i = 0; i < bodyNode.namedChildCount; i++) { + const child = bodyNode.namedChild(i); + if (!child) continue; + members.push({ + startIndex: child.startIndex, + endIndex: child.endIndex, + groupable: label === 'Class' && FIELD_LIKE_MEMBER_TYPES.has(child.type), + }); + } + + if (members.length === 0) return []; + + const grouped: Array<{ startIndex: number; endIndex: number }> = []; + let current = members[0]; + + for (let i = 1; i < members.length; i++) { + const next = members[i]; + if (current.groupable && next.groupable) { + current = { + startIndex: current.startIndex, + endIndex: next.endIndex, + groupable: true, + }; + continue; + } + grouped.push({ startIndex: current.startIndex, endIndex: current.endIndex }); + current = next; + } + + grouped.push({ startIndex: current.startIndex, endIndex: current.endIndex }); + return grouped; +}; diff --git a/gitnexus/src/core/embeddings/embedding-pipeline.ts b/gitnexus/src/core/embeddings/embedding-pipeline.ts index cb28ca945..302903f8b 100644 --- a/gitnexus/src/core/embeddings/embedding-pipeline.ts +++ b/gitnexus/src/core/embeddings/embedding-pipeline.ts @@ -3,9 +3,9 @@ * * Orchestrates the background embedding process: * 1. Query embeddable nodes from LadybugDB - * 2. Generate text representations - * 3. Batch embed using transformers.js - * 4. Update LadybugDB with embeddings + * 2. Generate text representations with enriched metadata + * 3. Chunk long nodes, batch embed + * 4. Update LadybugDB with chunk-aware embeddings * 5. Create vector index for semantic search */ @@ -17,20 +17,28 @@ import { embeddingToArray, isEmbedderReady, } from './embedder.js'; -import { generateEmbeddingText, generateBatchEmbeddingTexts } from './text-generator.js'; +import { generateEmbeddingText } from './text-generator.js'; +import { chunkNode, characterChunk } from './chunker.js'; +import { extractStructuralNames } from './structural-extractor.js'; import { type EmbeddingProgress, type EmbeddingConfig, type EmbeddableNode, type SemanticSearchResult, type ModelProgress, + type EmbeddingContext, DEFAULT_EMBEDDING_CONFIG, EMBEDDABLE_LABELS, + isShortLabel, + LABELS_WITH_EXPORTED, + STRUCTURAL_LABELS, + collectBestChunks, } from './types.js'; import { EMBEDDING_TABLE_NAME, EMBEDDING_INDEX_NAME, CREATE_VECTOR_INDEX_QUERY, + STALE_HASH_SENTINEL, } from '../lbug/schema.js'; import { loadVectorExtension } from '../lbug/lbug-adapter.js'; @@ -46,7 +54,14 @@ export const contentHashForNode = ( node: EmbeddableNode, config: Partial = {}, ): string => { - const text = generateEmbeddingText(node, config); + // Hash must be deterministic across runs, so exclude methodNames/fieldNames + // which are populated during the batch loop via AST extraction. + // Using only node.content ensures the hash stays stable. + const text = generateEmbeddingText( + { ...node, methodNames: undefined, fieldNames: undefined }, + node.content, + config, + ); return createHash('sha1').update(text).digest('hex'); }; @@ -57,37 +72,50 @@ export type EmbeddingProgressCallback = (progress: EmbeddingProgress) => void; /** * Query all embeddable nodes from LadybugDB - * Uses table-specific queries (File has different schema than code elements) + * Uses table-specific queries for different label types */ const queryEmbeddableNodes = async ( executeQuery: (cypher: string) => Promise, ): Promise => { const allNodes: EmbeddableNode[] = []; - // Query each embeddable table with table-specific columns for (const label of EMBEDDABLE_LABELS) { try { let query: string; - if (label === 'File') { - // File nodes don't have startLine/endLine + if (label === 'Method') { + // Method has parameterCount and returnType query = ` - MATCH (n:File) - RETURN n.id AS id, n.name AS name, 'File' AS label, - n.filePath AS filePath, n.content AS content + MATCH (n:Method) + RETURN n.id AS id, n.name AS name, 'Method' AS label, + n.filePath AS filePath, n.content AS content, + n.startLine AS startLine, n.endLine AS endLine, + n.isExported AS isExported, n.description AS description, + n.parameterCount AS parameterCount, n.returnType AS returnType + `; + } else if (LABELS_WITH_EXPORTED.has(label)) { + // Function, Class, Interface have isExported and description + query = ` + MATCH (n:\`${label}\`) + RETURN n.id AS id, n.name AS name, '${label}' AS label, + n.filePath AS filePath, n.content AS content, + n.startLine AS startLine, n.endLine AS endLine, + n.isExported AS isExported, n.description AS description `; } else { - // Code elements have startLine/endLine + // Multi-language tables (Struct, Enum, etc.) — have description but no isExported query = ` - MATCH (n:${label}) - RETURN n.id AS id, n.name AS name, '${label}' AS label, + MATCH (n:\`${label}\`) + RETURN n.id AS id, n.name AS name, '${label}' AS label, n.filePath AS filePath, n.content AS content, - n.startLine AS startLine, n.endLine AS endLine + n.startLine AS startLine, n.endLine AS endLine, + n.description AS description `; } const rows = await executeQuery(query); for (const row of rows) { + const hasExportedColumn = label === 'Method' || LABELS_WITH_EXPORTED.has(label); allNodes.push({ id: row.id ?? row[0], name: row.name ?? row[1], @@ -96,10 +124,17 @@ const queryEmbeddableNodes = async ( content: row.content ?? row[4] ?? '', startLine: row.startLine ?? row[5], endLine: row.endLine ?? row[6], + isExported: hasExportedColumn ? (row.isExported ?? row[7]) : undefined, + description: row.description ?? (hasExportedColumn ? row[8] : row[7]), + ...(label === 'Method' + ? { + parameterCount: row.parameterCount ?? row[9], + returnType: row.returnType ?? row[10], + } + : {}), }); } } catch (error) { - // Table might not exist or be empty, continue if (isDev) { console.warn(`Query for ${label} nodes failed:`, error); } @@ -110,32 +145,42 @@ const queryEmbeddableNodes = async ( }; /** - * Batch INSERT embeddings into separate CodeEmbedding table - * Using a separate lightweight table avoids copy-on-write overhead - * that occurs when UPDATEing nodes with large content fields + * Batch INSERT chunk-aware embeddings into CodeEmbedding table */ -const batchInsertEmbeddings = async ( +export const batchInsertEmbeddings = async ( executeWithReusedStatement: ( cypher: string, paramsList: Array>, ) => Promise, - updates: Array<{ id: string; embedding: number[]; contentHash: string }>, + updates: Array<{ + nodeId: string; + chunkIndex: number; + startLine: number; + endLine: number; + embedding: number[]; + contentHash?: string; + }>, ): Promise => { - // MERGE instead of CREATE — idempotent, handles concurrent analyzes and partial prior runs - const cypher = `MERGE (e:${EMBEDDING_TABLE_NAME} {nodeId: $nodeId}) SET e.embedding = $embedding, e.contentHash = $contentHash`; + const cypher = `CREATE (e:${EMBEDDING_TABLE_NAME} {id: $id, nodeId: $nodeId, chunkIndex: $chunkIndex, startLine: $startLine, endLine: $endLine, embedding: $embedding, contentHash: $contentHash})`; const paramsList = updates.map((u) => ({ - nodeId: u.id, + id: `${u.nodeId}:${u.chunkIndex}`, + nodeId: u.nodeId, + chunkIndex: u.chunkIndex, + startLine: u.startLine, + endLine: u.endLine, embedding: u.embedding, - contentHash: u.contentHash, + contentHash: u.contentHash ?? STALE_HASH_SENTINEL, })); await executeWithReusedStatement(cypher, paramsList); }; /** * Create the vector index for semantic search + * Now indexes the separate CodeEmbedding table. * Delegates extension loading to lbug-adapter's loadVectorExtension(), * which owns the VECTOR extension lifecycle and state tracking. + */ const createVectorIndex = async ( executeQuery: (cypher: string) => Promise, @@ -146,7 +191,6 @@ const createVectorIndex = async ( try { await executeQuery(CREATE_VECTOR_INDEX_QUERY); } catch (error) { - // Index might already exist if (isDev) { console.warn('Vector index creation warning:', error); } @@ -160,9 +204,12 @@ const createVectorIndex = async ( * @param executeWithReusedStatement - Function to execute with reused prepared statement * @param onProgress - Callback for progress updates * @param config - Optional configuration override + * @param skipNodeIds - Optional set of node IDs that already have embeddings (incremental mode) + * @param context - Optional repo/server context for metadata enrichment * @param existingEmbeddings - Optional map of nodeId → contentHash for incremental mode. * Nodes whose hash matches are skipped; nodes with a changed hash are DELETE'd * and re-embedded; nodes not in the map are embedded fresh. + */ export const runEmbeddingPipeline = async ( executeQuery: (cypher: string) => Promise, @@ -172,6 +219,8 @@ export const runEmbeddingPipeline = async ( ) => Promise, onProgress: EmbeddingProgressCallback, config: Partial = {}, + skipNodeIds?: Set, + context?: EmbeddingContext, existingEmbeddings?: Map, ): Promise => { const finalConfig = { ...DEFAULT_EMBEDDING_CONFIG, ...config }; @@ -208,6 +257,14 @@ export const runEmbeddingPipeline = async ( // Phase 2: Query embeddable nodes let nodes = await queryEmbeddableNodes(executeQuery); + // Apply context metadata + if (context?.repoName) { + for (const node of nodes) { + node.repoName = context.repoName; + node.serverName = context.serverName; + } + } + // Incremental mode: compare content hashes, delete stale rows, skip fresh ones. // Computed hashes for stale nodes are cached so batchInsertEmbeddings can reuse them // (avoids double computation). @@ -284,10 +341,12 @@ export const runEmbeddingPipeline = async ( return; } - // Phase 3: Batch embed nodes + // Phase 3: Chunk + embed nodes const batchSize = finalConfig.batchSize; - const totalBatches = Math.ceil(totalNodes / batchSize); + const chunkSize = finalConfig.chunkSize; + const overlap = finalConfig.overlap; let processedNodes = 0; + let totalChunks = 0; onProgress({ phase: 'embedding', @@ -295,40 +354,116 @@ export const runEmbeddingPipeline = async ( nodesProcessed: 0, totalNodes, currentBatch: 0, - totalBatches, + totalBatches: Math.ceil(totalNodes / batchSize), }); - for (let batchIndex = 0; batchIndex < totalBatches; batchIndex++) { - const start = batchIndex * batchSize; - const end = Math.min(start + batchSize, totalNodes); - const batch = nodes.slice(start, end); + // Process in batches of nodes + for (let batchIndex = 0; batchIndex < totalNodes; batchIndex += batchSize) { + const batch = nodes.slice(batchIndex, batchIndex + batchSize); - // Generate texts for this batch - const texts = generateBatchEmbeddingTexts(batch, finalConfig); + // Chunk each node and generate text + const allTexts: string[] = []; + const allUpdates: Array<{ + nodeId: string; + chunkIndex: number; + startLine: number; + endLine: number; + contentHash: string; + }> = []; - // Embed the batch - const embeddings = await embedBatch(texts); + for (const node of batch) { + const isShort = isShortLabel(node.label); + const startLine = node.startLine ?? 0; + const endLine = node.endLine ?? 0; - // Update LadybugDB with embeddings - const updates = batch.map((node, i) => ({ - id: node.id, - embedding: embeddingToArray(embeddings[i]), - contentHash: computedStaleHashes.get(node.id) ?? contentHashForNode(node, finalConfig), - })); + // Extract structural names for class-like nodes via AST extractors + if (!isShort && STRUCTURAL_LABELS.has(node.label)) { + try { + const names = await extractStructuralNames(node.content, node.filePath); + node.methodNames = names.methodNames; + node.fieldNames = names.fieldNames; + } catch { + // AST extraction failed — names stay undefined, text-generator handles gracefully + } + } - await batchInsertEmbeddings(executeWithReusedStatement, updates); + // Compute content hash once per node (re-use cached value for stale nodes) + const hash = computedStaleHashes.get(node.id) ?? contentHashForNode(node, finalConfig); + + let chunks: Array<{ text: string; chunkIndex: number; startLine: number; endLine: number }>; + if (isShort) { + chunks = [{ text: node.content, chunkIndex: 0, startLine, endLine }]; + } else { + try { + chunks = await chunkNode( + node.label, + node.content, + node.filePath, + startLine, + endLine, + chunkSize, + overlap, + ); + } catch (chunkErr) { + if (isDev) { + console.warn( + `⚠️ AST chunking failed for ${node.label} "${node.name}" (${node.filePath}), falling back to character-based chunking:`, + chunkErr, + ); + } + chunks = characterChunk(node.content, startLine, endLine, chunkSize, overlap); + } + } + + for (const chunk of chunks) { + const text = generateEmbeddingText(node, chunk.text, finalConfig); + allTexts.push(text); + allUpdates.push({ + nodeId: node.id, + chunkIndex: chunk.chunkIndex, + startLine: chunk.startLine, + endLine: chunk.endLine, + contentHash: hash, + }); + } + } + + // Embed chunk texts in sub-batches to control memory + const EMBED_SUB_BATCH = 8; + for (let si = 0; si < allTexts.length; si += EMBED_SUB_BATCH) { + const subTexts = allTexts.slice(si, si + EMBED_SUB_BATCH); + const subUpdates = allUpdates.slice(si, si + EMBED_SUB_BATCH); + + let embeddings: Float32Array[]; + try { + embeddings = await embedBatch(subTexts); + } catch (embedErr) { + console.error( + `❌ embedBatch failed for ${subTexts.length} texts (first: "${subTexts[0]?.substring(0, 80)}..."):`, + embedErr, + ); + throw embedErr; + } + + const dbUpdates = subUpdates.map((u, i) => ({ + ...u, + embedding: embeddingToArray(embeddings[i]), + })); + + await batchInsertEmbeddings(executeWithReusedStatement, dbUpdates); + } processedNodes += batch.length; + totalChunks += allUpdates.length; - // Report progress (20-90% for embedding phase) const embeddingProgress = 20 + (processedNodes / totalNodes) * 70; onProgress({ phase: 'embedding', percent: Math.round(embeddingProgress), nodesProcessed: processedNodes, totalNodes, - currentBatch: batchIndex + 1, - totalBatches, + currentBatch: Math.floor(batchIndex / batchSize) + 1, + totalBatches: Math.ceil(totalNodes / batchSize), }); } @@ -346,7 +481,6 @@ export const runEmbeddingPipeline = async ( await createVectorIndex(executeQuery); - // Complete onProgress({ phase: 'ready', percent: 100, @@ -355,7 +489,9 @@ export const runEmbeddingPipeline = async ( }); if (isDev) { - console.log('✅ Embedding pipeline complete!'); + console.log( + `✅ Embedding pipeline complete! (${totalChunks} chunks from ${totalNodes} nodes)`, + ); } } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; @@ -375,15 +511,7 @@ export const runEmbeddingPipeline = async ( }; /** - * Perform semantic search using the vector index - * - * Uses CodeEmbedding table and queries each node table to get metadata - * - * @param executeQuery - Function to execute Cypher queries - * @param query - Search query text - * @param k - Number of results to return (default: 10) - * @param maxDistance - Maximum distance threshold (default: 0.5) - * @returns Array of search results ordered by relevance + * Perform semantic search using the vector index with chunk deduplication */ export const semanticSearch = async ( executeQuery: (cypher: string) => Promise, @@ -395,37 +523,46 @@ export const semanticSearch = async ( throw new Error('Embedding model not initialized. Run embedding pipeline first.'); } - // Embed the query const queryEmbedding = await embedText(query); const queryVec = embeddingToArray(queryEmbedding); const queryVecStr = `[${queryVec.join(',')}]`; - // Query the vector index on CodeEmbedding to get nodeIds and distances - const vectorQuery = ` - CALL QUERY_VECTOR_INDEX('${EMBEDDING_TABLE_NAME}', '${EMBEDDING_INDEX_NAME}', - CAST(${queryVecStr} AS FLOAT[${queryVec.length}]), ${k}) - YIELD node AS emb, distance - WITH emb, distance - WHERE distance < ${maxDistance} - RETURN emb.nodeId AS nodeId, distance - ORDER BY distance - `; + const bestChunks = await collectBestChunks(k, async (fetchLimit) => { + const vectorQuery = ` + CALL QUERY_VECTOR_INDEX('${EMBEDDING_TABLE_NAME}', '${EMBEDDING_INDEX_NAME}', + CAST(${queryVecStr} AS FLOAT[${queryVec.length}]), ${fetchLimit}) + YIELD node AS emb, distance + WITH emb, distance + WHERE distance < ${maxDistance} + RETURN emb.nodeId AS nodeId, emb.chunkIndex AS chunkIndex, + emb.startLine AS startLine, emb.endLine AS endLine, distance + ORDER BY distance + `; - const embResults = await executeQuery(vectorQuery); + const embResults = await executeQuery(vectorQuery); + return embResults.map((row) => ({ + nodeId: row.nodeId ?? row[0], + chunkIndex: row.chunkIndex ?? row[1] ?? 0, + startLine: row.startLine ?? row[2] ?? 0, + endLine: row.endLine ?? row[3] ?? 0, + distance: row.distance ?? row[4], + })); + }); - if (embResults.length === 0) { + if (bestChunks.size === 0) { return []; } // Group results by label for batched metadata queries - const byLabel = new Map>(); - for (const embRow of embResults) { - const nodeId = embRow.nodeId ?? embRow[0]; - const distance = embRow.distance ?? embRow[1]; + const byLabel = new Map< + string, + Array<{ nodeId: string; distance: number } & Record> + >(); + for (const [nodeId, chunk] of Array.from(bestChunks.entries()).slice(0, k)) { const labelEndIdx = nodeId.indexOf(':'); const label = labelEndIdx > 0 ? nodeId.substring(0, labelEndIdx) : 'Unknown'; if (!byLabel.has(label)) byLabel.set(label, []); - byLabel.get(label)!.push({ nodeId, distance }); + byLabel.get(label)!.push({ nodeId, ...chunk }); } // Batch-fetch metadata per label @@ -434,19 +571,11 @@ export const semanticSearch = async ( for (const [label, items] of byLabel) { const idList = items.map((i) => `'${i.nodeId.replace(/'/g, "''")}'`).join(', '); try { - let nodeQuery: string; - if (label === 'File') { - nodeQuery = ` - MATCH (n:File) WHERE n.id IN [${idList}] - RETURN n.id AS id, n.name AS name, n.filePath AS filePath - `; - } else { - nodeQuery = ` - MATCH (n:${label}) WHERE n.id IN [${idList}] - RETURN n.id AS id, n.name AS name, n.filePath AS filePath, - n.startLine AS startLine, n.endLine AS endLine - `; - } + const nodeQuery = ` + MATCH (n:\`${label}\`) WHERE n.id IN [${idList}] + RETURN n.id AS id, n.name AS name, n.filePath AS filePath, + n.startLine AS startLine, n.endLine AS endLine + `; const nodeRows = await executeQuery(nodeQuery); const rowMap = new Map(); for (const row of nodeRows) { @@ -462,8 +591,8 @@ export const semanticSearch = async ( label, filePath: nodeRow.filePath ?? nodeRow[2] ?? '', distance: item.distance, - startLine: label !== 'File' ? (nodeRow.startLine ?? nodeRow[3]) : undefined, - endLine: label !== 'File' ? (nodeRow.endLine ?? nodeRow[4]) : undefined, + startLine: item.startLine, + endLine: item.endLine, }); } } @@ -472,7 +601,6 @@ export const semanticSearch = async ( } } - // Re-sort by distance since batch queries may have mixed order results.sort((a, b) => a.distance - b.distance); return results; @@ -480,16 +608,6 @@ export const semanticSearch = async ( /** * Semantic search with graph expansion (flattened results) - * - * Note: With multi-table schema, graph traversal is simplified. - * Returns semantic matches with their metadata. - * For full graph traversal, use execute_vector_cypher tool directly. - * - * @param executeQuery - Function to execute Cypher queries - * @param query - Search query text - * @param k - Number of initial semantic matches (default: 5) - * @param _hops - Unused (kept for API compatibility). - * @returns Semantic matches with metadata */ export const semanticSearchWithContext = async ( executeQuery: (cypher: string) => Promise, @@ -497,8 +615,6 @@ export const semanticSearchWithContext = async ( k: number = 5, _hops: number = 1, ): Promise => { - // For multi-table schema, just return semantic search results - // Graph traversal is complex with separate tables - use execute_vector_cypher instead const results = await semanticSearch(executeQuery, query, k, 0.5); return results.map((r) => ({ diff --git a/gitnexus/src/core/embeddings/line-index.ts b/gitnexus/src/core/embeddings/line-index.ts new file mode 100644 index 000000000..2dc12de3d --- /dev/null +++ b/gitnexus/src/core/embeddings/line-index.ts @@ -0,0 +1,50 @@ +export interface ResolvedLineRange { + startLine: number; + endLine: number; +} + +export const buildLineIndex = (content: string): Int32Array => { + const offsets: number[] = [0]; + for (let i = 0; i < content.length; i++) { + if (content.charCodeAt(i) === 10) offsets.push(i + 1); + } + return new Int32Array(offsets); +}; + +const clampOffset = (lineOffsets: Int32Array, charOffset: number): number => { + if (lineOffsets.length === 0) return 0; + const maxOffset = lineOffsets[lineOffsets.length - 1]; + if (charOffset < 0) return 0; + if (charOffset > maxOffset) return maxOffset; + return charOffset; +}; + +export const lineFromOffset = (lineOffsets: Int32Array, charOffset: number): number => { + if (lineOffsets.length === 0) return 0; + + const clamped = clampOffset(lineOffsets, charOffset); + let lo = 0; + let hi = lineOffsets.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (lineOffsets[mid] <= clamped) lo = mid; + else hi = mid - 1; + } + return lo; +}; + +export const resolveChunkLines = ( + lineOffsets: Int32Array, + startOffset: number, + endOffset: number, + baseStartLine: number, +): ResolvedLineRange => { + const relativeStartLine = lineFromOffset(lineOffsets, startOffset); + const effectiveEndOffset = endOffset > startOffset ? endOffset - 1 : startOffset; + const relativeEndLine = lineFromOffset(lineOffsets, effectiveEndOffset); + + return { + startLine: baseStartLine + relativeStartLine, + endLine: baseStartLine + relativeEndLine, + }; +}; diff --git a/gitnexus/src/core/embeddings/server-mapping.ts b/gitnexus/src/core/embeddings/server-mapping.ts new file mode 100644 index 000000000..fa685ac8f --- /dev/null +++ b/gitnexus/src/core/embeddings/server-mapping.ts @@ -0,0 +1,37 @@ +/** + * Server Mapping Configuration + * + * Reads ~/.gitnexus/server-mapping.json to map repo names to service names. + * Used in embedding text to enrich metadata with microservice context. + */ + +import fs from 'fs/promises'; +import path from 'path'; +import os from 'os'; + +const MAPPING_FILE = path.join(os.homedir(), '.gitnexus', 'server-mapping.json'); + +let cachedMapping: Record | null = null; + +/** + * Read the server mapping file and return the serverName for a given repoName. + * Returns undefined if no mapping exists. + */ +export const readServerMapping = async (repoName: string): Promise => { + try { + if (!cachedMapping) { + const raw = await fs.readFile(MAPPING_FILE, 'utf-8'); + cachedMapping = JSON.parse(raw); + } + return cachedMapping[repoName]; + } catch { + return undefined; + } +}; + +/** + * Clear the cached mapping (useful for testing or after file changes) + */ +export const clearServerMappingCache = (): void => { + cachedMapping = null; +}; diff --git a/gitnexus/src/core/embeddings/structural-extractor.ts b/gitnexus/src/core/embeddings/structural-extractor.ts new file mode 100644 index 000000000..7035ee569 --- /dev/null +++ b/gitnexus/src/core/embeddings/structural-extractor.ts @@ -0,0 +1,89 @@ +/** + * Structural Extractor Module + * + * Reuses ingestion pipeline's AST-based MethodExtractor / FieldExtractor + * to extract method and field names for embedding text generation. + */ + +import { getProviderForFile } from '../ingestion/languages/index.js'; +import type { MethodExtractorContext, ExtractedMethods } from '../ingestion/method-types.js'; +import type { FieldExtractorContext, ExtractedFields } from '../ingestion/field-types.js'; +import type { LanguageProvider } from '../ingestion/language-provider.js'; +import { buildTypeEnv } from '../ingestion/type-env.js'; +import { SupportedLanguages } from 'gitnexus-shared'; +import { ensureAndParse, findDeclarationNode } from './ast-utils.js'; + +export interface StructuralNames { + methodNames: string[]; + fieldNames: string[]; +} + +const NOOP_SYMBOL_TABLE = { + lookupExactAll: () => [], + lookupExact: () => undefined, + lookupExactFull: () => undefined, +} as any; + +/** + * Extract method and field names from a class/struct/interface node + * using the ingestion pipeline's AST extractors. + */ +export const extractStructuralNames = async ( + content: string, + filePath: string, +): Promise => { + const provider = getProviderForFile(filePath); + if (!provider) return { methodNames: [], fieldNames: [] }; + + const tree = await ensureAndParse(content, filePath); + if (!tree) return { methodNames: [], fieldNames: [] }; + + // Parse node.content (a snippet) — find declaration directly, not by range + const classNode = findDeclarationNode(tree.rootNode); + if (!classNode) return { methodNames: [], fieldNames: [] }; + + const language = provider.id; + + const methodNames = extractMethodNames(classNode, provider, filePath, language); + const fieldNames = extractFieldNames(classNode, provider, tree, filePath, language); + + return { methodNames, fieldNames }; +}; + +function extractMethodNames( + classNode: any, + provider: LanguageProvider, + filePath: string, + language: SupportedLanguages, +): string[] { + if (!provider.methodExtractor) return []; + + const context: MethodExtractorContext = { filePath, language }; + const result: ExtractedMethods | null = provider.methodExtractor.extract(classNode, context); + if (!result?.methods?.length) return []; + + return result.methods.map((m) => m.name); +} + +function extractFieldNames( + classNode: any, + provider: LanguageProvider, + tree: any, + filePath: string, + language: SupportedLanguages, +): string[] { + if (!provider.fieldExtractor) return []; + + const typeEnv = buildTypeEnv(tree, language); + + const context: FieldExtractorContext = { + typeEnv, + symbolTable: NOOP_SYMBOL_TABLE, + filePath, + language, + }; + const result: ExtractedFields | null = provider.fieldExtractor.extract(classNode, context); + if (!result?.fields?.length) return []; + + return result.fields.map((f) => f.name); +} diff --git a/gitnexus/src/core/embeddings/text-generator.ts b/gitnexus/src/core/embeddings/text-generator.ts index d74a9f894..5b96f6b5e 100644 --- a/gitnexus/src/core/embeddings/text-generator.ts +++ b/gitnexus/src/core/embeddings/text-generator.ts @@ -1,206 +1,253 @@ /** * Text Generator Module * - * Pure functions to generate embedding text from code nodes. - * Combines node metadata with code snippets for semantic matching. + * Generates enriched embedding text from code nodes with metadata. + * Supports chunkable labels (Function/Method with AST chunking), + * Class-specific structural text, and short-node direct embed. + * + * Method/field names for Class nodes are extracted by the ingestion + * pipeline's AST extractors and passed via node.methodNames/node.fieldNames. */ import type { EmbeddableNode, EmbeddingConfig } from './types.js'; -import { DEFAULT_EMBEDDING_CONFIG } from './types.js'; +import { DEFAULT_EMBEDDING_CONFIG, isShortLabel } from './types.js'; /** - * Extract the filename from a file path + * Truncate description to max length at sentence/word boundary */ -const getFileName = (filePath: string): string => { - const parts = filePath.split('/'); - return parts[parts.length - 1] || filePath; -}; +const truncateDescription = (text: string, maxLength: number): string => { + if (text.length <= maxLength) return text; -/** - * Extract the directory path from a file path - */ -const getDirectory = (filePath: string): string => { - const parts = filePath.split('/'); - parts.pop(); - return parts.join('/') || ''; -}; + const truncated = text.slice(0, maxLength); -/** - * Truncate content to max length, preserving word boundaries - */ -const truncateContent = (content: string, maxLength: number): string => { - if (content.length <= maxLength) { - return content; + // Try sentence boundary (. ! ?) + const sentenceEnd = Math.max( + truncated.lastIndexOf('. '), + truncated.lastIndexOf('! '), + truncated.lastIndexOf('? '), + ); + if (sentenceEnd > maxLength * 0.5) { + return truncated.slice(0, sentenceEnd + 1); } - // Find last space before maxLength to avoid cutting words - const truncated = content.slice(0, maxLength); + // Try word boundary const lastSpace = truncated.lastIndexOf(' '); - - if (lastSpace > maxLength * 0.8) { - return truncated.slice(0, lastSpace) + '...'; + if (lastSpace > maxLength * 0.5) { + return truncated.slice(0, lastSpace); } - return truncated + '...'; + return truncated; }; /** * Clean code content for embedding - * Removes excessive whitespace while preserving structure */ const cleanContent = (content: string): string => { - return ( - content - // Normalize line endings - .replace(/\r\n/g, '\n') - // Remove excessive blank lines (more than 2) - .replace(/\n{3,}/g, '\n\n') - // Trim each line - .split('\n') - .map((line) => line.trimEnd()) - .join('\n') - .trim() - ); + return content + .replace(/\r\n/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .split('\n') + .map((line) => line.trimEnd()) + .join('\n') + .trim(); }; /** - * Generate embedding text for a Function node + * Build metadata header for a node */ -const generateFunctionText = (node: EmbeddableNode, maxSnippetLength: number): string => { - const parts: string[] = [`Function: ${node.name}`, `File: ${getFileName(node.filePath)}`]; +const buildMetadataHeader = (node: EmbeddableNode, config: Partial): string => { + const parts: string[] = []; - const dir = getDirectory(node.filePath); - if (dir) { - parts.push(`Directory: ${dir}`); + // Label + name + parts.push(`${node.label}: ${node.name}`); + + // Repo name + if (node.repoName) { + parts.push(`Repo: ${node.repoName}`); } - if (node.content) { - const cleanedContent = cleanContent(node.content); - const snippet = truncateContent(cleanedContent, maxSnippetLength); - parts.push('', snippet); + // Server name (optional) + if (node.serverName) { + parts.push(`Server: ${node.serverName}`); + } + + // Full file path + parts.push(`Path: ${node.filePath}`); + + // Export status + if (node.isExported !== undefined) { + parts.push(`Export: ${node.isExported}`); + } + + // Description (truncated) + if (node.description) { + const maxLen = config.maxDescriptionLength ?? DEFAULT_EMBEDDING_CONFIG.maxDescriptionLength; + const truncated = truncateDescription(node.description, maxLen); + if (truncated) { + parts.push(truncated); + } } return parts.join('\n'); }; -/** - * Generate embedding text for a Class node - */ -const generateClassText = (node: EmbeddableNode, maxSnippetLength: number): string => { - const parts: string[] = [`Class: ${node.name}`, `File: ${getFileName(node.filePath)}`]; +const generateCodeBodyText = ( + node: EmbeddableNode, + codeBody: string, + config: Partial, +): string => { + const header = buildMetadataHeader(node, config); + const cleaned = cleanContent(codeBody); + return `${header}\n\n${cleaned}`; +}; - const dir = getDirectory(node.filePath); - if (dir) { - parts.push(`Directory: ${dir}`); +/** + * Generate embedding text for Class nodes + * Signature + properties + method name list only (no method bodies) + * Method/field names come from AST extractors via node.methodNames/node.fieldNames. + */ +const generateClassText = ( + node: EmbeddableNode, + codeBody: string, + config: Partial, +): string => { + return generateStructuralTypeText(node, codeBody, config); +}; + +const generateStructuralTypeText = ( + node: EmbeddableNode, + codeBody: string, + config: Partial, +): string => { + const header = buildMetadataHeader(node, config); + const parts: string[] = [header]; + + if (node.methodNames?.length) { + parts.push(`Methods: ${node.methodNames.join(', ')}`); + } + if (node.fieldNames?.length) { + parts.push(`Properties: ${node.fieldNames.join(', ')}`); } - if (node.content) { - const cleanedContent = cleanContent(node.content); - const snippet = truncateContent(cleanedContent, maxSnippetLength); - parts.push('', snippet); + const declarationOnly = extractDeclarationOnly(cleanContent(node.content)); + if (declarationOnly) { + parts.push('', declarationOnly); + } + + const cleanedChunk = cleanContent(codeBody); + if (cleanedChunk && cleanedChunk !== cleanContent(node.content)) { + parts.push('', cleanedChunk); } return parts.join('\n'); }; -/** - * Generate embedding text for a Method node - */ -const generateMethodText = (node: EmbeddableNode, maxSnippetLength: number): string => { - const parts: string[] = [`Method: ${node.name}`, `File: ${getFileName(node.filePath)}`]; - - const dir = getDirectory(node.filePath); - if (dir) { - parts.push(`Directory: ${dir}`); - } - - if (node.content) { - const cleanedContent = cleanContent(node.content); - const snippet = truncateContent(cleanedContent, maxSnippetLength); - parts.push('', snippet); - } - - return parts.join('\n'); -}; +const DECL_START_RE = + /^(?:(?:export|pub|data|abstract)\s+)*(?:type\s+\w+\s+struct|(?:class|struct|enum|interface)\s)/; /** - * Generate embedding text for an Interface node + * Extract class/interface/struct declaration lines, skipping method bodies. + * - Brace-based languages: detects method signatures (lines with `(` and `{`) + * and skips until depth returns to class body level. + * - Non-brace languages (Python/Ruby): returns empty string (patterns handle extraction). */ -const generateInterfaceText = (node: EmbeddableNode, maxSnippetLength: number): string => { - const parts: string[] = [`Interface: ${node.name}`, `File: ${getFileName(node.filePath)}`]; +export const extractDeclarationOnly = (content: string): string => { + const lines = content.split('\n'); + const declLines: string[] = []; + let depth = 0; + let started = false; + let classDepth = 0; + let skipDepth = 0; - const dir = getDirectory(node.filePath); - if (dir) { - parts.push(`Directory: ${dir}`); + for (const [idx, line] of lines.entries()) { + const trimmed = line.trim(); + + if (!started) { + if (DECL_START_RE.test(trimmed)) { + // Non-brace language check: current line or next 3 lines must have `{` + const nextLines = lines.slice(idx + 1, idx + 4); + if (!trimmed.includes('{') && !nextLines.some((l) => l.includes('{'))) { + return ''; + } + started = true; + declLines.push(trimmed); + for (const ch of trimmed) { + if (ch === '{') depth++; + else if (ch === '}') depth--; + } + if (depth > 0) classDepth = depth; + } + continue; + } + + // Always update depth (even when skipping) + const opens = (trimmed.match(/{/g) || []).length; + const closes = (trimmed.match(/}/g) || []).length; + const prevDepth = depth; + depth += opens - closes; + + if (skipDepth > 0) { + if (depth <= classDepth) { + skipDepth = 0; + // Closing brace of class + if (depth <= 0) { + declLines.push(trimmed); + break; + } + } + continue; + } + + // Detect method signature: line has both `(` and `{` and goes deeper than class body + const hasParens = trimmed.includes('('); + const hasOpenBrace = opens > 0; + if (hasParens && hasOpenBrace && prevDepth + opens > classDepth) { + if (opens === closes && trimmed.endsWith(';')) { + // Property with function/object initializer like `config = { timeout: 5000 };` — keep + declLines.push(trimmed); + } + // else: single-line or multi-line method — skip entirely + if (opens !== closes) { + skipDepth = classDepth; + } + continue; + } + + declLines.push(trimmed); + + if (depth <= 0 && declLines.length > 1) break; } - if (node.content) { - const cleanedContent = cleanContent(node.content); - const snippet = truncateContent(cleanedContent, maxSnippetLength); - parts.push('', snippet); - } - - return parts.join('\n'); -}; - -/** - * Generate embedding text for a File node - * Uses file name and first N characters of content - */ -const generateFileText = (node: EmbeddableNode, maxSnippetLength: number): string => { - const parts: string[] = [`File: ${node.name}`, `Path: ${node.filePath}`]; - - if (node.content) { - const cleanedContent = cleanContent(node.content); - // For files, use a shorter snippet since they can be very long - const snippet = truncateContent(cleanedContent, Math.min(maxSnippetLength, 300)); - parts.push('', snippet); - } - - return parts.join('\n'); + return declLines.join('\n').trim(); }; /** * Generate embedding text for any embeddable node * Dispatches to the appropriate generator based on node label - * - * @param node - The node to generate text for - * @param config - Optional configuration for max snippet length - * @returns Text suitable for embedding */ export const generateEmbeddingText = ( node: EmbeddableNode, + codeBody: string, config: Partial = {}, ): string => { - const maxSnippetLength = config.maxSnippetLength ?? DEFAULT_EMBEDDING_CONFIG.maxSnippetLength; - - switch (node.label) { - case 'Function': - return generateFunctionText(node, maxSnippetLength); - case 'Class': - return generateClassText(node, maxSnippetLength); - case 'Method': - return generateMethodText(node, maxSnippetLength); - case 'Interface': - return generateInterfaceText(node, maxSnippetLength); - case 'File': - return generateFileText(node, maxSnippetLength); - default: - // Fallback for any other embeddable type - return `${node.label}: ${node.name}\nPath: ${node.filePath}`; + if (isShortLabel(node.label)) { + const header = buildMetadataHeader(node, config); + const cleaned = cleanContent(node.content); + return `${header}\n\n${cleaned}`; } + + if (node.label === 'Class') { + return generateClassText(node, codeBody, config); + } + + if (node.label === 'Interface') { + return generateStructuralTypeText(node, codeBody, config); + } + + return generateCodeBodyText(node, codeBody, config); }; /** - * Generate embedding texts for a batch of nodes - * - * @param nodes - Array of nodes to generate text for - * @param config - Optional configuration - * @returns Array of texts in the same order as input nodes + * Export truncation helper for testing */ -export const generateBatchEmbeddingTexts = ( - nodes: EmbeddableNode[], - config: Partial = {}, -): string[] => { - return nodes.map((node) => generateEmbeddingText(node, config)); -}; +export { truncateDescription }; diff --git a/gitnexus/src/core/embeddings/types.ts b/gitnexus/src/core/embeddings/types.ts index 599faa3a9..c24dcdf40 100644 --- a/gitnexus/src/core/embeddings/types.ts +++ b/gitnexus/src/core/embeddings/types.ts @@ -5,10 +5,40 @@ */ /** - * Node labels that should be embedded for semantic search - * These are code elements that benefit from semantic matching + * Node labels that need chunking (have code body, potentially long) */ -export const EMBEDDABLE_LABELS = ['Function', 'Class', 'Method', 'Interface', 'File'] as const; +export const CHUNKABLE_LABELS = [ + 'Function', + 'Method', + 'Constructor', + 'Class', + 'Interface', + 'Struct', + 'Enum', + 'Trait', + 'Impl', + 'Macro', + 'Namespace', +] as const; + +/** + * Node labels that are short (no chunking needed, embed directly) + */ +export const SHORT_LABELS = [ + 'TypeAlias', + 'Typedef', + 'Const', + 'Property', + 'Record', + 'Union', + 'Static', + 'Variable', +] as const; + +/** + * All embeddable labels (union of CHUNKABLE + SHORT) + */ +export const EMBEDDABLE_LABELS = [...CHUNKABLE_LABELS, ...SHORT_LABELS] as const; export type EmbeddableLabel = (typeof EMBEDDABLE_LABELS)[number]; @@ -18,6 +48,39 @@ export type EmbeddableLabel = (typeof EMBEDDABLE_LABELS)[number]; export const isEmbeddableLabel = (label: string): label is EmbeddableLabel => EMBEDDABLE_LABELS.includes(label as EmbeddableLabel); +/** + * Check if a label needs chunking + */ +export const isChunkableLabel = (label: string): boolean => + (CHUNKABLE_LABELS as readonly string[]).includes(label); + +/** + * Check if a label is a short type (no chunking) + */ +export const isShortLabel = (label: string): boolean => + (SHORT_LABELS as readonly string[]).includes(label); + +/** + * Node labels that have structural names (methods/fields) extractable via AST + */ +export const STRUCTURAL_LABELS: ReadonlySet = new Set([ + 'Class', + 'Struct', + 'Interface', + 'Enum', +]); + +/** + * Node labels that have isExported column in their schema + */ +export const LABELS_WITH_EXPORTED = new Set([ + 'Function', + 'Class', + 'Interface', + 'Method', + 'CodeElement', +]) as ReadonlySet; + /** * Embedding pipeline phases */ @@ -57,6 +120,12 @@ export interface EmbeddingConfig { device: 'auto' | 'dml' | 'cuda' | 'cpu' | 'wasm'; /** Maximum characters of code snippet to include */ maxSnippetLength: number; + /** Maximum code chunk size in characters (for chunking long code) */ + chunkSize: number; + /** Overlap between chunks in characters */ + overlap: number; + /** Maximum description length in characters */ + maxDescriptionLength: number; } /** @@ -70,6 +139,9 @@ export const DEFAULT_EMBEDDING_CONFIG: EmbeddingConfig = { dimensions: 384, device: 'auto', maxSnippetLength: 500, + chunkSize: 1200, + overlap: 120, + maxDescriptionLength: 150, }; /** @@ -96,6 +168,34 @@ export interface EmbeddableNode { content: string; startLine?: number; endLine?: number; + isExported?: boolean; + description?: string; + parameterCount?: number; + returnType?: string; + repoName?: string; + serverName?: string; + methodNames?: string[]; + fieldNames?: string[]; +} + +/** + * Cached embedding entry restored from LadybugDB before a graph rebuild + */ +export interface CachedEmbedding { + nodeId: string; + chunkIndex: number; + startLine: number; + endLine: number; + embedding: number[]; + contentHash?: string; +} + +/** + * Context info for embedding pipeline (repo/server metadata enrichment) + */ +export interface EmbeddingContext { + repoName?: string; + serverName?: string; } /** @@ -108,3 +208,75 @@ export interface ModelProgress { loaded?: number; total?: number; } + +export interface ChunkSearchRow { + nodeId: string; + chunkIndex: number; + startLine: number; + endLine: number; + distance: number; +} + +export interface BestChunkMatch { + chunkIndex: number; + startLine: number; + endLine: number; + distance: number; +} + +/** + * Deduplicate vector search chunk results by nodeId, + * keeping the chunk with smallest distance for each node. + */ +export const dedupBestChunks = ( + rows: ChunkSearchRow[], + limit?: number, +): Map => { + const best = new Map(); + for (const row of rows) { + const existing = best.get(row.nodeId); + if (!existing || row.distance < existing.distance) { + best.set(row.nodeId, { + chunkIndex: row.chunkIndex, + startLine: row.startLine, + endLine: row.endLine, + distance: row.distance, + }); + } + if (limit !== undefined && best.size >= limit) break; + } + return best; +}; + +const DEFAULT_FETCH_MULTIPLIER = 4; +const DEFAULT_FETCH_BUFFER = 8; +const DEFAULT_MAX_FETCH = 200; + +/** + * Fetch vector-search chunks until we have enough unique nodeIds + * or can tell the result set is exhausted. + */ +export const collectBestChunks = async ( + limit: number, + fetchRows: (fetchLimit: number) => Promise, + maxFetch: number = DEFAULT_MAX_FETCH, +): Promise> => { + if (limit <= 0) return new Map(); + + let fetchLimit = Math.max(limit * DEFAULT_FETCH_MULTIPLIER, limit + DEFAULT_FETCH_BUFFER); + let previousFetchLimit = 0; + + while (fetchLimit > previousFetchLimit) { + const rows = await fetchRows(fetchLimit); + const bestChunks = dedupBestChunks(rows, limit); + + if (bestChunks.size >= limit || rows.length < fetchLimit) { + return bestChunks; + } + + previousFetchLimit = fetchLimit; + fetchLimit = fetchLimit >= maxFetch ? fetchLimit * 2 : Math.min(maxFetch, fetchLimit * 2); + } + + return new Map(); +}; 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/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..7d8a9da27 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -1,7 +1,34 @@ 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 } from 'gitnexus-shared'; +import type { SymbolTableReader, HeritageMap, ExtractedHeritage } from './model/index.js'; +import { CLASS_TYPES, CALL_TARGET_TYPES, lookupMethodByOwnerWithMRO } from './model/index.js'; +import type { DispatchDecision, ReceiverEnriched } from './call-types.js'; + +/** Shorthand for the receiver-source discriminant shared across the DAG. */ +type ReceiverSource = ReceiverEnriched['receiverSource']; + +/** + * DAG stage 4 fallback: used when `selectDispatch` is absent or returns null. + * Preserves pre-DAG dispatch semantics: + * - 'constructor' → constructor branch + * - 'free' → free branch (admits Swift/Kotlin class-target fast path) + * - 'member' or undefined → owner-scoped branch + * + * `undefined` callForm MUST route through owner-scoped (not free) so bare + * identifiers without a classified shape do NOT trigger `resolveFreeCall`'s + * class-target fast path. Without a `receiverTypeName`, the owner-scoped + * branch falls through to `resolveModuleAliasedCall` + `singleCandidate`, + * matching legacy behavior where non-callable symbols (Class, Interface) + * null-route instead of producing spurious Constructor edges. + */ +const defaultDispatchDecision = ( + callForm: 'free' | 'member' | 'constructor' | undefined, +): DispatchDecision => { + if (callForm === 'constructor') return { primary: 'constructor' }; + if (callForm === 'free') return { primary: 'free' }; + return { primary: 'owner-scoped' }; +}; 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 +59,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,14 +68,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 { extractParsedCallSite } from './call-sites/extract-language-call-site.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. */ @@ -765,22 +788,26 @@ export const processCalls = async ( // Extract heritage from query matches to build parentMap for buildTypeEnv. // Heritage-processor runs in PARALLEL, so graph edges don't exist when buildTypeEnv runs. const fileParentMap = new Map(); - for (const match of matches) { - const captureMap: Record = {}; - match.captures.forEach((c) => (captureMap[c.name] = c.node)); - if (captureMap['heritage.class'] && captureMap['heritage.extends']) { - const className: string = captureMap['heritage.class'].text; - const parentName: string = captureMap['heritage.extends'].text; - const extendsNode = captureMap['heritage.extends']; - const fieldDecl = extendsNode.parent; - if (fieldDecl?.type === 'field_declaration' && fieldDecl.childForFieldName('name')) - continue; - let parents = fileParentMap.get(className); - if (!parents) { - parents = []; - fileParentMap.set(className, parents); + if (provider.heritageExtractor) { + for (const match of matches) { + const captureMap: Record = {}; + match.captures.forEach((c) => (captureMap[c.name] = c.node)); + if (captureMap['heritage.class']) { + const heritageItems = provider.heritageExtractor.extract(captureMap, { + filePath: file.path, + language, + }); + for (const item of heritageItems) { + if (item.kind === 'extends') { + let parents = fileParentMap.get(item.className); + if (!parents) { + parents = []; + fileParentMap.set(item.className, parents); + } + if (!parents.includes(item.parentName)) parents.push(item.parentName); + } + } } - if (!parents.includes(parentName)) parents.push(parentName); } } const parentMap: ReadonlyMap = fileParentMap; @@ -910,74 +937,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']; @@ -985,6 +1017,28 @@ export const processCalls = async ( const calledName = nameNode.text; + // Check heritage extractor for call-based heritage (e.g., Ruby include/extend/prepend) + if (provider.heritageExtractor?.extractFromCall) { + const heritageItems = provider.heritageExtractor.extractFromCall( + calledName, + captureMap['call'], + { filePath: file.path, language }, + ); + if (heritageItems !== null) { + for (const item of heritageItems) { + collectedHeritage.push({ + filePath: file.path, + className: item.className, + parentName: item.parentName, + kind: item.kind, + }); + } + return; + } + } + + // Dispatch: route language-specific calls (properties, imports) + // Heritage routing is handled by heritageExtractor.extractFromCall above. const routed = callRouter?.(calledName, captureMap['call']); if (routed) { switch (routed.kind) { @@ -992,17 +1046,6 @@ export const processCalls = async ( case 'import': return; - case 'heritage': - for (const item of routed.items) { - collectedHeritage.push({ - filePath: file.path, - className: item.enclosingClass, - parentName: item.mixinName, - kind: item.heritageKind, - }); - } - return; - case 'properties': { const fileId = generateId('File', file.path); const propEnclosingClassId = findEnclosingClassId(captureMap['call'], file.path); @@ -1055,10 +1098,17 @@ export const processCalls = async ( if (provider.isBuiltInName(calledName)) return; - const callForm = inferCallForm(callNode, nameNode); - const receiverName = callForm === 'member' ? extractReceiverName(nameNode) : undefined; + // --- DAG stage 2-3: classify-form + infer-receiver (shared defaults) --- + // These stages run the shared inference chain. Language providers can + // customize infer-receiver (stage 3) via the inferImplicitReceiver hook + // which runs AFTER this default chain (typed-binding → constructor-map → + // module-alias → class-as-receiver → mixed-chain), and selectDispatch + // (stage 4) which picks the resolver branch. + let callForm = inferCallForm(callNode, nameNode); + let receiverName = callForm === 'member' ? extractReceiverName(nameNode) : undefined; let receiverTypeName = receiverName && typeEnv ? typeEnv.lookup(receiverName, callNode) : undefined; + let receiverSource: ReceiverSource = receiverTypeName ? 'typed-binding' : 'none'; // Phase P: virtual dispatch override — when the declared type is a base class but // the constructor created a known subclass, prefer the more specific type. // Checks per-file parentMap first, then falls back to globalParentMap for @@ -1097,6 +1147,7 @@ export const processCalls = async ( ctx.model.types.lookupClassByName(receiverTypeName).length > 0) ) { receiverTypeName = ctorType; + receiverSource = 'constructor-map'; } } } @@ -1105,10 +1156,14 @@ export const processCalls = async ( const enclosingFunc = findEnclosingFunction(callNode, file.path, ctx, provider); const funcName = enclosingFunc ? extractFuncNameFromSourceId(enclosingFunc) : ''; receiverTypeName = lookupReceiverType(receiverIndex, funcName, receiverName); + if (receiverTypeName) receiverSource = 'constructor-map'; } - // Fall back to class-as-receiver for static method calls (e.g. UserService.find_user()). - // When the receiver name is not a variable in TypeEnv but resolves to a Class/Struct/Interface - // through the standard tiered resolution, use it directly as the receiver type. + // Fall back to class-as-receiver for static method calls (e.g. UserService.find_user(), + // Greetable.format()). When the receiver name is not a variable in TypeEnv but + // resolves to a class-like symbol (Class / Interface / Struct / Enum / Trait) via + // tiered resolution, use it directly as the receiver type. `Trait` is included so + // Ruby module class-method calls flow through the class-as-receiver path and reach + // the `selectDispatch` hook's singleton branch. if (!receiverTypeName && receiverName && callForm === 'member') { const typeResolved = ctx.resolve(receiverName, file.path); if ( @@ -1118,10 +1173,12 @@ export const processCalls = async ( d.type === 'Class' || d.type === 'Interface' || d.type === 'Struct' || - d.type === 'Enum', + d.type === 'Enum' || + d.type === 'Trait', ) ) { receiverTypeName = receiverName; + receiverSource = 'class-as-receiver'; } } // Hoist sourceId so it's available for ACCESSES edge emission during chain walk. @@ -1167,11 +1224,51 @@ export const processCalls = async ( makeAccessEmitter(graph, sourceId), heritageMap, ); + if (receiverTypeName) receiverSource = 'mixed-chain'; } } } } + // --- DAG stage 3: infer-receiver (provider hook) --- + // Synthesize implicit receivers for languages that omit them (e.g., Ruby bare-call). + // This hook runs AFTER the shared inference chain so explicit receivers / + // typed bindings always take precedence. Output (if non-null) overlays onto + // the ReceiverEnriched for the next stage. + let dispatchHint: string | undefined; + if (provider.inferImplicitReceiver) { + const override = provider.inferImplicitReceiver({ + calledName, + callForm, + receiverName, + receiverTypeName, + callNode, + filePath: file.path, + }); + if (override) { + callForm = override.callForm; + receiverName = override.receiverName; + receiverTypeName = override.receiverTypeName; + receiverSource = override.receiverSource; + dispatchHint = override.hint; + } + } + + // --- DAG stage 4: select-dispatch (provider hook + default fallback) --- + // Decide which resolver path to try first (primary) and fallback strategy. + // Language providers can customize dispatch via selectDispatch hook; all + // others use the shared defaultDispatchDecision. Always non-null after this + // block so downstream resolvers are table-driven. + const dispatchDecision: DispatchDecision = + provider.selectDispatch?.({ + calledName, + callForm, + receiverName, + receiverTypeName, + receiverSource, + hint: dispatchHint, + }) ?? defaultDispatchDecision(callForm); + // Build overload hints for languages with inferLiteralType (Java/Kotlin/C#/C++). // Only used when multiple candidates survive arity filtering — ~1-3% of calls. const langConfig = provider.typeConfig; @@ -1193,6 +1290,7 @@ export const processCalls = async ( widenCache, undefined, heritageMap, + dispatchDecision, ); if (!resolved) return; @@ -1731,11 +1829,20 @@ const resolveCallTarget = ( widenCache?: WidenCache, preComputedArgTypes?: (string | undefined)[], heritageMap?: HeritageMap, + dispatchDecision?: DispatchDecision, ): ResolveResult | null => { const tiered = ctx.resolve(call.calledName, currentFile); if (!tiered) return null; - if (call.callForm === 'free') { + // DAG dispatch: use decision.primary to pick the resolver branch. + // Callers that own the DAG (processCalls + crossFile deferred paths) + // pass a decision; other callers use the shared default ladder. + // Language-specific primary / fallback / ancestryView overrides come from + // the provider's `selectDispatch` hook. + const decision = dispatchDecision ?? defaultDispatchDecision(call.callForm); + const primary = decision.primary; + + if (primary === 'free') { return resolveFreeCall( call.calledName, currentFile, @@ -1746,7 +1853,7 @@ const resolveCallTarget = ( preComputedArgTypes, ); } - if (call.callForm === 'constructor') { + if (primary === 'constructor') { return ( resolveStaticCall( call.calledName, @@ -1759,6 +1866,7 @@ const resolveCallTarget = ( ) ?? singleCandidate(tiered, call.argCount, 'constructor') ); } + // primary === 'owner-scoped' if (call.receiverTypeName) { // Skip the owner-scoped MRO path when the tiered pool has genuine // overload ambiguity that needs D1-D4+E handling, not D0. @@ -1766,6 +1874,15 @@ const resolveCallTarget = ( (!!overloadHints || !!preComputedArgTypes) && countCallableCandidates(tiered.candidates, call.argCount, call.callForm) > 1; // Try owner-scoped (resolveMemberCall) then file-scoped (resolveMemberCallByFile). + // DAG: dispatchDecision.ancestryView selects instance vs singleton ancestry + // for kind-aware MRO strategies. Ruby `Account.log` flows via 'singleton'. + // + // Singleton-ancestry miss MUST NOT degrade to the file-scoped fallback: + // resolveMemberCallByFile matches by ownerId and would happily pick an + // instance method defined on the same class, leaking instance dispatch + // onto what was declared a class-method call. For singleton dispatch, + // a miss either null-routes or falls through to `decision.fallback`. + const singletonDispatch = decision.ancestryView === 'singleton'; const memberResult = (!skipMember ? resolveMemberCall( @@ -1775,18 +1892,21 @@ const resolveCallTarget = ( ctx, heritageMap, call.argCount, + decision.ancestryView, ) : null) ?? - resolveMemberCallByFile( - call.calledName, - call.receiverTypeName, - currentFile, - ctx, - call.argCount, - call.callForm, - overloadHints, - preComputedArgTypes, - ); + (singletonDispatch + ? null + : resolveMemberCallByFile( + call.calledName, + call.receiverTypeName, + currentFile, + ctx, + call.argCount, + call.callForm, + overloadHints, + preComputedArgTypes, + )); if (memberResult) return memberResult; // Module-alias narrowing runs as a FALLBACK, after owner/file-scoped @@ -1822,7 +1942,26 @@ const resolveCallTarget = ( // hierarchy. When the type is NOT in the index (PHP `mixed`, dynamic // types, unresolvable aliases), the scoped resolvers had nothing to // work with and singleCandidate is the correct last resort. + // + // DAG fallback override: when `select-dispatch` returned + // `fallback: 'free-arity-narrowed'` (today: Ruby implicit-self bare + // calls whose enclosing class doesn't define the method), fall through + // to free-call resolution instead of null-routing. This preserves + // existing free-call arity-narrowing heuristics for bare calls that + // happen to target methods on unrelated classes. if (typeResolves && typeResolves.candidates.length > 0) { + if (decision.fallback === 'free-arity-narrowed') { + const free = resolveFreeCall( + call.calledName, + currentFile, + ctx, + call.argCount, + tiered, + overloadHints, + preComputedArgTypes, + ); + if (free) return free; + } return null; // null-route: type resolved, no candidate matched } return singleCandidate(tiered, call.argCount, call.callForm); @@ -2018,6 +2157,13 @@ const resolveMethodByOwner = ( ctx: ResolutionContext, heritageMap?: HeritageMap, argCount?: number, + /** + * DAG-sourced ancestry selector. `'singleton'` routes through + * `heritageMap.getSingletonAncestry(owner)` for class-method dispatch + * (Ruby `Account.log` via `extend LoggerMixin`). Default / undefined + * uses the walker's instance-dispatch behavior. + */ + ancestryView?: 'instance' | 'singleton', ): { def: SymbolDefinition; tier: ResolutionTier } | undefined => { const typeResolved = ctx.resolve(receiverTypeName, filePath); if (!typeResolved) return undefined; @@ -2046,6 +2192,14 @@ const resolveMethodByOwner = ( let ambiguous = false; for (const candidate of typeResolved.candidates) { if (!CLASS_LIKE_TYPES.has(candidate.type)) continue; + // Singleton dispatch: when the DAG decision requested the singleton + // ancestry view, pass `heritageMap.getSingletonAncestry` as the walker's + // ancestry override. Kind-aware strategies (e.g. MroStrategy 'ruby-mixin') + // honor the override by scanning it linearly in place of their default walk. + const singletonOverride = + ancestryView === 'singleton' && canWalkMRO && heritageMap + ? heritageMap.getSingletonAncestry(candidate.nodeId).map((e) => e.parentId) + : undefined; const def = canWalkMRO ? lookupMethodByOwnerWithMRO( candidate.nodeId, @@ -2054,6 +2208,7 @@ const resolveMethodByOwner = ( ctx.model, mroStrategy, argCount, + singletonOverride, ) : ctx.model.methods.lookupMethodByOwner(candidate.nodeId, methodName, argCount); if (!def) continue; @@ -2108,6 +2263,7 @@ export const resolveMemberCall = ( ctx: ResolutionContext, heritageMap?: HeritageMap, argCount?: number, + ancestryView?: 'instance' | 'singleton', ): ResolveResult | null => { const resolved = resolveMethodByOwner( ownerType, @@ -2116,6 +2272,7 @@ export const resolveMemberCall = ( ctx, heritageMap, argCount, + ancestryView, ); if (!resolved) return null; return toResolveResult(resolved.def, resolved.tier); diff --git a/gitnexus/src/core/ingestion/call-routing.ts b/gitnexus/src/core/ingestion/call-routing.ts index 3922c0c89..f9e60df10 100644 --- a/gitnexus/src/core/ingestion/call-routing.ts +++ b/gitnexus/src/core/ingestion/call-routing.ts @@ -1,10 +1,14 @@ /** * Shared Ruby call routing logic. * - * Ruby expresses imports, heritage (mixins), and property definitions as - * method calls rather than syntax-level constructs. This module provides a - * routing function used by the CLI call-processor, CLI parse-worker, and - * the web call-processor so that the classification logic lives in one place. + * Ruby expresses imports and property definitions as method calls rather + * than syntax-level constructs. This module provides a routing function + * used by the CLI call-processor, CLI parse-worker, and the web + * call-processor so that the classification logic lives in one place. + * + * Heritage (mixins: include/extend/prepend) was previously routed here + * but is now handled by heritageExtractor.extractFromCall before the + * call router runs. The router still returns 'skip' for these calls. * * NOTE: This file is intentionally duplicated in gitnexus-web/ because the * two packages have separate build targets (Node native vs WASM/browser). @@ -30,17 +34,10 @@ export type CallRouter = (calledName: string, callNode: SyntaxNode) => CallRouti export type RubyCallRouting = | { kind: 'import'; importPath: string; isRelative: boolean } - | { kind: 'heritage'; items: RubyHeritageItem[] } | { kind: 'properties'; items: RubyPropertyItem[] } | { kind: 'call' } | { kind: 'skip' }; -export interface RubyHeritageItem { - enclosingClass: string; - mixinName: string; - heritageKind: 'include' | 'extend' | 'prepend'; -} - export type RubyAccessorType = 'attr_accessor' | 'attr_reader' | 'attr_writer'; export interface RubyPropertyItem { @@ -56,9 +53,6 @@ export interface RubyPropertyItem { const CALL_RESULT: RubyCallRouting = { kind: 'call' }; const SKIP_RESULT: RubyCallRouting = { kind: 'skip' }; -/** Max depth for parent-walking loops to prevent pathological AST traversals */ -const MAX_PARENT_DEPTH = 50; - // ── Routing function ──────────────────────────────────────────────────────── /** @@ -88,35 +82,12 @@ export function routeRubyCall(calledName: string, callNode: SyntaxNode): RubyCal return { kind: 'import', importPath, isRelative }; } - // ── include / extend / prepend → heritage (mixin) ────────────────────── + // ── include / extend / prepend — heritage (now handled by heritageExtractor) ─ + // Call-based heritage is intercepted by heritageExtractor.extractFromCall + // before the call router runs. Return SKIP_RESULT so these calls don't + // fall through to normal call processing. if (calledName === 'include' || calledName === 'extend' || calledName === 'prepend') { - let enclosingClass: string | null = null; - let current = callNode.parent; - let depth = 0; - while (current && ++depth <= MAX_PARENT_DEPTH) { - if (current.type === 'class' || current.type === 'module') { - const nameNode = current.childForFieldName?.('name'); - if (nameNode) { - enclosingClass = nameNode.text; - break; - } - } - current = current.parent; - } - if (!enclosingClass) return SKIP_RESULT; - - const items: RubyHeritageItem[] = []; - const argList = callNode.childForFieldName?.('arguments'); - for (const arg of argList?.children ?? []) { - if (arg.type === 'constant' || arg.type === 'scope_resolution') { - items.push({ - enclosingClass, - mixinName: arg.text, - heritageKind: calledName as 'include' | 'extend' | 'prepend', - }); - } - } - return items.length > 0 ? { kind: 'heritage', items } : SKIP_RESULT; + return SKIP_RESULT; } // ── attr_accessor / attr_reader / attr_writer → property definitions ─── 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..b42ecbf16 --- /dev/null +++ b/gitnexus/src/core/ingestion/call-types.ts @@ -0,0 +1,177 @@ +// 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; +} + +// --------------------------------------------------------------------------- +// Call-resolution DAG types +// --------------------------------------------------------------------------- +// +// The call-resolution pipeline is a typed DAG: +// +// extract-call ──▶ classify-form ──▶ infer-receiver ──▶ select-dispatch ──▶ resolve-target ──▶ emit-edge +// +// Provider hooks plug in at infer-receiver and select-dispatch; shared stages +// stay language-agnostic. Stages 1-2 run in the parse worker; stages 3-6 run +// on the main thread. DAG-internal types below are main-thread-only and never +// serialize to the graph. + +/** + * DAG stage 3 output: call record with receiver type and source discriminant. + * + * `receiverTypeName` is resolved via TypeEnv → constructor-map → class-as-receiver → + * mixed-chain, or synthesized by `inferImplicitReceiver`. `receiverSource` tags + * which path won and drives MRO strategy selection in stage 4. + * + * Invariants: + * - `receiverSource` MUST match how `receiverTypeName` was resolved; every + * discriminant must have a live reader and writer. + * - `hint` is opaque to shared stages; only the same provider's `selectDispatch` reads it. + * + * @see language-provider.ts § inferImplicitReceiver, selectDispatch + */ +export interface ReceiverEnriched { + readonly calledName: string; + readonly callForm: 'free' | 'member' | 'constructor' | undefined; + readonly receiverName: string | undefined; + readonly receiverTypeName: string | undefined; + readonly receiverSource: + | 'none' + | 'typed-binding' + | 'constructor-map' + | 'class-as-receiver' + | 'mixed-chain' + | 'implicit-self'; + /** Free-form hint from the provider hook; opaque to shared stages. */ + readonly hint?: string; +} + +/** + * Provider hook output for `LanguageProvider.inferImplicitReceiver` (DAG stage 3). + * + * Overlay applied to `ReceiverEnriched` when an implicit receiver is synthesized. + * Ruby example: bare `serialize` inside `Account#call_serialize` → + * `{ callForm: 'member', receiverName: 'self', receiverTypeName: 'Account', + * receiverSource: 'implicit-self', hint: 'instance' }` + * + * Invariants: + * - `receiverSource` is always `'implicit-self'` — the only variant this type produces. + * - `callForm` is always `'member'` — the rewrite converts bare-call to method invocation. + * - `hint` is opaque to shared stages; consumed by the same language's `selectDispatch`. + */ +export interface ImplicitReceiverOverride { + readonly callForm: 'free' | 'member' | 'constructor'; + readonly receiverName: string; + readonly receiverTypeName: string; + readonly receiverSource: Extract; + /** Free-form language tag (e.g. Ruby sets 'singleton' for `def self.foo` + * method bodies). Consumed by the same language's `selectDispatch` hook. */ + readonly hint?: string; +} + +/** + * DAG stage 4 output: dispatch strategy for resolving the target method. + * + * Encodes which resolver branch to try first and an optional fallback. + * Stage 5 delegates to `resolveMemberCall`, `resolveFreeCall`, or + * `resolveStaticCall` based on `primary`. + * + * - `primary`: `'owner-scoped'` = MRO walk, `'free'` = arity-tiered global lookup, + * `'constructor'` = type instantiation. + * - `fallback`: Only `'free-arity-narrowed'` exists; used by Ruby implicit-self + * to degrade to arity-tiered free lookup when the MRO walk misses. + * - `ancestryView`: Ruby `'ruby-mixin'` only. `'singleton'` walks extend providers + * only; a miss NEVER falls through to file-scoped lookup (enforced in + * resolveCallTarget). `'instance'` is the default. + * + * Common patterns: + * - `{primary: 'constructor'}` — constructor call + * - `{primary: 'owner-scoped'}` — member call with known type + * - `{primary: 'owner-scoped', fallback: 'free-arity-narrowed', ancestryView: 'instance'}` — Ruby implicit-self + * - `{primary: 'owner-scoped', ancestryView: 'singleton'}` — Ruby class-method call + * + * @see language-provider.ts § selectDispatch + * @see call-processor.ts § defaultDispatchDecision, resolveCallTarget + */ +export interface DispatchDecision { + readonly primary: 'owner-scoped' | 'free' | 'constructor'; + readonly fallback?: 'free-arity-narrowed'; + readonly ancestryView?: 'instance' | 'singleton'; + readonly hint?: string; +} 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/emit-references.ts b/gitnexus/src/core/ingestion/emit-references.ts new file mode 100644 index 000000000..1d2c4db5b --- /dev/null +++ b/gitnexus/src/core/ingestion/emit-references.ts @@ -0,0 +1,299 @@ +/** + * Phase 5 of the RFC #909 ingestion lifecycle: drain `ReferenceIndex` + * into the knowledge graph as labeled edges with `confidence` and + * `evidence` properties (Ring 2 PKG #925). + * + * The resolution phase (future PR) writes `Reference` records into + * `model.scopes.referenceSites`-derived `ReferenceIndex`; this module + * materializes those records as `GraphRelationship`s via + * `graph.addRelationship`. Every emitted edge carries: + * + * - `type`: one of `'CALLS' | 'ACCESSES' | 'INHERITS' | 'USES'` + * (mapped from `Reference.kind` — `'read'` and `'write'` both route + * to `ACCESSES`; `'type-reference'` and `'import-use'` route to + * `USES`; `'call'` stays `CALLS`; `'inherits'` stays `INHERITS`). + * - `confidence`: the pre-computed confidence from the Reference record. + * - `reason`: human-readable summary (`"scope-resolution: call | confidence 0.75"`). + * - `evidence`: the full `ResolutionEvidence[]` trace — additive graph + * property (see `GraphRelationship.evidence` in gitnexus-shared), + * so queries that don't know about it are unaffected. + * - `step`: carries the reference's access-kind discriminant when + * available (`1` for read, `2` for write) so `ACCESSES` edges retain + * the read/write distinction without forcing a new edge type. + * + * ## Optional scope-tree flush + * + * When `INGESTION_EMIT_SCOPES=1` is set, this module also emits: + * + * - `Scope` nodes for every `Scope` in the tree + * - `CONTAINS` edges from parent scope to child scope + * - `DEFINES` edges from scope to its `ownedDefs` members + * - `IMPORTS` edges from scope to `targetModuleScope` of each finalized + * `ImportEdge` that carries one + * + * Off by default — existing queries that don't know about `Scope` nodes + * continue to work, and the storage cost is opt-in. + * + * ## Source-of-truth: the caller def for a reference + * + * A `Reference` says "some code inside `fromScope` references `toDef`". + * The graph wants `(callerNodeId, calleeNodeId)`. We resolve the caller + * by walking up the scope tree from `fromScope` until we find a scope + * whose `ownedDefs` contains a Function-like def. If no such ancestor + * exists, the edge is attributed to the first def owned by the innermost + * ancestor scope, and if THAT produces nothing either the edge is + * skipped (with a count returned in `EmitStats.skippedNoCaller`). + */ + +import type { + NodeLabel, + RelationshipType, + Reference, + ReferenceIndex, + ResolutionEvidence, + Scope, + ScopeId, + SymbolDefinition, +} from 'gitnexus-shared'; +import type { KnowledgeGraph } from '../graph/types.js'; +import type { ScopeResolutionIndexes } from './model/scope-resolution-indexes.js'; + +// ─── Public API ───────────────────────────────────────────────────────────── + +export interface EmitStats { + readonly edgesEmitted: number; + /** References dropped because no caller def could be resolved. */ + readonly skippedNoCaller: number; + /** References dropped because `toDef` was not found in the DefIndex. */ + readonly skippedMissingTarget: number; + /** Scope nodes emitted — `0` unless `INGESTION_EMIT_SCOPES=1`. */ + readonly scopeNodesEmitted: number; + /** Scope-tree structural edges emitted — `0` unless `INGESTION_EMIT_SCOPES=1`. */ + readonly scopeEdgesEmitted: number; +} + +export interface EmitReferencesInput { + readonly graph: KnowledgeGraph; + readonly scopes: ScopeResolutionIndexes; + readonly referenceIndex: ReferenceIndex; + /** Human-consumable label for the `reason` prefix. Defaults to `'scope-resolution'`. */ + readonly sourceLabel?: string; +} + +/** + * Drain `referenceIndex.bySourceScope` into graph edges. + * + * The scope-tree flush is controlled separately by + * `INGESTION_EMIT_SCOPES` — callers can run `emitReferencesToGraph` + * without scope-node emission or layer the two calls as needed. + */ +export function emitReferencesToGraph(input: EmitReferencesInput): EmitStats { + const { graph, scopes, referenceIndex } = input; + const sourceLabel = input.sourceLabel ?? 'scope-resolution'; + + let edgesEmitted = 0; + let skippedNoCaller = 0; + let skippedMissingTarget = 0; + + for (const [fromScope, refs] of referenceIndex.bySourceScope) { + for (const ref of refs) { + const targetDef = scopes.defs.get(ref.toDef); + if (targetDef === undefined) { + skippedMissingTarget++; + continue; + } + const callerId = resolveCallerNodeId(fromScope, scopes); + if (callerId === undefined) { + skippedNoCaller++; + continue; + } + graph.addRelationship(buildRelationship(ref, callerId, targetDef, sourceLabel)); + edgesEmitted++; + } + } + + const scopeStats = isScopeEmissionEnabled() + ? emitScopeGraph({ graph, scopes }) + : { scopeNodesEmitted: 0, scopeEdgesEmitted: 0 }; + + return { edgesEmitted, skippedNoCaller, skippedMissingTarget, ...scopeStats }; +} + +/** + * Emit `Scope` nodes + `CONTAINS`/`DEFINES`/`IMPORTS` edges representing + * the lexical scope tree itself. Skipped unless `INGESTION_EMIT_SCOPES=1` + * at the public entry point; exported here for tests that want to + * exercise the path directly. + */ +export function emitScopeGraph(input: { + readonly graph: KnowledgeGraph; + readonly scopes: ScopeResolutionIndexes; +}): { readonly scopeNodesEmitted: number; readonly scopeEdgesEmitted: number } { + const { graph, scopes } = input; + let scopeNodesEmitted = 0; + let scopeEdgesEmitted = 0; + + for (const scope of scopes.scopeTree.byId.values()) { + graph.addNode({ + id: scope.id, + label: 'CodeElement' as NodeLabel, // the generic bucket for non-symbol graph nodes + properties: { + name: scope.kind, + filePath: scope.filePath, + startLine: scope.range.startLine, + endLine: scope.range.endLine, + description: `Scope: ${scope.kind}`, + } as unknown as Parameters[0]['properties'], + }); + scopeNodesEmitted++; + + if (scope.parent !== null) { + graph.addRelationship({ + id: `rel:contains:${scope.parent}->${scope.id}`, + sourceId: scope.parent, + targetId: scope.id, + type: 'CONTAINS', + confidence: 1, + reason: 'scope-tree parent/child', + }); + scopeEdgesEmitted++; + } + + for (const def of scope.ownedDefs) { + graph.addRelationship({ + id: `rel:defines:${scope.id}->${def.nodeId}`, + sourceId: scope.id, + targetId: def.nodeId, + type: 'DEFINES', + confidence: 1, + reason: 'scope.ownedDefs', + }); + scopeEdgesEmitted++; + } + } + + for (const [scopeId, edges] of scopes.imports) { + for (const edge of edges) { + if (edge.targetModuleScope === undefined) continue; + graph.addRelationship({ + id: `rel:imports:${scopeId}->${edge.targetModuleScope}:${edge.localName}`, + sourceId: scopeId, + targetId: edge.targetModuleScope, + type: 'IMPORTS', + confidence: edge.linkStatus === 'unresolved' ? 0.5 : 1, + reason: `import ${edge.kind} ${edge.localName}`, + }); + scopeEdgesEmitted++; + } + } + + return { scopeNodesEmitted, scopeEdgesEmitted }; +} + +// ─── Internal ─────────────────────────────────────────────────────────────── + +/** Accepted truthy values for `INGESTION_EMIT_SCOPES`. */ +const TRUTHY: ReadonlySet = new Set(['true', '1', 'yes']); + +function isScopeEmissionEnabled(): boolean { + const raw = process.env['INGESTION_EMIT_SCOPES']; + if (raw === undefined) return false; + return TRUTHY.has(raw.trim().toLowerCase()); +} + +/** + * Walk up from `startScope` looking for the first ancestor scope whose + * `ownedDefs` contains a Function-like def (Function / Method / + * Constructor). Fall back to the innermost ancestor's first `ownedDef` + * if none is found; return `undefined` if all ancestors have no defs. + */ +function resolveCallerNodeId( + startScope: ScopeId, + scopes: ScopeResolutionIndexes, +): string | undefined { + const tree = scopes.scopeTree; + let current: ScopeId | null = startScope; + const visited = new Set(); + let firstOwnedFallback: string | undefined; + + while (current !== null) { + if (visited.has(current)) break; + visited.add(current); + + const scope: Scope | undefined = tree.getScope(current); + if (scope === undefined) break; + + // Prefer a Function-like owner. + const fnDef = scope.ownedDefs.find((d) => isFunctionLike(d.type)); + if (fnDef !== undefined) return fnDef.nodeId; + + // Stash the first owned def we see as a conservative fallback. + if (firstOwnedFallback === undefined && scope.ownedDefs.length > 0) { + firstOwnedFallback = scope.ownedDefs[0]!.nodeId; + } + + current = scope.parent; + } + + return firstOwnedFallback; +} + +function isFunctionLike(type: NodeLabel): boolean { + return type === 'Function' || type === 'Method' || type === 'Constructor'; +} + +function buildRelationship( + ref: Reference, + callerId: string, + targetDef: SymbolDefinition, + sourceLabel: string, +): Parameters[0] { + const type = mapKindToType(ref.kind); + const reason = `${sourceLabel}: ${ref.kind} | confidence ${ref.confidence.toFixed(3)}`; + // `step` encodes read/write discriminator for ACCESSES edges (1=read, 2=write). + // Other kinds omit `step`. + const step = ref.kind === 'read' ? 1 : ref.kind === 'write' ? 2 : undefined; + return { + id: `rel:${type}:${callerId}->${targetDef.nodeId}:${ref.atRange.startLine}:${ref.atRange.startCol}`, + sourceId: callerId, + targetId: targetDef.nodeId, + type, + confidence: ref.confidence, + reason, + evidence: ref.evidence.map(serializeEvidence), + ...(step !== undefined ? { step } : {}), + }; +} + +/** + * Map a `Reference.kind` to an existing `RelationshipType`. Read/write + * both fold into `ACCESSES`; `type-reference` + `import-use` both fold + * into `USES`. This keeps the graph schema additive — no new + * RelationshipType values are introduced by this module. + */ +function mapKindToType(kind: Reference['kind']): RelationshipType { + switch (kind) { + case 'call': + return 'CALLS'; + case 'read': + case 'write': + return 'ACCESSES'; + case 'inherits': + return 'INHERITS'; + case 'type-reference': + case 'import-use': + return 'USES'; + } +} + +function serializeEvidence(e: ResolutionEvidence): { + readonly kind: string; + readonly weight: number; + readonly note?: string; +} { + return { + kind: e.kind, + weight: e.weight, + ...(e.note !== undefined ? { note: e.note } : {}), + }; +} 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/finalize-orchestrator.ts b/gitnexus/src/core/ingestion/finalize-orchestrator.ts new file mode 100644 index 000000000..8541e58aa --- /dev/null +++ b/gitnexus/src/core/ingestion/finalize-orchestrator.ts @@ -0,0 +1,196 @@ +/** + * `finalizeScopeModel` — turn a workspace's `ParsedFile[]` into a + * materialized `ScopeResolutionIndexes` (RFC §3.2 Phase 2; Ring 2 PKG #921). + * + * Thin integration glue, per issue #884's boundary: all algorithmic logic + * lives in `gitnexus-shared` (finalize algorithm #915, the four per-file + * indexes #913, the method-dispatch materialization #914, the scope tree + * #912). This file does three things only: + * + * 1. Map `ParsedFile[]` → `FinalizeInput` and call shared `finalize()`. + * 2. Build the four workspace-wide indexes from the union of per-file + * defs/scopes/modules/qualified-names. + * 3. Bundle the results into `ScopeResolutionIndexes` for + * `MutableSemanticModel.attachScopeIndexes(...)`. + * + * ## What this module is NOT responsible for + * + * - Invoking tree-sitter or running AST walks. That's the extractor (#919). + * - Per-language import-target resolution. Hooks are plumbed through + * but default to "unresolved" when no provider supplies them — the + * real adapters land with #922. + * - Populating `ReferenceIndex`. That's the resolution phase (#925). + * - Deciding which language uses registry-primary lookup. That's the + * flag reader (#924). + * + * ## Empty-input behavior + * + * When `parsedFiles` is empty (the common case today — no language has + * migrated yet), the orchestrator produces a valid but empty bundle: all + * indexes are zero-sized, the scope tree is empty, and + * `finalize.stats.totalFiles === 0`. This lets downstream consumers + * safely consult `model.scopes` without branching on presence. + */ + +import type { + BindingRef, + FinalizeFile, + FinalizeHooks, + ParsedFile, + Scope, + ScopeId, + SymbolDefinition, + WorkspaceIndex, +} from 'gitnexus-shared'; +import { + buildDefIndex, + buildMethodDispatchIndex, + buildModuleScopeIndex, + buildQualifiedNameIndex, + buildScopeTree, + finalize, +} from 'gitnexus-shared'; +import type { ScopeResolutionIndexes } from './model/scope-resolution-indexes.js'; + +// ─── Public entry point ───────────────────────────────────────────────────── + +/** + * Options forwarded to the orchestrator. All fields optional so callers + * that don't yet have per-language hooks (today) get sensible defaults; + * #922 will populate `hooks.resolveImportTarget` + friends per language. + */ +export interface FinalizeOrchestratorOptions { + /** + * Hooks forwarded to shared `finalize()`. Any omitted field gets a + * no-op default: unresolved targets, empty wildcard expansion, append + * merge for bindings. + */ + readonly hooks?: Partial; + /** + * Opaque workspace context forwarded to hooks. `undefined` today; Ring + * 2 PKG #922 populates this with a real cross-file index for the + * per-language resolvers. + */ + readonly workspaceIndex?: WorkspaceIndex; +} + +/** + * Produce a fully materialized `ScopeResolutionIndexes` from the + * workspace's per-file artifacts. + * + * Pure function (given pure hooks). No I/O, no globals consulted. The + * pipeline calls this once per ingestion run and hands the result to + * `MutableSemanticModel.attachScopeIndexes`. + */ +export function finalizeScopeModel( + parsedFiles: readonly ParsedFile[], + options: FinalizeOrchestratorOptions = {}, +): ScopeResolutionIndexes { + const hooks = withDefaultHooks(options.hooks ?? {}); + const workspaceIndex: WorkspaceIndex = options.workspaceIndex ?? undefined; + + // ── Step 1: Shared finalize — runs SCC-aware cross-file link + binding + // materialization. Returns linked imports + merged bindings per module + // scope + SCC condensation + stats. + const finalizeInput = { + files: parsedFiles.map(toFinalizeFile), + workspaceIndex, + }; + const finalizeOut = finalize(finalizeInput, hooks); + + // ── Step 2: Workspace-wide indexes built from the per-file unions. + // These are pure aggregations — no algorithm beyond what the builders + // in gitnexus-shared already encapsulate (first-write-wins, qname + // collision buckets, etc.). + + const allScopes: Scope[] = []; + const allDefs: SymbolDefinition[] = []; + const moduleEntries: { filePath: string; moduleScopeId: ScopeId }[] = []; + const allReferenceSites = [] as ReturnType; + + for (const file of parsedFiles) { + for (const s of file.scopes) allScopes.push(s); + for (const d of file.localDefs) allDefs.push(d); + moduleEntries.push({ filePath: file.filePath, moduleScopeId: file.moduleScope }); + } + // References kept out of the loop above to centralize list-init. + allReferenceSites.push(...collectReferenceSites(parsedFiles)); + + const scopeTree = buildScopeTree(allScopes); + const defs = buildDefIndex(allDefs); + const qualifiedNames = buildQualifiedNameIndex(allDefs); + const moduleScopes = buildModuleScopeIndex(moduleEntries); + + // ── Step 3: MethodDispatchIndex. Today we lack per-language MRO + // strategies wired into this orchestrator (that belongs with the + // HeritageMap bridge, a separate piece of work). Ship an EMPTY index + // so the bundle shape is consistent; the callbacks return `[]` for + // every owner and `implementsOf` returns `[]`. Populating this + // properly is tracked alongside the per-language provider hooks. + const methodDispatch = buildMethodDispatchIndex({ + owners: [], // empty → no MRO entries; `mroFor(x)` returns the frozen empty array + computeMro: () => [], + implementsOf: () => [], + }); + + return { + scopeTree, + defs, + qualifiedNames, + moduleScopes, + methodDispatch, + imports: finalizeOut.imports, + bindings: finalizeOut.bindings, + referenceSites: Object.freeze([...allReferenceSites]), + sccs: finalizeOut.sccs, + stats: finalizeOut.stats, + }; +} + +// ─── Internal ─────────────────────────────────────────────────────────────── + +/** Shape-reduce a `ParsedFile` to the narrower `FinalizeFile` the shared + * algorithm reads. The subset is stable — `FinalizeFile` is a proper + * subset of `ParsedFile`. */ +function toFinalizeFile(file: ParsedFile): FinalizeFile { + return { + filePath: file.filePath, + moduleScope: file.moduleScope, + parsedImports: file.parsedImports, + localDefs: file.localDefs, + }; +} + +/** Flatten every file's reference sites into one list. Order reflects + * input-file order, then capture order inside each file. Deterministic. */ +function collectReferenceSites(parsedFiles: readonly ParsedFile[]) { + const out: ParsedFile['referenceSites'][number][] = []; + for (const file of parsedFiles) { + for (const site of file.referenceSites) out.push(site); + } + return out; +} + +/** + * Fill in no-op defaults for any omitted hook. Keeps `finalize()` + * behavior well-defined for the zero-provider case today: + * + * - `resolveImportTarget: () => null` — every import edge ends up + * `linkStatus: 'unresolved'` (or dynamic-unresolved pass-through). + * - `expandsWildcardTo: () => []` — wildcards don't materialize. + * - `mergeBindings: (existing, incoming) => [...existing, ...incoming]` + * — append without precedence; providers override to implement local- + * shadows-import and similar rules. + */ +function withDefaultHooks(partial: Partial): FinalizeHooks { + return { + resolveImportTarget: partial.resolveImportTarget ?? (() => null), + expandsWildcardTo: partial.expandsWildcardTo ?? (() => []), + mergeBindings: + partial.mergeBindings ?? + (( + existing: readonly BindingRef[], + incoming: readonly BindingRef[], + ): readonly BindingRef[] => [...existing, ...incoming]), + }; +} diff --git a/gitnexus/src/core/ingestion/framework-detection.ts b/gitnexus/src/core/ingestion/framework-detection.ts index 9ea43800c..739f22967 100644 --- a/gitnexus/src/core/ingestion/framework-detection.ts +++ b/gitnexus/src/core/ingestion/framework-detection.ts @@ -34,10 +34,14 @@ export interface FrameworkHint { */ export function detectFrameworkFromPath(filePath: string): FrameworkHint | null { // Normalize path separators and ensure leading slash for consistent matching - let p = filePath.toLowerCase().replace(/\\/g, '/'); + const originalPath = filePath.replace(/\\/g, '/'); + let p = originalPath.toLowerCase(); if (!p.startsWith('/')) { p = '/' + p; // Add leading slash so patterns like '/app/' match 'app/...' } + const originalPathWithLeadingSlash = originalPath.startsWith('/') + ? originalPath + : `/${originalPath}`; // ========== JAVASCRIPT / TYPESCRIPT FRAMEWORKS ========== @@ -128,7 +132,7 @@ export function detectFrameworkFromPath(filePath: string): FrameworkHint | null (p.endsWith('.tsx') || p.endsWith('.jsx')) ) { // Only boost if PascalCase filename (likely a component, not util) - const fileName = p.split('/').pop() || ''; + const fileName = originalPathWithLeadingSlash.split('/').pop() || ''; if (/^[A-Z]/.test(fileName)) { return { framework: 'react', entryPointMultiplier: 1.5, reason: 'react-component' }; } diff --git a/gitnexus/src/core/ingestion/heritage-extractors/configs/go.ts b/gitnexus/src/core/ingestion/heritage-extractors/configs/go.ts new file mode 100644 index 000000000..63768172d --- /dev/null +++ b/gitnexus/src/core/ingestion/heritage-extractors/configs/go.ts @@ -0,0 +1,24 @@ +// gitnexus/src/core/ingestion/heritage-extractors/configs/go.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { HeritageExtractionConfig } from '../../heritage-types.js'; + +/** + * Go heritage extraction config. + * + * Go struct embedding: the tree-sitter query matches ALL field_declarations + * with type_identifier, but only anonymous fields (no name) are embedded. + * Named fields like `Breed string` also match — skip them. + * + * The shouldSkipExtends hook checks if the extends node's parent is a + * field_declaration with a named field child, indicating a regular + * (non-embedded) field that should not produce a heritage record. + */ +export const goHeritageConfig: HeritageExtractionConfig = { + language: SupportedLanguages.Go, + + shouldSkipExtends(extendsNode) { + const fieldDecl = extendsNode.parent; + return fieldDecl?.type === 'field_declaration' && fieldDecl.childForFieldName?.('name') != null; + }, +}; diff --git a/gitnexus/src/core/ingestion/heritage-extractors/configs/ruby.ts b/gitnexus/src/core/ingestion/heritage-extractors/configs/ruby.ts new file mode 100644 index 000000000..41bca1ff2 --- /dev/null +++ b/gitnexus/src/core/ingestion/heritage-extractors/configs/ruby.ts @@ -0,0 +1,73 @@ +// gitnexus/src/core/ingestion/heritage-extractors/configs/ruby.ts + +import { SupportedLanguages } from 'gitnexus-shared'; +import type { HeritageExtractionConfig, HeritageInfo } from '../../heritage-types.js'; +import type { SyntaxNode } from '../../utils/ast-helpers.js'; + +/** + * Maximum parent depth for enclosing class/module walk. + * Prevents runaway walks on malformed/deeply-nested ASTs. + */ +const MAX_PARENT_DEPTH = 50; + +/** + * Walk up the AST from a call node to find the enclosing class or module name. + * Ruby include/extend/prepend calls must be inside a class or module body. + */ +function findEnclosingClassName(callNode: SyntaxNode): string | null { + let current = callNode.parent; + let depth = 0; + while (current && ++depth <= MAX_PARENT_DEPTH) { + if (current.type === 'class' || current.type === 'module') { + const nameNode = current.childForFieldName?.('name'); + if (nameNode) return nameNode.text; + } + current = current.parent; + } + return null; +} + +/** Ruby heritage call names that express mixin inclusion. */ +const RUBY_HERITAGE_CALL_NAMES: ReadonlySet = new Set(['include', 'extend', 'prepend']); + +/** + * Ruby heritage extraction config. + * + * Ruby expresses inheritance in two ways, and only one of them has + * dedicated tree-sitter heritage captures: + * + * 1. Class inheritance (`class A < B`) produces standard + * `@heritage.extends` captures and flows through the generic + * capture-based `extract` hook (not defined here — the factory + * handles it). + * 2. Mixin calls (`include`/`extend`/`prepend`) have no dedicated + * heritage captures; they surface as ordinary call sites. The + * `callBasedHeritage` hook below intercepts them before the call + * router, absorbing the mixin routing logic that previously lived + * in call-routing.ts (routeRubyCall). + */ +export const rubyHeritageConfig: HeritageExtractionConfig = { + language: SupportedLanguages.Ruby, + + callBasedHeritage: { + callNames: RUBY_HERITAGE_CALL_NAMES, + + extract(calledName, callNode, _filePath): HeritageInfo[] { + const enclosingClass = findEnclosingClassName(callNode); + if (!enclosingClass) return []; + + const results: HeritageInfo[] = []; + const argList = callNode.childForFieldName?.('arguments'); + for (const arg of argList?.children ?? []) { + if (arg.type === 'constant' || arg.type === 'scope_resolution') { + results.push({ + className: enclosingClass, + parentName: arg.text, + kind: calledName, // 'include' | 'extend' | 'prepend' + }); + } + } + return results; + }, + }, +}; diff --git a/gitnexus/src/core/ingestion/heritage-extractors/generic.ts b/gitnexus/src/core/ingestion/heritage-extractors/generic.ts new file mode 100644 index 000000000..39c3d34c8 --- /dev/null +++ b/gitnexus/src/core/ingestion/heritage-extractors/generic.ts @@ -0,0 +1,84 @@ +// gitnexus/src/core/ingestion/heritage-extractors/generic.ts + +/** + * Generic table-driven heritage extractor factory. + * + * Follows the same config+factory pattern as method-extractors/generic.ts, + * field-extractors/generic.ts, call-extractors/generic.ts, and + * variable-extractors/generic.ts. + * + * Languages with custom extraction hooks (Go: shouldSkipExtends, Ruby: + * callBasedHeritage) pass a full HeritageExtractionConfig. Languages + * that use the default capture-based extraction can pass just the + * SupportedLanguages enum value — no per-language config file needed. + */ + +import type { SupportedLanguages } from 'gitnexus-shared'; +import type { CaptureMap } from '../language-provider.js'; +import type { + HeritageExtractionConfig, + HeritageExtractor, + HeritageExtractorContext, + HeritageInfo, +} from '../heritage-types.js'; +import type { SyntaxNode } from '../utils/ast-helpers.js'; + +/** + * Create a HeritageExtractor from a declarative config or a language enum. + * + * When a full HeritageExtractionConfig is provided, custom hooks + * (shouldSkipExtends, callBasedHeritage) drive the extraction. + * When only a SupportedLanguages value is provided, the factory produces + * a default extractor that handles the standard @heritage.* captures. + */ +export function createHeritageExtractor( + config: HeritageExtractionConfig | SupportedLanguages, +): HeritageExtractor { + const actualConfig: HeritageExtractionConfig = + typeof config === 'string' ? { language: config } : config; + const callNameSet = actualConfig.callBasedHeritage?.callNames; + + return { + language: actualConfig.language, + + extract(captureMap: CaptureMap, context: HeritageExtractorContext): HeritageInfo[] { + const classNode = captureMap['heritage.class']; + if (!classNode) return []; + + const className = classNode.text; + const results: HeritageInfo[] = []; + + const extendsNode = captureMap['heritage.extends']; + if (extendsNode) { + if (!actualConfig.shouldSkipExtends?.(extendsNode)) { + results.push({ className, parentName: extendsNode.text, kind: 'extends' }); + } + } + + const implementsNode = captureMap['heritage.implements']; + if (implementsNode) { + results.push({ className, parentName: implementsNode.text, kind: 'implements' }); + } + + const traitNode = captureMap['heritage.trait']; + if (traitNode) { + results.push({ className, parentName: traitNode.text, kind: 'trait-impl' }); + } + + return results; + }, + + ...(callNameSet + ? { + extractFromCall( + calledName: string, + callNode: SyntaxNode, + context: HeritageExtractorContext, + ): HeritageInfo[] | null { + if (!callNameSet.has(calledName)) return null; + return actualConfig.callBasedHeritage!.extract(calledName, callNode, context.filePath); + }, + } + : {}), + }; +} diff --git a/gitnexus/src/core/ingestion/heritage-processor.ts b/gitnexus/src/core/ingestion/heritage-processor.ts index bc8628fa5..6692d1c95 100644 --- a/gitnexus/src/core/ingestion/heritage-processor.ts +++ b/gitnexus/src/core/ingestion/heritage-processor.ts @@ -19,7 +19,7 @@ import { ASTCache } from './ast-cache.js'; import Parser from 'tree-sitter'; import { isLanguageAvailable, loadParser, loadLanguage } from '../tree-sitter/parser-loader.js'; import { generateId } from '../../lib/utils.js'; -import { getLanguageFromFilename, type SupportedLanguages } from 'gitnexus-shared'; +import { getLanguageFromFilename, type NodeLabel, type SupportedLanguages } from 'gitnexus-shared'; import { isVerboseIngestionEnabled } from './utils/verbose.js'; import { yieldToEventLoop } from './utils/event-loop.js'; import { getProvider } from './languages/index.js'; @@ -32,6 +32,7 @@ import type { import { resolveExtendsType } from './model/heritage-map.js'; import type { ResolutionContext } from './model/resolution-context.js'; import { TIER_CONFIDENCE } from './model/resolution-context.js'; +import type { HeritageInfo } from './heritage-types.js'; /** * Derive the heritage-resolution strategy for a language from its @@ -83,6 +84,103 @@ const resolveHeritageId = ( }; }; +/** + * Resolve a single HeritageInfo to a graph edge, using the same resolution + * logic as processHeritageFromExtracted. This bridges the heritage extractor + * output format to the graph-resolution side. + */ +const resolveAndAddHeritageEdge = ( + graph: KnowledgeGraph, + item: HeritageInfo, + filePath: string, + language: SupportedLanguages, + ctx: ResolutionContext, +): void => { + if (item.kind === 'extends') { + const { type: relType, idPrefix } = resolveExtendsType( + item.parentName, + filePath, + ctx, + getHeritageStrategyForLanguage(language), + ); + + const child = resolveHeritageId( + item.className, + filePath, + ctx, + 'Class', + `${filePath}:${item.className}`, + ); + const parent = resolveHeritageId(item.parentName, filePath, ctx, idPrefix); + + if (child.id && parent.id && child.id !== parent.id) { + graph.addRelationship({ + id: generateId(relType, `${child.id}->${parent.id}`), + sourceId: child.id, + targetId: parent.id, + type: relType, + confidence: Math.sqrt(child.confidence * parent.confidence), + reason: '', + }); + } + } else if (item.kind === 'implements') { + const cls = resolveHeritageId( + item.className, + filePath, + ctx, + 'Class', + `${filePath}:${item.className}`, + ); + const iface = resolveHeritageId(item.parentName, filePath, ctx, 'Interface'); + + if (cls.id && iface.id) { + graph.addRelationship({ + id: generateId('IMPLEMENTS', `${cls.id}->${iface.id}`), + sourceId: cls.id, + targetId: iface.id, + type: 'IMPLEMENTS', + confidence: Math.sqrt(cls.confidence * iface.confidence), + reason: '', + }); + } + } else if ( + item.kind === 'trait-impl' || + item.kind === 'include' || + item.kind === 'extend' || + item.kind === 'prepend' + ) { + // Fallback label for an unresolved child name. Rust `trait-impl` children + // are structs; Ruby mixin children are classes or modules (Trait). For + // Ruby mixin kinds the common case resolves through the type registry + // post-plan-001, so the fallback only fires for true-unresolved references + // (e.g. mixin inside a singleton_class). `Class` is strictly better than + // `Struct` there because it matches the label the structure phase would + // emit for a Ruby `class` — the dominant shape. Ruby modules that fail + // to resolve still lose their `Trait` label in the synthesized id, but + // they fail to resolve rarely and the tradeoff is documented. + const childFallbackLabel: NodeLabel = item.kind === 'trait-impl' ? 'Struct' : 'Class'; + const strct = resolveHeritageId( + item.className, + filePath, + ctx, + childFallbackLabel, + `${filePath}:${item.className}`, + ); + const trait = resolveHeritageId(item.parentName, filePath, ctx, 'Trait'); + + if (strct.id && trait.id) { + graph.addRelationship({ + id: generateId('IMPLEMENTS', `${strct.id}->${trait.id}:${item.kind}`), + sourceId: strct.id, + targetId: trait.id, + type: 'IMPLEMENTS', + confidence: Math.sqrt(strct.confidence * trait.confidence), + reason: item.kind, + }); + } + } +}; + export const processHeritage = async ( graph: KnowledgeGraph, files: { path: string; content: string }[], @@ -135,112 +233,32 @@ export const processHeritage = async ( let query; let matches; try { - const language = parser.getLanguage(); - query = new Parser.Query(language, queryStr); + const treeSitterLang = parser.getLanguage(); + query = new Parser.Query(treeSitterLang, queryStr); matches = query.matches(tree.rootNode); } catch (queryError) { console.warn(`Heritage query error for ${file.path}:`, queryError); continue; } - // 4. Process heritage matches + // 4. Process heritage matches via provider heritage extractor + const heritageExtractor = provider.heritageExtractor; matches.forEach((match) => { const captureMap: Record = {}; match.captures.forEach((c) => { captureMap[c.name] = c.node; }); - // EXTENDS or IMPLEMENTS: resolve via symbol table for languages where - // the tree-sitter query can't distinguish classes from interfaces (C#, Java) - if (captureMap['heritage.class'] && captureMap['heritage.extends']) { - // Go struct embedding: skip named fields (only anonymous fields are embedded) - const extendsNode = captureMap['heritage.extends']; - const fieldDecl = extendsNode.parent; - if (fieldDecl?.type === 'field_declaration' && fieldDecl.childForFieldName('name')) { - return; // Named field, not struct embedding - } + if (!captureMap['heritage.class']) return; + if (!heritageExtractor) return; - const className = captureMap['heritage.class'].text; - const parentClassName = captureMap['heritage.extends'].text; + const heritageItems = heritageExtractor.extract(captureMap, { + filePath: file.path, + language, + }); - const { type: relType, idPrefix } = resolveExtendsType( - parentClassName, - file.path, - ctx, - getHeritageStrategyForLanguage(language), - ); - - const child = resolveHeritageId( - className, - file.path, - ctx, - 'Class', - `${file.path}:${className}`, - ); - const parent = resolveHeritageId(parentClassName, file.path, ctx, idPrefix); - - if (child.id && parent.id && child.id !== parent.id) { - graph.addRelationship({ - id: generateId(relType, `${child.id}->${parent.id}`), - sourceId: child.id, - targetId: parent.id, - type: relType, - confidence: Math.sqrt(child.confidence * parent.confidence), - reason: '', - }); - } - } - - // IMPLEMENTS: Class implements Interface (TypeScript only) - if (captureMap['heritage.class'] && captureMap['heritage.implements']) { - const className = captureMap['heritage.class'].text; - const interfaceName = captureMap['heritage.implements'].text; - - const cls = resolveHeritageId( - className, - file.path, - ctx, - 'Class', - `${file.path}:${className}`, - ); - const iface = resolveHeritageId(interfaceName, file.path, ctx, 'Interface'); - - if (cls.id && iface.id) { - graph.addRelationship({ - id: generateId('IMPLEMENTS', `${cls.id}->${iface.id}`), - sourceId: cls.id, - targetId: iface.id, - type: 'IMPLEMENTS', - confidence: Math.sqrt(cls.confidence * iface.confidence), - reason: '', - }); - } - } - - // IMPLEMENTS (Rust): impl Trait for Struct - if (captureMap['heritage.trait'] && captureMap['heritage.class']) { - const structName = captureMap['heritage.class'].text; - const traitName = captureMap['heritage.trait'].text; - - const strct = resolveHeritageId( - structName, - file.path, - ctx, - 'Struct', - `${file.path}:${structName}`, - ); - const trait = resolveHeritageId(traitName, file.path, ctx, 'Trait'); - - if (strct.id && trait.id) { - graph.addRelationship({ - id: generateId('IMPLEMENTS', `${strct.id}->${trait.id}`), - sourceId: strct.id, - targetId: trait.id, - type: 'IMPLEMENTS', - confidence: Math.sqrt(strct.confidence * trait.confidence), - reason: 'trait-impl', - }); - } + for (const item of heritageItems) { + resolveAndAddHeritageEdge(graph, item, file.path, language, ctx); } }); @@ -331,11 +349,15 @@ export const processHeritageFromExtracted = async ( h.kind === 'extend' || h.kind === 'prepend' ) { + // See the per-item call above (processHeritageFromExtractedItem) for + // rationale: `Class` is the correct fallback for Ruby mixin kinds, + // `Struct` stays the Rust `trait-impl` default. + const childFallbackLabel: NodeLabel = h.kind === 'trait-impl' ? 'Struct' : 'Class'; const strct = resolveHeritageId( h.className, h.filePath, ctx, - 'Struct', + childFallbackLabel, `${h.filePath}:${h.className}`, ); const trait = resolveHeritageId(h.parentName, h.filePath, ctx, 'Trait'); @@ -361,6 +383,15 @@ export const processHeritageFromExtracted = async ( * {@link ExtractedHeritage} rows without mutating the graph. Used on the * sequential pipeline path so `buildHeritageMap(..., ctx)` can run before * `processCalls` (worker path defers calls until heritage from all chunks exists). + * + * This prepass extracts BOTH capture-based heritage (`@heritage.*` — extends / + * implements / trait-impl) AND call-based heritage (`@call.name` routed through + * `heritageExtractor.extractFromCall` — Ruby `include` / `extend` / `prepend`). + * Without the second pass, sequential-mode `sequentialHeritageMap` would not + * know about Ruby mixin ancestry before `processCalls` resolves calls against + * it, silently dropping mixed-in methods from the graph. This function stays + * read-only — `processCalls` still owns emission of heritage graph edges via + * its `rubyHeritage` return path. */ export async function extractExtractedHeritageFromFiles( files: { path: string; content: string }[], @@ -400,6 +431,8 @@ export async function extractExtractedHeritageFromFiles( continue; } + const callBasedEnabled = !!provider.heritageExtractor?.extractFromCall; + for (const match of matches) { const captureMap: Record = {}; match.captures.forEach((c) => { @@ -407,35 +440,44 @@ export async function extractExtractedHeritageFromFiles( }); if (captureMap['heritage.class']) { - if (captureMap['heritage.extends']) { - const extendsNode = captureMap['heritage.extends']; - const fieldDecl = extendsNode.parent; - const isNamedField = - fieldDecl?.type === 'field_declaration' && fieldDecl.childForFieldName('name'); - if (!isNamedField) { + if (provider.heritageExtractor) { + const heritageItems = provider.heritageExtractor.extract(captureMap, { + filePath: file.path, + language, + }); + for (const item of heritageItems) { out.push({ filePath: file.path, - className: captureMap['heritage.class'].text, - parentName: captureMap['heritage.extends'].text, - kind: 'extends', + className: item.className, + parentName: item.parentName, + kind: item.kind, }); } } - if (captureMap['heritage.implements']) { - out.push({ - filePath: file.path, - className: captureMap['heritage.class'].text, - parentName: captureMap['heritage.implements'].text, - kind: 'implements', - }); - } - if (captureMap['heritage.trait']) { - out.push({ - filePath: file.path, - className: captureMap['heritage.class'].text, - parentName: captureMap['heritage.trait'].text, - kind: 'trait-impl', - }); + continue; + } + + // Call-based heritage (e.g. Ruby include/extend/prepend). Matches the + // routing the worker path performs inline in parse-worker.ts — see the + // `provider.heritageExtractor?.extractFromCall` branch there. We only + // need call-based records here; other @call captures are consumed by + // processCalls later in the sequential loop. + if (callBasedEnabled && captureMap['call'] && captureMap['call.name']) { + const calledName: string = captureMap['call.name'].text; + const heritageItems = provider.heritageExtractor!.extractFromCall!( + calledName, + captureMap['call'], + { filePath: file.path, language }, + ); + if (heritageItems) { + for (const item of heritageItems) { + out.push({ + filePath: file.path, + className: item.className, + parentName: item.parentName, + kind: item.kind, + }); + } } } } diff --git a/gitnexus/src/core/ingestion/heritage-types.ts b/gitnexus/src/core/ingestion/heritage-types.ts new file mode 100644 index 000000000..82b6bbfad --- /dev/null +++ b/gitnexus/src/core/ingestion/heritage-types.ts @@ -0,0 +1,104 @@ +// gitnexus/src/core/ingestion/heritage-types.ts + +/** + * Types for the language-agnostic heritage extraction pipeline. + * + * Follows the same pattern as call-types.ts, variable-types.ts, and + * method-types.ts: defines the domain interfaces consumed by + * createHeritageExtractor() and the per-language configs. + * + * Heritage extraction handles extends/implements/trait-impl captures from + * tree-sitter queries, plus call-based heritage for languages like Ruby + * (include/extend/prepend expressed as method calls). + */ + +import type { SupportedLanguages } from 'gitnexus-shared'; +import type { SyntaxNode } from './utils/ast-helpers.js'; +import type { CaptureMap } from './language-provider.js'; + +// --------------------------------------------------------------------------- +// Extracted result +// --------------------------------------------------------------------------- + +/** + * Per-match heritage extraction result. The parse worker adds filePath to + * produce the final {@link ExtractedHeritage} that enters the resolution + * pipeline (heritage-processor.ts / heritage-map.ts). + */ +export interface HeritageInfo { + className: string; + parentName: string; + /** 'extends' | 'implements' | 'trait-impl' | 'include' | 'extend' | 'prepend' */ + kind: string; +} + +// --------------------------------------------------------------------------- +// Context +// --------------------------------------------------------------------------- + +export interface HeritageExtractorContext { + filePath: string; + language: SupportedLanguages; +} + +// --------------------------------------------------------------------------- +// Extractor interface (produced by createHeritageExtractor) +// --------------------------------------------------------------------------- + +export interface HeritageExtractor { + readonly language: SupportedLanguages; + + /** + * Extract heritage records from tree-sitter @heritage.* captures. + * + * @param captureMap The capture map from a single tree-sitter match + * @param context File path and language context + * @returns Array of heritage records (may be empty if captures don't match) + */ + extract(captureMap: CaptureMap, context: HeritageExtractorContext): HeritageInfo[]; + + /** + * Extract heritage from a call node (for languages where heritage is + * expressed as method calls, e.g., Ruby include/extend/prepend). + * + * @param calledName The method name (e.g. 'include', 'extend', 'prepend') + * @param callNode The tree-sitter call AST node + * @param context File path and language context + * @returns Heritage records if the call is heritage-related, or null to + * fall through to the call router / normal call handling. + */ + extractFromCall?( + calledName: string, + callNode: SyntaxNode, + context: HeritageExtractorContext, + ): HeritageInfo[] | null; +} + +// --------------------------------------------------------------------------- +// Config interface (one per language / language group) +// --------------------------------------------------------------------------- + +export interface HeritageExtractionConfig { + language: SupportedLanguages; + + /** + * Called for heritage.extends captures. Return true to skip this extends + * capture. Used by Go to skip named struct fields that match the + * field_declaration pattern but are not anonymous embeddings. + * + * Default: never skip (all extends captures are valid). + */ + shouldSkipExtends?: (extendsNode: SyntaxNode) => boolean; + + /** + * Call-based heritage extraction for languages where heritage is expressed + * as method calls (e.g., Ruby include/extend/prepend). + * + * callNames: set of method names that trigger heritage extraction. + * extract: extract heritage items from the call node + method name. + */ + callBasedHeritage?: { + readonly callNames: ReadonlySet; + extract(calledName: string, callNode: SyntaxNode, filePath: string): HeritageInfo[]; + }; +} 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..d98304561 --- /dev/null +++ b/gitnexus/src/core/ingestion/import-resolvers/configs/python.ts @@ -0,0 +1,47 @@ +/** + * 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: [] }; + + // External dotted imports like `django.apps` should not fall through to generic + // suffix matching when the repo has unrelated local files such as `accounts/apps.py`. + // Keep suffix fallback only when the leading segment appears somewhere in-repo, + // which preserves existing internal absolute-import behavior like `accounts.models`. + const pathLike = rawImportPath.replace(/\./g, '/'); + if (pathLike.includes('/')) { + const [leadingSegment] = pathLike.split('/').filter(Boolean); + const hasRepoCandidate = + !!leadingSegment && + (ctx.index.get(`${leadingSegment}.py`) !== undefined || + ctx.index.get(`${leadingSegment}/__init__.py`) !== undefined || + ctx.index.getFilesInDir(leadingSegment, '.py').length > 0); + + if (!hasRepoCandidate) 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