diff --git a/.claude/skills/gitnexus/gitnexus-cli/SKILL.md b/.claude/skills/gitnexus/gitnexus-cli/SKILL.md index c9e0af341..24586205e 100644 --- a/.claude/skills/gitnexus/gitnexus-cli/SKILL.md +++ b/.claude/skills/gitnexus/gitnexus-cli/SKILL.md @@ -17,12 +17,13 @@ npx gitnexus analyze Run from the project root. This parses all source files, builds the knowledge graph, writes it to `.gitnexus/`, and generates CLAUDE.md / AGENTS.md context files. -| Flag | Effect | -| -------------- | ---------------------------------------------------------------- | -| `--force` | Force full re-index even if up to date | -| `--embeddings` | Enable embedding generation for semantic search (off by default) | +| Flag | Effect | +| ------------------- | ------------------------------------------------------------------------------------------------------- | +| `--force` | Force full re-index even if up to date | +| `--embeddings` | Enable embedding generation for semantic search (off by default) | +| `--drop-embeddings` | Drop existing embeddings on rebuild. By default, an `analyze` without `--embeddings` preserves them. | -**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook runs `analyze` automatically after `git commit` and `git merge`, preserving embeddings if previously generated. +**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook detects staleness after `git commit` and `git merge` and notifies the agent to run `analyze` — the hook does not run analyze itself, to avoid blocking the agent for up to 120s and risking KuzuDB corruption on timeout. ### status — Check index freshness diff --git a/.env.example b/.env.example index ec967c79e..8af9dee79 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,8 @@ -# Images (signed Cosign keyless on every push from main / vX.Y.Z tags) +# Images (signed Cosign keyless on every push from main / vX.Y.Z tags). +# Available from both GHCR (default below) and Docker Hub — pick one: +# GHCR: ghcr.io/abhigyanpatwari/gitnexus{,-web}:latest +# Docker Hub: akonlabs/gitnexus{,-web}:latest +# Both registries receive the same digest from a single signed build. SERVER_IMAGE=ghcr.io/abhigyanpatwari/gitnexus:latest WEB_IMAGE=ghcr.io/abhigyanpatwari/gitnexus-web:latest diff --git a/.github/actions/docker-build-push-retry/action.yml b/.github/actions/docker-build-push-retry/action.yml new file mode 100644 index 000000000..5d86c5c1e --- /dev/null +++ b/.github/actions/docker-build-push-retry/action.yml @@ -0,0 +1,105 @@ +# Wraps docker/build-push-action with one automatic retry. Upstream explicitly +# keeps retry out of the action (docker/build-push-action#1422); a local +# composite keeps docker.yml readable and pins the same action SHA in one place. +name: Docker build-push (with retry) +description: >- + Runs docker/build-push-action twice on failure with a configurable backoff, + then exposes the digest from whichever attempt succeeded. + +inputs: + context: + description: Build context path + required: false + default: '.' + file: + description: Dockerfile path (relative to repo root) + required: true + platforms: + description: Comma-separated platforms list for buildx + required: true + push: + description: Whether to push (string 'true' or 'false') + required: true + tags: + description: Newline-separated image tags (from docker/metadata-action) + required: true + labels: + description: Labels string (from docker/metadata-action) + required: true + cache-from: + description: buildx cache-from value + required: true + cache-to: + description: buildx cache-to value (include ignore-error=true for GHA cache flakes) + required: true + retry-wait-seconds: + description: Seconds to sleep before the second attempt + required: false + default: '45' + +outputs: + digest: + description: Manifest digest from the successful build attempt + value: ${{ steps.resolve.outputs.digest }} + +runs: + using: composite + steps: + - name: Build and push (attempt 1) + id: try1 + continue-on-error: true + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + with: + context: ${{ inputs.context }} + file: ${{ inputs.file }} + platforms: ${{ inputs.platforms }} + push: ${{ inputs.push == 'true' }} + tags: ${{ inputs.tags }} + labels: ${{ inputs.labels }} + cache-from: ${{ inputs.cache-from }} + cache-to: ${{ inputs.cache-to }} + provenance: mode=max + sbom: true + + - name: Backoff before Docker build retry + if: steps.try1.outcome == 'failure' + shell: bash + env: + RETRY_WAIT_SECONDS: ${{ inputs.retry-wait-seconds }} + run: | + echo "::warning::Docker build-push attempt 1 failed; retrying in ${RETRY_WAIT_SECONDS}s…" + sleep "${RETRY_WAIT_SECONDS}" + + - name: Build and push (attempt 2) + id: try2 + if: steps.try1.outcome == 'failure' + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + with: + context: ${{ inputs.context }} + file: ${{ inputs.file }} + platforms: ${{ inputs.platforms }} + push: ${{ inputs.push == 'true' }} + tags: ${{ inputs.tags }} + labels: ${{ inputs.labels }} + cache-from: ${{ inputs.cache-from }} + cache-to: ${{ inputs.cache-to }} + provenance: mode=max + sbom: true + + - name: Resolve image digest + id: resolve + if: always() + shell: bash + run: | + set -euo pipefail + if [ "${{ steps.try1.outcome }}" = "success" ]; then + echo "digest=${{ steps.try1.outputs.digest }}" >> "$GITHUB_OUTPUT" + exit 0 + fi + if [ "${{ steps.try2.outcome }}" = "success" ]; then + echo "::notice::docker-build-push retry succeeded (attempt 2); investigate if this recurs across runs." + echo "digest=${{ steps.try2.outputs.digest }}" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "::error::Docker build and push failed after two attempts (registry/cache flake or real build error)." + exit 1 diff --git a/.github/actions/setup-gitnexus-web/action.yml b/.github/actions/setup-gitnexus-web/action.yml index 32854f352..86331e36d 100644 --- a/.github/actions/setup-gitnexus-web/action.yml +++ b/.github/actions/setup-gitnexus-web/action.yml @@ -1,12 +1,15 @@ name: Setup GitNexus Web -description: Setup Node.js 20, build gitnexus-shared, install web dependencies +description: Setup Node.js 20.19+ (vite 7 floor), build gitnexus-shared, install web dependencies runs: using: composite steps: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: - node-version: 20 + # Vite 7 requires Node ^20.19.0 || >=22.12.0 (require(esm) support). + # Pin explicitly so we don't depend on the floating "20" alias resolving + # to a high enough patch version on every runner image. + node-version: '20.19.0' cache: npm cache-dependency-path: gitnexus-web/package-lock.json diff --git a/.github/workflows/ci-quality.yml b/.github/workflows/ci-quality.yml index 5a0da5fd1..36a936c82 100644 --- a/.github/workflows/ci-quality.yml +++ b/.github/workflows/ci-quality.yml @@ -9,7 +9,7 @@ jobs: timeout-minutes: 5 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 20 cache: npm @@ -22,7 +22,7 @@ jobs: timeout-minutes: 10 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 20 cache: npm diff --git a/.github/workflows/ci-scope-parity.yml b/.github/workflows/ci-scope-parity.yml index e8efde9f1..cffdeb341 100644 --- a/.github/workflows/ci-scope-parity.yml +++ b/.github/workflows/ci-scope-parity.yml @@ -11,7 +11,8 @@ name: Scope Resolution Parity # TWICE on every PR: # # 1. `REGISTRY_PRIMARY_=0` — legacy DAG path (guarantees we haven't -# broken the old path while migrating). +# broken the old path while migrating). Known legacy gaps may be skipped +# through the resolver test helper's expected-failure list. # 2. `REGISTRY_PRIMARY_=1` — registry-primary path (guarantees the # new path carries the same behavior — the parity gate). # diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 7cd8b678c..9e5750210 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -4,9 +4,18 @@ 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. + pull_request: + # workflow_dispatch is allowed for dry-run testing only. Publishing is still + # exclusively tag-driven so that every signed image corresponds 1:1 to a + # published `gitnexus@X.Y.Z` on npm. dry_run:true (the default) skips all + # push, sign, and attestation steps — the build runs but nothing is published. + workflow_dispatch: + inputs: + dry_run: + description: 'Build only — skip push, signing, and attestations' + required: false + default: true + type: boolean workflow_call: inputs: tag: @@ -60,6 +69,12 @@ jobs: slug: gitnexus steps: + # Only the workflow_call path requires a non-empty `inputs.tag` — callers + # (e.g. release-candidate.yml) must pass the RC tag explicitly. On direct + # tag pushes the tag comes from `github.ref`, so `inputs.tag` is always + # empty and validating it here would break every real release (#1064). + # The downstream "Verify tag matches gitnexus/package.json version" step + # handles both event types by falling back to GITHUB_REF. - name: Validate tag input if: github.event_name == 'workflow_call' shell: bash @@ -85,6 +100,7 @@ jobs: # `gitnexus@X.Y.Z` published to npm — no drift, no surprises. - name: Verify tag matches gitnexus/package.json version id: version + if: github.event_name != 'workflow_dispatch' && github.event_name != 'pull_request' shell: bash env: # For workflow_call the tag comes from the caller input; for push events @@ -119,12 +135,27 @@ jobs: uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1 - name: Log in to GitHub Container Registry + if: ${{ github.event_name != 'pull_request' && !inputs.dry_run }} uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + # Docker Hub is a mirror of GHCR: same tags, same digests, same Cosign + # signatures. GHCR remains authoritative (it is the registry the + # ClusterImagePolicy globs against by default), but Docker Hub is the + # registry most users reach for first, so we publish there too. + # Requires repo secrets DOCKERHUB_USERNAME and DOCKERHUB_TOKEN (a scoped + # access token, NOT the account password) with write access to the + # `akonlabs/gitnexus` and `akonlabs/gitnexus-web` repos. + - name: Log in to Docker Hub + if: ${{ github.event_name != 'pull_request' && !inputs.dry_run }} + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_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) @@ -142,7 +173,16 @@ jobs: id: meta uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 with: - images: ghcr.io/${{ github.repository_owner }}/${{ matrix.image.slug }} + # Dual-registry publish. metadata-action expands the same tag set + # against every image ref listed here, and build-push-action pushes + # one build to all of them, so the GHCR and Docker Hub images share + # a digest and are byte-identical. The Docker Hub namespace + # (`akonlabs`) is hardcoded because it differs from the GitHub org + # (`abhigyanpatwari`) — `github.repository_owner` would produce the + # wrong ref. + images: | + ghcr.io/${{ github.repository_owner }}/${{ matrix.image.slug }} + docker.io/akonlabs/${{ matrix.image.slug }} flavor: latest=auto tags: | type=semver,pattern={{version}} @@ -150,20 +190,23 @@ jobs: type=semver,pattern={{major}} type=raw,value=${{ steps.version.outputs.version }},enable=${{ inputs.tag != '' }} + # Transient 502s from GHCR / Docker Hub / GHA cache during multi-platform + # exports are retried inside `.github/actions/docker-build-push-retry` + # (see docker/build-push-action#1422 — retry policy stays out of the + # upstream action). `ignore-error=true` on cache-to avoids cache export + # flakes failing an otherwise successful push. - name: Build and push id: build - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + uses: ./.github/actions/docker-build-push-retry with: context: . file: ${{ matrix.image.dockerfile }} platforms: linux/amd64,linux/arm64 - push: true + push: ${{ github.event_name != 'pull_request' && !inputs.dry_run }} 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 + cache-to: type=gha,mode=max,scope=${{ matrix.image.slug }},ignore-error=true # Cosign keyless signing. Each pushed tag is signed by the workflow's # OIDC identity, so consumers can verify the image with the strict, @@ -178,6 +221,7 @@ jobs: # 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) + if: ${{ github.event_name != 'pull_request' && !inputs.dry_run }} env: # Cosign v2 (installed by sigstore/cosign-installer above) makes # keyless the default. COSIGN_EXPERIMENTAL is a v1-only opt-in flag @@ -193,10 +237,23 @@ jobs: [[ -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 + # Attach the SBOM produced by buildx as a verifiable attestation on the + # digest. Attestations are pushed as OCI referrers to the registry named + # in `subject-name`, so we call the action once per registry. The digest + # is identical across registries (same build, same push), so consumers + # pulling from either GHCR or Docker Hub see the same provenance. + - name: Generate build provenance attestation (GHCR) + if: ${{ github.event_name != 'pull_request' && !inputs.dry_run }} 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 + + - name: Generate build provenance attestation (Docker Hub) + if: ${{ github.event_name != 'pull_request' && !inputs.dry_run }} + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + with: + subject-name: docker.io/akonlabs/${{ matrix.image.slug }} + subject-digest: ${{ steps.build.outputs.digest }} + push-to-registry: true diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml index 3c0c52725..51664bba8 100644 --- a/.github/workflows/pr-labeler.yml +++ b/.github/workflows/pr-labeler.yml @@ -105,7 +105,7 @@ jobs: # 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 + - uses: release-drafter/release-drafter@563bf132657a13ded0b01fcb723c5a58cdd824e2 # v7.2.1 with: config-name: release-drafter.yml dry-run: true diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3d883425a..1fc95a13a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -33,7 +33,7 @@ jobs: id-token: write steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 20 registry-url: https://registry.npmjs.org diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index 61782da1c..7413a02e1 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -133,7 +133,7 @@ jobs: fetch-depth: 0 fetch-tags: true - - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 20 registry-url: https://registry.npmjs.org @@ -379,6 +379,10 @@ jobs: needs: [guard, publish] if: needs.guard.outputs.should_run == 'true' && needs.publish.outputs.vtag != '' uses: ./.github/workflows/docker.yml + # Reusable workflows do not receive caller secrets unless inherited; without + # this, DOCKERHUB_* / GITHUB_TOKEN are empty in docker.yml → "Username and + # password required" on Docker Hub login (see same pattern on `ci:` above). + secrets: inherit permissions: contents: read packages: write diff --git a/.gitignore b/.gitignore index 92027b529..bbebd405d 100644 --- a/.gitignore +++ b/.gitignore @@ -106,3 +106,4 @@ local_docs/ # Local agent scratch / review prompts (never commit) .tmp/ .agents/ +.context/ diff --git a/AGENTS.md b/AGENTS.md index 17ff788bd..60317d73d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ - - + + -Last reviewed: 2026-04-20 +Last reviewed: 2026-04-23 **Project:** GitNexus · **Environment:** dev · **Maintainer:** repository maintainers (see GitHub) @@ -40,7 +40,7 @@ Commands and gotchas live under **Repo reference** below and in **[CONTRIBUTING. - **[ARCHITECTURE.md](ARCHITECTURE.md)**, **[CONTRIBUTING.md](CONTRIBUTING.md)**, **[GUARDRAILS.md](GUARDRAILS.md)** - **Call-resolution DAG (legacy path):** 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`. -- **Scope-resolution pipeline (RFC #909 Ring 3):** See ARCHITECTURE.md § Scope-Resolution Pipeline. Replaces the legacy DAG for languages in `MIGRATED_LANGUAGES` (currently Python). A language plugs in by implementing `ScopeResolver` (`scope-resolution/contract/scope-resolver.ts`) and registering it in `SCOPE_RESOLVERS`. CI parity gate runs BOTH paths per migrated language on every PR. +- **Scope-resolution pipeline (RFC #909 Ring 3):** See ARCHITECTURE.md § Scope-Resolution Pipeline. Replaces the legacy DAG for languages in `MIGRATED_LANGUAGES` (see `registry-primary-flag.ts`). A language plugs in by implementing `ScopeResolver` (`scope-resolution/contract/scope-resolver.ts`) and registering it in `SCOPE_RESOLVERS`. CI parity gate runs BOTH paths per migrated language on every PR. - **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. @@ -48,6 +48,7 @@ Commands and gotchas live under **Repo reference** below and in **[CONTRIBUTING. | Date | Version | Change | |------|---------|--------| +| 2026-04-23 | 1.7.0 | TypeScript added to `MIGRATED_LANGUAGES` (registry-primary call resolution by default). | | 2026-04-20 | 1.6.0 | Added scope-resolution pipeline pointer (RFC #909 Ring 3); Python migrated to registry-primary. | | 2026-04-19 | 1.5.0 | Cross-repo impact (#794): `impact`/`query`/`context` accept `repo: "@"` + `service`. Removed `group_query`/`group_contracts`/`group_status` MCP tools; added `gitnexus://group/{name}/contracts` and `gitnexus://group/{name}/status` resources. | | 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. | @@ -148,13 +149,14 @@ Indexed as **GitNexus** (4325 symbols, 10556 relationships, 300 execution flows) ## Keeping the Index Fresh ```bash -npx gitnexus analyze # basic refresh -npx gitnexus analyze --embeddings # preserve embeddings +npx gitnexus analyze # basic refresh; preserves any existing embeddings +npx gitnexus analyze --embeddings # also generate embeddings for new/changed nodes +npx gitnexus analyze --drop-embeddings # explicit opt-in to wipe existing embeddings ``` -Check `.gitnexus/meta.json` `stats.embeddings` (0 = none). Running without `--embeddings` deletes existing vectors. +Check `.gitnexus/meta.json` `stats.embeddings` (0 = none). A plain `analyze` no longer drops existing vectors — pass `--drop-embeddings` to wipe. -> Claude Code: PostToolUse hook handles this after `git commit` and `git merge`. +> Claude Code: PostToolUse hook detects a stale index after `git commit` and `git merge` and prompts the agent to run `analyze`. The hook does not invoke `analyze` itself. ## CLI Skills diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f54776eef..d39fef3a1 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -215,10 +215,52 @@ Both hooks are optional on `LanguageProvider`. Ruby is the only current implemen The Call-Resolution DAG is the **legacy path**. RFC #909 Ring 3 introduces a parallel **scope-resolution pipeline** (next section) that replaces stages 1–6 with a scope-indexed registry lookup. Both paths ship side-by-side and are gated per-language via `MIGRATED_LANGUAGES` + the `REGISTRY_PRIMARY_` env var. - **Unmigrated language** → Call-Resolution DAG runs; scope-resolution phase is a no-op. -- **Migrated language** (currently: Python) → scope-resolution owns CALLS/ACCESSES/USES emission; the legacy DAG gates off for that language via `isRegistryPrimary(lang)` checks in `call-processor.ts` and `import-processor.ts`. +- **Migrated language** (currently: Python, C#) → scope-resolution owns CALLS/ACCESSES/USES emission; the legacy DAG gates off for that language via `isRegistryPrimary(lang)` checks in `call-processor.ts` and `import-processor.ts`. - `import-processor` still populates `importMap` for migrated languages — heritage's `ctx.resolve` reads it to disambiguate parent classes. Only edge emission is gated. - CI runs BOTH paths for every migrated language on every PR (`.github/workflows/ci-scope-parity.yml`); both must pass. +#### Same-graph guarantee + +Edges emitted by the scope-resolution pipeline and edges emitted by the legacy DAG are indistinguishable to downstream consumers (MCP tools, HTTP API, embeddings, group bridge): + +- **Node identity** — both paths use `generateId(...)` from `lib/utils.ts`, the same qualified-name keyspace, and the same node labels (`File`, `Folder`, `Class`, `Method`, `Function`, …). Overload disambiguation suffixes `parameterTypes` into the id consistently — see `scope-resolution/graph-bridge/ids.ts` and the legacy emitter in `call-processor.ts`. +- **Edge vocabulary** — both paths emit the same reasons: `'import-resolved' | 'global' | 'local-call' | 'same-file' | 'interface-dispatch' | 'read' | 'write'`. Migrating a language must not change which reasons consumers see for previously-resolved edges. +- **Confidence tier** — both paths attach a numeric `confidence` to each edge using the same scale. + +The CI parity workflow (`.github/workflows/ci-scope-parity.yml`) runs both paths against every migrated language's fixture corpus and fails on any divergence. + +#### Semantic-model source of truth + +Two independent invariants. + +**ParsedFile = the AST-level truth.** `ParsedFile` (`gitnexus-shared/src/scope-resolution/parsed-file.ts`) is the single per-file artifact both resolution paths consume. Scope-resolution passes MUST NOT build a parallel parse representation. If a per-language hook needs AST-level facts that `ParsedFile` doesn't expose, it should reuse the orchestrator's `treeCache` (`RunScopeResolutionInput.treeCache`) rather than re-invoking `parser.parse(...)` on its own — the C# `populateNamespaceSiblings` hook is the reference implementation of this pattern. + +**SemanticModel = the symbol-level truth.** `SemanticModel` (`gitnexus/src/core/ingestion/model/semantic-model.ts`) is the authoritative store for every symbol-indexed lookup (by `nodeId`, `simpleName`, `qualifiedName`, or `filePath`). Both paths read from here: + +- Legacy Call-Resolution DAG → `call-processor` Tier 1/2/3 via `model.symbols.lookupExactAll`, `model.methods.lookupMethodByName`, `model.types.lookupClassByName`, `lookupMethodByOwnerWithMRO`. +- Scope-resolution pipeline → `findOwnedMember`, `pickOverload`, `findExportedDefByName` all consult `model.methods` / `model.fields` / `model.symbols`. + +The scope-resolution pipeline additionally carries `WorkspaceResolutionIndex` for `Scope`-valued lookups (`classScopeByDefId`, `moduleScopeByFile`) that `SemanticModel` structurally cannot hold. No symbol-indexed duplicates exist outside `SemanticModel`. + +**Write / read phase contract.** The model is mutable during three ordered phases and read-only afterward: + +``` + Phase 1: legacy parse ──► symbolTable.add fans into types/methods/fields + Phase 2: scope-resolution ──► reconcileOwnership() registers corrected ownerIds + Phase 3: finalize ──► model.attachScopeIndexes(bundle) — one-shot freeze + ─────────────────────────── phase boundary ─────────────────────────── + Read phase: all resolution passes + MCP + HTTP + embeddings see + SemanticModel (read-only handle); writes are type-errors. +``` + +`runScopeResolution` narrows `MutableSemanticModel` → `SemanticModel` at the phase boundary so downstream passes physically cannot mutate the model even accidentally. + +**Transitional: reconciliation pass.** `reconcileOwnership` (`scope-resolution/pipeline/reconcile-ownership.ts`) is a shim for languages whose legacy extractor doesn't resolve `enclosingClassId` at parse time (Python class-body methods are the canonical case). It walks `parsed.localDefs[i].ownerId` after `populateOwners` and registers any missed methods/fields into the model. Idempotent — safe to re-run, safe alongside languages whose legacy extractor already carries `ownerId` (C#). + +The architectural end state is for every language's parse-time extractor to emit the correct `ownerId` directly, making reconciliation a no-op (tracked as a follow-up refactor). The dev-mode validator `validateOwnershipParity` surfaces any drift via `onWarn` under `NODE_ENV !== 'production' && VALIDATE_SEMANTIC_MODEL !== '0'`. + +References: `semantic-model.ts` file-head (full write/read contract); `contract/scope-resolver.ts` Contract Invariant I9 (scope-resolution-side rule). + --- ## Scope-Resolution Pipeline (RFC #909 Ring 3) @@ -260,6 +302,11 @@ Single interface a language implements to plug into the pipeline. Contract fully | `arityCompatibility` | Provider consumed by registry during `MethodRegistry.lookup` Step 2 | | `importEdgeReason` | Confidence-tier string for IMPORTS edge reason field | | `propagatesReturnTypesAcrossImports?` | Opt out of cross-file return-type propagation (default on) | +| `fieldFallbackOnMethodLookup?` | Statically-typed languages turn this OFF — the heuristic over-connects (default on) | +| `unwrapCollectionAccessor?` | Property-style collection views (`data.Values` on Dictionary-like receivers) — default off | +| `collapseMemberCallsByCallerTarget?` | One CALLS edge per (caller, target) instead of per-site — default off | +| `populateNamespaceSiblings?` | Cross-file implicit visibility (compiler-implicit namespace sharing) — default off; ctx carries `treeCache` | +| `hoistTypeBindingsToModule?` | Walk up to Module scope when looking up a method's return-type typeBinding — default off; enable only when bindings are stored at module level | ### Per-language registration @@ -284,12 +331,16 @@ CI auto-discovers the set via `tsx`. No workflow edit required. | `registry-primary-flag.ts` | `MIGRATED_LANGUAGES` set + `isRegistryPrimary(lang)` | | `languages/python/index.ts` | Python `ScopeResolver` hooks + known-limitation docs | | `languages/python/captures.ts` | `emitPythonScopeCaptures` (honors cross-phase Tree cache) | +| `languages/csharp/index.ts` | C# `ScopeResolver` hooks + known-limitation docs | +| `languages/csharp/captures.ts` | `emitCsharpScopeCaptures` (honors cross-phase Tree cache) | +| `languages/csharp/namespace-siblings.ts` | Cross-file implicit-namespace visibility hook (reads `treeCache`) | ### Performance notes - **Cross-phase Tree cache**: parse phase writes Trees into `scopeTreeCache` (separate from the chunk-local `astCache`) ONLY for languages with `emitScopeCaptures`. Scope-resolution reads from it to skip the second parse. Cleared at end of the phase. Workers leave the cache empty — Trees can't cross MessageChannels; cache miss = fresh parse. `PROF_SCOPE_RESOLUTION=1` emits hit/miss counters and a worker-engaged warning. - **Typed relationship iteration**: heritage + MRO walk only the EXTENDS / IMPLEMENTS / HAS_METHOD edges via `iterRelationshipsByType`, not the full relationship map. - **Workspace-resolution-index**: O(1) `findOwnedMember` / `findExportedDef` / `classScopeByDefId` built once per run. +- **SCC-ordered cross-file return-type propagation** (PR #1050): `propagateImportedReturnTypes` walks `indexes.sccs` in reverse-topological order (leaves first), so multi-hop alias chains like `models.User → service.user → app.user` collapse to the terminal class in a single linear pass. Within each importer, the source module's `typeBindings` is chain-followed BEFORE mirroring (so we mirror terminal types, not intermediate refs), and the importer's own `typeBindings` is chain-followed AFTER mirroring (so local `const x = importedFn()` resolves before downstream importers run). Cyclic SCCs reach a partial fixpoint within a single pass without iterating to convergence — see the `ts-circular` cross-file-binding fixture which only asserts pipeline-no-throw. PROF output (`PROF_SCOPE_RESOLUTION=1`) splits `finalize` from `propagate` so quadratic regressions in the chain-follow surface independently. --- diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7f797f9a0..0ed2aeba5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -129,9 +129,10 @@ Two publish workflows ship `gitnexus` to npm: 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). + (e.g. `ghcr.io/abhigyanpatwari/gitnexus:1.7.0-rc.1`, mirrored to + `docker.io/akonlabs/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 diff --git a/DoD.md b/DoD.md new file mode 100644 index 000000000..83b7132df --- /dev/null +++ b/DoD.md @@ -0,0 +1,209 @@ +# Definition of Done — GitNexus + +Last reviewed: 2026-04-23 · Version: 2.0.0 + +This document defines the repo-wide completion bar for production-ready changes in GitNexus. It is the stable baseline. Implementation prompts, agent behavior, and review workflows may add task-specific checks, but they must never weaken this bar. + +Use it together with: + +- `AGENTS.md` — agent-facing rules of engagement +- `GUARDRAILS.md` — hard safety constraints +- `CONTRIBUTING.md` — contributor workflow +- `TESTING.md` — test strategy and coverage expectations +- `ARCHITECTURE.md` — pipeline boundaries, Call-Resolution DAG, LanguageProvider contract + +## 1. Scope and Intent + +A change is **Done** when it is correct, safely integrated, appropriately tested, operationally sound, and a net improvement to the codebase — not merely "the code compiles and a test passes." + +This DoD applies to: + +- CLI, MCP, and HTTP-bridge behavior in `gitnexus/` +- Browser UI in `gitnexus-web/` +- Shared contracts in `gitnexus-shared/` +- CI workflows, release pipelines, and repo-level docs + +Out of scope: full agent personas, step-by-step implementation prompts, verbose review formatting rules, repo walkthroughs already covered elsewhere, temporary task-specific acceptance criteria. Those belong in prompts, PR templates, or other repo docs. + +## 2. Core Definition of Done + +Every change must satisfy **every relevant item** below. If an item does not apply, say so explicitly in the PR description. + +### 2.1 Correctness and Completeness + +- [ ] The requested behavior is implemented end-to-end in the **real runtime path** for the affected surface — no dead code, partial wiring, test-only shims, or "works in isolation but not in production" seams. +- [ ] Edge cases relevant to the changed surface are handled or explicitly documented as out of scope. +- [ ] Error handling is proportionate: inputs at system boundaries (user input, external APIs, filesystem, process spawn) are validated; internal, framework-guaranteed paths are trusted. +- [ ] The change produces the same result on re-run (idempotent where expected) and does not rely on accidental ordering. + +### 2.2 Architecture and Placement + +- [ ] The change is placed in the correct package and layer: + - `gitnexus/` for CLI, MCP, HTTP bridge, ingestion, graph, and runtime logic + - `gitnexus-web/` for browser UI (thin client — no WASM workers, all queries via HTTP API) + - `gitnexus-shared/` for shared contracts, types, and constants +- [ ] Pipeline and architecture boundaries remain explicit. Shared ingestion code in `gitnexus/src/core/ingestion/` must not name languages — use `LanguageProvider` hooks (see `AGENTS.md` and `ARCHITECTURE.md` § Call-Resolution DAG). +- [ ] No hidden cross-phase coupling; no leaking of language-specific logic into shared infrastructure without a documented architectural reason. +- [ ] Runtime and graph behavior are consistent — the real source of truth is fixed at the source, not symptom-patched in a downstream layer. +- [ ] Direct imports from `gitnexus-shared` are used. No barrel re-exports introduced to paper over drift between packages. + +### 2.3 Design and Readability + +- [ ] The implementation is the **smallest correct solution** for the requirement. No speculative abstraction, unnecessary indirection, clever but hard-to-follow control flow, or unrelated cleanup. +- [ ] Naming, control flow, ownership, and extension points are clear enough that the next contributor can extend the code without archaeology. +- [ ] Comments are minimal and useful — they explain intent, invariants, contracts, or non-obvious constraints. No stale comments, placeholder comments, narrated code, commented-out code, or "what" comments where a good name would do. +- [ ] No copy-paste duplication created for convenience; no premature deduplication of three similar lines. + +### 2.4 Contracts and Compatibility + +- [ ] Existing contracts (types in `gitnexus-shared/`, CLI flags, MCP tools/resources, HTTP routes, graph node/edge shapes, persisted IDs) are preserved unless the task explicitly requires a contract change. +- [ ] Any contract change is intentional, explicit, and reflected in **every direct consumer** in the same change, with types aligned end-to-end. +- [ ] Persisted data changes (graph schema, IDs, embeddings) are backward-compatible or accompanied by a documented migration / reindex path. +- [ ] If user-visible behavior, public usage, CLI help, or README examples change, the relevant docs, examples, help text, or migration notes are updated in the same change. + +### 2.5 Security + +- [ ] No new injection surfaces (command, path, SQL/Cypher-style, prompt) introduced on paths that consume untrusted input. +- [ ] No secrets, tokens, or credentials committed to the repo, to logs, or to error messages. +- [ ] Filesystem access honors the repo-scope and indexed-repo boundaries documented in `AGENTS.md` and `GUARDRAILS.md`. +- [ ] Third-party dependencies added or bumped are justified, from reputable sources, and do not regress the supply-chain posture. + +### 2.6 Performance and Resource Use + +- [ ] No repeated avoidable work, unnecessary scans, unnecessary round-trips, unbounded caches, or obvious hot-path regressions. +- [ ] Tree-sitter buffer sizing follows the adaptive 512KB–32MB convention (`getTreeSitterBufferSize`) — do not hard-code new buffer sizes. +- [ ] Memory and handle lifecycles are explicit: database handles (LadybugDB) close cleanly, no dangling process watchers, no leaked tree-sitter parsers. +- [ ] Long-running or large-graph paths remain bounded or are measurably streamed; degradation on large real repos is considered, not assumed benign. + +### 2.7 Tests + +- [ ] Tests cover the **real changed path** — they would fail if behavior, wiring, or contracts were broken, not only if a mock were misconfigured. +- [ ] Integration tests hit a real database where the production path does; do not introduce mocks that hide migration or schema drift. +- [ ] Assertions are meaningful. Use `toBe` / `toEqual` for exact expectations; avoid `toBeGreaterThanOrEqual` and other bounds-only assertions that mask regressions. +- [ ] Fixtures are realistic enough for the risk of the change — a one-file fixture is not sufficient for a pipeline-wide behavior change. +- [ ] New tests are deterministic and do not depend on network, clock, or host-specific paths without explicit isolation. + +### 2.8 Observability and Operability + +- [ ] Errors surfaced to users or callers are actionable: they name what failed, what input was involved (without leaking secrets), and how to recover where possible. +- [ ] Logging is proportionate — no noisy debug logs left in hot paths, no silent catches that swallow diagnostics. +- [ ] CLI exit codes and MCP tool responses are correct for each outcome (success, user error, internal error). +- [ ] Progress reporting (`PipelineProgress` and similar shared contracts) remains accurate after the change. + +### 2.9 Reversibility and Risk + +- [ ] The change has a clear rollback story: revert is safe, or migration is accompanied by a documented rollback / reindex procedure. +- [ ] Residual risks, compatibility impacts, and operational concerns are either resolved or **clearly stated** in the PR description. +- [ ] Destructive or hard-to-reverse operations (graph rebuild, schema change, `git` state manipulation) are opt-in or guarded. + +## 3. Agent-Assisted Workflow Guardrails + +When the change is produced with or reviewed by an AI agent, the following additional gates apply: + +- [ ] **Scope match.** The final diff matches the intended symbols, files, and processes — no speculative refactors, unrelated formatting churn, or collateral edits outside the task scope. +- [ ] **Evidence-based edits.** Claims about repo state are verified against the current code, not trusted from memory or stale documentation. +- [ ] **Impact analysis.** Where GitNexus graph tooling is available and relevant, impact of non-trivial symbol, contract, or runtime-path changes is checked **before** editing. +- [ ] **Embeddings preserved.** If an indexed repo already has embeddings and re-analysis is required, embeddings are preserved — not accidentally dropped by a destructive reindex. +- [ ] **No false-done.** "Done" is claimed only after the Validation Baseline below has been run or any gap is explicitly named. Green tests on an unrelated path do not constitute validation. +- [ ] **Five-axis self-review** before handing off: correctness, readability, architecture, security, performance. + +## 4. Validation Baseline + +Run the commands relevant to the touched area. If something cannot be run in the current environment, state it explicitly in the handoff. + +### 4.1 Build ordering + +- [ ] `gitnexus-shared/` dist is built before consuming packages are typechecked or tested (CI uses the `setup-gitnexus` action for this — local runs must match). + +### 4.2 If `gitnexus/` changed + +- [ ] `cd gitnexus && npx tsc --noEmit` +- [ ] `cd gitnexus && npm test` +- [ ] `cd gitnexus && npx prettier --check .` for files in the diff (pre-commit runs the affected-tests subset; do not expand scope) + +### 4.3 If `gitnexus-web/` changed + +- [ ] `cd gitnexus-web && npx tsc -b --noEmit` +- [ ] `cd gitnexus-web && npm test` +- [ ] `cd gitnexus-web && npm run test:e2e` when browser flows or user-facing UI behavior changed + +### 4.4 If `gitnexus-shared/` changed + +- [ ] Shared package builds cleanly (`npm run build` in `gitnexus-shared/`) +- [ ] Dependent packages still typecheck and test after the shared change — verify both CLI and web consumers together + +### 4.5 If CI workflows or release pipelines changed + +- [ ] The workflow passes a dry-run or triggered run before merge; concurrency (`cancel-in-progress`) and the `setup-gitnexus` action remain wired correctly. +- [ ] `CHANGELOG.md` is **not** edited here — it is owned by the release process. + +## 5. Review Gates + +A reviewer (human or agent) should be able to answer **yes** to each of the following before approving: + +1. **Correctness** — Does the change do what it claims on the real runtime path? +2. **Readability** — Will the next contributor understand this in six months without asking? +3. **Architecture** — Is it in the right package, layer, and phase? Are boundaries respected? +4. **Security** — No new injection, leak, or trust-boundary violation? +5. **Performance** — No obvious regression on realistic inputs? +6. **Tests** — Would a regression in the changed behavior fail loudly? +7. **Scope** — Does the diff match the intended change, with no unrelated churn? + +## 6. "Not Done" Signals + +A change is **not** Done if any of the following is true, even if CI is green: + +- The runtime path is not actually exercised by the tests. +- A contract drifted between `gitnexus/`, `gitnexus-web/`, and `gitnexus-shared/` and only one side was updated. +- A language-specific concern leaked into shared ingestion code. +- The diff contains unrelated reformatting, refactors, or cleanup beyond the stated task. +- Logs, comments, or TODOs were added as placeholders for work not done. +- The change depends on a manual step that is not documented. +- `CHANGELOG.md` was edited during PR work. +- Pre-commit, prettier, or typecheck was bypassed without explicit justification. + +## 7. Task-Specific DoD Template + +Use this in implementation and review prompts. Keep it short and tailor it to the actual change: + +```md +# Definition of Done for this implementation + +- [ ] Runtime wiring is complete for the affected path. +- [ ] Requested behavior is correct and relevant contracts are preserved or explicitly updated. +- [ ] The design stays scoped, readable, and proportionate to the task. +- [ ] Tests prove the changed behavior and catch broken wiring. +- [ ] Required validation for touched packages has been run, or any gap is explicitly noted. +- [ ] Repo boundaries, security, performance, and operational safety are respected. +- [ ] The diff contains only the intended change — no unrelated churn. +``` + +## 8. How to Use This File in Claude Review + +Reference this file as the repo-wide completion bar. Add a task-specific review instruction such as: + +```md +Review this change against `DoD.md` and the repo docs (`AGENTS.md`, `GUARDRAILS.md`, +`CONTRIBUTING.md`, `TESTING.md`, `ARCHITECTURE.md`). Treat `DoD.md` as the minimum +bar for production readiness. Flag anything that is partially wired, contract-unsafe, +under-tested, architecturally misplaced, scope-creeping, or harder to maintain than +necessary. Apply the five-axis review gate: correctness, readability, architecture, +security, performance. +``` + +## 9. Evolution + +This DoD is living. Revisit it when: + +- A class of incident slips past it (add a gate). +- A gate becomes consistently ceremonial without catching issues (remove or merge it). +- The architecture evolves in a way that changes what "done" means (update placement, validation, or contracts sections). + +Track material updates in the changelog below. Keep the file tight — if it grows past a single read-in-one-sitting, something has drifted into the wrong place. + +## Changelog + +| Date | Version | Change | +| ---------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 2026-04-23 | 2.0.0 | Restructured into numbered sections; added Security, Observability, Reversibility, Agent-Assisted Guardrails, Review Gates, Not-Done Signals; expanded validation baseline (shared-first build, prettier, CI workflow checks). | +| 2026-04-13 | 1.0.0 | Initial repo-wide Definition of Done. | diff --git a/GUARDRAILS.md b/GUARDRAILS.md index ac48ab906..1cc032759 100644 --- a/GUARDRAILS.md +++ b/GUARDRAILS.md @@ -19,7 +19,7 @@ Maintainer may widen scope per task. 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. +5. **Preserve embeddings** — plain `npx gitnexus analyze` now preserves any embeddings recorded in `.gitnexus/meta.json` (the previous behavior wiped them). Use `--embeddings` to also generate vectors for new/changed nodes; use `--drop-embeddings` only when an explicit wipe is intended (e.g., model swap). --- @@ -36,8 +36,8 @@ Format: **Trigger → Instruction → Reason**. Append new Signs when the same m ### Embeddings vanished after analyze - **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. +- **Do:** Re-run `npx gitnexus analyze --embeddings` to regenerate. Check the analyze log for a `Warning: could not load cached embeddings` line — if present, the cache restore failed (corrupt DB / schema mismatch) and the rebuild had nothing to preserve. If you intentionally passed `--drop-embeddings`, this is expected. +- **Why:** Plain `analyze` preserves prior vectors by re-inserting them after the rebuild; the only ways to end up at zero are an explicit `--drop-embeddings`, a cache-load failure (now logged), or a model/dimension change that invalidates the cache. ### MCP lists no repos diff --git a/README.md b/README.md index 5260e8388..ade7a4879 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # GitNexus -⚠️ Important Notice:** GitNexus has NO official cryptocurrency, token, or coin. Any token/coin using the GitNexus name on Pump.fun or any other platform is **not affiliated with, endorsed by, or created by** this project or its maintainers. Do not purchase any cryptocurrency claiming association with GitNexus. +**⚠️ Important Notice:** GitNexus has NO official cryptocurrency, token, or coin. Any token/coin using the GitNexus name on Pump.fun or any other platform is **not affiliated with, endorsed by, or created by** this project or its maintainers. Do not purchase any cryptocurrency claiming association with GitNexus.
@@ -36,7 +36,7 @@ https://github.com/user-attachments/assets/172685ba-8e54-4ea7-9ad1-e31a3398da72 > *Like DeepWiki, but deeper.* DeepWiki helps you *understand* code. GitNexus lets you *analyze* it — because a knowledge graph tracks every relationship, not just descriptions. -**TL;DR:** The **Web UI** is a quick way to chat with any repo. The **CLI + MCP** is how you make your AI agent actually reliable — it gives Cursor, Claude Code, Codex, and friends a deep architectural view of your codebase so they stop missing dependencies, breaking call chains, and shipping blind edits. Even smaller models get full architectural clarity, making it compete with goliath models. +**TL;DR:** The **Web UI** is a quick way to chat with any repo. The **CLI + MCP** is how you make your AI agent actually reliable — it gives Cursor, Claude Code, Codex, and friends a deep architectural view of your codebase so they stop missing dependencies, breaking call chains, and shipping blind edits. Even smaller models get full architectural clarity, making it compete with Goliath models. --- @@ -120,7 +120,7 @@ To configure MCP for your editor, run `npx gitnexus setup` once — or set it up | **Windsurf** | Yes | — | — | MCP | | **OpenCode** | Yes | Yes | — | MCP + Skills | -> **Claude Code** gets the deepest integration: MCP tools + agent skills + PreToolUse hooks that enrich searches with graph context + PostToolUse hooks that auto-reindex after commits. +> **Claude Code** gets the deepest integration: MCP tools + agent skills + PreToolUse hooks that enrich searches with graph context + PostToolUse hooks that detect a stale index after commits and prompt the agent to reindex. ## Community Integrations @@ -197,6 +197,7 @@ gitnexus analyze --skip-agents-md # Preserve custom AGENTS.md/CLAUDE.md gitnexu gitnexus analyze --skip-git # Index folders that are not Git repositories gitnexus analyze --embeddings # Enable embedding generation (slower, better search) gitnexus analyze --verbose # Log skipped files when parsers are unavailable +gitnexus analyze --worker-timeout 60 # Increase worker idle timeout for slow parses gitnexus mcp # Start MCP server (stdio) — serves all indexed repos gitnexus serve # Start local HTTP server (multi-repo) for web UI connection gitnexus list # List all indexed repositories @@ -218,6 +219,8 @@ gitnexus group query # Search execution flows across all repos in a gitnexus group status # Check staleness of repos in a group ``` +If `analyze` reports a worker parse timeout on a large or unusual repository, it keeps running and falls back safely. To give slow worker jobs more time, use `gitnexus analyze --worker-timeout 60` or set `GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=60000`. For very large files, `GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES` controls the worker job byte budget. + ### What Your AI Agent Gets **16 tools** exposed via MCP (11 per-repo + 5 group): @@ -321,29 +324,31 @@ flowchart TD ## Web UI (browser-based) -A fully client-side graph explorer and AI chat. No server, no install — your code never leaves the browser. +A client-side graph explorer and AI chat — your code never leaves your machine. -**Try it now:** [gitnexus.vercel.app](https://gitnexus.vercel.app) — drag & drop a ZIP and start exploring. +**Try it now:** [gitnexus.vercel.app](https://gitnexus.vercel.app) — run `npx gitnexus@latest serve` locally and the page auto-connects to your local backend. gitnexus_img -Or run locally: +Or run the frontend locally: ```bash git clone https://github.com/abhigyanpatwari/gitnexus.git cd gitnexus/gitnexus-shared && npm install && npm run build cd ../gitnexus-web && npm install npm run dev +# Then in another terminal, start the backend the frontend connects to: +npx gitnexus@latest serve ``` ## Docker -The official Docker setup ships **two signed images** orchestrated by `docker-compose.yaml`: +The official Docker setup ships **two signed images** orchestrated by `docker-compose.yaml`. Each image is published to both **GitHub Container Registry** (GHCR) and **Docker Hub** — same build, same digest, same Cosign signature — so pick whichever registry you prefer: -| 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`) | +| Purpose | GHCR (default in `docker-compose.yaml`) | Docker Hub mirror | +| ---------------------------------------------------------------------- | --------------------------------------------- | ------------------------------------------- | +| CLI / `gitnexus serve` backend (HTTP API on port `4747`, MCP, indexer) | `ghcr.io/abhigyanpatwari/gitnexus:latest` | `akonlabs/gitnexus:latest` | +| Static web UI (port `4173`) | `ghcr.io/abhigyanpatwari/gitnexus-web:latest` | `akonlabs/gitnexus-web:latest` | > **Heads-up — image rename.** Earlier releases published the web UI under > `ghcr.io/abhigyanpatwari/gitnexus`. Starting with the introduction of the @@ -404,8 +409,11 @@ 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`. + `ghcr.io/abhigyanpatwari/gitnexus:1.6.2` (and its Docker Hub mirror + `akonlabs/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`. + Both registries receive the same digest from a single build step, so you can + pull from either and the signature verifies identically. - 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. @@ -426,11 +434,18 @@ sensitive environments: 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 + +# Same signature verifies the Docker Hub mirror (identical digest): +cosign verify docker.io/akonlabs/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. +workflows, and images signed from unprotected refs. It is identical for both +registries because both sets of tags were signed at the same digest in one +workflow run. **Release candidates** — signed from `refs/heads/main` (the caller's ref when `release-candidate.yml` invokes `docker.yml` as a reusable workflow): diff --git a/deploy/kubernetes/cluster-image-policy.yaml b/deploy/kubernetes/cluster-image-policy.yaml index 9287a3c45..f04ed0fc7 100644 --- a/deploy/kubernetes/cluster-image-policy.yaml +++ b/deploy/kubernetes/cluster-image-policy.yaml @@ -36,12 +36,24 @@ 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. + # Apply to both published GitNexus images on both registries. Image + # references always carry a tag or digest at admission time, so these globs + # cover every `gitnexus:`, `gitnexus@sha256:...`, `gitnexus-web:`, + # and `gitnexus-web@sha256:...` reference on either GHCR or Docker Hub. + # The Docker Hub images are byte-for-byte mirrors of the GHCR images (same + # build, same digest, same Cosign signature), so the same keyless identity + # authority verifies both. images: - glob: 'ghcr.io/abhigyanpatwari/gitnexus*' + # Docker Hub references can appear in three forms at admission time + # (`docker.io/...`, `index.docker.io/...`, and bare `akonlabs/...` with + # the default registry implied). List all three so the policy cannot be + # sidestepped by the choice of registry prefix. The Docker Hub namespace + # is `akonlabs` rather than `abhigyanpatwari` because the Docker Hub org + # differs from the GitHub org. + - glob: 'docker.io/akonlabs/gitnexus*' + - glob: 'index.docker.io/akonlabs/gitnexus*' + - glob: 'akonlabs/gitnexus*' authorities: - name: gitnexus-cosign-keyless keyless: diff --git a/docker-compose.yaml b/docker-compose.yaml index d17f012db..297eab85e 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -19,7 +19,7 @@ services: - ${WORKSPACE_DIR:-./workspace}:/workspace:ro restart: unless-stopped healthcheck: - test: ['CMD', 'curl', '-fsS', 'http://localhost:4747/api/heartbeat'] + test: ['CMD', 'curl', '-fsSI', 'http://localhost:4747/api/heartbeat'] interval: 30s timeout: 5s retries: 3 diff --git a/docs/guides/microservices-grpc.md b/docs/guides/microservices-grpc.md index afe6b2499..b09fd55e3 100644 --- a/docs/guides/microservices-grpc.md +++ b/docs/guides/microservices-grpc.md @@ -82,6 +82,9 @@ matching: bm25_threshold: 0.7 embedding_threshold: 0.65 max_candidates_per_step: 3 + # Exclude noisy paths from cross-link matching (contracts are still extracted) + exclude_links_paths: [/ping, /health, /healthcheck] + exclude_links_param_only_paths: true ``` Field notes (schema in [`types.ts`](../../gitnexus/src/core/group/types.ts)): @@ -91,7 +94,9 @@ Field notes (schema in [`types.ts`](../../gitnexus/src/core/group/types.ts)): - `repos` — a mapping from **group path** (a logical name you choose; can be a hierarchy like `backend/orders`) to **registry name** (the name shown by `npx gitnexus list`). Both sides appear throughout the tooling: contract rows use the group path; `@/` routes tools to a single member. - `links` — optional manifest escape hatch, one entry per explicit cross-repo contract. Validated by the parser: `from` and `to` must be known repo paths, `type` must be one of `http | grpc | topic | lib | custom`, and `role` must be `provider | consumer`. - `detect` — toggles per extractor family. Defaults (set in `config-parser.ts`) turn `http`, `grpc`, `topics`, and `shared_libs` on; disable the ones you don't use to speed up sync. -- `matching` — thresholds for the matching cascade. The exact match is always run; other strategies depend on indexer state. +- `matching` — thresholds for the matching cascade. The exact match is always run; other strategies depend on indexer state. Two optional fields reduce false-positive cross-links in large groups: + - `exclude_links_paths` — list of HTTP paths to exclude from cross-link matching (default `[]`). Contracts at these paths are still extracted and visible in the registry, but they don't produce cross-repo links. Useful for health-check endpoints (`/ping`, `/health`) that every service exposes. Trailing slashes are normalized. + - `exclude_links_param_only_paths` — when `true`, exclude routes where every segment is `{param}` (e.g. `/{param}`, `/{param}/{param}`) from cross-link matching (default `false`). Mixed routes like `/users/{param}` are not affected. ### 3. Sync the group diff --git a/eval/bridge/mcp_bridge.py b/eval/bridge/mcp_bridge.py index 7c2c0262c..aa10d5608 100644 --- a/eval/bridge/mcp_bridge.py +++ b/eval/bridge/mcp_bridge.py @@ -53,13 +53,13 @@ class MCPBridge: try: # Find gitnexus binary - gitnexus_bin = self._find_gitnexus() - if not gitnexus_bin: + gitnexus_cmd = self._find_gitnexus_command() + if not gitnexus_cmd: logger.error("GitNexus not found. Install with: npm install -g gitnexus") return False self.process = subprocess.Popen( - [gitnexus_bin, "mcp"], + [*gitnexus_cmd, "mcp"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, @@ -152,33 +152,34 @@ class MCPBridge: return contents[0].get("text", "") return None - def _find_gitnexus(self) -> str | None: - """Find the gitnexus CLI binary.""" + def _find_gitnexus_command(self) -> list[str] | None: + """Find the gitnexus CLI command prefix.""" # Check if npx is available (preferred - uses local install) - for cmd in ["npx"]: - try: - result = subprocess.run( - [cmd, "gitnexus", "--version"], - capture_output=True, - text=True, - timeout=MCP_FIND_GITNEXUS_TIMEOUT_SECONDS, - cwd=self.repo_path, - ) - if result.returncode == 0: - return cmd # Will use "npx gitnexus mcp" - except Exception: - continue + try: + result = subprocess.run( + ["npx", "gitnexus", "--version"], + stdin=subprocess.DEVNULL, + capture_output=True, + text=True, + timeout=MCP_FIND_GITNEXUS_TIMEOUT_SECONDS, + cwd=self.repo_path, + ) + if result.returncode == 0: + return ["npx", "gitnexus"] + except Exception: + pass # Check for global install try: result = subprocess.run( ["gitnexus", "--version"], + stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=MCP_FIND_GITNEXUS_FALLBACK_TIMEOUT_SECONDS, ) if result.returncode == 0: - return "gitnexus" + return ["gitnexus"] except Exception: pass diff --git a/eval/tests/test_mcp_bridge.py b/eval/tests/test_mcp_bridge.py new file mode 100644 index 000000000..b10178a13 --- /dev/null +++ b/eval/tests/test_mcp_bridge.py @@ -0,0 +1,170 @@ +"""Tests for MCPBridge._find_gitnexus_command() and subprocess spawn.""" +import subprocess +import unittest +from unittest.mock import MagicMock, call, patch + + +class TestFindGitnexusCommand(unittest.TestCase): + """Verify _find_gitnexus_command() returns the correct command prefix.""" + + def _make_bridge(self): + from bridge.mcp_bridge import MCPBridge + return MCPBridge(repo_path="/fake/repo") + + def _success(self): + r = MagicMock() + r.returncode = 0 + return r + + def _failure(self): + r = MagicMock() + r.returncode = 1 + return r + + def test_npx_path_returns_npx_gitnexus(self): + """When npx probe succeeds, command prefix is ['npx', 'gitnexus'].""" + with patch("subprocess.run", return_value=self._success()) as mock_run: + bridge = self._make_bridge() + result = bridge._find_gitnexus_command() + + self.assertEqual(result, ["npx", "gitnexus"]) + mock_run.assert_called_once() + args = mock_run.call_args[0][0] + self.assertEqual(args, ["npx", "gitnexus", "--version"]) + + def test_global_path_returns_gitnexus(self): + """When npx probe fails but global install exists, prefix is ['gitnexus'].""" + with patch("subprocess.run", side_effect=[self._failure(), self._success()]) as mock_run: + bridge = self._make_bridge() + result = bridge._find_gitnexus_command() + + self.assertEqual(result, ["gitnexus"]) + self.assertEqual(mock_run.call_count, 2) + + def test_both_fail_returns_none(self): + """When both probes fail, returns None.""" + with patch("subprocess.run", return_value=self._failure()): + bridge = self._make_bridge() + result = bridge._find_gitnexus_command() + + self.assertIsNone(result) + + def test_npx_exception_falls_back_to_global(self): + """When npx raises (not installed), falls back to global probe.""" + with patch("subprocess.run", side_effect=[FileNotFoundError, self._success()]): + bridge = self._make_bridge() + result = bridge._find_gitnexus_command() + + self.assertEqual(result, ["gitnexus"]) + + def test_both_raise_returns_none(self): + """When both probes raise exceptions, returns None.""" + with patch("subprocess.run", side_effect=FileNotFoundError): + bridge = self._make_bridge() + result = bridge._find_gitnexus_command() + + self.assertIsNone(result) + + def test_stdin_devnull_on_npx_probe(self): + """npx probe must pass stdin=DEVNULL to prevent interactive blocking.""" + with patch("subprocess.run", return_value=self._success()) as mock_run: + bridge = self._make_bridge() + bridge._find_gitnexus_command() + + kwargs = mock_run.call_args[1] + self.assertEqual(kwargs.get("stdin"), subprocess.DEVNULL) + + def test_stdin_devnull_on_global_probe(self): + """global probe must pass stdin=DEVNULL to prevent interactive blocking.""" + with patch("subprocess.run", side_effect=[self._failure(), self._success()]) as mock_run: + bridge = self._make_bridge() + bridge._find_gitnexus_command() + + global_call_kwargs = mock_run.call_args_list[1][1] + self.assertEqual(global_call_kwargs.get("stdin"), subprocess.DEVNULL) + + +class TestStartSpawnCommand(unittest.TestCase): + """Verify start() spawns Popen with the correct argv.""" + + def _make_bridge(self): + from bridge.mcp_bridge import MCPBridge + return MCPBridge(repo_path="/fake/repo") + + def test_npx_path_spawns_npx_gitnexus_mcp(self): + """When npx path found, Popen must receive ['npx', 'gitnexus', 'mcp'].""" + bridge = self._make_bridge() + + with patch.object(bridge, "_find_gitnexus_command", return_value=["npx", "gitnexus"]), \ + patch("subprocess.Popen") as mock_popen, \ + patch.object(bridge, "_send_request", return_value={"protocolVersion": "2024-11-05"}), \ + patch.object(bridge, "_send_notification"): + + mock_proc = MagicMock() + mock_proc.stdin = MagicMock() + mock_proc.stdout = MagicMock() + mock_proc.stderr = MagicMock() + mock_popen.return_value = mock_proc + + bridge.start() + + mock_popen.assert_called_once() + argv = mock_popen.call_args[0][0] + self.assertEqual(argv, ["npx", "gitnexus", "mcp"]) + + def test_global_path_spawns_gitnexus_mcp(self): + """When global path found, Popen must receive ['gitnexus', 'mcp'].""" + bridge = self._make_bridge() + + with patch.object(bridge, "_find_gitnexus_command", return_value=["gitnexus"]), \ + patch("subprocess.Popen") as mock_popen, \ + patch.object(bridge, "_send_request", return_value={"protocolVersion": "2024-11-05"}), \ + patch.object(bridge, "_send_notification"): + + mock_proc = MagicMock() + mock_proc.stdin = MagicMock() + mock_proc.stdout = MagicMock() + mock_proc.stderr = MagicMock() + mock_popen.return_value = mock_proc + + bridge.start() + + mock_popen.assert_called_once() + argv = mock_popen.call_args[0][0] + self.assertEqual(argv, ["gitnexus", "mcp"]) + + def test_no_shell_true(self): + """Popen must never be called with shell=True.""" + bridge = self._make_bridge() + + with patch.object(bridge, "_find_gitnexus_command", return_value=["npx", "gitnexus"]), \ + patch("subprocess.Popen") as mock_popen, \ + patch.object(bridge, "_send_request", return_value={"protocolVersion": "2024-11-05"}), \ + patch.object(bridge, "_send_notification"): + + mock_proc = MagicMock() + mock_proc.stdin = MagicMock() + mock_proc.stdout = MagicMock() + mock_proc.stderr = MagicMock() + mock_popen.return_value = mock_proc + + bridge.start() + + kwargs = mock_popen.call_args[1] + self.assertNotEqual(kwargs.get("shell"), True) + + def test_gitnexus_not_found_returns_false(self): + """start() returns False and does not call Popen when gitnexus not found.""" + bridge = self._make_bridge() + + with patch.object(bridge, "_find_gitnexus_command", return_value=None), \ + patch("subprocess.Popen") as mock_popen: + + result = bridge.start() + + self.assertFalse(result) + mock_popen.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/gitnexus-claude-plugin/hooks/gitnexus-hook.js b/gitnexus-claude-plugin/hooks/gitnexus-hook.js index e29726d85..c3c4ca0de 100644 --- a/gitnexus-claude-plugin/hooks/gitnexus-hook.js +++ b/gitnexus-claude-plugin/hooks/gitnexus-hook.js @@ -31,11 +31,21 @@ function readInput() { * Find the .gitnexus directory by walking up from startDir. * Returns the path to .gitnexus/ or null if not found. */ +function isGlobalRegistryDir(candidate) { + if (fs.existsSync(path.join(candidate, 'meta.json'))) return false; + return ( + fs.existsSync(path.join(candidate, 'registry.json')) || + fs.existsSync(path.join(candidate, 'repos')) + ); +} + function findGitNexusDir(startDir) { let dir = startDir || process.cwd(); for (let i = 0; i < 5; i++) { const candidate = path.join(dir, '.gitnexus'); - if (fs.existsSync(candidate)) return candidate; + if (fs.existsSync(candidate)) { + if (!isGlobalRegistryDir(candidate)) return candidate; + } const parent = path.dirname(dir); if (parent === dir) break; dir = parent; diff --git a/gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md b/gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md index 607aa8c4a..1c38face4 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md +++ b/gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md @@ -21,6 +21,7 @@ Run from the project root. This parses all source files, builds the knowledge gr |------|--------| | `--force` | Force full re-index even if up to date | | `--embeddings` | Enable embedding generation for semantic search (off by default) | +| `--drop-embeddings` | Drop existing embeddings on rebuild. By default, an `analyze` without `--embeddings` preserves them. | **When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. diff --git a/gitnexus-shared/package-lock.json b/gitnexus-shared/package-lock.json index b7064d0ac..0fee05147 100644 --- a/gitnexus-shared/package-lock.json +++ b/gitnexus-shared/package-lock.json @@ -8,13 +8,13 @@ "name": "gitnexus-shared", "version": "1.0.0", "devDependencies": { - "typescript": "^6.0.2" + "typescript": "^6.0.3" } }, "node_modules/typescript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", - "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/gitnexus-shared/package.json b/gitnexus-shared/package.json index f0da6bf71..7c1e6847a 100644 --- a/gitnexus-shared/package.json +++ b/gitnexus-shared/package.json @@ -20,6 +20,6 @@ "src" ], "devDependencies": { - "typescript": "^6.0.2" + "typescript": "^6.0.3" } } diff --git a/gitnexus-shared/src/index.ts b/gitnexus-shared/src/index.ts index d4efe8a89..ea66c3855 100644 --- a/gitnexus-shared/src/index.ts +++ b/gitnexus-shared/src/index.ts @@ -134,7 +134,11 @@ export type { // 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 { + buildScopeTree, + canParentScope, + 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'; diff --git a/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts b/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts index 6f6de8ba6..5ebb098f1 100644 --- a/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts +++ b/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts @@ -26,9 +26,9 @@ * (`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. + * **Non-binding imports rule.** `dynamic-unresolved` passes through with + * `targetFile: null`; `dynamic-resolved` and `side-effect` resolve to + * file-level `ImportEdge`s. None of these materialize `BindingRef`s. */ import type { SymbolDefinition } from './symbol-definition.js'; @@ -45,20 +45,28 @@ export interface FinalizeFile { /** * 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. + * declarations); parsers MAY also surface re-exported names here as a + * shortcut, but it is no longer required for correctness. * * **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. + * `A → B (importedName: 'X')` by first looking up `X` in `B.localDefs`. + * If `B` only has `export { X } from './C'` and does NOT surface `X` in + * its own `localDefs`, `finalize` falls back to the precomputed + * per-file re-export closure (`buildReexportClosures`), which encodes + * every name reachable through `B`'s named and wildcard re-exports — + * including transitively through cyclic SCCs. The lookup is O(1) and + * inherits the upstream `targetDefId`, populating `transitiveVia` with + * the file paths traversed to reach the leaf def. * - * 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. + * Surfacing re-exported names in `localDefs` is still a valid (and + * slightly cheaper) optimization: the direct lookup short-circuits the + * closure consult. Parsers SHOULD prefer surfacing names they can resolve + * statically (e.g., `export { X } from './c'` when `c.ts` is parsed in + * the same workspace), and rely on the closure for the long tail of + * barrel patterns. + * + * The fixpoint does NOT mutate `localDefs` across iterations — it is + * static input. */ readonly localDefs: readonly SymbolDefinition[]; } @@ -186,7 +194,8 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu graph.set(file.filePath, new Set()); } for (const [fromFile, drafts] of edgeIndex) { - const edges = graph.get(fromFile)!; + const edges = graph.get(fromFile); + if (edges === undefined) continue; for (const d of drafts) { if (d.targetFile !== null && byFilePath.has(d.targetFile)) { edges.add(d.targetFile); @@ -197,6 +206,12 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu // ── Phase 2: Tarjan SCC → reverse-topological list of SCCs. const sccs = tarjanSccs(graph); + // ── Phase 2.5: precompute the per-file re-export closure (iterative, + // SCC-condensed). Eliminates the recursive crawl that the per-edge + // `tryFinalize` call site used to do; lookups are O(1) afterwards. + // See `buildReexportClosures` for the algorithm. + const reexportClosures = buildReexportClosures(input.files, byFilePath, edgeIndex); + // ── 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 @@ -217,10 +232,11 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu progressed = false; iterations++; for (const filePath of scc.files) { - const drafts = edgeIndex.get(filePath)!; + const drafts = edgeIndex.get(filePath); + if (drafts === undefined) continue; for (const draft of drafts) { if (draft.finalized !== null) continue; - const finalized = tryFinalize(draft, byFilePath); + const finalized = tryFinalize(draft, byFilePath, reexportClosures); if (finalized !== null) { draft.finalized = finalized; progressed = true; @@ -231,7 +247,8 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu // Any drafts still not finalized within this SCC hit the cap → unresolved. for (const filePath of scc.files) { - const drafts = edgeIndex.get(filePath)!; + const drafts = edgeIndex.get(filePath); + if (drafts === undefined) continue; for (const draft of drafts) { if (draft.finalized !== null) continue; draft.finalized = { @@ -245,10 +262,14 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu // ── 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 drafts = edgeIndex.get(file.filePath); + if (drafts === undefined) continue; const finalized: ImportEdge[] = []; for (const d of drafts) { - const edge = d.finalized!; + const edge = d.finalized; + if (edge === null) { + throw new Error(`Invariant violated: import edge was not finalized for ${file.filePath}`); + } if (d.source.kind === 'wildcard' && edge.linkStatus !== 'unresolved') { // Produce one `wildcard-expanded` ImportEdge per exported name. const expanded = expandWildcard(edge, byFilePath, hooks, input.workspaceIndex); @@ -327,14 +348,11 @@ function makeEdgeDraft( // 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, + localName: extractLocalName(parsed), targetFile: null, - targetExportedName, - kind: edgeKind, + targetExportedName: extractExportedName(parsed), + kind: edgeKindFor(parsed), linkStatus: 'unresolved', }; return { @@ -348,26 +366,43 @@ function makeEdgeDraft( } // 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); + // in `targetDefId` (e.g., symbol not exported from target). Side-effect + // and resolved-dynamic imports are terminal at the file level — no + // `targetDefId` needed since they materialize no `BindingRef`. Pre- + // finalize them here so the fixpoint loop skips them entirely. const base: ImportEdge = { - localName, + localName: extractLocalName(parsed), targetFile, - targetExportedName, - kind: edgeKind, + targetExportedName: extractExportedName(parsed), + kind: edgeKindFor(parsed), }; + const isFileLevelTerminal = parsed.kind === 'side-effect' || parsed.kind === 'dynamic-resolved'; return { source: parsed, fromFile: file.filePath, fromScope: file.moduleScope, targetFile, base, - finalized: null, + finalized: isFileLevelTerminal ? base : null, }; } +function edgeKindFor(parsed: ParsedImport): ImportEdge['kind'] { + if (parsed.kind === 'wildcard') return 'wildcard-expanded'; + return parsed.kind; +} + +function extractLocalName(parsed: ParsedImport): string { + switch (parsed.kind) { + case 'wildcard': + case 'side-effect': + case 'dynamic-resolved': + return ''; + default: + return parsed.localName; + } +} + function extractExportedName(parsed: ParsedImport): string { switch (parsed.kind) { case 'named': @@ -377,6 +412,8 @@ function extractExportedName(parsed: ParsedImport): string { return parsed.importedName; case 'wildcard': case 'dynamic-unresolved': + case 'dynamic-resolved': + case 'side-effect': return ''; } } @@ -386,6 +423,7 @@ function extractExportedName(parsed: ParsedImport): string { function tryFinalize( draft: ImportEdgeDraft, byFilePath: Map, + reexportClosures: ReadonlyMap, ): ImportEdge | null { const targetFile = draft.targetFile; if (targetFile === null) return draft.base; // already terminal @@ -423,22 +461,265 @@ function tryFinalize( const importedName = extractExportedName(draft.source); const exported = findExportByName(targetModule.localDefs, importedName); - if (exported === undefined) { + if (exported !== undefined) { + const transitiveVia = + draft.source.kind === 'reexport' ? Object.freeze([targetFile]) : undefined; + return { + ...draft.base, + targetModuleScope: targetModule.moduleScope, + targetDefId: exported.nodeId, + ...(transitiveVia !== undefined ? { transitiveVia } : {}), + }; + } + + // Multi-hop re-export follow. Barrel modules like + // // models.ts + // export { User } from './base'; + // emit no local def for `User`; the name surfaces only via their own + // `reexport` edge. The per-file re-export closure built in phase 2.5 + // already encodes every name reachable through that file's named and + // wildcard re-exports — including transitively through cyclic SCCs — + // so the lookup is O(1) and never recurses. + const followed = lookupReexportedName(reexportClosures, targetFile, importedName); + if (followed === null) { // 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; + const viaFiles = [targetFile, ...followed.via]; + const transitiveVia = + draft.source.kind === 'reexport' || viaFiles.length > 1 ? Object.freeze(viaFiles) : undefined; return { ...draft.base, targetModuleScope: targetModule.moduleScope, - targetDefId: exported.nodeId, + targetDefId: followed.def.nodeId, ...(transitiveVia !== undefined ? { transitiveVia } : {}), }; } +// ─── Internal: re-export closure (phase 2.5) ─────────────────────────────── + +/** + * Per-file map of `name → terminal def + via path` — i.e. every name + * importable from this file via its named/wildcard re-export chain + * (excluding the file's own `localDefs`, which the caller checks first + * via `findExportByName`). `via` is the ordered list of intermediate + * files traversed to reach the def. + * + * Built once per finalize pass. Lookups are O(1). + */ +type ReexportClosureEntry = { readonly def: SymbolDefinition; readonly via: readonly string[] }; +type FileReexportClosure = ReadonlyMap; + +/** + * Build per-file re-export closures. + * + * **Algorithm.** Iterative SCC-condensed reverse-topological propagation, + * structurally identical to how `finalize` itself processes the file- + * level import graph. Replaces the legacy recursive + * `followReexportChain` crawl with a bounded, stack-safe pass: + * + * 1. **Sub-graph.** Build a directed graph whose edges are + * `reexport` and `wildcard` drafts only (regular imports do not + * contribute to the export surface, and `namespace`/ + * `reexport-namespace` are terminal — their target def lives in + * `localDefs`). + * 2. **SCC condensation.** Run the same iterative `tarjanSccs` over + * the sub-graph. Output is in reverse-topological order (leaves + * first), so when we process an SCC every out-of-SCC neighbor + * already has its closure populated. + * 3. **Per-SCC propagation.** + * * Acyclic singleton: one pass — read neighbors' (already + * fully populated) closures. + * * Cyclic SCC (cycle ≥ 2 files, or self-loop): bounded + * fixpoint inside the SCC, capped at `|SCC| + 1` iterations + * (each iteration propagates names one hop further around + * the cycle; first-wins precedence keeps the map monotone + * so the fixpoint converges in at most |SCC| hops). + * + * **Precedence semantics — preserved from the recursive crawl.** + * * Named re-exports take precedence over wildcards. + * * Within each kind, declaration order wins (first match for a + * given exported name is kept; later drafts skip). + * + * **Complexity.** + * * Pre-pass: O(V + E_re) for SCC, plus O(|SCC| × Σ drafts) per cyclic + * SCC. For tree-shaped barrel graphs (the common case) it + * collapses to O(E_re) total. + * * Per-edge lookup at finalize time: O(1). + * * `transitiveVia` preserves the exact file path chain for diagnostics + * and graph provenance. Building those arrays copies the inherited path, + * which is O(depth²) in a pathological single-name barrel chain; practical + * TypeScript barrel chains are shallow enough that we keep exact paths + * instead of capping or summarizing them. + * * Pathological deep chains that previously needed + * `MAX_REEXPORT_DEPTH=100` to bound stack growth now resolve + * in full and are bounded only by available memory — the + * iterative formulation has no call-stack ceiling. + */ +function buildReexportClosures( + files: readonly FinalizeFile[], + byFilePath: ReadonlyMap, + edgeIndex: ReadonlyMap, +): ReadonlyMap { + const closures = new Map>(); + for (const file of files) closures.set(file.filePath, new Map()); + + // ── Step 1: build the re-export sub-graph (only resolvable + // reexport/wildcard targets contribute edges). + const subGraph = new Map>(); + for (const file of files) { + const targets = new Set(); + const drafts = edgeIndex.get(file.filePath); + if (drafts !== undefined) { + for (const d of drafts) { + if (d.source.kind !== 'reexport' && d.source.kind !== 'wildcard') continue; + if (d.targetFile === null) continue; + if (!byFilePath.has(d.targetFile)) continue; + targets.add(d.targetFile); + } + } + subGraph.set(file.filePath, targets); + } + + // ── Step 2: SCC over the sub-graph. Reuses the same iterative Tarjan + // implementation that drives the file-level finalize loop, so any + // call-stack-safety guarantees there transfer here unchanged. + const subSccs = tarjanSccs(subGraph); + + // ── Step 3: process SCCs in reverse-topological order. Acyclic + // singletons settle in one pass; cyclic SCCs run a bounded fixpoint. + for (const scc of subSccs) { + if (!scc.isCycle) { + const filePath = scc.files[0]; + if (filePath !== undefined) { + populateFileClosure(filePath, byFilePath, edgeIndex, closures); + } + continue; + } + // Cap = |SCC| + 1. With first-wins precedence each name needs at + // most |SCC| iterations to propagate fully around the cycle; the + // extra iteration confirms no progress and breaks the loop. + const cap = scc.files.length + 1; + let progressed = true; + let iter = 0; + while (progressed && iter < cap) { + progressed = false; + iter++; + for (const filePath of scc.files) { + if (populateFileClosure(filePath, byFilePath, edgeIndex, closures)) { + progressed = true; + } + } + } + } + + return closures; +} + +/** + * Populate one file's re-export closure for one pass. Returns `true` + * iff the closure grew (signalling fixpoint progress to the caller). + * + * Walks the file's drafts in declaration order, named re-exports first + * (precedence), then wildcards. For each draft, attempts: + * 1. **Direct hit** — name exists in the target file's `localDefs`. + * 2. **Inherited** — name exists in the target file's already-populated + * closure (which encodes the target's own re-export chain). + * + * `closures.get(targetFile)` may itself still be empty for in-SCC + * targets on the first iteration; the outer fixpoint loop handles + * that by re-invoking this function. + */ +function populateFileClosure( + filePath: string, + byFilePath: ReadonlyMap, + edgeIndex: ReadonlyMap, + closures: Map>, +): boolean { + const myClosure = closures.get(filePath); + if (myClosure === undefined) return false; + const before = myClosure.size; + const drafts = edgeIndex.get(filePath); + if (drafts === undefined) return false; + + // Named re-exports — precedence over wildcards, declaration order + // first-wins for duplicates of the same exported name. + for (const draft of drafts) { + if (draft.source.kind !== 'reexport') continue; + const targetFile = draft.targetFile; + if (targetFile === null) continue; + const targetModule = byFilePath.get(targetFile); + if (targetModule === undefined) continue; + + const localName = draft.source.localName; + if (myClosure.has(localName)) continue; + + const importedName = draft.source.importedName; + const direct = findExportByName(targetModule.localDefs, importedName); + if (direct !== undefined) { + myClosure.set(localName, { def: direct, via: Object.freeze([targetFile]) }); + continue; + } + const inherited = closures.get(targetFile)?.get(importedName); + if (inherited !== undefined) { + myClosure.set(localName, { + def: inherited.def, + via: Object.freeze([targetFile, ...inherited.via]), + }); + } + // Else: target's closure is still empty (in-SCC, awaiting next + // iteration). Outer loop will revisit. + } + + // Wildcard re-exports — fan out the target's own surface (localDefs + // + transitive closure). `myClosure.has(name)` checks below preserve + // the named-precedence and first-wins semantics from above. + for (const draft of drafts) { + if (draft.source.kind !== 'wildcard') continue; + const targetFile = draft.targetFile; + if (targetFile === null) continue; + const targetModule = byFilePath.get(targetFile); + if (targetModule === undefined) continue; + + for (const def of targetModule.localDefs) { + const name = deriveSimpleName(def); + if (name === null || myClosure.has(name)) continue; + myClosure.set(name, { def, via: Object.freeze([targetFile]) }); + } + const targetClosure = closures.get(targetFile); + if (targetClosure !== undefined) { + for (const [name, entry] of targetClosure) { + if (myClosure.has(name)) continue; + myClosure.set(name, { + def: entry.def, + via: Object.freeze([targetFile, ...entry.via]), + }); + } + } + } + + return myClosure.size > before; +} + +/** + * O(1) lookup into a precomputed re-export closure. Replaces the legacy + * recursive `followReexportChain` traversal with a single map indexing. + */ +function lookupReexportedName( + closures: ReadonlyMap, + filePath: string, + name: string, +): { def: SymbolDefinition; via: readonly string[] } | null { + const closure = closures.get(filePath); + if (closure === undefined) return null; + const entry = closure.get(name); + if (entry === undefined) return null; + return { def: entry.def, via: entry.via }; +} + /** * The "simple" (unqualified) name of a def, for import-name matching. * @@ -522,6 +803,17 @@ function materializeBindings( ): ReadonlyMap> { const out = new Map>(); + // Build a `nodeId → SymbolDefinition` index once across all files + // (O(N_files × D_defs)) so the per-edge lookup below is O(1) instead + // of a full linear scan. At realistic TypeScript monorepo scale + // (~5k files × ~50 defs × ~100k linked import edges) this is the + // difference between ~25 s and a few ms inside finalize. The map + // is local to this pass — no cross-pass state leaks. + const defById = new Map(); + for (const f of files) { + for (const d of f.localDefs) defById.set(d.nodeId, d); + } + for (const file of files) { const scopeBindings = new Map(); @@ -538,10 +830,7 @@ function materializeBindings( 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); + const def = defById.get(edge.targetDefId); if (def === undefined) continue; const origin: BindingRef['origin'] = @@ -571,15 +860,6 @@ function materializeBindings( 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 ────────────────────────────────────────────────── /** @@ -607,7 +887,8 @@ function tarjanSccs(graph: ReadonlyMap>): FinalizedS entered: false, }); while (iterStack.length > 0) { - const frame = iterStack[iterStack.length - 1]!; + const frame = iterStack[iterStack.length - 1]; + if (frame === undefined) break; if (!frame.entered) { frame.entered = true; @@ -625,7 +906,10 @@ function tarjanSccs(graph: ReadonlyMap>): FinalizedS const scc: string[] = []; let selfInCycle = false; while (true) { - const w = stack.pop()!; + const w = stack.pop(); + if (w === undefined) { + throw new Error(`Invariant violated: Tarjan stack exhausted at ${frame.node}`); + } onStack.delete(w); scc.push(w); // A single-file self-loop counts as a cycle. @@ -640,8 +924,16 @@ function tarjanSccs(graph: ReadonlyMap>): FinalizedS 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)!)); + const parent = iterStack[iterStack.length - 1]; + if (parent !== undefined) { + lowlink.set( + parent.node, + Math.min( + requiredNumber(lowlink, parent.node, 'lowlink'), + requiredNumber(lowlink, frame.node, 'lowlink'), + ), + ); + } } continue; } @@ -654,10 +946,24 @@ function tarjanSccs(graph: ReadonlyMap>): FinalizedS entered: false, }); } else if (onStack.has(child)) { - lowlink.set(frame.node, Math.min(lowlink.get(frame.node)!, index.get(child)!)); + lowlink.set( + frame.node, + Math.min( + requiredNumber(lowlink, frame.node, 'lowlink'), + requiredNumber(index, child, 'index'), + ), + ); } } } return sccs; } + +function requiredNumber(map: ReadonlyMap, key: string, label: string): number { + const value = map.get(key); + if (value === undefined) { + throw new Error(`Invariant violated: missing Tarjan ${label} for ${key}`); + } + return value; +} diff --git a/gitnexus-shared/src/scope-resolution/parsed-file.ts b/gitnexus-shared/src/scope-resolution/parsed-file.ts index ddd8afccd..50eb5a795 100644 --- a/gitnexus-shared/src/scope-resolution/parsed-file.ts +++ b/gitnexus-shared/src/scope-resolution/parsed-file.ts @@ -37,6 +37,18 @@ * `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. + * + * ## Source-of-truth invariant + * + * `ParsedFile` is the single semantic model consumed by both the legacy + * DAG (`gitnexus/src/core/ingestion/` outside `scope-resolution/`) and + * the scope-resolution pipeline (`gitnexus/src/core/ingestion/scope-resolution/`). + * Downstream passes MUST NOT build a parallel parse representation; if + * a pass needs AST-level facts that `ParsedFile` doesn't expose, it + * should reuse the orchestrator's `treeCache` rather than re-invoke + * `parser.parse(...)` on its own. See the + * `ScopeResolver` contract (`gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts`) + * for the full list of invariants downstream consumers rely on. */ import type { Scope, ScopeId } from './types.js'; diff --git a/gitnexus-shared/src/scope-resolution/reference-site.ts b/gitnexus-shared/src/scope-resolution/reference-site.ts index c9abdef7e..67ed23e6b 100644 --- a/gitnexus-shared/src/scope-resolution/reference-site.ts +++ b/gitnexus-shared/src/scope-resolution/reference-site.ts @@ -71,4 +71,12 @@ export interface ReferenceSite { readonly explicitReceiver?: { readonly name: string }; /** Argument count at the call site; used by `provider.arityCompatibility`. */ readonly arity?: number; + /** + * Inferred argument types at the call site, one per argument. An + * empty-string entry means "unknown" — consumers narrowing overload + * candidates treat unknown as any-match. Populated by languages + * that can derive types from literals / constructor expressions + * (C#: `42` → `'int'`, `"alice"` → `'string'`). + */ + readonly argumentTypes?: readonly string[]; } diff --git a/gitnexus-shared/src/scope-resolution/scope-tree.ts b/gitnexus-shared/src/scope-resolution/scope-tree.ts index 7f2b54684..5011f2a36 100644 --- a/gitnexus-shared/src/scope-resolution/scope-tree.ts +++ b/gitnexus-shared/src/scope-resolution/scope-tree.ts @@ -119,10 +119,10 @@ export function buildScopeTree(scopes: readonly Scope[]): ScopeTree { `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)) { + if (!canParentScope(parent.range, scope.range, parent.kind, scope.kind)) { 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)}.`, + `Parent scope '${parent.id}' at ${formatRange(parent.range)} does not contain child '${scope.id}' at ${formatRange(scope.range)} (allowed: strict containment, or equal-range Module-as-parent).`, ); } @@ -230,6 +230,47 @@ function rangeStrictlyContains(outer: Range, inner: Range): boolean { return outerStartsAtOrBefore && outerEndsAtOrAfter; } +function rangesEqual(a: Range, b: Range): boolean { + return ( + a.startLine === b.startLine && + a.startCol === b.startCol && + a.endLine === b.endLine && + a.endCol === b.endCol + ); +} + +/** + * Whether `outer` (kind `outerKind`) is a valid parent for `inner` (kind + * `innerKind`). + * + * Strict containment is the general rule. The single carve-out is the + * `Module`/non-`Module` pair whose ranges are exactly equal — this happens + * naturally when tree-sitter reports identical byte spans for the + * `compilation_unit` (or equivalent file-root construct) and the file's + * single top-level scope. Common shape: a C# file consisting of nothing + * but `namespace X { ... }` with no leading or trailing trivia outside the + * namespace's `{}` body — `compilation_unit` and `namespace_declaration` + * both span exactly the same byte range. The `Module` is the universal + * outer of any file-level scope by language semantics, so coincident + * ranges should not break the parent chain. + * + * The carve-out is direction-asymmetric: only `Module`-as-outer parents a + * same-range non-`Module`, never the reverse. This preserves the + * acyclicity buildScopeTree relies on, and matches the corresponding + * helper in `scope-extractor.ts` so `pass1BuildScopes` and the validator + * agree on what a well-formed parent edge looks like. + */ +export function canParentScope( + outer: Range, + inner: Range, + outerKind: Scope['kind'], + innerKind: Scope['kind'], +): boolean { + if (rangeStrictlyContains(outer, inner)) return true; + if (outerKind === 'Module' && innerKind !== 'Module' && rangesEqual(outer, inner)) return true; + return false; +} + /** * Two ranges overlap when neither finishes before the other begins. Ranges * that merely touch at a single boundary point (`a.end === b.start`) do diff --git a/gitnexus-shared/src/scope-resolution/types.ts b/gitnexus-shared/src/scope-resolution/types.ts index 3e1611593..c3d8b80fb 100644 --- a/gitnexus-shared/src/scope-resolution/types.ts +++ b/gitnexus-shared/src/scope-resolution/types.ts @@ -10,8 +10,16 @@ * 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. + * builds. + * + * Two structures are populated after freeze: + * 1. `ReferenceIndex` — by resolution, before emission. + * 2. `ScopeResolutionIndexes.bindingAugmentations` — the dedicated + * append-only post-finalize binding channel (e.g. C# same-namespace + * cross-file fanout). The companion `indexes.bindings` is the + * finalize-output channel and is deep-frozen by `materializeBindings`; + * walkers consult both via `lookupBindingsAt`. See `ScopeResolver` + * Invariant I8 for the full lifecycle contract. */ import type { NodeLabel } from '../graph/types.js'; @@ -182,6 +190,42 @@ export type ParsedImport = readonly localName: string; /** Source text of the unresolved expression when available; `null` otherwise. */ readonly targetRaw: string | null; + } + /** + * Lazy / dynamic import whose target IS a static string literal at parse + * time, so it can be linked to a concrete `targetFile`. No local name + * binding is materialized — `import('./m')` returns `Promise` and + * any consumer-visible names appear via subsequent `.then(({ X }) => …)` + * destructuring, which is outside the static-import surface. The edge + * exists for module-reachability and impact analysis (so editing `./m` + * still flags the dynamic importer as affected). + * + * Providers MUST only emit this kind when `targetRaw` is a literal + * string they can hand to `resolveImportTarget`; expression arguments + * stay `dynamic-unresolved`. + * + * Examples: + * - JS `import('./feature')` → `{ kind: 'dynamic-resolved', targetRaw: './feature' }` + * - JS `await import('@scope/pkg/sub')` → `{ kind: 'dynamic-resolved', targetRaw: '@scope/pkg/sub' }` + */ + | { + readonly kind: 'dynamic-resolved'; + readonly targetRaw: string; + } + /** + * Bare-source / side-effect import that introduces no local name binding + * but still establishes a file-level dependency. Resolves to a concrete + * `targetFile` via `resolveImportTarget` and produces a file→file + * `ImportEdge` for module-reachability and impact analysis, with no + * `BindingRef` materialized. + * + * Examples: + * - JS / TS `import './polyfill'` → `{ kind: 'side-effect', targetRaw: './polyfill' }` + * - Rust `use foo::bar as _` → side-effect (binding hidden under `_`) + */ + | { + readonly kind: 'side-effect'; + readonly targetRaw: string; }; /** @@ -253,7 +297,9 @@ export interface ImportEdge { | 'namespace' | 'wildcard-expanded' | 'reexport' - | 'dynamic-unresolved'; + | 'dynamic-unresolved' + | 'dynamic-resolved' + | 'side-effect'; /** 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. */ diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index b62d3eddc..56509144c 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -8,14 +8,14 @@ "name": "gitnexus", "version": "0.0.0", "dependencies": { - "@langchain/anthropic": "^1.3.10", - "@langchain/core": "^1.1.15", - "@langchain/google-genai": "^2.1.10", - "@langchain/langgraph": "^1.1.0", - "@langchain/ollama": "^1.2.0", - "@langchain/openai": "^1.2.2", + "@langchain/anthropic": "^1.3.27", + "@langchain/core": "^1.1.41", + "@langchain/google-genai": "^2.1.28", + "@langchain/langgraph": "^1.2.9", + "@langchain/ollama": "^1.2.6", + "@langchain/openai": "^1.4.5", "@sigma/edge-curve": "^3.1.0", - "@tailwindcss/vite": "^4.1.18", + "@tailwindcss/vite": "^4.2.4", "axios": "^1.13.2", "d3": "^7.9.0", "dompurify": "^3.3.3", @@ -26,9 +26,9 @@ "graphology-layout-forceatlas2": "^0.10.1", "graphology-layout-noverlap": "^0.4.2", "graphology-utils": "^2.3.0", - "langchain": "^1.2.10", + "langchain": "^1.3.4", "lru-cache": "^11.2.4", - "lucide-react": "^0.562.0", + "lucide-react": "^1.11.0", "mermaid": "^11.14.0", "mnemonist": "^0.39.0", "pandemonium": "^2.4.0", @@ -39,12 +39,12 @@ "react-zoom-pan-pinch": "^3.7.0", "remark-gfm": "^4.0.1", "sigma": "^3.0.2", - "tailwindcss": "^4.2.2", + "tailwindcss": "^4.2.4", "uuid": "^13.0.0", "zod": "^3.25.76" }, "devDependencies": { - "@babel/types": "^7.28.5", + "@babel/types": "^7.29.0", "@playwright/test": "^1.58.2", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", @@ -55,23 +55,23 @@ "@types/react-dom": "^18.3.0", "@types/react-syntax-highlighter": "^15.5.13", "@vercel/node": "^5.5.16", - "@vitejs/plugin-react": "^5.1.0", - "@vitest/coverage-v8": "^3.2.4", + "@vitejs/plugin-react": "^5.1.4", + "@vitest/coverage-v8": "^4.1.5", "jsdom": "^29.0.2", "tree-sitter-wasms": "^0.1.13", "typescript": "^5.4.5", - "vite": "^5.2.0", - "vitest": "^3.2.4", + "vite": "^8.0.10", + "vitest": "^4.1.5", "wait-on": "^9.0.5" }, "engines": { - "node": ">=20.0.0" + "node": "^20.19.0 || >=22.12.0" } }, "../gitnexus-shared": { "version": "1.0.0", "devDependencies": { - "typescript": "^6.0.2" + "typescript": "^6.0.3" } }, "node_modules/@adobe/css-tools": { @@ -81,20 +81,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@antfu/install-pkg": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", @@ -109,9 +95,9 @@ } }, "node_modules/@anthropic-ai/sdk": { - "version": "0.71.2", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.71.2.tgz", - "integrity": "sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ==", + "version": "0.90.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.90.0.tgz", + "integrity": "sha512-MzZtPabJF1b0FTDl6Z6H5ljphPwACLGP13lu8MTiB8jXaW/YXlpOp+Po2cVou3MPM5+f5toyLnul9whKCy7fBg==", "license": "MIT", "dependencies": { "json-schema-to-ts": "^3.1.1" @@ -180,9 +166,9 @@ "license": "MIT" }, "node_modules/@babel/code-frame": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", - "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "dev": true, "license": "MIT", "dependencies": { @@ -205,21 +191,21 @@ } }, "node_modules/@babel/core": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", - "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/generator": "^7.28.6", + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.28.6", + "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -246,14 +232,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz", - "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==", + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -403,13 +389,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", - "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.6" + "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -475,18 +461,18 @@ } }, "node_modules/@babel/traverse": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz", - "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/generator": "^7.28.6", + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.6", + "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/types": "^7.29.0", "debug": "^4.3.1" }, "engines": { @@ -494,9 +480,9 @@ } }, "node_modules/@babel/types": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", - "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "dev": true, "license": "MIT", "dependencies": { @@ -797,20 +783,20 @@ } }, "node_modules/@emnapi/core": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz", - "integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.0", + "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", - "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "license": "MIT", "optional": true, "dependencies": { @@ -818,9 +804,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==", "license": "MIT", "optional": true, "dependencies": { @@ -834,7 +820,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -851,7 +836,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -868,7 +852,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -885,7 +868,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -902,7 +884,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -919,7 +900,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -936,7 +916,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -953,7 +932,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -970,7 +948,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -987,7 +964,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1004,7 +980,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1021,7 +996,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1038,7 +1012,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1055,7 +1028,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1072,7 +1044,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1089,7 +1060,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1106,7 +1076,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1123,7 +1092,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1140,7 +1108,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1157,7 +1124,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1174,7 +1140,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1191,7 +1156,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1208,7 +1172,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1225,7 +1188,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1242,7 +1204,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1259,7 +1220,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1400,24 +1360,6 @@ "node": "20 || >=22" } }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -1431,16 +1373,6 @@ "node": ">=18.0.0" } }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1487,59 +1419,46 @@ } }, "node_modules/@langchain/anthropic": { - "version": "1.3.10", - "resolved": "https://registry.npmjs.org/@langchain/anthropic/-/anthropic-1.3.10.tgz", - "integrity": "sha512-VXq5fsEJ4FB5XGrnoG+bfm0I7OlmYLI4jZ6cX9RasyqhGo9wcDyKw1+uEQ1H7Og7jWrTa1bfXCun76wttewJnw==", + "version": "1.3.27", + "resolved": "https://registry.npmjs.org/@langchain/anthropic/-/anthropic-1.3.27.tgz", + "integrity": "sha512-A0pWKIMIhgF01z3ILA8uAbZ6ZR2H8UQP2Ww8Ofq5DtHp36uJkQgrNCdS+q9pUVJJ87eq5dEdFkpjrjmH4fVkfQ==", "license": "MIT", "dependencies": { - "@anthropic-ai/sdk": "^0.71.0", + "@anthropic-ai/sdk": "^0.90.0", "zod": "^3.25.76 || ^4" }, "engines": { "node": ">=20" }, "peerDependencies": { - "@langchain/core": "1.1.15" + "@langchain/core": "^1.1.41" } }, "node_modules/@langchain/core": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.15.tgz", - "integrity": "sha512-b8RN5DkWAmDAlMu/UpTZEluYwCLpm63PPWniRKlE8ie3KkkE7IuMQ38pf4kV1iaiI+d99BEQa2vafQHfCujsRA==", + "version": "1.1.42", + "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.42.tgz", + "integrity": "sha512-d0tN96BrwPMryYyWR9VfyAntSivn7EQrZCe5Kpxum93tcjTXbKKmKvItFec8AluQt88iTcmAJrahUZUNfzGwTA==", "license": "MIT", "dependencies": { "@cfworker/json-schema": "^4.0.2", + "@standard-schema/spec": "^1.1.0", "ansi-styles": "^5.0.0", "camelcase": "6", "decamelize": "1.2.0", "js-tiktoken": "^1.0.12", - "langsmith": ">=0.4.0 <1.0.0", + "langsmith": ">=0.5.0 <1.0.0", "mustache": "^4.2.0", "p-queue": "^6.6.2", - "uuid": "^10.0.0", "zod": "^3.25.76 || ^4" }, "engines": { "node": ">=20" } }, - "node_modules/@langchain/core/node_modules/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/@langchain/google-genai": { - "version": "2.1.10", - "resolved": "https://registry.npmjs.org/@langchain/google-genai/-/google-genai-2.1.10.tgz", - "integrity": "sha512-OpiBr2OUzB9Pg20mjLId+vfxJvYurc8TzbElaM/d6KE7aE8DiKCEOuQn5ZSgHTVzZV2g++lcJXw6iZlso4SORA==", + "version": "2.1.28", + "resolved": "https://registry.npmjs.org/@langchain/google-genai/-/google-genai-2.1.28.tgz", + "integrity": "sha512-iTzNYWST8hTRqOXZdme18tq5GnCUwtrrJECE51ZCjg6Vg0mPsV44amdC+/bc+UK0+uphKWESGWs277aAyM2MlA==", "license": "MIT", "dependencies": { "@google/generative-ai": "^0.24.0", @@ -1549,7 +1468,7 @@ "node": ">=20" }, "peerDependencies": { - "@langchain/core": "1.1.15" + "@langchain/core": "^1.1.41" } }, "node_modules/@langchain/google-genai/node_modules/uuid": { @@ -1566,20 +1485,21 @@ } }, "node_modules/@langchain/langgraph": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-1.1.0.tgz", - "integrity": "sha512-3n1GL0ZTtr57ZwbYvbi4Th26fwiGogmpFn8OA8UXEpBM2HcpGwcv1+c8YSBJF4XRjlcCzIlXtY+DyrNsvinc6g==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-1.2.9.tgz", + "integrity": "sha512-3c7BtGycHC2v9p6w/Hv8L7kEl1YnZYOQTDJtmAp3knk6JOedO7d2bYP3y0SRyhv5orUEGf/KGvx8ZsB/ideP7g==", "license": "MIT", "dependencies": { - "@langchain/langgraph-checkpoint": "^1.0.0", - "@langchain/langgraph-sdk": "~1.5.4", + "@langchain/langgraph-checkpoint": "^1.0.1", + "@langchain/langgraph-sdk": "~1.8.9", + "@standard-schema/spec": "1.1.0", "uuid": "^10.0.0" }, "engines": { "node": ">=18" }, "peerDependencies": { - "@langchain/core": "^1.0.1", + "@langchain/core": "^1.1.40", "zod": "^3.25.32 || ^4.2.0", "zod-to-json-schema": "^3.x" }, @@ -1590,9 +1510,9 @@ } }, "node_modules/@langchain/langgraph-checkpoint": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-1.0.0.tgz", - "integrity": "sha512-xrclBGvNCXDmi0Nz28t3vjpxSH6UYx6w5XAXSiiB1WEdc2xD2iY/a913I3x3a31XpInUW/GGfXXfePfaghV54A==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-1.0.1.tgz", + "integrity": "sha512-HM0cJLRpIsSlWBQ/xuDC67l52SqZ62Bh2Y61DX+Xorqwoh5e1KxYvfCD7GnSTbWWhjBOutvnR0vPhu4orFkZfw==", "license": "MIT", "dependencies": { "uuid": "^10.0.0" @@ -1618,19 +1538,22 @@ } }, "node_modules/@langchain/langgraph-sdk": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.5.4.tgz", - "integrity": "sha512-eSYqG875c2qvcPwdvBwQH0niTZxt6roMGc2dAWBqCbWCUiUL0X4ftYHg2OqOelsrNE3SO6faLr/m0LIPc9hDwg==", + "version": "1.8.9", + "resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.8.9.tgz", + "integrity": "sha512-vpz90auS4iFTNy2X/CFexOEoeFSvaK+MyI7iSmzYs9gGcfzwRjWUJ4MWsuc5ZNRecLStwho0PExVXRgGOXtcRw==", "license": "MIT", "dependencies": { + "@types/json-schema": "^7.0.15", "p-queue": "^9.0.1", "p-retry": "^7.1.1", "uuid": "^13.0.0" }, "peerDependencies": { - "@langchain/core": "^1.0.1", + "@langchain/core": "^1.1.16", "react": "^18 || ^19", - "react-dom": "^18 || ^19" + "react-dom": "^18 || ^19", + "svelte": "^4.0.0 || ^5.0.0", + "vue": "^3.0.0" }, "peerDependenciesMeta": { "@langchain/core": { @@ -1641,19 +1564,25 @@ }, "react-dom": { "optional": true + }, + "svelte": { + "optional": true + }, + "vue": { + "optional": true } } }, "node_modules/@langchain/langgraph-sdk/node_modules/eventemitter3": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", - "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, "node_modules/@langchain/langgraph-sdk/node_modules/p-queue": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.1.0.tgz", - "integrity": "sha512-O/ZPaXuQV29uSLbxWBGGZO1mCQXV2BLIwUr59JUU9SoH76mnYvtms7aafH/isNSNGwuEfP6W/4xD0/TJXxrizw==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.1.2.tgz", + "integrity": "sha512-ktsDOALzTYTWWF1PbkNVg2rOt+HaOaMWJMUnt7T3qf5tvZ1L8dBW3tObzprBcXNMKkwj+yFSLqHso0x+UFcJXw==", "license": "MIT", "dependencies": { "eventemitter3": "^5.0.1", @@ -1692,9 +1621,9 @@ } }, "node_modules/@langchain/ollama": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@langchain/ollama/-/ollama-1.2.0.tgz", - "integrity": "sha512-OinxIhssKXdDQKnQoBF4TQTMBuMMV5OcNPk4Zze8UjcaSOGngn3CAI1FVbBxl0bTG5ov61w4AoWWsUwOwiSJFw==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@langchain/ollama/-/ollama-1.2.6.tgz", + "integrity": "sha512-wEfjRjyB20SMduqjriIBEalXZf1twbfaNTxxLIjKCVrufHPtKJKGy1a0tQHqa+27HwektNNXlcMre7MTuaS5Rw==", "license": "MIT", "dependencies": { "ollama": "^0.6.3", @@ -1721,20 +1650,20 @@ } }, "node_modules/@langchain/openai": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@langchain/openai/-/openai-1.2.2.tgz", - "integrity": "sha512-ByGtj9nJlyL2UPR7BAxtM34g8JA0qEfDKZq7ZisLW23ju+da1ZRAKogoEqoEHHSxl5fAt2LXcydsIYx0qgCDgg==", + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@langchain/openai/-/openai-1.4.5.tgz", + "integrity": "sha512-bQ2WMIZfSh02trJLYSAtiIcD3j6EBCiAm9nw0dZWQsVaUxmWc3JJqs8uUte6AkMazmLHzcUIw+14UkXO5fRJvQ==", "license": "MIT", "dependencies": { "js-tiktoken": "^1.0.12", - "openai": "^6.10.0", + "openai": "^6.34.0", "zod": "^3.25.76 || ^4" }, "engines": { "node": ">=20" }, "peerDependencies": { - "@langchain/core": "^1.0.0" + "@langchain/core": "^1.1.42" } }, "node_modules/@mapbox/node-pre-gyp": { @@ -1769,19 +1698,21 @@ } }, "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==", "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/@nodelib/fs.scandir": { @@ -1822,15 +1753,13 @@ "node": ">= 8" } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, + "node_modules/@oxc-project/types": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", + "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" + "funding": { + "url": "https://github.com/sponsors/Boshen" } }, "node_modules/@playwright/test": { @@ -1849,10 +1778,252 @@ "node": ">=18" } }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", + "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", + "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.53", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", - "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==", + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", "dev": true, "license": "MIT" }, @@ -1879,331 +2050,6 @@ } } }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", - "integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz", - "integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz", - "integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz", - "integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz", - "integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz", - "integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz", - "integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz", - "integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz", - "integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz", - "integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz", - "integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz", - "integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz", - "integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz", - "integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz", - "integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz", - "integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz", - "integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==", - "cpu": [ - "s390x" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz", - "integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz", - "integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz", - "integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz", - "integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz", - "integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz", - "integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz", - "integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz", - "integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/@sigma/edge-curve": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@sigma/edge-curve/-/edge-curve-3.1.0.tgz", @@ -2217,57 +2063,50 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, "license": "MIT" }, "node_modules/@tailwindcss/node": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", - "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz", + "integrity": "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==", "license": "MIT", "dependencies": { - "@jridgewell/remapping": "^2.3.4", - "enhanced-resolve": "^5.18.3", + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", - "lightningcss": "1.30.2", + "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.1.18" + "tailwindcss": "4.2.4" } }, - "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", - "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.4.tgz", + "integrity": "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==", "license": "MIT", "engines": { - "node": ">= 10" + "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.1.18", - "@tailwindcss/oxide-darwin-arm64": "4.1.18", - "@tailwindcss/oxide-darwin-x64": "4.1.18", - "@tailwindcss/oxide-freebsd-x64": "4.1.18", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", - "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", - "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", - "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", - "@tailwindcss/oxide-linux-x64-musl": "4.1.18", - "@tailwindcss/oxide-wasm32-wasi": "4.1.18", - "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", - "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" + "@tailwindcss/oxide-android-arm64": "4.2.4", + "@tailwindcss/oxide-darwin-arm64": "4.2.4", + "@tailwindcss/oxide-darwin-x64": "4.2.4", + "@tailwindcss/oxide-freebsd-x64": "4.2.4", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", + "@tailwindcss/oxide-linux-x64-musl": "4.2.4", + "@tailwindcss/oxide-wasm32-wasi": "4.2.4", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", - "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.4.tgz", + "integrity": "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==", "cpu": [ "arm64" ], @@ -2277,13 +2116,13 @@ "android" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", - "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.4.tgz", + "integrity": "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==", "cpu": [ "arm64" ], @@ -2293,13 +2132,13 @@ "darwin" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", - "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.4.tgz", + "integrity": "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==", "cpu": [ "x64" ], @@ -2309,13 +2148,13 @@ "darwin" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", - "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.4.tgz", + "integrity": "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==", "cpu": [ "x64" ], @@ -2325,13 +2164,13 @@ "freebsd" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", - "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.4.tgz", + "integrity": "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==", "cpu": [ "arm" ], @@ -2341,13 +2180,13 @@ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", - "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.4.tgz", + "integrity": "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==", "cpu": [ "arm64" ], @@ -2357,13 +2196,13 @@ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", - "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.4.tgz", + "integrity": "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==", "cpu": [ "arm64" ], @@ -2373,13 +2212,13 @@ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", - "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.4.tgz", + "integrity": "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==", "cpu": [ "x64" ], @@ -2389,13 +2228,13 @@ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", - "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.4.tgz", + "integrity": "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==", "cpu": [ "x64" ], @@ -2405,13 +2244,13 @@ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", - "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.4.tgz", + "integrity": "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -2426,21 +2265,21 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", - "tslib": "^2.4.0" + "tslib": "^2.8.1" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", - "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz", + "integrity": "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==", "cpu": [ "arm64" ], @@ -2450,13 +2289,13 @@ "win32" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", - "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.4.tgz", + "integrity": "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==", "cpu": [ "x64" ], @@ -2466,29 +2305,23 @@ "win32" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/vite": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz", - "integrity": "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.4.tgz", + "integrity": "sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw==", "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.1.18", - "@tailwindcss/oxide": "4.1.18", - "tailwindcss": "4.1.18" + "@tailwindcss/node": "4.2.4", + "@tailwindcss/oxide": "4.2.4", + "tailwindcss": "4.2.4" }, "peerDependencies": { - "vite": "^5.2.0 || ^6 || ^7" + "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, - "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", @@ -3020,7 +2853,6 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, "license": "MIT" }, "node_modules/@types/mdast": { @@ -3103,12 +2935,6 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, - "node_modules/@types/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", - "license": "MIT" - }, "node_modules/@ungap/structured-clone": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", @@ -3243,16 +3069,16 @@ } }, "node_modules/@vitejs/plugin-react": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz", - "integrity": "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.28.5", + "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.53", + "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, @@ -3260,36 +3086,33 @@ "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "node_modules/@vitest/coverage-v8": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", - "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz", + "integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==", "dev": true, "license": "MIT", "dependencies": { - "@ampproject/remapping": "^2.3.0", "@bcoe/v8-coverage": "^1.0.2", - "ast-v8-to-istanbul": "^0.3.3", - "debug": "^4.4.1", + "@vitest/utils": "4.1.5", + "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", - "istanbul-lib-source-maps": "^5.0.6", - "istanbul-reports": "^3.1.7", - "magic-string": "^0.30.17", - "magicast": "^0.3.5", - "std-env": "^3.9.0", - "test-exclude": "^7.0.1", - "tinyrainbow": "^2.0.0" + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "3.2.4", - "vitest": "3.2.4" + "@vitest/browser": "4.1.5", + "vitest": "4.1.5" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -3298,39 +3121,40 @@ } }, "node_modules/@vitest/expect": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", - "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", + "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", "dev": true, "license": "MIT", "dependencies": { + "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", - "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", + "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.2.4", + "@vitest/spy": "4.1.5", "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" + "magic-string": "^0.30.21" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "msw": { @@ -3352,42 +3176,42 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", - "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", + "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^2.0.0" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", - "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", + "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.2.4", - "pathe": "^2.0.3", - "strip-literal": "^3.0.0" + "@vitest/utils": "4.1.5", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/snapshot": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", - "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", + "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.4", - "magic-string": "^0.30.17", + "@vitest/pretty-format": "4.1.5", + "@vitest/utils": "4.1.5", + "magic-string": "^0.30.21", "pathe": "^2.0.3" }, "funding": { @@ -3395,28 +3219,25 @@ } }, "node_modules/@vitest/spy": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", - "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", + "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", "dev": true, "license": "MIT", - "dependencies": { - "tinyspy": "^4.0.3" - }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/utils": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", - "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", + "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.4", - "loupe": "^3.1.4", - "tinyrainbow": "^2.0.0" + "@vitest/pretty-format": "4.1.5", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -3500,6 +3321,7 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -3544,9 +3366,9 @@ } }, "node_modules/ast-v8-to-istanbul": { - "version": "0.3.12", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz", - "integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", + "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", "dev": true, "license": "MIT", "dependencies": { @@ -3731,16 +3553,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -3798,53 +3610,15 @@ } }, "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, "engines": { "node": ">=18" } }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chalk/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/character-entities": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", @@ -3885,16 +3659,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, "node_modules/chevrotain": { "version": "12.0.0", "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-12.0.0.tgz", @@ -3947,24 +3711,6 @@ "dev": true, "license": "MIT" }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -4019,15 +3765,6 @@ "node": "^14.18.0 || >=16.10.0" } }, - "node_modules/console-table-printer": { - "version": "2.15.0", - "resolved": "https://registry.npmjs.org/console-table-printer/-/console-table-printer-2.15.0.tgz", - "integrity": "sha512-SrhBq4hYVjLCkBVOWaTzceJalvn5K1Zq5aQA6wXC/cYjI3frKWNPEMK3sZsJfNNQApvCQmgBcc13ZKmFj8qExw==", - "license": "MIT", - "dependencies": { - "simple-wcswidth": "^1.1.2" - } - }, "node_modules/convert-hrtime": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/convert-hrtime/-/convert-hrtime-3.0.0.tgz", @@ -4061,21 +3798,6 @@ "dev": true, "license": "MIT" }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/css-tree": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", @@ -4706,16 +4428,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/delaunator": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz", @@ -4806,13 +4518,6 @@ "node": ">= 0.4" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT" - }, "node_modules/edge-runtime": { "version": "2.5.9", "resolved": "https://registry.npmjs.org/edge-runtime/-/edge-runtime-2.5.9.tgz", @@ -4861,21 +4566,14 @@ "dev": true, "license": "ISC" }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, "node_modules/enhanced-resolve": { - "version": "5.18.4", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", - "integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==", + "version": "5.21.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz", + "integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" + "tapable": "^2.3.3" }, "engines": { "node": ">=10.13.0" @@ -4950,7 +4648,7 @@ "version": "0.27.0", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.0.tgz", "integrity": "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -5107,7 +4805,6 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -5161,23 +4858,6 @@ } } }, - "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==", - "dev": true, - "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/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", @@ -5412,6 +5092,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5666,16 +5347,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -5700,9 +5371,9 @@ } }, "node_modules/is-network-error": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.0.tgz", - "integrity": "sha512-6oIwpsgRfnDiyEDLMay/GqCl3HoAtH5+RUKW29gYkL0QA+ipzpDLA16yQs7/RHCSu+BwgbJaOUqa4A99qNVQVw==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.1.tgz", + "integrity": "sha512-6QCxa49rQbmUWLfk0nuGqzql9U8uaV2H6279bRErPBHe/109hCzsLUBUHfbEtvLIHBd6hyXbgedBSHevm43Edw==", "license": "MIT", "engines": { "node": ">=16" @@ -5740,13 +5411,6 @@ "dev": true, "license": "MIT" }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -5772,21 +5436,6 @@ "node": ">=10" } }, - "node_modules/istanbul-lib-source-maps": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", - "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.23", - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/istanbul-reports": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", @@ -5801,22 +5450,6 @@ "node": ">=8" } }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -6026,35 +5659,35 @@ "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" }, "node_modules/langchain": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/langchain/-/langchain-1.2.10.tgz", - "integrity": "sha512-9uVxOJE/RTECvNutQfOLwH7f6R9mcq0G/IMHwA2eptDA86R/Yz2zWMz4vARVFPxPrdSJ9nJFDPAqRQlRFwdHBw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/langchain/-/langchain-1.3.4.tgz", + "integrity": "sha512-umrD+ZC6vr0Q0U1lC5PoIMMQqgnP+7QhaIdAXCeZiSX2GPVBiVR7Ed2ZR+3MPvz1yWrRtIm17wPaovkND+wOXg==", "license": "MIT", "dependencies": { - "@langchain/langgraph": "^1.0.0", - "@langchain/langgraph-checkpoint": "^1.0.0", - "langsmith": ">=0.4.0 <1.0.0", - "uuid": "^10.0.0", + "@langchain/langgraph": "^1.2.9", + "@langchain/langgraph-checkpoint": "^1.0.1", + "langsmith": ">=0.5.0 <1.0.0", + "uuid": "^11.1.0", "zod": "^3.25.76 || ^4" }, "engines": { "node": ">=20" }, "peerDependencies": { - "@langchain/core": "1.1.15" + "@langchain/core": "^1.1.41" } }, "node_modules/langchain/node_modules/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist/esm/bin/uuid" } }, "node_modules/langium": { @@ -6076,23 +5709,20 @@ } }, "node_modules/langsmith": { - "version": "0.4.7", - "resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.4.7.tgz", - "integrity": "sha512-Esv5g/J8wwRwbGQr10PB9+bLsNk0mWbrXc7nnEreQDhh0azbU57I7epSnT7GC4sS4EOWavhbxk+6p8PTXtreHw==", + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.5.23.tgz", + "integrity": "sha512-dE/M/2Gg2S2R8ygDdkWGJVO3JstijvsNvPXsy9V8WGbpb88Zn8xF/aTjPx4mIy5gIoo02T6FssOgYyLf51Dv1Q==", "license": "MIT", "dependencies": { - "@types/uuid": "^10.0.0", - "chalk": "^4.1.2", - "console-table-printer": "^2.12.1", - "p-queue": "^6.6.2", - "semver": "^7.6.3", - "uuid": "^10.0.0" + "p-queue": "6.6.2", + "uuid": "10.0.0" }, "peerDependencies": { "@opentelemetry/api": "*", "@opentelemetry/exporter-trace-otlp-proto": "*", "@opentelemetry/sdk-trace-base": "*", - "openai": "*" + "openai": "*", + "ws": ">=7" }, "peerDependenciesMeta": { "@opentelemetry/api": { @@ -6106,6 +5736,9 @@ }, "openai": { "optional": true + }, + "ws": { + "optional": true } } }, @@ -6129,9 +5762,9 @@ "license": "MIT" }, "node_modules/lightningcss": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", - "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -6144,23 +5777,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.30.2", - "lightningcss-darwin-arm64": "1.30.2", - "lightningcss-darwin-x64": "1.30.2", - "lightningcss-freebsd-x64": "1.30.2", - "lightningcss-linux-arm-gnueabihf": "1.30.2", - "lightningcss-linux-arm64-gnu": "1.30.2", - "lightningcss-linux-arm64-musl": "1.30.2", - "lightningcss-linux-x64-gnu": "1.30.2", - "lightningcss-linux-x64-musl": "1.30.2", - "lightningcss-win32-arm64-msvc": "1.30.2", - "lightningcss-win32-x64-msvc": "1.30.2" + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", - "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", "cpu": [ "arm64" ], @@ -6178,9 +5811,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", - "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", "cpu": [ "arm64" ], @@ -6198,9 +5831,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", - "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", "cpu": [ "x64" ], @@ -6218,9 +5851,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", - "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", "cpu": [ "x64" ], @@ -6238,9 +5871,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", - "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", "cpu": [ "arm" ], @@ -6258,9 +5891,9 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", - "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", "cpu": [ "arm64" ], @@ -6278,9 +5911,9 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", - "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", "cpu": [ "arm64" ], @@ -6298,9 +5931,9 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", - "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", "cpu": [ "x64" ], @@ -6318,9 +5951,9 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", - "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", "cpu": [ "x64" ], @@ -6338,9 +5971,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", - "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", "cpu": [ "arm64" ], @@ -6358,9 +5991,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", - "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", "cpu": [ "x64" ], @@ -6412,13 +6045,6 @@ "loose-envify": "cli.js" } }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, "node_modules/lowlight": { "version": "1.20.0", "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz", @@ -6443,9 +6069,9 @@ } }, "node_modules/lucide-react": { - "version": "0.562.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz", - "integrity": "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.11.0.tgz", + "integrity": "sha512-UOhjdztXCgdBReRcIhsvz2siIBogfv/lhJEIViCpLt924dO+GDms9T7DNoucI23s6kEPpe988m5N0D2ajnzb2g==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -6472,15 +6098,15 @@ } }, "node_modules/magicast": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", - "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.25.4", - "@babel/types": "^7.25.4", - "source-map-js": "^1.2.0" + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" } }, "node_modules/make-dir": { @@ -7687,6 +7313,17 @@ "integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==", "license": "MIT" }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/ollama": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/ollama/-/ollama-0.6.3.tgz", @@ -7697,9 +7334,9 @@ } }, "node_modules/openai": { - "version": "6.16.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-6.16.0.tgz", - "integrity": "sha512-fZ1uBqjFUjXzbGc35fFtYKEOxd20kd9fDpFeqWtsOZWiubY8CZ1NAlXHW3iathaFvqmNtCWMIsosCuyeI7Joxg==", + "version": "6.34.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.34.0.tgz", + "integrity": "sha512-yEr2jdGf4tVFYG6ohmr3pF6VJuveP0EA/sS8TBx+4Eq5NT10alu5zg2dmxMXMgqpihRDQlFGpRt2XwsGj+Fyxw==", "license": "Apache-2.0", "bin": { "openai": "bin/cli" @@ -7769,13 +7406,6 @@ "node": ">=8" } }, - "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==", - "dev": true, - "license": "BlueOak-1.0.0" - }, "node_modules/package-manager-detector": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", @@ -7852,16 +7482,6 @@ "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", "license": "MIT" }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/path-scurry": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", @@ -7900,16 +7520,6 @@ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "license": "MIT" }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -7917,10 +7527,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "engines": { "node": ">=12" @@ -8004,9 +7613,9 @@ } }, "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==", "funding": [ { "type": "opencollective", @@ -8359,50 +7968,45 @@ "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==", "license": "Unlicense" }, - "node_modules/rollup": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz", - "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==", + "node_modules/rolldown": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", + "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@oxc-project/types": "=0.127.0", + "@rolldown/pluginutils": "1.0.0-rc.17" }, "bin": { - "rollup": "dist/bin/rollup" + "rolldown": "bin/cli.mjs" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" + "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.55.1", - "@rollup/rollup-android-arm64": "4.55.1", - "@rollup/rollup-darwin-arm64": "4.55.1", - "@rollup/rollup-darwin-x64": "4.55.1", - "@rollup/rollup-freebsd-arm64": "4.55.1", - "@rollup/rollup-freebsd-x64": "4.55.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", - "@rollup/rollup-linux-arm-musleabihf": "4.55.1", - "@rollup/rollup-linux-arm64-gnu": "4.55.1", - "@rollup/rollup-linux-arm64-musl": "4.55.1", - "@rollup/rollup-linux-loong64-gnu": "4.55.1", - "@rollup/rollup-linux-loong64-musl": "4.55.1", - "@rollup/rollup-linux-ppc64-gnu": "4.55.1", - "@rollup/rollup-linux-ppc64-musl": "4.55.1", - "@rollup/rollup-linux-riscv64-gnu": "4.55.1", - "@rollup/rollup-linux-riscv64-musl": "4.55.1", - "@rollup/rollup-linux-s390x-gnu": "4.55.1", - "@rollup/rollup-linux-x64-gnu": "4.55.1", - "@rollup/rollup-linux-x64-musl": "4.55.1", - "@rollup/rollup-openbsd-x64": "4.55.1", - "@rollup/rollup-openharmony-arm64": "4.55.1", - "@rollup/rollup-win32-arm64-msvc": "4.55.1", - "@rollup/rollup-win32-ia32-msvc": "4.55.1", - "@rollup/rollup-win32-x64-gnu": "4.55.1", - "@rollup/rollup-win32-x64-msvc": "4.55.1", - "fsevents": "~2.3.2" + "@rolldown/binding-android-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-x64": "1.0.0-rc.17", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" } }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", + "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", + "license": "MIT" + }, "node_modules/roughjs": { "version": "4.6.6", "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz", @@ -8487,6 +8091,7 @@ "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -8495,29 +8100,6 @@ "node": ">=10" } }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -8548,12 +8130,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/simple-wcswidth": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/simple-wcswidth/-/simple-wcswidth-1.1.2.tgz", - "integrity": "sha512-j7piyCjAeTDSjzTSQ7DokZtMNwNlEAyxqSZeCS+CXH7fJ4jx3FuJ/mTW3mE+6JLs4VJBbcll0Kjn+KXI5t21Iw==", - "license": "MIT" - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -8581,66 +8157,12 @@ "license": "MIT" }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", "dev": true, "license": "MIT" }, - "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -8655,49 +8177,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -8711,26 +8190,6 @@ "node": ">=8" } }, - "node_modules/strip-literal": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", - "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/strip-literal/node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true, - "license": "MIT" - }, "node_modules/style-to-js": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", @@ -8759,6 +8218,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -8775,15 +8235,15 @@ "license": "MIT" }, "node_modules/tailwindcss": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", - "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz", + "integrity": "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==", "license": "MIT" }, "node_modules/tapable": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", - "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", "license": "MIT", "engines": { "node": ">=6" @@ -8810,139 +8270,6 @@ "node": ">=18" } }, - "node_modules/test-exclude": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", - "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", - "dev": true, - "license": "ISC", - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^10.4.1", - "minimatch": "^10.2.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/test-exclude/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/test-exclude/node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "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", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/test-exclude/node_modules/glob/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/test-exclude/node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/test-exclude/node_modules/glob/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/test-exclude/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/test-exclude/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/test-exclude/node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/time-span": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/time-span/-/time-span-4.0.0.tgz", @@ -8976,14 +8303,13 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -8992,30 +8318,10 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, "node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", - "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, "license": "MIT", "engines": { @@ -9421,20 +8727,22 @@ } }, "node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", + "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", "license": "MIT", "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.10", + "rolldown": "1.0.0-rc.17", + "tinyglobby": "^0.2.16" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^20.19.0 || >=22.12.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -9443,23 +8751,33 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" }, "peerDependenciesMeta": { "@types/node": { "optional": true }, - "less": { + "@vitejs/devtools": { "optional": true }, - "lightningcss": { + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { "optional": true }, "sass": { @@ -9476,505 +8794,89 @@ }, "terser": { "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true } } }, - "node_modules/vite-node": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", - "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.1", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vite-node/node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, - "license": "MIT" - }, - "node_modules/vite/node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, "node_modules/vitest": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", - "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", + "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", "dev": true, "license": "MIT", "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.4", - "@vitest/mocker": "3.2.4", - "@vitest/pretty-format": "^3.2.4", - "@vitest/runner": "3.2.4", - "@vitest/snapshot": "3.2.4", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "debug": "^4.4.1", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", + "@vitest/expect": "4.1.5", + "@vitest/mocker": "4.1.5", + "@vitest/pretty-format": "4.1.5", + "@vitest/runner": "4.1.5", + "@vitest/snapshot": "4.1.5", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", "pathe": "^2.0.3", - "picomatch": "^4.0.2", - "std-env": "^3.9.0", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.14", - "tinypool": "^1.1.1", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", - "vite-node": "3.2.4", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.4", - "@vitest/ui": "3.2.4", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.5", + "@vitest/browser-preview": "4.1.5", + "@vitest/browser-webdriverio": "4.1.5", + "@vitest/coverage-istanbul": "4.1.5", + "@vitest/coverage-v8": "4.1.5", + "@vitest/ui": "4.1.5", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { "optional": true }, - "@types/debug": { + "@opentelemetry/api": { "optional": true }, "@types/node": { "optional": true }, - "@vitest/browser": { + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { "optional": true }, "@vitest/ui": { @@ -9985,13 +8887,16 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, - "node_modules/vitest/node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "node_modules/vitest/node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", "dev": true, "license": "MIT" }, @@ -10111,22 +9016,6 @@ "webidl-conversions": "^3.0.0" } }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -10144,107 +9033,6 @@ "node": ">=8" } }, - "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json index 08b2eed7d..463eb2d8e 100644 --- a/gitnexus-web/package.json +++ b/gitnexus-web/package.json @@ -3,7 +3,7 @@ "private": true, "version": "0.0.0", "engines": { - "node": ">=20.0.0" + "node": "^20.19.0 || >=22.12.0" }, "type": "module", "scripts": { @@ -19,14 +19,14 @@ }, "dependencies": { "gitnexus-shared": "file:../gitnexus-shared", - "@langchain/anthropic": "^1.3.10", - "@langchain/core": "^1.1.15", - "@langchain/google-genai": "^2.1.10", - "@langchain/langgraph": "^1.1.0", - "@langchain/ollama": "^1.2.0", - "@langchain/openai": "^1.2.2", + "@langchain/anthropic": "^1.3.27", + "@langchain/core": "^1.1.41", + "@langchain/google-genai": "^2.1.28", + "@langchain/langgraph": "^1.2.9", + "@langchain/ollama": "^1.2.6", + "@langchain/openai": "^1.4.5", "@sigma/edge-curve": "^3.1.0", - "@tailwindcss/vite": "^4.1.18", + "@tailwindcss/vite": "^4.2.4", "axios": "^1.13.2", "d3": "^7.9.0", "dompurify": "^3.3.3", @@ -36,9 +36,9 @@ "graphology-layout-forceatlas2": "^0.10.1", "graphology-layout-noverlap": "^0.4.2", "graphology-utils": "^2.3.0", - "langchain": "^1.2.10", + "langchain": "^1.3.4", "lru-cache": "^11.2.4", - "lucide-react": "^0.562.0", + "lucide-react": "^1.11.0", "mermaid": "^11.14.0", "mnemonist": "^0.39.0", "pandemonium": "^2.4.0", @@ -49,12 +49,12 @@ "react-zoom-pan-pinch": "^3.7.0", "remark-gfm": "^4.0.1", "sigma": "^3.0.2", - "tailwindcss": "^4.2.2", + "tailwindcss": "^4.2.4", "uuid": "^13.0.0", "zod": "^3.25.76" }, "devDependencies": { - "@babel/types": "^7.28.5", + "@babel/types": "^7.29.0", "@playwright/test": "^1.58.2", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", @@ -65,13 +65,13 @@ "@types/react-dom": "^18.3.0", "@types/react-syntax-highlighter": "^15.5.13", "@vercel/node": "^5.5.16", - "@vitejs/plugin-react": "^5.1.0", - "@vitest/coverage-v8": "^3.2.4", + "@vitejs/plugin-react": "^5.1.4", + "@vitest/coverage-v8": "^4.1.5", "jsdom": "^29.0.2", "tree-sitter-wasms": "^0.1.13", "typescript": "^5.4.5", - "vite": "^5.2.0", - "vitest": "^3.2.4", + "vite": "^8.0.10", + "vitest": "^4.1.5", "wait-on": "^9.0.5" } } diff --git a/gitnexus-web/src/lib/lucide-icons.tsx b/gitnexus-web/src/lib/lucide-icons.tsx index c6a4b565b..05676e50d 100644 --- a/gitnexus-web/src/lib/lucide-icons.tsx +++ b/gitnexus-web/src/lib/lucide-icons.tsx @@ -5,7 +5,42 @@ * than directly from lucide-react. This provides a single place to manage * which icons are used and allows future optimization (e.g., tree-shaking * configuration, icon subset bundling) without touching every component. + * + * --- Why a local `Github` icon? --- + * + * Lucide removed all brand icons in v1 + * (https://lucide.dev/guide/react/migration, + * https://github.com/lucide-icons/lucide/blob/main/BRAND_LOGOS_STATEMENT.md), + * so `import { Github } from 'lucide-react'` no longer compiles. + * + * We replace it with the official mark from Primer Octicons — the icon set + * GitHub itself uses on github.com — copied verbatim. This was preferred over + * the alternatives because it: + * + * 1. Is the canonical GitHub-maintained source for the mark, kept in sync + * with what users see on github.com. + * 2. Is MIT-licensed (Copyright (c) GitHub Inc., + * https://github.com/primer/octicons/blob/main/LICENSE), so embedding the + * path data is permitted. + * 3. Adds zero new npm dependencies (vs. `@primer/octicons-react`, + * `react-icons`, or `simple-icons`), keeping the web bundle lean. + * 4. Ships per-size hand-tuned glyphs (16 + 24) — the same approach Primer + * uses — so the mark stays crisp at the small `h-4 w-4` sites in the + * header and onboarding screens as well as at full size. + * + * Trademark note: the GitHub mark is a registered trademark of GitHub, Inc. + * (https://brand.github.com/foundations/logo). The MIT license covers our + * right to copy the SVG; trademark rules still govern *use*. We use the mark + * here only to link to GitHub and to indicate GitHub source-repo integration, + * which are explicitly permitted by GitHub's brand toolkit. + * + * SVG sources (copied verbatim, MIT, Copyright (c) GitHub Inc.): + * - https://github.com/primer/octicons/blob/main/icons/mark-github-16.svg + * - https://github.com/primer/octicons/blob/main/icons/mark-github-24.svg */ +import { forwardRef } from 'react'; +import type { LucideProps } from 'lucide-react'; + export { AlertCircle, AlertTriangle, @@ -31,7 +66,6 @@ export { Folder, FolderOpen, GitBranch, - Github, Globe, Hash, Heart, @@ -75,3 +109,63 @@ export { ZoomIn, ZoomOut, } from 'lucide-react'; + +/** + * GitHub mark — local copy of Primer Octicons `mark-github-{16,24}`. + * + * Why this exists, why Primer Octicons, and the trademark caveat are documented + * at the top of this file. Please read that header before changing the SVG + * paths or swapping the source. + * + * API-compatible with `lucide-react` icons (`LucideProps`). The Octicons mark + * is a *filled* glyph, so the lucide-only stroke props (`strokeWidth`, + * `absoluteStrokeWidth`) are accepted for type parity but ignored. Color + * defaults to `currentColor`, so Tailwind `text-*` utilities work the same as + * with any other icon in this module. + */ +export const Github = forwardRef(function Github( + { + size = 24, + color = 'currentColor', + className, + strokeWidth: _strokeWidth, + absoluteStrokeWidth: _absoluteStrokeWidth, + ...rest + }, + ref, +) { + const numericSize = typeof size === 'string' ? Number.parseFloat(size) : size; + const useSmallVariant = Number.isFinite(numericSize) && (numericSize as number) <= 16; + + if (useSmallVariant) { + return ( + + + + ); + } + + return ( + + + + ); +}); diff --git a/gitnexus-web/test/unit/heartbeat.test.ts b/gitnexus-web/test/unit/heartbeat.test.ts index b090f31f1..a16655015 100644 --- a/gitnexus-web/test/unit/heartbeat.test.ts +++ b/gitnexus-web/test/unit/heartbeat.test.ts @@ -16,9 +16,12 @@ let lastEventSource: MockEventSource | null = null; beforeEach(() => { lastEventSource = null; + // vitest 4 enforces that mock implementations used with `new` must have a + // [[Construct]] slot. Arrow functions don't, so we use a regular function + // declaration here. The production code calls `new EventSource(...)`. vi.stubGlobal( 'EventSource', - vi.fn().mockImplementation(() => { + vi.fn().mockImplementation(function () { lastEventSource = new MockEventSource(); return lastEventSource; }), diff --git a/gitnexus-web/vitest.config.ts b/gitnexus-web/vitest.config.ts index bf870c8df..e1460d840 100644 --- a/gitnexus-web/vitest.config.ts +++ b/gitnexus-web/vitest.config.ts @@ -38,11 +38,15 @@ export default defineConfig({ 'src/main.tsx', // Entry point 'src/vite-env.d.ts', // Type declarations ], + // Thresholds set to the post-vitest-4 baseline (AST-aware remapping + // measures coverage more accurately than the old istanbul-style mapping, + // so the same 220 tests now report slightly lower percentages). These + // are soft floors for regression detection, not coverage targets. thresholds: { - statements: 10, - branches: 10, - functions: 10, - lines: 10, + statements: 9, + branches: 4, + functions: 7, + lines: 9, }, }, }, diff --git a/gitnexus/CHANGELOG.md b/gitnexus/CHANGELOG.md index e4a5041ba..ca51f0493 100644 --- a/gitnexus/CHANGELOG.md +++ b/gitnexus/CHANGELOG.md @@ -4,9 +4,73 @@ All notable changes to GitNexus will be documented in this file. ## [Unreleased] -### Performance +## [1.6.3] - 2026-04-24 -- **`analyze` ~33% faster** — moved FTS index creation from the analyze pipeline to first-use lazy initialisation. The 5 `CREATE_FTS_INDEX` calls cost ~440 ms each in LadybugDB regardless of table size (≈2 s fixed overhead) and dominated runtime on small repos and slow CI runners. The cost now amortises across the first `query`/`context` call in a session via a new `ensureFTSIndex` helper. Mini-repo `analyze` measured locally on Windows: 6.4 s → 4.0 s warm; on CI Windows runners (≈3× slower) restores comfortable headroom against the 30 s e2e test budget. +### Added + +- **Cross-repo impact analysis** — `@repo` MCP routing plus group resources let impact queries span multiple indexed repositories in a group (#794, #984) +- **Python scope-based call resolution** — registry-primary flip, performance, and generalization work from RFC #909 Ring 3 (#980) +- **C# scope-resolution migration** — C# now runs on the registry-primary path alongside Python (#934, #1019) +- **RFC #909 Ring 1 & Ring 2 scope-resolution infrastructure** — the shared foundation for language-agnostic scope resolution: + - Scope-resolution types and constants, `LanguageProvider` hook extension (#910, #911, #949, #950) + - `ScopeTree` + `PositionIndex` + `makeScopeId` (#912, #961) + - `DefIndex` / `ModuleScopeIndex` / `QualifiedNameIndex` (#913, #958) + - `MethodDispatchIndex` materialized view over `HeritageMap` (#914, #960) + - `resolveTypeRef` strict single-return type resolver (#916, #959) + - SCC-aware finalize with bounded fixpoint (#915, #962) + - `ClassRegistry` / `MethodRegistry` / `FieldRegistry` + 7-step lookup (#917, #963) + - Shadow-mode diff + aggregate, parity harness + static dashboard (#918, #923, #951, #972) + - `ScopeExtractor` driver with 5-pass CaptureMatch → ParsedFile (#919, #965) + - `ScopeExtractor` wired into parse-worker + processor (#920, #969) + - `finalize-orchestrator` materializes `ScopeResolutionIndexes` (#921, #970) + - Per-language `resolveImportTarget` adapter (#922, #971) + - `REGISTRY_PRIMARY_` per-language flag reader (#924, #968) + - `emit-references` drains `ReferenceIndex` to graph edges (#925, #973) +- **`gitnexus analyze --name `** with duplicate-name guard in the repo registry (#955) +- **`gitnexus remove `** unindexes a registered repo by name or path (#664, #1003) +- **Auto-infer registry name** from `git remote.origin.url` when `--name` is omitted (#981) +- **Sibling-clone drift detection** — indexed repos are fingerprinted by remote URL so duplicate registrations are caught before graph divergence (#982) +- **Configurable large-file skip threshold** — the walker's 512 KB default is now overridable via `GITNEXUS_MAX_FILE_SIZE` (KB) or `gitnexus analyze --max-file-size `. Values are clamped to the 32 MB tree-sitter ceiling, invalid inputs fall back to the default with a one-time warning, and the CLI banner reports the effective post-clamp threshold when an override is active (#991, #1044, #1045) +- **`GITNEXUS_INDEX_TEST_DIRS` opt-in** for `__tests__` / `__mocks__` traversal (#771, #1046) +- **`analyze` embedding preservation** — existing embeddings are preserved by default, `--force` regenerates them, `--drop-embeddings` opts out entirely (CLI + HTTP API) (#1055) +- **Structural embedding chunking** with data-driven `CHUNKING_RULES` dispatch, replacing the flat line-based split (#987) +- **PHP HTTP consumer detection** for the extractor catalogue (#993) +- **Per-phase search timing** instrumentation across the query pipeline (#953) +- **MCP disambiguation ranking** — `context` / `impact` candidates are ranked and expose `kind` / `file_path` hints (#888) +- **Docker images for UI + CLI/server** shipped via `docker-compose` with cosign signing (#967), RC image builds (#978), and GHCR → Docker Hub mirroring (#1029) + +### Fixed + +- **Go CALLS edges for receiver methods** — worker source IDs now align with the main pipeline, restoring receiver-method call edges (#1043) +- **Node 22 DEP0151 warning** from `tree-sitter-c-sharp` import silenced (#1013, #1049) +- **FTS index bootstrap** tries a local `LOAD` before `INSTALL` so offline/air-gapped runs no longer fail on network errors (#726) +- **FTS ensure failures** are no longer cached and are invalidated on pool teardown (#1006) +- **`groupImpact` local-impact errors** now bubble to the caller instead of being swallowed (#1004, #1007) +- **Friendly error** when a group name is not found, with regression test for #903 (#989) +- **`bm25` results** return FTS-matched symbols instead of an arbitrary `LIMIT 3` slice (#806) +- **Embedding AST traversal** switched from recursion to iterative DFS, fixing stack overflow on deeply nested files (#990) +- **React component path detection** runs before lowercasing, so mixed-case `.jsx`/`.tsx` files are recognised (#260) +- **`detect-changes` ENOBUFS** by setting `maxBuffer` on `git` / `rg` `execFileSync` invocations (#957) +- **`detect-changes` in direct CLI** — command was wired to MCP only; now exposed on the CLI as well (#892) +- **CLI gitnexus markers** — `` is only matched at section position, no longer inside code/prose (#1041, #1042) +- **`opencode.json` setup** preserves existing comments and config during install (#998) +- **Sequential parser logging** — skipped languages are now logged instead of silently dropped (#1021) +- **`cli-e2e` fixture isolation** from the shared mini-repo, plus stabilised `rel-csv-split` stream teardown on Windows via `expect.poll` (#954, #1052) +- **Docker** — RC build guarded against empty `vtag`, `inputs.tag` used to detect `workflow_call` context, web builder stage now copies `gitnexus/package.json`, base image switched from alpine to debian (#983, #996, #997, #1014) +- **CI** — reusable `docker.yml` now inherits secrets from `release-candidate.yml` (#1054) + +### Changed + +- **`setup` config I/O unified** on `mergeJsoncFile` across all writers (#1031) +- **Docker CI** gains a retry wrapper for `build-push` with visibility and hardened shell + +### Chore / Dependencies + +- Dependency bumps: `graphology` 0.25.4 → 0.26.0 (#1001), `uuid` 13 → 14 (#1000), `@huggingface/transformers` (#1035), `@types/node` (#1002), `@types/uuid` (#1016), `vitest` 4.1.4 → 4.1.5 (#1017), `@vitest/coverage-v8` (#1018) +- gitnexus-web dependency bumps: `vite` 5.4.21 → 6.4.2 → 7.3.2 → 8.0.10 + `vitest` 4 (#1061, #1062, #1063), `lucide-react` 0.562.0 → 1.11.0 with local GitHub SVG fallback (#1038), `@langchain/anthropic` 1.3.10 → 1.3.27 (#1039), `@babel/types` (#1037) +- gitnexus-shared dependency bumps: `typescript` (#1034) +- GitHub Actions bumps: `actions/setup-node` 6.3.0 → 6.4.0 (#1033) +- Documentation: repo-wide `DoD.md` Definition of Done (#1032), gRPC microservices group guide (#906, #994), `group add` / `group remove` README fixes (#1020), CLI docs include `--skip-git` (#750), README Discord link updated ## [1.6.2] - 2026-04-18 diff --git a/gitnexus/Dockerfile.test b/gitnexus/Dockerfile.test index 7cafbe2c1..0282129ff 100644 --- a/gitnexus/Dockerfile.test +++ b/gitnexus/Dockerfile.test @@ -3,7 +3,8 @@ WORKDIR /app 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 \ + && npm rebuild tree-sitter-swift 2>&1 \ + && node -e "require('tree-sitter-swift')" \ && (npm rebuild 2>&1 || true) \ && cd node_modules/tree-sitter-kotlin && npx --yes node-gyp rebuild 2>&1 CMD ["npx", "vitest", "run", "test/integration", "--reporter=verbose"] diff --git a/gitnexus/README.md b/gitnexus/README.md index 26f861f7e..8a5144598 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -155,6 +155,8 @@ gitnexus analyze --force # Force full re-index gitnexus analyze --embeddings # Enable embedding generation (slower, better search) gitnexus analyze --skip-agents-md # Preserve custom AGENTS.md/CLAUDE.md gitnexus section edits gitnexus analyze --verbose # Log skipped files when parsers are unavailable +gitnexus analyze --max-file-size 1024 # Skip files larger than N KB (default: 512, cap: 32768) +gitnexus analyze --worker-timeout 60 # Increase worker idle timeout for slow parses gitnexus mcp # Start MCP server (stdio) — serves all indexed repos gitnexus serve # Start local HTTP server (multi-repo) for web UI gitnexus index # Register an existing .gitnexus/ folder into the global registry @@ -294,6 +296,25 @@ If `npm install -g gitnexus` fails on native modules: npm install -g gitnexus ``` +### Analyze warns about unavailable FTS or VECTOR extensions + +GitNexus uses optional DuckDB extensions for BM25 and vector search. The `gitnexus serve` and MCP read paths only ever try to `LOAD` the extensions — they never block on a network install. The `analyze` command, by default, attempts one bounded out-of-process `INSTALL` if `LOAD` fails and proceeds even when that install times out, so the index is always written to disk; BM25/vector search degrade gracefully until the extensions become available. + +Configure the behavior with two environment variables: + +| Variable | Values | Default | Effect | +|----------|--------|---------|--------| +| `GITNEXUS_LBUG_EXTENSION_INSTALL` | `auto`, `load-only`, `never` | `auto` | `auto` runs one bounded INSTALL if LOAD fails. `load-only` only uses already-installed extensions (recommended for offline / firewalled environments). `never` skips optional extensions entirely. | +| `GITNEXUS_LBUG_EXTENSION_INSTALL_TIMEOUT_MS` | positive integer | `15000` | Wall-clock budget for the out-of-process `INSTALL` child before it is killed. | + +```bash +# Offline/airgapped: never reach the network for extensions +GITNEXUS_LBUG_EXTENSION_INSTALL=load-only npx gitnexus analyze + +# Slow network: give extension downloads more time +GITNEXUS_LBUG_EXTENSION_INSTALL_TIMEOUT_MS=30000 npx gitnexus analyze +``` + ### Analysis runs out of memory For very large repositories: @@ -307,6 +328,36 @@ echo "vendor/" >> .gitnexusignore echo "dist/" >> .gitnexusignore ``` +### Large files are being skipped + +By default the walker skips files larger than **512 KB** (see log line `Skipped N large files (>512KB)`). Raise the threshold via either the CLI flag or the environment variable — both accept a value in **KB**: + +```bash +# CLI flag (takes precedence over the env var) +npx gitnexus analyze --max-file-size 2048 # skip only files > 2 MB + +# Environment variable (persists across commands) +export GITNEXUS_MAX_FILE_SIZE=2048 +npx gitnexus analyze +``` + +Values above **32768 KB (32 MB)** are clamped to the tree-sitter parser ceiling; invalid values fall back to the 512 KB default with a one-time warning. When an override is active, `analyze` prints the effective threshold in its startup banner (e.g. `GITNEXUS_MAX_FILE_SIZE: effective threshold 2048KB (default 512KB)`). + +### Analyze reports a worker timeout + +Worker parse timeouts are recoverable. GitNexus retries stalled worker jobs with backoff, splits large jobs to isolate slow files, and falls back to the sequential parser when needed. If a large repository needs more time per worker job, use either: + +```bash +# CLI flag, in seconds +npx gitnexus analyze --worker-timeout 60 + +# Environment variable, in milliseconds +export GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=60000 +npx gitnexus analyze +``` + +For repositories with very large source files, `GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES` controls the worker job byte budget. The default is **8388608 bytes (8 MB)**. + ## Privacy - All processing happens locally on your machine diff --git a/gitnexus/hooks/claude/gitnexus-hook.cjs b/gitnexus/hooks/claude/gitnexus-hook.cjs index 8b146acfc..34283b48e 100755 --- a/gitnexus/hooks/claude/gitnexus-hook.cjs +++ b/gitnexus/hooks/claude/gitnexus-hook.cjs @@ -31,11 +31,21 @@ function readInput() { * Find the .gitnexus directory by walking up from startDir. * Returns the path to .gitnexus/ or null if not found. */ +function isGlobalRegistryDir(candidate) { + if (fs.existsSync(path.join(candidate, 'meta.json'))) return false; + return ( + fs.existsSync(path.join(candidate, 'registry.json')) || + fs.existsSync(path.join(candidate, 'repos')) + ); +} + function findGitNexusDir(startDir) { let dir = startDir || process.cwd(); for (let i = 0; i < 5; i++) { const candidate = path.join(dir, '.gitnexus'); - if (fs.existsSync(candidate)) return candidate; + if (fs.existsSync(candidate)) { + if (!isGlobalRegistryDir(candidate)) return candidate; + } const parent = path.dirname(dir); if (parent === dir) break; dir = parent; diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 5166377ca..9b37042c3 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -1,12 +1,12 @@ { "name": "gitnexus", - "version": "1.6.2", + "version": "1.6.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitnexus", - "version": "1.6.2", + "version": "1.6.3", "hasInstallScript": true, "license": "PolyForm-Noncommercial-1.0.0", "dependencies": { @@ -24,6 +24,7 @@ "graphology-utils": "^2.3.0", "ignore": "^7.0.5", "js-yaml": "^4.1.1", + "jsonc-parser": "^3.3.1", "lru-cache": "^11.0.0", "mnemonist": "^0.40.3", "onnxruntime-node": "^1.24.0", @@ -64,10 +65,10 @@ "optionalDependencies": { "node-addon-api": "^8.0.0", "node-gyp-build": "^4.8.0", - "tree-sitter-dart": "git+https://github.com/UserNobody14/tree-sitter-dart.git#80e23c07b64494f7e21090bb3450223ef0b192f4", + "tree-sitter-dart": "file:./vendor/tree-sitter-dart", "tree-sitter-kotlin": "^0.3.8", "tree-sitter-proto": "file:./vendor/tree-sitter-proto", - "tree-sitter-swift": "^0.6.0" + "tree-sitter-swift": "file:./vendor/tree-sitter-swift" } }, "../gitnexus-shared": { @@ -640,15 +641,15 @@ "license": "Apache-2.0" }, "node_modules/@huggingface/transformers": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@huggingface/transformers/-/transformers-4.1.0.tgz", - "integrity": "sha512-WiMf9eyvF6V2pj4gs12A7GQV3svyFIBtB/W+Hn5lT5E5DyqWUno1ZrWoAfJv69X1RNv/0GoOo6DFmL6NOYd+rg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@huggingface/transformers/-/transformers-4.2.0.tgz", + "integrity": "sha512-8BRCoBMH0XsWaEIamuR0LrJGAfftgHAfb2Vrffy0VKlSAE/MnUJ5/h/zTfEP3fDIft+nk7TqB8xXEyABGitBjQ==", "license": "Apache-2.0", "dependencies": { "@huggingface/jinja": "^0.5.6", "@huggingface/tokenizers": "^0.1.3", "onnxruntime-node": "1.24.3", - "onnxruntime-web": "1.26.0-dev.20260410-5e55544225", + "onnxruntime-web": "1.26.0-dev.20260416-b7804b056c", "sharp": "^0.34.5" } }, @@ -3617,6 +3618,12 @@ "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", "license": "ISC" }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "license": "MIT" + }, "node_modules/jsonfile": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", @@ -4230,9 +4237,9 @@ } }, "node_modules/onnxruntime-web": { - "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==", + "version": "1.26.0-dev.20260416-b7804b056c", + "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.26.0-dev.20260416-b7804b056c.tgz", + "integrity": "sha512-MD6Ss4GSpQBo6zqoJzyT9LRbKYs7x/JVN23FT24EcEvlqF4VuzPOeH6X38orZPKHQDbprn7K+SBpu0/mj2CQiw==", "license": "MIT", "dependencies": { "flatbuffers": "^25.1.24", @@ -5058,20 +5065,6 @@ } } }, - "node_modules/tree-sitter-cli": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/tree-sitter-cli/-/tree-sitter-cli-0.23.2.tgz", - "integrity": "sha512-kPPXprOqREX+C/FgUp2Qpt9jd0vSwn+hOgjzVv/7hapdoWpa+VeWId53rf4oNNd29ikheF12BYtGD/W90feMbA==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "bin": { - "tree-sitter": "cli.js" - }, - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/tree-sitter-cpp": { "version": "0.23.4", "resolved": "https://registry.npmjs.org/tree-sitter-cpp/-/tree-sitter-cpp-0.23.4.tgz", @@ -5093,31 +5086,8 @@ } }, "node_modules/tree-sitter-dart": { - "version": "1.0.0", - "resolved": "git+ssh://git@github.com/UserNobody14/tree-sitter-dart.git#80e23c07b64494f7e21090bb3450223ef0b192f4", - "integrity": "sha512-Bs/1wAOIJ2akPEXlE/XVpuES19Oo3NqoSJRJ/0N2r38qAd9nTXdqmaGHQ44/JXnA6QHcbgD2YzCCc4wUc98cyQ==", - "hasInstallScript": true, - "license": "ISC", - "optional": true, - "dependencies": { - "node-addon-api": "^7.1.0", - "node-gyp-build": "^4.8.0" - }, - "peerDependencies": { - "tree-sitter": "^0.21.0" - }, - "peerDependenciesMeta": { - "tree_sitter": { - "optional": true - } - } - }, - "node_modules/tree-sitter-dart/node_modules/node-addon-api": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", - "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", - "license": "MIT", - "optional": true + "resolved": "vendor/tree-sitter-dart", + "link": true }, "node_modules/tree-sitter-go": { "version": "0.23.4", @@ -5284,49 +5254,8 @@ } }, "node_modules/tree-sitter-swift": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tree-sitter-swift/-/tree-sitter-swift-0.6.0.tgz", - "integrity": "sha512-9vOJZes4/UFjBr4COHtp6ZHVuZYwfChSQbpneXQog04dAstfx5px3ybVX2cN+ylvLqsvVpmXLpidxxgF2rDQ7A==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "node-addon-api": "^8.0.0", - "node-gyp-build": "^4.8.0", - "tree-sitter-cli": "^0.23", - "which": "2.0.2" - }, - "peerDependencies": { - "tree-sitter": "^0.21.1" - }, - "peerDependenciesMeta": { - "tree_sitter": { - "optional": true - } - } - }, - "node_modules/tree-sitter-swift/node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC", - "optional": true - }, - "node_modules/tree-sitter-swift/node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "optional": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } + "resolved": "vendor/tree-sitter-swift", + "link": true }, "node_modules/tree-sitter-typescript": { "version": "0.23.2", @@ -5761,6 +5690,19 @@ "zod": "^3.25.28 || ^4" } }, + "vendor/tree-sitter-dart": { + "version": "1.0.0", + "license": "ISC", + "optional": true, + "peerDependencies": { + "tree-sitter": "^0.21.0" + }, + "peerDependenciesMeta": { + "tree_sitter": { + "optional": true + } + } + }, "vendor/tree-sitter-proto": { "version": "0.4.1", "license": "MIT", @@ -5768,6 +5710,24 @@ "peerDependencies": { "tree-sitter": ">=0.21.0" } + }, + "vendor/tree-sitter-swift": { + "version": "0.7.1", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-addon-api": "^8.0.0", + "node-gyp-build": "^4.8.0" + }, + "peerDependencies": { + "tree-sitter": "^0.21.1 || ^0.22.1" + }, + "peerDependenciesMeta": { + "tree-sitter": { + "optional": true + } + } } } } diff --git a/gitnexus/package.json b/gitnexus/package.json index da5f6c925..59940e834 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -1,6 +1,6 @@ { "name": "gitnexus", - "version": "1.6.2", + "version": "1.6.3", "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", @@ -35,7 +35,8 @@ "hooks", "scripts", "skills", - "vendor" + "vendor", + "web" ], "scripts": { "build": "node scripts/build.js", @@ -46,7 +47,7 @@ "test:integration": "vitest run test/integration", "test:watch": "vitest", "test:coverage": "vitest run --coverage", - "postinstall": "node scripts/patch-tree-sitter-swift.cjs && node scripts/build-tree-sitter-proto.cjs", + "postinstall": "node scripts/build-tree-sitter-dart.cjs && node scripts/build-tree-sitter-proto.cjs", "prepare": "node scripts/build.js", "prepack": "node scripts/build.js" }, @@ -65,6 +66,7 @@ "graphology-utils": "^2.3.0", "ignore": "^7.0.5", "js-yaml": "^4.1.1", + "jsonc-parser": "^3.3.1", "lru-cache": "^11.0.0", "mnemonist": "^0.40.3", "onnxruntime-node": "^1.24.0", @@ -86,10 +88,10 @@ "optionalDependencies": { "node-addon-api": "^8.0.0", "node-gyp-build": "^4.8.0", - "tree-sitter-dart": "git+https://github.com/UserNobody14/tree-sitter-dart.git#80e23c07b64494f7e21090bb3450223ef0b192f4", + "tree-sitter-dart": "file:./vendor/tree-sitter-dart", "tree-sitter-kotlin": "^0.3.8", "tree-sitter-proto": "file:./vendor/tree-sitter-proto", - "tree-sitter-swift": "^0.6.0" + "tree-sitter-swift": "file:./vendor/tree-sitter-swift" }, "devDependencies": { "@types/cli-progress": "^3.11.6", diff --git a/gitnexus/scripts/build-tree-sitter-dart.cjs b/gitnexus/scripts/build-tree-sitter-dart.cjs new file mode 100644 index 000000000..60da9a751 --- /dev/null +++ b/gitnexus/scripts/build-tree-sitter-dart.cjs @@ -0,0 +1,42 @@ +#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); + +const dartDir = path.join(__dirname, '..', 'node_modules', 'tree-sitter-dart'); +const bindingGyp = path.join(dartDir, 'binding.gyp'); +const bindingNode = path.join(dartDir, 'build', 'Release', 'tree_sitter_dart_binding.node'); + +try { + if (!fs.existsSync(bindingGyp) || fs.existsSync(bindingNode)) { + process.exit(0); + } + + try { + require.resolve('node-addon-api'); + require.resolve('node-gyp-build'); + } catch (resolveErr) { + console.warn( + '[tree-sitter-dart] Skipping build: hoisted build deps not resolvable (%s).', + resolveErr.message, + ); + console.warn( + '[tree-sitter-dart] Dart parsing will be unavailable. Install without --no-optional and with scripts enabled to build.', + ); + process.exit(0); + } + + console.log('[tree-sitter-dart] Building native binding...'); + execSync('npx node-gyp rebuild', { + cwd: dartDir, + stdio: 'pipe', + timeout: 180000, + }); + console.log('[tree-sitter-dart] Native binding built successfully'); +} catch (err) { + console.warn('[tree-sitter-dart] Could not build native binding:', err.message); + console.warn( + '[tree-sitter-dart] Dart parsing will be unavailable. Non-Dart functionality is unaffected.', + ); + process.exit(0); +} diff --git a/gitnexus/scripts/build-tree-sitter-proto.cjs b/gitnexus/scripts/build-tree-sitter-proto.cjs index d2828d5ba..0690dfca3 100644 --- a/gitnexus/scripts/build-tree-sitter-proto.cjs +++ b/gitnexus/scripts/build-tree-sitter-proto.cjs @@ -26,7 +26,7 @@ * `node_modules/tree-sitter-proto/build/Release/tree_sitter_proto_binding.node` * — under npm-managed territory, safe on upgrade. * - * Mirrors scripts/patch-tree-sitter-swift.cjs. Best-effort: if any + * Mirrors the tree-sitter-dart build helper. Best-effort: if any * precondition fails (optional dep absent, no toolchain, --ignore-scripts), * warn and exit 0 so gitnexus install still succeeds. */ diff --git a/gitnexus/scripts/build.js b/gitnexus/scripts/build.js index 68378c61d..84f43fc6b 100644 --- a/gitnexus/scripts/build.js +++ b/gitnexus/scripts/build.js @@ -21,11 +21,11 @@ const SHARED_DEST = path.join(DIST, '_shared'); // ── 1. Build gitnexus-shared ─────────────────────────────────────── console.log('[build] compiling gitnexus-shared…'); -execSync('npx tsc', { cwd: SHARED_ROOT, stdio: 'inherit' }); +execSync('npx tsc', { cwd: SHARED_ROOT, stdio: 'inherit', timeout: 120_000 }); // ── 2. Build gitnexus ────────────────────────────────────────────── console.log('[build] compiling gitnexus…'); -execSync('npx tsc', { cwd: ROOT, stdio: 'inherit' }); +execSync('npx tsc', { cwd: ROOT, stdio: 'inherit', timeout: 120_000 }); // ── 3. Copy shared dist ──────────────────────────────────────────── console.log('[build] copying shared module into dist/_shared…'); @@ -70,4 +70,24 @@ walk(DIST, ['.js', '.d.ts'], rewriteFile); const cliEntry = path.join(DIST, 'cli', 'index.js'); if (fs.existsSync(cliEntry)) fs.chmodSync(cliEntry, 0o755); +// ── 6. Build & copy web UI ────────────────────────────────────────── +const WEB_ROOT = path.resolve(ROOT, '..', 'gitnexus-web'); +const WEB_DEST = path.join(DIST, '..', 'web'); + +if (fs.existsSync(path.join(WEB_ROOT, 'package.json'))) { + console.log('[build] building gitnexus-web…'); + if (!fs.existsSync(path.join(WEB_ROOT, 'node_modules'))) { + console.log('[build] installing gitnexus-web dependencies…'); + execSync('npm ci', { cwd: WEB_ROOT, stdio: 'inherit', timeout: 120_000 }); + } + execSync('npm run build', { cwd: WEB_ROOT, stdio: 'inherit', timeout: 120_000 }); + + // Copy dist → gitnexus/web/ (shipped in the npm package) + fs.rmSync(WEB_DEST, { recursive: true, force: true }); + fs.cpSync(path.join(WEB_ROOT, 'dist'), WEB_DEST, { recursive: true }); + console.log('[build] copied web UI → gitnexus/web/'); +} else { + console.log('[build] skipping web UI (gitnexus-web not found)'); +} + console.log(`[build] done — rewrote ${rewritten} files.`); diff --git a/gitnexus/scripts/install-duckdb-extension.mjs b/gitnexus/scripts/install-duckdb-extension.mjs new file mode 100644 index 000000000..8b0b3324b --- /dev/null +++ b/gitnexus/scripts/install-duckdb-extension.mjs @@ -0,0 +1,37 @@ +#!/usr/bin/env node +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { createRequire } from 'node:module'; + +const EXTENSION_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_]*$/; + +async function installDuckDbExtension(extensionName) { + if (!extensionName || !EXTENSION_NAME_PATTERN.test(extensionName)) { + throw new Error(`Invalid DuckDB extension name: ${extensionName ?? ''}`); + } + + const require = createRequire(import.meta.url); + const lbugModule = require('@ladybugdb/core'); + const lbug = lbugModule.default ?? lbugModule; + + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-ext-install-')); + const dbPath = path.join(tmpDir, 'install.lbug'); + let db; + let conn; + + try { + db = new lbug.Database(dbPath); + conn = new lbug.Connection(db); + await conn.query(`INSTALL ${extensionName}`); + } finally { + if (conn) await conn.close().catch(() => {}); + if (db) await db.close().catch(() => {}); + await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + } +} + +installDuckDbExtension(process.argv[2] ?? process.env.GITNEXUS_LBUG_EXTENSION_NAME).catch((err) => { + console.error(err instanceof Error ? (err.stack ?? err.message) : String(err)); + process.exitCode = 1; +}); diff --git a/gitnexus/scripts/patch-tree-sitter-swift.cjs b/gitnexus/scripts/patch-tree-sitter-swift.cjs deleted file mode 100644 index 6580b00e7..000000000 --- a/gitnexus/scripts/patch-tree-sitter-swift.cjs +++ /dev/null @@ -1,78 +0,0 @@ -#!/usr/bin/env node -/** - * WORKAROUND: tree-sitter-swift@0.6.0 binding.gyp build failure - * - * Background: - * tree-sitter-swift@0.6.0's binding.gyp contains an "actions" array that - * invokes `tree-sitter generate` to regenerate parser.c from grammar.js. - * This is intended for grammar developers, but the published npm package - * already ships pre-generated parser files (parser.c, scanner.c), so the - * actions are unnecessary for consumers. Since consumers don't have - * tree-sitter-cli installed, the actions always fail during `npm install`. - * - * Why we can't just upgrade: - * tree-sitter-swift@0.7.1 fixes this (removes postinstall, ships prebuilds), - * but it requires tree-sitter@^0.22.1. The upstream project pins tree-sitter - * to ^0.21.0 and all other grammar packages depend on that version. - * Upgrading tree-sitter would be a separate breaking change. - * - * How this workaround works: - * 1. tree-sitter-swift's own postinstall fails (npm warns but continues) - * 2. This script runs as gitnexus's postinstall - * 3. It removes the "actions" array from binding.gyp - * 4. It rebuilds the native binding with the cleaned binding.gyp - * - * TODO: Remove this script when tree-sitter is upgraded to ^0.22.x, - * which allows using tree-sitter-swift@0.7.1+ directly. - */ -const fs = require('fs'); -const path = require('path'); -const { execSync } = require('child_process'); - -const swiftDir = path.join(__dirname, '..', 'node_modules', 'tree-sitter-swift'); -const bindingPath = path.join(swiftDir, 'binding.gyp'); - -try { - if (!fs.existsSync(bindingPath)) { - process.exit(0); - } - - const content = fs.readFileSync(bindingPath, 'utf8'); - let needsRebuild = false; - - if (content.includes('"actions"')) { - // Strip Python-style comments (#) and trailing commas before JSON parsing - const cleaned = content - .replace(/#[^\n]*/g, '') // Remove # comments - .replace(/,(\s*[\]}])/g, '$1'); // Remove trailing commas before ] or } - const gyp = JSON.parse(cleaned); - - if (gyp.targets && gyp.targets[0] && gyp.targets[0].actions) { - delete gyp.targets[0].actions; - fs.writeFileSync(bindingPath, JSON.stringify(gyp, null, 2) + '\n'); - console.log('[tree-sitter-swift] Patched binding.gyp (removed actions array)'); - needsRebuild = true; - } - } - - // Check if native binding exists - const bindingNode = path.join(swiftDir, 'build', 'Release', 'tree_sitter_swift_binding.node'); - if (!fs.existsSync(bindingNode)) { - needsRebuild = true; - } - - if (needsRebuild) { - console.log('[tree-sitter-swift] Rebuilding native binding...'); - execSync('npx node-gyp rebuild', { - cwd: swiftDir, - stdio: 'pipe', - timeout: 120000, - }); - console.log('[tree-sitter-swift] Native binding built successfully'); - } -} catch (err) { - console.warn('[tree-sitter-swift] Could not build native binding:', err.message); - console.warn( - '[tree-sitter-swift] You may need to manually run: cd node_modules/tree-sitter-swift && npx node-gyp rebuild', - ); -} diff --git a/gitnexus/skills/gitnexus-cli.md b/gitnexus/skills/gitnexus-cli.md index c9e0af341..cd9a83be0 100644 --- a/gitnexus/skills/gitnexus-cli.md +++ b/gitnexus/skills/gitnexus-cli.md @@ -21,8 +21,9 @@ Run from the project root. This parses all source files, builds the knowledge gr | -------------- | ---------------------------------------------------------------- | | `--force` | Force full re-index even if up to date | | `--embeddings` | Enable embedding generation for semantic search (off by default) | +| `--drop-embeddings` | Drop existing embeddings on rebuild. By default, an `analyze` without `--embeddings` preserves them. | -**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook runs `analyze` automatically after `git commit` and `git merge`, preserving embeddings if previously generated. +**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook detects staleness after `git commit` and `git merge` and notifies the agent to run `analyze` — the hook does not run analyze itself, to avoid blocking the agent for up to 120s and risking KuzuDB corruption on timeout. ### status — Check index freshness diff --git a/gitnexus/src/cli/ai-context.ts b/gitnexus/src/cli/ai-context.ts index 984a16f7b..1b8396301 100644 --- a/gitnexus/src/cli/ai-context.ts +++ b/gitnexus/src/cli/ai-context.ts @@ -32,6 +32,33 @@ export interface AIContextOptions { const GITNEXUS_START_MARKER = ''; const GITNEXUS_END_MARKER = ''; +/** + * Find the index of a section marker that occupies its own line. + * Unlike `indexOf`, this rejects inline prose references like + * `` See the `` block `` that appear + * mid-sentence (#1041). A marker counts as section-position only when: + * - preceded by newline or start-of-file, AND + * - followed by newline, `\r` (CRLF files), or end-of-file. + * The generator always emits each marker alone on its line, so this + * matches every legitimate section and none of the inline mentions. + * + * `startFrom` lets the end-marker lookup start after the already-found + * start marker, avoiding a scan from 0 and guaranteeing we never pick + * up an end marker that appears earlier in the file than the start. + */ +function findSectionMarkerIndex(content: string, marker: string, startFrom = 0): number { + let idx = content.indexOf(marker, startFrom); + while (idx !== -1) { + const atLineStart = idx === 0 || content[idx - 1] === '\n'; + const endPos = idx + marker.length; + const atLineEnd = + endPos === content.length || content[endPos] === '\n' || content[endPos] === '\r'; + if (atLineStart && atLineEnd) return idx; + idx = content.indexOf(marker, idx + 1); + } + return -1; +} + /** * Generate the full GitNexus context content. * @@ -163,9 +190,18 @@ async function upsertGitNexusSection( const existingContent = await fs.readFile(filePath, 'utf-8'); - // Check if GitNexus section already exists - const startIdx = existingContent.indexOf(GITNEXUS_START_MARKER); - const endIdx = existingContent.indexOf(GITNEXUS_END_MARKER); + // Check if GitNexus section already exists. Matching is restricted + // to markers that occupy their own line so that inline prose + // references (e.g. `` See the `` block `` in + // the shipped CLAUDE.md) are NOT treated as section delimiters + // (#1041). The end-marker scan starts after the start-marker so it + // can never pick up an earlier end in the file. + const startIdx = findSectionMarkerIndex(existingContent, GITNEXUS_START_MARKER); + const endIdx = findSectionMarkerIndex( + existingContent, + GITNEXUS_END_MARKER, + startIdx === -1 ? 0 : startIdx, + ); if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) { // Replace existing section diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index 46cedc434..d7b293a9f 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -20,6 +20,7 @@ import { } from '../storage/repo-manager.js'; import { getGitRoot, hasGitDir } from '../storage/git.js'; import { runFullAnalysis } from '../core/run-analyze.js'; +import { getMaxFileSizeBannerMessage } from '../core/ingestion/utils/max-file-size.js'; import fs from 'fs/promises'; const HEAP_MB = 8192; @@ -55,6 +56,12 @@ function ensureHeap(): boolean { export interface AnalyzeOptions { force?: boolean; embeddings?: boolean; + /** + * Explicitly drop existing embeddings on rebuild instead of preserving + * them. Without this flag, a routine `analyze` keeps any embeddings + * already present in the index even when `--embeddings` is omitted. + */ + dropEmbeddings?: boolean; skills?: boolean; verbose?: boolean; /** Skip AGENTS.md and CLAUDE.md gitnexus block updates. */ @@ -78,6 +85,18 @@ export interface AnalyzeOptions { * `allowDuplicateName` option end-to-end. */ allowDuplicateName?: boolean; + /** + * Override the walker's large-file skip threshold (#991). Value in KB; + * clamped downstream to the tree-sitter 32 MB ceiling. Sets + * `GITNEXUS_MAX_FILE_SIZE` for the rest of the pipeline. + */ + maxFileSize?: string; + /** Override worker sub-batch idle timeout in seconds. */ + workerTimeout?: string; + embeddingThreads?: string; + embeddingBatchSize?: string; + embeddingSubBatchSize?: string; + embeddingDevice?: string; } export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOptions) => { @@ -87,6 +106,68 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption process.env.GITNEXUS_VERBOSE = '1'; } + if (options?.maxFileSize) { + process.env.GITNEXUS_MAX_FILE_SIZE = options.maxFileSize; + } + + if (options?.workerTimeout) { + const workerTimeoutSeconds = Number(options.workerTimeout); + if (!Number.isFinite(workerTimeoutSeconds) || workerTimeoutSeconds < 1) { + console.error(' --worker-timeout must be at least 1 second.\n'); + process.exitCode = 1; + return; + } + process.env.GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS = String( + Math.round(workerTimeoutSeconds * 1000), + ); + } + + const setPositiveEnv = ( + optionName: string, + envName: string, + value: string | undefined, + ): boolean => { + if (value === undefined) return true; + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) { + console.error(` ${optionName} must be a positive integer.\n`); + process.exitCode = 1; + return false; + } + process.env[envName] = String(parsed); + return true; + }; + + if ( + !setPositiveEnv( + '--embedding-threads', + 'GITNEXUS_EMBEDDING_THREADS', + options?.embeddingThreads, + ) || + !setPositiveEnv( + '--embedding-batch-size', + 'GITNEXUS_EMBEDDING_BATCH_SIZE', + options?.embeddingBatchSize, + ) || + !setPositiveEnv( + '--embedding-sub-batch-size', + 'GITNEXUS_EMBEDDING_SUB_BATCH_SIZE', + options?.embeddingSubBatchSize, + ) + ) { + return; + } + + if (options?.embeddingDevice) { + const allowed = new Set(['auto', 'cpu', 'dml', 'cuda', 'wasm']); + if (!allowed.has(options.embeddingDevice)) { + console.error(' --embedding-device must be one of: auto, cpu, dml, cuda, wasm.\n'); + process.exitCode = 1; + return; + } + process.env.GITNEXUS_EMBEDDING_DEVICE = options.embeddingDevice; + } + console.log('\n GitNexus Analyzer\n'); let repoPath: string; @@ -132,6 +213,11 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption ); } + const maxFileSizeBanner = getMaxFileSizeBannerMessage(); + if (maxFileSizeBanner) { + console.log(`${maxFileSizeBanner}\n`); + } + // ── CLI progress bar setup ───────────────────────────────────────── const bar = new cliProgress.SingleBar( { @@ -210,6 +296,7 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption // collision guard (see allowDuplicateName below). force: options?.force || options?.skills, embeddings: options?.embeddings, + dropEmbeddings: options?.dropEmbeddings, skipGit: options?.skipGit, skipAgentsMd: options?.skipAgentsMd, noStats: options?.noStats, diff --git a/gitnexus/src/cli/doctor.ts b/gitnexus/src/cli/doctor.ts new file mode 100644 index 000000000..43866ae00 --- /dev/null +++ b/gitnexus/src/cli/doctor.ts @@ -0,0 +1,32 @@ +import { getRuntimeCapabilities, getRuntimeFingerprint } from '../core/platform/capabilities.js'; +import { resolveEmbeddingConfig } from '../core/embeddings/config.js'; +import { isHttpMode } from '../core/embeddings/http-client.js'; + +export const doctorCommand = async () => { + const fingerprint = getRuntimeFingerprint(); + const capabilities = getRuntimeCapabilities(); + const embeddingConfig = resolveEmbeddingConfig(); + + console.log('GitNexus Doctor\n'); + console.log('Runtime'); + console.log(` OS: ${fingerprint.platform}/${fingerprint.arch}`); + console.log(` Node: ${fingerprint.node}`); + console.log(` GitNexus: ${fingerprint.gitnexus}`); + console.log(` LadybugDB: ${fingerprint.ladybugdb ?? 'unknown'}`); + console.log(` ONNX: ${fingerprint.onnxruntime ?? 'unknown'}`); + console.log(''); + console.log('Capabilities'); + console.log(` Graph store: ${capabilities.graph}`); + console.log(` Full-text search:${capabilities.fts.padStart(10)}`); + console.log(` VECTOR index: ${capabilities.vector}`); + console.log(` Semantic mode: ${capabilities.semanticMode}`); + console.log(` Exact scan limit:${String(capabilities.exactScanLimit).padStart(9)} chunks`); + if (capabilities.reason) console.log(` Note: ${capabilities.reason}`); + console.log(''); + console.log('Embeddings'); + console.log(` Backend: ${isHttpMode() ? 'http' : 'local'}`); + console.log(` Device: ${embeddingConfig.device}`); + console.log(` Threads: ${embeddingConfig.threads}`); + console.log(` Batch: ${embeddingConfig.batchSize} nodes`); + console.log(` Sub-batch: ${embeddingConfig.subBatchSize} chunks`); +}; diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index fe19ba753..dcee1339a 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -24,6 +24,11 @@ program .description('Index a repository (full analysis)') .option('-f, --force', 'Force full re-index even if up to date') .option('--embeddings', 'Enable embedding generation for semantic search (off by default)') + .option( + '--drop-embeddings', + 'Drop existing embeddings on rebuild. By default, an `analyze` without `--embeddings` ' + + 'preserves any embeddings already present in the index.', + ) .option('--skills', 'Generate repo-specific skill files from detected communities') .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') @@ -39,9 +44,29 @@ program 'Leaves `-r ` ambiguous for the two paths; use -r to disambiguate.', ) .option('-v, --verbose', 'Enable verbose ingestion warnings (default: false)') + .option( + '--max-file-size ', + 'Skip files larger than this (KB). Default: 512. Hard cap: 32768 (tree-sitter limit).', + ) + .option( + '--worker-timeout ', + 'Worker sub-batch idle timeout before retry/fallback. Default: 30.', + ) + .option('--embedding-threads ', 'Limit local ONNX embedding CPU threads') + .option('--embedding-batch-size ', 'Number of nodes per embedding batch') + .option('--embedding-sub-batch-size ', 'Number of chunks per embedding model call') + .option('--embedding-device ', 'Embedding device: auto, cpu, dml, cuda, or wasm') .addHelpText( 'after', - '\nEnvironment variables:\n GITNEXUS_NO_GITIGNORE=1 Skip .gitignore parsing (still reads .gitnexusignore)', + '\nEnvironment variables:\n' + + ' GITNEXUS_NO_GITIGNORE=1 Skip .gitignore parsing (still reads .gitnexusignore)\n' + + ' GITNEXUS_MAX_FILE_SIZE=N Override large-file skip threshold (KB). Default 512, max 32768.\n' + + ' GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker idle timeout in milliseconds. Default 30000.\n' + + ' GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker job byte budget. Default 8388608.\n' + + ' GITNEXUS_EMBEDDING_THREADS=N Limit local ONNX CPU threads for --embeddings.\n' + + ' GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N Max embedding chunks for exact-scan fallback. Default 10000.\n' + + '\nTip: `.gitnexusignore` supports `.gitignore`-style negation. Add e.g.\n' + + ' `!__tests__/` to index a directory that is auto-filtered by default (#771).', ) .action(createLazyAction(() => import('./analyze.js'), 'analyzeCommand')); @@ -76,6 +101,11 @@ program .description('Show index status for current repo') .action(createLazyAction(() => import('./status.js'), 'statusCommand')); +program + .command('doctor') + .description('Show runtime platform capabilities and embedding configuration') + .action(createLazyAction(() => import('./doctor.js'), 'doctorCommand')); + program .command('clean') .description('Delete GitNexus index for current repo') diff --git a/gitnexus/src/cli/setup.ts b/gitnexus/src/cli/setup.ts index 8263405a5..9a2be4505 100644 --- a/gitnexus/src/cli/setup.ts +++ b/gitnexus/src/cli/setup.ts @@ -13,6 +13,7 @@ import { execFile, execFileSync } from 'child_process'; import { promisify } from 'util'; import { fileURLToPath } from 'url'; import { glob } from 'glob'; +import { parseTree, modify, applyEdits, ParseError, parse as parseJsonc } from 'jsonc-parser'; import { getGlobalDir } from '../storage/repo-manager.js'; const __filename = fileURLToPath(import.meta.url); @@ -76,38 +77,70 @@ function getMcpEntry() { } /** - * Merge gitnexus entry into an existing MCP config JSON object. - * Returns the updated config. + * OpenCode uses a different MCP format: { type: "local", command: [...] } + * where command is a flat array (command + args combined). */ -function mergeMcpConfig(existing: any): any { - if (!existing || typeof existing !== 'object') { - existing = {}; +function getOpenCodeMcpEntry() { + const bin = resolveGitnexusBin(); + + if (bin) { + return { type: 'local', command: [bin, 'mcp'] }; } - if (!existing.mcpServers || typeof existing.mcpServers !== 'object') { - existing.mcpServers = {}; + + if (process.platform === 'win32') { + return { type: 'local', command: ['cmd', '/c', 'npx', '-y', 'gitnexus@latest', 'mcp'] }; } - existing.mcpServers.gitnexus = getMcpEntry(); - return existing; + return { type: 'local', command: ['npx', '-y', 'gitnexus@latest', 'mcp'] }; } /** - * Try to read a JSON file, returning null if it doesn't exist or is invalid. + * Detect indentation style from file content. + * Returns formatting options matching the file's existing style. */ -async function readJsonFile(filePath: string): Promise { +function detectIndentation(raw: string): { tabSize: number; insertSpaces: boolean } { + const firstIndented = raw.match(/^( +|\t)/m); + if (!firstIndented) return { tabSize: 2, insertSpaces: true }; + if (firstIndented[1] === '\t') return { tabSize: 1, insertSpaces: false }; + return { tabSize: firstIndented[1].length, insertSpaces: true }; +} + +/** + * Merge a key/value pair into a JSONC config file, preserving comments and formatting. + * If the file is genuinely corrupt (not valid JSONC), leaves it untouched. + */ +async function mergeJsoncFile( + filePath: string, + keyPath: string[], + value: unknown, +): Promise { + let raw: string; try { - const raw = await fs.readFile(filePath, 'utf-8'); - return JSON.parse(raw); + raw = await fs.readFile(filePath, 'utf-8'); } catch { - return null; + raw = ''; } -} -/** - * Write JSON to a file, creating parent directories if needed. - */ -async function writeJsonFile(filePath: string, data: any): Promise { - await fs.mkdir(path.dirname(filePath), { recursive: true }); - await fs.writeFile(filePath, JSON.stringify(data, null, 2) + '\n', 'utf-8'); + if (raw.trim().length === 0) { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + const formattingOptions = { tabSize: 2, insertSpaces: true }; + const edits = modify('{}', keyPath, value, { formattingOptions }); + const result = applyEdits('{}', edits); + await fs.writeFile(filePath, result, 'utf-8'); + return true; + } + + const parseErrors: ParseError[] = []; + const tree = parseTree(raw, parseErrors); + + if (tree && tree.type === 'object' && parseErrors.length === 0) { + const formattingOptions = detectIndentation(raw); + const edits = modify(raw, keyPath, value, { formattingOptions }); + const result = applyEdits(raw, edits); + await fs.writeFile(filePath, result, 'utf-8'); + return true; + } + + return false; } /** @@ -133,10 +166,12 @@ async function setupCursor(result: SetupResult): Promise { const mcpPath = path.join(cursorDir, 'mcp.json'); try { - const existing = await readJsonFile(mcpPath); - const updated = mergeMcpConfig(existing); - await writeJsonFile(mcpPath, updated); - result.configured.push('Cursor'); + const ok = await mergeJsoncFile(mcpPath, ['mcpServers', 'gitnexus'], getMcpEntry()); + if (ok) { + result.configured.push('Cursor'); + } else { + result.errors.push('Cursor: mcp.json is corrupt — skipping to preserve existing content'); + } } catch (err: any) { result.errors.push(`Cursor: ${err.message}`); } @@ -152,10 +187,14 @@ async function setupClaudeCode(result: SetupResult): Promise { // Claude Code stores MCP config in ~/.claude.json const mcpPath = path.join(os.homedir(), '.claude.json'); try { - const existing = await readJsonFile(mcpPath); - const updated = mergeMcpConfig(existing); - await writeJsonFile(mcpPath, updated); - result.configured.push('Claude Code'); + const ok = await mergeJsoncFile(mcpPath, ['mcpServers', 'gitnexus'], getMcpEntry()); + if (ok) { + result.configured.push('Claude Code'); + } else { + result.errors.push( + 'Claude Code: .claude.json is corrupt — skipping to preserve existing content', + ); + } } catch (err: any) { result.errors.push(`Claude Code: ${err.message}`); } @@ -179,9 +218,90 @@ async function installClaudeCodeSkills(result: SetupResult): Promise { } } +/** + * Check whether an event array already contains a gitnexus-hook entry. + */ +function hasGitnexusHook(hooksObj: any, eventName: string): boolean { + const entries = hooksObj?.[eventName]; + if (!Array.isArray(entries)) return false; + return entries.some( + (h: any) => + Array.isArray(h.hooks) && + h.hooks.some( + (hh: any) => typeof hh.command === 'string' && hh.command.includes('gitnexus-hook'), + ), + ); +} + +/** + * Merge hook entries into a JSONC settings file, preserving comments and formatting. + * Uses chained modify()+applyEdits() calls to append to arrays without a full + * JSON.stringify roundtrip that would strip comments. + */ +async function mergeHooksJsonc( + filePath: string, + entries: Array<{ eventName: string; value: unknown }>, +): Promise { + let raw: string; + try { + raw = await fs.readFile(filePath, 'utf-8'); + } catch { + raw = ''; + } + + if (raw.trim().length === 0) { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + const hooks: any = {}; + for (const { eventName, value } of entries) { + hooks[eventName] = [value]; + } + const formattingOptions = { tabSize: 2, insertSpaces: true }; + const edits = modify('{}', ['hooks'], hooks, { formattingOptions }); + await fs.writeFile(filePath, applyEdits('{}', edits), 'utf-8'); + return true; + } + + const parseErrors: ParseError[] = []; + const tree = parseTree(raw, parseErrors); + + if (!tree || tree.type !== 'object' || parseErrors.length > 0) { + return false; + } + + const formattingOptions = detectIndentation(raw); + let current = raw; + + for (const { eventName, value } of entries) { + // Re-parse after each edit to get a fresh insertion index. + const currentTree = parseTree(current, []); + const hooksNode = currentTree?.children?.find( + (c) => c.type === 'property' && c.children?.[0]?.value === 'hooks', + ); + const eventNode = hooksNode?.children?.[1]?.children?.find( + (c: any) => c.type === 'property' && c.children?.[0]?.value === eventName, + ); + + let insertIndex: number; + if (eventNode?.children?.[1] && Array.isArray(eventNode.children[1].children)) { + insertIndex = eventNode.children[1].children.length; + } else { + insertIndex = 0; + } + + const edits = modify(current, ['hooks', eventName, insertIndex], value, { + formattingOptions, + }); + current = applyEdits(current, edits); + } + + await fs.writeFile(filePath, current, 'utf-8'); + return true; +} + /** * Install GitNexus hooks to ~/.claude/settings.json for Claude Code. - * Merges hook config without overwriting existing hooks. + * Merges hook config without overwriting existing hooks, preserving + * comments and formatting in the JSONC file. */ async function installClaudeCodeHooks(result: SetupResult): Promise { const claudeDir = path.join(os.homedir(), '.claude'); @@ -202,8 +322,6 @@ async function installClaudeCodeHooks(result: SetupResult): Promise { const dest = path.join(destHooksDir, 'gitnexus-hook.cjs'); try { let content = await fs.readFile(src, 'utf-8'); - // Inject resolved CLI path so the copied hook can find the CLI - // even when it's no longer inside the npm package tree const resolvedCli = path.join(__dirname, '..', 'cli', 'index.js'); const normalizedCli = path.resolve(resolvedCli).replace(/\\/g, '/'); const jsonCli = JSON.stringify(normalizedCli); @@ -219,40 +337,67 @@ async function installClaudeCodeHooks(result: SetupResult): Promise { const hookPath = path.join(destHooksDir, 'gitnexus-hook.cjs').replace(/\\/g, '/'); const hookCmd = `node "${hookPath.replace(/"/g, '\\"')}"`; - // Merge hook config into ~/.claude/settings.json - const existing = (await readJsonFile(settingsPath)) || {}; - if (!existing.hooks) existing.hooks = {}; + // Check which hook events need entries (idempotent: skip if already registered) + const parsed = await (async () => { + try { + const r = await fs.readFile(settingsPath, 'utf-8'); + return parseJsonc(r); + } catch { + return null; + } + })(); + + const hookEntries: Array<{ eventName: string; value: unknown }> = []; // NOTE: SessionStart hooks are broken on Windows (Claude Code bug #23576). // Session context is delivered via CLAUDE.md / skills instead. - // Helper: add a hook entry if one with 'gitnexus-hook' isn't already registered - interface HookEntry { - hooks?: Array<{ command?: string }>; + if (!hasGitnexusHook(parsed?.hooks, 'PreToolUse')) { + hookEntries.push({ + eventName: 'PreToolUse', + value: { + matcher: 'Grep|Glob|Bash', + hooks: [ + { + type: 'command', + command: hookCmd, + timeout: 10, + statusMessage: 'Enriching with GitNexus graph context...', + }, + ], + }, + }); } - function ensureHookEntry( - eventName: string, - matcher: string, - timeout: number, - statusMessage: string, - ) { - if (!existing.hooks[eventName]) existing.hooks[eventName] = []; - const hasHook = existing.hooks[eventName].some((h: HookEntry) => - h.hooks?.some((hh) => hh.command?.includes('gitnexus-hook')), + if (!hasGitnexusHook(parsed?.hooks, 'PostToolUse')) { + hookEntries.push({ + eventName: 'PostToolUse', + value: { + matcher: 'Bash', + hooks: [ + { + type: 'command', + command: hookCmd, + timeout: 10, + statusMessage: 'Checking GitNexus index freshness...', + }, + ], + }, + }); + } + + if (hookEntries.length === 0) { + result.configured.push('Claude Code hooks (already configured)'); + return; + } + + const ok = await mergeHooksJsonc(settingsPath, hookEntries); + if (ok) { + result.configured.push('Claude Code hooks (PreToolUse, PostToolUse)'); + } else { + result.errors.push( + 'Claude Code hooks: settings.json is corrupt — skipping to preserve existing content', ); - if (!hasHook) { - existing.hooks[eventName].push({ - matcher, - hooks: [{ type: 'command', command: hookCmd, timeout, statusMessage }], - }); - } } - - ensureHookEntry('PreToolUse', 'Grep|Glob|Bash', 10, 'Enriching with GitNexus graph context...'); - ensureHookEntry('PostToolUse', 'Bash', 10, 'Checking GitNexus index freshness...'); - - await writeJsonFile(settingsPath, existing); - result.configured.push('Claude Code hooks (PreToolUse, PostToolUse)'); } catch (err: any) { result.errors.push(`Claude Code hooks: ${err.message}`); } @@ -267,12 +412,14 @@ async function setupOpenCode(result: SetupResult): Promise { const configPath = path.join(opencodeDir, 'opencode.json'); try { - const existing = await readJsonFile(configPath); - const config = existing || {}; - if (!config.mcp) config.mcp = {}; - config.mcp.gitnexus = getMcpEntry(); - await writeJsonFile(configPath, config); - result.configured.push('OpenCode'); + const ok = await mergeJsoncFile(configPath, ['mcp', 'gitnexus'], getOpenCodeMcpEntry()); + if (ok) { + result.configured.push('OpenCode'); + } else { + result.errors.push( + 'OpenCode: opencode.json is corrupt — skipping to preserve existing content', + ); + } } catch (err: any) { result.errors.push(`OpenCode: ${err.message}`); } @@ -434,13 +581,13 @@ async function installCursorSkills(result: SetupResult): Promise { } /** - * Install global OpenCode skills to ~/.config/opencode/skill/gitnexus/ + * Install global OpenCode skills to ~/.config/opencode/skills/gitnexus/ */ async function installOpenCodeSkills(result: SetupResult): Promise { const opencodeDir = path.join(os.homedir(), '.config', 'opencode'); if (!(await dirExists(opencodeDir))) return; - const skillsDir = path.join(opencodeDir, 'skill'); + const skillsDir = path.join(opencodeDir, 'skills'); try { const installed = await installSkillsTo(skillsDir); if (installed.length > 0) { diff --git a/gitnexus/src/config/ignore-service.ts b/gitnexus/src/config/ignore-service.ts index ce0771dd3..ff61f0eb3 100644 --- a/gitnexus/src/config/ignore-service.ts +++ b/gitnexus/src/config/ignore-service.ts @@ -270,17 +270,21 @@ const IGNORED_FILES = new Set([ '.env.example', ]); -// NOTE: Negation patterns in .gitnexusignore (e.g. `!vendor/`) cannot override -// entries in DEFAULT_IGNORE_LIST — this is intentional. The hardcoded list protects -// against indexing directories that are almost never source code (node_modules, .git, etc.). -// Users who need to include such directories should remove them from the hardcoded list. +// The hardcoded DEFAULT_IGNORE_LIST is the "safety net" default: directories +// that are almost never source code (node_modules, .git, dist, __tests__, +// etc.). Users who legitimately need to index one of these can negate the +// hardcoded rule via a `!pattern` line in `.gitnexusignore` (#771) — same +// semantics as `.gitignore` negation. That override is applied in +// `createIgnoreFilter` below; `shouldIgnorePath` itself stays a pure +// hardcoded-list check so its callers (wiki generator, tests) get +// deterministic results independent of per-repo config. export const shouldIgnorePath = (filePath: string): boolean => { const normalizedPath = filePath.replace(/\\/g, '/'); const parts = normalizedPath.split('/'); const fileName = parts[parts.length - 1]; const fileNameLower = fileName.toLowerCase(); - // Check if any path segment is in ignore list + // Check if any path segment is in the hardcoded ignore list. for (const part of parts) { if (DEFAULT_IGNORE_LIST.has(part)) { return true; @@ -369,6 +373,42 @@ export const loadIgnoreRules = async ( return hasRules ? ig : null; }; +/** + * Walk ancestor segments of `rel` and check whether `.gitnexusignore` + * (or `.gitignore`) contains an explicit `!pattern` negation that + * applies. Returns true as soon as any segment — or the path itself — + * is matched by a negation rule. + * + * Why this exists (#771): the hardcoded DEFAULT_IGNORE_LIST would + * otherwise block indexing of directories like `__tests__/` even when + * the user has an explicit `!__tests__/` line in `.gitnexusignore`. + * Mirroring `.gitignore` negation semantics: a user's explicit + * unignore of a parent directory implicitly unignores everything + * underneath, so we walk the ancestor chain rather than only testing + * the leaf. + * + * The `ignore` package's `test(path)` returns `{ignored, unignored}`; + * `unignored: true` is the "a negation rule matched this path" + * signal. Children of a negated directory return + * `{ignored: false, unignored: false}` on a direct test, which is why + * we also walk the ancestors here. + */ +const hasExplicitUnignore = (ig: Ignore, rel: string): boolean => { + // Direct match on the path (as a file). + if (ig.test(rel).unignored) return true; + // Direct match on the path treated as a directory — `!dir/` matches + // here when rel is the directory itself. + if (ig.test(rel + '/').unignored) return true; + // Walk ancestor segments. `!parent/` should propagate to every + // descendant the same way `.gitignore` negation propagates. + const parts = rel.split('/'); + for (let i = parts.length - 1; i > 0; i--) { + const ancestor = parts.slice(0, i).join('/') + '/'; + if (ig.test(ancestor).unignored) return true; + } + return false; +}; + /** * Create a glob-compatible ignore filter combining: * - .gitignore / .gitnexusignore patterns (via `ignore` package) @@ -376,6 +416,15 @@ export const loadIgnoreRules = async ( * * Returns an IgnoreLike object for glob's `ignore` option, * enabling directory-level pruning during traversal. + * + * Precedence (#771): user's `.gitnexusignore` negation patterns take + * priority over the hardcoded list, matching `.gitignore` semantics. + * An explicit `!pattern` rule unignores descendants even when they + * would otherwise be blocked by DEFAULT_IGNORE_LIST — UNLESS a more + * specific rule in the same file re-ignores a subset (e.g. + * `!__tests__/` paired with `__tests__/generated/` blocks the child + * while leaving the parent negated). Last-match-wins is enforced by + * consulting `ig.ignores(rel)` after `hasExplicitUnignore`. */ export const createIgnoreFilter = async (repoPath: string, options?: IgnoreOptions) => { const ig = await loadIgnoreRules(repoPath, options); @@ -386,16 +435,33 @@ export const createIgnoreFilter = async (repoPath: string, options?: IgnoreOptio // which is what the `ignore` package expects. No explicit normalization needed. const rel = p.relative(); if (!rel) return false; + // User's .gitnexusignore negation takes precedence over hardcoded + // rules (#771). If any ancestor or the path itself was explicitly + // unignored AND no more-specific rule re-ignores this exact path, + // allow it through. The `!ig.ignores(rel)` guard matches + // .gitignore's last-match-wins semantics: `!__tests__/` followed + // by `__tests__/generated/` negates the parent but still blocks + // the re-ignored child. + if (ig && hasExplicitUnignore(ig, rel) && !ig.ignores(rel)) return false; // Check .gitignore / .gitnexusignore patterns if (ig && ig.ignores(rel)) return true; // Fall back to hardcoded rules return shouldIgnorePath(rel); }, childrenIgnored(p: Path): boolean { - // Fast path: check directory name against hardcoded list. // Note: dot-directories (.git, .vscode, etc.) are primarily excluded by - // glob's `dot: false` option in filesystem-walker.ts. This check is - // defense-in-depth — do not remove `dot: false` assuming this covers it. + // glob's `dot: false` option in filesystem-walker.ts. The hardcoded + // list check below is defense-in-depth — do not remove `dot: false` + // assuming this covers it. + const rel = p.relative(); + // User's .gitnexusignore negation takes precedence (#771) — if the + // user explicitly unignored this directory or any ancestor via a + // !pattern rule, allow descent even if the directory name is in + // DEFAULT_IGNORE_LIST. The `!ig.ignores(rel + '/')` guard keeps + // last-match-wins: `!__tests__/` + `__tests__/generated/` still + // blocks descent into `__tests__/generated/`. + if (ig && rel && hasExplicitUnignore(ig, rel) && !ig.ignores(rel + '/')) return false; + // Hardcoded list: block descent into well-known noise directories. if (DEFAULT_IGNORE_LIST.has(p.name)) return true; // Check against .gitignore / .gitnexusignore patterns. // Since childrenIgnored is only called for directories, always test with @@ -405,10 +471,7 @@ export const createIgnoreFilter = async (repoPath: string, options?: IgnoreOptio // Bare-name patterns (e.g. `local`) still match `local/` per gitignore spec: // the `ignore` package normalizes `dir` and `dir/` to match directories. // See: https://github.com/kaelzhang/node-ignore#2-filenames-and-dirnames - if (ig) { - const rel = p.relative(); - if (rel && ig.ignores(rel + '/')) return true; - } + if (ig && rel && ig.ignores(rel + '/')) return true; return false; }, }; diff --git a/gitnexus/src/core/embedding-mode.ts b/gitnexus/src/core/embedding-mode.ts new file mode 100644 index 000000000..7c5b5cf02 --- /dev/null +++ b/gitnexus/src/core/embedding-mode.ts @@ -0,0 +1,54 @@ +/** + * Pure derivation of the embedding-mode flags for `runFullAnalysis`. + * + * Lives in its own module (no native imports) so the branching contract can + * be unit-tested without spinning up LadybugDB, tree-sitter, or any of the + * other side-effecting dependencies pulled in by `run-analyze.ts`. + * + * Semantics: + * --drop-embeddings -> wipe (skip cache load entirely) + * --embeddings -> load cache, restore, then generate + * --force + existing>0 -> load cache, restore, then generate (regenerate top-up) + * (default) + existing>0 -> preserve only (load + restore, no generation) + * any path with existing=0 -> no cache work, no preservation + */ + +export interface EmbeddingModeInput { + force?: boolean; + embeddings?: boolean; + dropEmbeddings?: boolean; +} + +export interface EmbeddingMode { + /** True when phase 4 should run the embedding generation pipeline. */ + shouldGenerateEmbeddings: boolean; + /** True when we should load the cache to re-insert vectors after rebuild without generating new ones. */ + preserveExistingEmbeddings: boolean; + /** True when `--force` upgraded a default analyze into a regeneration because the repo was already embedded. */ + forceRegenerateEmbeddings: boolean; + /** True when we need to load cached embeddings from the existing DB before the rebuild. */ + shouldLoadCache: boolean; +} + +export function deriveEmbeddingMode( + options: EmbeddingModeInput, + existingEmbeddingCount: number, +): EmbeddingMode { + const hasExisting = existingEmbeddingCount > 0; + const drop = !!options.dropEmbeddings; + const explicit = !!options.embeddings; + const force = !!options.force; + + const forceRegenerateEmbeddings = force && !explicit && !drop && hasExisting; + const preserveExistingEmbeddings = + !explicit && !drop && !forceRegenerateEmbeddings && hasExisting; + const shouldGenerateEmbeddings = explicit || forceRegenerateEmbeddings; + const shouldLoadCache = !drop && (shouldGenerateEmbeddings || preserveExistingEmbeddings); + + return { + shouldGenerateEmbeddings, + preserveExistingEmbeddings, + forceRegenerateEmbeddings, + shouldLoadCache, + }; +} diff --git a/gitnexus/src/core/embeddings/config.ts b/gitnexus/src/core/embeddings/config.ts new file mode 100644 index 000000000..7f09cf5af --- /dev/null +++ b/gitnexus/src/core/embeddings/config.ts @@ -0,0 +1,54 @@ +import { defaultEmbeddingThreads } from '../platform/capabilities.js'; +import { DEFAULT_EMBEDDING_CONFIG, type EmbeddingConfig } from './types.js'; + +const parsePositiveInt = (name: string, value: string | undefined, fallback: number): number => { + if (value === undefined) return fallback; + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error(`${name} must be a positive integer, got "${value}"`); + } + return parsed; +}; + +const parseDevice = (value: string | undefined): EmbeddingConfig['device'] | undefined => { + if (value === undefined) return undefined; + if ( + value === 'auto' || + value === 'dml' || + value === 'cuda' || + value === 'cpu' || + value === 'wasm' + ) { + return value; + } + throw new Error(`embedding device must be one of auto, dml, cuda, cpu, wasm; got "${value}"`); +}; + +export const resolveEmbeddingConfig = ( + overrides: Partial = {}, +): EmbeddingConfig => { + const env = process.env; + return { + ...DEFAULT_EMBEDDING_CONFIG, + ...overrides, + batchSize: parsePositiveInt( + 'GITNEXUS_EMBEDDING_BATCH_SIZE', + env.GITNEXUS_EMBEDDING_BATCH_SIZE, + overrides.batchSize ?? DEFAULT_EMBEDDING_CONFIG.batchSize, + ), + subBatchSize: parsePositiveInt( + 'GITNEXUS_EMBEDDING_SUB_BATCH_SIZE', + env.GITNEXUS_EMBEDDING_SUB_BATCH_SIZE, + overrides.subBatchSize ?? DEFAULT_EMBEDDING_CONFIG.subBatchSize, + ), + threads: parsePositiveInt( + 'GITNEXUS_EMBEDDING_THREADS', + env.GITNEXUS_EMBEDDING_THREADS, + overrides.threads ?? defaultEmbeddingThreads(), + ), + device: + parseDevice(env.GITNEXUS_EMBEDDING_DEVICE) ?? + overrides.device ?? + DEFAULT_EMBEDDING_CONFIG.device, + }; +}; diff --git a/gitnexus/src/core/embeddings/embedder.ts b/gitnexus/src/core/embeddings/embedder.ts index d27718ce5..caab198b4 100644 --- a/gitnexus/src/core/embeddings/embedder.ts +++ b/gitnexus/src/core/embeddings/embedder.ts @@ -15,12 +15,14 @@ if (!process.env.ORT_LOG_LEVEL) { } import { pipeline, env, type FeatureExtractionPipeline } from '@huggingface/transformers'; +import os from 'os'; import { existsSync } from 'fs'; import { execFileSync } from 'child_process'; import { join, dirname } from 'path'; import { createRequire } from 'module'; import { DEFAULT_EMBEDDING_CONFIG, type EmbeddingConfig, type ModelProgress } from './types.js'; import { isHttpMode, getHttpDimensions, httpEmbed } from './http-client.js'; +import { resolveEmbeddingConfig } from './config.js'; /** * Check whether the onnxruntime-node package that @huggingface/transformers @@ -143,13 +145,12 @@ export const initEmbedder = async ( isInitializing = true; - const finalConfig = { ...DEFAULT_EMBEDDING_CONFIG, ...config }; - // On Windows, use DirectML for GPU acceleration (via DirectX12) - // CUDA is only available on Linux x64 with onnxruntime-node + const finalConfig = resolveEmbeddingConfig(config); + // CUDA is probe-gated because ONNX Runtime can crash in native code when + // provider libraries are missing. DirectML stays opt-in for the same reason. // Probe for CUDA first — ONNX Runtime crashes (uncatchable native error) // if we attempt CUDA without the required shared libraries - const isWindows = process.platform === 'win32'; - const gpuDevice = isWindows ? 'dml' : isCudaAvailable() ? 'cuda' : 'cpu'; + const gpuDevice = isCudaAvailable() ? 'cuda' : 'cpu'; const requestedDevice = forceDevice || (finalConfig.device === 'auto' ? gpuDevice : finalConfig.device); @@ -161,7 +162,7 @@ export const initEmbedder = async ( // ./node_modules/.cache inside its own install dir, which is unwritable // when gitnexus is installed globally (e.g. /usr/lib/node_modules/). // Respect HF_HOME if set, otherwise fall back to ~/.cache/huggingface. - env.cacheDir = process.env.HF_HOME ?? `${process.env.HOME}/.cache/huggingface`; + env.cacheDir = process.env.HF_HOME ?? join(os.homedir(), '.cache', 'huggingface'); const isDev = process.env.NODE_ENV === 'development'; if (isDev) { @@ -204,7 +205,12 @@ export const initEmbedder = async ( device: device, dtype: 'fp32', progress_callback: progressCallback, - session_options: { logSeverityLevel: 3 }, + session_options: { + logSeverityLevel: 3, + intraOpNumThreads: finalConfig.threads, + interOpNumThreads: 1, + executionMode: 'sequential', + }, }); currentDevice = device; diff --git a/gitnexus/src/core/embeddings/embedding-pipeline.ts b/gitnexus/src/core/embeddings/embedding-pipeline.ts index be16789c2..82af563d4 100644 --- a/gitnexus/src/core/embeddings/embedding-pipeline.ts +++ b/gitnexus/src/core/embeddings/embedding-pipeline.ts @@ -27,7 +27,6 @@ import { type SemanticSearchResult, type ModelProgress, type EmbeddingContext, - DEFAULT_EMBEDDING_CONFIG, EMBEDDABLE_LABELS, isShortLabel, LABEL_METHOD, @@ -35,6 +34,8 @@ import { STRUCTURAL_LABELS, collectBestChunks, } from './types.js'; +import { resolveEmbeddingConfig } from './config.js'; +import { rankExactEmbeddingRows, type ExactEmbeddingRow } from './exact-search.js'; import { EMBEDDING_TABLE_NAME, EMBEDDING_INDEX_NAME, @@ -42,8 +43,20 @@ import { STALE_HASH_SENTINEL, } from '../lbug/schema.js'; import { loadVectorExtension } from '../lbug/lbug-adapter.js'; +import { getExactScanLimit } from '../platform/capabilities.js'; const isDev = process.env.NODE_ENV === 'development'; + +const vectorUnavailableMessage = + 'VECTOR extension is unavailable for this LadybugDB runtime; semantic search will use exact scan when embeddings exist.'; + +const ensureVectorExtensionAvailable = async (): Promise => { + const vectorReady = await loadVectorExtension(); + if (!vectorReady) { + return false; + } + return true; +}; /** * Bump this when the embedding text template changes in a way that should * invalidate existing vectors, such as metadata/header shape changes, @@ -192,19 +205,26 @@ export const batchInsertEmbeddings = async ( */ const createVectorIndex = async ( executeQuery: (cypher: string) => Promise, -): Promise => { - // Delegate to the adapter which tracks loaded state and handles DB reconnect resets - await loadVectorExtension(); - +): Promise => { + if (!(await ensureVectorExtensionAvailable())) return false; try { await executeQuery(CREATE_VECTOR_INDEX_QUERY); + return true; } catch (error) { if (isDev) { console.warn('Vector index creation warning:', error); } + return false; } }; +export interface EmbeddingPipelineResult { + nodesProcessed: number; + chunksProcessed: number; + vectorIndexReady: boolean; + semanticMode: 'vector-index' | 'exact-scan'; +} + /** * Run the embedding pipeline * @@ -230,10 +250,14 @@ export const runEmbeddingPipeline = async ( skipNodeIds?: Set, context?: EmbeddingContext, existingEmbeddings?: Map, -): Promise => { - const finalConfig = { ...DEFAULT_EMBEDDING_CONFIG, ...config }; +): Promise => { + const finalConfig = resolveEmbeddingConfig(config); + let totalChunks = 0; try { + const vectorAvailable = await ensureVectorExtensionAvailable(); + if (!vectorAvailable && isDev) console.warn(vectorUnavailableMessage); + // Phase 1: Load embedding model onProgress({ phase: 'loading-model', @@ -338,7 +362,7 @@ export const runEmbeddingPipeline = async ( // Ensure the vector index exists even when no new nodes need embedding. // A prior crash or first-time incremental run may have left CodeEmbedding // rows without ever reaching index creation. - await createVectorIndex(executeQuery); + const vectorIndexReady = await createVectorIndex(executeQuery); onProgress({ phase: 'ready', @@ -346,7 +370,12 @@ export const runEmbeddingPipeline = async ( nodesProcessed: 0, totalNodes: 0, }); - return; + return { + nodesProcessed: 0, + chunksProcessed: 0, + vectorIndexReady, + semanticMode: vectorIndexReady ? 'vector-index' : 'exact-scan', + }; } // Phase 3: Chunk + embed nodes @@ -354,7 +383,6 @@ export const runEmbeddingPipeline = async ( const chunkSize = finalConfig.chunkSize; const overlap = finalConfig.overlap; let processedNodes = 0; - let totalChunks = 0; onProgress({ phase: 'embedding', @@ -445,7 +473,7 @@ export const runEmbeddingPipeline = async ( } // Embed chunk texts in sub-batches to control memory - const EMBED_SUB_BATCH = 8; + const EMBED_SUB_BATCH = finalConfig.subBatchSize; 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); @@ -495,7 +523,7 @@ export const runEmbeddingPipeline = async ( console.log('📇 Creating vector index...'); } - await createVectorIndex(executeQuery); + const vectorIndexReady = await createVectorIndex(executeQuery); onProgress({ phase: 'ready', @@ -509,6 +537,12 @@ export const runEmbeddingPipeline = async ( `✅ Embedding pipeline complete! (${totalChunks} chunks from ${totalNodes} nodes)`, ); } + return { + nodesProcessed: totalNodes, + chunksProcessed: totalChunks, + vectorIndexReady, + semanticMode: vectorIndexReady ? 'vector-index' : 'exact-scan', + }; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; @@ -543,27 +577,71 @@ export const semanticSearch = async ( const queryVec = embeddingToArray(queryEmbedding); const queryVecStr = `[${queryVec.join(',')}]`; - 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 - `; + let bestChunks = new Map< + string, + { distance: number; chunkIndex: number; startLine: number; endLine: number } + >(); + if (await loadVectorExtension()) { + try { + 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); - 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], - })); - }); + 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], + })); + }); + } catch { + bestChunks = new Map(); + } + } + + if (bestChunks.size === 0) { + const countRows = await executeQuery( + `MATCH (e:${EMBEDDING_TABLE_NAME}) RETURN count(e) AS cnt`, + ); + const countRow = countRows[0]; + const embeddingCount = Number(countRow?.cnt ?? countRow?.[0] ?? 0); + const exactLimit = getExactScanLimit(); + if (embeddingCount > 0 && embeddingCount <= exactLimit) { + const rows = await executeQuery(` + MATCH (e:${EMBEDDING_TABLE_NAME}) + RETURN e.nodeId AS nodeId, e.chunkIndex AS chunkIndex, + e.startLine AS startLine, e.endLine AS endLine, e.embedding AS embedding + `); + const exactRows: ExactEmbeddingRow[] = rows.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, + embedding: row.embedding ?? row[4] ?? [], + })); + bestChunks = new Map( + rankExactEmbeddingRows(exactRows, queryVec, k, maxDistance).map((row) => [ + row.nodeId, + { + distance: row.distance, + chunkIndex: row.chunkIndex, + startLine: row.startLine, + endLine: row.endLine, + }, + ]), + ); + } + } if (bestChunks.size === 0) { return []; diff --git a/gitnexus/src/core/embeddings/exact-search.ts b/gitnexus/src/core/embeddings/exact-search.ts new file mode 100644 index 000000000..e8db6ea91 --- /dev/null +++ b/gitnexus/src/core/embeddings/exact-search.ts @@ -0,0 +1,49 @@ +export interface ExactEmbeddingRow { + nodeId: string; + chunkIndex: number; + startLine: number; + endLine: number; + embedding: readonly number[]; +} + +export interface ExactSearchChunk { + nodeId: string; + chunkIndex: number; + startLine: number; + endLine: number; + distance: number; +} + +const cosineDistance = (a: readonly number[], b: readonly number[]): number => { + let dot = 0; + let aNorm = 0; + let bNorm = 0; + const len = Math.min(a.length, b.length); + for (let i = 0; i < len; i++) { + const av = a[i] ?? 0; + const bv = b[i] ?? 0; + dot += av * bv; + aNorm += av * av; + bNorm += bv * bv; + } + if (aNorm === 0 || bNorm === 0) return 1; + return 1 - dot / (Math.sqrt(aNorm) * Math.sqrt(bNorm)); +}; + +export const rankExactEmbeddingRows = ( + rows: readonly ExactEmbeddingRow[], + queryEmbedding: readonly number[], + limit: number, + maxDistance: number, +): ExactSearchChunk[] => + rows + .map((row) => ({ + nodeId: row.nodeId, + chunkIndex: row.chunkIndex, + startLine: row.startLine, + endLine: row.endLine, + distance: cosineDistance(row.embedding, queryEmbedding), + })) + .filter((row) => row.distance < maxDistance) + .sort((a, b) => a.distance - b.distance) + .slice(0, limit); diff --git a/gitnexus/src/core/embeddings/types.ts b/gitnexus/src/core/embeddings/types.ts index 4156e9b64..309a3683e 100644 --- a/gitnexus/src/core/embeddings/types.ts +++ b/gitnexus/src/core/embeddings/types.ts @@ -207,6 +207,10 @@ export interface EmbeddingConfig { modelId: string; /** Number of nodes to embed in each batch */ batchSize: number; + /** Number of chunks passed to one local/HTTP embedding call */ + subBatchSize: number; + /** Maximum ONNX Runtime CPU threads for local inference */ + threads: number; /** Embedding vector dimensions */ dimensions: number; /** Device to use for inference: 'auto' tries GPU first (DirectML on Windows, CUDA on Linux), falls back to CPU */ @@ -229,6 +233,8 @@ export interface EmbeddingConfig { export const DEFAULT_EMBEDDING_CONFIG: EmbeddingConfig = { modelId: 'Snowflake/snowflake-arctic-embed-xs', batchSize: 16, + subBatchSize: 8, + threads: 2, dimensions: 384, device: 'auto', maxSnippetLength: 500, diff --git a/gitnexus/src/core/group/config-parser.ts b/gitnexus/src/core/group/config-parser.ts index 7ee3e2dcf..429c3a50d 100644 --- a/gitnexus/src/core/group/config-parser.ts +++ b/gitnexus/src/core/group/config-parser.ts @@ -20,6 +20,8 @@ const DEFAULT_MATCHING = { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3, + exclude_links_paths: [] as string[], + exclude_links_param_only_paths: false, }; export function parseGroupConfig(yamlContent: string): GroupConfig { diff --git a/gitnexus/src/core/group/matching.ts b/gitnexus/src/core/group/matching.ts index 495c9bd36..e50673e81 100644 --- a/gitnexus/src/core/group/matching.ts +++ b/gitnexus/src/core/group/matching.ts @@ -1,4 +1,4 @@ -import type { StoredContract, CrossLink } from './types.js'; +import type { StoredContract, CrossLink, MatchingConfig } from './types.js'; export interface MatchResult { matched: CrossLink[]; @@ -14,6 +14,43 @@ function isServiceWildcard(cid: string): boolean { return (cid.startsWith('grpc::') || cid.startsWith('thrift::')) && cid.endsWith('/*'); } +/** + * Detect HTTP contracts that are too generic or infrastructure-level to + * produce meaningful cross-repo links. These are still extracted (useful + * for documentation / route maps) but excluded from cross-link matching. + * + * Two categories: + * 1. Health-check / readiness endpoints — every service has one, matching + * them produces N×M false links. + * 2. Param-only paths — routes like `/{param}` or `/{param}/{param}` that + * collapse to a single catch-all after normalization. These match any + * service with a similar shape, producing false positives. + * + * Both are configurable via matching.exclude_links_paths and + * matching.exclude_links_param_only_paths in group.yaml. + */ +function buildNoisyContractFilter( + matchingConfig?: MatchingConfig, +): (contractId: string) => boolean { + const excludePaths = matchingConfig?.exclude_links_paths?.length + ? new Set(matchingConfig.exclude_links_paths.map((p) => p.replace(/\/+$/, ''))) + : new Set(); + const excludeParamOnly = matchingConfig?.exclude_links_param_only_paths === true; + + return function isNoisyHttpContract(contractId: string): boolean { + if (!contractId.startsWith('http::')) return false; + const parts = contractId.split('::'); + if (parts.length < 3) return false; + const pathPart = parts.slice(2).join('::').replace(/\/+$/, ''); + if (excludePaths.has(pathPart)) return true; + if (excludeParamOnly) { + const segments = pathPart.split('/').filter(Boolean); + if (segments.length > 0 && segments.every((s) => s === '{param}')) return true; + } + return false; + }; +} + export function normalizeContractId(id: string): string { const colonIdx = id.indexOf('::'); if (colonIdx === -1) return id; @@ -119,8 +156,12 @@ function findMatchingKeys(contractId: string, index: Map { - const providers = contracts.filter((c) => c.role === 'provider'); +export function buildProviderIndex( + contracts: StoredContract[], + matchingConfig?: MatchingConfig, +): Map { + const isNoisy = buildNoisyContractFilter(matchingConfig); + const providers = contracts.filter((c) => c.role === 'provider' && !isNoisy(c.contractId)); const index = new Map(); for (const p of providers) { const key = normalizeContractId(p.contractId); @@ -134,12 +175,14 @@ export function buildProviderIndex(contracts: StoredContract[]): Map, + matchingConfig?: MatchingConfig, ): MatchResult { - const index = providerIndex ?? buildProviderIndex(contracts); + const isNoisy = buildNoisyContractFilter(matchingConfig); + const index = providerIndex ?? buildProviderIndex(contracts, matchingConfig); // Skip service wildcard consumers — they go to wildcard pass only const consumers = contracts.filter( - (c) => c.role === 'consumer' && !isServiceWildcard(c.contractId), + (c) => c.role === 'consumer' && !isServiceWildcard(c.contractId) && !isNoisy(c.contractId), ); const matched: CrossLink[] = []; @@ -185,6 +228,7 @@ export function runExactMatch( // normalUnmatched: contracts that weren't matched in exact pass const normalUnmatched = contracts.filter((c) => { if (isServiceWildcard(c.contractId)) return false; // excluded from exact, handled separately + if (isNoisy(c.contractId)) return false; // excluded from matching — don't surface as unmatched const id = `${c.repo}::${c.contractId}`; return c.role === 'provider' ? !matchedProviderIds.has(id) : !matchedConsumerIds.has(id); }); diff --git a/gitnexus/src/core/group/storage.ts b/gitnexus/src/core/group/storage.ts index aa6a781a5..5380b9867 100644 --- a/gitnexus/src/core/group/storage.ts +++ b/gitnexus/src/core/group/storage.ts @@ -103,6 +103,8 @@ matching: bm25_threshold: 0.7 embedding_threshold: 0.65 max_candidates_per_step: 3 + # exclude_links_paths: [/ping, /health, /healthcheck] + # exclude_links_param_only_paths: false `; await fsp.writeFile(path.join(groupDir, 'group.yaml'), template, 'utf-8'); return groupDir; diff --git a/gitnexus/src/core/group/sync.ts b/gitnexus/src/core/group/sync.ts index 0a70ae333..2bffbeaf7 100644 --- a/gitnexus/src/core/group/sync.ts +++ b/gitnexus/src/core/group/sync.ts @@ -221,8 +221,8 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis } } - const providerIndex = buildProviderIndex(autoContracts); - const { matched, unmatched } = runExactMatch(autoContracts, providerIndex); + const providerIndex = buildProviderIndex(autoContracts, config.matching); + const { matched, unmatched } = runExactMatch(autoContracts, providerIndex, config.matching); const wildcard = runWildcardMatch(unmatched, providerIndex); // Dedupe cross-links. Manifest contracts participate in runExactMatch, so a diff --git a/gitnexus/src/core/group/types.ts b/gitnexus/src/core/group/types.ts index ce18fd8d5..38b487b20 100644 --- a/gitnexus/src/core/group/types.ts +++ b/gitnexus/src/core/group/types.ts @@ -34,6 +34,24 @@ export interface MatchingConfig { bm25_threshold: number; embedding_threshold: number; max_candidates_per_step: number; + /** + * HTTP paths to exclude from cross-link matching. Contracts at these paths + * are still extracted and visible in the registry, but they don't produce + * cross-repo links. Useful for health-check endpoints (`/ping`, `/health`) + * that every service exposes and would otherwise create N×M false links. + * Trailing slashes are normalized before comparison. + * @default [] + */ + exclude_links_paths?: string[]; + /** + * When `true`, exclude HTTP routes where every path segment is `{param}` + * (e.g. `/{param}`, `/{param}/{param}`) from cross-link matching. Mixed + * routes like `/users/{param}` are not affected. These param-only routes + * collapse to a single catch-all after normalization and produce false + * positives across unrelated services. + * @default false + */ + exclude_links_param_only_paths?: boolean; } export interface SymbolRef { diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index 2debe56b3..1871d0605 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -12,7 +12,7 @@ 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) + * - 'free' → free branch (admits class-target fast path) * - 'member' or undefined → owner-scoped branch * * `undefined` callForm MUST route through owner-scoped (not free) so bare @@ -770,7 +770,7 @@ export const processCalls = async ( if (!tree) { try { tree = parser.parse(file.content, undefined, { - bufferSize: getTreeSitterBufferSize(file.content.length), + bufferSize: getTreeSitterBufferSize(file.content), }); } catch (parseError) { continue; @@ -1595,41 +1595,30 @@ const disambiguateByOverloadOrArgTypes = ( return null; }; -/** - * Collapse Swift-extension duplicate Class/Struct candidates to the primary - * definition, preferring the shortest file path. - * - * Swift extensions (`extension User { ... }` in a separate file) create - * multiple `Class` nodes sharing the same symbol name — one for the primary - * declaration and one per extension file. When overload disambiguation and - * receiver narrowing both fail to converge on a single candidate, this - * heuristic picks the primary definition based on the assumption that it - * lives at the shortest file path (e.g. `User.swift` over `UserExtensions.swift`). - * - * Intentionally narrower than {@link INSTANTIABLE_CLASS_TYPES}: only `Class` - * and `Struct` are considered, not `Record`. Swift extensions only produce - * `Class` duplicates in practice, and C#/Kotlin records do not exhibit the - * same multi-file-definition pattern, so widening this set risks accidental - * dedup of legitimately distinct record types. - * - * Returns a `ResolveResult` when the heuristic fires, `null` when the - * candidate pool does not match the shape (mixed types, non-Class/Struct - * kinds, or `length <= 1`). Callers should fall through to their own null - * return when this helper returns `null`. - * - * Used by `resolveFreeCall`. Having a single source of truth prevents - * duplication if the heuristic is ever tuned. - */ -const dedupSwiftExtensionCandidates = ( +const orderProviderSameNameTypeCandidates = ( + candidates: readonly SymbolDefinition[], + typeName: string, + filePath: string, +): readonly SymbolDefinition[] | null => { + const language = getLanguageFromFilename(filePath); + if (language == null) return null; + return ( + getProvider(language).orderSameNameTypeCandidates?.({ + typeName, + callSiteFilePath: filePath, + candidates, + }) ?? null + ); +}; + +const resolveProviderPrimaryTypeCandidate = ( candidates: readonly SymbolDefinition[], tier: ResolutionTier, + typeName: string, + filePath: string, ): ResolveResult | null => { - if (candidates.length <= 1) return null; - const allSameType = candidates.every((c) => c.type === candidates[0].type); - if (!allSameType) return null; - if (candidates[0].type !== 'Class' && candidates[0].type !== 'Struct') return null; - const sorted = [...candidates].sort((a, b) => a.filePath.length - b.filePath.length); - return toResolveResult(sorted[0], tier); + const ordered = orderProviderSameNameTypeCandidates(candidates, typeName, filePath); + return ordered && ordered.length > 0 ? toResolveResult(ordered[0], tier) : null; }; /** @@ -2223,6 +2212,35 @@ const resolveMethodByOwner = ( } } + if (!firstDef && !ambiguous) { + const orderedTypeCandidates = orderProviderSameNameTypeCandidates( + ctx.model.types.lookupClassByName(receiverTypeName), + receiverTypeName, + filePath, + ); + if (orderedTypeCandidates) { + for (const candidate of orderedTypeCandidates) { + const def = canWalkMRO + ? lookupMethodByOwnerWithMRO( + candidate.nodeId, + methodName, + heritageMap, + ctx.model, + mroStrategy, + argCount, + ) + : ctx.model.methods.lookupMethodByOwner(candidate.nodeId, methodName, argCount); + if (!def) continue; + if (!firstDef) { + firstDef = def; + } else if (def.nodeId !== firstDef.nodeId) { + ambiguous = true; + break; + } + } + } + } + if (!firstDef || ambiguous) return undefined; return { def: firstDef, tier: typeResolved.tier }; }; @@ -2290,9 +2308,9 @@ export const resolveMemberCall = ( * resolution via `ctx.resolve()`. * * Used for `foo()`, `doStuff()` — unqualified calls with no receiver. - * Also handles Swift/Kotlin implicit constructors (`User()` without `new`) - * by delegating to {@link resolveStaticCall} when the tiered pool contains - * class-like targets. + * Also handles implicit constructors (`User()` without `new`) by delegating + * to {@link resolveStaticCall} when the tiered pool contains class-like + * targets. * * {@link resolveCallTarget} delegates here for `callForm === 'free'`. * @@ -2324,33 +2342,30 @@ export const resolveFreeCall = ( let filteredCandidates = filterCallableCandidates(tiered.candidates, argCount, 'free'); - // Class-target fast path: Swift/Kotlin `User()` — free-form call targeting a - // class. Delegates to resolveStaticCall for O(1) class + constructor lookup. + // Class-target fast path: free-form call targeting a class. Delegates to + // resolveStaticCall for O(1) class + constructor lookup. // The `.some()` trigger must stay aligned with `INSTANTIABLE_CLASS_TYPES` — // any type admitted here that is not in that set will cause resolveStaticCall // to return null, wasting two lookup passes per call. `Enum` is deliberately - // excluded; `Record` is included so C# records and Kotlin data classes reach - // the fast path. + // excluded; `Record` is included so record-like class targets reach the fast + // path. // Align with INSTANTIABLE_CLASS_TYPES by reusing the set directly rather // than enumerating literal strings. This converts an invariant that was // previously enforced by a comment ("keep this list aligned with // INSTANTIABLE_CLASS_TYPES") into one enforced structurally — any future - // extension of the set (e.g. Kotlin `object`) propagates here automatically. - // The `dedupSwiftExtensionCandidates` helper used in the tail of this - // function deliberately uses a narrower literal `'Class' | 'Struct'` check - // — Swift extensions only produce Class duplicates in practice, so Record - // is excluded there by design. Do not collapse that helper into - // INSTANTIABLE_CLASS_TYPES. + // extension of the set propagates here automatically. + // Language providers can still choose a primary same-name type candidate in + // the tail of this function when their grammars index one logical type + // multiple times. const hasClassTarget = filteredCandidates.length === 0 && tiered.candidates.some((c) => INSTANTIABLE_CLASS_TYPES.has(c.type)); if (hasClassTarget) { const staticResult = resolveStaticCall(calledName, filePath, ctx, argCount, tiered); if (staticResult) return staticResult; - // Retry with constructor form: Swift/Kotlin constructor calls look like - // free function calls (no `new` keyword). If resolveStaticCall didn't - // match, re-filter with constructor form so CONSTRUCTOR_TARGET_TYPES - // applies. + // Retry with constructor form for languages whose constructor calls look + // like free function calls. If resolveStaticCall didn't match, re-filter + // with constructor form so CONSTRUCTOR_TARGET_TYPES applies. // // The retry fires for every null return from `resolveStaticCall`, which // can happen for three distinct reasons — all three are handled below: @@ -2364,9 +2379,8 @@ export const resolveFreeCall = ( // (b) Homonym ambiguity — two or more instantiable class candidates // share the name (e.g. `User` in two files, same tier). The // retry repopulates `filteredCandidates` with both Classes and - // they flow into `dedupSwiftExtensionCandidates` below, which - // either picks the shortest-path primary or null-routes. - // Covered by the R7 Swift-extension dedup test. + // they flow into the provider same-name candidate hook below, which + // can pick a primary definition or null-route. // // (c) `resolveStaticCall` step 4 bailed because the tiered pool // contains ownerless `Constructor` nodes (some extractors emit @@ -2391,10 +2405,13 @@ export const resolveFreeCall = ( } if (filteredCandidates.length !== 1) { - // See `dedupSwiftExtensionCandidates` — shared helper, single source of - // truth for the Swift-extension same-name collision heuristic. - const deduped = dedupSwiftExtensionCandidates(filteredCandidates, tiered.tier); - if (deduped) return deduped; + const primary = resolveProviderPrimaryTypeCandidate( + filteredCandidates, + tiered.tier, + calledName, + filePath, + ); + if (primary) return primary; return null; } @@ -2559,9 +2576,16 @@ export const resolveStaticCall = ( // Interface / Trait / Impl). Null-route via the fall-through `return // null` — this is the dominant Codex-fix case. // length === 1 → a single instantiable candidate remains, return it. - // length > 1 → two or more instantiable classes share the name (e.g. - // homonym classes across files with no import narrowing). Fall through - // to `return null` so the caller null-routes rather than guess. + // length > 1 → let the call-site provider choose a primary when it can + // prove the candidates are one logical type; otherwise null-route. + const primary = resolveProviderPrimaryTypeCandidate( + instantiableCandidates, + typeResolved.tier, + className, + currentFile, + ); + if (primary) return primary; + if (instantiableCandidates.length === 1) { return toResolveResult(instantiableCandidates[0], typeResolved.tier); } @@ -3257,7 +3281,7 @@ export const extractFetchCallsFromFiles = async ( if (!tree) { try { tree = parser.parse(file.content, undefined, { - bufferSize: getTreeSitterBufferSize(file.content.length), + bufferSize: getTreeSitterBufferSize(file.content), }); } catch { continue; diff --git a/gitnexus/src/core/ingestion/constants.ts b/gitnexus/src/core/ingestion/constants.ts index 81f1107e1..4862d31a0 100644 --- a/gitnexus/src/core/ingestion/constants.ts +++ b/gitnexus/src/core/ingestion/constants.ts @@ -1,3 +1,5 @@ +import { Buffer } from 'node:buffer'; + /** * Default minimum buffer size for tree-sitter parsing (512 KB). * tree-sitter requires bufferSize >= file size in bytes. @@ -12,8 +14,13 @@ export const TREE_SITTER_MAX_BUFFER = 32 * 1024 * 1024; /** * Compute adaptive buffer size for tree-sitter parsing. - * Uses 2× file size, clamped between 512 KB and 32 MB. - * Previous 256 KB fixed limit silently skipped files > ~200 KB (e.g., imgui.h at 411 KB). + * Uses 2x UTF-8 byte size, clamped between 512 KB and 32 MB. + * Keeps tree-sitter's byte-sized buffer above large ASCII and multibyte sources. */ -export const getTreeSitterBufferSize = (contentLength: number): number => - Math.min(Math.max(contentLength * 2, TREE_SITTER_BUFFER_SIZE), TREE_SITTER_MAX_BUFFER); +export const getTreeSitterContentByteLength = (sourceText: string): number => + Buffer.byteLength(sourceText, 'utf8'); + +export const getTreeSitterBufferSize = (sourceText: string): number => { + const byteLength = getTreeSitterContentByteLength(sourceText); + return Math.min(Math.max(byteLength * 2, TREE_SITTER_BUFFER_SIZE), TREE_SITTER_MAX_BUFFER); +}; diff --git a/gitnexus/src/core/ingestion/filesystem-walker.ts b/gitnexus/src/core/ingestion/filesystem-walker.ts index 575efc0f3..71a4046f2 100644 --- a/gitnexus/src/core/ingestion/filesystem-walker.ts +++ b/gitnexus/src/core/ingestion/filesystem-walker.ts @@ -1,4 +1,5 @@ import { isVerboseIngestionEnabled } from './utils/verbose.js'; +import { DEFAULT_MAX_FILE_SIZE_BYTES, getMaxFileSizeBytes } from './utils/max-file-size.js'; import fs from 'fs/promises'; import path from 'path'; import { glob } from 'glob'; @@ -22,9 +23,6 @@ export interface FilePath { const READ_CONCURRENCY = 32; -/** Skip files larger than 512KB — they're usually generated/vendored and crash tree-sitter */ -const MAX_FILE_SIZE = 512 * 1024; - /** * Phase 1: Scan repository — stat files to get paths + sizes, no content loaded. * Memory: ~10MB for 100K files vs ~1GB+ with content. @@ -34,6 +32,7 @@ export const walkRepositoryPaths = async ( onProgress?: (current: number, total: number, filePath: string) => void, ): Promise => { const ignoreFilter = await createIgnoreFilter(repoPath); + const maxFileSizeBytes = getMaxFileSizeBytes(); const filtered = await glob('**/*', { cwd: repoPath, @@ -52,7 +51,7 @@ export const walkRepositoryPaths = async ( batch.map(async (relativePath) => { const fullPath = path.join(repoPath, relativePath); const stat = await fs.stat(fullPath); - if (stat.size > MAX_FILE_SIZE) { + if (stat.size > maxFileSizeBytes) { skippedLarge++; skippedLargePaths.push(relativePath.replace(/\\/g, '/')); return null; @@ -73,9 +72,9 @@ export const walkRepositoryPaths = async ( } if (skippedLarge > 0) { - console.warn( - ` Skipped ${skippedLarge} large files (>${MAX_FILE_SIZE / 1024}KB, likely generated/vendored)`, - ); + const isDefault = maxFileSizeBytes === DEFAULT_MAX_FILE_SIZE_BYTES; + const suffix = isDefault ? ', likely generated/vendored' : ''; + console.warn(` Skipped ${skippedLarge} large files (>${maxFileSizeBytes / 1024}KB${suffix})`); if (isVerboseIngestionEnabled()) { for (const p of skippedLargePaths) { console.warn(` - ${p}`); diff --git a/gitnexus/src/core/ingestion/finalize-orchestrator.ts b/gitnexus/src/core/ingestion/finalize-orchestrator.ts index 8541e58aa..70dff9874 100644 --- a/gitnexus/src/core/ingestion/finalize-orchestrator.ts +++ b/gitnexus/src/core/ingestion/finalize-orchestrator.ts @@ -106,15 +106,13 @@ export function finalizeScopeModel( const allScopes: Scope[] = []; const allDefs: SymbolDefinition[] = []; const moduleEntries: { filePath: string; moduleScopeId: ScopeId }[] = []; - const allReferenceSites = [] as ReturnType; + const allReferenceSites = collectReferenceSites(parsedFiles); 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); @@ -141,6 +139,11 @@ export function finalizeScopeModel( methodDispatch, imports: finalizeOut.imports, bindings: finalizeOut.bindings, + // Empty post-finalize augmentation channel. Populated (if at all) + // by language hooks like `populateCsharpNamespaceSiblings` running + // AFTER `finalizeScopeModel` returns, before `resolveReferenceSites` + // consumes the bundle. Most languages leave it empty. + bindingAugmentations: new Map(), referenceSites: Object.freeze([...allReferenceSites]), sccs: finalizeOut.sccs, stats: finalizeOut.stats, diff --git a/gitnexus/src/core/ingestion/heritage-processor.ts b/gitnexus/src/core/ingestion/heritage-processor.ts index 6692d1c95..12e59a19a 100644 --- a/gitnexus/src/core/ingestion/heritage-processor.ts +++ b/gitnexus/src/core/ingestion/heritage-processor.ts @@ -220,7 +220,7 @@ export const processHeritage = async ( // Use larger bufferSize for files > 32KB try { tree = parser.parse(file.content, undefined, { - bufferSize: getTreeSitterBufferSize(file.content.length), + bufferSize: getTreeSitterBufferSize(file.content), }); } catch (parseError) { // Skip files that can't be parsed @@ -414,7 +414,7 @@ export async function extractExtractedHeritageFromFiles( if (!tree) { try { tree = parser.parse(file.content, undefined, { - bufferSize: getTreeSitterBufferSize(file.content.length), + bufferSize: getTreeSitterBufferSize(file.content), }); } catch { continue; diff --git a/gitnexus/src/core/ingestion/import-processor.ts b/gitnexus/src/core/ingestion/import-processor.ts index d1b039d93..b669d2744 100644 --- a/gitnexus/src/core/ingestion/import-processor.ts +++ b/gitnexus/src/core/ingestion/import-processor.ts @@ -306,7 +306,7 @@ export const processImports = async ( if (!tree) { try { tree = parser.parse(file.content, undefined, { - bufferSize: getTreeSitterBufferSize(file.content.length), + bufferSize: getTreeSitterBufferSize(file.content), }); } catch (parseError) { continue; diff --git a/gitnexus/src/core/ingestion/language-provider.ts b/gitnexus/src/core/ingestion/language-provider.ts index 7ab0d33fc..92566d30e 100644 --- a/gitnexus/src/core/ingestion/language-provider.ts +++ b/gitnexus/src/core/ingestion/language-provider.ts @@ -303,8 +303,9 @@ interface LanguageProviderConfig { /** * Emit scope captures from raw source, **pre-grouped per tree-sitter - * query match**. Tree-sitter-based providers run a `scopes.scm` query - * and emit one `CaptureMatch` per query match; standalone providers + * query match**. Tree-sitter-based providers run a scope query + * (embedded as a string constant in each language's `query.ts`) and + * emit one `CaptureMatch` per query match; standalone providers * (COBOL) emit matches from a regex tagger. The return shape is * parser-agnostic: the central `ScopeExtractor` consumes * `CaptureMatch[]` without knowing which parser produced them. @@ -497,6 +498,15 @@ interface LanguageProviderConfig { // ── Resolution phase (RFC §4v2) ──────────────────────────────────── + /** Order same-name type candidates when a language can index multiple + * definitions for one logical type. Return null to keep shared ambiguity + * handling. */ + readonly orderSameNameTypeCandidates?: (params: { + readonly typeName: string; + readonly callSiteFilePath: string; + readonly candidates: readonly SymbolDefinition[]; + }) => readonly SymbolDefinition[] | null; + /** * Is this callable definition compatible with the given call-site arity? * Language-specific rules: Python `*args`/`**kwargs`/defaults, JS default diff --git a/gitnexus/src/core/ingestion/languages/csharp.ts b/gitnexus/src/core/ingestion/languages/csharp.ts index a491f339a..837a7517b 100644 --- a/gitnexus/src/core/ingestion/languages/csharp.ts +++ b/gitnexus/src/core/ingestion/languages/csharp.ts @@ -25,6 +25,17 @@ import { csharpMethodConfig } from '../method-extractors/configs/csharp.js'; import { createVariableExtractor } from '../variable-extractors/generic.js'; import { csharpVariableConfig } from '../variable-extractors/configs/csharp.js'; import { createHeritageExtractor } from '../heritage-extractors/generic.js'; +import { + emitCsharpScopeCaptures, + interpretCsharpImport, + interpretCsharpTypeBinding, + csharpBindingScopeFor, + csharpImportOwningScope, + csharpMergeBindings, + csharpReceiverBinding, + csharpArityCompatibility, + resolveCsharpImportTarget, +} from './csharp/index.js'; const BUILT_INS: ReadonlySet = new Set([ 'Console', @@ -138,4 +149,18 @@ export const csharpProvider = defineLanguage({ classExtractor: createClassExtractor(csharpClassConfig), heritageExtractor: createHeritageExtractor(SupportedLanguages.CSharp), builtInNames: BUILT_INS, + + // ── RFC #909 Ring 3: scope-based resolution hooks (RFC §5) ────────── + // C# is the second migration after Python. See ./csharp/index.ts for + // the full per-hook rationale and the canonical capture vocabulary + // in ./csharp/query.ts (CSHARP_SCOPE_QUERY constant). + emitScopeCaptures: emitCsharpScopeCaptures, + interpretImport: interpretCsharpImport, + interpretTypeBinding: interpretCsharpTypeBinding, + bindingScopeFor: csharpBindingScopeFor, + importOwningScope: csharpImportOwningScope, + mergeBindings: (_scope, bindings) => csharpMergeBindings(bindings), + receiverBinding: csharpReceiverBinding, + arityCompatibility: csharpArityCompatibility, + resolveImportTarget: resolveCsharpImportTarget, }); diff --git a/gitnexus/src/core/ingestion/languages/csharp/accessor-unwrap.ts b/gitnexus/src/core/ingestion/languages/csharp/accessor-unwrap.ts new file mode 100644 index 000000000..d0ea89789 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/csharp/accessor-unwrap.ts @@ -0,0 +1,57 @@ +/** + * C# collection-accessor unwrapping. + * + * When the compound-receiver resolver encounters a trailing + * `.Values` / `.Keys` on a dotted member-access chain, it calls the + * provider's `unwrapCollectionAccessor` hook to find the element + * type. This module supplies the C# implementation — recognizing + * Dictionary-family generics and returning the value or key type. + * + * Other languages (Python, Java, TypeScript) use method-call syntax + * for the same access (`.values()` / `.keys()`), which the compound- + * receiver's call-expression branch already handles; they leave this + * hook undefined. + */ + +/** Extract (K, V) from `Dictionary` / `IDictionary` / + * `IReadOnlyDictionary` / `SortedDictionary` / + * `ConcurrentDictionary` / `ImmutableDictionary`. + * Returns undefined if the type name doesn't match or the argument + * list isn't exactly two top-level args. */ +function extractDictionaryArgs(rawName: string): { key: string; value: string } | undefined { + const match = rawName.match( + /^(?:[A-Za-z_][A-Za-z0-9_.]*\.)?(?:Dictionary|IDictionary|IReadOnlyDictionary|SortedDictionary|ConcurrentDictionary|ImmutableDictionary)<(.+)>$/, + ); + if (match === null) return undefined; + const inner = match[1]!; + // Split on the top-level comma (tolerate nested `<...>`). + let depth = 0; + let commaIdx = -1; + for (let i = 0; i < inner.length; i++) { + const ch = inner[i]; + if (ch === '<') depth++; + else if (ch === '>') depth--; + else if (ch === ',' && depth === 0) { + commaIdx = i; + break; + } + } + if (commaIdx === -1) return undefined; + return { key: inner.slice(0, commaIdx).trim(), value: inner.slice(commaIdx + 1).trim() }; +} + +/** + * Resolve `data.Values` / `data.Keys` on a Dictionary-like receiver + * to its element-type simple name. Returns `undefined` for any + * receiver / accessor combination we don't recognize, letting the + * compound-receiver pass fall through to the regular field walk. + */ +export function unwrapCsharpCollectionAccessor( + receiverType: string, + accessor: string, +): string | undefined { + if (accessor !== 'Values' && accessor !== 'Keys') return undefined; + const args = extractDictionaryArgs(receiverType); + if (args === undefined) return undefined; + return accessor === 'Values' ? args.value : args.key; +} diff --git a/gitnexus/src/core/ingestion/languages/csharp/arity-metadata.ts b/gitnexus/src/core/ingestion/languages/csharp/arity-metadata.ts new file mode 100644 index 000000000..92b377352 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/csharp/arity-metadata.ts @@ -0,0 +1,54 @@ +/** + * Extract C# arity metadata from a method-like tree-sitter node — + * `method_declaration`, `constructor_declaration`, `destructor_declaration`, + * `operator_declaration`, `conversion_operator_declaration`, or + * `local_function_statement`. + * + * Reuses `csharpMethodConfig.extractParameters` so scope-extracted defs + * carry the same arity semantics as the legacy parse-worker path: + * - `params` variadic collapses `parameterCount` to `undefined`, + * which `csharpArityCompatibility` then treats as "max unknown" — + * the candidate stays eligible at `argCount >= required`. + * - Defaulted parameters (`= expr`) contribute to `optionalCount`; + * `requiredParameterCount = total − optionalCount`. + * - `parameterTypes` collects declared type names (with `ref`/`out`/ + * `in` prefix) for overload narrowing; a literal `'params'` marker + * is appended for variadic methods so `csharpArityCompatibility` + * can detect them without re-reading the AST. + */ + +import type { SyntaxNode } from '../../utils/ast-helpers.js'; +import { csharpMethodConfig } from '../../method-extractors/configs/csharp.js'; + +interface CsharpArityMetadata { + readonly parameterCount: number | undefined; + readonly requiredParameterCount: number | undefined; + readonly parameterTypes: readonly string[] | undefined; +} + +export function computeCsharpArityMetadata(fnNode: SyntaxNode): CsharpArityMetadata { + const params = csharpMethodConfig.extractParameters?.(fnNode) ?? []; + + let hasVariadic = false; + let optionalCount = 0; + const types: string[] = []; + for (const p of params) { + if (p.isVariadic) hasVariadic = true; + else if (p.isOptional) optionalCount++; + if (p.type !== null) types.push(p.type); + } + if (hasVariadic) types.push('params'); + + const total = params.length; + // `params int[] args` declares one formal param but accepts any arg + // count ≥ required — mirror Python's treatment of `*args` and leave + // `parameterCount` undefined so the registry treats max as unknown. + const parameterCount = hasVariadic ? undefined : total; + const requiredParameterCount = hasVariadic ? undefined : total - optionalCount; + + return { + parameterCount, + requiredParameterCount, + parameterTypes: types.length > 0 ? types : undefined, + }; +} diff --git a/gitnexus/src/core/ingestion/languages/csharp/arity.ts b/gitnexus/src/core/ingestion/languages/csharp/arity.ts new file mode 100644 index 000000000..b80dc602e --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/csharp/arity.ts @@ -0,0 +1,44 @@ +/** + * C# arity check, accommodating `params` variadic and default parameters. + * + * The `def` metadata we care about (synthesized by `arity-metadata.ts`): + * - `parameterCount` — total formal parameters; `undefined` + * when the method has `params T[]` variadic. + * - `requiredParameterCount` — min required (excludes defaulted params + * and `params` variadic). + * - `parameterTypes` — declared type strings; contains the + * literal `'params'` when the method is + * variadic. + * + * Verdicts: + * - `'compatible'` — `requiredParameterCount <= argCount <= parameterCount`, + * OR the def takes `params` (then any `argCount >= required`). + * - `'incompatible'` — argCount is below required, OR above max with no variadic. + * - `'unknown'` — metadata is absent / incomplete. + * + * `'incompatible'` is a soft signal in `Registry.lookup` (penalized but + * still considered when no compatible candidate exists), per RFC §4. + */ + +import type { Callsite, SymbolDefinition } from 'gitnexus-shared'; + +export function csharpArityCompatibility( + def: SymbolDefinition, + callsite: Callsite, +): 'compatible' | 'unknown' | 'incompatible' { + const max = def.parameterCount; + const min = def.requiredParameterCount; + if (max === undefined && min === undefined) return 'unknown'; + + const argCount = callsite.arity; + if (!Number.isFinite(argCount) || argCount < 0) return 'unknown'; + + const hasVarArgs = + def.parameterTypes !== undefined && + def.parameterTypes.some((t) => t === 'params' || t.startsWith('params ')); + + if (min !== undefined && argCount < min) return 'incompatible'; + if (max !== undefined && argCount > max && !hasVarArgs) return 'incompatible'; + + return 'compatible'; +} diff --git a/gitnexus/src/core/ingestion/languages/csharp/cache-stats.ts b/gitnexus/src/core/ingestion/languages/csharp/cache-stats.ts new file mode 100644 index 000000000..c3bf9f1f1 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/csharp/cache-stats.ts @@ -0,0 +1,30 @@ +/** + * Dev-mode counters for the cross-phase scope-captures parse cache + * (C# mirror of `languages/python/cache-stats.ts`). + * + * Gated by `PROF_SCOPE_RESOLUTION=1`. Production builds fold every + * increment into dead code via the module-level `PROF` constant, so + * the hot path in `captures.ts` stays branch-free. + */ + +const PROF = process.env.PROF_SCOPE_RESOLUTION === '1'; + +let CACHE_HITS = 0; +let CACHE_MISSES = 0; + +export function recordCacheHit(): void { + if (PROF) CACHE_HITS++; +} + +export function recordCacheMiss(): void { + if (PROF) CACHE_MISSES++; +} + +export function getCsharpCaptureCacheStats(): { hits: number; misses: number } { + return { hits: CACHE_HITS, misses: CACHE_MISSES }; +} + +export function resetCsharpCaptureCacheStats(): void { + CACHE_HITS = 0; + CACHE_MISSES = 0; +} diff --git a/gitnexus/src/core/ingestion/languages/csharp/captures.ts b/gitnexus/src/core/ingestion/languages/csharp/captures.ts new file mode 100644 index 000000000..2f913a14e --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/csharp/captures.ts @@ -0,0 +1,308 @@ +/** + * `emitScopeCaptures` for C#. + * + * Drives the C# scope query against tree-sitter-c-sharp and groups raw + * matches into `CaptureMatch[]` for the central extractor. Layers one + * synthesized stream on top today: + * + * 1. **Decomposed using directives** — each `using_directive` is + * re-emitted with `@import.kind/source/name/alias` markers so + * `interpretCsharpImport` can recover the ParsedImport shape + * without re-parsing raw text (see `import-decomposer.ts`). + * + * Receiver-binding synthesis (`this` / `base` type anchors) and arity + * metadata synthesis (Unit 5) layer on top later. + * + * Pure given the input source text. No I/O, no globals consulted. + */ + +import type { Capture, CaptureMatch } from 'gitnexus-shared'; +import { findNodeAtRange, nodeToCapture, syntheticCapture } from '../../utils/ast-helpers.js'; +import { splitUsingDirective } from './import-decomposer.js'; +import { computeCsharpArityMetadata } from './arity-metadata.js'; +import { synthesizeCsharpReceiverBinding } from './receiver-binding.js'; +import { getCsharpParser, getCsharpScopeQuery } from './query.js'; +import { recordCacheHit, recordCacheMiss } from './cache-stats.js'; +import { getTreeSitterBufferSize } from '../../constants.js'; + +/** Declaration anchors that carry function-like arity metadata. */ +const FUNCTION_DECL_TAGS = [ + '@declaration.method', + '@declaration.constructor', + '@declaration.function', +] as const; + +/** tree-sitter-c-sharp node types that the method extractor accepts. */ +const FUNCTION_NODE_TYPES = [ + 'method_declaration', + 'constructor_declaration', + 'destructor_declaration', + 'operator_declaration', + 'conversion_operator_declaration', + 'local_function_statement', +] as const; + +export function emitCsharpScopeCaptures( + sourceText: string, + _filePath: string, + cachedTree?: unknown, +): readonly CaptureMatch[] { + // Skip the parse when the caller (parse phase's scopeTreeCache) + // already produced a Tree for this source. Cache miss = re-parse, + // same as before. The cachedTree parameter is typed as `unknown` at + // the LanguageProvider contract layer; cast here at the use site. + let tree = cachedTree as ReturnType['parse']> | undefined; + if (tree === undefined) { + tree = getCsharpParser().parse(sourceText, undefined, { + bufferSize: getTreeSitterBufferSize(sourceText), + }); + recordCacheMiss(); + } else { + recordCacheHit(); + } + + const rawMatches = getCsharpScopeQuery().matches(tree.rootNode); + const out: CaptureMatch[] = []; + + for (const m of rawMatches) { + // Group captures by their tag name. Tree-sitter strips the leading + // `@`; we put it back so the central extractor's prefix lookups + // (`@scope.`, `@declaration.`, …) work. + const grouped: Record = {}; + for (const c of m.captures) { + const tag = '@' + c.name; + grouped[tag] = nodeToCapture(tag, c.node); + } + if (Object.keys(grouped).length === 0) continue; + + // Decompose each `using_directive` so `interpretCsharpImport` sees + // the kind/source/name/alias markers it consumes. Raw query match + // only carries the @import.statement anchor. + if (grouped['@import.statement'] !== undefined) { + const stmtCapture = grouped['@import.statement']; + const stmtNode = findNodeAtRange(tree.rootNode, stmtCapture.range, 'using_directive'); + if (stmtNode !== null) { + const decomposed = splitUsingDirective(stmtNode); + if (decomposed !== null) { + out.push(decomposed); + continue; + } + } + // Defensive fallback: emit the raw match so the extractor at + // least sees an anchor, even without markers. + out.push(grouped); + continue; + } + + // Synthesize `this` / `base` receiver type-bindings on every + // instance method-like. Tree-sitter can't cleanly express "the + // implicit receiver of a non-static member of a class/struct/ + // record/interface" via a static `.scm` pattern, so we walk up + // the AST in code. Mirrors Python's `self`/`cls` synthesis on + // `@scope.function` matches. + if (grouped['@scope.function'] !== undefined) { + out.push(grouped); + const anchor = grouped['@scope.function']!; + const fnNode = findFunctionNode(tree.rootNode, anchor.range); + if (fnNode !== null) { + for (const synth of synthesizeCsharpReceiverBinding(fnNode)) { + out.push(synth); + } + } + continue; + } + + // Synthesize arity metadata on function-like declarations so the + // registry can narrow overloads (C# relies heavily on this). Mirrors + // Python's captures.ts pattern — one anchor per match, so we find + // the first tag that matches. + const declTag = FUNCTION_DECL_TAGS.find((t) => grouped[t] !== undefined); + if (declTag !== undefined) { + const anchor = grouped[declTag]!; + const fnNode = findFunctionNode(tree.rootNode, anchor.range); + if (fnNode !== null) { + const arity = computeCsharpArityMetadata(fnNode); + if (arity.parameterCount !== undefined) { + grouped['@declaration.parameter-count'] = syntheticCapture( + '@declaration.parameter-count', + fnNode, + String(arity.parameterCount), + ); + } + if (arity.requiredParameterCount !== undefined) { + grouped['@declaration.required-parameter-count'] = syntheticCapture( + '@declaration.required-parameter-count', + fnNode, + String(arity.requiredParameterCount), + ); + } + if (arity.parameterTypes !== undefined) { + grouped['@declaration.parameter-types'] = syntheticCapture( + '@declaration.parameter-types', + fnNode, + JSON.stringify(arity.parameterTypes), + ); + } + } + } + + // Synthesize `@reference.arity` on every callsite so the + // registry's arity filter can narrow overloads. Count the + // `argument` named children of the backing `argument_list`. + // Python doesn't synthesize this today; C# needs it because the + // language has method overloading and the suite asserts overload + // resolution. + const callTag = ( + ['@reference.call.free', '@reference.call.member', '@reference.call.constructor'] as const + ).find((t) => grouped[t] !== undefined); + if (callTag !== undefined && grouped['@reference.arity'] === undefined) { + const anchor = grouped[callTag]!; + const callNode = + findNodeAtRange(tree.rootNode, anchor.range, 'invocation_expression') ?? + findNodeAtRange(tree.rootNode, anchor.range, 'object_creation_expression'); + if (callNode !== null) { + const argList = callNode.childForFieldName('arguments'); + const args = + argList === null + ? [] + : argList.namedChildren.filter((c) => c !== null && c.type === 'argument'); + grouped['@reference.arity'] = syntheticCapture( + '@reference.arity', + callNode, + String(args.length), + ); + + // Infer argument types from literal nodes so overload + // disambiguation can narrow same-arity candidates by param + // type. Non-literal arguments emit empty string to indicate + // "unknown" — consumers treat unknown as any-match. + const argTypes = args.map((arg) => inferArgType(arg!)); + grouped['@reference.parameter-types'] = syntheticCapture( + '@reference.parameter-types', + callNode, + JSON.stringify(argTypes), + ); + } + } + + out.push(grouped); + + // Synthesize primary-constructor declarations on class/record + // declarations that carry a `parameter_list` child (C# 12 syntax + // `public class User(string name, int age) { ... }` or + // `public record Person(string FirstName, string LastName)`). + // Legacy `csharpMethodConfig.extractPrimaryConstructor` runs via + // the parse phase; the scope-resolution path needs its own emit so + // `new User(...)` resolves to a Constructor def in memberByOwner. + if ( + grouped['@declaration.class'] !== undefined || + grouped['@declaration.record'] !== undefined + ) { + const anchor = grouped['@declaration.class'] ?? grouped['@declaration.record']!; + const typeNode = + findNodeAtRange(tree.rootNode, anchor.range, 'class_declaration') ?? + findNodeAtRange(tree.rootNode, anchor.range, 'record_declaration'); + if (typeNode !== null) { + const synth = synthesizePrimaryConstructor(typeNode); + if (synth !== null) out.push(synth); + } + } + } + + return out; +} + +/** C# 12 primary constructor: `class X(a, b) { }` / `record X(a, b)`. + * The parameters are a bare `parameter_list` named child of the type + * declaration (no `constructor_declaration` node). Emit a synthetic + * @declaration.constructor match so the extractor creates a + * Constructor def in memberByOwner — free-call-fallback's + * `pickConstructorOrClass` then targets it for `new X(...)` calls. */ +function synthesizePrimaryConstructor(typeNode: SyntaxNode): CaptureMatch | null { + // Skip types with an explicit constructor_declaration — that would + // create duplicate defs. + const body = typeNode.childForFieldName('body'); + if (body !== null) { + for (let i = 0; i < body.namedChildCount; i++) { + const child = body.namedChild(i); + if (child !== null && child.type === 'constructor_declaration') return null; + } + } + let paramList: SyntaxNode | null = null; + for (let i = 0; i < typeNode.namedChildCount; i++) { + const child = typeNode.namedChild(i); + if (child !== null && child.type === 'parameter_list') { + paramList = child; + break; + } + } + if (paramList === null) return null; + + const nameNode = typeNode.childForFieldName('name'); + if (nameNode === null) return null; + + const paramCount = paramList.namedChildren.filter( + (c) => c !== null && c.type === 'parameter', + ).length; + + const m: Record = { + '@declaration.constructor': nodeToCapture('@declaration.constructor', paramList), + '@declaration.name': syntheticCapture('@declaration.name', nameNode, nameNode.text), + '@declaration.parameter-count': syntheticCapture( + '@declaration.parameter-count', + paramList, + String(paramCount), + ), + '@declaration.required-parameter-count': syntheticCapture( + '@declaration.required-parameter-count', + paramList, + String(paramCount), + ), + }; + return m; +} + +type SyntaxNode = ReturnType['parse']>['rootNode']; + +/** Infer a C# argument's static type from literal / constructor + * patterns. Returns `''` when the arg has no statically-derivable + * type (e.g. identifier — would require full type inference). */ +function inferArgType(argNode: SyntaxNode): string { + // `argument > expression` — tree-sitter-c-sharp wraps the value. + const expr = argNode.namedChild(0); + if (expr === null) return ''; + switch (expr.type) { + case 'integer_literal': + return 'int'; + case 'real_literal': + return 'double'; + case 'string_literal': + case 'verbatim_string_literal': + case 'interpolated_string_expression': + case 'raw_string_literal': + return 'string'; + case 'character_literal': + return 'char'; + case 'boolean_literal': + return 'bool'; + case 'null_literal': + return 'null'; + case 'object_creation_expression': { + const typeNode = expr.childForFieldName('type'); + return typeNode?.text ?? ''; + } + default: + return ''; + } +} + +/** Find the first C# function-like node at the given range. The + * declaration anchor range covers the whole method/constructor/etc. + * node, but the tag alone doesn't tell us which node type. */ +function findFunctionNode(rootNode: SyntaxNode, range: Capture['range']): SyntaxNode | null { + for (const nodeType of FUNCTION_NODE_TYPES) { + const n = findNodeAtRange(rootNode, range, nodeType); + if (n !== null) return n as SyntaxNode; + } + return null; +} diff --git a/gitnexus/src/core/ingestion/languages/csharp/import-decomposer.ts b/gitnexus/src/core/ingestion/languages/csharp/import-decomposer.ts new file mode 100644 index 000000000..fab91f89d --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/csharp/import-decomposer.ts @@ -0,0 +1,115 @@ +/** + * Decompose a C# `using_directive` into a `CaptureMatch` carrying the + * synthesized markers `@import.kind` / `@import.source` / `@import.name` + * / `@import.alias` that `interpretCsharpImport` consumes. + * + * Unlike Python's decomposer this is 1:1 — each `using` produces exactly + * one import. The split layer exists to expose the kind (namespace vs + * alias vs static) without pushing raw-text parsing into `interpret.ts`. + * + * using System; → namespace + * using System.Collections.Generic; → namespace + * using Foo = System.Bar; → alias + * using static System.Math; → static + * global using System.IO; → namespace (treated as file-scoped) + * using global::System.IO; → namespace (global:: alias stripped) + */ + +import type { Capture, CaptureMatch } from 'gitnexus-shared'; +import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; + +type ImportKind = 'namespace' | 'alias' | 'static'; + +interface ImportSpec { + readonly kind: ImportKind; + /** Full dotted path (generics stripped): `System.Collections.Generic`. */ + readonly source: string; + /** Local binding name — last source segment for namespace/static, + * the alias for alias imports. */ + readonly name: string; + /** Present iff `kind === 'alias'`. */ + readonly alias?: string; + /** Node to anchor the synthesized captures (range-wise). */ + readonly atNode: SyntaxNode; +} + +export function splitUsingDirective(stmtNode: SyntaxNode): CaptureMatch | null { + if (stmtNode.type !== 'using_directive') return null; + const spec = parseUsingDirective(stmtNode); + if (spec === null) return null; + return buildImportMatch(stmtNode, spec); +} + +function parseUsingDirective(node: SyntaxNode): ImportSpec | null { + // tree-sitter-c-sharp's using_directive exposes named children + // corresponding to the parts of the directive but omits keyword tokens + // (`using`, `static`, `global`) from the named-child list. We inspect + // the raw source text to detect the flavor — the grammar doesn't give + // us a cleaner signal. + const raw = node.text; + + // Named child layout: + // namespace form: [pathNode] + // alias form: [aliasIdNode, pathNode] (name field = aliasId) + // static form: [pathNode] (same as namespace) + // global using: [pathNode] (same as namespace) + const aliasField = node.childForFieldName('name'); + const children = node.namedChildren; + if (children.length === 0) return null; + + // Alias form — the `name:` field is the alias identifier; the + // remaining named child is the type/namespace path. + if (aliasField !== null) { + const pathNode = children.find((c) => c !== null && c.startIndex !== aliasField.startIndex) as + | SyntaxNode + | undefined; + if (pathNode === undefined) return null; + return { + kind: 'alias', + source: stripGenericArgs(unwrapGlobalAlias(pathNode.text)), + name: aliasField.text, + alias: aliasField.text, + atNode: node, + }; + } + + const pathNode = children[0]; + if (pathNode === null) return null; + const source = stripGenericArgs(unwrapGlobalAlias(pathNode.text)); + if (source === '') return null; + const lastSegment = source.split('.').pop() ?? source; + + // `using static X.Y;` — detect by scanning the raw text before the path. + // `global using` behaves semantically as a file-scoped using for our + // purposes, so it isn't a separate kind here. + if (/^\s*(?:global\s+)?using\s+static\s/.test(raw)) { + return { kind: 'static', source, name: '*', atNode: node }; + } + + return { kind: 'namespace', source, name: lastSegment, atNode: node }; +} + +/** Strip `global::` prefix — `global::System.IO` → `System.IO`. */ +function unwrapGlobalAlias(text: string): string { + return text.replace(/^global::/, ''); +} + +/** Strip generic type arguments — `Dictionary` → `Dictionary`. */ +function stripGenericArgs(text: string): string { + const lt = text.indexOf('<'); + if (lt === -1) return text; + return text.slice(0, lt); +} + +function buildImportMatch(stmtNode: SyntaxNode, spec: ImportSpec): CaptureMatch { + const m: Record = { + '@import.statement': nodeToCapture('@import.statement', stmtNode), + '@import.kind': syntheticCapture('@import.kind', spec.atNode, spec.kind), + '@import.source': syntheticCapture('@import.source', spec.atNode, spec.source), + '@import.name': syntheticCapture('@import.name', spec.atNode, spec.name), + }; + if (spec.alias !== undefined) { + m['@import.alias'] = syntheticCapture('@import.alias', spec.atNode, spec.alias); + } + return m; +} diff --git a/gitnexus/src/core/ingestion/languages/csharp/import-target.ts b/gitnexus/src/core/ingestion/languages/csharp/import-target.ts new file mode 100644 index 000000000..8183bd2a4 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/csharp/import-target.ts @@ -0,0 +1,130 @@ +/** + * Adapter from `(ParsedImport, WorkspaceIndex)` → concrete file path. + * + * Unit 2 shape: suffix-match against the repo's `.cs` files. Each + * `using System.Collections.Generic;` could legally expand to multiple + * files (every `.cs` that declares `namespace System.Collections.Generic` + * — partial classes, assembly-wide namespaces). The scope-resolver + * contract returns a single primary target, so we pick the first + * match. Cross-file partial-class aggregation runs at graph-bridge + * time (Unit 6) via `populateOwners`. + * + * The legacy csproj-based `resolveCSharpImportInternal` needs config + * objects the scope-resolver doesn't carry; the Unit 7 parity gate + * will surface cases where the suffix-match diverges from the + * namespace-based resolver and we'll adjust the contract if needed. + * + * Returning `null` lets the finalize algorithm mark the edge as + * `linkStatus: 'unresolved'`. + */ + +import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared'; + +export interface CsharpResolveContext { + readonly fromFile: string; + readonly allFilePaths: ReadonlySet; +} + +export function resolveCsharpImportTarget( + parsedImport: ParsedImport, + workspaceIndex: WorkspaceIndex, +): string | null { + // WorkspaceIndex is `unknown` in the shared contract (Ring 1 + // placeholder). The scope-resolution orchestrator hands us a + // CsharpResolveContext-shaped object; narrow structurally rather + // than via a cast chain so unexpected shapes return null cleanly. + const ctx = workspaceIndex as CsharpResolveContext | undefined; + if ( + ctx === undefined || + typeof (ctx as { fromFile?: unknown }).fromFile !== 'string' || + !((ctx as { allFilePaths?: unknown }).allFilePaths instanceof Set) + ) { + return null; + } + if (parsedImport.kind === 'dynamic-unresolved') return null; + if (parsedImport.targetRaw === null || parsedImport.targetRaw === '') return null; + + // Namespace path: `System.Collections.Generic` → `System/Collections/Generic`. + const pathLike = parsedImport.targetRaw.replace(/\./g, '/'); + const suffix = `/${pathLike}`; + + // Exact file match: `System/Collections/Generic.cs` (rare but legal). + // Suffix match for nested layouts: `src/lib/System/Collections/Generic.cs`. + // Directory match: first `.cs` file directly inside the namespace dir + // (e.g. `System/Collections/Generic/List.cs` matches namespace Generic). + let exactFile: string | null = null; + let suffixFile: string | null = null; + let directoryChild: string | null = null; + const dirPrefix = `${pathLike}/`; + const suffixDirPrefix = `/${dirPrefix}`; + + for (const raw of ctx.allFilePaths) { + const f = raw.replace(/\\/g, '/'); + if (!f.endsWith('.cs')) continue; + if (f === `${pathLike}.cs`) { + exactFile = raw; + break; + } + if (suffixFile === null && f.endsWith(`${suffix}.cs`)) { + suffixFile = raw; + } + if (directoryChild === null) { + // Namespace-to-directory match: pick the first `.cs` directly in + // the namespace dir (not nested deeper). Legacy resolver emits + // all of them; we take one so the scope-resolver contract stays + // single-target. + const atRoot = f.startsWith(dirPrefix); + const atNested = f.includes(suffixDirPrefix); + if (atRoot || atNested) { + const idx = atRoot ? 0 : f.indexOf(suffixDirPrefix) + 1; + const after = f.slice(idx + dirPrefix.length); + if (after.length > 0 && !after.includes('/')) { + directoryChild = raw; + } + } + } + } + + if (exactFile !== null) return exactFile; + if (suffixFile !== null) return suffixFile; + if (directoryChild !== null) return directoryChild; + + // Progressive prefix stripping — mirrors csproj's root-namespace + // mapping without the csproj. `using CrossFile.Models;` in a repo + // laid out `Models/User.cs` (no `CrossFile/` prefix) works because + // the legacy resolver consults csproj; the scope-resolver layer + // doesn't have csproj, so we try each suffix of the namespace path + // against `.cs` files and directories. + // + // Also handles `using static CrossFile.Models.UserFactory;` — + // strip the leading segment, try `Models/UserFactory.cs`; strip + // two, try `UserFactory.cs`. + const segments = pathLike.split('/').filter(Boolean); + for (let skip = 1; skip < segments.length; skip++) { + const tail = segments.slice(skip).join('/'); + if (tail === '') continue; + const tailFile = `${tail}.cs`; + const tailSuffix = `/${tailFile}`; + const tailDir = `${tail}/`; + const tailSuffixDir = `/${tailDir}`; + let tailDirectChild: string | null = null; + for (const raw of ctx.allFilePaths) { + const f = raw.replace(/\\/g, '/'); + if (!f.endsWith('.cs')) continue; + if (f === tailFile) return raw; + if (f.endsWith(tailSuffix)) return raw; + if (tailDirectChild === null) { + const atRoot = f.startsWith(tailDir); + const atNested = f.includes(tailSuffixDir); + if (atRoot || atNested) { + const idx = atRoot ? 0 : f.indexOf(tailSuffixDir) + 1; + const after = f.slice(idx + tailDir.length); + if (after.length > 0 && !after.includes('/')) tailDirectChild = raw; + } + } + } + if (tailDirectChild !== null) return tailDirectChild; + } + + return null; +} diff --git a/gitnexus/src/core/ingestion/languages/csharp/index.ts b/gitnexus/src/core/ingestion/languages/csharp/index.ts new file mode 100644 index 000000000..9f06ce87d --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/csharp/index.ts @@ -0,0 +1,87 @@ +/** + * C# scope-resolution hooks (RFC #909 Ring 3, RFC §5). + * + * Public API barrel. Consumers should import from this file rather than + * the individual modules. + * + * Module layout (each file is a single concern): + * + * - `query.ts` — tree-sitter query + lazy parser/query singletons + * - `captures.ts` — `emitCsharpScopeCaptures` orchestrator + * - `import-decomposer.ts` — each `using` → ParsedImport-shaped captures + * - `interpret.ts` — capture-match → `ParsedImport` / `ParsedTypeBinding` + * - `simple-hooks.ts` — small/no-op hooks made explicit + * - `receiver-binding.ts` — synthesize `this`/`base` type-bindings on + * instance-method entry + * - `merge-bindings.ts` — C# `using` precedence + * - `arity.ts` — C# arity compatibility (`params`, default values) + * - `arity-metadata.ts` — synthesize arity metadata from declarations + * - `accessor-unwrap.ts` — `.Values` / `.Keys` receiver-type unwrap for + * `Dictionary` chains + * - `namespace-siblings.ts` — AST-driven cross-file implicit-namespace + * visibility (file/namespace attribution, no + * regex; reuses orchestrator's treeCache) + * - `import-target.ts` — `(ParsedImport, WorkspaceIndex) → file path` adapter + * - `scope-resolver.ts` — `ScopeResolver` registered in `SCOPE_RESOLVERS` + * - `cache-stats.ts` — PROF_SCOPE_RESOLUTION cache hit/miss counters + * + * ## Known limitations + * + * The C# registry-primary path intentionally does NOT resolve the + * following. Each is a conscious trade-off at migration time. + * + * 1. **csproj-driven namespace resolution** — the legacy path + * consults `csharpConfigs` (the parsed .csproj workspace) to map + * `using X.Y;` back to the exact files declaring `namespace X.Y`. + * The scope-resolver contract passes only `allFilePaths`, so we + * fall back to suffix matching on `.cs` files. Unit 7's parity + * gate flags any divergence. + * 2. **Multi-file namespace expansion** — a single `using X.Y;` in + * the legacy path can emit multiple IMPORTS edges (every file + * declaring that namespace). The scope-resolver contract returns + * a single target, so we pick the first match; partial-class + * aggregation runs at graph-bridge time. + * 3. **Overload resolution by parameter type** — arity narrowing is + * wired (`arity.ts` + `arity-metadata.ts`), but type-based + * disambiguation (`F(int)` vs `F(string)` at a call with a typed + * argument) is left to the registry's type-binding layer. + * 4. **Generic type parameter resolution** — `List` binds the + * bound name to `User` via the single-arg-generic stripper; + * nested generics (`Dictionary>`) fall through the + * receiver-type heuristic. + * 5. **`dynamic` typed expressions** — runtime dispatch through + * `dynamic` is not followed. + * 6. **Preprocessor-conditional code** — `#if DEBUG` blocks parse + * as usual; branch selection is ignored, so both arms contribute + * bindings. + * 7. **Global using propagation across files** — treated as a + * file-scoped using for the declaring file. Unit 7 parity gate + * will flag cases where this matters. + * 8. **Expression-bodied `=>` members** — handled by the method + * extractor, but receiver synthesis for `=> this.Field` shortcuts + * follows the same path as block-bodied methods. + * 9. **Multi-namespace file attribution** — when a single file + * declares two namespaces (rare), all top-level classes are + * attributed to the first declared namespace via a `first-wins` + * rule in `namespace-siblings.ts`. Namespace detection itself is + * AST-driven (tree-sitter), so `global using static`, aliased + * `using static X = Y.Z;`, attributes, and preprocessor-gated + * declarations are all recognized correctly. + * + * Shadow-harness corpus parity is the authoritative signal for which + * of these matter in practice. The CI parity gate blocks any PR that + * regresses either the legacy or registry-primary run of + * `test/integration/resolvers/csharp.test.ts`. + */ + +export { emitCsharpScopeCaptures } from './captures.js'; +export { getCsharpCaptureCacheStats, resetCsharpCaptureCacheStats } from './cache-stats.js'; +export { interpretCsharpImport, interpretCsharpTypeBinding } from './interpret.js'; +export { csharpMergeBindings } from './merge-bindings.js'; +export { csharpArityCompatibility } from './arity.js'; +export { resolveCsharpImportTarget, type CsharpResolveContext } from './import-target.js'; +export { + csharpBindingScopeFor, + csharpImportOwningScope, + csharpReceiverBinding, +} from './simple-hooks.js'; diff --git a/gitnexus/src/core/ingestion/languages/csharp/interpret.ts b/gitnexus/src/core/ingestion/languages/csharp/interpret.ts new file mode 100644 index 000000000..beb2594a9 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/csharp/interpret.ts @@ -0,0 +1,137 @@ +/** + * Capture-match → semantic-shape interpreters for C#. + * + * - `interpretCsharpImport` → `ParsedImport` + * - `interpretCsharpTypeBinding` → `ParsedTypeBinding` + * + * The using-directive matches arrive pre-decomposed by + * `emitCsharpScopeCaptures` (one import per match, with synthesized + * `@import.kind/source/name/alias` markers). Type-binding matches arrive + * from the raw query captures — each `@type-binding.*` anchor carries + * `@type-binding.name` + `@type-binding.type`. + */ + +import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'gitnexus-shared'; + +// ─── interpretImport ────────────────────────────────────────────────────── + +export function interpretCsharpImport(captures: CaptureMatch): ParsedImport | null { + const kindCap = captures['@import.kind']; + const sourceCap = captures['@import.source']; + const nameCap = captures['@import.name']; + const aliasCap = captures['@import.alias']; + + const kind = kindCap?.text; + if (kind === undefined || sourceCap === undefined) return null; + + switch (kind) { + case 'namespace': { + // `using System;` / `using System.Collections.Generic;` + // Bind the last segment as the local name so `Generic.Foo`-style + // qualifier references resolve. Full path is the resolution target. + return { + kind: 'namespace', + localName: nameCap?.text ?? sourceCap.text.split('.').pop() ?? sourceCap.text, + importedName: sourceCap.text, + targetRaw: sourceCap.text, + }; + } + case 'alias': { + // `using Dict = System.Collections.Generic.Dictionary;` + // The decomposer already stripped generic args from source. + if (aliasCap === undefined) return null; + const importedName = sourceCap.text.split('.').pop() ?? sourceCap.text; + return { + kind: 'alias', + localName: aliasCap.text, + importedName, + alias: aliasCap.text, + targetRaw: sourceCap.text, + }; + } + case 'static': { + // `using static System.Math;` — brings static members of Math into + // unqualified scope. Semantically closest to a wildcard, but we + // map to `namespace` here so finalize emits the File→File IMPORTS + // edge without requiring `expandsWildcardTo` (which would list + // every exported member). Static-member unqualified-access is a + // deferred limitation; the usual cross-file lookup via + // namespace-siblings covers `Target.Member` calls. + const lastSegment = sourceCap.text.split('.').pop() ?? sourceCap.text; + return { + kind: 'namespace', + localName: lastSegment, + importedName: sourceCap.text, + targetRaw: sourceCap.text, + }; + } + default: + return null; + } +} + +// ─── interpretTypeBinding ───────────────────────────────────────────────── + +export function interpretCsharpTypeBinding(captures: CaptureMatch): ParsedTypeBinding | null { + const nameCap = captures['@type-binding.name']; + const typeCap = captures['@type-binding.type']; + if (nameCap === undefined || typeCap === undefined) return null; + + // Strip nullable suffix (`User?` → `User`), single-arg generic wrapper + // (`List` → `User`), and qualifier (`System.User` → `User`) so + // receiver-typed resolution treats these identically. + const rawType = stripQualifier(stripGeneric(stripNullable(typeCap.text.trim()))); + + // Anchor captures distinguish the source of the binding. Order + // matters: more-specific anchors take precedence. + let source: TypeRef['source'] = 'parameter-annotation'; + if (captures['@type-binding.self'] !== undefined) source = 'self'; + else if (captures['@type-binding.constructor'] !== undefined) source = 'constructor-inferred'; + else if (captures['@type-binding.annotation'] !== undefined) source = 'annotation'; + else if (captures['@type-binding.alias'] !== undefined) source = 'assignment-inferred'; + else if (captures['@type-binding.return'] !== undefined) source = 'return-annotation'; + + return { boundName: nameCap.text, rawTypeName: rawType, source }; +} + +/** Member accesses we want to preserve through qualifier stripping. + * Dictionary/collection views (`data.Values`, `data.Keys`) survive + * so the compound-receiver pass can unwrap the receiver's generic + * type (Dictionary) based on the suffix. */ +const COLLECTION_ACCESSOR_SUFFIXES = new Set(['Values', 'Keys']); + +/** `User?` → `User`. */ +function stripNullable(text: string): string { + if (text.endsWith('?')) return text.slice(0, -1).trim(); + return text; +} + +/** + * Unwrap a single-arg generic collection wrapper — `List`, + * `IEnumerable`, `Task` — to its element type. Mirrors + * Python's `stripGeneric` behavior so for-loop and chain propagation + * work on the element type. + * + * Multi-arg generics (`Dictionary`, `Func`) + * are left alone — element semantics aren't unambiguous. + */ +function stripGeneric(text: string): string { + const single = text.match( + /^(?:[A-Za-z_][A-Za-z0-9_.]*\.)?(?:List|IList|IEnumerable|ICollection|IReadOnlyList|IReadOnlyCollection|HashSet|ISet|Task|ValueTask|Nullable|IAsyncEnumerable)<([^,<>]+)>$/, + ); + if (single !== null) return single[1].trim(); + return text; +} + +/** `System.Collections.User` → `User`. Preserves dotted paths whose + * final segment is a known Dictionary/collection accessor (`.Values`, + * `.Keys`, `.Count`, etc.) so downstream resolvers can unwrap the + * receiver's generic type based on the suffix — `data.Values` → + * element type of `data`'s Dictionary. */ +function stripQualifier(text: string): string { + const lastDot = text.lastIndexOf('.'); + if (lastDot === -1) return text; + const tail = text.slice(lastDot + 1); + if (COLLECTION_ACCESSOR_SUFFIXES.has(tail)) return text; + return tail; +} diff --git a/gitnexus/src/core/ingestion/languages/csharp/merge-bindings.ts b/gitnexus/src/core/ingestion/languages/csharp/merge-bindings.ts new file mode 100644 index 000000000..dcd1a6f68 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/csharp/merge-bindings.ts @@ -0,0 +1,59 @@ +/** + * C# shadowing precedence for the `mergeBindings` hook. + * + * Tier ranking (lower wins in shadowing): + * + * - 0: `local` — a class member, method, local variable, or parameter + * declared in this scope. + * - 1: `import` / `namespace` / `reexport` — `using System;`, + * `using System.Collections.Generic;`, `using Alias = Foo;`. + * All three using flavors that introduce a name at this scope + * tier together; the compiler resolves ambiguity by requiring + * an explicit qualifier when two `using`s collide, but for + * receiver-typed dispatch we treat them as equivalent tiers. + * - 2: `wildcard` — `using static System.Math;` brings static + * members in; any local or `using` with the same simple name + * shadows. + * + * Explicit interface implementations (`void IFoo.Bar() { }`) bind under + * the qualified name in the extractor layer, so they never collide with + * a plain `Bar` at this layer. + * + * Within a surviving tier we de-dup by `DefId`, last-write-wins so a + * `using` re-declared further down the file cleanly replaces the + * earlier binding. + */ + +import type { BindingRef } from 'gitnexus-shared'; + +const TIER_LOCAL = 0; +const TIER_IMPORT = 1; +const TIER_WILDCARD = 2; +const TIER_UNKNOWN = 3; + +function tierOf(b: BindingRef): number { + switch (b.origin) { + case 'local': + return TIER_LOCAL; + case 'reexport': + case 'import': + case 'namespace': + return TIER_IMPORT; + case 'wildcard': + return TIER_WILDCARD; + default: + return TIER_UNKNOWN; + } +} + +export function csharpMergeBindings(bindings: readonly BindingRef[]): readonly BindingRef[] { + if (bindings.length === 0) return bindings; + + let bestTier = Number.POSITIVE_INFINITY; + for (const b of bindings) bestTier = Math.min(bestTier, tierOf(b)); + const survivors = bindings.filter((b) => tierOf(b) === bestTier); + + const seen = new Map(); + for (const b of survivors) seen.set(b.def.nodeId, b); + return [...seen.values()]; +} diff --git a/gitnexus/src/core/ingestion/languages/csharp/namespace-siblings.ts b/gitnexus/src/core/ingestion/languages/csharp/namespace-siblings.ts new file mode 100644 index 000000000..2cd2cf724 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/csharp/namespace-siblings.ts @@ -0,0 +1,419 @@ +/** + * C# same-namespace cross-file visibility. + * + * C# makes every type declared in `namespace X` visible to every other + * file that also declares `namespace X`, without any explicit `using` + * directive. Python has no equivalent — every cross-file reference + * needs an explicit import — so this is a C#-specific pass. + * + * Without this: `Service.cs` (namespace `FieldTypes`) can't see + * `User` declared in `Models.cs` (same namespace), so `user.Address` + * field-chain resolution fails at `findClassBindingInScope('User')` + * in the Service.cs scope chain. + * + * Implementation: after the finalize pass populates immutable + * `indexes.bindings` (from explicit `using` directives), walk each + * file's tree-sitter AST for `namespace_declaration` / + * `file_scoped_namespace_declaration` and `using_directive` nodes. + * The orchestrator hands us its `treeCache` so files already parsed + * by `extractParsedFile` are re-used instead of re-parsed — + * `ParsedFile`'s underlying tree is the single source of truth. + * Group classes by namespace, and append cross-file sibling classes + * into each Namespace scope's `bindingAugmentations` bucket with + * `origin: 'namespace'`. Finalized bindings remain first in + * `lookupBindingsAt`, and local lexical `Scope.bindings` remains the + * first-tier shadowing channel. + * + * The tree-sitter walk is authoritative: it sees `global using static`, + * aliased `using static X = Y.Z;`, attributed namespace declarations, + * and preprocessor-guarded declarations correctly because the + * tree-sitter grammar parses them as real nodes (not textual + * coincidences). + */ + +import type { SyntaxNode } from 'tree-sitter'; +import type { BindingRef, ParsedFile, Scope, ScopeId, SymbolDefinition } from 'gitnexus-shared'; +import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; +import { getCsharpParser } from './query.js'; +import { getTreeSitterBufferSize } from '../../constants.js'; + +interface CsharpFileStructure { + /** Declared namespace names in file source order. Empty array means + * the file has no `namespace X;` / `namespace X { }` declaration + * and sits in the default (global) namespace. */ + readonly namespaces: readonly string[]; + /** Dotted paths from `using static X.Y.Z;` (including + * `global using static` and aliased `using static A = X.Y.Z;`). */ + readonly usingStaticPaths: readonly string[]; +} + +/** Build a structural view of a C# file by walking the tree-sitter + * AST. Prefers `cachedTree` (handed in via `treeCache`) so we don't + * re-parse files the orchestrator already parsed for `extractParsedFile`; + * falls back to a fresh parse on cache miss. Parser singleton is + * shared across calls. */ +function extractFileStructure(content: string, cachedTree: unknown): CsharpFileStructure { + type CsharpTree = ReturnType['parse']>; + const tree = + (cachedTree as CsharpTree | undefined) ?? + getCsharpParser().parse(content, undefined, { + bufferSize: getTreeSitterBufferSize(content), + }); + const namespaces: string[] = []; + const usingStaticPaths: string[] = []; + + const visit = (node: SyntaxNode): void => { + if ( + node.type === 'namespace_declaration' || + node.type === 'file_scoped_namespace_declaration' + ) { + const nameNode = node.childForFieldName('name'); + if (nameNode !== null) namespaces.push(nameNode.text); + } else if (node.type === 'using_directive') { + // Inspect the directive's own text for the `static` keyword + // (tree-sitter-c-sharp does not expose it as a named child). + // This is a single-node-scoped text inspection, not a whole-file + // regex, so it stays well within AST semantics. + if (/^\s*(?:global\s+)?using\s+static\s/.test(node.text)) { + // Path lives on the `name:` field when the using-directive is + // aliased (`using static A = X.Y.Z;`); otherwise it's the + // first named child. + const aliasField = node.childForFieldName('name'); + let pathNode: SyntaxNode | null = null; + if (aliasField !== null) { + for (const c of node.namedChildren) { + if (c !== null && c.startIndex !== aliasField.startIndex) { + pathNode = c; + break; + } + } + } else { + pathNode = node.namedChildren[0] ?? null; + } + if (pathNode !== null) usingStaticPaths.push(pathNode.text); + } + } + for (const child of node.namedChildren) { + if (child !== null) visit(child); + } + }; + + visit(tree.rootNode); + return { namespaces, usingStaticPaths }; +} + +/** Content + (optional) pre-parsed tree-sitter trees keyed by filePath. + * The orchestrator builds `fileContents` from the pipeline's file list; + * `treeCache` is the same `scopeTreeCache` already populated by the + * parse phase, so cache hits avoid a second `parser.parse()`. */ +export interface CsharpSiblingInputs { + readonly fileContents: ReadonlyMap; + readonly treeCache?: { get(filePath: string): unknown }; +} + +/** + * Append cross-file sibling class defs to each Namespace scope's + * `bindingAugmentations` bucket. Class-like defs (Class / Interface / + * Struct / Record / Enum) are visible cross-file; method / field + * members are not. + */ +export function populateCsharpNamespaceSiblings( + parsedFiles: readonly ParsedFile[], + indexes: ScopeResolutionIndexes, + inputs: CsharpSiblingInputs, +): void { + // Build a structural view (namespaces + using-static paths) per + // file once up-front. Reuses the orchestrator's `treeCache` so + // files already parsed by `extractParsedFile` don't get re-parsed + // here — single-source-of-truth for the AST. + const structureByFile = new Map(); + for (const parsed of parsedFiles) { + const content = inputs.fileContents.get(parsed.filePath); + if (content === undefined) continue; + const cachedTree = inputs.treeCache?.get(parsed.filePath); + structureByFile.set(parsed.filePath, extractFileStructure(content, cachedTree)); + } + + // Group namespace scopes by their dotted name. Each entry carries + // the scope id so we can inject bindings post-hoc, plus the + // file's own class-like defs for cross-pollination. + interface NamespaceBucket { + readonly scopes: { filePath: string; scopeId: ScopeId; scope: Scope }[]; + readonly classDefs: SymbolDefinition[]; + } + const buckets = new Map(); + const getBucket = (name: string): NamespaceBucket => { + let b = buckets.get(name); + if (b === undefined) { + b = { scopes: [], classDefs: [] }; + buckets.set(name, b); + } + return b; + }; + + for (const parsed of parsedFiles) { + const struct = structureByFile.get(parsed.filePath); + if (struct === undefined) continue; + + // Declared namespace names, source order (AST walk visits children + // left-to-right, matching the scope-extractor's ordering). + const names = struct.namespaces.length > 0 ? [...struct.namespaces] : ['']; + + const namespaceScopes = parsed.scopes.filter((s) => s.kind === 'Namespace'); + // With file-scoped namespaces (`namespace X;`), the Namespace + // scope's range covers only the declaration line, not the rest of + // the file — so classes below it land under the Module scope, not + // the Namespace scope. Group top-level classes by "any class whose + // parent scope is Module or Namespace" and attribute them to the + // first declared namespace in the file. Multiple-namespace files + // are rare enough that first-wins is the right first pass; fix + // when the parity suite surfaces a case. + const moduleScope = parsed.scopes.find((s) => s.kind === 'Module'); + const topLevelParentIds = new Set(); + if (moduleScope !== undefined) topLevelParentIds.add(moduleScope.id); + for (const ns of namespaceScopes) topLevelParentIds.add(ns.id); + + // Attribute all top-level classes to the first-declared namespace + // in this file. Multiple-namespace files are rare and can be + // addressed if the parity suite surfaces a case. Inject into BOTH + // the Module and the Namespace scopes — the Module scope is on + // the ancestor chain of every function body (the Namespace scope + // is not, because file-scoped `namespace X;` has a 1-line range). + const firstName = names[0]!; + const bucket = getBucket(firstName); + if (moduleScope !== undefined) { + bucket.scopes.push({ + filePath: parsed.filePath, + scopeId: moduleScope.id, + scope: moduleScope, + }); + } + for (const ns of namespaceScopes) { + bucket.scopes.push({ filePath: parsed.filePath, scopeId: ns.id, scope: ns }); + } + + for (const s of parsed.scopes) { + if (s.kind !== 'Class') continue; + if (s.parent === null || !topLevelParentIds.has(s.parent)) continue; + for (const def of s.ownedDefs) { + if (isTypeDef(def)) { + bucket.classDefs.push(def); + break; + } + } + } + } + + // Inject cross-file siblings into each namespace scope's + // post-finalize augmentation channel (per I8). The + // `indexes.bindingAugmentations` map is the dedicated mutable + // append-only buffer for post-finalize hooks: inner `BindingRef[]` + // arrays here are NEVER frozen (unlike `indexes.bindings`, which + // `materializeBindings` freezes). Walkers consult both channels + // via `lookupBindingsAt`; we never need to consult or mutate + // `indexes.bindings`. + const augmentations = indexes.bindingAugmentations as Map>; + + // Cross-namespace type-binding propagation: for each file, mirror + // method return-type bindings from same-namespace sibling files and + // from files in namespaces the importer `using`s, into the + // importer's Module scope typeBindings. This enables + // chain-follow from `var u = svc.GetUser()` → `GetUser → User` + // even across files — without it the chain stalls at `GetUser` + // because the return binding lives in the defining file's Module + // scope, which isn't an ancestor of the importer's scope chain. + for (const parsed of parsedFiles) { + const moduleScope = parsed.scopes.find((s) => s.kind === 'Module'); + if (moduleScope === undefined) continue; + const moduleTypeBindings = moduleScope.typeBindings as Map< + string, + import('gitnexus-shared').TypeRef + >; + + // Accessible namespaces = this file's own namespaces + every + // `using namespace X;` target. Source of truth is the cached AST + // structure captured above. + const accessibleNamespaces = new Set(); + const struct = structureByFile.get(parsed.filePath); + if (struct !== undefined) { + for (const n of struct.namespaces) accessibleNamespaces.add(n); + } + if (accessibleNamespaces.size === 0) accessibleNamespaces.add(''); + for (const imp of parsed.parsedImports) { + if (imp.kind === 'namespace' && imp.targetRaw !== null) { + accessibleNamespaces.add(imp.targetRaw); + } + } + + // For each accessible namespace, also walk up the dotted path — + // `using static X.Y.Z;` targets a type, so the real namespace is + // `X.Y`. Both parse into `accessibleNamespaces` as-is; we probe + // the bucket map with every prefix. + const expandedNamespaces = new Set(accessibleNamespaces); + for (const ns of accessibleNamespaces) { + const segments = ns.split('.'); + for (let i = segments.length - 1; i > 0; i--) { + expandedNamespaces.add(segments.slice(0, i).join('.')); + } + } + + for (const nsName of expandedNamespaces) { + const bucket = buckets.get(nsName); + if (bucket === undefined) continue; + for (const scopeInfo of bucket.scopes) { + if (scopeInfo.filePath === parsed.filePath) continue; + if (scopeInfo.scope.kind !== 'Module') continue; + for (const [boundName, typeRef] of scopeInfo.scope.typeBindings) { + if (moduleTypeBindings.has(boundName)) continue; + moduleTypeBindings.set(boundName, typeRef); + } + } + } + } + + // `using static X.Y.Z;` — expose every public static method of + // class Z as a free-callable binding in the importer's module + // scope, so `Record(...)` (without `Logger.` qualifier) resolves + // to `Logger.Record`. AST walk above captured these (including + // `global using static` and aliased forms). + for (const parsed of parsedFiles) { + const struct = structureByFile.get(parsed.filePath); + if (struct === undefined) continue; + const moduleScope = parsed.scopes.find((s) => s.kind === 'Module'); + if (moduleScope === undefined) continue; + + for (const fullPath of struct.usingStaticPaths) { + const lastDot = fullPath.lastIndexOf('.'); + if (lastDot === -1) continue; + const className = fullPath.slice(lastDot + 1); + const enclosingNs = fullPath.slice(0, lastDot); + + // Find the target class in the named namespace bucket. + const bucket = buckets.get(enclosingNs); + if (bucket === undefined) continue; + const targetDef = bucket.classDefs.find((d) => { + const q = d.qualifiedName ?? ''; + const simple = q.includes('.') ? q.slice(q.lastIndexOf('.') + 1) : q; + return simple === className; + }); + if (targetDef === undefined) continue; + + // Inject the class's member methods into the importer's module + // scope. `memberByOwner` wasn't built yet here, so we walk the + // file's localDefs to find members with `ownerId === targetDef.nodeId`. + const targetFile = parsedFiles.find((p) => p.filePath === targetDef.filePath); + if (targetFile === undefined) continue; + for (const memberDef of targetFile.localDefs) { + if ((memberDef as { ownerId?: string }).ownerId !== targetDef.nodeId) continue; + if (memberDef.type !== 'Method' && memberDef.type !== 'Function') continue; + const mq = memberDef.qualifiedName ?? ''; + const simpleName = mq.includes('.') ? mq.slice(mq.lastIndexOf('.') + 1) : mq; + if (simpleName === '') continue; + + // Append to the augmentation bucket for the importer's module + // scope. `findCallableBindingInScope` reads via + // `lookupBindingsAt`, which fans out across `bindings` + + // `bindingAugmentations`. + const bucketArr = getAugmentationBucket(augmentations, moduleScope.id, simpleName); + if (bucketArr.some((b) => b.def.nodeId === memberDef.nodeId)) continue; + bucketArr.push({ def: memberDef, origin: 'import' }); + } + } + } + + // Cross-namespace imports: for each file's `using X;` directive, + // if `X` matches a known namespace bucket, inject that bucket's + // classes into the importer's module scope. This is what makes + // `new User()` in `namespace App;` resolve to `User` declared in + // a sibling file with `namespace Models;` when the importer says + // `using Models;`. Legacy uses csproj directory↔namespace mapping; + // the scope-resolver layer uses the declared namespace directly. + for (const parsed of parsedFiles) { + const moduleScope = parsed.scopes.find((s) => s.kind === 'Module'); + if (moduleScope === undefined) continue; + for (const imp of parsed.parsedImports) { + if (imp.kind !== 'namespace') continue; + const targetNs = imp.targetRaw; + if (targetNs === null || targetNs === '') continue; + const bucket = buckets.get(targetNs); + if (bucket === undefined) continue; + for (const def of bucket.classDefs) { + if (def.filePath === parsed.filePath) continue; + const q = def.qualifiedName ?? ''; + const simpleName = q.includes('.') ? q.slice(q.lastIndexOf('.') + 1) : q; + if (simpleName === '') continue; + const bucketArr = getAugmentationBucket(augmentations, moduleScope.id, simpleName); + if (bucketArr.some((b) => b.def.nodeId === def.nodeId)) continue; + bucketArr.push({ def, origin: 'namespace' }); + } + } + } + + for (const [, bucket] of buckets) { + // De-dup by (nodeId, filePath) across multiple declarations (e.g. + // partial classes declaring the same name in two files — we take + // both and leave de-dup to downstream consumers of bindings). + const defsByName = new Map(); + for (const def of bucket.classDefs) { + // Simple name = last segment of qualifiedName (e.g. `App.User` → `User`). + const q = def.qualifiedName ?? ''; + const key = q.includes('.') ? q.slice(q.lastIndexOf('.') + 1) : q; + if (key === '') continue; + const arr = defsByName.get(key) ?? []; + arr.push(def); + defsByName.set(key, arr); + } + + for (const { scopeId, filePath } of bucket.scopes) { + for (const [name, defs] of defsByName) { + // Skip names already present locally — `origin: 'local'` in + // scope.bindings would naturally shadow the cross-file + // namespace entry, but we also keep this index lean. + const local = bucket.scopes.find((s) => s.filePath === filePath)?.scope.bindings.get(name); + if (local !== undefined && local.some((b) => b.origin === 'local')) continue; + + let bucketArr: BindingRef[] | null = null; + for (const def of defs) { + if (def.filePath === filePath) continue; // don't self-reference + if (bucketArr === null) bucketArr = getAugmentationBucket(augmentations, scopeId, name); + if (bucketArr.some((b) => b.def.nodeId === def.nodeId)) continue; + bucketArr.push({ def, origin: 'namespace' }); + } + } + } + } +} + +/** Get-or-create a mutable inner bucket inside the `bindingAugmentations` + * channel. The inner arrays here are mutable by contract (see + * `ScopeResolutionIndexes.bindingAugmentations` doc + scope-resolver I8); + * callers may `push` directly. Allocating the outer/inner Maps lazily + * keeps the augmentation footprint zero for files with no cross-file + * fanout. */ +function getAugmentationBucket( + augmentations: Map>, + scopeId: ScopeId, + name: string, +): BindingRef[] { + let scopeBindings = augmentations.get(scopeId); + if (scopeBindings === undefined) { + scopeBindings = new Map(); + augmentations.set(scopeId, scopeBindings); + } + let bucketArr = scopeBindings.get(name); + if (bucketArr === undefined) { + bucketArr = []; + scopeBindings.set(name, bucketArr); + } + return bucketArr; +} + +function isTypeDef(def: SymbolDefinition): boolean { + return ( + def.type === 'Class' || + def.type === 'Interface' || + def.type === 'Struct' || + def.type === 'Record' || + def.type === 'Enum' + ); +} diff --git a/gitnexus/src/core/ingestion/languages/csharp/query.ts b/gitnexus/src/core/ingestion/languages/csharp/query.ts new file mode 100644 index 000000000..11da2d8cd --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/csharp/query.ts @@ -0,0 +1,520 @@ +/** + * Tree-sitter query for C# scope captures (RFC §5.1). + * + * Captures the structural skeleton the generic scope-resolution + * pipeline consumes: scopes (module/namespace/class/function), + * declarations (class-likes, method-likes, properties, variables, + * local functions), imports (using directives), type bindings + * (parameter annotations, variable annotations, constructor + * inference), and references (call sites, member writes). + * + * C# specifics that shape this query: + * + * - Both block-scoped (`namespace X { }`) and file-scoped + * (`namespace X;`) namespaces. tree-sitter-c-sharp emits them + * under distinct node types (`namespace_declaration` vs + * `file_scoped_namespace_declaration`); both map to + * `@scope.namespace` since the scope semantics are identical. + * - `partial class X` splits a Class def across files. Each file + * emits its own `@declaration.class`; cross-file resolution is + * handled at the graph-bridge layer via the qualified-name key. + * - `using X = Y;` aliases and `using static X;` are interpreted in + * `interpret.ts` via the `@import.*` captures. All three using + * flavors share the same anchor (`@import.statement`). + * - Explicit interface implementations (`void IFoo.Bar() { }`) + * expose the qualified name via the existing `@declaration.name` + * — the extractor's `csharpMethodConfig.extractQualifiedName` + * picks up the explicit qualifier from the method declaration + * node. + * + * Exposes lazy `Parser` and `Query` singletons so callers don't pay + * tree-sitter init cost per file. + */ + +import Parser from 'tree-sitter'; +import CSharp from 'tree-sitter-c-sharp'; + +const CSHARP_SCOPE_QUERY = ` +;; Scopes +(compilation_unit) @scope.module + +(namespace_declaration) @scope.namespace +(file_scoped_namespace_declaration) @scope.namespace + +(class_declaration) @scope.class +(interface_declaration) @scope.class +(struct_declaration) @scope.class +(record_declaration) @scope.class +(enum_declaration) @scope.class + +(method_declaration) @scope.function +(constructor_declaration) @scope.function +(destructor_declaration) @scope.function +(local_function_statement) @scope.function +(operator_declaration) @scope.function +(conversion_operator_declaration) @scope.function +;; Property accessors are blocks within a property; not scoped here. +;; Anonymous methods / lambdas are not scoped — out of scope per plan. + +;; Declarations — types +(class_declaration + name: (identifier) @declaration.name) @declaration.class + +(interface_declaration + name: (identifier) @declaration.name) @declaration.interface + +(struct_declaration + name: (identifier) @declaration.name) @declaration.struct + +(record_declaration + name: (identifier) @declaration.name) @declaration.record + +(enum_declaration + name: (identifier) @declaration.name) @declaration.enum + +;; Declarations — methods / constructors / properties +(method_declaration + name: (identifier) @declaration.name) @declaration.method + +(constructor_declaration + name: (identifier) @declaration.name) @declaration.constructor + +(destructor_declaration + name: (identifier) @declaration.name) @declaration.method + +(local_function_statement + name: (identifier) @declaration.name) @declaration.function + +;; Operator declarations — \`public static T operator +(T a, T b)\`. +;; tree-sitter-c-sharp exposes the operator token under the \`operator:\` +;; field (an anonymous node like \`+\`, \`-\`, \`==\`). Capture the whole +;; node under @declaration.name so the extractor reads the operator +;; symbol as the declared name; downstream csharpMethodConfig can +;; normalize it (e.g. to \`op_Addition\`) when it runs. +(operator_declaration + operator: _ @declaration.name) @declaration.method + +;; Conversion operators — \`public static explicit operator int(T x)\`. +;; No operator token; the target type (\`int\`) identifies the conversion +;; and serves as the name anchor. +(conversion_operator_declaration + type: _ @declaration.name) @declaration.method + +(property_declaration + name: (identifier) @declaration.name) @declaration.property + +(indexer_declaration) @declaration.property + +;; Fields — \`int x;\` at class scope. variable_declarator inside +;; field_declaration carries the name. +(field_declaration + (variable_declaration + (variable_declarator + name: (identifier) @declaration.name))) @declaration.variable + +;; Local variables — \`int x = 1;\` inside a method body +(local_declaration_statement + (variable_declaration + (variable_declarator + name: (identifier) @declaration.name))) @declaration.variable + +;; Imports — single anchor per directive; interpretCsharpImport classifies +(using_directive) @import.statement + +;; Type bindings — parameter annotations: \`void F(User u)\` +(parameter + type: (identifier) @type-binding.type + name: (identifier) @type-binding.name) @type-binding.parameter + +(parameter + type: (generic_name) @type-binding.type + name: (identifier) @type-binding.name) @type-binding.parameter + +(parameter + type: (qualified_name) @type-binding.type + name: (identifier) @type-binding.name) @type-binding.parameter + +(parameter + type: (nullable_type) @type-binding.type + name: (identifier) @type-binding.name) @type-binding.parameter + +;; Type bindings — local variable annotations: \`User u = new User();\` +;; Typed local with identifier type + \`new X()\` initializer — shape +;; matters so \`u\` binds to \`X\` (the constructor call's type), not the +;; declared type alias (which is usually the same, but \`new DerivedUser()\` +;; would be distinct). +(local_declaration_statement + (variable_declaration + type: (identifier) @type-binding.type + (variable_declarator + name: (identifier) @type-binding.name))) @type-binding.annotation + +(local_declaration_statement + (variable_declaration + type: (generic_name) @type-binding.type + (variable_declarator + name: (identifier) @type-binding.name))) @type-binding.annotation + +(local_declaration_statement + (variable_declaration + type: (qualified_name) @type-binding.type + (variable_declarator + name: (identifier) @type-binding.name))) @type-binding.annotation + +;; Type bindings — \`var u = new User();\` — constructor-inferred. +;; Captures object_creation_expression's type as the binding type. +;; variable_declarator wraps the \`= \` directly; tree-sitter-c-sharp +;; does not surface an equals_value_clause wrapper here. +(local_declaration_statement + (variable_declaration + (variable_declarator + name: (identifier) @type-binding.name + (object_creation_expression + type: (identifier) @type-binding.type)))) @type-binding.constructor + +(local_declaration_statement + (variable_declaration + (variable_declarator + name: (identifier) @type-binding.name + (object_creation_expression + type: (generic_name) @type-binding.type)))) @type-binding.constructor + +(local_declaration_statement + (variable_declaration + (variable_declarator + name: (identifier) @type-binding.name + (object_creation_expression + type: (qualified_name) @type-binding.type)))) @type-binding.constructor + +;; Type bindings — \`var u = factory();\` alias (chain-follow picks up +;; factory's return type via propagateImportedReturnTypes) +(local_declaration_statement + (variable_declaration + (variable_declarator + name: (identifier) @type-binding.name + (invocation_expression + function: (identifier) @type-binding.type)))) @type-binding.alias + +;; Type bindings — identifier-to-identifier alias: \`var alias = u;\`. +;; The resolver's chain-follow walks from \`alias\` → \`u\` → u's +;; declared type, so we only need to tag the rename here. +(local_declaration_statement + (variable_declaration + type: (implicit_type) + (variable_declarator + name: (identifier) @type-binding.name + (identifier) @type-binding.type))) @type-binding.alias + +;; Type bindings — chained method-call alias: \`var u = svc.GetUser();\`. +;; The chain-follow then walks GetUser's return-type binding. +(local_declaration_statement + (variable_declaration + type: (implicit_type) + (variable_declarator + name: (identifier) @type-binding.name + (invocation_expression + function: (member_access_expression + name: (identifier) @type-binding.type))))) @type-binding.alias + +;; Type bindings — \`await\` propagation: \`var u = await Factory();\`. +;; Strip the await wrapper to get the underlying invocation; interpret +;; layer's stripGeneric handles Task / ValueTask. +(local_declaration_statement + (variable_declaration + type: (implicit_type) + (variable_declarator + name: (identifier) @type-binding.name + (await_expression + (invocation_expression + function: (identifier) @type-binding.type))))) @type-binding.alias + +(local_declaration_statement + (variable_declaration + type: (implicit_type) + (variable_declarator + name: (identifier) @type-binding.name + (await_expression + (invocation_expression + function: (member_access_expression + name: (identifier) @type-binding.type)))))) @type-binding.alias + +;; Type bindings — identifier-to-identifier assignment rebind: +;; \`alias = u;\` — aliases the rhs identifier's current type. +(assignment_expression + left: (identifier) @type-binding.name + right: (identifier) @type-binding.type) @type-binding.alias + +;; Type bindings — method return type: \`public User GetUser() { ... }\`. +;; Anchor on the method_declaration so bindingScopeFor can hoist the +;; binding from function scope to the enclosing class/module scope +;; (callers, not the function body, look up the return type by the +;; function's name). Required for cross-file return-type propagation +;; via propagateImportedReturnTypes. +(method_declaration + returns: (identifier) @type-binding.type + name: (identifier) @type-binding.name) @type-binding.return + +(method_declaration + returns: (generic_name) @type-binding.type + name: (identifier) @type-binding.name) @type-binding.return + +(method_declaration + returns: (qualified_name) @type-binding.type + name: (identifier) @type-binding.name) @type-binding.return + +(method_declaration + returns: (nullable_type) @type-binding.type + name: (identifier) @type-binding.name) @type-binding.return + +;; Type bindings — field declaration: \`private City _city;\`. Attaches +;; to the enclosing class scope via positionIndex, so \`this._city.X\` +;; can look up _city's type on the class. +(field_declaration + (variable_declaration + type: (identifier) @type-binding.type + (variable_declarator + name: (identifier) @type-binding.name))) @type-binding.annotation + +(field_declaration + (variable_declaration + type: (generic_name) @type-binding.type + (variable_declarator + name: (identifier) @type-binding.name))) @type-binding.annotation + +(field_declaration + (variable_declaration + type: (qualified_name) @type-binding.type + (variable_declarator + name: (identifier) @type-binding.name))) @type-binding.annotation + +;; Type bindings — property declaration: \`public User Owner { get; set; }\`. +(property_declaration + type: (identifier) @type-binding.type + name: (identifier) @type-binding.name) @type-binding.annotation + +(property_declaration + type: (generic_name) @type-binding.type + name: (identifier) @type-binding.name) @type-binding.annotation + +(property_declaration + type: (qualified_name) @type-binding.type + name: (identifier) @type-binding.name) @type-binding.annotation + +(property_declaration + type: (nullable_type) @type-binding.type + name: (identifier) @type-binding.name) @type-binding.annotation + +;; Type bindings — assignment rebind: \`alias = Factory();\` where +;; \`alias\` was previously declared. Same alias shape as \`var x = F();\`. +(assignment_expression + left: (identifier) @type-binding.name + right: (invocation_expression + function: (identifier) @type-binding.type)) @type-binding.alias + +;; Type bindings — assignment with constructor: \`alias = new User();\`. +(assignment_expression + left: (identifier) @type-binding.name + right: (object_creation_expression + type: (identifier) @type-binding.type)) @type-binding.constructor + +(assignment_expression + left: (identifier) @type-binding.name + right: (object_creation_expression + type: (generic_name) @type-binding.type)) @type-binding.constructor + +;; Type bindings — \`is\` pattern: \`if (obj is User u) { u.Save(); }\`. +;; The declaration_pattern carries both the matched type and the +;; binding name; scope narrowing to the guarded branch is simplified +;; to function-scope (matches Python's match-case treatment) since we +;; don't emit @scope.block. +(is_pattern_expression + pattern: (declaration_pattern + type: (identifier) @type-binding.type + name: (identifier) @type-binding.name)) @type-binding.annotation + +(is_pattern_expression + pattern: (declaration_pattern + type: (generic_name) @type-binding.type + name: (identifier) @type-binding.name)) @type-binding.annotation + +(is_pattern_expression + pattern: (declaration_pattern + type: (qualified_name) @type-binding.type + name: (identifier) @type-binding.name)) @type-binding.annotation + +;; Type bindings — \`case User u:\` inside a switch section. +;; tree-sitter-c-sharp's switch_section directly contains the +;; declaration_pattern / recursive_pattern (no case_pattern_switch_label +;; wrapper as in other C# grammars). +(switch_section + (declaration_pattern + type: (identifier) @type-binding.type + name: (identifier) @type-binding.name)) @type-binding.annotation + +(switch_section + (declaration_pattern + type: (generic_name) @type-binding.type + name: (identifier) @type-binding.name)) @type-binding.annotation + +;; Type bindings — recursive_pattern with named binding: +;; \`x is User { Age: 1 } u\` / \`case User { Age: 1 } u:\`. type + name +;; are named fields on recursive_pattern; inner property/positional +;; clauses don't affect the binding. +(is_pattern_expression + pattern: (recursive_pattern + type: (identifier) @type-binding.type + name: (identifier) @type-binding.name)) @type-binding.annotation + +(switch_section + (recursive_pattern + type: (identifier) @type-binding.type + name: (identifier) @type-binding.name)) @type-binding.annotation + +;; Type bindings — switch-expression arms: \`obj switch { User u => …, Repo { Name: "x" } r => … }\`. +;; Distinct from the \`switch_statement\` shape above — expression-switch +;; uses \`switch_expression_arm\` nodes. +(switch_expression_arm + (declaration_pattern + type: (identifier) @type-binding.type + name: (identifier) @type-binding.name)) @type-binding.annotation + +(switch_expression_arm + (declaration_pattern + type: (generic_name) @type-binding.type + name: (identifier) @type-binding.name)) @type-binding.annotation + +(switch_expression_arm + (recursive_pattern + type: (identifier) @type-binding.type + name: (identifier) @type-binding.name)) @type-binding.annotation + +;; Type bindings — typed foreach: \`foreach (User u in xs)\`. +;; Shape parity with \`User u = …;\` — left binds to the declared type. +(foreach_statement + type: (identifier) @type-binding.type + left: (identifier) @type-binding.name) @type-binding.annotation + +(foreach_statement + type: (generic_name) @type-binding.type + left: (identifier) @type-binding.name) @type-binding.annotation + +(foreach_statement + type: (qualified_name) @type-binding.type + left: (identifier) @type-binding.name) @type-binding.annotation + +(foreach_statement + type: (nullable_type) @type-binding.type + left: (identifier) @type-binding.name) @type-binding.annotation + +;; Type bindings — \`var\` foreach: \`foreach (var u in xs)\`. Alias to +;; the iterable's identifier / chain so chain-follow unwraps +;; \`List\` / \`Dictionary.Values\` to the element type via +;; the generic-stripper in interpret.ts. Mirrors Python's for-loop +;; alias patterns. +(foreach_statement + type: (implicit_type) + left: (identifier) @type-binding.name + right: (identifier) @type-binding.type) @type-binding.alias + +(foreach_statement + type: (implicit_type) + left: (identifier) @type-binding.name + right: (member_access_expression) @type-binding.type) @type-binding.alias + +(foreach_statement + type: (implicit_type) + left: (identifier) @type-binding.name + right: (invocation_expression + function: (identifier) @type-binding.type)) @type-binding.alias + +;; Return-type captures on method_declaration / property_declaration / +;; field_declaration are deferred — tree-sitter-c-sharp does not expose +;; the return/field type under a simple named field that pattern-matches +;; cleanly. When Unit 7's parity gate surfaces a gap requiring these +;; bindings, revisit with a positional pattern or a post-hoc lookup via +;; csharpMethodConfig.extractReturnType / csharpFieldConfig.extractType. + +;; References — free calls: \`Foo()\` +(invocation_expression + function: (identifier) @reference.name) @reference.call.free + +;; References — member calls: \`obj.Method()\` +;; \`(_)\` matches only named nodes in tree-sitter queries. \`this\` and +;; \`base\` are anonymous tokens in tree-sitter-c-sharp (unlike Python's +;; \`self\` which is a regular identifier), so they need explicit +;; patterns to emit a receiver capture. +(invocation_expression + function: (member_access_expression + expression: (_) @reference.receiver + name: (identifier) @reference.name)) @reference.call.member + +(invocation_expression + function: (member_access_expression + expression: "this" @reference.receiver + name: (identifier) @reference.name)) @reference.call.member + +(invocation_expression + function: (member_access_expression + expression: "base" @reference.receiver + name: (identifier) @reference.name)) @reference.call.member + +;; References — null-conditional member calls: \`obj?.Method()\` +;; conditional_access_expression wraps a receiver followed by a +;; member_binding_expression. Capture the receiver explicitly so +;; receiver-bound resolution doesn't silently downgrade the call to +;; a free-call (which would misresolve to an imported \`Save\`). +;; tree-sitter-c-sharp doesn't expose named fields here, so use +;; positional wildcards. +(invocation_expression + function: (conditional_access_expression + (_) @reference.receiver + (member_binding_expression + (identifier) @reference.name))) @reference.call.member + +;; References — constructor calls: \`new User(...)\` +(object_creation_expression + type: (identifier) @reference.name) @reference.call.constructor + +(object_creation_expression + type: (generic_name + (identifier) @reference.name)) @reference.call.constructor + +(object_creation_expression + type: (qualified_name) @reference.call.constructor.qualified) @reference.call.constructor + +;; References — field/property writes: \`obj.Name = "x"\` emits a write +;; ACCESSES edge from the enclosing method to the field/property on +;; obj's class. +(assignment_expression + left: (member_access_expression + expression: (_) @reference.receiver + name: (identifier) @reference.name)) @reference.write.member + +(assignment_expression + left: (member_access_expression + expression: "this" @reference.receiver + name: (identifier) @reference.name)) @reference.write.member + +(assignment_expression + left: (member_access_expression + expression: "base" @reference.receiver + name: (identifier) @reference.name)) @reference.write.member +`; + +let _parser: Parser | null = null; +let _query: Parser.Query | null = null; + +export function getCsharpParser(): Parser { + if (_parser === null) { + _parser = new Parser(); + _parser.setLanguage(CSharp as Parameters[0]); + } + return _parser; +} + +export function getCsharpScopeQuery(): Parser.Query { + if (_query === null) { + _query = new Parser.Query(CSharp as Parameters[0], CSHARP_SCOPE_QUERY); + } + return _query; +} diff --git a/gitnexus/src/core/ingestion/languages/csharp/receiver-binding.ts b/gitnexus/src/core/ingestion/languages/csharp/receiver-binding.ts new file mode 100644 index 000000000..9cd69b99a --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/csharp/receiver-binding.ts @@ -0,0 +1,142 @@ +/** + * Synthesize `@type-binding.self` captures for C# instance methods — + * one for `this` (always on non-static methods inside a type + * declaration) and optionally one for `base` (only on class methods + * when the enclosing class has an explicit base in its `base_list`). + * + * Mirrors `languages/python/receiver-binding.ts` in structure. The + * tree-sitter-c-sharp grammar doesn't give us a clean `.scm` pattern + * for "this-receiver on every instance method inside an enclosing + * type" because the binding isn't a parameter — it's an implicit + * receiver. Synthesis in code is the same approach Python uses for + * `self` / `cls`. + */ + +import type { Capture, CaptureMatch } from 'gitnexus-shared'; +import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; + +const TYPE_DECL_NODE_TYPES = new Set([ + 'class_declaration', + 'struct_declaration', + 'record_declaration', + 'interface_declaration', +]); + +const FUNCTION_NODE_TYPES = new Set([ + 'method_declaration', + 'constructor_declaration', + 'destructor_declaration', + 'operator_declaration', + 'conversion_operator_declaration', + 'local_function_statement', +]); + +/** Walk up to the enclosing type declaration, stopping at any other + * function-like node (nested local functions shouldn't leak `this` + * from an outer class to an inner closure). */ +function findEnclosingTypeDeclaration(node: SyntaxNode): SyntaxNode | null { + let cur: SyntaxNode | null = node.parent; + while (cur !== null) { + if (TYPE_DECL_NODE_TYPES.has(cur.type)) return cur; + // A local function nested inside another method still sees `this` + // from the enclosing class — don't break on function-like nodes. + cur = cur.parent; + } + return null; +} + +function typeName(typeNode: SyntaxNode): string | null { + return typeNode.childForFieldName('name')?.text ?? null; +} + +/** First entry in the type's `base_list`, read as raw text. C# allows + * generic and qualified bases (`Foo`, `N.M.Base`); we keep the raw + * form so downstream interpret layer can strip generics/qualifiers + * the same way as other type-binding captures. Returns null when the + * type has no base (or an empty base_list). */ +function firstBaseText(typeNode: SyntaxNode): string | null { + for (let i = 0; i < typeNode.namedChildCount; i++) { + const child = typeNode.namedChild(i); + if (child === null || child.type !== 'base_list') continue; + const firstBase = child.namedChild(0); + if (firstBase === null) return null; + return firstBase.text; + } + return null; +} + +function isStaticMethod(fnNode: SyntaxNode): boolean { + // A `static` modifier appears as a named `modifier` child whose text + // is exactly "static". collectModifierTexts in field-extractors + // handles this, but duplicating the tiny scan here keeps the + // receiver-binding module dependency-free. + for (let i = 0; i < fnNode.namedChildCount; i++) { + const child = fnNode.namedChild(i); + if (child !== null && child.type === 'modifier' && child.text.trim() === 'static') return true; + } + return false; +} + +/** + * Build zero, one, or two `@type-binding.self` matches for `fnNode`: + * + * - Returns `null` if the function is free (no enclosing type), + * static, or the enclosing type has no resolvable name. + * - Returns one match (`this`) for non-static methods inside a + * class/struct/record/interface. + * - Returns two matches (`this` + `base`) only when the function + * lives in a `class_declaration` (or `record_declaration`) that has + * at least one base entry. Structs cannot inherit classes; + * interfaces cannot call `base.X`. + * + * The caller is responsible for guaranteeing + * `FUNCTION_NODE_TYPES.has(fnNode.type)`. + */ +export function synthesizeCsharpReceiverBinding(fnNode: SyntaxNode): CaptureMatch[] { + if (!FUNCTION_NODE_TYPES.has(fnNode.type)) return []; + if (isStaticMethod(fnNode)) return []; + + const enclosingType = findEnclosingTypeDeclaration(fnNode); + if (enclosingType === null) return []; + + const enclosingName = typeName(enclosingType); + if (enclosingName === null) return []; + + // Anchor the synthesized captures to a node clearly *inside* the + // function's scope (not at the method's start position, which maps + // to the enclosing class scope via positionIndex). The method's + // `body` field is the block statement — its range is guaranteed to + // be inside the function scope. If the method has no body (interface + // declaration, `abstract`), skip — there's no function scope to + // attach the binding to. + const anchorNode = fnNode.childForFieldName('body'); + if (anchorNode === null) return []; + + const out: CaptureMatch[] = []; + out.push(buildReceiverMatch(anchorNode, 'this', enclosingName)); + + // `base` applies only to class / record methods with an explicit + // base class. `struct` can't inherit a class; `interface` can't + // call `base.X`. The first entry of `base_list` is the base class + // (interfaces follow); we can't statically distinguish the two here, + // but `base.X` only compiles when the first entry IS a class, so we + // trust the source — if the user wrote `base.X` in a class with + // interface-only bases, their code wouldn't compile anyway. + if (enclosingType.type === 'class_declaration' || enclosingType.type === 'record_declaration') { + const baseText = firstBaseText(enclosingType); + if (baseText !== null) { + out.push(buildReceiverMatch(anchorNode, 'base', baseText)); + } + } + + return out; +} + +function buildReceiverMatch(anchorNode: SyntaxNode, name: string, typeText: string): CaptureMatch { + const m: Record = { + '@type-binding.self': nodeToCapture('@type-binding.self', anchorNode), + '@type-binding.name': syntheticCapture('@type-binding.name', anchorNode, name), + '@type-binding.type': syntheticCapture('@type-binding.type', anchorNode, typeText), + }; + return m; +} diff --git a/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts new file mode 100644 index 000000000..5fc4f9c63 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts @@ -0,0 +1,88 @@ +/** + * C# `ScopeResolver` registered in `SCOPE_RESOLVERS` and consumed by + * the generic `runScopeResolution` orchestrator (RFC #909 Ring 3). + * + * Second migration after Python — see `pythonScopeResolver` for the + * canonical shape. + */ + +import type { ParsedFile } from 'gitnexus-shared'; +import { SupportedLanguages } from 'gitnexus-shared'; +import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js'; +import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js'; +import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js'; +import { csharpProvider } from '../csharp.js'; +import { + csharpArityCompatibility, + csharpMergeBindings, + resolveCsharpImportTarget, + type CsharpResolveContext, +} from './index.js'; +import { populateCsharpNamespaceSiblings } from './namespace-siblings.js'; +import { unwrapCsharpCollectionAccessor } from './accessor-unwrap.js'; + +const csharpScopeResolver: ScopeResolver = { + language: SupportedLanguages.CSharp, + languageProvider: csharpProvider, + importEdgeReason: 'csharp-scope: using', + + resolveImportTarget: (targetRaw, fromFile, allFilePaths) => { + const ws: CsharpResolveContext = { fromFile, allFilePaths }; + // `WorkspaceIndex` is an opaque `unknown` placeholder in the + // shared contract, so `ws` passes structurally without a cast. + return resolveCsharpImportTarget( + { kind: 'namespace', localName: '_', importedName: '_', targetRaw }, + ws, + ); + }, + + // C# shadowing: local > using > using static. The per-scope id is + // unused by the C# implementation (shadowing is computed purely + // from the binding tier), so we don't need to synthesize a Scope. + mergeBindings: (existing, incoming) => [...csharpMergeBindings([...existing, ...incoming])], + + // Adapter: csharpArityCompatibility uses (def, callsite); the + // contract is (callsite, def). + arityCompatibility: (callsite, def) => csharpArityCompatibility(def, callsite), + + buildMro: (graph, parsedFiles, nodeLookup) => + buildMro(graph, parsedFiles, nodeLookup, defaultLinearize), + + populateOwners: (parsed: ParsedFile) => populateClassOwnedMembers(parsed), + + // C# uses `base` for super-class dispatch, not `super`. Match as a + // plain identifier (no `()` call like Python's `super(...)`) — `base` + // is a keyword-like receiver, not a callable. + isSuperReceiver: (text) => text.trim() === 'base', + + // Same-namespace cross-file visibility — C# makes every type + // declared in `namespace X` visible to other files declaring the + // same namespace, without any `using` directive. See + // `namespace-siblings.ts` for the implementation. + populateNamespaceSiblings: populateCsharpNamespaceSiblings, + + // C# is statically typed — type information is reliable. Field- + // fallback heuristic stays off (the type-binding layer already + // produces precise owner types); return-type propagation on is fine + // since signatures are authoritative. + fieldFallbackOnMethodLookup: false, + propagatesReturnTypesAcrossImports: true, + + // `data.Values` / `data.Keys` on Dictionary-like receivers unwrap + // to the value / key element type. Other languages use method-call + // syntax for the same access and leave this hook undefined. + unwrapCollectionAccessor: unwrapCsharpCollectionAccessor, + + // C# matches legacy DAG by collapsing member-call CALLS edges to + // `(caller, target)` — multiple `g.Greet(...)` sites from Main + // yield ONE edge, not one per site. + collapseMemberCallsByCallerTarget: true, + + // C# hoists method return-type bindings to the enclosing Module + // scope so `propagateImportedReturnTypes` can mirror them across + // files. The compound-receiver walker needs to walk up from the + // class scope to find them; see the contract field for rationale. + hoistTypeBindingsToModule: true, +}; + +export { csharpScopeResolver }; diff --git a/gitnexus/src/core/ingestion/languages/csharp/simple-hooks.ts b/gitnexus/src/core/ingestion/languages/csharp/simple-hooks.ts new file mode 100644 index 000000000..8b77464fb --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/csharp/simple-hooks.ts @@ -0,0 +1,96 @@ +/** + * Trivial / no-op-ish hooks for the C# provider. Kept together because + * each is a few lines and they share a common theme: they make the + * provider's choice explicit rather than relying on "absence == default" + * so reviewers don't have to re-derive the analysis. + */ + +import type { + CaptureMatch, + ParsedImport, + Scope, + ScopeId, + ScopeTree, + TypeRef, +} from 'gitnexus-shared'; + +// ─── bindingScopeFor ────────────────────────────────────────────────────── + +/** C# has block scope, but the central extractor's "innermost enclosing + * scope" default already handles it correctly: class-body declarations + * attach to the innermost Class scope, method-body declarations attach + * to the innermost Function scope, and namespace-body declarations + * attach to the innermost Namespace scope (which the scope query emits + * for both `namespace X { }` and `namespace X;` forms). + * + * Exception: **method return-type bindings** (`@type-binding.return`) + * must hoist all the way to the Module scope. The default auto-hoist + * in the central extractor only promotes one level (Function → its + * parent). For C# methods the parent is always a Class, so without + * this override the return binding gets stuck at the Class scope, + * where it's invisible to: + * - chain-follow's parent-chain walk in `followChainPostFinalize` + * (tests: `var u = GetUser(); u.Save()` single-file); + * - cross-file `propagateImportedReturnTypes`, which reads only + * `sourceModule.typeBindings`. + * Walking to Module restores both paths. */ +export function csharpBindingScopeFor( + decl: CaptureMatch, + innermost: Scope, + tree: ScopeTree, +): ScopeId | null { + if (decl['@type-binding.return'] !== undefined) { + let cur: Scope | undefined = innermost; + while (cur !== undefined && cur.kind !== 'Module') { + const parentId: ScopeId | null = cur.parent ?? null; + if (parentId === null) break; + cur = tree.getScope(parentId); + } + if (cur !== undefined && cur.kind === 'Module') return cur.id; + } + return null; +} + +// ─── importOwningScope ──────────────────────────────────────────────────── + +/** `using` inside `namespace X { }` binds to that namespace's scope (its + * types are visible only within that namespace's members). File-level + * `using` delegates to the module default. Class-body `using` is not + * legal C# — defensively handle it by attaching to the class if it + * ever slips through. + * + * `global using X;` (C# 10+) at the compilation_unit level is treated + * as a file-scoped using for Unit 2's purposes; cross-file propagation + * will be addressed if Unit 7's parity gate flags it. */ +export function csharpImportOwningScope( + _imp: ParsedImport, + innermost: Scope, + _tree: ScopeTree, +): ScopeId | null { + if (innermost.kind === 'Namespace' || innermost.kind === 'Class' || innermost.kind === 'Function') + return innermost.id; + return null; +} + +// ─── receiverBinding ────────────────────────────────────────────────────── + +/** Look up `this` or `base` in the function scope's type bindings. + * + * `this` and `base` are synthesized as type bindings on instance + * methods during capture emission (`receiver-binding.ts`) — `this` + * for every method inside a class/struct/record/interface body, and + * `base` additionally for methods of a class-like type with an + * explicit `base_list`. This hook therefore returns a non-null + * `TypeRef` for instance-method bodies. + * + * Returns `null` for: + * - static methods (no `this` synthesized) + * - free functions / module-level code (no enclosing class) + * - non-Function scopes + * + * Matches `pythonReceiverBinding`'s shape so the two provider + * wirings stay symmetric. */ +export function csharpReceiverBinding(functionScope: Scope): TypeRef | null { + if (functionScope.kind !== 'Function') return null; + return functionScope.typeBindings.get('this') ?? functionScope.typeBindings.get('base') ?? null; +} diff --git a/gitnexus/src/core/ingestion/languages/python.ts b/gitnexus/src/core/ingestion/languages/python.ts index 6b8686e33..2b37aab1b 100644 --- a/gitnexus/src/core/ingestion/languages/python.ts +++ b/gitnexus/src/core/ingestion/languages/python.ts @@ -10,6 +10,7 @@ * - namedBindingExtractor: present (from X import Y) */ +import type { NodeLabel } from 'gitnexus-shared'; import { SupportedLanguages } from 'gitnexus-shared'; import { createClassExtractor } from '../class-extractors/generic.js'; import { pythonClassConfig } from '../class-extractors/configs/python.js'; @@ -29,8 +30,11 @@ import { pythonVariableConfig } from '../variable-extractors/configs/python.js'; import { createCallExtractor } from '../call-extractors/generic.js'; import { pythonCallConfig } from '../call-extractors/configs/python.js'; import { createHeritageExtractor } from '../heritage-extractors/generic.js'; +import type { CaptureMap } from '../language-provider.js'; +import type { SyntaxNode } from '../utils/ast-helpers.js'; import { emitPythonScopeCaptures, + pythonFunctionDefinitionLabel, interpretPythonImport, interpretPythonTypeBinding, pythonArityCompatibility, @@ -71,6 +75,34 @@ const BUILT_INS: ReadonlySet = new Set([ 'abs', ]); +function pythonDescriptionExtractor( + nodeLabel: NodeLabel, + _nodeName: string, + captureMap: CaptureMap, +): string | undefined { + if (nodeLabel !== 'Function' && nodeLabel !== 'Method') return undefined; + const functionNode = captureMap['definition.function'] ?? captureMap['definition.method']; + if (functionNode === undefined) return undefined; + return extractPythonDocstring(functionNode); +} + +function extractPythonDocstring(functionNode: SyntaxNode): string | undefined { + const body = functionNode.childForFieldName('body'); + const firstStatement = body?.namedChild(0); + if (firstStatement?.type !== 'expression_statement') return undefined; + + const literal = firstStatement.namedChild(0); + if (literal?.type !== 'string') return undefined; + return normalizePythonStringLiteral(literal.text); +} + +function normalizePythonStringLiteral(text: string): string | undefined { + const match = text.match(/^[rRuUbBfF]*("""|'''|"|')([\s\S]*)\1$/); + const raw = match?.[2]?.trim(); + if (!raw) return undefined; + return raw.replace(/\s+/g, ' '); +} + export const pythonProvider = defineLanguage({ id: SupportedLanguages.Python, extensions: ['.py'], @@ -87,18 +119,20 @@ export const pythonProvider = defineLanguage({ variableExtractor: createVariableExtractor(pythonVariableConfig), classExtractor: createClassExtractor(pythonClassConfig), heritageExtractor: createHeritageExtractor(SupportedLanguages.Python), + descriptionExtractor: pythonDescriptionExtractor, builtInNames: BUILT_INS, + labelOverride: pythonFunctionDefinitionLabel, // ── RFC #909 Ring 3: scope-based resolution hooks (RFC §5) ────────── // Python is the first migration. See ./python/index.ts for the // full per-hook rationale and the canonical capture vocabulary in - // ./python/scopes.scm. + // ./python/query.ts (PYTHON_SCOPE_QUERY constant). emitScopeCaptures: emitPythonScopeCaptures, interpretImport: interpretPythonImport, interpretTypeBinding: interpretPythonTypeBinding, bindingScopeFor: pythonBindingScopeFor, importOwningScope: pythonImportOwningScope, - mergeBindings: pythonMergeBindings, + mergeBindings: (_scope, bindings) => pythonMergeBindings(bindings), receiverBinding: pythonReceiverBinding, arityCompatibility: pythonArityCompatibility, resolveImportTarget: resolvePythonImportTarget, diff --git a/gitnexus/src/core/ingestion/languages/python/captures.ts b/gitnexus/src/core/ingestion/languages/python/captures.ts index e96a60644..1685fa24c 100644 --- a/gitnexus/src/core/ingestion/languages/python/captures.ts +++ b/gitnexus/src/core/ingestion/languages/python/captures.ts @@ -23,6 +23,8 @@ import { getPythonParser, getPythonScopeQuery } from './query.js'; import { synthesizeReceiverTypeBinding } from './receiver-binding.js'; import { computePythonArityMetadata } from './arity-metadata.js'; import { recordCacheHit, recordCacheMiss } from './cache-stats.js'; +import { getTreeSitterBufferSize } from '../../constants.js'; +import { pythonFunctionDefinitionLabel } from './simple-hooks.js'; export function emitPythonScopeCaptures( sourceText: string, @@ -36,7 +38,9 @@ export function emitPythonScopeCaptures( // here at the use site. let tree = cachedTree as ReturnType['parse']> | undefined; if (tree === undefined) { - tree = getPythonParser().parse(sourceText); + tree = getPythonParser().parse(sourceText, undefined, { + bufferSize: getTreeSitterBufferSize(sourceText), + }); recordCacheMiss(); } else { recordCacheHit(); @@ -95,6 +99,10 @@ export function emitPythonScopeCaptures( const anchorCap = grouped['@declaration.function']!; const fnNode = findNodeAtRange(tree.rootNode, anchorCap.range, 'function_definition'); if (fnNode !== null) { + if (pythonFunctionDefinitionLabel(fnNode, 'Function') === 'Method') { + delete grouped['@declaration.function']; + grouped['@declaration.method'] = { ...anchorCap, name: '@declaration.method' }; + } const arity = computePythonArityMetadata(fnNode); if (arity.parameterCount !== undefined) { grouped['@declaration.parameter-count'] = syntheticCapture( diff --git a/gitnexus/src/core/ingestion/languages/python/import-target.ts b/gitnexus/src/core/ingestion/languages/python/import-target.ts index c99620115..3905f3301 100644 --- a/gitnexus/src/core/ingestion/languages/python/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/python/import-target.ts @@ -15,6 +15,10 @@ import { resolvePythonImportInternal } from '../../import-resolvers/python.js'; export interface PythonResolveContext { readonly fromFile: string; + /** Mutable `Set` because the legacy `resolvePythonImportInternal` + * chain downstream is typed to accept `Set`. Callers that + * only hold a `ReadonlySet` should copy via `new Set(...)` at the + * adapter boundary. */ readonly allFilePaths: Set; } @@ -22,6 +26,10 @@ export function resolvePythonImportTarget( parsedImport: ParsedImport, workspaceIndex: WorkspaceIndex, ): string | null { + // WorkspaceIndex is `unknown` in the shared contract (Ring 1 + // placeholder). The scope-resolution orchestrator hands us a + // PythonResolveContext-shaped object; narrow structurally rather + // than via a cast chain so unexpected shapes return null cleanly. const ctx = workspaceIndex as PythonResolveContext | undefined; if ( ctx === undefined || diff --git a/gitnexus/src/core/ingestion/languages/python/index.ts b/gitnexus/src/core/ingestion/languages/python/index.ts index fcb723c5c..166c3a7ae 100644 --- a/gitnexus/src/core/ingestion/languages/python/index.ts +++ b/gitnexus/src/core/ingestion/languages/python/index.ts @@ -80,6 +80,7 @@ export { pythonArityCompatibility } from './arity.js'; export { resolvePythonImportTarget, type PythonResolveContext } from './import-target.js'; export { pythonBindingScopeFor, + pythonFunctionDefinitionLabel, pythonImportOwningScope, pythonReceiverBinding, } from './simple-hooks.js'; diff --git a/gitnexus/src/core/ingestion/languages/python/merge-bindings.ts b/gitnexus/src/core/ingestion/languages/python/merge-bindings.ts index cf37ed6b4..1487698fa 100644 --- a/gitnexus/src/core/ingestion/languages/python/merge-bindings.ts +++ b/gitnexus/src/core/ingestion/languages/python/merge-bindings.ts @@ -13,7 +13,7 @@ * purposes). */ -import type { BindingRef, Scope } from 'gitnexus-shared'; +import type { BindingRef } from 'gitnexus-shared'; const TIER_LOCAL = 0; const TIER_IMPORT = 1; @@ -35,10 +35,7 @@ function tierOf(b: BindingRef): number { } } -export function pythonMergeBindings( - _scope: Scope, - bindings: readonly BindingRef[], -): readonly BindingRef[] { +export function pythonMergeBindings(bindings: readonly BindingRef[]): readonly BindingRef[] { if (bindings.length === 0) return bindings; let bestTier = Number.POSITIVE_INFINITY; diff --git a/gitnexus/src/core/ingestion/languages/python/query.ts b/gitnexus/src/core/ingestion/languages/python/query.ts index 03b27f730..b22573a91 100644 --- a/gitnexus/src/core/ingestion/languages/python/query.ts +++ b/gitnexus/src/core/ingestion/languages/python/query.ts @@ -1,12 +1,7 @@ /** * Tree-sitter query for Python scope captures (RFC §5.1). * - * The `.scm` sibling file is the human-readable spec; this module - * mirrors it at runtime. **Edit both together** — the unit/integration - * tests reference the embedded constant, and the file documents the - * contract. - * - * Also exposes lazy `Parser` and `Query` singletons so callers don't + * Exposes lazy `Parser` and `Query` singletons so callers don't * pay tree-sitter init cost per file. */ diff --git a/gitnexus/src/core/ingestion/languages/python/receiver-binding.ts b/gitnexus/src/core/ingestion/languages/python/receiver-binding.ts index 8294233f1..ec525d398 100644 --- a/gitnexus/src/core/ingestion/languages/python/receiver-binding.ts +++ b/gitnexus/src/core/ingestion/languages/python/receiver-binding.ts @@ -4,9 +4,9 @@ * * Tree-sitter can't easily express "the first parameter of a function * defined directly inside a class body" via a single static query. - * Doing this in code keeps the `.scm` file declarative and lets us - * encode the `@classmethod` / `@staticmethod` decorator awareness that - * Python's runtime depends on. + * Doing this in code keeps the embedded scope query declarative and + * lets us encode the `@classmethod` / `@staticmethod` decorator + * awareness that Python's runtime depends on. */ import type { CaptureMatch } from 'gitnexus-shared'; diff --git a/gitnexus/src/core/ingestion/languages/python/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/python/scope-resolver.ts index a29abfe09..8f0ca23f5 100644 --- a/gitnexus/src/core/ingestion/languages/python/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/python/scope-resolver.ts @@ -12,7 +12,7 @@ * the 2 booleans, and register in `scope-resolution/pipeline/registry.ts`. */ -import type { ParsedFile, Scope, WorkspaceIndex } from 'gitnexus-shared'; +import type { ParsedFile } from 'gitnexus-shared'; import { SupportedLanguages } from 'gitnexus-shared'; import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js'; import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js'; @@ -31,30 +31,30 @@ const pythonScopeResolver: ScopeResolver = { importEdgeReason: 'python-scope: import', resolveImportTarget: (targetRaw, fromFile, allFilePaths) => { - // PythonResolveContext expects a mutable Set; orchestrator hands us - // a ReadonlySet — safe to widen since the resolver only reads. - const ws: PythonResolveContext = { - fromFile, - allFilePaths: allFilePaths as Set, - }; + // Copy the orchestrator's `ReadonlySet` into a `Set` because the + // legacy Python resolver chain (`resolvePythonImportInternal` → + // `resolveAbsoluteFromFiles` / `hasRepoCandidate`) is typed to + // receive a mutable `Set`. The copy is O(N) but called + // once per import — trivial compared to the parser work. + const ws: PythonResolveContext = { fromFile, allFilePaths: new Set(allFilePaths) }; + // `WorkspaceIndex` is an opaque `unknown` placeholder in the + // shared contract, so `ws` passes structurally without a cast. return resolvePythonImportTarget( { kind: 'named', localName: '_', importedName: '_', targetRaw }, - ws as unknown as WorkspaceIndex, + ws, ); }, // Python LEGB precedence: local > import/namespace/reexport > wildcard. - mergeBindings: (existing, incoming, scopeId) => { - // pythonMergeBindings(scope, bindings) only consults BindingRef.origin - // for tier ordering, not scope.kind. A shape-stub satisfies the type - // contract without falsifying behavior. Widen the readonly result - // to a mutable BindingRef[] for the orchestrator's hook signature. - const fakeScope = { id: scopeId } as unknown as Scope; - return [...pythonMergeBindings(fakeScope, [...existing, ...incoming])]; - }, + // The per-scope id is unused by pythonMergeBindings (tier ordering + // is computed purely from BindingRef.origin), so we don't need to + // synthesize a Scope. + mergeBindings: (existing, incoming) => [...pythonMergeBindings([...existing, ...incoming])], // Adapter: pythonArityCompatibility predates RegistryProviders and - // uses (def, callsite). Contract is (callsite, def). + // uses (def, callsite). ScopeResolver contract is (callsite, def). + // Wrapper kept to honor both contracts without altering the legacy + // shape that LanguageProvider.arityCompatibility consumes. arityCompatibility: (callsite, def) => pythonArityCompatibility(def, callsite), buildMro: (graph, parsedFiles, nodeLookup) => diff --git a/gitnexus/src/core/ingestion/languages/python/scopes.scm b/gitnexus/src/core/ingestion/languages/python/scopes.scm deleted file mode 100644 index b03220bea..000000000 --- a/gitnexus/src/core/ingestion/languages/python/scopes.scm +++ /dev/null @@ -1,266 +0,0 @@ -; Tree-sitter Python query — RFC §5.1 captures for scope-based resolution -; (RFC #909 Ring 3, language: Python — first migration). -; -; Capture vocabulary consumed by the central `ScopeExtractor`: -; -; @scope.module — file root -; @scope.class — class body -; @scope.function — def / async def body (functions, methods, lambdas) -; -; @declaration.class + @declaration.name -; @declaration.function + @declaration.name -; @declaration.method + @declaration.name (functions inside class bodies) -; @declaration.variable + @declaration.name (module/class/function-level assignments) -; -; @import.statement — anchor for `interpretImport`. The hook reads -; the captured source text and tokenizes it into -; a `ParsedImport`. We do NOT decompose -; `import X, Y` here at query time — `interpretImport` -; splits multi-target statements into N matches. -; -; @type-binding.parameter + @type-binding.name + @type-binding.type -; -; @reference.call.free + @reference.name (e.g. `print(x)`) -; @reference.call.member + @reference.name + @reference.receiver -; (e.g. `obj.save()`) -; -; Python has NO block scope: `if`, `for`, `while`, `try`, `with`, `match` -; bodies do NOT introduce a new lexical scope (PEP 8 / language reference). -; We therefore do NOT emit `@scope.block` captures for those constructs; -; their contained declarations land in the enclosing function/class/module -; scope automatically (RFC §5.1 "transparent block" behavior). -; -; `@reference.call.constructor` is intentionally absent: Python has no -; `new` keyword. A call to a class is syntactically identical to a call to -; a free function; the registry decides constructor-vs-call by inspecting -; the resolved `def.type`. Stays out of the parser to avoid duplicating -; that logic in tree-sitter. - -; ─── Scopes ──────────────────────────────────────────────────────────────── - -(module) @scope.module - -(class_definition) @scope.class - -(function_definition) @scope.function - -; ─── Declarations: class / function ──────────────────────────────────────── - -(class_definition - name: (identifier) @declaration.name) @declaration.class - -(function_definition - name: (identifier) @declaration.name) @declaration.function - -; ─── Declarations: assignments (module-, class-, function-level variables) -; -; Note: tree-sitter-python parses both annotated and plain assignments as -; `(assignment left: ...)` — typed and untyped both surface here. The -; central extractor de-dupes by `nodeId` (file#line:col:type:name). - -(assignment - left: (identifier) @declaration.name) @declaration.variable - -; for-loop target — Python `for` does NOT introduce a new scope; the -; loop variable binds in the enclosing function/module scope. -(for_statement - left: (identifier) @declaration.name) @declaration.variable - -; ─── Imports ─────────────────────────────────────────────────────────────── -; -; The whole statement is the anchor — `interpretImport` decomposes it. -; We tag both shapes so the hook sees a single capture name (`@import.statement`). - -(import_statement) @import.statement - -(import_from_statement) @import.statement - -; ─── Type bindings: parameter annotations ────────────────────────────────── -; -; `def f(x: User)` — `x` is bound to `User` in `f`'s scope. -; -; `interpretTypeBinding` reads `@type-binding.name` (the parameter name) -; and `@type-binding.type` (the annotation source text) to produce a -; `ParsedTypeBinding { boundName, rawTypeName, source: 'parameter-annotation' }`. - -(typed_parameter - (identifier) @type-binding.name - type: (type) @type-binding.type) @type-binding.parameter - -(typed_default_parameter - name: (identifier) @type-binding.name - type: (type) @type-binding.type) @type-binding.parameter - -; ─── Type bindings: constructor-inferred assignments ─────────────────────── -; -; `u = User("alice")` — `u`'s type is inferred from the RHS call's target. -; Python has no `new` keyword, so the pattern matches any `assignment` -; whose RHS is a `call` with a bare-identifier function (constructor- -; shaped). The registry resolves the raw name through the scope chain at -; lookup time, so imported classes, local classes, and aliased imports -; all work without query-time knowledge. -; -; Emits `source: 'constructor-inferred'`. -; -; Listed BEFORE the annotation pattern so `u: User = find()` — which -; matches both patterns — has the annotation (later-processed match) -; overwrite the constructor-inferred guess. Explicit user intent wins. - -(assignment - left: (identifier) @type-binding.name - right: (call - function: (identifier) @type-binding.type)) @type-binding.constructor - -; ─── Type bindings: variable annotations ─────────────────────────────────── -; -; `u: User` or `u: User = some_value` — `u` is explicitly annotated. Both -; forms parse under tree-sitter-python as `(assignment left: type:)` with -; an optional `right:`. Module-, class-, and function-scope annotations -; all land here; scope attachment is handled by the central extractor -; via the anchor's innermost-containing scope. -; -; Emits `source: 'annotation'`. - -(assignment - left: (identifier) @type-binding.name - type: (type) @type-binding.type) @type-binding.annotation - -; For-loop iterable of a free-call result: `for u in get_users()` — -; binds `u → get_users` so the chain post-pass follows it through -; `get_users`'s return-type annotation (cross-file via -; `propagateImportedReturnTypes`). -(for_statement - left: (identifier) @type-binding.name - right: (call - function: (identifier) @type-binding.type)) @type-binding.alias - -; for (i, u) in enumerate(X) and for i, u in enumerate(X) — bind the -; second tuple element to X. enumerate yields (int, X-element); the -; chain-follow unwraps X via generic-strip when X is an annotated -; collection. -(for_statement - left: (tuple_pattern - (identifier) - (identifier) @type-binding.name) - right: (call - function: (identifier) @_enum - arguments: (argument_list - (identifier) @type-binding.type)) - (#eq? @_enum "enumerate")) @type-binding.alias - -(for_statement - left: (pattern_list - (identifier) - (identifier) @type-binding.name) - right: (call - function: (identifier) @_enum - arguments: (argument_list - (identifier) @type-binding.type)) - (#eq? @_enum "enumerate")) @type-binding.alias - -; for k, v in d.items() — bind v to d. The chain-follow unwraps d's -; dict[K, V] annotation to V via the dict-aware stripGeneric. -(for_statement - left: (pattern_list - (identifier) - (identifier) @type-binding.name) - right: (call - function: (attribute - object: (identifier) @type-binding.type - attribute: (identifier) @_items)) - (#eq? @_items "items")) @type-binding.alias - -(for_statement - left: (tuple_pattern - (identifier) - (identifier) @type-binding.name) - right: (call - function: (attribute - object: (identifier) @type-binding.type - attribute: (identifier) @_items)) - (#eq? @_items "items")) @type-binding.alias - -; for i, (k, v) in enumerate(d.items()) — nested tuple destructuring. -(for_statement - left: (pattern_list - (identifier) - (tuple_pattern - (identifier) - (identifier) @type-binding.name)) - right: (call - function: (identifier) @_enum - arguments: (argument_list - (call - function: (attribute - object: (identifier) @type-binding.type - attribute: (identifier) @_items)))) - (#eq? @_enum "enumerate") - (#eq? @_items "items")) @type-binding.alias - -; for i, k, v in enumerate(d.items()) — 3-var flat destructuring. -(for_statement - left: (pattern_list - (identifier) - (identifier) - (identifier) @type-binding.name) - right: (call - function: (identifier) @_enum - arguments: (argument_list - (call - function: (attribute - object: (identifier) @type-binding.type - attribute: (identifier) @_items)))) - (#eq? @_enum "enumerate") - (#eq? @_items "items")) @type-binding.alias - -; for u in self.X — heuristic: bind u to X (the attribute name). -; The chain-follow resolves X via the enclosing method's parameter -; typeBinding. Supports fixtures that reference `self.X` as a stand-in -; for a parameter X (matches legacy DAG fallback behavior). -(for_statement - left: (identifier) @type-binding.name - right: (attribute - object: (identifier) @_self - attribute: (identifier) @type-binding.type) - (#eq? @_self "self")) @type-binding.alias - -; for v in d.values() — bind v to d (dict-strip yields value type). -(for_statement - left: (identifier) @type-binding.name - right: (call - function: (attribute - object: (identifier) @type-binding.type - attribute: (identifier) @_values)) - (#eq? @_values "values")) @type-binding.alias - -; ─── Type bindings: function return-type annotations ───────────────────── -; -; `def get_user() -> User:` — binds the function's NAME to its return -; type in the enclosing scope. Combined with the constructor-inferred + -; chain-follow path, `u = get_user()` then resolves `u: User` cross- -; call. Python provider hoists the binding via `pythonBindingScopeFor` -; to the function's parent scope so callers in module/class scope see it. - -(function_definition - name: (identifier) @type-binding.name - return_type: (type) @type-binding.type) @type-binding.return - -; ─── References: calls ───────────────────────────────────────────────────── -; -; Free call: `print(x)` — function is a bare identifier -; Member call: `obj.save()` — function is an attribute access - -(call - function: (identifier) @reference.name) @reference.call.free - -(call - function: (attribute - object: (_) @reference.receiver - attribute: (identifier) @reference.name)) @reference.call.member - -; Attribute write: `obj.name = "x"` — emits ACCESSES (write) edge from -; the enclosing function to the field on obj's class. -(assignment - left: (attribute - object: (_) @reference.receiver - attribute: (identifier) @reference.name)) @reference.write.member diff --git a/gitnexus/src/core/ingestion/languages/python/simple-hooks.ts b/gitnexus/src/core/ingestion/languages/python/simple-hooks.ts index 66c4e9ec5..aafc374a6 100644 --- a/gitnexus/src/core/ingestion/languages/python/simple-hooks.ts +++ b/gitnexus/src/core/ingestion/languages/python/simple-hooks.ts @@ -8,12 +8,30 @@ import type { CaptureMatch, + NodeLabel, ParsedImport, Scope, ScopeId, ScopeTree, TypeRef, } from 'gitnexus-shared'; +import type { SyntaxNode } from 'tree-sitter'; +import { findAncestorBeforeBoundary, FUNCTION_NODE_TYPES } from '../../utils/ast-helpers.js'; + +const PYTHON_METHOD_CONTAINER_TYPES: ReadonlySet = new Set(['class_definition']); + +export function pythonFunctionDefinitionLabel( + functionNode: SyntaxNode, + defaultLabel: NodeLabel, +): NodeLabel { + if (defaultLabel !== 'Function') return defaultLabel; + const ancestor = findAncestorBeforeBoundary( + functionNode, + PYTHON_METHOD_CONTAINER_TYPES, + FUNCTION_NODE_TYPES, + ); + return ancestor === null ? 'Function' : 'Method'; +} // ─── bindingScopeFor ────────────────────────────────────────────────────── diff --git a/gitnexus/src/core/ingestion/languages/swift.ts b/gitnexus/src/core/ingestion/languages/swift.ts index 38f5d8147..7b74386a8 100644 --- a/gitnexus/src/core/ingestion/languages/swift.ts +++ b/gitnexus/src/core/ingestion/languages/swift.ts @@ -11,7 +11,7 @@ */ import { SupportedLanguages } from 'gitnexus-shared'; -import type { NodeLabel } from 'gitnexus-shared'; +import type { NodeLabel, SymbolDefinition } from 'gitnexus-shared'; import { createClassExtractor } from '../class-extractors/generic.js'; import { swiftClassConfig } from '../class-extractors/configs/swift.js'; import { defineLanguage } from '../language-provider.js'; @@ -128,6 +128,24 @@ const swiftExtractFunctionName = ( return null; // fall through to generic }; +const orderSwiftSameNameTypeCandidates = ({ + callSiteFilePath, + candidates, +}: { + readonly typeName: string; + readonly callSiteFilePath: string; + readonly candidates: readonly SymbolDefinition[]; +}): readonly SymbolDefinition[] | null => { + if (!callSiteFilePath.endsWith('.swift')) return null; + if (candidates.length <= 1) return null; + if (!candidates.every((c) => c.type === candidates[0].type)) return null; + if (candidates[0].type !== 'Class' && candidates[0].type !== 'Struct') return null; + if (!candidates.every((c) => c.filePath.endsWith('.swift'))) return null; + return [...candidates].sort( + (a, b) => a.filePath.length - b.filePath.length || a.filePath.localeCompare(b.filePath), + ); +}; + const BUILT_INS: ReadonlySet = new Set([ 'print', 'debugPrint', @@ -257,5 +275,6 @@ export const swiftProvider = defineLanguage({ classExtractor: createClassExtractor(swiftClassConfig), heritageExtractor: createHeritageExtractor(SupportedLanguages.Swift), implicitImportWirer: wireSwiftImplicitImports, + orderSameNameTypeCandidates: orderSwiftSameNameTypeCandidates, builtInNames: BUILT_INS, }); diff --git a/gitnexus/src/core/ingestion/languages/typescript.ts b/gitnexus/src/core/ingestion/languages/typescript.ts index 0a1c3f28f..e9dc21ab4 100644 --- a/gitnexus/src/core/ingestion/languages/typescript.ts +++ b/gitnexus/src/core/ingestion/languages/typescript.ts @@ -44,6 +44,17 @@ import { javascriptCallConfig, } from '../call-extractors/configs/typescript-javascript.js'; import { createHeritageExtractor } from '../heritage-extractors/generic.js'; +import { + emitTsScopeCaptures, + interpretTsImport, + interpretTsTypeBinding, + tsBindingScopeFor, + tsImportOwningScope, + tsReceiverBinding, + typescriptMergeBindings, + typescriptArityCompatibility, + resolveTsImportTarget, +} from './typescript/index.js'; /** * TypeScript/JavaScript: arrow_function and function_expression get their name @@ -185,6 +196,25 @@ export const typescriptProvider = defineLanguage({ classExtractor: createClassExtractor(typescriptClassConfig), heritageExtractor: createHeritageExtractor(SupportedLanguages.TypeScript), builtInNames: BUILT_INS, + + // ── RFC #909 Ring 3: scope-based resolution hooks (RFC §5) ────────── + // TypeScript is the third migration after Python and C#. See + // ./typescript/index.ts for the full per-hook rationale and the + // canonical capture vocabulary in ./typescript/query.ts + // (TYPESCRIPT_SCOPE_QUERY constant). + emitScopeCaptures: emitTsScopeCaptures, + interpretImport: interpretTsImport, + interpretTypeBinding: interpretTsTypeBinding, + bindingScopeFor: tsBindingScopeFor, + importOwningScope: tsImportOwningScope, + // Merge precedence is decided from BindingRef origin + declaration + // space only. The central finalizer already calls this per (scope, + // name), so the Scope object itself intentionally does not affect + // TypeScript declaration merging. + mergeBindings: (_scope, bindings) => typescriptMergeBindings(bindings), + receiverBinding: tsReceiverBinding, + arityCompatibility: typescriptArityCompatibility, + resolveImportTarget: resolveTsImportTarget, }); export const javascriptProvider = defineLanguage({ diff --git a/gitnexus/src/core/ingestion/languages/typescript/arity-metadata.ts b/gitnexus/src/core/ingestion/languages/typescript/arity-metadata.ts new file mode 100644 index 000000000..97ac7a0c9 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/typescript/arity-metadata.ts @@ -0,0 +1,113 @@ +/** + * Extract TypeScript arity metadata from a method-like tree-sitter node — + * `method_definition`, `method_signature`, `abstract_method_signature`, + * `function_declaration`, `generator_function_declaration`, or + * `function_signature` (overload signature). + * + * Reuses `typescriptMethodConfig.extractParameters` so scope-extracted defs + * carry the same arity semantics as the legacy parse-worker path: + * - Rest parameters (`...args: T[]`) collapse `parameterCount` to + * `undefined`, which `typescriptArityCompatibility` treats as + * "max unknown" — the candidate stays eligible at + * `argCount >= required` (mirrors Python `*args` / C# `params`). + * - Optional (`p?: T`) and defaulted (`p: T = …`) parameters both + * contribute to `optionalCount`; + * `requiredParameterCount = total − optionalCount`. + * - `parameterTypes` collects declared type-annotation text for + * overload narrowing; TypeScript supports function overloading + * (`function f(x: string); function f(x: number); function f(x) {}`), + * so populated types let the registry disambiguate same-arity + * siblings by declared types. + * - A literal `'params'` marker is appended for variadic methods so + * `typescriptArityCompatibility` can detect rest params without + * re-reading the AST. + * + * ## Generics stripping + * + * TypeScript parameter types frequently contain generic instantiations + * (`User`, `Array`, `Promise`). For overload + * narrowing by declared type, we want the "head" name — `User`, + * `Array`, `Promise` — so `arity-metadata` applies a light strip to + * each `parameterTypes[i]`: + * + * - `Foo` → `Foo` + * - `Foo` → `Foo` + * - `Foo[]` → `Foo` + * - `Foo[]` → `Foo` + * - `Foo>` → `Foo` (greedy — strip the outermost once) + * - plain `Foo` → `Foo` + * + * We do NOT strip unions / intersections at this layer — those stay + * intact because the registry's overload narrowing is a string + * equality check; union types shouldn't match anything and we prefer + * "unknown" to "accidental match". `undefined` / `null` in unions + * (TS strict mode) is handled by `interpret.ts`'s `stripNullableUnion` + * when the name would be consumed as a receiver type — that path is + * separate from this arity-metadata path. + * + * Generic type parameters on the function itself (`function f(x: T)`) + * do NOT enter here — the method extractor reads the `parameters` + * field only, which contains value parameters, not type parameters. + */ + +import type { SyntaxNode } from '../../utils/ast-helpers.js'; +import { typescriptMethodConfig } from '../../method-extractors/configs/typescript-javascript.js'; + +interface TsArityMetadata { + readonly parameterCount: number | undefined; + readonly requiredParameterCount: number | undefined; + readonly parameterTypes: readonly string[] | undefined; +} + +export function computeTsArityMetadata(fnNode: SyntaxNode): TsArityMetadata { + const params = typescriptMethodConfig.extractParameters?.(fnNode) ?? []; + + let hasRest = false; + let optionalCount = 0; + const types: string[] = []; + for (const p of params) { + if (p.isVariadic) hasRest = true; + else if (p.isOptional) optionalCount++; + const t = p.type !== null && p.type !== undefined ? stripGenericsAndArraySuffix(p.type) : ''; + types.push(t); + } + if (hasRest) types.push('params'); + + const total = params.length; + const parameterCount = hasRest ? undefined : total; + const requiredParameterCount = hasRest ? undefined : total - optionalCount; + + // Only emit parameterTypes when at least one param carries a non- + // empty type name. An array of all empty strings adds noise to the + // registry without aiding narrowing — callers treat absence as + // "types unknown". + const hasAnyType = types.some((t) => t !== '' && t !== 'params'); + const parameterTypes = hasAnyType || hasRest ? (types.length > 0 ? types : undefined) : undefined; + + return { + parameterCount, + requiredParameterCount, + parameterTypes, + }; +} + +/** + * Light generic + array-suffix strip used only for registry overload + * narrowing. See file-level JSDoc for the exact transformation table. + * + * Handles nesting greedily at the outermost level: + * `Foo>[]` — strip `[]` → `Foo>`, then strip + * outermost `<>` → `Foo`. + */ +function stripGenericsAndArraySuffix(raw: string): string { + let t = raw.trim(); + // Repeatedly peel trailing `[]` pairs, then peel the outermost `<…>` + // block once. We don't loop the `<>` peel since nesting is rare and + // the head name is already reached after one peel. + while (t.endsWith('[]')) t = t.slice(0, -2).trim(); + const lt = t.indexOf('<'); + if (lt > 0 && t.endsWith('>')) { + t = t.slice(0, lt).trim(); + } + return t; +} diff --git a/gitnexus/src/core/ingestion/languages/typescript/arity.ts b/gitnexus/src/core/ingestion/languages/typescript/arity.ts new file mode 100644 index 000000000..d56b3479f --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/typescript/arity.ts @@ -0,0 +1,61 @@ +/** + * TypeScript arity check, accommodating rest parameters and optional + * (`p?: T`) / defaulted (`p: T = …`) parameters. + * + * TypeScript-specific semantics vs C#: + * + * - **Optional** — `p?: T` collapses to `isOptional` in the extractor + * and contributes to `optionalCount`, so `requiredParameterCount` + * excludes it. Same wire shape as a default-valued parameter. + * - **Rest** — `...args: T[]` makes `parameterCount` undefined (max + * unknown) and `parameterTypes` carries a literal `'params'` marker + * so this hook can detect variadic calls without re-reading the AST + * (mirrors the C# convention for cross-language consistency). + * - **Generics** — function-level generic type parameters (``) + * do NOT count toward arity; the method-extractor reads the + * `parameters` field and ignores `type_parameters`, so generic + * count never enters the metadata. + * + * The metadata shape (`parameterCount`, `requiredParameterCount`, + * `parameterTypes`) is synthesized by `arity-metadata.ts` and stored + * on `SymbolDefinition`. This file consumes that metadata. + * + * Verdicts: + * - `'compatible'` — `requiredParameterCount <= argCount <= + * parameterCount`, OR the def has rest params + * (any `argCount >= required`). + * - `'incompatible'` — argCount is below required, OR above max with + * no rest params. + * - `'unknown'` — metadata is absent / incomplete (treated as + * neutral by the registry). + * + * `'incompatible'` is a soft signal in `Registry.lookup` (penalized + * but still considered when no compatible candidate exists), per + * RFC §4. + */ + +import type { Callsite, SymbolDefinition } from 'gitnexus-shared'; + +export function typescriptArityCompatibility( + def: SymbolDefinition, + callsite: Callsite, +): 'compatible' | 'unknown' | 'incompatible' { + const max = def.parameterCount; + const min = def.requiredParameterCount; + if (max === undefined && min === undefined) return 'unknown'; + + const argCount = callsite.arity; + if (!Number.isFinite(argCount) || argCount < 0) return 'unknown'; + + // Variadic detection: the `arity-metadata` synthesizer appends the + // literal `'params'` marker to `parameterTypes` when the def has a + // rest parameter, to avoid re-parsing the AST here. + const hasRest = + def.parameterTypes !== undefined && + def.parameterTypes.some((t) => t === 'params' || t.startsWith('params ')); + + if (min !== undefined && argCount < min) return 'incompatible'; + if (max !== undefined && argCount > max && !hasRest) return 'incompatible'; + + return 'compatible'; +} diff --git a/gitnexus/src/core/ingestion/languages/typescript/cache-stats.ts b/gitnexus/src/core/ingestion/languages/typescript/cache-stats.ts new file mode 100644 index 000000000..3476f6f60 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/typescript/cache-stats.ts @@ -0,0 +1,32 @@ +/** + * Dev-mode counters for the TypeScript cross-phase scope-captures parse cache. + * + * Gated by `PROF_SCOPE_RESOLUTION=1`. In production the module-level `PROF` + * constant is `false` and V8 folds every increment site into dead code, so the + * hot path in `captures.ts` stays branch-free. + * + * Extracted from `captures.ts` so the production hot-path module doesn't carry + * a module-global counter and its reset/export surface. + */ + +const PROF = process.env.PROF_SCOPE_RESOLUTION === '1'; + +let CACHE_HITS = 0; +let CACHE_MISSES = 0; + +export function recordCacheHit(): void { + if (PROF) CACHE_HITS++; +} + +export function recordCacheMiss(): void { + if (PROF) CACHE_MISSES++; +} + +export function getTypescriptCaptureCacheStats(): { hits: number; misses: number } { + return { hits: CACHE_HITS, misses: CACHE_MISSES }; +} + +export function resetTypescriptCaptureCacheStats(): void { + CACHE_HITS = 0; + CACHE_MISSES = 0; +} diff --git a/gitnexus/src/core/ingestion/languages/typescript/captures.ts b/gitnexus/src/core/ingestion/languages/typescript/captures.ts new file mode 100644 index 000000000..e7bb91698 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/typescript/captures.ts @@ -0,0 +1,504 @@ +/** + * `emitScopeCaptures` for TypeScript. + * + * Drives the TypeScript scope query against tree-sitter-typescript and groups + * raw matches into `CaptureMatch[]` for the central extractor. Layers + * synthesized streams on top: + * + * 1. **Import decomposition** — each `import_statement` / re-export is + * re-emitted with `@import.kind/source/name/alias/typeOnly` markers so + * `interpretTsImport` can recover the `ParsedImport` shape without + * re-parsing raw text (see `import-decomposer.ts`). Unit 2 adds this; + * until then, raw `@import.statement` matches flow through as-is. + * 2. **Dynamic imports** — `import('./m')` is re-emitted as a + * decomposed `@import.statement` with `@import.kind=dynamic` so the + * central extractor treats it uniformly with static imports. + * 3. **Function-decl arity metadata** (Unit 5) — `@declaration.parameter-count` + * / `@declaration.required-parameter-count` / `@declaration.parameter-types` + * synthesized onto function-like declarations so the registry can narrow + * overloads. + * 4. **Callsite arity metadata** (Unit 5) — `@reference.arity` / + * `@reference.parameter-types` on every callsite. + * 5. **Receiver-binding synthesis** (Unit 3) — `this` type anchors on + * instance methods, with arrow-function lexical-this walk-up. + * + * Pure given the input source text. No I/O, no globals consulted. + */ + +import type { Capture, CaptureMatch } from 'gitnexus-shared'; +import { + findNodeAtRange, + nodeToCapture, + syntheticCapture, + type SyntaxNode, +} from '../../utils/ast-helpers.js'; +import { splitImportStatement } from './import-decomposer.js'; +import { getTsParser, getTsScopeQuery, tsCachedTreeMatchesGrammar } from './query.js'; +import { recordCacheHit, recordCacheMiss } from './cache-stats.js'; +import { synthesizeTsReceiverBinding } from './receiver-binding.js'; +import { computeTsArityMetadata } from './arity-metadata.js'; +import { getTreeSitterBufferSize } from '../../constants.js'; + +/** tree-sitter-typescript node types for function-like scopes that may + * carry a synthesized `this` binding. Kept in sync with the + * `@scope.function` patterns in `query.ts`. */ +const FUNCTION_NODE_TYPES = [ + 'method_definition', + 'method_signature', + 'abstract_method_signature', + 'arrow_function', + 'function_expression', + 'function_declaration', + 'generator_function_declaration', + 'function_signature', +] as const; + +/** Declaration anchors that carry function-like arity metadata. */ +const FUNCTION_DECL_TAGS = ['@declaration.method', '@declaration.function'] as const; + +/** Callsite anchors that should carry `@reference.arity` + param types. */ +const CALL_TAGS = [ + '@reference.call.free', + '@reference.call.member', + '@reference.call.constructor', +] as const; + +function pickFirstDefined(grouped: CaptureMatch, tags: readonly string[]): Capture | undefined { + for (const tag of tags) { + const cap = grouped[tag]; + if (cap !== undefined) return cap; + } + return undefined; +} + +/** + * Drop `@reference.read.member` matches whose underlying `member_expression` + * is NOT actually a read context: + * + * 1. The member_expression is the `function:` of a `call_expression` + * (it's a call, already captured as `@reference.call.member`). + * 2. The member_expression is the `constructor:` of a `new_expression` + * (already captured as `@reference.call.constructor.qualified`). + * 3. The member_expression is the `left:` of an `assignment_expression` / + * `augmented_assignment_expression` (it's a write, already captured + * as `@reference.write.member`). + * 4. The member_expression is the `function:` of an `await_expression` + * being called (handled by the member-call capture). + * + * Returns `true` when the capture should be kept as a read reference, + * `false` when it should be dropped. + */ +function shouldEmitReadMember(memberNode: SyntaxNode): boolean { + const parent = memberNode.parent; + if (parent === null) return true; + switch (parent.type) { + case 'call_expression': + return parent.childForFieldName('function')?.id !== memberNode.id; + case 'new_expression': + return parent.childForFieldName('constructor')?.id !== memberNode.id; + case 'assignment_expression': + case 'augmented_assignment_expression': + return parent.childForFieldName('left')?.id !== memberNode.id; + default: + return true; + } +} + +export function emitTsScopeCaptures( + sourceText: string, + filePath: string, + cachedTree?: unknown, +): readonly CaptureMatch[] { + // Skip the parse when the caller (parse phase's scopeTreeCache) already + // produced a Tree for this source. Cache miss = re-parse, same as before. + // The cachedTree parameter is typed as `unknown` at the LanguageProvider + // contract layer; cast here at the use site. + // + // Grammar selection: `.tsx` files are parsed with the TSX grammar, + // `.ts` files with the TypeScript grammar. The two grammars have + // separate node-type id spaces, so a Query compiled against one + // cannot match a Tree produced by the other. We validate the cached + // tree's grammar against the file extension and fall back to a + // fresh parse if they disagree (e.g. a worker-mode parse landed + // with the wrong grammar pinned). + let tree = cachedTree as ReturnType['parse']> | undefined; + if (tree !== undefined && !tsCachedTreeMatchesGrammar(tree, filePath)) { + tree = undefined; + } + if (tree === undefined) { + tree = getTsParser(filePath).parse(sourceText, undefined, { + bufferSize: getTreeSitterBufferSize(sourceText), + }); + recordCacheMiss(); + } else { + recordCacheHit(); + } + + const rawMatches = getTsScopeQuery(filePath).matches(tree.rootNode); + const out: CaptureMatch[] = []; + + for (const m of rawMatches) { + // Group captures by their tag name. Tree-sitter strips the leading + // `@`; we put it back so the central extractor's prefix lookups + // (`@scope.`, `@declaration.`, …) work. + const grouped: Record = {}; + for (const c of m.captures) { + const tag = '@' + c.name; + grouped[tag] = nodeToCapture(tag, c.node); + } + if (Object.keys(grouped).length === 0) continue; + + // Decompose each `import_statement` / re-export `export_statement` + // so `interpretTsImport` sees the kind/source/name/alias markers + // it consumes. The raw query anchor carries only @import.statement. + // Side-effect imports emit a non-binding marker so finalize can keep + // the file-level dependency. + if (grouped['@import.statement'] !== undefined) { + const stmtCapture = grouped['@import.statement']; + const stmtNode = + findNodeAtRange(tree.rootNode, stmtCapture.range, 'import_statement') ?? + findNodeAtRange(tree.rootNode, stmtCapture.range, 'export_statement'); + if (stmtNode !== null) { + const decomposed = splitImportStatement(stmtNode); + for (const d of decomposed) out.push(d); + } + // If decomposition yielded nothing (malformed/bare anchor), drop + // the match. Emitting a bare + // @import.statement without kind/source would confuse the + // central extractor. + continue; + } + + // Dynamic imports — decompose via the same path. `@import.dynamic` + // is anchored on a `call_expression`, which the decomposer's + // `splitDynamicImport` branch consumes. + if (grouped['@import.dynamic'] !== undefined) { + const dynCapture = grouped['@import.dynamic']; + const callNode = findNodeAtRange(tree.rootNode, dynCapture.range, 'call_expression'); + if (callNode !== null) { + const decomposed = splitImportStatement(callNode); + for (const d of decomposed) out.push(d); + } + continue; + } + + // Filter out `@reference.read.member` matches whose AST parent tells + // us they are actually calls / writes / constructor invocations. The + // tree-sitter pattern is context-free and matches every member_expression; + // we rely on this emit-side filter so the query stays simple. + if (grouped['@reference.read.member'] !== undefined) { + const anchor = grouped['@reference.read.member']; + const memberNode = findNodeAtRange(tree.rootNode, anchor.range, 'member_expression'); + if (memberNode === null || !shouldEmitReadMember(memberNode)) { + continue; + } + } + + // Synthesize arity metadata on function-like declaration anchors + // before pushing the match. The registry uses these to narrow + // overloads — TypeScript supports overload signatures via + // function_signature, so `parameterTypes` is populated when + // available. + const declAnchor = pickFirstDefined(grouped, FUNCTION_DECL_TAGS); + if (declAnchor !== undefined) { + const fnNode = findFunctionNode(tree.rootNode, declAnchor.range); + if (fnNode !== null) { + const arity = computeTsArityMetadata(fnNode); + if (arity.parameterCount !== undefined) { + grouped['@declaration.parameter-count'] = syntheticCapture( + '@declaration.parameter-count', + fnNode, + String(arity.parameterCount), + ); + } + if (arity.requiredParameterCount !== undefined) { + grouped['@declaration.required-parameter-count'] = syntheticCapture( + '@declaration.required-parameter-count', + fnNode, + String(arity.requiredParameterCount), + ); + } + if (arity.parameterTypes !== undefined) { + grouped['@declaration.parameter-types'] = syntheticCapture( + '@declaration.parameter-types', + fnNode, + JSON.stringify(arity.parameterTypes), + ); + } + } + } + + // Synthesize `@reference.arity` on every callsite so the registry's + // arity filter can narrow overloads. Count the `argument` named + // children of the backing `arguments` node. TypeScript constructor + // calls use `new_expression`; regular calls use `call_expression`. + const callAnchor = pickFirstDefined(grouped, CALL_TAGS); + if (callAnchor !== undefined && grouped['@reference.arity'] === undefined) { + const callNode = + findNodeAtRange(tree.rootNode, callAnchor.range, 'call_expression') ?? + findNodeAtRange(tree.rootNode, callAnchor.range, 'new_expression'); + if (callNode !== null) { + const argList = callNode.childForFieldName('arguments'); + const args: SyntaxNode[] = + argList === null + ? [] + : argList.namedChildren.filter( + (c): c is SyntaxNode => c !== null && c.type !== 'comment', + ); + grouped['@reference.arity'] = syntheticCapture( + '@reference.arity', + callNode, + String(args.length), + ); + + const argTypes = args.map((arg) => inferArgType(arg)); + grouped['@reference.parameter-types'] = syntheticCapture( + '@reference.parameter-types', + callNode, + JSON.stringify(argTypes), + ); + } + } + + out.push(grouped); + + // Synthesize `this` receiver type-bindings on every function-like + // scope that is structurally a class member. `receiver-binding.ts` + // handles the walk-up (method, method_signature, abstract + // signature, arrow/function-expression assigned to a class field). + // Arrow functions nested inside method bodies rely on scope-chain + // lookup instead of synthesis — covered by `tsReceiverBinding`. + const scopeFnAnchor = grouped['@scope.function']; + if (scopeFnAnchor !== undefined) { + const fnNode = findFunctionNode(tree.rootNode, scopeFnAnchor.range); + if (fnNode !== null) { + const synth = synthesizeTsReceiverBinding(fnNode); + if (synth !== null) out.push(synth); + } + } + } + + // Synthesize object-destructuring type bindings. The tree-sitter query + // alone can't express "give me the field NAME and the RHS identifier + // together" in a way that produces usable @type-binding.name / + // @type-binding.type captures, so we walk `variable_declarator` nodes + // whose `name:` is an `object_pattern` and synthesize per-field + // bindings keyed to the receiver-path `rhsName.fieldName`. The + // compound-receiver resolver's Case 3b then walks that path when the + // destructured local is used as a receiver (e.g. `address.save()`). + synthesizeDestructuringBindings(tree.rootNode, out); + synthesizeForOfMapTupleBindings(tree.rootNode, out); + synthesizeInstanceofNarrowings(tree.rootNode, out); + + return out; +} + +/** + * Walk the AST and synthesize type-binding captures for object + * destructuring of the form `const { field } = rhs` or + * `const { field: alias } = rhs`. Pushes one synthetic CaptureMatch + * per destructured identifier with: + * + * - `@type-binding.name` → the local identifier + * - `@type-binding.type` → the compound path `rhs.field` + * - `@type-binding.destructured` anchor + * + * Only fires when the RHS is a bare identifier — more complex RHS + * shapes (call_expression, member_expression) resolve via the normal + * type-alias + chain-follow paths on the RHS first, then the field + * walk catches the destructured identifier on a second fixpoint pass. + * Left as a follow-up optimization. + */ +function synthesizeDestructuringBindings(root: SyntaxNode, out: CaptureMatch[]): void { + const stack: SyntaxNode[] = [root]; + for (;;) { + const node = stack.pop(); + if (node === undefined) break; + for (const child of node.namedChildren) { + if (child !== null) stack.push(child); + } + if (node.type !== 'variable_declarator') continue; + const nameNode = node.childForFieldName('name'); + const valueNode = node.childForFieldName('value'); + if (nameNode === null || valueNode === null) continue; + if (nameNode.type !== 'object_pattern') continue; + if (valueNode.type !== 'identifier') continue; + const rhsName = valueNode.text; + for (const fieldNode of nameNode.namedChildren) { + if (fieldNode === null) continue; + if (fieldNode.type === 'shorthand_property_identifier_pattern') { + // `const { address } = user` + const localName = fieldNode.text; + out.push({ + '@type-binding.name': syntheticCapture('@type-binding.name', fieldNode, localName), + '@type-binding.type': syntheticCapture( + '@type-binding.type', + fieldNode, + `${rhsName}.${localName}`, + ), + '@type-binding.destructured': syntheticCapture( + '@type-binding.destructured', + fieldNode, + fieldNode.text, + ), + }); + } else if (fieldNode.type === 'pair_pattern') { + // `const { address: addr } = user` + const key = fieldNode.childForFieldName('key'); + const value = fieldNode.childForFieldName('value'); + if (key === null || value === null) continue; + if (value.type !== 'identifier') continue; + const fieldName = key.text; + const localName = value.text; + out.push({ + '@type-binding.name': syntheticCapture('@type-binding.name', value, localName), + '@type-binding.type': syntheticCapture( + '@type-binding.type', + fieldNode, + `${rhsName}.${fieldName}`, + ), + '@type-binding.destructured': syntheticCapture( + '@type-binding.destructured', + fieldNode, + fieldNode.text, + ), + }); + } + } + } +} + +/** + * `for (const [k, v] of mapId)` over a `Map` — synthesize per-slot + * type bindings so `v` resolves like a `Map` iterator tuple element. + * Uses sentinel `__MAP_TUPLE_i__:rhs` consumed by compound-receiver. + */ +function synthesizeForOfMapTupleBindings(root: SyntaxNode, out: CaptureMatch[]): void { + const stack: SyntaxNode[] = [root]; + for (;;) { + const node = stack.pop(); + if (node === undefined) break; + for (const child of node.namedChildren) { + if (child !== null) stack.push(child); + } + if (node.type !== 'for_in_statement') continue; + const left = node.childForFieldName('left'); + const right = node.childForFieldName('right'); + if (left === null || right === null) continue; + if (left.type !== 'array_pattern' || right.type !== 'identifier') continue; + const rhs = right.text; + let slot = 0; + for (const child of left.namedChildren) { + if (child === null || child.type !== 'identifier') continue; + const localName = child.text; + out.push({ + '@type-binding.name': syntheticCapture('@type-binding.name', child, localName), + '@type-binding.type': syntheticCapture( + '@type-binding.type', + child, + `__MAP_TUPLE_${slot}__:${rhs}`, + ), + '@type-binding.map-tuple-entry': syntheticCapture( + '@type-binding.map-tuple-entry', + child, + String(slot), + ), + }); + slot++; + } + } +} + +/** + * `if (x instanceof User) { x.save() }` — synthesize a `User` type binding + * for `x` anchored in the consequence block so scope-chain lookup inside + * the then-branch sees the narrowed type. + * + * **Known limitation:** the LHS must be a bare `identifier` and the RHS + * an `identifier`/`type_identifier`. Member-expression LHS such as + * `if (user.address instanceof Address)` is intentionally NOT synthesized + * — narrowing a property-access target requires a stable storage key + * the binding layer can hold, which member chains don't supply. Field- + * type resolution covers the common case for those receivers via + * declared types instead. + */ +function synthesizeInstanceofNarrowings(root: SyntaxNode, out: CaptureMatch[]): void { + const stack: SyntaxNode[] = [root]; + for (;;) { + const node = stack.pop(); + if (node === undefined) break; + for (const child of node.namedChildren) { + if (child !== null) stack.push(child); + } + if (node.type !== 'if_statement') continue; + const cond = node.childForFieldName('condition'); + if (cond === null) continue; + const inner = cond.type === 'parenthesized_expression' ? cond.namedChildren[0] : cond; + if (inner === null || inner.type !== 'binary_expression') continue; + const op = inner.childForFieldName('operator'); + const left = inner.childForFieldName('left'); + const right = inner.childForFieldName('right'); + if (op === null || left === null || right === null) continue; + if (op.type !== 'instanceof') continue; + if (left.type !== 'identifier') continue; + if (right.type !== 'identifier' && right.type !== 'type_identifier') continue; + const varName = left.text; + const typeName = right.text; + const cons = node.childForFieldName('consequence'); + if (cons === null) continue; + out.push({ + '@type-binding.name': syntheticCapture('@type-binding.name', cons, varName), + '@type-binding.type': syntheticCapture('@type-binding.type', right, typeName), + '@type-binding.instanceof-narrow': syntheticCapture( + '@type-binding.instanceof-narrow', + cons, + '1', + ), + }); + } +} + +/** Infer a TypeScript argument expression's static type from literal + * shapes. Returns `''` when the arg has no statically-derivable type + * (identifiers, member accesses, etc.) — consumers treat unknown as + * any-match during overload narrowing. */ +function inferArgType(argNode: SyntaxNode): string { + switch (argNode.type) { + case 'number': + return 'number'; + case 'string': + case 'template_string': + return 'string'; + case 'true': + case 'false': + return 'boolean'; + case 'null': + return 'null'; + case 'undefined': + return 'undefined'; + case 'array': + return 'Array'; + case 'object': + return 'object'; + case 'regex': + return 'RegExp'; + case 'new_expression': { + const ctor = argNode.childForFieldName('constructor'); + return ctor?.text ?? ''; + } + default: + return ''; + } +} + +/** Find the first TypeScript function-like node at the given range. + * The `@scope.function` anchor range covers the whole node, but the + * tag alone doesn't identify which node type among the many TS + * function-likes. */ +function findFunctionNode(rootNode: SyntaxNode, range: Capture['range']): SyntaxNode | null { + for (const nodeType of FUNCTION_NODE_TYPES) { + const n = findNodeAtRange(rootNode, range, nodeType); + if (n !== null) return n; + } + return null; +} diff --git a/gitnexus/src/core/ingestion/languages/typescript/import-decomposer.ts b/gitnexus/src/core/ingestion/languages/typescript/import-decomposer.ts new file mode 100644 index 000000000..babcb45da --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/typescript/import-decomposer.ts @@ -0,0 +1,438 @@ +/** + * Decompose a TypeScript `import_statement` / re-export `export_statement` / + * dynamic `call_expression(import)` into one `CaptureMatch` per imported + * name. + * + * Why split here? The `LanguageProvider.interpretImport` contract is + * one `ParsedImport` per call. Tree-sitter delivers + * + * import D, { X as Y, type Z } from './m' + * + * as a single `import_statement` match, so without decomposition we'd + * lose names. The synthesized markers (`@import.kind` / `@import.name` + * / `@import.alias` / `@import.source`) carry everything + * `interpretTsImport` needs to recover the `ParsedImport` shape — + * see `interpret.ts`. + * + * Kinds we emit and how `interpret.ts` maps them to `ParsedImport`: + * + * - `default` : `import D from './m'` → alias (importedName=default) + * - `named` : `import { X } from './m'` → named + * - `named-alias` : `import { X as Y } from './m'` → alias + * - `namespace` : `import * as N from './m'` → namespace + * - `reexport` : `export { X } from './m'` → reexport + * - `reexport-alias` : `export { X as Y } from './m'` → reexport (with alias) + * - `reexport-wildcard` : `export * from './m'` → wildcard + * - `reexport-namespace` : `export * as ns from './m'` → namespace (local=ns,imported=source) + * - `dynamic` : `import('./m')` / `import(x)` → dynamic-resolved or dynamic-unresolved + * + * Type-only constructs (`import type { X }`, `import { type X }`, + * `export type { X }`) emit the same kinds as runtime forms — at the + * TypeScript scope-resolution layer, types and values share the same + * lookup; runtime-emission is a downstream concern. + * + * Side-effect imports (`import './polyfill'`) produce a single match + * with `kind: 'side-effect'`. The shared finalize algorithm resolves + * the target file and emits a file-level IMPORTS edge, but + * materializes no `BindingRef` (matching the legacy DAG, which counts + * `import './polyfill'` as a module-reachability dependency only). + */ + +import type { Capture, CaptureMatch } from 'gitnexus-shared'; +import { + findChild, + nodeToCapture, + syntheticCapture, + type SyntaxNode, +} from '../../utils/ast-helpers.js'; + +type ImportKind = + | 'default' + | 'named' + | 'named-alias' + | 'namespace' + | 'reexport' + | 'reexport-alias' + | 'reexport-wildcard' + | 'reexport-namespace' + | 'dynamic' + | 'side-effect'; + +interface ImportSpec { + readonly kind: ImportKind; + /** Module path as written (quotes stripped): `./m`, `numpy`, `@scope/pkg`. + * `null` only for dynamic imports whose argument isn't a string literal. */ + readonly source: string | null; + /** Imported name from the source (or `''` when N/A, e.g. default imports + * use `'default'`, wildcards use `'*'`). */ + readonly name: string; + /** Local alias — only present for aliased forms. */ + readonly alias?: string; + /** Node to anchor the synthesized captures (for range + match provenance). */ + readonly atNode: SyntaxNode; + /** Set on `dynamic` kind imports when the argument is a string literal — + * enables `interpretTsImport` to emit `dynamic-resolved`. */ + readonly literalSource?: boolean; +} + +/** + * Decompose an import anchor. Handles three node types: + * + * - `import_statement` : all static import forms (incl. side-effect) + * - `export_statement` (w/ source) : re-exports + * - `call_expression` (import fn) : dynamic `import()` + */ +export function splitImportStatement(stmtNode: SyntaxNode): CaptureMatch[] { + if (stmtNode.type === 'import_statement') return splitImport(stmtNode); + if (stmtNode.type === 'export_statement') return splitReexport(stmtNode); + if (stmtNode.type === 'call_expression') return splitDynamicImport(stmtNode); + return []; +} + +// ─── static imports ───────────────────────────────────────────────────── + +function splitImport(stmtNode: SyntaxNode): CaptureMatch[] { + // `import_statement` shape: + // import_clause? "from" string (static form with bindings) + // string (side-effect `import './m'`) + // + // The `source` field is the string literal — we strip its surrounding + // quotes. An import without an `import_clause` child is side-effect + // only and still emits one non-binding match. + const source = extractSource(stmtNode); + if (source === null) return []; + + const importClause = findChild(stmtNode, 'import_clause'); + if (importClause === null) { + // `import './polyfill'` — no clause, no local binding. Emit a + // side-effect match so the finalize layer still produces a + // file-level IMPORTS edge (parity with the legacy DAG). + return [ + buildImportMatch(stmtNode, { + kind: 'side-effect', + source, + name: '', + atNode: stmtNode, + }), + ]; + } + + const out: CaptureMatch[] = []; + // An import_clause can have any combination of: + // - leading identifier (default import) + // - namespace_import (* as N) + // - named_imports ({ X, Y as Z }) + for (let i = 0; i < importClause.namedChildCount; i++) { + const child = importClause.namedChild(i); + if (child === null) continue; + + if (child.type === 'identifier') { + // Default import: `import D from './m'`. + out.push( + buildImportMatch(stmtNode, { + kind: 'default', + source, + name: 'default', + alias: child.text, + atNode: child, + }), + ); + continue; + } + + if (child.type === 'namespace_import') { + // `* as N` — the identifier child is the local binding. + const aliasId = findChild(child, 'identifier'); + if (aliasId !== null) { + out.push( + buildImportMatch(stmtNode, { + kind: 'namespace', + source, + name: source, + alias: aliasId.text, + atNode: child, + }), + ); + } + continue; + } + + if (child.type === 'named_imports') { + for (let j = 0; j < child.namedChildCount; j++) { + const spec = child.namedChild(j); + if (spec === null || spec.type !== 'import_specifier') continue; + const decomposed = decomposeNamedSpecifier(spec, source, stmtNode); + if (decomposed !== null) out.push(decomposed); + } + continue; + } + // Other children (e.g. `type` keyword token for `import type { ... }`) + // are ignored — they carry no per-specifier info; we fold type-only + // semantics into the same emitted kinds. + } + + return out; +} + +/** + * Decompose a single `import_specifier` into one match. Handles: + * + * - `{ X }` → named + * - `{ X as Y }` → named-alias + * - `{ type X }` → named (type-only; same shape) + * - `{ type X as Y }` → named-alias (type-only) + */ +function decomposeNamedSpecifier( + spec: SyntaxNode, + source: string, + stmtNode: SyntaxNode, +): CaptureMatch | null { + // `import_specifier` layout: + // name: identifier + // alias: identifier? (only when `as` is present) + // plus an optional `type` keyword token in front (per-specifier type-only) + // + // tree-sitter-typescript exposes `name` and `alias` as named fields. + // If `name` is absent, fail closed rather than guessing positionally: + // binding the alias as the imported name would invert the edge. + const nameNode = spec.childForFieldName('name'); + const aliasNode = spec.childForFieldName('alias'); + if (nameNode === null) return null; + const name = nameNode.text; + + if (aliasNode !== null && aliasNode.startIndex !== nameNode.startIndex) { + return buildImportMatch(stmtNode, { + kind: 'named-alias', + source, + name, + alias: aliasNode.text, + atNode: spec, + }); + } + return buildImportMatch(stmtNode, { + kind: 'named', + source, + name, + atNode: spec, + }); +} + +// ─── re-exports ────────────────────────────────────────────────────────── + +function splitReexport(stmtNode: SyntaxNode): CaptureMatch[] { + // `export_statement` with a `source:` field is a re-export. Forms: + // + // export { X, Y as Z } from './m' → export_clause children + // export * from './m' → no clause + // export * as ns from './m' → namespace_export child + // export type { X } from './m' → same clause path + // + // Local `export { X }` (no `from`) is visibility metadata, not an + // import; the captures-layer query guards with a `source: (string)` + // predicate so we always have a source here — but we defend + // structurally anyway. + const source = extractSource(stmtNode); + if (source === null) return []; + + const exportClause = findChild(stmtNode, 'export_clause'); + if (exportClause !== null) { + const out: CaptureMatch[] = []; + for (let i = 0; i < exportClause.namedChildCount; i++) { + const spec = exportClause.namedChild(i); + if (spec === null || spec.type !== 'export_specifier') continue; + const decomposed = decomposeReexportSpecifier(spec, source, stmtNode); + if (decomposed !== null) out.push(decomposed); + } + return out; + } + + // `export * as ns from './m'` — tree-sitter-typescript emits a + // `namespace_export` child whose identifier is the local re-export + // name. Two facts are emitted: + // + // 1. An `@import.statement` (kind `reexport-namespace`) so finalize + // knows the barrel imports `./m` as `ns` (binds `ns` locally + // inside the barrel for consumers like `barrel.ts` calling + // `ns.X()`). + // 2. A synthetic `@declaration.namespace` so the central + // scope-extractor adds a `Namespace` SymbolDefinition for `ns` + // to the barrel's `localDefs`. Without this, downstream files + // doing `import { ns } from './barrel'` cannot resolve `ns`: + // `findExportByName` and the precomputed re-export closure only + // consult `localDefs` / `reexport` / `wildcard` drafts, never + // `namespace`-kind imports. The synthetic declaration fixes that + // without growing the shared finalizer's surface. + const namespaceExport = findChild(stmtNode, 'namespace_export'); + if (namespaceExport !== null) { + const aliasId = findChild(namespaceExport, 'identifier'); + if (aliasId !== null) { + return [ + buildImportMatch(stmtNode, { + kind: 'reexport-namespace', + source, + name: source, + alias: aliasId.text, + atNode: namespaceExport, + }), + buildNamespaceDeclarationMatch(namespaceExport, aliasId), + ]; + } + } + + // `export * from './m'` — no clause, no namespace_export. The bare + // `*` token is the only remaining marker; we don't need to inspect + // it since the shape alone says "wildcard". + return [ + buildImportMatch(stmtNode, { + kind: 'reexport-wildcard', + source, + name: '*', + atNode: stmtNode, + }), + ]; +} + +function decomposeReexportSpecifier( + spec: SyntaxNode, + source: string, + stmtNode: SyntaxNode, +): CaptureMatch | null { + const nameNode = spec.childForFieldName('name'); + const aliasNode = spec.childForFieldName('alias'); + if (nameNode === null) return null; + const name = nameNode.text; + + if (aliasNode !== null && aliasNode.startIndex !== nameNode.startIndex) { + return buildImportMatch(stmtNode, { + kind: 'reexport-alias', + source, + name, + alias: aliasNode.text, + atNode: spec, + }); + } + return buildImportMatch(stmtNode, { + kind: 'reexport', + source, + name, + atNode: spec, + }); +} + +// ─── dynamic imports ───────────────────────────────────────────────────── + +function splitDynamicImport(callNode: SyntaxNode): CaptureMatch[] { + // `call_expression` shape for dynamic imports: + // function: (import) — named leaf node in tree-sitter-typescript + // arguments: (arguments (string) ...) — first arg is the path + // + // When the argument is a string literal, preserve its value. When it's + // anything else (variable, template literal, member access), surface + // the raw text for diagnostics and let `interpretTsImport` emit + // `dynamic-unresolved` with a `targetRaw` hint. + const args = callNode.childForFieldName('arguments'); + if (args === null) { + return [ + buildImportMatch(callNode, { + kind: 'dynamic', + source: null, + name: '', + atNode: callNode, + }), + ]; + } + + const firstArg = args.namedChild(0); + if (firstArg === null) { + return [ + buildImportMatch(callNode, { + kind: 'dynamic', + source: null, + name: '', + atNode: callNode, + }), + ]; + } + + if (firstArg.type === 'string') { + const source = stripQuotes(firstArg.text); + return [ + buildImportMatch(callNode, { + kind: 'dynamic', + source, + name: '', + atNode: callNode, + literalSource: true, + }), + ]; + } + + // Non-literal argument — preserve source text so downstream + // diagnostics show what the user wrote. + return [ + buildImportMatch(callNode, { + kind: 'dynamic', + source: firstArg.text, + name: '', + atNode: callNode, + }), + ]; +} + +// ─── helpers ───────────────────────────────────────────────────────────── + +function extractSource(stmtNode: SyntaxNode): string | null { + // Both `import_statement` and `export_statement` expose the module + // path through the `source:` field. It's typed as `string` in the + // grammar; we strip its surrounding quotes. + const sourceField = stmtNode.childForFieldName('source'); + if (sourceField === null || sourceField.type !== 'string') return null; + return stripQuotes(sourceField.text); +} + +function stripQuotes(raw: string): string { + const trimmed = raw.trim(); + if (trimmed.length < 2) return trimmed; + const first = trimmed.charAt(0); + const last = trimmed.charAt(trimmed.length - 1); + if ( + (first === '"' && last === '"') || + (first === "'" && last === "'") || + (first === '`' && last === '`') + ) { + return trimmed.slice(1, -1); + } + return trimmed; +} + +function buildImportMatch(stmtNode: SyntaxNode, spec: ImportSpec): CaptureMatch { + const m: Record = { + '@import.statement': nodeToCapture('@import.statement', stmtNode), + '@import.kind': syntheticCapture('@import.kind', spec.atNode, spec.kind), + '@import.name': syntheticCapture('@import.name', spec.atNode, spec.name), + }; + if (spec.source !== null) { + m['@import.source'] = syntheticCapture('@import.source', spec.atNode, spec.source); + } + if (spec.alias !== undefined) { + m['@import.alias'] = syntheticCapture('@import.alias', spec.atNode, spec.alias); + } + if (spec.literalSource === true) { + m['@import.literal'] = syntheticCapture('@import.literal', spec.atNode, ''); + } + return m; +} + +/** Synthesize a `@declaration.namespace` match for `export * as ns from './m'`. + * The central scope-extractor turns this into a `SymbolDefinition` of type + * `Namespace` in the barrel's `localDefs`, which makes `findExportByName` + * resolve `ns` for downstream `import { ns } from './barrel'` consumers. */ +function buildNamespaceDeclarationMatch( + namespaceExportNode: SyntaxNode, + aliasId: SyntaxNode, +): CaptureMatch { + return { + '@declaration.namespace': nodeToCapture('@declaration.namespace', namespaceExportNode), + '@declaration.name': nodeToCapture('@declaration.name', aliasId), + }; +} diff --git a/gitnexus/src/core/ingestion/languages/typescript/import-target.ts b/gitnexus/src/core/ingestion/languages/typescript/import-target.ts new file mode 100644 index 000000000..d082529f5 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/typescript/import-target.ts @@ -0,0 +1,100 @@ +/** + * Adapter from `(ParsedImport, WorkspaceIndex)` → concrete file path. + * + * Delegates to the existing standard-strategy resolver + * (`resolveImportPath`) so tsconfig path aliases (`@/`, `~/`, …) and + * suffix-based resolution follow the same rules as the legacy path. + * + * The `WorkspaceIndex` is opaque at the shared contract layer; we + * narrow it to a TypeScript-shaped context that carries `fromFile` + + * the full `allFilePaths` set + the optional `tsconfigPaths` the + * resolver reads. + * + * Returning `null` lets the finalize algorithm mark the edge as + * `linkStatus: 'unresolved'`. + */ + +import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared'; +import { SupportedLanguages } from 'gitnexus-shared'; +import { resolveImportPath } from '../../import-resolvers/standard.js'; +import type { TsconfigPaths } from '../../language-config.js'; + +export interface TsResolveContext { + readonly fromFile: string; + /** Mutable `Set` because the standard resolver consumes `Set`. + * Callers holding a `ReadonlySet` should copy via `new Set(...)`. */ + readonly allFilePaths: Set; + /** Repo file list, normalized (lowercased) for suffix matching. May + * be supplied by the orchestrator; if absent we derive it on the + * fly from `allFilePaths`. */ + readonly allFileList?: readonly string[]; + readonly normalizedFileList?: readonly string[]; + /** Per-call resolution cache to dedupe repeated lookups. */ + readonly resolveCache?: Map; + /** Parsed tsconfig path-aliases. `null` = no aliases configured. */ + readonly tsconfigPaths?: TsconfigPaths | null; + /** JavaScript vs TypeScript switch — affects the extensions the + * resolver tries. Defaults to TypeScript. */ + readonly language?: SupportedLanguages.TypeScript | SupportedLanguages.JavaScript; +} + +export function resolveTsImportTarget( + parsedImport: ParsedImport, + workspaceIndex: WorkspaceIndex, +): string | null { + const ctx = narrowTsContext(workspaceIndex); + if (ctx === null) return null; + + // Dynamic imports carry `targetRaw` only for diagnostics; when the + // expression isn't a string literal we can't resolve a file. + // A string-literal dynamic import (`import('./m')`) resolves like a + // static import — fall through to the shared path resolver. + if (parsedImport.kind === 'dynamic-unresolved' && parsedImport.targetRaw === null) return null; + if (parsedImport.targetRaw === null || parsedImport.targetRaw === '') return null; + + return resolveTsTarget(parsedImport.targetRaw, ctx); +} + +/** + * Resolve a raw module-path string to a workspace file path using the + * same standard-strategy resolver as the legacy DAG. Operates directly on + * the source string without requiring a `ParsedImport`, so the + * `ScopeResolver.resolveImportTarget` adapter doesn't need to construct + * a fake `ParsedImport` to reach the resolver. + * + * Returns `null` when: + * - the context is malformed (missing `fromFile` / `allFilePaths`) + * - `targetRaw` is empty + * - the resolver finds no matching file + */ +export function resolveTsTarget(targetRaw: string, ctx: TsResolveContext): string | null { + if (targetRaw === '') return null; + + const language = ctx.language ?? SupportedLanguages.TypeScript; + const allFileList = ctx.allFileList ?? Array.from(ctx.allFilePaths); + const normalizedFileList = ctx.normalizedFileList ?? allFileList.map((f) => f.toLowerCase()); + const resolveCache = ctx.resolveCache ?? new Map(); + + return resolveImportPath( + ctx.fromFile, + targetRaw, + ctx.allFilePaths, + allFileList as string[], + normalizedFileList as string[], + resolveCache, + language, + ctx.tsconfigPaths ?? null, + ); +} + +function narrowTsContext(workspaceIndex: WorkspaceIndex): TsResolveContext | null { + const ctx = workspaceIndex as TsResolveContext | undefined; + if ( + ctx === undefined || + typeof (ctx as { fromFile?: unknown }).fromFile !== 'string' || + !((ctx as { allFilePaths?: unknown }).allFilePaths instanceof Set) + ) { + return null; + } + return ctx; +} diff --git a/gitnexus/src/core/ingestion/languages/typescript/index.ts b/gitnexus/src/core/ingestion/languages/typescript/index.ts new file mode 100644 index 000000000..cb4bd4023 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/typescript/index.ts @@ -0,0 +1,95 @@ +/** + * TypeScript scope-resolution hooks (RFC #909 Ring 3, RFC §5). + * + * Public API barrel. Consumers should import from this file rather + * than the individual modules. + * + * Module layout (each file is a single concern): + * + * - `query.ts` — tree-sitter query + lazy parser/query singletons + * - `captures.ts` — `emitTsScopeCaptures` orchestrator + * - `import-decomposer.ts` — each import/re-export/dynamic-import → + * ParsedImport-shaped captures + * - `interpret.ts` — capture-match → `ParsedImport` / + * `ParsedTypeBinding` + * - `simple-hooks.ts` — `bindingScopeFor` (var hoisting + return- + * type hoisting), `importOwningScope` + * (module/namespace default), `receiverBinding` + * (`this` lookup on Function scope) + * - `receiver-binding.ts` — synthesize `this` type-bindings on + * instance-method entry (methods, interface + * signatures, class-field arrow functions) + * - `merge-bindings.ts` — TypeScript declaration merging + * (value / type / namespace spaces) + LEGB + * tier shadowing + * - `arity.ts` — TypeScript arity compatibility (rest, + * optional, default params) + * - `arity-metadata.ts` — synthesize arity metadata from + * declarations; includes generics + array- + * suffix stripping on parameter types + * - `import-target.ts` — `(ParsedImport, WorkspaceIndex) → file path` + * adapter delegating to the shared standard + * resolver (tsconfig paths, node_modules, + * relative/extension suffix matching) + * - `cache-stats.ts` — PROF_SCOPE_RESOLUTION cache hit/miss + * counters + * + * ## Known limitations + * + * The TypeScript registry-primary path intentionally does NOT resolve + * the following. Each is a conscious trade-off at migration time. + * + * 1. **Type-only import / export separation** — `import type { X }` + * and `import { X }` produce the same `ParsedImport` shape today; + * `def.type` on the resolved symbol is the only discriminator. + * Parity with the legacy path is preserved. Tracking in #927. + * 2. **Declaration merging for imports** — when `import { Foo }` + * brings in a symbol that is BOTH a class and a namespace in the + * source module, we currently surface a single binding per the + * target `def.type`. Downstream type/value-space lookups still + * work for the primary space; the other space's members resolve + * via the same target (class statics reachable via dotted access). + * 3. **Overload narrowing by argument type** — `@reference.parameter- + * types` carries static literal types inferred from the callsite + * (`string`, `number`, `Array`, etc.). Identifier / member-access + * arguments emit empty strings (unknown type); the registry's + * narrowing treats them as any-match. Full control-flow type + * narrowing is out of scope. + * 4. **Computed member access** — `obj[key]()` / `obj['method']()` + * is classified as an index-access call; member-call resolution + * falls back to the identifier-indexed branch and matches only + * when the key is a string literal. + * 5. **`this` for nested regular functions inside methods** — our + * scope-chain lookup returns the enclosing method's `this`, which + * is technically incorrect at runtime (a non-arrow nested function + * has its own `this` binding). Accepted false-positive; see + * `simple-hooks.ts` docstring. + * 6. **`class_expression` receiver types** — `const C = class { }` + * skips `this` synthesis when the expression is anonymous (no + * type name to propagate). `const C = class Named { }` works via + * the class's own `name` field. + * 7. **JSX element types** — JSX-specific constructs are ignored by + * the scope query; component references resolve via regular + * identifier / member-expression paths. + * 8. **Ambient module declarations** (`declare module '…'`) — parsed + * but not indexed at this layer; same as today's legacy path. + * 9. **Intersection types on parameters** (`(a: A & B)`) — treated + * as opaque (no strip); overload narrowing on intersections + * won't match. + * 10. **`instanceof` member-expression narrowing** — only bare + * identifiers are narrowed (`user instanceof User`). Member paths + * such as `user.address instanceof Address` remain unresolved. + * + * Shadow-harness corpus parity on `test/integration/resolvers/ + * typescript.test.ts` is the authoritative signal for which of these + * matter in practice. The CI parity gate blocks any PR that regresses + * either the legacy or registry-primary run. + */ + +export { emitTsScopeCaptures } from './captures.js'; +export { getTypescriptCaptureCacheStats, resetTypescriptCaptureCacheStats } from './cache-stats.js'; +export { interpretTsImport, interpretTsTypeBinding } from './interpret.js'; +export { typescriptMergeBindings } from './merge-bindings.js'; +export { typescriptArityCompatibility } from './arity.js'; +export { resolveTsImportTarget, resolveTsTarget, type TsResolveContext } from './import-target.js'; +export { tsBindingScopeFor, tsImportOwningScope, tsReceiverBinding } from './simple-hooks.js'; diff --git a/gitnexus/src/core/ingestion/languages/typescript/interpret.ts b/gitnexus/src/core/ingestion/languages/typescript/interpret.ts new file mode 100644 index 000000000..acf511527 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/typescript/interpret.ts @@ -0,0 +1,312 @@ +/** + * Capture-match → semantic-shape interpreters for TypeScript. + * + * Two pure functions, both consumed by the central scope extractor: + * + * - `interpretTsImport` → `ParsedImport` + * - `interpretTsTypeBinding` → `ParsedTypeBinding` (wired in Unit 6) + * + * The import matches arrive pre-decomposed by `emitTsScopeCaptures` + * (one imported name per match, with synthesized + * `@import.kind/source/name/alias` markers — see `import-decomposer.ts`). + * The type-binding matches arrive straight from the raw query captures — + * each `@type-binding.*` anchor carries `@type-binding.name` + + * `@type-binding.type`. + */ + +import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'gitnexus-shared'; + +// ─── interpretImport ────────────────────────────────────────────────────── + +export function interpretTsImport(captures: CaptureMatch): ParsedImport | null { + // Markers attached by `splitImportStatement` (import-decomposer.ts): + // @import.kind : one of the kinds documented there + // @import.name : imported name from the source module + // @import.alias : local alias name (for default / aliased / namespace forms) + // @import.source : module path (always present except dynamic-unresolved) + const kindCap = captures['@import.kind']; + const nameCap = captures['@import.name']; + const aliasCap = captures['@import.alias']; + const sourceCap = captures['@import.source']; + + const kind = kindCap?.text; + if (kind === undefined) return null; + + switch (kind) { + case 'default': { + // `import D from './m'` — semantically "alias for the module's + // default export". We map to ParsedImport `alias` with + // importedName='default' so the finalize algorithm looks up the + // target module's `default` export for cross-file resolution. + if (sourceCap === undefined || aliasCap === undefined) return null; + return { + kind: 'alias', + localName: aliasCap.text, + importedName: 'default', + alias: aliasCap.text, + targetRaw: sourceCap.text, + }; + } + case 'named': { + // `import { X } from './m'` (plus type-only forms). + if (sourceCap === undefined || nameCap === undefined) return null; + return { + kind: 'named', + localName: nameCap.text, + importedName: nameCap.text, + targetRaw: sourceCap.text, + }; + } + case 'named-alias': { + // `import { X as Y } from './m'`. + if (sourceCap === undefined || nameCap === undefined || aliasCap === undefined) { + return null; + } + return { + kind: 'alias', + localName: aliasCap.text, + importedName: nameCap.text, + alias: aliasCap.text, + targetRaw: sourceCap.text, + }; + } + case 'namespace': { + // `import * as N from './m'` — `N` binds the whole module. + if (sourceCap === undefined || aliasCap === undefined) return null; + return { + kind: 'namespace', + localName: aliasCap.text, + importedName: sourceCap.text, + targetRaw: sourceCap.text, + }; + } + case 'reexport': { + // `export { X } from './m'`. + if (sourceCap === undefined || nameCap === undefined) return null; + return { + kind: 'reexport', + localName: nameCap.text, + importedName: nameCap.text, + targetRaw: sourceCap.text, + }; + } + case 'reexport-alias': { + // `export { X as Y } from './m'`. + if (sourceCap === undefined || nameCap === undefined || aliasCap === undefined) { + return null; + } + return { + kind: 'reexport', + localName: aliasCap.text, + importedName: nameCap.text, + alias: aliasCap.text, + targetRaw: sourceCap.text, + }; + } + case 'reexport-wildcard': { + // `export * from './m'` — no local name, just a blanket passthrough. + if (sourceCap === undefined) return null; + return { kind: 'wildcard', targetRaw: sourceCap.text }; + } + case 'reexport-namespace': { + // `export * as ns from './m'` — creates a local binding `ns` + // that exposes the whole module, while also re-exporting it. + // Closest ParsedImport fit is `namespace`; the re-export side + // of this edge is tracked by the export detector downstream. + if (sourceCap === undefined || aliasCap === undefined) return null; + return { + kind: 'namespace', + localName: aliasCap.text, + importedName: sourceCap.text, + targetRaw: sourceCap.text, + }; + } + case 'dynamic': { + // `import('./m')` / `import(x)`. The decomposer marks literal- + // string arguments with `@import.literal` so we can promote them + // to `dynamic-resolved` here — that lets the shared finalizer + // produce a file-level IMPORTS edge for lazy-loaded modules. + // Non-literal arguments stay `dynamic-unresolved` (target is + // runtime-computed and unreachable to the static finalizer). + const isLiteral = captures['@import.literal'] !== undefined; + if (isLiteral && sourceCap !== undefined) { + return { kind: 'dynamic-resolved', targetRaw: sourceCap.text }; + } + return { + kind: 'dynamic-unresolved', + localName: '', + targetRaw: sourceCap?.text ?? null, + }; + } + case 'side-effect': { + // `import './polyfill'` — bare-source, no local binding. The + // finalize layer resolves to a target file and emits a + // file-level IMPORTS edge; no `BindingRef` is materialized. + if (sourceCap === undefined) return null; + return { kind: 'side-effect', targetRaw: sourceCap.text }; + } + default: + return null; + } +} + +// ─── interpretTypeBinding ───────────────────────────────────────────────── + +/** + * Interpret a `@type-binding.*` capture-match into a `ParsedTypeBinding`. + * + * TypeScript-specific strips: + * + * - Trailing `?` on optional parameters: `(u?: User)` → `User` + * - `Promise` / `Array` / `ReadonlyArray` / `Readonly` + * → `User` (wrappers that are transparent to chain propagation) + * - Single-arg `List` / `Iterable` / `Iterator` — + * mirrors Python/C#'s generic-collection strip for for-of loops + * - Trailing `[]` on array types: `User[]` → `User` + * - Nullable unions: `User | null` / `User | undefined` / `null | User` + * → `User` + * - Dotted qualifiers: `models.User` → `User` (unless the suffix is + * a known collection accessor we'd want to preserve — none apply + * to TS today, since TS uses `.values()` / `.keys()` call syntax) + */ +export function interpretTsTypeBinding(captures: CaptureMatch): ParsedTypeBinding | null { + const nameCap = captures['@type-binding.name']; + const typeCap = captures['@type-binding.type']; + if (nameCap === undefined || typeCap === undefined) return null; + + // Readonly/array/nullable wrappers can stack; apply passes until a + // fixed point (bounded by the text length since every strip monotonically + // shrinks the string). + let prev = ''; + let rawType = typeCap.text.trim(); + while (prev !== rawType) { + prev = rawType; + rawType = stripReadonly(rawType); + rawType = stripNullableUnion(rawType); + rawType = stripGeneric(rawType); + rawType = stripArraySuffix(rawType); + } + // Destructuring / member-alias / map-tuple / dotted-call-alias bindings + // carry receiver paths or sentinel strings that must survive verbatim. + // Also preserve dotted member-call callee text (`svc.getUser`) for + // `@type-binding.alias` — stripQualifier would reduce it to `getUser`, + // breaking compound-receiver's `obj.method()` split. + const isDestructured = captures['@type-binding.destructured'] !== undefined; + const isMemberAlias = captures['@type-binding.member-alias'] !== undefined; + const isMapTupleEntry = captures['@type-binding.map-tuple-entry'] !== undefined; + const isInstanceofNarrow = captures['@type-binding.instanceof-narrow'] !== undefined; + const isAlias = captures['@type-binding.alias'] !== undefined; + const preserveRawTypeName = + isDestructured || + isMemberAlias || + isMapTupleEntry || + isInstanceofNarrow || + (isAlias && rawType.includes('.')); + if (!preserveRawTypeName) { + rawType = stripQualifier(rawType); + } + + // Drop non-discriminating / wildcard types — `as any` / `as unknown` + // should not block a more-informative sibling binding (typically the + // constructor-inferred capture from the inner `new_expression`). By + // returning null here we let the scope-extractor's tie-break select + // the next-best binding for the same name. + if (UNINFORMATIVE_TYPES.has(rawType)) return null; + + // Anchor captures distinguish the source of the binding. Order + // matters: more-specific anchors take precedence. `this` is a + // TypeScript-specific receiver synthesized in `receiver-binding.ts` + // (Unit 3); treat it as `self` for Registry.lookup parity with + // Python/C#. + let source: TypeRef['source'] = 'parameter-annotation'; + if (captures['@type-binding.this'] !== undefined) source = 'self'; + else if (captures['@type-binding.constructor'] !== undefined) source = 'constructor-inferred'; + else if (captures['@type-binding.assertion'] !== undefined) source = 'annotation'; + else if (captures['@type-binding.annotation'] !== undefined) source = 'annotation'; + else if (captures['@type-binding.member-alias'] !== undefined) source = 'assignment-inferred'; + else if (captures['@type-binding.alias'] !== undefined) source = 'assignment-inferred'; + else if (captures['@type-binding.return'] !== undefined) source = 'return-annotation'; + else if (captures['@type-binding.parameter-property'] !== undefined) source = 'annotation'; + else if (captures['@type-binding.destructured'] !== undefined) source = 'assignment-inferred'; + else if (captures['@type-binding.map-tuple-entry'] !== undefined) source = 'assignment-inferred'; + else if (captures['@type-binding.instanceof-narrow'] !== undefined) source = 'annotation'; + + return { boundName: nameCap.text, rawTypeName: rawType, source }; +} + +/** Types that carry no discriminating information for chain resolution. + * `any` / `unknown` / `object` / `never` / `void` match anything, so a + * sibling capture (e.g. a constructor-inferred type from `new X() as any`) + * is strictly preferable. Empty string emerges from malformed captures + * and is also useless. `null` / `undefined` shouldn't survive + * stripNullableUnion but are listed here for defense-in-depth. */ +const UNINFORMATIVE_TYPES: ReadonlySet = new Set([ + '', + 'any', + 'unknown', + 'object', + 'never', + 'void', + 'null', + 'undefined', +]); + +/** `readonly User[]` → `User[]`. Applied before stripArraySuffix so + * `readonly User[]` reduces through the same pipeline. */ +function stripReadonly(text: string): string { + if (text.startsWith('readonly ')) return text.slice('readonly '.length).trim(); + return text; +} + +/** `User | null` / `User | undefined` / `null | User | undefined` → `User`. + * Any number of `null` / `undefined` arms may appear; collapse to the + * single remaining discriminating arm. Preserves multi-arm unions + * of real types (`User | Admin`) since the concrete receiver type is + * ambiguous. */ +function stripNullableUnion(text: string): string { + const parts = text.split('|').map((p) => p.trim()); + if (parts.length < 2) return text; + const NULLS = new Set(['null', 'undefined']); + const nonNull = parts.filter((p) => !NULLS.has(p)); + if (nonNull.length === 1) return nonNull[0]; + return text; +} + +/** Single-arg generic wrappers transparent to receiver-type chain + * propagation: `Promise`, `Array`, `ReadonlyArray`, + * `Readonly`, `Iterable`, `Iterator`, `Set`, `List`, + * `Map` (single-arg form rare but kept for completeness), etc. + * Multi-arg generics (`Map`, `Record`) are left alone — + * element semantics aren't unambiguous. */ +function stripGeneric(text: string): string { + const single = text.match( + /^(?:[A-Za-z_][A-Za-z0-9_]*\.)?(?:Promise|Array|ReadonlyArray|Readonly|Iterable|Iterator|AsyncIterable|AsyncIterator|AsyncGenerator|Generator|Set|ReadonlySet|List|Awaited)<([^,<>]+)>$/, + ); + if (single !== null) return single[1].trim(); + return text; +} + +/** `User[]` / `(User)[]` → `User`. Chained `User[][]` unwraps one + * level at a time per resolve pass. */ +function stripArraySuffix(text: string): string { + if (text.endsWith('[]')) { + const inner = text.slice(0, -2).trim(); + // Unwrap a single pair of parentheses introduced for precedence + // disambiguation: `(User | Admin)[]` — we leave the union intact + // but drop the parens. + if (inner.startsWith('(') && inner.endsWith(')')) { + return inner.slice(1, -1).trim(); + } + return inner; + } + return text; +} + +/** `models.User` → `User`. TS doesn't carry a qualified-suffix exception + * list today — `.values()` / `.keys()` use method-call syntax and are + * resolved via the member-call chain, not via a dotted type. */ +function stripQualifier(text: string): string { + const lastDot = text.lastIndexOf('.'); + if (lastDot === -1) return text; + return text.slice(lastDot + 1); +} diff --git a/gitnexus/src/core/ingestion/languages/typescript/merge-bindings.ts b/gitnexus/src/core/ingestion/languages/typescript/merge-bindings.ts new file mode 100644 index 000000000..3c434a059 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/typescript/merge-bindings.ts @@ -0,0 +1,168 @@ +/** + * TypeScript declaration-merging + LEGB precedence for the `mergeBindings` + * hook. + * + * TypeScript has a unique wrinkle that Python / C# don't: **declaration + * merging**. The same name can legally coexist in several "declaration + * spaces" simultaneously: + * + * - **value** space — `class X`, `function X`, `const X`, `var X`, + * `let X`, `enum X`, `namespace X` (adds runtime object) + * - **type** space — `interface X`, `type X`, `class X`, `enum X` + * - **namespace** space — `namespace X`, `class X` (static-accessed + * members are reachable via dotted name) + * + * Classes and enums are unique in that each declaration occupies both + * the value AND type spaces. This lets: + * + * class Foo {} + * interface Foo { bar: number; } // merges additional type members + * namespace Foo { export const X = 1; } // adds static-like value + * + * all coexist for the same name. + * + * ## Algorithm + * + * For each declaration space independently: + * 1. Tier bindings by origin (lower wins): + * 0 — `local` + * 1 — `import` / `namespace` / `reexport` + * 2 — `wildcard` (`export * from …`) + * 2. Keep only bindings at the best (lowest) tier in that space. + * + * Then union survivors across spaces and dedupe by `DefId`. + * + * ## Shadowing examples + * + * - `class Foo {}` + `function Foo() {}` in same scope → COMPILE ERROR + * in TS source, but if both reach us with distinct DefIds we keep + * both (value space has two locals at tier 0 — de-dup by nodeId + * preserves both). No worse than C#-style merge. + * - `class Foo {}` (local, value+type) + `import type { Foo } from './a'` + * (tier-1, type-only) → local wins in both type AND value spaces; + * the import is not kept. + * - `interface Foo {}` (local, type-only) + `import { Foo } from './a'` + * (tier-1, value+type) → local wins in type space; import wins in + * value space (local doesn't occupy it). Both kept. + * - `namespace Foo {}` (local, namespace+value) + `class Foo {}` (local, + * value+type) → both at tier 0 in their respective spaces, kept. + * + * ## Limitations + * + * - We classify imports by their `def.type` just like locals. Without + * a space-annotation on `ParsedImport`, `import type { Foo }` looks + * the same as `import { Foo }` at this layer — the parse phase + * decomposer marks type-only imports so the extractor CAN annotate + * `def.type = 'Type'` downstream if desired. Today it doesn't, so + * `import type` imports and value imports fall in the same bucket + * per their target def's NodeLabel. Parity with legacy behavior + * (which also doesn't track type-only separately) is preserved. + */ + +import type { BindingRef, NodeLabel } from 'gitnexus-shared'; + +/** Declaration spaces a TypeScript binding can occupy. */ +type Space = 'value' | 'type' | 'namespace'; + +const TIER_LOCAL = 0; +const TIER_IMPORT = 1; +const TIER_WILDCARD = 2; +const TIER_UNKNOWN = 3; + +function tierOf(b: BindingRef): number { + switch (b.origin) { + case 'local': + return TIER_LOCAL; + case 'reexport': + case 'import': + case 'namespace': + return TIER_IMPORT; + case 'wildcard': + return TIER_WILDCARD; + default: + return TIER_UNKNOWN; + } +} + +/** + * Map a `SymbolDefinition.type` (`NodeLabel`) to the set of TypeScript + * declaration spaces the binding occupies. + * + * Unknown / unused labels default to `['value']` — the permissive choice, + * matching legacy behavior where everything lives in a single flat bucket. + */ +function spacesOf(type: NodeLabel): readonly Space[] { + switch (type) { + // value-only + case 'Function': + case 'Method': + case 'Variable': + case 'Const': + case 'Static': + case 'Property': + case 'Constructor': + case 'Macro': + return ['value']; + + // type-only + case 'Interface': + case 'Type': + case 'TypeAlias': + case 'Typedef': + case 'Trait': + case 'Annotation': + case 'Decorator': + return ['type']; + + // dual: value AND type + case 'Class': + case 'Enum': + case 'Struct': + case 'Record': + case 'Union': + return ['value', 'type']; + + // namespace AND value (namespaces introduce a runtime object AND a + // named scope for static-style access) + case 'Namespace': + case 'Module': + return ['namespace', 'value']; + + // catch-all — treat as value to match legacy permissive behavior + default: + return ['value']; + } +} + +export function typescriptMergeBindings(bindings: readonly BindingRef[]): readonly BindingRef[] { + if (bindings.length === 0) return bindings; + + // Partition bindings by space. A single binding occupying two spaces + // (e.g. a class) is duplicated into both partitions; the final dedupe + // by nodeId collapses it back. + const perSpace = new Map(); + for (const b of bindings) { + const spaces = spacesOf(b.def.type); + for (const s of spaces) { + const list = perSpace.get(s); + if (list === undefined) perSpace.set(s, [b]); + else list.push(b); + } + } + + // Within each space, keep only the best-tier bindings. + const survivorsSet = new Set(); + for (const list of perSpace.values()) { + let bestTier = Number.POSITIVE_INFINITY; + for (const b of list) bestTier = Math.min(bestTier, tierOf(b)); + for (const b of list) { + if (tierOf(b) === bestTier) survivorsSet.add(b); + } + } + + // Dedupe by def.nodeId. If the same binding survived in multiple + // spaces (e.g. a class in both value + type) we keep a single entry. + const seen = new Map(); + for (const b of survivorsSet) seen.set(b.def.nodeId, b); + return [...seen.values()]; +} diff --git a/gitnexus/src/core/ingestion/languages/typescript/query.ts b/gitnexus/src/core/ingestion/languages/typescript/query.ts new file mode 100644 index 000000000..5645b0868 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/typescript/query.ts @@ -0,0 +1,785 @@ +/** + * Tree-sitter query for TypeScript scope captures (RFC §5.1). + * + * Captures the structural skeleton the generic scope-resolution pipeline + * consumes: scopes (module/namespace/class/function), declarations (class- + * likes, method-likes, properties, variables), imports (one anchor per + * statement — decomposed in `import-decomposer.ts`), type bindings + * (parameter annotations, variable annotations, constructor inference, + * return types), and references (call sites, member writes). + * + * TypeScript specifics that shape this query: + * + * - **Namespaces** (`namespace Foo { }`) use `internal_module` with a + * `namespace` anon keyword + `identifier` or `nested_identifier` name + + * `statement_block` body. Verified via Unit 1 probe. + * - **`this` / `super`** are NAMED nodes `(this)` / `(super)` — unlike + * C#'s `this`/`base` which are anonymous tokens. `(_)` wildcard matches + * them as the receiver child of `member_expression`, so we don't need + * explicit string patterns. + * - **Optional chaining** (`obj?.m()`) still matches the regular + * `member_expression > object: (_) / property: (property_identifier)` + * pattern; the `(optional_chain)` child sits between them but doesn't + * occupy a named field. Same query handles both. + * - **Dynamic imports** (`import('./mod')`) are `call_expression` whose + * `function` field is a named `import` node (not a regular identifier). + * Captured via a dedicated pattern. + * - **Function overloads** — `function f(x:string); function f(x:number); + * function f(x) { … }` emits two `function_signature` nodes plus one + * `function_declaration`. All three emit `@declaration.function`; + * arity metadata synthesis merges parameterTypes. + * - **Parameter properties** (`constructor(public name: string)`) — each + * parameter emits `@declaration.property` on the enclosing class; the + * same identifier also binds as a parameter in the constructor scope + * via the normal `required_parameter` → `@type-binding.parameter` path. + * - **Enum** — dual type+value. Emits `@scope.class` (enum body contains + * member declarations) + `@declaration.enum`. Members are captured as + * `@declaration.property` via the generic property_identifier pattern + * inside enum_body. + * + * Node types pinned via `scripts/_probe_typescript_grammar.ts`: + * internal_module, namespace_export, namespace_import, import_specifier, + * export_specifier, enum_declaration, type_alias_declaration, + * abstract_class_declaration, abstract_method_signature, method_signature, + * generator_function_declaration, optional_parameter, rest_parameter, + * required_parameter, public_field_definition, private_property_identifier, + * new_expression (constructor field), call_expression with (import) fn. + * + * Grammar version: tree-sitter-typescript pinned in gitnexus/package.json. + * + * Exposes lazy `Parser` and `Query` singletons so callers don't pay tree- + * sitter init cost per file. + */ + +import Parser from 'tree-sitter'; +import TS from 'tree-sitter-typescript'; + +// tree-sitter-typescript exports both `typescript` and `tsx` grammars on +// the default export. The package's `.d.ts` types the default export +// loosely; we narrow at the use site. The two grammars are NOT +// interchangeable: feeding a `.tsx` source to the `typescript` grammar +// mis-parses JSX as a sequence of less-than/greater-than expressions +// and silently drops every capture inside JSX elements. We therefore +// pick the grammar by file extension. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const TS_GRAMMAR = (TS as any).typescript as Parameters[0]; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const TSX_GRAMMAR = (TS as any).tsx as Parameters[0]; + +/** True when the file should be parsed with the TSX grammar. The TSX + * grammar is a superset of TypeScript that adds JSX productions; it + * parses plain `.ts` files correctly too, but we keep `.ts` on the + * `typescript` grammar so the parser cache stays small and so any + * subtle TSX-only mis-parses don't bleed into non-TSX files. */ +function isTsxFile(filePath: string): boolean { + return filePath.endsWith('.tsx'); +} + +const TYPESCRIPT_SCOPE_QUERY = ` +;; Scopes — module / namespace / class-likes / function-likes +(program) @scope.module + +(internal_module) @scope.namespace + +(class_declaration) @scope.class +(abstract_class_declaration) @scope.class +(interface_declaration) @scope.class +(enum_declaration) @scope.class + +(function_declaration) @scope.function +(generator_function_declaration) @scope.function +(function_signature) @scope.function +(method_definition) @scope.function +(method_signature) @scope.function +(abstract_method_signature) @scope.function +(arrow_function) @scope.function +(function_expression) @scope.function + +;; Type aliases that contain an object_type are structurally class-like — +;; they define a shape with named members. Emit @scope.class so the +;; field-extractor's type-alias-with-object-type handling (in +;; field-extractors/typescript.ts) finds a scope for its members. +(type_alias_declaration + value: (object_type)) @scope.class + +;; Declarations — types +(class_declaration + name: (type_identifier) @declaration.name) @declaration.class + +(abstract_class_declaration + name: (type_identifier) @declaration.name) @declaration.class + +(interface_declaration + name: (type_identifier) @declaration.name) @declaration.interface + +(enum_declaration + name: (identifier) @declaration.name) @declaration.enum + +(type_alias_declaration + name: (type_identifier) @declaration.name) @declaration.type + +(internal_module + name: (identifier) @declaration.name) @declaration.namespace + +;; Declarations — methods / functions / constructors +(function_declaration + name: (identifier) @declaration.name) @declaration.function + +(generator_function_declaration + name: (identifier) @declaration.name) @declaration.function + +;; Function overload signatures (declaration-only; body in a separate +;; function_declaration). Extractors dedup by (name, parameterTypes). +(function_signature + name: (identifier) @declaration.name) @declaration.function + +;; Arrow/function-expression assigned to a const/let/var — named by the +;; variable_declarator. Covers \`const fn = () => {}\` and its export +;; variant. Matches the legacy TYPESCRIPT_QUERIES pattern. +(lexical_declaration + (variable_declarator + name: (identifier) @declaration.name + value: (arrow_function))) @declaration.function + +(lexical_declaration + (variable_declarator + name: (identifier) @declaration.name + value: (function_expression))) @declaration.function + +(variable_declaration + (variable_declarator + name: (identifier) @declaration.name + value: (arrow_function))) @declaration.function + +(variable_declaration + (variable_declarator + name: (identifier) @declaration.name + value: (function_expression))) @declaration.function + +;; Method definitions — regular + private (#field) methods. +(method_definition + name: (property_identifier) @declaration.name) @declaration.method + +(method_definition + name: (private_property_identifier) @declaration.name) @declaration.method + +;; Abstract method signatures in abstract classes. +(abstract_method_signature + name: (property_identifier) @declaration.name) @declaration.method + +;; Interface method signatures. +(method_signature + name: (property_identifier) @declaration.name) @declaration.method + +;; Declarations — class fields +(public_field_definition + name: (property_identifier) @declaration.name) @declaration.property + +(public_field_definition + name: (private_property_identifier) @declaration.name) @declaration.property + +;; Declarations — parameter properties: \`constructor(public name: string)\`. +;; The accessibility_modifier presence distinguishes these from regular +;; parameters. The identifier is also bound as a parameter in the +;; constructor's scope via @type-binding.parameter below (dual binding). +(required_parameter + (accessibility_modifier) + pattern: (identifier) @declaration.name) @declaration.property + +;; Declarations — variables (let / const / var) +(lexical_declaration + (variable_declarator + name: (identifier) @declaration.name)) @declaration.variable + +(variable_declaration + (variable_declarator + name: (identifier) @declaration.name)) @declaration.variable + +;; Imports — single anchor per statement; decomposer emits per-specifier markers. +(import_statement) @import.statement + +;; Re-exports: \`export { X } from './y'\` / \`export * from './y'\` / +;; \`export * as ns from './y'\` / \`export type { X } from './y'\`. +;; Only re-exports (those with a \`from\` clause) emit @import.statement; +;; local \`export { X }\` (no source) is just visibility metadata, not an +;; import. The decomposer filters by source presence. +(export_statement + source: (string)) @import.statement + +;; Dynamic imports: \`import('./m')\` / \`await import(x)\`. tree-sitter- +;; typescript represents \`import\` as a named leaf node; the call_expression's +;; function field points at it. +(call_expression + function: (import)) @import.dynamic + +;; Type bindings — parameter annotations: \`function f(u: User)\` +(required_parameter + pattern: (identifier) @type-binding.name + type: (type_annotation + (type_identifier) @type-binding.type)) @type-binding.parameter + +(required_parameter + pattern: (identifier) @type-binding.name + type: (type_annotation + (generic_type) @type-binding.type)) @type-binding.parameter + +(required_parameter + pattern: (identifier) @type-binding.name + type: (type_annotation + (predefined_type) @type-binding.type)) @type-binding.parameter + +;; Parameter with union / array / readonly wrappers: \`users: readonly User[]\`, +;; \`x: User | null\`, \`xs: User[]\`. interpret strips wrappers to the +;; discriminating type. +(required_parameter + pattern: (identifier) @type-binding.name + type: (type_annotation + (union_type) @type-binding.type)) @type-binding.parameter + +(required_parameter + pattern: (identifier) @type-binding.name + type: (type_annotation + (array_type) @type-binding.type)) @type-binding.parameter + +(required_parameter + pattern: (identifier) @type-binding.name + type: (type_annotation + (readonly_type) @type-binding.type)) @type-binding.parameter + +;; Type bindings — parameter properties: +;; \`constructor(public address: Address)\` — each parameter with an +;; accessibility modifier is ALSO a class field. We emit a second +;; capture so \`tsBindingScopeFor\` can hoist these to the Class scope, +;; enabling \`user.address\` field access resolution. The regular +;; @type-binding.parameter above still fires for the constructor +;; scope binding — both bindings coexist, which is correct. +(required_parameter + (accessibility_modifier) + pattern: (identifier) @type-binding.name + type: (type_annotation + (type_identifier) @type-binding.type)) @type-binding.parameter-property + +(required_parameter + (accessibility_modifier) + pattern: (identifier) @type-binding.name + type: (type_annotation + (generic_type) @type-binding.type)) @type-binding.parameter-property + +(required_parameter + (accessibility_modifier) + pattern: (identifier) @type-binding.name + type: (type_annotation + (predefined_type) @type-binding.type)) @type-binding.parameter-property + +(required_parameter + (accessibility_modifier) + pattern: (identifier) @type-binding.name + type: (type_annotation + (union_type) @type-binding.type)) @type-binding.parameter-property + +(required_parameter + (accessibility_modifier) + pattern: (identifier) @type-binding.name + type: (type_annotation + (array_type) @type-binding.type)) @type-binding.parameter-property + +(required_parameter + (accessibility_modifier) + pattern: (identifier) @type-binding.name + type: (type_annotation + (readonly_type) @type-binding.type)) @type-binding.parameter-property + +(optional_parameter + pattern: (identifier) @type-binding.name + type: (type_annotation + (type_identifier) @type-binding.type)) @type-binding.parameter + +(optional_parameter + pattern: (identifier) @type-binding.name + type: (type_annotation + (generic_type) @type-binding.type)) @type-binding.parameter + +;; Type bindings — variable annotations: \`let u: User = ...\` / \`const u: User\`. +(variable_declarator + name: (identifier) @type-binding.name + type: (type_annotation + (type_identifier) @type-binding.type)) @type-binding.annotation + +(variable_declarator + name: (identifier) @type-binding.name + type: (type_annotation + (generic_type) @type-binding.type)) @type-binding.annotation + +(variable_declarator + name: (identifier) @type-binding.name + type: (type_annotation + (predefined_type) @type-binding.type)) @type-binding.annotation + +;; Union types like \`User | null\` / \`User | undefined\` — interpret's +;; stripNullableUnion collapses to the discriminating arm. +(variable_declarator + name: (identifier) @type-binding.name + type: (type_annotation + (union_type) @type-binding.type)) @type-binding.annotation + +;; Array types: \`User[]\` / \`readonly User[]\` — stripArraySuffix unwraps. +(variable_declarator + name: (identifier) @type-binding.name + type: (type_annotation + (array_type) @type-binding.type)) @type-binding.annotation + +(variable_declarator + name: (identifier) @type-binding.name + type: (type_annotation + (readonly_type) @type-binding.type)) @type-binding.annotation + +;; Type bindings — constructor-inferred: \`const u = new User()\`. +;; The variable_declarator's \`value\` field carries the new_expression; its +;; \`constructor\` field is the type identifier. Covers both typed (\`:User = \`) +;; and untyped declarations — the annotation pattern above wins if both +;; fire, via the scope-extractor's source-strength tie-break in +;; pass4CollectTypeBindings. +(variable_declarator + name: (identifier) @type-binding.name + value: (new_expression + constructor: (identifier) @type-binding.type)) @type-binding.constructor + +;; Qualified constructor: \`const u = new models.User()\`. Captures the +;; member_expression's text as the type — resolver's QualifiedNameIndex +;; handles the dotted lookup. +(variable_declarator + name: (identifier) @type-binding.name + value: (new_expression + constructor: (member_expression) @type-binding.type)) @type-binding.constructor + +;; Cast-wrapped constructor: \`const u = new User() as any\` / +;; \`const u = new User()!\`. The \`as T\` pattern also captures T itself +;; via the assertion clause above, but T is usually a non-discriminating +;; type (\`any\`, \`unknown\`) in these idioms; interpretTsTypeBinding +;; drops those so the constructor-inferred binding survives. +(variable_declarator + name: (identifier) @type-binding.name + value: (as_expression + (new_expression + constructor: (identifier) @type-binding.type))) @type-binding.constructor + +(variable_declarator + name: (identifier) @type-binding.name + value: (non_null_expression + (new_expression + constructor: (identifier) @type-binding.type))) @type-binding.constructor + +;; Double-cast: \`const u = new User() as unknown as any\` — as_expression +;; nested inside as_expression, with new_expression at the core. +(variable_declarator + name: (identifier) @type-binding.name + value: (as_expression + (as_expression + (new_expression + constructor: (identifier) @type-binding.type)))) @type-binding.constructor + +;; Type bindings — call-result alias: \`const u = find()\`. Chain-follow +;; walks \`find\`'s return type via propagateImportedReturnTypes for cross- +;; file; same-file covered by explicit return annotations. +(variable_declarator + name: (identifier) @type-binding.name + value: (call_expression + function: (identifier) @type-binding.type)) @type-binding.alias + +;; Type bindings — member-call alias: \`const u = svc.getUser()\`. The +;; callee is captured as a full \`member_expression\` text (\`svc.getUser\`) +;; so compound-receiver can resolve the receiver object before looking up +;; the method's hoisted return-type binding. +(variable_declarator + name: (identifier) @type-binding.name + value: (call_expression + function: (member_expression) @type-binding.type)) @type-binding.alias + +;; Type bindings — await chain: \`const u = await find()\` / \`await svc.m()\`. +(variable_declarator + name: (identifier) @type-binding.name + value: (await_expression + (call_expression + function: (identifier) @type-binding.type))) @type-binding.alias + +(variable_declarator + name: (identifier) @type-binding.name + value: (await_expression + (call_expression + function: (member_expression) @type-binding.type))) @type-binding.alias + +;; Awaited generic calls re-associate: \`await fn(...)\` parses as +;; \`call_expression(function: await_expression(identifier), type_arguments, arguments)\` +;; — NOT as an await_expression wrapping a call_expression. Handle both +;; free and member forms so the chain-follow picks up the inner callee. +(variable_declarator + name: (identifier) @type-binding.name + value: (call_expression + function: (await_expression + (identifier) @type-binding.type))) @type-binding.alias + +(variable_declarator + name: (identifier) @type-binding.name + value: (call_expression + function: (await_expression + (member_expression) @type-binding.type))) @type-binding.alias + +;; Type bindings — member-access alias: \`const addr = user.address\`. +;; Full \`member_expression\` text feeds compound-receiver Case 3b. +(variable_declarator + name: (identifier) @type-binding.name + value: (member_expression) @type-binding.type) @type-binding.member-alias + +;; Type bindings — identifier alias: \`const alias = user\`. Chain-follow +;; resolves alias via user's binding. +(variable_declarator + name: (identifier) @type-binding.name + value: (identifier) @type-binding.type) @type-binding.alias + +;; Type bindings — \`as\` assertion: \`const u = x as User\`. Prefer +;; the assertion's target type over RHS inference. as_expression's right +;; child is the target type (positional; no field name). +(variable_declarator + name: (identifier) @type-binding.name + value: (as_expression + (_) + (type_identifier) @type-binding.type)) @type-binding.assertion + +(variable_declarator + name: (identifier) @type-binding.name + value: (as_expression + (_) + (generic_type) @type-binding.type)) @type-binding.assertion + +;; Type bindings — non-null assertion: \`const u = find()!\`. Unwrap to the +;; underlying call's function identifier (matches the call-alias pattern). +(variable_declarator + name: (identifier) @type-binding.name + value: (non_null_expression + (call_expression + function: (identifier) @type-binding.type))) @type-binding.alias + +;; Type bindings — for-of element: \`for (const u of users)\` — bind u to +;; users (chain-follow unwraps to element type via stripGeneric). +(for_in_statement + left: (identifier) @type-binding.name + right: (identifier) @type-binding.type) @type-binding.alias + +;; Type bindings — for-of call iterable: \`for (const u of getUsers())\`. +(for_in_statement + left: (identifier) @type-binding.name + right: (call_expression + function: (identifier) @type-binding.type)) @type-binding.alias + +;; Type bindings — for-of member-call iterable: \`for (const u of svc.getUsers())\`. +(for_in_statement + left: (identifier) @type-binding.name + right: (call_expression + function: (member_expression) @type-binding.type)) @type-binding.alias + +;; Type bindings — for-of member-access iterable: \`for (const u of this.users)\`. +;; Bind u to \`users\` (the attribute name); chain-follow resolves users +;; via the enclosing class's field binding. +(for_in_statement + left: (identifier) @type-binding.name + right: (member_expression + property: (property_identifier) @type-binding.type)) @type-binding.alias + +;; Type bindings — class field annotation: \`private city: City\`. +(public_field_definition + name: (property_identifier) @type-binding.name + type: (type_annotation + (type_identifier) @type-binding.type)) @type-binding.annotation + +(public_field_definition + name: (property_identifier) @type-binding.name + type: (type_annotation + (generic_type) @type-binding.type)) @type-binding.annotation + +(public_field_definition + name: (property_identifier) @type-binding.name + type: (type_annotation + (predefined_type) @type-binding.type)) @type-binding.annotation + +;; Class field with union / array / readonly wrappers: +;; \`private users: User[]\`, \`private repos: readonly Repo[]\`, +;; \`private x: City | null\`. interpret strips wrappers to the +;; discriminating type so chain-follow unwraps to the element. +(public_field_definition + name: (property_identifier) @type-binding.name + type: (type_annotation + (union_type) @type-binding.type)) @type-binding.annotation + +(public_field_definition + name: (property_identifier) @type-binding.name + type: (type_annotation + (array_type) @type-binding.type)) @type-binding.annotation + +(public_field_definition + name: (property_identifier) @type-binding.name + type: (type_annotation + (readonly_type) @type-binding.type)) @type-binding.annotation + +;; Private class field annotation: \`#city: City\`. +(public_field_definition + name: (private_property_identifier) @type-binding.name + type: (type_annotation + (type_identifier) @type-binding.type)) @type-binding.annotation + +;; Type bindings — method return type: \`save(): User { … }\` / \`function f(): User { … }\`. +;; Function/method return-type is the type_annotation that is a direct +;; child of the function node (not the parameter's annotation). Anchor on +;; the function node so bindingScopeFor can hoist if the language requests +;; (TS keeps it on the method scope; we emit here and let the resolver +;; decide via hoistTypeBindingsToModule). +;; +;; Wrapper forms covered: plain \`User\`, generic \`Promise\`, +;; array \`User[]\`, readonly \`readonly User[]\`, union \`User | null\`. +;; \`stripArraySuffix\` / \`stripReadonly\` / \`stripNullableUnion\` in +;; interpret reduce these to the discriminating element so chain-follow +;; can unwrap iterators returned from \`getUsers(): User[]\`. +(function_declaration + name: (identifier) @type-binding.name + return_type: (type_annotation + (type_identifier) @type-binding.type)) @type-binding.return + +(function_declaration + name: (identifier) @type-binding.name + return_type: (type_annotation + (generic_type) @type-binding.type)) @type-binding.return + +(function_declaration + name: (identifier) @type-binding.name + return_type: (type_annotation + (array_type) @type-binding.type)) @type-binding.return + +(function_declaration + name: (identifier) @type-binding.name + return_type: (type_annotation + (readonly_type) @type-binding.type)) @type-binding.return + +(function_declaration + name: (identifier) @type-binding.name + return_type: (type_annotation + (union_type) @type-binding.type)) @type-binding.return + +(function_signature + name: (identifier) @type-binding.name + return_type: (type_annotation + (type_identifier) @type-binding.type)) @type-binding.return + +(function_signature + name: (identifier) @type-binding.name + return_type: (type_annotation + (generic_type) @type-binding.type)) @type-binding.return + +(function_signature + name: (identifier) @type-binding.name + return_type: (type_annotation + (array_type) @type-binding.type)) @type-binding.return + +(function_signature + name: (identifier) @type-binding.name + return_type: (type_annotation + (readonly_type) @type-binding.type)) @type-binding.return + +(function_signature + name: (identifier) @type-binding.name + return_type: (type_annotation + (union_type) @type-binding.type)) @type-binding.return + +(method_definition + name: (property_identifier) @type-binding.name + return_type: (type_annotation + (type_identifier) @type-binding.type)) @type-binding.return + +(method_definition + name: (property_identifier) @type-binding.name + return_type: (type_annotation + (generic_type) @type-binding.type)) @type-binding.return + +(method_definition + name: (property_identifier) @type-binding.name + return_type: (type_annotation + (array_type) @type-binding.type)) @type-binding.return + +(method_definition + name: (property_identifier) @type-binding.name + return_type: (type_annotation + (readonly_type) @type-binding.type)) @type-binding.return + +(method_definition + name: (property_identifier) @type-binding.name + return_type: (type_annotation + (union_type) @type-binding.type)) @type-binding.return + +(method_signature + name: (property_identifier) @type-binding.name + return_type: (type_annotation + (type_identifier) @type-binding.type)) @type-binding.return + +(method_signature + name: (property_identifier) @type-binding.name + return_type: (type_annotation + (generic_type) @type-binding.type)) @type-binding.return + +(method_signature + name: (property_identifier) @type-binding.name + return_type: (type_annotation + (array_type) @type-binding.type)) @type-binding.return + +(method_signature + name: (property_identifier) @type-binding.name + return_type: (type_annotation + (readonly_type) @type-binding.type)) @type-binding.return + +(method_signature + name: (property_identifier) @type-binding.name + return_type: (type_annotation + (union_type) @type-binding.type)) @type-binding.return + +(abstract_method_signature + name: (property_identifier) @type-binding.name + return_type: (type_annotation + (type_identifier) @type-binding.type)) @type-binding.return + +(abstract_method_signature + name: (property_identifier) @type-binding.name + return_type: (type_annotation + (generic_type) @type-binding.type)) @type-binding.return + +;; Type bindings — assignment rebind: \`u = new User()\` (no \`const\`). +(assignment_expression + left: (identifier) @type-binding.name + right: (new_expression + constructor: (identifier) @type-binding.type)) @type-binding.constructor + +(assignment_expression + left: (identifier) @type-binding.name + right: (call_expression + function: (identifier) @type-binding.type)) @type-binding.alias + +(assignment_expression + left: (identifier) @type-binding.name + right: (identifier) @type-binding.type) @type-binding.alias + +;; References — free calls: \`fn(args)\`. Exclude the dynamic-import form, +;; which would otherwise double-classify as a call to a built-in \`import\`. +;; tree-sitter can't negate (import) with #not-eq?; the captures.ts layer +;; filters dynamic-imports BEFORE the free-call is consumed. +(call_expression + function: (identifier) @reference.name) @reference.call.free + +;; Awaited free call with generics: \`await fn(...)\` — re-associated +;; by tree-sitter as \`call_expression(function: await_expression(identifier))\`. +(call_expression + function: (await_expression + (identifier) @reference.name)) @reference.call.free + +;; References — member calls: \`obj.method()\` (includes optional chain). +;; The (_) wildcard matches any named receiver including \`this\` / +;; \`super\` (both are named nodes in tree-sitter-typescript, unlike C#'s +;; anonymous tokens). +(call_expression + function: (member_expression + object: (_) @reference.receiver + property: (property_identifier) @reference.name)) @reference.call.member + +;; Awaited member call with generics: \`await svc.m(...)\` — re-associated +;; as \`call_expression(function: await_expression(member_expression))\`. +(call_expression + function: (await_expression + (member_expression + object: (_) @reference.receiver + property: (property_identifier) @reference.name))) @reference.call.member + +;; References — constructor calls: \`new User()\` / \`new ns.User()\`. +(new_expression + constructor: (identifier) @reference.name) @reference.call.constructor + +(new_expression + constructor: (member_expression) @reference.call.constructor.qualified) @reference.call.constructor + +;; References — write access: \`obj.field = value\`. +(assignment_expression + left: (member_expression + object: (_) @reference.receiver + property: (property_identifier) @reference.name)) @reference.write.member + +(augmented_assignment_expression + left: (member_expression + object: (_) @reference.receiver + property: (property_identifier) @reference.name)) @reference.write.member + +;; References — read access: \`obj.field\` used in a read context. +;; Fires on EVERY member_expression; \`emitTsScopeCaptures\` filters out +;; contexts that shouldn't emit a read ACCESSES edge (LHS of assignment, +;; the \`function:\` of a call_expression, property_identifier inside a +;; computed member name, etc.). Keeping the filter on the emit side lets +;; tree-sitter's pattern stay simple and we don't replicate AST-context +;; predicates in the query itself. +(member_expression + object: (_) @reference.receiver + property: (property_identifier) @reference.name) @reference.read.member +`; + +let _tsParser: Parser | null = null; +let _tsxParser: Parser | null = null; +let _tsQuery: Parser.Query | null = null; +let _tsxQuery: Parser.Query | null = null; + +/** + * Return the right tree-sitter parser for `filePath` (or the TS parser + * when no path is given — the legacy callsite shape). + */ +export function getTsParser(filePath?: string): Parser { + if (filePath !== undefined && isTsxFile(filePath)) { + if (_tsxParser === null) { + _tsxParser = new Parser(); + _tsxParser.setLanguage(TSX_GRAMMAR); + } + return _tsxParser; + } + if (_tsParser === null) { + _tsParser = new Parser(); + _tsParser.setLanguage(TS_GRAMMAR); + } + return _tsParser; +} + +/** + * Return the right tree-sitter Query (compiled against the same grammar + * as the parser). A Query bound to the `typescript` grammar can NOT be + * executed against a Tree produced by the `tsx` grammar — tree-sitter + * matches by node-type id, and the two grammars have separate id + * spaces. + */ +export function getTsScopeQuery(filePath?: string): Parser.Query { + if (filePath !== undefined && isTsxFile(filePath)) { + if (_tsxQuery === null) { + _tsxQuery = new Parser.Query(TSX_GRAMMAR, TYPESCRIPT_SCOPE_QUERY); + } + return _tsxQuery; + } + if (_tsQuery === null) { + _tsQuery = new Parser.Query(TS_GRAMMAR, TYPESCRIPT_SCOPE_QUERY); + } + return _tsQuery; +} + +/** + * Validate that a cached `Tree` was produced by the grammar matching + * `filePath` (TSX vs TypeScript). The runtime tree-sitter `Tree` exposes + * `getLanguage()` (returning the grammar object the parser was bound + * to); the .d.ts is incomplete, so we reach via a cast. Identity + * comparison against `TSX_GRAMMAR` / `TS_GRAMMAR` is exact: the same + * module instance produces both. If `getLanguage` is unavailable for + * any reason, return true to keep behavior backwards-compatible (the + * original code never validated grammar at all). + */ +export function tsCachedTreeMatchesGrammar(tree: unknown, filePath: string): boolean { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const lang = (tree as any)?.getLanguage?.(); + if (lang === undefined || lang === null) return true; + return isTsxFile(filePath) ? lang === TSX_GRAMMAR : lang === TS_GRAMMAR; +} diff --git a/gitnexus/src/core/ingestion/languages/typescript/receiver-binding.ts b/gitnexus/src/core/ingestion/languages/typescript/receiver-binding.ts new file mode 100644 index 000000000..bc213e353 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/typescript/receiver-binding.ts @@ -0,0 +1,186 @@ +/** + * Synthesize `@type-binding.this` captures for TypeScript instance-like + * methods. + * + * Tree-sitter can't cleanly express "the implicit `this` receiver of a + * non-static member of a class / interface / abstract class" via a + * static `.scm` pattern, so we walk up the AST in code — mirrors + * Python's `self` / `cls` and C#'s `this` / `base` synthesis. + * + * Scope coverage: + * + * - `method_definition` inside `class_declaration`, + * `abstract_class_declaration`, or `class_expression` → synthesize + * `this` → enclosing class name. + * - `method_signature` / `abstract_method_signature` inside + * `interface_declaration` or `abstract_class_declaration` → + * synthesize `this` → enclosing type's name (so interface method + * bodies' `this.x` chains resolve via the interface's field + * annotations). + * - `arrow_function` / `function_expression` that is a direct value + * of a `public_field_definition` (class field) — `m = () => {}` — + * synthesize `this` → enclosing class name. These capture `this` + * lexically; without synthesis, their body's `this.foo` wouldn't + * resolve. + * + * Not synthesized (intentionally): + * + * - `static` methods / static fields. `this` in a static context + * refers to the class constructor, not an instance; we leave the + * binding empty and let chain resolution fall through to the + * class's static members lookup. + * - Regular `function_declaration` / `function_expression` at + * module level or in a non-class context. No enclosing type, no + * `this` semantics. + * - Arrow functions nested inside a method body. The scope-chain + * walk in `tsReceiverBinding` finds the outer method's `this` + * naturally, matching TS's lexical-this rule for arrow functions. + * + * Each synthesized match emits the anchor captures needed by + * `interpretTsTypeBinding`: + * + * `@type-binding.this` (source discriminator — interpret maps to 'self') + * `@type-binding.name` (the literal `'this'`) + * `@type-binding.type` (the enclosing type's name) + */ + +import type { Capture, CaptureMatch } from 'gitnexus-shared'; +import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; + +/** Node types that define a TypeScript "type with instance members". */ +const TYPE_DECL_NODE_TYPES = new Set([ + 'class_declaration', + 'abstract_class_declaration', + 'class', + 'class_expression', + 'interface_declaration', +]); + +/** Scope function nodes that could be a class method body. */ +const CLASS_MEMBER_FUNCTION_TYPES = new Set([ + 'method_definition', + 'method_signature', + 'abstract_method_signature', +]); + +/** Function-like values that can back a class field (`m = () => {}`). */ +const CLASS_FIELD_FUNCTION_TYPES = new Set(['arrow_function', 'function_expression']); + +/** + * Produce zero or one `CaptureMatch` synthesizing `this` for `fnNode`. + * + * - `null` — function has no synthetic `this` (free / static / + * not-in-class / no name on enclosing type). + * - One match — anchor on the function body so the synthetic binding + * attaches to the function's scope (not the outer class scope). + * + * The caller is responsible for passing a `fnNode` whose type is one + * of the scope function nodes the scope query emits. + */ +export function synthesizeTsReceiverBinding(fnNode: SyntaxNode): CaptureMatch | null { + // Classify the function's role. + const role = classifyFunctionRole(fnNode); + if (role === null) return null; + + // Static methods / static fields don't have an instance `this`. + if (isStaticMember(role.memberNode)) return null; + + // Find enclosing type declaration. Walking past function-like + // boundaries is fine — nested local functions inside a method can + // still reference outer `this` through the lexical chain, but we + // only synthesize on the immediate function body. The resolver's + // scope-chain walk reaches ancestor synthesized bindings for nested + // arrow functions. + const enclosingType = findEnclosingType(role.memberNode); + if (enclosingType === null) return null; + + const typeName = getTypeDeclName(enclosingType); + if (typeName === null) return null; + + // Anchor the synthetic capture on the function body so the binding + // lands in the method's scope, not its parent type scope. Method + // signatures in interfaces / abstract classes have no body — use + // the method node itself as the anchor; scope-extractor attaches + // it to the function scope created by the `@scope.function` + // anchor at the same range. + const anchorNode = fnNode.childForFieldName('body') ?? fnNode; + + return buildThisBinding(anchorNode, typeName); +} + +interface FunctionRole { + /** Node carrying the (possibly `static`) modifier. For method + * definitions this is `fnNode` itself; for arrow-bodied fields + * this is the `public_field_definition` parent. */ + readonly memberNode: SyntaxNode; +} + +/** + * Decide whether `fnNode` participates as a class member. Returns + * `null` when the function is not structurally "a class instance + * member" — e.g. a free function, a non-method arrow expression, an + * arrow inside a method body (those inherit `this` via scope chain + * lookup, no synthesis needed). + */ +function classifyFunctionRole(fnNode: SyntaxNode): FunctionRole | null { + if (CLASS_MEMBER_FUNCTION_TYPES.has(fnNode.type)) { + return { memberNode: fnNode }; + } + if (CLASS_FIELD_FUNCTION_TYPES.has(fnNode.type)) { + // `public_field_definition` represents a class field. Only when + // the arrow/function-expression is a DIRECT value of a field do + // we treat it as a class method with synthesized `this`. + const parent = fnNode.parent; + if (parent !== null && parent.type === 'public_field_definition') { + const valueField = parent.childForFieldName('value'); + if (valueField !== null && valueField.startIndex === fnNode.startIndex) { + return { memberNode: parent }; + } + } + } + return null; +} + +/** Class-body definitions carry an optional `accessibility_modifier` and + * optional `static` keyword as named children. The `static` token is + * usually a plain child of the member node — not a named field — so + * we scan children for a token whose text is exactly `static`. */ +function isStaticMember(memberNode: SyntaxNode): boolean { + for (let i = 0; i < memberNode.childCount; i++) { + const c = memberNode.child(i); + if (c === null) continue; + // `static` can appear as an unnamed token or as a `readonly` / + // `static` keyword node depending on grammar version; check text. + if (c.text === 'static') return true; + } + return false; +} + +function findEnclosingType(node: SyntaxNode): SyntaxNode | null { + let cur: SyntaxNode | null = node.parent; + while (cur !== null) { + if (TYPE_DECL_NODE_TYPES.has(cur.type)) return cur; + cur = cur.parent; + } + return null; +} + +/** Return the declared name of a class / interface / abstract-class. + * `class_expression` ( `const X = class { … }` ) may lack a name — + * in that case we return `null` and the caller skips synthesis (the + * outer variable's name is the usable handle, but wiring it would + * require a separate walk; defer to a follow-up). */ +function getTypeDeclName(typeNode: SyntaxNode): string | null { + const nameField = typeNode.childForFieldName('name'); + if (nameField === null) return null; + return nameField.text; +} + +function buildThisBinding(anchorNode: SyntaxNode, typeText: string): CaptureMatch { + const m: Record = { + '@type-binding.this': nodeToCapture('@type-binding.this', anchorNode), + '@type-binding.name': syntheticCapture('@type-binding.name', anchorNode, 'this'), + '@type-binding.type': syntheticCapture('@type-binding.type', anchorNode, typeText), + }; + return m; +} diff --git a/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts new file mode 100644 index 000000000..33f49307d --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/typescript/scope-resolver.ts @@ -0,0 +1,148 @@ +/** + * TypeScript `ScopeResolver` registered in `SCOPE_RESOLVERS` and + * consumed by the generic `runScopeResolution` orchestrator + * (RFC #909 Ring 3). + * + * Third migration after Python and C#. Follows the same minimal + * wiring-only pattern — per-hook logic lives in the sibling modules + * (`arity.ts`, `merge-bindings.ts`, `import-target.ts`, etc.). + * + * See ./index.ts for the per-module rationale and the full list of + * known limitations. The canonical capture vocabulary is pinned in + * ./query.ts (TYPESCRIPT_SCOPE_QUERY constant). + */ + +import type { ParsedFile } from 'gitnexus-shared'; +import { SupportedLanguages } from 'gitnexus-shared'; +import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js'; +import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js'; +import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js'; +import { typescriptProvider } from '../typescript.js'; +import { loadTsconfigPaths, type TsconfigPaths } from '../../language-config.js'; +import { + typescriptArityCompatibility, + typescriptMergeBindings, + resolveTsTarget, + type TsResolveContext, +} from './index.js'; + +/** Shape the orchestrator threads in via `RunScopeResolutionInput.resolutionConfig`. */ +interface TypescriptResolutionConfig { + readonly tsconfigPaths: TsconfigPaths | null; +} + +/** + * Build a `resolveImportTarget` adapter that memoizes the workspace + * file list, the lower-cased file list, and the per-pass `resolveCache` + * across every import lookup in a single workspace pass. The + * orchestrator passes the same `ReadonlySet` reference for every call + * within a pass — we use that identity to detect when the workspace + * changes and recompute the derived state lazily. + * + * Without this memoization, `resolveTsTarget` re-derived + * `allFileList` and `normalizedFileList` (both O(N_files)) and threw + * away the `resolveCache` on every import — O(N_files × N_imports) + * total work for what should be O(N_files + N_imports). + */ +function makeTsResolveImportTarget(): ScopeResolver['resolveImportTarget'] { + interface PassCache { + readonly key: ReadonlySet; + readonly allFilePaths: Set; + readonly allFileList: readonly string[]; + readonly normalizedFileList: readonly string[]; + readonly resolveCache: Map; + } + let cached: PassCache | null = null; + + return (targetRaw, fromFile, allFilePaths, resolutionConfig) => { + if (cached === null || cached.key !== allFilePaths) { + const allFileList = Array.from(allFilePaths); + cached = { + key: allFilePaths, + allFilePaths: new Set(allFilePaths), + allFileList, + normalizedFileList: allFileList.map((f) => f.toLowerCase()), + resolveCache: new Map(), + }; + } + + const cfg = resolutionConfig as TypescriptResolutionConfig | undefined; + const ws: TsResolveContext = { + fromFile, + allFilePaths: cached.allFilePaths, + allFileList: cached.allFileList, + normalizedFileList: cached.normalizedFileList, + resolveCache: cached.resolveCache, + tsconfigPaths: cfg?.tsconfigPaths ?? null, + }; + return resolveTsTarget(targetRaw, ws); + }; +} + +const typescriptScopeResolver: ScopeResolver = { + language: SupportedLanguages.TypeScript, + languageProvider: typescriptProvider, + importEdgeReason: 'typescript-scope: import', + + resolveImportTarget: makeTsResolveImportTarget(), + + // Threaded into `resolveImportTarget` so tsconfig path aliases + // (`@/services/user`, `~/x`, …) resolve through the same standard + // resolver branch the legacy DAG uses. One I/O round-trip per + // workspace pass; the orchestrator awaits this once. + loadResolutionConfig: async (repoPath: string) => ({ + tsconfigPaths: await loadTsconfigPaths(repoPath), + }), + + // TypeScript declaration merging + LEGB: local > import > wildcard, + // separated by declaration space (value / type / namespace). The + // per-scope id is unused (shadowing is computed from origin + def.type), + // so we don't need to synthesize a Scope here. + mergeBindings: (existing, incoming) => [...typescriptMergeBindings([...existing, ...incoming])], + + // Adapter: typescriptArityCompatibility uses (def, callsite); the + // ScopeResolver contract is (callsite, def). + arityCompatibility: (callsite, def) => typescriptArityCompatibility(def, callsite), + + buildMro: (graph, parsedFiles, nodeLookup) => + buildMro(graph, parsedFiles, nodeLookup, defaultLinearize), + + populateOwners: (parsed: ParsedFile) => populateClassOwnedMembers(parsed), + + // TypeScript uses `super` for super-class dispatch as a plain + // identifier or as `super()` in constructors. Match both — `super` + // on its own (`super.foo`, `super[x]`) and `super(...)` (constructor + // chain). This also correctly rejects identifiers that merely + // contain the substring `super` (e.g. `superman`). + isSuperReceiver: (text) => /^super(\s*\(|\s*\.|\s*\[|\s*$)/.test(text.trim()), + + // TypeScript is statically typed — field-fallback heuristic off + // (the type-binding layer produces precise owner types). Return- + // type propagation across imports on (matches the legacy DAG's + // behavior: explicit return-type annotations flow across `export` + // boundaries and resolve chained member calls). + fieldFallbackOnMethodLookup: false, + propagatesReturnTypesAcrossImports: true, + + // TypeScript uses `.values()` / `.keys()` method-call syntax for + // collection views — no property-style accessors like C#'s + // `Dictionary.Values`. Leave `unwrapCollectionAccessor` + // undefined and let the regular member-call branch handle them. + // + // `collapseMemberCallsByCallerTarget` left undefined (= false) — + // TypeScript legacy DAG emits one edge per call site, so + // per-site dedup is the parity target. + // + // `populateNamespaceSiblings` left undefined — TypeScript requires + // an explicit `import` / namespace augmentation for cross-file + // visibility; there's no implicit same-namespace sibling rule + // like C#'s. + // + // `hoistTypeBindingsToModule` — `tsBindingScopeFor` DOES hoist + // method return-type bindings to the enclosing Module scope + // (mirrors C#), so enable the walk-up that lets the compound- + // receiver resolver find them. + hoistTypeBindingsToModule: true, +}; + +export { typescriptScopeResolver }; diff --git a/gitnexus/src/core/ingestion/languages/typescript/simple-hooks.ts b/gitnexus/src/core/ingestion/languages/typescript/simple-hooks.ts new file mode 100644 index 000000000..1dbb03dc7 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/typescript/simple-hooks.ts @@ -0,0 +1,162 @@ +/** + * Trivial / no-op-ish hooks for the TypeScript provider. Kept together + * because each is a few lines and they share a common theme: making + * the provider's choice explicit rather than relying on "absence == + * default" so reviewers don't have to re-derive the analysis. + */ + +import type { + CaptureMatch, + ParsedImport, + Scope, + ScopeId, + ScopeTree, + TypeRef, +} from 'gitnexus-shared'; + +// ─── bindingScopeFor ────────────────────────────────────────────────────── + +/** + * TypeScript/JavaScript has block-scoped `let`/`const` (the innermost + * default covers these) but function-scoped `var` — which hoists to + * the enclosing **function or module** scope, bypassing intermediate + * blocks. JS also function-hoists `function_declaration` to the same + * level. + * + * We distinguish var from let/const by sniffing the `@declaration.variable` + * capture's leading keyword. The capture's text begins with the + * source-literal keyword (`var ` / `let ` / `const `) because the + * anchor is the outer `lexical_declaration` / `variable_declaration` + * node — there's no whitespace before the keyword in any well-formed + * TS/JS source. + * + * Additionally hoists **method return-type bindings** + * (`@type-binding.return`) all the way to the Module scope, matching + * C#: the compound-receiver walker and `propagateImportedReturnTypes` + * both read from module-level typeBindings for cross-file chain + * propagation. + */ +export function tsBindingScopeFor( + decl: CaptureMatch, + innermost: Scope, + tree: ScopeTree, +): ScopeId | null { + // Method return type: hoist to Module (mirrors csharpBindingScopeFor). + if (decl['@type-binding.return'] !== undefined) { + return walkToScope(innermost, tree, 'Module'); + } + + // Parameter property (`constructor(public address: Address)`): hoist + // to the enclosing Class scope so `user.address` field access + // resolves through the class's typeBindings. The regular + // @type-binding.parameter binding still fires for the constructor + // scope; this one adds a second binding on the class. + if (decl['@type-binding.parameter-property'] !== undefined) { + return walkToScope(innermost, tree, 'Class'); + } + + // `var` declarations: hoist to nearest enclosing Function or Module. + const variable = decl['@declaration.variable']; + if (variable !== undefined && isVarDeclaration(variable.text)) { + return walkToScope(innermost, tree, 'Function', 'Module'); + } + + // Function declarations are already anchored at their definition + // site via `@scope.function`; hoisting is a no-op for them (JS + // function hoisting is about visibility before the definition, not + // about placing the binding in a different scope). The scope tree + // already attaches their name to the enclosing scope. No override + // needed. + return null; +} + +/** + * Walk up the scope chain to find the first scope whose `kind` matches + * any of `kinds`. Returns the matching scope's id or `null` when no + * ancestor matches (e.g., a return type binding emitted outside any + * Module scope — shouldn't happen in well-formed input). + */ +function walkToScope( + from: Scope, + tree: ScopeTree, + ...kinds: readonly Scope['kind'][] +): ScopeId | null { + let cur: Scope | undefined = from; + const kindSet = new Set(kinds); + while (cur !== undefined) { + if (kindSet.has(cur.kind)) return cur.id; + const parentId: ScopeId | null = cur.parent ?? null; + if (parentId === null) break; + cur = tree.getScope(parentId); + } + return null; +} + +/** `var x = 1;` vs `let x = 1;` / `const x = 1;`. The capture's text + * starts at the outer declaration's `startIndex` in source, which is + * the keyword's first character — no leading whitespace possible. */ +function isVarDeclaration(captureText: string): boolean { + return ( + captureText.startsWith('var ') || + captureText.startsWith('var\t') || + captureText.startsWith('var\n') + ); +} + +// ─── importOwningScope ──────────────────────────────────────────────────── + +/** + * TypeScript imports are syntactically top-level: `import_statement` is + * legal only inside `program` (the module root). `namespace X { … }` + * bodies CAN contain imports (`internal_module`), in which case the + * import scopes to the namespace. Dynamic `import()` calls appear + * inside any scope but their runtime effect is still a module-level + * resolution — we attach the `ParsedImport` to the innermost Module / + * Namespace scope so the binding is visible through the full subtree. + * + * Returning `null` delegates to the central default, which walks to + * the nearest enclosing `Module`/`Namespace`. That matches our rule, + * so we only override when we explicitly need a non-default scope + * (we don't). + */ +export function tsImportOwningScope( + _imp: ParsedImport, + _innermost: Scope, + _tree: ScopeTree, +): ScopeId | null { + return null; +} + +// ─── receiverBinding ────────────────────────────────────────────────────── + +/** + * Look up `this` on the function scope's type bindings. + * + * `this` is synthesized as a type binding on instance-method function + * scopes during capture emission (`receiver-binding.ts`). Arrow + * functions and nested functions that reference `this` naturally + * resolve it via the scope-chain walk — if the arrow function is a + * class method (`m = () => {}`), it gets a synthesized `this`; if it + * is nested inside a class method, the scope-chain lookup finds the + * outer method's `this`. This mirrors TypeScript's lexical-this + * semantics for arrow functions. + * + * Returns `null` for: + * - static methods (no `this` synthesized) + * - free functions / module-level code (no enclosing class-like) + * - non-Function scopes + * + * Caveat: a non-arrow `function` declaration nested inside a method + * DOES see the outer `this` via our scope-chain lookup, even though at + * runtime its `this` is independently bound (strict-mode `undefined`, + * sloppy `globalThis`). We accept this false-positive — the real-world + * pattern that relies on independent `this` inside a nested regular + * function inside a class method is extremely rare, and catching it + * would require injecting a `this: undefined` shadow on every non- + * arrow function scope. Documented as a known limitation in + * `index.ts`. + */ +export function tsReceiverBinding(functionScope: Scope): TypeRef | null { + if (functionScope.kind !== 'Function') return null; + return functionScope.typeBindings.get('this') ?? null; +} diff --git a/gitnexus/src/core/ingestion/method-extractors/configs/swift.ts b/gitnexus/src/core/ingestion/method-extractors/configs/swift.ts index 99ff2e63f..6c0fdfbe3 100644 --- a/gitnexus/src/core/ingestion/method-extractors/configs/swift.ts +++ b/gitnexus/src/core/ingestion/method-extractors/configs/swift.ts @@ -1,5 +1,5 @@ // gitnexus/src/core/ingestion/method-extractors/configs/swift.ts -// Verified against tree-sitter-swift 0.6.0 +// Verified against tree-sitter-swift 0.7.x import { SupportedLanguages } from 'gitnexus-shared'; import type { @@ -110,7 +110,7 @@ function extractSwiftReturnType(node: SyntaxNode): string | undefined { function extractSwiftParameters(node: SyntaxNode): ParameterInfo[] { const params: ParameterInfo[] = []; - // In tree-sitter-swift 0.6.0, parameters are direct children of function_declaration. + // In tree-sitter-swift, parameters are direct children of function_declaration. // Default value tokens ('=', literal) are siblings of the parameter node at the // function_declaration level, not children of the parameter node. for (let i = 0; i < node.childCount; i++) { @@ -264,8 +264,7 @@ function extractSwiftAnnotations(node: SyntaxNode): string[] { export const swiftMethodConfig: MethodExtractionConfig = { language: SupportedLanguages.Swift, - // tree-sitter-swift 0.6.0 may use class_declaration for classes, structs, enums, extensions, - // and actors — but this cannot be verified until the grammar installs on Node 22+. + // Keep this conservative until Swift type-shape coverage is expanded. // TODO: Verify struct_declaration, enum_declaration, extension_declaration, actor_declaration // node types once tree-sitter-swift loads on Node 22, and add them here if they are distinct. // protocol_declaration is a separate, confirmed node type. diff --git a/gitnexus/src/core/ingestion/model/method-registry.ts b/gitnexus/src/core/ingestion/model/method-registry.ts index 327f568e7..75f9834c2 100644 --- a/gitnexus/src/core/ingestion/model/method-registry.ts +++ b/gitnexus/src/core/ingestion/model/method-registry.ts @@ -49,6 +49,16 @@ export interface MethodRegistry { */ lookupMethodByName(name: string): readonly SymbolDefinition[]; + /** + * Return every overload registered under `(ownerNodeId, methodName)`, + * unfiltered by arity or return type. This is the raw owner-scoped + * view — callers that need arity narrowing or unambiguous single- + * result semantics should use `lookupMethodByOwner` instead. + * + * Returns `[]` on miss so callers can iterate without null checks. + */ + lookupAllByOwner(ownerNodeId: string, methodName: string): readonly SymbolDefinition[]; + /** * True iff at least one registered def has `type === 'Function'` — i.e., * a Python/Rust/Kotlin class method emitted by the worker as @@ -162,6 +172,13 @@ export const createMethodRegistry = (): MutableMethodRegistry => { return methodsByName.get(name) ?? EMPTY; }; + const lookupAllByOwner = ( + ownerNodeId: string, + methodName: string, + ): readonly SymbolDefinition[] => { + return methodByOwner.get(`${ownerNodeId}\0${methodName}`) ?? EMPTY; + }; + const register = (ownerNodeId: string, methodName: string, def: SymbolDefinition): void => { const key = `${ownerNodeId}\0${methodName}`; const existing = methodByOwner.get(key); @@ -195,6 +212,7 @@ export const createMethodRegistry = (): MutableMethodRegistry => { return { lookupMethodByOwner, lookupMethodByName, + lookupAllByOwner, register, clear, get hasFunctionMethods() { diff --git a/gitnexus/src/core/ingestion/model/scope-resolution-indexes.ts b/gitnexus/src/core/ingestion/model/scope-resolution-indexes.ts index 67e32fc0e..a3fba7b30 100644 --- a/gitnexus/src/core/ingestion/model/scope-resolution-indexes.ts +++ b/gitnexus/src/core/ingestion/model/scope-resolution-indexes.ts @@ -62,8 +62,21 @@ export interface ScopeResolutionIndexes { readonly methodDispatch: MethodDispatchIndex; /** Finalized `ImportEdge[]` per module scope. */ readonly imports: ReadonlyMap; - /** Merged bindings (local + imports + wildcards) per module scope. */ + /** Finalize-output bindings (local + imports + wildcards) per module scope. + * Inner `BindingRef[]` arrays are frozen by `materializeBindings`; + * this channel is permanently immutable post-finalize. Consumers + * MUST read via `lookupBindingsAt` so the augmentation channel is + * consulted alongside. See I8 in `contract/scope-resolver.ts`. */ readonly bindings: ReadonlyMap>; + /** Append-only post-finalize augmentation channel. Populated by + * language hooks such as `populateNamespaceSiblings` for cross-file + * bindings synthesized after finalize (e.g. C# same-namespace + * visibility, `using static` member exposure). Inner arrays are + * NOT frozen — hooks `push()` directly. Walkers must consult both + * this map and `bindings` via `lookupBindingsAt`; finalized refs + * are returned first and win duplicate `def.nodeId` metadata, with + * unique augmentations appended after. See I8. */ + readonly bindingAugmentations: ReadonlyMap>; /** Pre-resolution usage facts; consumed by the resolution phase. */ readonly referenceSites: readonly ReferenceSite[]; /** SCC condensation of the file-level import graph — callers that want diff --git a/gitnexus/src/core/ingestion/model/semantic-model.ts b/gitnexus/src/core/ingestion/model/semantic-model.ts index d1c5f1446..b989ff5b3 100644 --- a/gitnexus/src/core/ingestion/model/semantic-model.ts +++ b/gitnexus/src/core/ingestion/model/semantic-model.ts @@ -44,6 +44,45 @@ * direct `createSymbolTable()` caller (e.g. an isolated unit test) gets * the pure, registry-free behavior — no surprises, no hidden side * effects. + * + * ## Single-source-of-truth invariant + * + * `SemanticModel` is the authoritative symbol store for the whole + * ingestion pipeline. Both the legacy Call-Resolution DAG and the + * new scope-resolution pipeline read symbol-keyed lookups from here + * exclusively — no parallel owner-keyed, name-keyed, or file-keyed + * symbol indexes exist outside this module. The scope-resolution + * pipeline does carry a small `WorkspaceResolutionIndex` for + * `Scope`-valued maps (`classScopeByDefId`, `moduleScopeByFile`) that + * `SemanticModel` structurally cannot hold, but nothing else. + * + * ## Write / read phase contract + * + * Writes to the model happen in three clearly-ordered phases during a + * single ingestion run: + * + * 1. **Legacy parse phase** (`parsing-processor`) calls + * `symbols.add(...)` per extracted symbol, which fans out via + * the dispatch table into `types` / `methods` / `fields`. + * 2. **Scope-resolution reconciliation** (`reconcileOwnership` in + * `scope-resolution/pipeline/reconcile-ownership.ts`) registers + * any `parsed.localDefs[i]` with a scope-resolution-corrected + * `ownerId` that the legacy pass missed (Python class-body + * methods are the canonical case). Idempotent. + * 3. **Finalize-orchestrator** calls `attachScopeIndexes(...)` to + * stamp the materialized `ScopeResolutionIndexes` bundle onto + * `model.scopes`. One-shot; throws on a second call. + * + * After these three phases, the model is effectively frozen: + * - `attachScopeIndexes` applied `Object.freeze` to its bundle. + * - Downstream passes receive the narrowed `SemanticModel` reader + * handle (not `MutableSemanticModel`), so `.register()` / + * `.clear()` / `attachScopeIndexes()` are structurally absent. + * + * See `scope-resolution/contract/scope-resolver.ts` Contract + * Invariant I9 for the scope-resolution-side rule and + * `ARCHITECTURE.md` § "Semantic-model source of truth" for the + * overall architecture. */ import type { NodeLabel } from 'gitnexus-shared'; diff --git a/gitnexus/src/core/ingestion/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index 866fec14f..86b419990 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -11,6 +11,7 @@ import { ASTCache } from './ast-cache.js'; import { getLanguageFromFilename, SupportedLanguages } from 'gitnexus-shared'; import { extractVueScript, isVueSetupTopLevel } from './vue-sfc-extractor.js'; import { yieldToEventLoop } from './utils/event-loop.js'; +import { isVerboseIngestionEnabled } from './utils/verbose.js'; import { getDefinitionNodeFromCaptures, findEnclosingClassInfo, @@ -47,7 +48,11 @@ import type { FileScopeBindings, ExtractedORMQuery, } from './workers/parse-worker.js'; -import { getTreeSitterBufferSize, TREE_SITTER_MAX_BUFFER } from './constants.js'; +import { + getTreeSitterBufferSize, + getTreeSitterContentByteLength, + TREE_SITTER_MAX_BUFFER, +} from './constants.js'; export type FileProgressCallback = (current: number, total: number, filePath: string) => void; @@ -323,7 +328,8 @@ const processParsingSequential = async ( ) => { const parser = await loadParser(); const total = files.length; - const skippedLanguages = new Map(); + const logSkipped = isVerboseIngestionEnabled(); + const skippedByLang = logSkipped ? new Map() : null; for (let i = 0; i < files.length; i++) { const file = files[i]; @@ -342,15 +348,15 @@ const processParsingSequential = async ( const language = getLanguageFromFilename(file.path); if (!language) continue; - - // Skip unsupported languages (e.g. Swift when tree-sitter-swift not installed) if (!isLanguageAvailable(language)) { - skippedLanguages.set(language, (skippedLanguages.get(language) || 0) + 1); + if (skippedByLang) { + skippedByLang.set(language, (skippedByLang.get(language) ?? 0) + 1); + } continue; } // Skip files larger than the max tree-sitter buffer (32 MB) - if (file.content.length > TREE_SITTER_MAX_BUFFER) continue; + if (getTreeSitterContentByteLength(file.content) > TREE_SITTER_MAX_BUFFER) continue; // Vue SFC preprocessing: extract