mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-24 00:51:53 +00:00
Merge branch 'feat/Desktop-app' of https://github.com/prajapatisparsh/GitNexus into feat/Desktop-app
This commit is contained in:
commit
ba882ff450
188 changed files with 19514 additions and 2122 deletions
|
|
@ -17,11 +17,11 @@ 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) |
|
||||
| `--drop-embeddings` | Drop existing embeddings on rebuild. By default, an `analyze` without `--embeddings` preserves them. |
|
||||
| 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 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.
|
||||
|
||||
|
|
|
|||
102
.github/workflows/ci-tests.yml
vendored
102
.github/workflows/ci-tests.yml
vendored
|
|
@ -75,3 +75,105 @@ jobs:
|
|||
build: 'true'
|
||||
- run: npx vitest run
|
||||
working-directory: gitnexus
|
||||
|
||||
# End-to-end smoke test for the #1728 packaging fix: pack the published
|
||||
# tarball, install it globally into a temp prefix, and assert no junction
|
||||
# creation (the EPERM root cause) plus working CLI plus vendor cleanliness
|
||||
# (#836). Runs on windows-latest because that is the platform the fix
|
||||
# targets; the in-repo `npm ci` job above only exercises the dev-tree path
|
||||
# and skips the tarball reify step where the historical EPERM occurred.
|
||||
packaged-install-smoke:
|
||||
name: packaged install smoke (${{ matrix.os }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [windows-latest, ubuntu-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
# persist-credentials: false — this job runs npm pack + npm install -g
|
||||
# from a tarball and never pushes back; the token in .git/config would
|
||||
# be at risk of leaking through any future artifact-upload step
|
||||
# (zizmor artipacked audit). Disable upfront.
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
with:
|
||||
build: 'true'
|
||||
|
||||
- name: Pack gitnexus tarball
|
||||
shell: bash
|
||||
run: npm pack
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Install gitnexus tarball into isolated prefix
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PREFIX="$RUNNER_TEMP/gitnexus-smoke"
|
||||
mkdir -p "$PREFIX"
|
||||
TARBALL=$(find . -maxdepth 1 -name 'gitnexus-*.tgz' -print -quit)
|
||||
if [ -z "$TARBALL" ]; then
|
||||
echo "ERROR: no gitnexus-*.tgz tarball found in $(pwd)" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Installing $TARBALL into $PREFIX"
|
||||
npm install -g --prefix "$PREFIX" "./$TARBALL" --no-audit --no-fund
|
||||
echo "PREFIX=$PREFIX" >> "$GITHUB_ENV"
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Assert no junctions or vendor build artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Locate the installed gitnexus package across npm prefix layouts
|
||||
# (lib/node_modules on POSIX, node_modules on Windows).
|
||||
for candidate in "$PREFIX/lib/node_modules/gitnexus" "$PREFIX/node_modules/gitnexus"; do
|
||||
if [ -d "$candidate" ]; then
|
||||
INSTALLED="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -z "${INSTALLED:-}" ]; then
|
||||
echo "ERROR: installed gitnexus package not found under $PREFIX" >&2
|
||||
ls -la "$PREFIX" || true
|
||||
exit 1
|
||||
fi
|
||||
echo "Installed package at: $INSTALLED"
|
||||
|
||||
# #836 invariant: no node_modules/ or build/ under any vendor/*.
|
||||
BAD=$(find "$INSTALLED/vendor" \( -name node_modules -o -name build \) -print 2>/dev/null || true)
|
||||
if [ -n "$BAD" ]; then
|
||||
echo "ERROR: vendor tree contains forbidden build artifacts (#836):" >&2
|
||||
echo "$BAD" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# #1728 invariant: materialized grammar dirs are real directories,
|
||||
# not junctions/symlinks (which is what the EPERM regression created).
|
||||
for name in tree-sitter-dart tree-sitter-proto tree-sitter-swift; do
|
||||
entry="$INSTALLED/node_modules/$name"
|
||||
if [ ! -e "$entry" ]; then
|
||||
echo "WARN: $name not materialized (toolchain/prebuild may be unavailable on $RUNNER_OS)"
|
||||
continue
|
||||
fi
|
||||
if [ -L "$entry" ]; then
|
||||
echo "ERROR: $entry is a symlink/junction — #1728 regression" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -d "$entry" ]; then
|
||||
echo "ERROR: $entry is not a directory" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Assert gitnexus --version works
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$RUNNER_OS" = "Windows" ]; then
|
||||
"$PREFIX/gitnexus.cmd" --version
|
||||
else
|
||||
"$PREFIX/bin/gitnexus" --version
|
||||
fi
|
||||
|
|
|
|||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
|
|
@ -48,7 +48,7 @@ jobs:
|
|||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: security-and-quality
|
||||
|
|
@ -69,6 +69,6 @@ jobs:
|
|||
- '**/test/fixtures/**'
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
|
||||
with:
|
||||
category: '/language:${{ matrix.language }}'
|
||||
|
|
|
|||
2
.github/workflows/dependency-review.yml
vendored
2
.github/workflows/dependency-review.yml
vendored
|
|
@ -33,7 +33,7 @@ jobs:
|
|||
persist-credentials: false
|
||||
|
||||
- name: Dependency Review
|
||||
uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0
|
||||
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
|
||||
with:
|
||||
fail-on-severity: high
|
||||
comment-summary-in-pr: on-failure
|
||||
|
|
|
|||
2
.github/workflows/pr-labeler.yml
vendored
2
.github/workflows/pr-labeler.yml
vendored
|
|
@ -108,7 +108,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@563bf132657a13ded0b01fcb723c5a58cdd824e2 # v7.2.1
|
||||
- uses: release-drafter/release-drafter@c2e2804cc59f45f57076a99af580d0fedb697927 # v7.3.0
|
||||
with:
|
||||
config-name: release-drafter.yml
|
||||
dry-run: true
|
||||
|
|
|
|||
2
.github/workflows/scorecard.yml
vendored
2
.github/workflows/scorecard.yml
vendored
|
|
@ -53,6 +53,6 @@ jobs:
|
|||
retention-days: 5
|
||||
|
||||
- name: Upload to Security tab
|
||||
uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
|
|
|||
2
.github/workflows/trivy.yml
vendored
2
.github/workflows/trivy.yml
vendored
|
|
@ -76,7 +76,7 @@ jobs:
|
|||
exit-code: '0'
|
||||
|
||||
- name: Upload to Security tab
|
||||
uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
|
||||
with:
|
||||
sarif_file: trivy-${{ matrix.image.name }}.sarif
|
||||
category: trivy-${{ matrix.image.name }}
|
||||
|
|
|
|||
2
.github/workflows/workflow-lint.yml
vendored
2
.github/workflows/workflow-lint.yml
vendored
|
|
@ -76,7 +76,7 @@ jobs:
|
|||
continue-on-error: true
|
||||
|
||||
- name: Upload SARIF
|
||||
uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
|
||||
with:
|
||||
sarif_file: zizmor.sarif
|
||||
category: zizmor
|
||||
|
|
|
|||
118
AGENTS.md
118
AGENTS.md
|
|
@ -62,7 +62,7 @@ Commands and gotchas live under **Repo reference** below and in **[CONTRIBUTING.
|
|||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **gitnexus** (18208 symbols, 25369 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
This project is indexed by GitNexus as **GitNexus** (26675 symbols, 35395 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
|
|
@ -74,19 +74,6 @@ This project is indexed by GitNexus as **gitnexus** (18208 symbols, 25369 relati
|
|||
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
||||
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
|
||||
|
||||
## When Debugging
|
||||
|
||||
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
|
||||
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
|
||||
3. `READ gitnexus://repo/gitnexus/process/{processName}` — trace the full execution flow step by step
|
||||
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
|
||||
|
||||
## When Refactoring
|
||||
|
||||
- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`.
|
||||
- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code.
|
||||
- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed.
|
||||
|
||||
## Never Do
|
||||
|
||||
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
|
||||
|
|
@ -94,81 +81,14 @@ This project is indexed by GitNexus as **gitnexus** (18208 symbols, 25369 relati
|
|||
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
|
||||
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
|
||||
|
||||
## Tools Quick Reference
|
||||
|
||||
| Tool | When to use | Command |
|
||||
|------|-------------|---------|
|
||||
| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
|
||||
| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
|
||||
| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` |
|
||||
| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` |
|
||||
| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
|
||||
| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` |
|
||||
| `api_impact` | Pre-change API route impact | `gitnexus_api_impact({route: "/api/users", method: "GET"})` |
|
||||
| `route_map` | Route → handler → consumer map | `gitnexus_route_map({})` |
|
||||
| `tool_map` | MCP/RPC tool definitions | `gitnexus_tool_map({})` |
|
||||
| `shape_check` | Response shape vs consumer access | `gitnexus_shape_check({route: "/api/users"})` |
|
||||
| `group_list` | List repo groups | `gitnexus_group_list({})` |
|
||||
| `group_sync` | Rebuild group Contract Registry | `gitnexus_group_sync({name: "myGroup"})` |
|
||||
| `query` (group mode) | Cross-repo search in a group (RRF-merged) | `gitnexus_query({repo: "@myGroup", query: "auth"})` |
|
||||
| `context` (group mode) | 360° view across all member repos | `gitnexus_context({repo: "@myGroup", name: "validateUser"})` |
|
||||
| `impact` (group mode) | Cross-repo blast radius via Contract Bridge | `gitnexus_impact({repo: "@myGroup", target: "X", direction: "upstream"})` |
|
||||
|
||||
> Group mode: pass `repo: "@<groupName>"` to fan out across all member repos, or `repo: "@<groupName>/<memberPath>"` to target a single member (path keys from `group.yaml`). Optional `service: "<monorepo/path>"` filters by service root. Group-level state (contracts, staleness) lives in the resources table below — there are **no** `group_query` / `group_context` / `group_impact` / `group_contracts` / `group_status` MCP tools.
|
||||
>
|
||||
> For a full walkthrough of setting up a group across multiple repos that communicate over gRPC, see [docs/guides/microservices-grpc.md](docs/guides/microservices-grpc.md).
|
||||
|
||||
## Impact Risk Levels
|
||||
|
||||
| Depth | Meaning | Action |
|
||||
|-------|---------|--------|
|
||||
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
|
||||
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
|
||||
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
|
||||
|
||||
## Resources
|
||||
|
||||
| Resource | Use for |
|
||||
|----------|---------|
|
||||
| `gitnexus://repo/GitNexus/context` | Codebase overview, index freshness |
|
||||
| `gitnexus://repo/GitNexus/context` | Codebase overview, check index freshness |
|
||||
| `gitnexus://repo/GitNexus/clusters` | All functional areas |
|
||||
| `gitnexus://repo/GitNexus/processes` | All execution flows |
|
||||
| `gitnexus://repo/GitNexus/process/{name}` | Step-by-step execution trace |
|
||||
| `gitnexus://group/{name}/contracts` | Group Contract Registry (provider/consumer rows + cross-links) |
|
||||
| `gitnexus://group/{name}/status` | Per-member index + Contract Registry staleness report |
|
||||
|
||||
## Self-Check Before Finishing
|
||||
|
||||
Before completing any code modification task, verify:
|
||||
1. `gitnexus_impact` was run for all modified symbols
|
||||
2. No HIGH/CRITICAL risk warnings were ignored
|
||||
3. `gitnexus_detect_changes()` confirms changes match expected scope
|
||||
4. All d=1 (WILL BREAK) dependents were updated
|
||||
|
||||
## Keeping the Index Fresh
|
||||
|
||||
After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it:
|
||||
|
||||
```bash
|
||||
npx gitnexus analyze
|
||||
```
|
||||
|
||||
If the index previously included embeddings, preserve them by adding `--embeddings`:
|
||||
|
||||
```bash
|
||||
npx gitnexus analyze # incremental by default; preserves embeddings
|
||||
npx gitnexus analyze --force # full rebuild from scratch (opt out of incremental)
|
||||
npx gitnexus analyze --embeddings # also generate embeddings for new/changed nodes
|
||||
npx gitnexus analyze --drop-embeddings # explicit opt-in to wipe existing embeddings
|
||||
```
|
||||
|
||||
`analyze` runs **incrementally by default**. The pipeline still parses every file every run (cross-file resolution requires it), but tree-sitter parsing is **served from a content-addressed cache** under `.gitnexus/parse-cache/` (per-chunk JSON shards plus `index.json`) for chunks whose file contents haven't changed since the last run. Older installs may still have a legacy single file `.gitnexus/parse-cache.json`, which is read for backward compatibility but no longer written. Only changed-file rows (and their importers) are rewritten in LadybugDB; unchanged-file rows are preserved. Output is byte-equivalent to a full rebuild. Pass `--force` to wipe and re-index from scratch (e.g., to recover from a corrupt index, or after upgrading GitNexus).
|
||||
|
||||
The parse cache key is **content-addressed and version-tagged**: it survives `--force` runs, and is automatically invalidated by a `gitnexus` package upgrade (so a new tree-sitter grammar doesn't silently replay stale parse output). Safe to delete the whole `.gitnexus/parse-cache/` directory (and remove any legacy `.gitnexus/parse-cache.json` if present) at any time — it'll be rebuilt on the next analyze.
|
||||
|
||||
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 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
|
||||
|
||||
|
|
@ -180,20 +100,26 @@ Check `.gitnexus/meta.json` `stats.embeddings` (0 = none). A plain `analyze` no
|
|||
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
|
||||
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
|
||||
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
|
||||
|
||||
## Hook env knobs
|
||||
|
||||
The Claude Code hook (`gitnexus/hooks/claude/gitnexus-hook.cjs` and the mirrored plugin copy under `gitnexus-claude-plugin/hooks/`) honours these env vars. Defaults work for normal installations; set them only to override resolution. All path overrides ignore values that do not exist on disk and fall through to the standard resolution chain.
|
||||
|
||||
| Env var | Type | Default | Purpose |
|
||||
|---------|------|---------|---------|
|
||||
| `GITNEXUS_HOOK_CLI_PATH` | path | resolved via package layout / `require.resolve` | Override path to the `gitnexus` CLI entry the hook spawns for `augment`. |
|
||||
| `GITNEXUS_HOOK_LSOF_PATH` | path | `lsof` on `PATH` (with `/usr/bin/lsof`, `/usr/sbin/lsof`, `/sbin/lsof` fallbacks) | Override POSIX `lsof` location for the DB-lock probe. |
|
||||
| `GITNEXUS_HOOK_PS_PATH` | path | `ps` on `PATH` (with `/bin/ps`, `/usr/bin/ps` fallbacks) | Override POSIX `ps` location. |
|
||||
| `GITNEXUS_HOOK_POWERSHELL_PATH` | path | `%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe` (then `SysWOW64`, then `powershell.exe` on `PATH`) | Override Windows PowerShell location used by the Restart-Manager probe. |
|
||||
| `GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS` | integer ms | `1200` | Max wall-clock for the Linux `/proc` fd scan before bailing out to the `lsof` fallback. |
|
||||
| `GITNEXUS_HOOK_RM_TARGET` | path | derived | Restart-Manager target file (the LadybugDB path under `.gitnexus/`). Set internally by the hook; rarely overridden manually. |
|
||||
| `GITNEXUS_DEBUG` | boolean (`1`/`true`) | unset | Verbose stderr from the hook: prints discarded augment-stderr prefixes and one-shot `.ps1` load-failure warnings. |
|
||||
| Work in the Ingestion area (239 symbols) | `.claude/skills/generated/ingestion/SKILL.md` |
|
||||
| Work in the Extractors area (135 symbols) | `.claude/skills/generated/extractors/SKILL.md` |
|
||||
| Work in the Components area (112 symbols) | `.claude/skills/generated/components/SKILL.md` |
|
||||
| Work in the Lbug area (96 symbols) | `.claude/skills/generated/lbug/SKILL.md` |
|
||||
| Work in the Group area (94 symbols) | `.claude/skills/generated/group/SKILL.md` |
|
||||
| Work in the Cli area (92 symbols) | `.claude/skills/generated/cli/SKILL.md` |
|
||||
| Work in the Configs area (92 symbols) | `.claude/skills/generated/configs/SKILL.md` |
|
||||
| Work in the Type-extractors area (90 symbols) | `.claude/skills/generated/type-extractors/SKILL.md` |
|
||||
| Work in the Hooks area (88 symbols) | `.claude/skills/generated/hooks/SKILL.md` |
|
||||
| Work in the Unit area (80 symbols) | `.claude/skills/generated/unit/SKILL.md` |
|
||||
| Work in the Cpp area (73 symbols) | `.claude/skills/generated/cpp/SKILL.md` |
|
||||
| Work in the Scope-resolution area (72 symbols) | `.claude/skills/generated/scope-resolution/SKILL.md` |
|
||||
| Work in the Server area (66 symbols) | `.claude/skills/generated/server/SKILL.md` |
|
||||
| Work in the Local area (61 symbols) | `.claude/skills/generated/local/SKILL.md` |
|
||||
| Work in the Wiki area (60 symbols) | `.claude/skills/generated/wiki/SKILL.md` |
|
||||
| Work in the Workers area (57 symbols) | `.claude/skills/generated/workers/SKILL.md` |
|
||||
| Work in the Embeddings area (56 symbols) | `.claude/skills/generated/embeddings/SKILL.md` |
|
||||
| Work in the Typescript area (53 symbols) | `.claude/skills/generated/typescript/SKILL.md` |
|
||||
| Work in the Storage area (51 symbols) | `.claude/skills/generated/storage/SKILL.md` |
|
||||
| Work in the Php area (48 symbols) | `.claude/skills/generated/php/SKILL.md` |
|
||||
|
||||
<!-- gitnexus:end -->
|
||||
|
||||
|
|
|
|||
66
CLAUDE.md
66
CLAUDE.md
|
|
@ -51,4 +51,68 @@ If always-on instructions grow, load deep conventions via conditional reads (e.g
|
|||
|
||||
## GitNexus rules
|
||||
|
||||
See the canonical `gitnexus:start` … `gitnexus:end` block in [AGENTS.md](AGENTS.md).
|
||||
See the `<!-- gitnexus:start --> … <!-- gitnexus:end -->` block in **[AGENTS.md](AGENTS.md)** for the canonical MCP tools, impact analysis rules, and index instructions.
|
||||
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **GitNexus** (26675 symbols, 35395 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
## Always Do
|
||||
|
||||
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
|
||||
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
|
||||
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
|
||||
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
||||
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
|
||||
|
||||
## Never Do
|
||||
|
||||
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
|
||||
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
|
||||
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
|
||||
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
|
||||
|
||||
## Resources
|
||||
|
||||
| Resource | Use for |
|
||||
|----------|---------|
|
||||
| `gitnexus://repo/GitNexus/context` | Codebase overview, check index freshness |
|
||||
| `gitnexus://repo/GitNexus/clusters` | All functional areas |
|
||||
| `gitnexus://repo/GitNexus/processes` | All execution flows |
|
||||
| `gitnexus://repo/GitNexus/process/{name}` | Step-by-step execution trace |
|
||||
|
||||
## CLI
|
||||
|
||||
| Task | Read this skill file |
|
||||
|------|---------------------|
|
||||
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
|
||||
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
|
||||
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
|
||||
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
|
||||
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
|
||||
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
|
||||
| Work in the Ingestion area (239 symbols) | `.claude/skills/generated/ingestion/SKILL.md` |
|
||||
| Work in the Extractors area (135 symbols) | `.claude/skills/generated/extractors/SKILL.md` |
|
||||
| Work in the Components area (112 symbols) | `.claude/skills/generated/components/SKILL.md` |
|
||||
| Work in the Lbug area (96 symbols) | `.claude/skills/generated/lbug/SKILL.md` |
|
||||
| Work in the Group area (94 symbols) | `.claude/skills/generated/group/SKILL.md` |
|
||||
| Work in the Cli area (92 symbols) | `.claude/skills/generated/cli/SKILL.md` |
|
||||
| Work in the Configs area (92 symbols) | `.claude/skills/generated/configs/SKILL.md` |
|
||||
| Work in the Type-extractors area (90 symbols) | `.claude/skills/generated/type-extractors/SKILL.md` |
|
||||
| Work in the Hooks area (88 symbols) | `.claude/skills/generated/hooks/SKILL.md` |
|
||||
| Work in the Unit area (80 symbols) | `.claude/skills/generated/unit/SKILL.md` |
|
||||
| Work in the Cpp area (73 symbols) | `.claude/skills/generated/cpp/SKILL.md` |
|
||||
| Work in the Scope-resolution area (72 symbols) | `.claude/skills/generated/scope-resolution/SKILL.md` |
|
||||
| Work in the Server area (66 symbols) | `.claude/skills/generated/server/SKILL.md` |
|
||||
| Work in the Local area (61 symbols) | `.claude/skills/generated/local/SKILL.md` |
|
||||
| Work in the Wiki area (60 symbols) | `.claude/skills/generated/wiki/SKILL.md` |
|
||||
| Work in the Workers area (57 symbols) | `.claude/skills/generated/workers/SKILL.md` |
|
||||
| Work in the Embeddings area (56 symbols) | `.claude/skills/generated/embeddings/SKILL.md` |
|
||||
| Work in the Typescript area (53 symbols) | `.claude/skills/generated/typescript/SKILL.md` |
|
||||
| Work in the Storage area (51 symbols) | `.claude/skills/generated/storage/SKILL.md` |
|
||||
| Work in the Php area (48 symbols) | `.claude/skills/generated/php/SKILL.md` |
|
||||
|
||||
<!-- gitnexus:end -->
|
||||
|
|
|
|||
180
README.md
180
README.md
|
|
@ -1,4 +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.
|
||||
|
||||
<div align="center">
|
||||
|
|
@ -30,14 +31,9 @@
|
|||
|
||||
Indexes any codebase into a knowledge graph — every dependency, call chain, cluster, and execution flow — then exposes it through smart tools so AI agents never miss code.
|
||||
|
||||
|
||||
|
||||
|
||||
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.
|
||||
> _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.
|
||||
|
||||
|
|
@ -47,18 +43,17 @@ https://github.com/user-attachments/assets/172685ba-8e54-4ea7-9ad1-e31a3398da72
|
|||
|
||||
[](https://www.star-history.com/#abhigyanpatwari/GitNexus&type=date&legend=top-left)
|
||||
|
||||
|
||||
## Two Ways to Use GitNexus
|
||||
|
||||
| | **CLI + MCP** | **Web UI** |
|
||||
| ----------------- | -------------------------------------------------------------- | ------------------------------------------------------------ |
|
||||
| **What** | Index repos locally, connect AI agents via MCP | Visual graph explorer + AI chat in browser |
|
||||
| **For** | Daily development with Cursor, Claude Code, Codex, Windsurf, OpenCode | Quick exploration, demos, one-off analysis |
|
||||
| **Scale** | Full repos, any size | Limited by browser memory (~5k files), or unlimited via backend mode |
|
||||
| **Install** | `npm install -g gitnexus` | No install — [gitnexus.vercel.app](https://gitnexus.vercel.app) |
|
||||
| **Storage** | LadybugDB native (fast, persistent) | LadybugDB WASM (in-memory, per session) |
|
||||
| **Parsing** | Tree-sitter native bindings | Tree-sitter WASM |
|
||||
| **Privacy** | Everything local, no network | Everything in-browser, no server |
|
||||
| | **CLI + MCP** | **Web UI** |
|
||||
| ----------- | --------------------------------------------------------------------- | -------------------------------------------------------------------- |
|
||||
| **What** | Index repos locally, connect AI agents via MCP | Visual graph explorer + AI chat in browser |
|
||||
| **For** | Daily development with Cursor, Claude Code, Codex, Windsurf, OpenCode | Quick exploration, demos, one-off analysis |
|
||||
| **Scale** | Full repos, any size | Limited by browser memory (~5k files), or unlimited via backend mode |
|
||||
| **Install** | `npm install -g gitnexus` | No install — [gitnexus.vercel.app](https://gitnexus.vercel.app) |
|
||||
| **Storage** | LadybugDB native (fast, persistent) | LadybugDB WASM (in-memory, per session) |
|
||||
| **Parsing** | Tree-sitter native bindings | Tree-sitter WASM |
|
||||
| **Privacy** | Everything local, no network | Everything in-browser, no server |
|
||||
|
||||
> **Bridge mode:** `gitnexus serve` connects the two — the web UI auto-detects the local server and can browse all your CLI-indexed repos without re-uploading or re-indexing.
|
||||
|
||||
|
|
@ -69,6 +64,7 @@ https://github.com/user-attachments/assets/172685ba-8e54-4ea7-9ad1-e31a3398da72
|
|||
GitNexus is available as an **enterprise offering** - either as a fully managed **SaaS** or a **self-hosted** deployment. Also available for **commercial use** of the OSS version with proper licensing.
|
||||
|
||||
Enterprise includes:
|
||||
|
||||
- **PR Review** - automated blast radius analysis on pull requests
|
||||
- **Auto-updating Code Wiki** - always up-to-date documentation (Code Wiki is also available in OSS)
|
||||
- **Auto-reindexing** - knowledge graph stays fresh automatically
|
||||
|
|
@ -77,6 +73,7 @@ Enterprise includes:
|
|||
- **Priority feature/language support** - request new languages or features
|
||||
|
||||
**Upcoming:**
|
||||
|
||||
- Auto regression forensics
|
||||
- End-to-end test generation
|
||||
|
||||
|
|
@ -109,7 +106,7 @@ That's it. This indexes the codebase, installs agent skills, registers Claude Co
|
|||
|
||||
To configure MCP for your editor, run `npx gitnexus setup` once — or set it up manually below.
|
||||
|
||||
> **Faster install (no C++ toolchain needed):** set `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` before `npm install -g gitnexus` to skip the native `tree-sitter-dart` and `tree-sitter-proto` builds. Dart/Proto files won't be parsed, but install completes in seconds without `python3`/`make`/`g++`. Strict `=1` only — any other value falls through to the rebuild.
|
||||
> **Faster install (no C++ toolchain needed):** set `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` before `npm install -g gitnexus` to skip vendored grammar materialize/build (`tree-sitter-dart`, `tree-sitter-proto`, `tree-sitter-swift`). Dart/Proto/Swift files won't be parsed, but install completes in seconds without `python3`/`make`/`g++`. Strict `=1` only — any other value falls through to the rebuild.
|
||||
|
||||
### MCP Setup
|
||||
|
||||
|
|
@ -117,13 +114,13 @@ To configure MCP for your editor, run `npx gitnexus setup` once — or set it up
|
|||
|
||||
### Editor Support
|
||||
|
||||
| Editor | MCP | Skills | Hooks (auto-augment) | Support |
|
||||
| --------------------- | --- | ------ | -------------------- | -------------- |
|
||||
| **Claude Code** | Yes | Yes | Yes (PreToolUse + PostToolUse) | **Full** |
|
||||
| **Cursor** | Yes | Yes | Yes (postToolUse, [manual install](gitnexus-cursor-integration/README.md#hook-install)) | **Full** |
|
||||
| **Codex** | Yes | Yes | — | MCP + Skills |
|
||||
| **Windsurf** | Yes | — | — | MCP |
|
||||
| **OpenCode** | Yes | Yes | — | MCP + Skills |
|
||||
| Editor | MCP | Skills | Hooks (auto-augment) | Support |
|
||||
| --------------- | --- | ------ | --------------------------------------------------------------------------------------- | ------------ |
|
||||
| **Claude Code** | Yes | Yes | Yes (PreToolUse + PostToolUse) | **Full** |
|
||||
| **Cursor** | Yes | Yes | Yes (postToolUse, [manual install](gitnexus-cursor-integration/README.md#hook-install)) | **Full** |
|
||||
| **Codex** | Yes | Yes | — | MCP + Skills |
|
||||
| **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 detect a stale index after commits and prompt the agent to reindex.
|
||||
|
||||
|
|
@ -131,10 +128,10 @@ To configure MCP for your editor, run `npx gitnexus setup` once — or set it up
|
|||
|
||||
Built by the community — not officially maintained, but worth checking out.
|
||||
|
||||
| Project | Author | Description |
|
||||
|---------|--------|-------------|
|
||||
| [pi-gitnexus](https://github.com/tintinweb/pi-gitnexus) | [@tintinweb](https://github.com/tintinweb) | GitNexus plugin for [pi](https://pi.dev) — `pi install npm:pi-gitnexus` |
|
||||
| [gitnexus-stable-ops](https://github.com/ShunsukeHayashi/gitnexus-stable-ops) | [@ShunsukeHayashi](https://github.com/ShunsukeHayashi) | Stable ops & deployment workflows (Miyabi ecosystem) |
|
||||
| Project | Author | Description |
|
||||
| ----------------------------------------------------------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------- |
|
||||
| [pi-gitnexus](https://github.com/tintinweb/pi-gitnexus) | [@tintinweb](https://github.com/tintinweb) | GitNexus plugin for [pi](https://pi.dev) — `pi install npm:pi-gitnexus` |
|
||||
| [gitnexus-stable-ops](https://github.com/ShunsukeHayashi/gitnexus-stable-ops) | [@ShunsukeHayashi](https://github.com/ShunsukeHayashi) | Stable ops & deployment workflows (Miyabi ecosystem) |
|
||||
|
||||
> Have a project built on GitNexus? Open a PR to add it here!
|
||||
|
||||
|
|
@ -197,7 +194,8 @@ args = ["-y", "gitnexus@latest", "mcp"]
|
|||
```bash
|
||||
gitnexus setup # Configure MCP for your editors (one-time)
|
||||
gitnexus analyze [path] # Index a repository (or update stale index)
|
||||
gitnexus analyze --force # Force full re-index
|
||||
gitnexus analyze --repair-fts # Fast path: rebuild/verify only FTS indexes on existing index data
|
||||
gitnexus analyze --force # Full rebuild: re-parse + graph rebuild + FTS rebuild
|
||||
gitnexus analyze --skills # Generate repo-specific skill files from detected communities
|
||||
gitnexus analyze --skip-embeddings # Skip embedding generation (faster)
|
||||
gitnexus analyze --skip-agents-md # Preserve custom AGENTS.md/CLAUDE.md gitnexus section edits
|
||||
|
|
@ -205,6 +203,8 @@ 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 analyze --wal-checkpoint-threshold 67108864 # 64 MiB. Control LadybugDB WAL auto-checkpoint threshold (default: 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB)
|
||||
gitnexus analyze --workers <n> # Parse worker pool size (default: cores-1, capped at 16; 0 = sequential)
|
||||
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
|
||||
|
|
@ -229,6 +229,28 @@ gitnexus group status <name> # 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.
|
||||
|
||||
#### Environment variables
|
||||
|
||||
Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max-file-size`, `--verbose`). Use the env-var form when you'd otherwise repeat the same flag every run, or when invoking GitNexus from a long-running host (MCP server, eval-server, CI shell) that already manages its own environment. CLI flags take precedence over env vars; env vars take precedence over built-in defaults.
|
||||
|
||||
| Variable | Default | Effect | Tune when… |
|
||||
| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GITNEXUS_WORKER_POOL_SIZE` | `cores - 1`, capped at 16 | Parse worker pool size. `0` disables the pool (sequential fallback). Equivalent to `--workers <n>`. | Constrained containers (cgroup CPU limits), CI runners with explicit quotas, or debugging a worker-only crash via `0`. |
|
||||
| `GITNEXUS_PARSE_CHUNK_CONCURRENCY` | `2` | Number of chunks whose file contents may be read into memory in parallel while the pool dispatches the current chunk. Worker dispatch itself stays serial. | Repos large enough to chunk (multi-MB total source) where disk I/O is a measurable fraction of analyze wall-clock. |
|
||||
| `GITNEXUS_VERBOSE` | unset | When `1`, enables verbose ingestion logs (skipped-file warnings, per-chunk throughput, parse-cache stats). Equivalent to `--verbose`. | Debugging an analyze that "completed" but seems to have missed files; tuning `--workers` / chunk concurrency against observable throughput. |
|
||||
| `GITNEXUS_PROFILE_DEFERRED` | unset | When `1`, emits `[deferred-profile]` timing/progress logs for the post-chunk deferred resolution band (imports → heritage → buildHeritageMap → legacy call resolution). Implied by `GITNEXUS_VERBOSE`. | Diagnosing analyze stalls in "Resolving calls (all chunks)" on large Java/Kotlin repos (issue #1741) without the full verbose ingestion noise. |
|
||||
| `GITNEXUS_PROFILE_DEFERRED_SLOW_MS` | `3000` (verbose) / `5000` | Per-file threshold in ms above which `processCallsFromExtracted` emits a `slow file …` log line. Parsed via `Number()`: accepts integers (`5000`), scientific notation (`2.5e3`), decimals (`.5`), and hex (`0x10`). Non-finite or non-positive values fall back to the default. | Hunting a few outlier files dominating the deferred call-resolution stage; lower to surface more, raise to focus only on the worst. |
|
||||
| `GITNEXUS_MAX_FILE_SIZE` | `512` (KB) | Walker skip threshold in KB. Hard cap is `32768` (tree-sitter buffer ceiling). Equivalent to `--max-file-size <kb>`. | Indexing repos with intentionally-large source files (generated parsers, vendored bundles) that should still be parsed. |
|
||||
| `GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS` | `30000` | Worker idle timeout in milliseconds before retry/fallback. Equivalent to `--worker-timeout <seconds>` × 1000. | Slow-parsing files (large minified JS, deeply-nested TS types) that legitimately need more than 30s. |
|
||||
| `GITNEXUS_WAL_CHECKPOINT_THRESHOLD` | `67108864` (64 MiB) | LadybugDB WAL auto-checkpoint threshold in bytes. Equivalent to `--wal-checkpoint-threshold <bytes>`. `-1` keeps LadybugDB's stock threshold (~16 MiB). Larger thresholds reduce checkpoint frequency but increase the WAL size at rotation time — choose a smaller value on disk-constrained environments. | You need a larger or smaller WAL auto-checkpoint threshold for your analyze workload. |
|
||||
| `GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES` | `8388608` (8 MB) | Per-job byte budget the pool will send to a worker in one `postMessage`. | Very large individual files; mostly diagnostic — bumping past 8 MB risks structured-clone memory pressure. |
|
||||
| `GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT` | `3` | Max replacement spawns per worker slot before the slot is dropped from the active rotation. Bounds respawn loops on a chronically-crashing slot. | Hosts where a flaky worker should retry more (raise) or fail-fast (lower) before the slot is dropped. |
|
||||
| `GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS` | `5 × subBatchTimeoutMs` | Total retry wall-time budget per job before quarantining. Combined with `timeoutBackoffFactor`, prevents exponentially-growing retries from stalling for hours. | Slow files that legitimately need long total retry windows; lower to fail-fast on stalls. |
|
||||
| `GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD`| `max(3, poolSize)` | Per-slot consecutive deaths before the pool's circuit breaker trips. After tripping, every subsequent dispatch rejects until a fresh pool is created. | Hosts where a SIGSEGV-prone native grammar should trip the breaker sooner; CI runners that should fail loudly. |
|
||||
| `GITNEXUS_CHUNK_BYTE_BUDGET` | `2097152` (2 MB) | Chunk boundary used for cache-key composition and dispatch. Smaller = finer-grained cache hits but more dispatch overhead. | Tuning incremental-analyze cache behavior on monorepos. |
|
||||
| `GITNEXUS_NO_GITIGNORE` | unset | When set, skips `.gitignore` parsing. `.gitnexusignore` is still honored. | Indexing a repo whose `.gitignore` excludes files you actually want indexed (e.g., generated code committed for cross-repo lookup). |
|
||||
| `GITNEXUS_SKIP_OPTIONAL_GRAMMARS` | unset | When `=1` strictly, skips vendored grammar materialize/build for `tree-sitter-dart`, `tree-sitter-proto`, and `tree-sitter-swift` at install time. | Installing on a host without a C++ toolchain or where Swift prebuilds don't match; you're willing to skip Dart/Proto/Swift parsing. |
|
||||
|
||||
#### Publishing to understand-quickly (opt-in)
|
||||
|
||||
[`looptech-ai/understand-quickly`](https://github.com/looptech-ai/understand-quickly) is a public registry of code-knowledge graphs that lists `gitnexus@1` as a first-class format. After registering your repo once (`npx @understand-quickly/cli add` or the [wizard](https://looptech-ai.github.io/understand-quickly/add.html)), `gitnexus publish` fires a single `repository_dispatch` event so the registry resyncs your entry on demand instead of waiting for the nightly job.
|
||||
|
|
@ -239,27 +261,27 @@ It is opt-in and a no-op without `UNDERSTAND_QUICKLY_TOKEN` — a fine-grained G
|
|||
|
||||
**16 tools** exposed via MCP (11 per-repo + 5 group):
|
||||
|
||||
| Tool | What It Does | `repo` Param |
|
||||
| ------------------ | ----------------------------------------------------------------- | -------------- |
|
||||
| `list_repos` | Discover all indexed repositories | — |
|
||||
| `query` | Process-grouped hybrid search (BM25 + semantic + RRF) | Optional |
|
||||
| `context` | 360-degree symbol view — categorized refs, process participation | Optional |
|
||||
| `impact` | Blast radius analysis with depth grouping and confidence | Optional |
|
||||
| `detect_changes` | Git-diff impact — maps changed lines to affected processes | Optional |
|
||||
| `rename` | Multi-file coordinated rename with graph + text search | Optional |
|
||||
| `cypher` | Raw Cypher graph queries | Optional |
|
||||
| `group_list` | List configured repository groups | — |
|
||||
| `group_sync` | Extract contracts and match across repos/services | — |
|
||||
| `group_contracts`| Inspect extracted contracts and cross-links | — |
|
||||
| `group_query` | Search execution flows across all repos in a group | — |
|
||||
| `group_status` | Check staleness of repos in a group | — |
|
||||
| Tool | What It Does | `repo` Param |
|
||||
| ----------------- | ---------------------------------------------------------------- | ------------ |
|
||||
| `list_repos` | Discover all indexed repositories | — |
|
||||
| `query` | Process-grouped hybrid search (BM25 + semantic + RRF) | Optional |
|
||||
| `context` | 360-degree symbol view — categorized refs, process participation | Optional |
|
||||
| `impact` | Blast radius analysis with depth grouping and confidence | Optional |
|
||||
| `detect_changes` | Git-diff impact — maps changed lines to affected processes | Optional |
|
||||
| `rename` | Multi-file coordinated rename with graph + text search | Optional |
|
||||
| `cypher` | Raw Cypher graph queries | Optional |
|
||||
| `group_list` | List configured repository groups | — |
|
||||
| `group_sync` | Extract contracts and match across repos/services | — |
|
||||
| `group_contracts` | Inspect extracted contracts and cross-links | — |
|
||||
| `group_query` | Search execution flows across all repos in a group | — |
|
||||
| `group_status` | Check staleness of repos in a group | — |
|
||||
|
||||
> When only one repo is indexed, the `repo` parameter is optional. With multiple repos, specify which one: `query({query: "auth", repo: "my-app"})`.
|
||||
|
||||
**Resources** for instant context:
|
||||
|
||||
| Resource | Purpose |
|
||||
| ----------------------------------------- | ---------------------------------------------------- |
|
||||
| Resource | Purpose |
|
||||
| --------------------------------------- | ---------------------------------------------------- |
|
||||
| `gitnexus://repos` | List all indexed repositories (read this first) |
|
||||
| `gitnexus://repo/{name}/context` | Codebase stats, staleness check, and available tools |
|
||||
| `gitnexus://repo/{name}/clusters` | All functional clusters with cohesion scores |
|
||||
|
|
@ -270,9 +292,9 @@ It is opt-in and a no-op without `UNDERSTAND_QUICKLY_TOKEN` — a fine-grained G
|
|||
|
||||
**2 MCP prompts** for guided workflows:
|
||||
|
||||
| Prompt | What It Does |
|
||||
| ----------------- | ------------------------------------------------------------------------- |
|
||||
| `detect_impact` | Pre-commit change analysis — scope, affected processes, risk level |
|
||||
| Prompt | What It Does |
|
||||
| --------------- | ------------------------------------------------------------------------- |
|
||||
| `detect_impact` | Pre-commit change analysis — scope, affected processes, risk level |
|
||||
| `generate_map` | Architecture documentation from the knowledge graph with mermaid diagrams |
|
||||
|
||||
**4 agent skills** installed to `.claude/skills/` automatically:
|
||||
|
|
@ -359,10 +381,10 @@ npx gitnexus@latest serve
|
|||
|
||||
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:
|
||||
|
||||
| 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` |
|
||||
| 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
|
||||
|
|
@ -578,22 +600,22 @@ GitNexus builds a complete knowledge graph of your codebase through a multi-phas
|
|||
|
||||
### Supported Languages
|
||||
|
||||
| Language | Imports | Named Bindings | Exports | Heritage | Type Annotations | Constructor Inference | Config | Frameworks | Entry Points |
|
||||
|----------|---------|----------------|---------|----------|-----------------|---------------------|--------|------------|-------------|
|
||||
| TypeScript | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| JavaScript | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ | ✓ |
|
||||
| Python | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| Java | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
|
||||
| Kotlin | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
|
||||
| C# | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| Go | ✓ | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| Rust | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
|
||||
| PHP | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| Ruby | ✓ | — | ✓ | ✓ | — | ✓ | — | ✓ | ✓ |
|
||||
| Swift | — | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| C | — | — | ✓ | — | ✓ | ✓ | — | ✓ | ✓ |
|
||||
| C++ | — | — | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
|
||||
| Dart | ✓ | — | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
|
||||
| Language | Imports | Named Bindings | Exports | Heritage | Type Annotations | Constructor Inference | Config | Frameworks | Entry Points |
|
||||
| ---------- | ------- | -------------- | ------- | -------- | ---------------- | --------------------- | ------ | ---------- | ------------ |
|
||||
| TypeScript | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| JavaScript | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ | ✓ |
|
||||
| Python | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| Java | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
|
||||
| Kotlin | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
|
||||
| C# | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| Go | ✓ | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| Rust | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
|
||||
| PHP | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| Ruby | ✓ | — | ✓ | ✓ | — | ✓ | — | ✓ | ✓ |
|
||||
| Swift | — | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| C | — | — | ✓ | — | ✓ | ✓ | — | ✓ | ✓ |
|
||||
| C++ | — | — | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
|
||||
| Dart | ✓ | — | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
|
||||
|
||||
**Imports** — cross-file import resolution · **Named Bindings** — `import { X as Y }` / re-export tracking · **Exports** — public/exported symbol detection · **Heritage** — class inheritance, interfaces, mixins · **Type Annotations** — explicit type extraction for receiver resolution · **Constructor Inference** — infer receiver type from constructor calls (`self`/`this` resolution included for all languages) · **Config** — language toolchain config parsing (tsconfig, go.mod, etc.) · **Frameworks** — AST-based framework pattern detection · **Entry Points** — entry point scoring heuristics
|
||||
|
||||
|
|
@ -738,16 +760,16 @@ The wiki generator reads the indexed graph structure, groups files into modules
|
|||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | CLI | Web |
|
||||
| ------------------------- | ------------------------------------- | --------------------------------------- |
|
||||
| Layer | CLI | Web |
|
||||
| ------------------- | ------------------------------------- | --------------------------------------- |
|
||||
| **Runtime** | Node.js (native) | Browser (WASM) |
|
||||
| **Parsing** | Tree-sitter native bindings | Tree-sitter WASM |
|
||||
| **Database** | LadybugDB native | LadybugDB WASM |
|
||||
| **Database** | LadybugDB native | LadybugDB WASM |
|
||||
| **Embeddings** | HuggingFace transformers.js (GPU/CPU) | transformers.js (WebGPU/WASM) |
|
||||
| **Search** | BM25 + semantic + RRF | BM25 + semantic + RRF |
|
||||
| **Agent Interface** | MCP (stdio) | LangChain ReAct agent |
|
||||
| **Visualization** | — | Sigma.js + Graphology (WebGL) |
|
||||
| **Frontend** | — | React 18, TypeScript, Vite, Tailwind v4 |
|
||||
| **Visualization** | — | Sigma.js + Graphology (WebGL) |
|
||||
| **Frontend** | — | React 18, TypeScript, Vite, Tailwind v4 |
|
||||
| **Clustering** | Graphology | Graphology |
|
||||
| **Concurrency** | Worker threads + async | Web Workers + Comlink |
|
||||
|
||||
|
|
@ -763,12 +785,12 @@ The wiki generator reads the indexed graph structure, groups files into modules
|
|||
|
||||
### Recently Completed
|
||||
|
||||
- [X] Constructor-Inferred Type Resolution, `self`/`this` Receiver Mapping
|
||||
- [X] Wiki Generation, Multi-File Rename, Git-Diff Impact Analysis
|
||||
- [X] Process-Grouped Search, 360-Degree Context, Claude Code Hooks
|
||||
- [X] Multi-Repo MCP, Zero-Config Setup, 14 Language Support
|
||||
- [X] Community Detection, Process Detection, Confidence Scoring
|
||||
- [X] Hybrid Search, Vector Index
|
||||
- [x] Constructor-Inferred Type Resolution, `self`/`this` Receiver Mapping
|
||||
- [x] Wiki Generation, Multi-File Rename, Git-Diff Impact Analysis
|
||||
- [x] Process-Grouped Search, 360-Degree Context, Claude Code Hooks
|
||||
- [x] Multi-Repo MCP, Zero-Config Setup, 14 Language Support
|
||||
- [x] Community Detection, Process Detection, Confidence Scoring
|
||||
- [x] Hybrid Search, Vector Index
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -211,6 +211,8 @@ environment:
|
|||
|
||||
Defaults are `port: 4848` and `host: 127.0.0.1` (loopback only). Use `0.0.0.0` only when the agent container needs to reach the eval-server from a separate network namespace. The health probe and tool scripts connect via the configured bind host (defaulting to `127.0.0.1`), which is reachable for both loopback and all-interface binds.
|
||||
|
||||
`"localhost"` is also a valid `eval_server_host` value. The OS resolves it at bind time — typically `127.0.0.1` on dual-stack or IPv4-only systems, and `::1` on IPv6-only systems. The exact result depends on your `/etc/hosts` and `gai.conf`. The READY signal will reflect the actual bound address (e.g. `GITNEXUS_EVAL_SERVER_READY:127.0.0.1:4848` or `GITNEXUS_EVAL_SERVER_READY:[::1]:4848`), not the literal string `localhost`. Use this when you want the server to bind to whichever loopback address the OS prefers rather than forcing IPv4.
|
||||
|
||||
**Running eval-server directly in Docker / Docker Compose:**
|
||||
|
||||
```bash
|
||||
|
|
|
|||
6
eval/uv.lock
generated
6
eval/uv.lock
generated
|
|
@ -760,11 +760,11 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
version = "3.15"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
843
gitnexus-web/package-lock.json
generated
843
gitnexus-web/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -18,7 +18,6 @@
|
|||
"test:e2e:report": "playwright show-report"
|
||||
},
|
||||
"dependencies": {
|
||||
"gitnexus-shared": "file:../gitnexus-shared",
|
||||
"@langchain/anthropic": "^1.3.29",
|
||||
"@langchain/core": "^1.1.44",
|
||||
"@langchain/google-genai": "^2.1.30",
|
||||
|
|
@ -26,10 +25,11 @@
|
|||
"@langchain/ollama": "^1.2.6",
|
||||
"@langchain/openai": "^1.4.5",
|
||||
"@sigma/edge-curve": "^3.1.0",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"axios": "^1.16.0",
|
||||
"d3": "^7.9.0",
|
||||
"dompurify": "^3.4.2",
|
||||
"dompurify": "^3.4.3",
|
||||
"gitnexus-shared": "file:../gitnexus-shared",
|
||||
"graphology": "^0.26.0",
|
||||
"graphology-indices": "^0.17.0",
|
||||
"graphology-layout-force": "^0.2.4",
|
||||
|
|
@ -45,13 +45,13 @@
|
|||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"react-zoom-pan-pinch": "^4.0.3",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sigma": "^3.0.2",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"uuid": "^14.0.0",
|
||||
"zod": "^3.25.76"
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/types": "^7.29.0",
|
||||
|
|
@ -64,7 +64,7 @@
|
|||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@vercel/node": "^5.5.16",
|
||||
"@vercel/node": "^5.8.2",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
"jsdom": "^29.1.1",
|
||||
|
|
@ -73,5 +73,18 @@
|
|||
"vite": "^8.0.11",
|
||||
"vitest": "^4.1.5",
|
||||
"wait-on": "^9.0.5"
|
||||
},
|
||||
"overrides": {
|
||||
"@vercel/static-config": {
|
||||
"ajv": "8.18.0"
|
||||
},
|
||||
"@vercel/node": {
|
||||
"path-to-regexp": "6.3.0",
|
||||
"undici": "6.24.0"
|
||||
},
|
||||
"@vercel/python-analysis": {
|
||||
"minimatch": "10.2.3",
|
||||
"smol-toml": "1.6.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
import { useState, useRef, useEffect, useId } from 'react';
|
||||
import {
|
||||
Github,
|
||||
Gitlab,
|
||||
FolderOpen,
|
||||
Loader2,
|
||||
Check,
|
||||
|
|
@ -26,15 +27,20 @@ import { AnalyzeProgress } from './AnalyzeProgress';
|
|||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type InputMode = 'github' | 'local';
|
||||
type InputMode = 'github' | 'gitlab' | 'local';
|
||||
|
||||
const GITHUB_RE = /^https?:\/\/(www\.)?github\.com\/[^/\s]+\/[^/\s]+/i;
|
||||
const GITLAB_RE = /^https?:\/\/[^/\s]+\/[^/\s]+\/[^/\s]+(\/.*)?$/i;
|
||||
const IS_WINDOWS = navigator.userAgent.toLowerCase().includes('win');
|
||||
|
||||
function isValidGithubUrl(value: string): boolean {
|
||||
return GITHUB_RE.test(value.trim());
|
||||
}
|
||||
|
||||
function isValidGitlabUrl(value: string): boolean {
|
||||
return GITLAB_RE.test(value.trim());
|
||||
}
|
||||
|
||||
// ── Mode tabs ────────────────────────────────────────────────────────────────
|
||||
|
||||
function ModeTabs({ mode, onChange }: { mode: InputMode; onChange: (m: InputMode) => void }) {
|
||||
|
|
@ -53,6 +59,19 @@ function ModeTabs({ mode, onChange }: { mode: InputMode; onChange: (m: InputMode
|
|||
<Github className="h-3 w-3" />
|
||||
GitHub URL
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={mode === 'gitlab'}
|
||||
onClick={() => onChange('gitlab')}
|
||||
className={`flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-all duration-150 ${
|
||||
mode === 'gitlab'
|
||||
? 'bg-accent text-white shadow-sm'
|
||||
: 'text-text-muted hover:text-text-secondary'
|
||||
} `}
|
||||
>
|
||||
<Gitlab className="h-3 w-3" />
|
||||
GitLab URL
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={mode === 'local'}
|
||||
|
|
@ -138,6 +157,7 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
|||
const folderInputRef = useRef<HTMLInputElement>(null);
|
||||
const [mode, setMode] = useState<InputMode>('github');
|
||||
const [githubUrl, setGithubUrl] = useState('');
|
||||
const [gitlabUrl, setGitlabUrl] = useState('');
|
||||
const [localPath, setLocalPath] = useState('');
|
||||
const [phase, setPhase] = useState<InternalPhase>('input');
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
|
|
@ -162,6 +182,7 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
|||
const handleModeChange = (m: InputMode) => {
|
||||
setMode(m);
|
||||
setGithubUrl('');
|
||||
setGitlabUrl('');
|
||||
setLocalPath('');
|
||||
setValidationError(null);
|
||||
};
|
||||
|
|
@ -175,13 +196,19 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
|||
const canSubmit =
|
||||
mode === 'github'
|
||||
? isValidGithubUrl(githubUrl) && (phase === 'input' || phase === 'error')
|
||||
: localPath.trim().length > 1 && (phase === 'input' || phase === 'error');
|
||||
: mode === 'gitlab'
|
||||
? isValidGitlabUrl(gitlabUrl) && (phase === 'input' || phase === 'error')
|
||||
: localPath.trim().length > 1 && (phase === 'input' || phase === 'error');
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
if (mode === 'github' && !isValidGithubUrl(githubUrl)) {
|
||||
setValidationError('Please enter a valid GitHub repository URL.');
|
||||
return;
|
||||
}
|
||||
if (mode === 'gitlab' && !isValidGitlabUrl(gitlabUrl)) {
|
||||
setValidationError('Please enter a valid GitLab repository URL.');
|
||||
return;
|
||||
}
|
||||
if (mode === 'local' && localPath.trim().length < 2) {
|
||||
setValidationError('Please enter a folder path.');
|
||||
return;
|
||||
|
|
@ -191,12 +218,22 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
|||
setPhase('starting');
|
||||
|
||||
try {
|
||||
const request = mode === 'github' ? { url: githubUrl.trim() } : { path: localPath.trim() };
|
||||
const request =
|
||||
mode === 'github'
|
||||
? { url: githubUrl.trim() }
|
||||
: mode === 'gitlab'
|
||||
? { url: gitlabUrl.trim() }
|
||||
: { path: localPath.trim() };
|
||||
const { jobId } = await startAnalyze(request);
|
||||
jobIdRef.current = jobId;
|
||||
setPhase('analyzing');
|
||||
|
||||
const nameSource = mode === 'github' ? githubUrl.trim() : localPath.trim();
|
||||
const nameSource =
|
||||
mode === 'github'
|
||||
? githubUrl.trim()
|
||||
: mode === 'gitlab'
|
||||
? gitlabUrl.trim()
|
||||
: localPath.trim();
|
||||
const controller = streamAnalyzeProgress(
|
||||
jobId,
|
||||
(p) => setProgress(p),
|
||||
|
|
@ -297,6 +334,61 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* GitLab URL input */}
|
||||
{showInput && mode === 'gitlab' && (
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="block text-xs font-medium tracking-wider text-text-secondary uppercase"
|
||||
>
|
||||
GitLab Repository URL
|
||||
</label>
|
||||
<div
|
||||
className={`flex items-center gap-3 rounded-xl border bg-void px-4 py-3.5 transition-all duration-200 ${
|
||||
validationError && phase === 'error'
|
||||
? 'border-red-500/50'
|
||||
: isValidGitlabUrl(gitlabUrl)
|
||||
? 'border-accent/50 shadow-[0_0_0_3px_rgba(124,58,237,0.08)]'
|
||||
: 'border-border-default focus-within:border-accent/40'
|
||||
} `}
|
||||
>
|
||||
<Gitlab className="h-4 w-4 shrink-0 text-text-muted" />
|
||||
<input
|
||||
id={inputId}
|
||||
type="url"
|
||||
value={gitlabUrl}
|
||||
onChange={(e) => {
|
||||
setGitlabUrl(e.target.value);
|
||||
if (validationError) setValidationError(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && canSubmit && !isLoading) {
|
||||
e.preventDefault();
|
||||
handleAnalyze();
|
||||
}
|
||||
}}
|
||||
disabled={isLoading}
|
||||
placeholder="https://gitlab.com/owner/repo"
|
||||
autoComplete="url"
|
||||
spellCheck={false}
|
||||
className="flex-1 border-none bg-transparent font-mono text-sm text-text-primary outline-none placeholder:text-text-muted disabled:opacity-50"
|
||||
/>
|
||||
{gitlabUrl.length > 10 && (
|
||||
<div className="shrink-0">
|
||||
{isValidGitlabUrl(gitlabUrl) ? (
|
||||
<Check className="h-3.5 w-3.5 text-emerald-400" />
|
||||
) : (
|
||||
<AlertCircle className="h-3.5 w-3.5 text-text-muted" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">
|
||||
Supports GitLab.com and self-hosted GitLab instances.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Local folder input */}
|
||||
{showInput && mode === 'local' && (
|
||||
<div className="space-y-2">
|
||||
|
|
|
|||
|
|
@ -123,6 +123,67 @@ export {
|
|||
* defaults to `currentColor`, so Tailwind `text-*` utilities work the same as
|
||||
* with any other icon in this module.
|
||||
*/
|
||||
/**
|
||||
* GitLab tanuki mark — SVG path data from simple-icons (CC0-1.0).
|
||||
*
|
||||
* GitLab's logo (the tanuki/fox-head) is a registered trademark of GitLab Inc.
|
||||
* We use it here only to indicate GitLab source-repo integration.
|
||||
*
|
||||
* API-compatible with `lucide-react` icons (`LucideProps`).
|
||||
*/
|
||||
export const Gitlab = forwardRef<SVGSVGElement, LucideProps>(function Gitlab(
|
||||
{
|
||||
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 (
|
||||
<svg
|
||||
ref={ref}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 16 16"
|
||||
fill={color}
|
||||
className={className}
|
||||
{...rest}
|
||||
>
|
||||
<path d="M8 15.282l1.855-5.717H6.145L8 15.282z" />
|
||||
<path d="M8 15.282L6.145 9.565H2.333L8 15.282z" />
|
||||
<path d="M2.333 9.565l-.944-2.942c-.09-.267.067-.553.333-.553h3.153L2.333 9.565z" />
|
||||
<path d="M4.875 6.07L6.145 9.565H2.333l2.542-3.495z" />
|
||||
<path d="M13.667 9.565l.944-2.942c.09-.267-.067-.553-.333-.553h-3.153l2.542 3.495z" />
|
||||
<path d="M11.125 6.07L9.855 9.565h3.812l-2.542-3.495z" />
|
||||
<path d="M8 15.282l1.855-5.717H6.145L8 15.282z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
ref={ref}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill={color}
|
||||
className={className}
|
||||
{...rest}
|
||||
>
|
||||
<path d="m23.6004 9.5927-.0337-.0862L20.3.9814a.851.851 0 0 0-.3362-.405.8748.8748 0 0 0-.9997.0539.8748.8748 0 0 0-.29.4399l-2.2055 6.748H7.5375l-2.2057-6.748a.8573.8573 0 0 0-.29-.4412.8748.8748 0 0 0-.9997-.0537.8585.8585 0 0 0-.3362.4049L.4332 9.5015l-.0325.0862a6.0657 6.0657 0 0 0 2.0119 7.0105l.0113.0087.03.0213 4.976 3.7264 2.462 1.8633 1.4995 1.1321a1.0085 1.0085 0 0 0 1.2197 0l1.4995-1.1321 2.4619-1.8633 5.006-3.7489.0125-.01a6.0682 6.0682 0 0 0 2.0094-7.003z" />
|
||||
</svg>
|
||||
);
|
||||
});
|
||||
|
||||
export const Github = forwardRef<SVGSVGElement, LucideProps>(function Github(
|
||||
{
|
||||
size = 24,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
{
|
||||
"installCommand": "cd ../gitnexus-shared && npm install && npm run build && cd ../gitnexus-web && npm install"
|
||||
"installCommand": "cd ../gitnexus-shared && npm install && npm run build && cd ../gitnexus-web && npm ci --include=dev"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -151,12 +151,14 @@ Your AI agent gets these tools automatically:
|
|||
```bash
|
||||
gitnexus setup # Configure MCP for your editors (one-time)
|
||||
gitnexus analyze [path] # Index a repository (or update stale index)
|
||||
gitnexus analyze --force # Force full re-index
|
||||
gitnexus analyze --repair-fts # Fast path: rebuild/verify only FTS indexes on existing index data
|
||||
gitnexus analyze --force # Full rebuild: re-parse + graph rebuild + FTS rebuild
|
||||
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 analyze --wal-checkpoint-threshold 67108864 # 64 MiB. Control LadybugDB WAL auto-checkpoint threshold (default: 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB)
|
||||
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
|
||||
|
|
@ -306,6 +308,7 @@ Configure the behavior with two environment variables:
|
|||
|----------|--------|---------|--------|
|
||||
| `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. |
|
||||
| `GITNEXUS_WAL_CHECKPOINT_THRESHOLD` | integer `>= -1` | `67108864` (64 MiB) | LadybugDB WAL auto-checkpoint threshold during analyze (bytes). Auto-checkpoint remains enabled; `-1` keeps Ladybug's stock ~16 MiB. Larger thresholds reduce checkpoint frequency but increase the WAL size at rotation time — choose a smaller value on disk-constrained environments. |
|
||||
|
||||
```bash
|
||||
# Offline/airgapped: never reach the network for extensions
|
||||
|
|
@ -358,6 +361,16 @@ 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)**.
|
||||
|
||||
### Worker pool resilience tuning
|
||||
|
||||
Three env vars expose the pool's resilience layers (respawn budget, cumulative-timeout cap, circuit breaker). Defaults are tuned for typical repos; bump them when an analyze legitimately needs more retries, or lower them to fail-fast on a known-bad shape.
|
||||
|
||||
| Variable | Default | Effect |
|
||||
| ------------------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT` | `3` | Max replacement spawns per slot before the slot is dropped from the active rotation. |
|
||||
| `GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS` | `5 × subBatchTimeoutMs` | Total retry wall-time budget per job before quarantining. Bounds exponentially-growing retry waits. |
|
||||
| `GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD` | `max(3, poolSize)` | Per-slot consecutive deaths before the pool's circuit breaker trips. After tripping, dispatches require a fresh pool. |
|
||||
|
||||
## Privacy
|
||||
|
||||
- All processing happens locally on your machine
|
||||
|
|
|
|||
175
gitnexus/bench/parse-throughput.md
Normal file
175
gitnexus/bench/parse-throughput.md
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
# Parse-throughput benchmark (scaffold)
|
||||
|
||||
> **Status: methodology + harness scaffold, no measurement data yet.**
|
||||
> The Latest measurement table below contains `_TBD_` placeholders.
|
||||
> This file ships intentionally without numbers — populating it
|
||||
> requires a dedicated bench-pass against the U6 fixture (and ideally
|
||||
> a real-world TS-root-scale repo) on consistent hardware, which is
|
||||
> tracked as future work rather than gated on PR #1693's merge.
|
||||
> Until the table is populated, the load-bearing perf-regression
|
||||
> protection lives in `gitnexus/test/integration/parse-impl-large-fixture.test.ts`
|
||||
> (U6, 30 s wall-clock budget via `Promise.race`).
|
||||
|
||||
Tracks `runChunkedParseAndResolve` wall-clock + peak heap on a synthetic
|
||||
fixture so PR #1693's "analyze no longer hangs on TS-root-shaped loads"
|
||||
claim is measurable, not just asserted by smoke tests. The harness
|
||||
recipe below is deliberately small enough to re-run in a few minutes
|
||||
when the bench-pass is undertaken.
|
||||
|
||||
---
|
||||
|
||||
## Methodology
|
||||
|
||||
### Fixture
|
||||
|
||||
Synthetic TypeScript repo, _not_ a clone of microsoft/TypeScript. CI cost
|
||||
of cloning real-world repos is prohibitive; the synthetic shape exercises
|
||||
the same pipeline paths (chunking, deferred extraction, cross-chunk
|
||||
imports + heritage) without the disk-I/O overhead. Larger numbers can be
|
||||
manually captured against real repos and cross-referenced here, but the
|
||||
authoritative regression-tracking shape is the synthetic fixture so runs
|
||||
are reproducible across hardware.
|
||||
|
||||
The fixture matches the structure pinned by
|
||||
`gitnexus/test/integration/parse-impl-large-fixture.test.ts` (U6):
|
||||
|
||||
- 15 small modules (`mod0.ts` … `mod14.ts`), one exported function each.
|
||||
- 1 dense `complex.ts` with 30 functions + 1 class + 1 interface.
|
||||
- 1 `index.ts` re-exporting every symbol from every module.
|
||||
|
||||
`GITNEXUS_CHUNK_BYTE_BUDGET=64` forces multi-chunk parsing on this small
|
||||
fixture — without that override the whole thing fits in one chunk and
|
||||
the deferred-extraction path is not exercised end-to-end.
|
||||
|
||||
### What to measure
|
||||
|
||||
| Metric | How |
|
||||
| --------------------------- | -------------------------------------------------------------------------------- |
|
||||
| Wall-clock total | `Date.now()` delta around `runChunkedParseAndResolve` |
|
||||
| Peak heap | Sample `process.memoryUsage().heapUsed` every 50 ms during the run; keep the max |
|
||||
| Chunks observed | Count distinct `Parsing chunk X/Y` progress messages |
|
||||
| `getStats()` final snapshot | Quarantined paths, dropped slots, breaker state |
|
||||
|
||||
### Hardware shape (record alongside each measurement)
|
||||
|
||||
- OS + version
|
||||
- CPU model + logical core count
|
||||
- RAM
|
||||
- Node version
|
||||
- gitnexus commit SHA (so the snapshot is anchored to a tree, not "main")
|
||||
|
||||
---
|
||||
|
||||
## Harness recipe
|
||||
|
||||
The U6 test (`test/integration/parse-impl-large-fixture.test.ts`) is the
|
||||
checked-in mini-benchmark — it exercises the same fixture and bounds the
|
||||
wall-clock at 30 s via `Promise.race`. To produce a richer snapshot for
|
||||
this doc, run it under instrumentation:
|
||||
|
||||
```bash
|
||||
# From the gitnexus/ subdir:
|
||||
cd gitnexus
|
||||
# Single-threaded baseline (sequential fallback):
|
||||
npx vitest run test/integration/parse-impl-large-fixture.test.ts --reporter=verbose
|
||||
|
||||
# Worker-pool path (requires built dist/ — pre-built by `npm run build`):
|
||||
npm run build && \
|
||||
GITNEXUS_WORKER_POOL_SIZE=4 \
|
||||
GITNEXUS_PARSE_CHUNK_CONCURRENCY=2 \
|
||||
GITNEXUS_VERBOSE=1 \
|
||||
npx vitest run test/integration/parse-impl-large-fixture.test.ts --reporter=verbose
|
||||
```
|
||||
|
||||
For peak-heap sampling, wrap the dispatch call in a Node script that
|
||||
polls `process.memoryUsage()`. A future helper at
|
||||
`gitnexus/bench/scripts/parse-throughput.ts` would automate this — the
|
||||
plan's stretch goal. Until that lands, capture peak heap manually via:
|
||||
|
||||
```bash
|
||||
node --inspect=0 \
|
||||
--require ./scripts/heap-sampler.js \
|
||||
./node_modules/.bin/vitest run test/integration/parse-impl-large-fixture.test.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Latest measurement
|
||||
|
||||
> _No measurement data has been collected yet — this file is the
|
||||
> methodology + harness scaffold. The single recorded data point is the
|
||||
> U6 wall-clock smoke baseline below; the worker-pool rows are
|
||||
> placeholders for future bench-pass output._
|
||||
|
||||
The U6 integration test (`gitnexus/test/integration/parse-impl-large-fixture.test.ts`)
|
||||
was observed completing the synthetic fixture in **~6 seconds** under
|
||||
the sequential path (`skipWorkers: true`) on the development machine,
|
||||
well under the 30 s `Promise.race` wall-clock budget. That number is a
|
||||
smoke baseline only — recorded here for reference, not as a regression
|
||||
target.
|
||||
|
||||
| Path | files/s | wall-clock | peak heap | chunks | quarantined |
|
||||
| ------------------------------------------ | ------- | -------------------- | --------- | ------ | ----------- |
|
||||
| Sequential (`skipWorkers: true`, U6 smoke) | _TBD_ | ~6 s _(observation)_ | _TBD_ | 17 | 0 |
|
||||
| Worker pool, `--workers 4`, concurrency 2 | _TBD_ | _TBD_ | _TBD_ | _TBD_ | 0 |
|
||||
| Worker pool, `--workers 1`, concurrency 1 | _TBD_ | _TBD_ | _TBD_ | _TBD_ | 0 |
|
||||
|
||||
**Hardware:** _TBD — record OS, CPU, RAM, Node version, gitnexus SHA at
|
||||
the time of the bench-pass that populates the table above._
|
||||
|
||||
---
|
||||
|
||||
## Operator-tuning quick reference
|
||||
|
||||
Cross-links to the env vars documented in the [README](../../README.md#environment-variables).
|
||||
Use this section as a starting point when the benchmark numbers above
|
||||
suggest a tuning opportunity for your hardware shape.
|
||||
|
||||
- **CPU-bound, big repo, lots of cores:** raise `GITNEXUS_WORKER_POOL_SIZE`
|
||||
past the default cap of 16. The 16-worker cap exists because past that
|
||||
point main-thread merge / extraction dominates; if you've measurably
|
||||
ruled that out, the env var lifts the cap explicitly. (See
|
||||
`worker-pool.ts` `DEFAULT_POOL_SIZE_CAP`.)
|
||||
- **Slow files (large minified JS, deep TS types):** raise
|
||||
`GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS` past 30 000 ms. The cumulative
|
||||
budget is 5× this value (U10 pins this) so a 60 s idle timeout permits
|
||||
300 s of total retry-and-split wall-clock before quarantining the file.
|
||||
- **Constrained container (cgroup CPU limit):** the pool now uses
|
||||
`os.availableParallelism()` (U3 H2), which honors cgroup limits — no
|
||||
manual `GITNEXUS_WORKER_POOL_SIZE` override needed unless the auto-
|
||||
resolved value is too aggressive for your I/O budget.
|
||||
- **Long-running host (eval-server, MCP daemon) running back-to-back
|
||||
analyzes:** `--workers` is now threaded through `AnalyzeOptions`
|
||||
(U2 B2), so per-invocation sizing is honored without `process.env`
|
||||
state leaking across calls. `GITNEXUS_VERBOSE` is similarly snapshot/
|
||||
restore-bracketed.
|
||||
|
||||
---
|
||||
|
||||
## What this benchmark does NOT measure
|
||||
|
||||
- **Real-repo performance.** The synthetic fixture is sized for CI; it
|
||||
doesn't exercise the cumulative-load shape (50k files, occasional
|
||||
pathological file) that drove the original PR #1693 hang report. Real-
|
||||
repo numbers should be captured ad-hoc against the user's target repo
|
||||
and cross-referenced here only as supplementary evidence.
|
||||
- **Worker-pool resilience under real crashes.** That's verified by the
|
||||
`worker-pool.test.ts` integration tests (real `process.exit`, real
|
||||
`error` events, real protocol violations) and the unit suite. The
|
||||
benchmark cares about throughput on the happy path.
|
||||
- **IPC repack throughput.** Phase 3 of the PR #1693 plan introduces a
|
||||
transferList + binary wire-format IPC repack (U16-U17). Once that
|
||||
lands, an `IPC repack` row should be added to the "Latest measurement"
|
||||
table above with before/after numbers on the same hardware.
|
||||
|
||||
---
|
||||
|
||||
## Related artifacts
|
||||
|
||||
- Plan: `docs/plans/2026-05-20-001-feat-pr1693-resilience-hardening-and-ipc-repack-plan.md`
|
||||
- Integration test (mini-benchmark with wall-clock guard): `gitnexus/test/integration/parse-impl-large-fixture.test.ts` (U6)
|
||||
- Operator env-var reference: `README.md` → Environment variables
|
||||
- Resilience layer tests: `gitnexus/test/unit/worker-pool-resilience.test.ts`,
|
||||
`worker-pool-cumulative-timeout.test.ts`,
|
||||
`worker-pool-windows-quarantine.test.ts`,
|
||||
`worker-pool-slot-generation.test.ts`
|
||||
1074
gitnexus/package-lock.json
generated
1074
gitnexus/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -48,7 +48,7 @@
|
|||
"test:integration": "vitest run test/integration",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"postinstall": "node scripts/build-tree-sitter-dart.cjs && node scripts/build-tree-sitter-proto.cjs",
|
||||
"postinstall": "node scripts/materialize-vendor-grammars.cjs && node scripts/build-tree-sitter-dart.cjs && node scripts/build-tree-sitter-proto.cjs && node scripts/build-tree-sitter-swift.cjs",
|
||||
"prepare": "node scripts/build.js",
|
||||
"prepack": "node scripts/build.js"
|
||||
},
|
||||
|
|
@ -60,7 +60,7 @@
|
|||
"cli-progress": "^3.12.0",
|
||||
"commander": "^14.0.3",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.19.2",
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.4.1",
|
||||
"glob": "^13.0.6",
|
||||
"graphology": "^0.26.0",
|
||||
|
|
@ -92,15 +92,12 @@
|
|||
"optionalDependencies": {
|
||||
"node-addon-api": "^8.0.0",
|
||||
"node-gyp-build": "^4.8.0",
|
||||
"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": "file:./vendor/tree-sitter-swift"
|
||||
"tree-sitter-kotlin": "^0.3.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cli-progress": "^3.11.6",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/uuid": "^11.0.0",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Build tree-sitter-dart native binding in node_modules/ after materialize-vendor-grammars.cjs.
|
||||
* Vendored source lives in vendor/ only; see #836 and #1728.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
*
|
||||
* Why this script exists:
|
||||
* tree-sitter-proto is vendored under gitnexus/vendor/tree-sitter-proto/
|
||||
* and declared as a `file:` optionalDependency. Previously, the vendored
|
||||
* and copied into node_modules/ by materialize-vendor-grammars.cjs. Previously, the vendored
|
||||
* package had its own `dependencies` and `install` script, which caused
|
||||
* npm to create `vendor/tree-sitter-proto/node_modules/` and
|
||||
* `vendor/tree-sitter-proto/build/` during install. Those directories
|
||||
|
|
@ -20,9 +20,8 @@
|
|||
* gitnexus's own optionalDependencies, and moved native compilation here.
|
||||
*
|
||||
* What this does:
|
||||
* Runs `npx node-gyp rebuild` inside `node_modules/tree-sitter-proto/`
|
||||
* (which npm creates as a copy of vendor/tree-sitter-proto/ when
|
||||
* resolving the file: dep). Build output lands in
|
||||
* Runs `npx node-gyp rebuild` inside `node_modules/tree-sitter-proto/`.
|
||||
* Build output lands in
|
||||
* `node_modules/tree-sitter-proto/build/Release/tree_sitter_proto_binding.node`
|
||||
* — under npm-managed territory, safe on upgrade.
|
||||
*
|
||||
|
|
|
|||
39
gitnexus/scripts/build-tree-sitter-swift.cjs
Normal file
39
gitnexus/scripts/build-tree-sitter-swift.cjs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Probe tree-sitter-swift prebuild availability at install time.
|
||||
*
|
||||
* The vendored package ships platform prebuilds; node-gyp-build selects the
|
||||
* correct binary at require time. This script calls node-gyp-build once
|
||||
* against the materialized package so a missing-prebuild failure surfaces
|
||||
* as an install-time warning (with the rest of the gitnexus install
|
||||
* succeeding) rather than as a runtime error the first time Swift parsing
|
||||
* is requested. The result is discarded — it does not copy, register, or
|
||||
* mutate anything; the runtime require() path in parser-loader does the
|
||||
* actual load. Running this probe here instead of an npm `install` script
|
||||
* on the vendored package preserves the #836 hygiene (no scripts.install
|
||||
* inside vendor/).
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
if (process.env.GITNEXUS_SKIP_OPTIONAL_GRAMMARS === '1') {
|
||||
console.warn('[tree-sitter-swift] Skipping prebuild probe (GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1).');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const swiftDir = path.join(__dirname, '..', 'node_modules', 'tree-sitter-swift');
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(path.join(swiftDir, 'bindings', 'node', 'index.js'))) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const nodeGypBuild = require('node-gyp-build');
|
||||
nodeGypBuild(swiftDir);
|
||||
} catch (err) {
|
||||
console.warn('[tree-sitter-swift] Prebuild probe failed:', err.message);
|
||||
console.warn(
|
||||
'[tree-sitter-swift] Swift parsing will be unavailable. Non-Swift functionality is unaffected.',
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
|
@ -18,6 +18,22 @@ const ROOT = path.resolve(__dirname, '..');
|
|||
const SHARED_ROOT = path.resolve(ROOT, '..', 'gitnexus-shared');
|
||||
const DIST = path.join(ROOT, 'dist');
|
||||
const SHARED_DEST = path.join(DIST, '_shared');
|
||||
const DEFAULT_BUILD_TIMEOUT_MS = 300_000;
|
||||
|
||||
function getBuildTimeoutMs() {
|
||||
const raw = process.env.GITNEXUS_BUILD_TIMEOUT_MS;
|
||||
if (raw === undefined || raw.trim() === '') return DEFAULT_BUILD_TIMEOUT_MS;
|
||||
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
||||
|
||||
console.warn(
|
||||
`[build] ignoring invalid GITNEXUS_BUILD_TIMEOUT_MS=${JSON.stringify(raw)}; using ${DEFAULT_BUILD_TIMEOUT_MS}ms`,
|
||||
);
|
||||
return DEFAULT_BUILD_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
const BUILD_TIMEOUT_MS = getBuildTimeoutMs();
|
||||
|
||||
// ── 1. Build gitnexus-shared ───────────────────────────────────────
|
||||
console.log('[build] compiling gitnexus-shared…');
|
||||
|
|
@ -25,11 +41,11 @@ const tscCmd =
|
|||
process.platform === 'win32'
|
||||
? path.join('node_modules', '.bin', 'tsc.cmd')
|
||||
: path.join('node_modules', '.bin', 'tsc');
|
||||
execSync(tscCmd, { cwd: SHARED_ROOT, stdio: 'inherit', timeout: 120_000 });
|
||||
execSync(tscCmd, { cwd: SHARED_ROOT, stdio: 'inherit', timeout: BUILD_TIMEOUT_MS });
|
||||
|
||||
// ── 2. Build gitnexus ──────────────────────────────────────────────
|
||||
console.log('[build] compiling gitnexus…');
|
||||
execSync(tscCmd, { cwd: ROOT, stdio: 'inherit', timeout: 120_000 });
|
||||
execSync(tscCmd, { cwd: ROOT, stdio: 'inherit', timeout: BUILD_TIMEOUT_MS });
|
||||
|
||||
// ── 3. Copy shared dist ────────────────────────────────────────────
|
||||
console.log('[build] copying shared module into dist/_shared…');
|
||||
|
|
@ -82,9 +98,9 @@ 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: 600_000 });
|
||||
execSync('npm ci', { cwd: WEB_ROOT, stdio: 'inherit', timeout: BUILD_TIMEOUT_MS });
|
||||
}
|
||||
execSync('npm run build', { cwd: WEB_ROOT, stdio: 'inherit', timeout: 600_000 });
|
||||
execSync('npm run build', { cwd: WEB_ROOT, stdio: 'inherit', timeout: BUILD_TIMEOUT_MS });
|
||||
|
||||
// Copy dist → gitnexus/web/ (shipped in the npm package)
|
||||
fs.rmSync(WEB_DEST, { recursive: true, force: true });
|
||||
|
|
|
|||
72
gitnexus/scripts/materialize-vendor-grammars.cjs
Normal file
72
gitnexus/scripts/materialize-vendor-grammars.cjs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Copy vendored tree-sitter grammars into node_modules/ using real files (fs.cpSync).
|
||||
*
|
||||
* Published gitnexus used to declare these as optionalDependencies with
|
||||
* `file:./vendor/...`, which makes npm symlink/junction vendor → node_modules on
|
||||
* install. Windows without Developer Mode often fails with EPERM (#1728).
|
||||
*
|
||||
* Vendor trees stay read-only in gitnexus/vendor/; build artifacts must only
|
||||
* land under node_modules/ (see #836).
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const VENDORED_GRAMMARS = ['tree-sitter-dart', 'tree-sitter-proto', 'tree-sitter-swift'];
|
||||
|
||||
if (process.env.GITNEXUS_SKIP_OPTIONAL_GRAMMARS === '1') {
|
||||
console.warn(
|
||||
'[gitnexus] Skipping vendored grammar materialize (GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1). Dart/Proto/Swift parsing will be unavailable.',
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
for (const name of VENDORED_GRAMMARS) {
|
||||
const src = path.join(ROOT, 'vendor', name);
|
||||
const dest = path.join(ROOT, 'node_modules', name);
|
||||
|
||||
if (!fs.existsSync(src)) {
|
||||
console.warn(`[gitnexus] vendor/${name} missing; skipping materialize.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Sequence: copy src → partial; rename dest → backup; rename partial → dest;
|
||||
// remove backup. If any step fails, restore from backup so a previously-
|
||||
// materialized grammar is never lost. Targets the #1728 EPERM scenario plus
|
||||
// narrower failure modes (Windows AV scanner racing on rename, EBUSY mid-swap).
|
||||
const partial = `${dest}.materialize-tmp`;
|
||||
const backup = `${dest}.materialize-bak`;
|
||||
try {
|
||||
fs.mkdirSync(path.join(ROOT, 'node_modules'), { recursive: true });
|
||||
fs.rmSync(partial, { recursive: true, force: true });
|
||||
fs.rmSync(backup, { recursive: true, force: true });
|
||||
fs.cpSync(src, partial, { recursive: true, verbatim: true });
|
||||
if (fs.existsSync(dest)) {
|
||||
fs.renameSync(dest, backup);
|
||||
}
|
||||
try {
|
||||
fs.renameSync(partial, dest);
|
||||
} catch (renameErr) {
|
||||
// Best-effort rollback: restore the previous dest from backup.
|
||||
if (fs.existsSync(backup)) {
|
||||
try {
|
||||
fs.renameSync(backup, dest);
|
||||
} catch {
|
||||
// If rollback also fails, the prior backup directory still exists on
|
||||
// disk — the catch block below surfaces both errors via the warning.
|
||||
}
|
||||
}
|
||||
throw renameErr;
|
||||
}
|
||||
fs.rmSync(backup, { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
// Fail-soft: a single locked/inaccessible file (common on Windows) must not
|
||||
// abort the whole gitnexus install. Matches build-tree-sitter-*.cjs pattern.
|
||||
fs.rmSync(partial, { recursive: true, force: true });
|
||||
console.warn(`[gitnexus] Could not materialize vendor/${name}: ${err.message}`);
|
||||
console.warn(
|
||||
`[gitnexus] ${name} parsing will be unavailable. Other functionality is unaffected.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -201,6 +201,7 @@ async function upsertGitNexusSection(
|
|||
content: string,
|
||||
projectName: string,
|
||||
stats: RepoStats,
|
||||
noStats?: boolean,
|
||||
): Promise<'created' | 'updated' | 'appended' | 'preserved'> {
|
||||
const exists = await fileExists(filePath);
|
||||
|
||||
|
|
@ -246,14 +247,23 @@ async function upsertGitNexusSection(
|
|||
// like `({target: "symbolName", direction: "upstream"})`
|
||||
// when noStats is set
|
||||
// Passing projectName + stats explicitly makes the contract obvious.
|
||||
// noStats controls template generation, not keep-section stat updates — the user opted into a stats line by keeping it.
|
||||
// --no-stats wins in the keep path too (#1706): a lean block committed
|
||||
// to git would otherwise churn the volatile counts on every analyze,
|
||||
// producing no-value merge conflicts between branches. Under noStats we
|
||||
// drop the parenthetical but still refresh the project name so renames
|
||||
// propagate.
|
||||
const newStatsInner = `${stats.nodes || 0} symbols, ${stats.edges || 0} relationships, ${stats.processes || 0} execution flows`;
|
||||
const statsLine = `Indexed as **${projectName}** (${newStatsInner})`;
|
||||
const statsLine = noStats
|
||||
? `Indexed as **${projectName}**`
|
||||
: `Indexed as **${projectName}** (${newStatsInner})`;
|
||||
|
||||
// Match either canonical phrasing at line start (`^` with `m` flag) so we
|
||||
// cannot replace prose embedded mid-paragraph. Deliberately no `$`: text
|
||||
// after the closing `)` on the same line (e.g. ". MCP tools.") stays intact.
|
||||
const statsPattern = /^(?:Indexed as|indexed by GitNexus as) \*\*[^*]+\*\* \([^)]+\)/m;
|
||||
// after the line on the same line (e.g. ". MCP tools.") stays intact.
|
||||
// The parenthetical is optional so a count-free line left by a prior
|
||||
// --no-stats run still matches — letting the name refresh, and letting
|
||||
// counts return if --no-stats is later dropped.
|
||||
const statsPattern = /^(?:Indexed as|indexed by GitNexus as) \*\*[^*]+\*\*(?: \([^)]+\))?/m;
|
||||
|
||||
if (statsPattern.test(existingSection)) {
|
||||
const updatedSection = existingSection.replace(statsPattern, statsLine);
|
||||
|
|
@ -389,12 +399,24 @@ export async function generateAIContextFiles(
|
|||
if (!options?.skipAgentsMd) {
|
||||
// Create AGENTS.md (standard for Cursor, Windsurf, OpenCode, Cline, etc.)
|
||||
const agentsPath = path.join(repoPath, 'AGENTS.md');
|
||||
const agentsResult = await upsertGitNexusSection(agentsPath, content, projectName, stats);
|
||||
const agentsResult = await upsertGitNexusSection(
|
||||
agentsPath,
|
||||
content,
|
||||
projectName,
|
||||
stats,
|
||||
options?.noStats,
|
||||
);
|
||||
createdFiles.push(`AGENTS.md (${agentsResult})`);
|
||||
|
||||
// Create CLAUDE.md (for Claude Code)
|
||||
const claudePath = path.join(repoPath, 'CLAUDE.md');
|
||||
const claudeResult = await upsertGitNexusSection(claudePath, content, projectName, stats);
|
||||
const claudeResult = await upsertGitNexusSection(
|
||||
claudePath,
|
||||
content,
|
||||
projectName,
|
||||
stats,
|
||||
options?.noStats,
|
||||
);
|
||||
createdFiles.push(`CLAUDE.md (${claudeResult})`);
|
||||
} else {
|
||||
createdFiles.push('AGENTS.md (skipped via --skip-agents-md)');
|
||||
|
|
|
|||
|
|
@ -9,11 +9,16 @@
|
|||
*/
|
||||
|
||||
import path from 'path';
|
||||
import { execFileSync } from 'child_process';
|
||||
import { spawn } from 'child_process';
|
||||
import v8 from 'v8';
|
||||
import cliProgress from 'cli-progress';
|
||||
import { closeLbug } from '../core/lbug/lbug-adapter.js';
|
||||
import { isWalCorruptionError, WAL_RECOVERY_SUGGESTION } from '../core/lbug/lbug-config.js';
|
||||
import {
|
||||
isLbugCheckpointIoError,
|
||||
isWalCorruptionError,
|
||||
parseWalCheckpointThreshold,
|
||||
WAL_RECOVERY_SUGGESTION,
|
||||
} from '../core/lbug/lbug-config.js';
|
||||
import {
|
||||
getStoragePaths,
|
||||
getGlobalRegistryPath,
|
||||
|
|
@ -37,6 +42,7 @@ import { isHfDownloadFailure } from '../core/embeddings/hf-env.js';
|
|||
// previous behaviour silently swallowed stack traces and made #1169
|
||||
// indistinguishable from a no-op success on Windows.
|
||||
const realStderrWrite = process.stderr.write.bind(process.stderr);
|
||||
const realStdoutWrite = process.stdout.write.bind(process.stdout);
|
||||
|
||||
const writeFatalToStderr = (label: string, err: unknown): void => {
|
||||
const isErr = err instanceof Error;
|
||||
|
|
@ -78,15 +84,274 @@ const HEAP_FLAG = `--max-old-space-size=${RESPAWN_HEAP_MB}`;
|
|||
/** Increase default stack size (KB) to prevent stack overflow on deep class hierarchies. */
|
||||
const STACK_KB = 4096;
|
||||
const STACK_FLAG = `--stack-size=${STACK_KB}`;
|
||||
const RESPAWN_OUTPUT_TAIL_CHARS = 1024 * 1024;
|
||||
const RESPAWN_PROGRESS_ENV = 'GITNEXUS_RESPAWN_PROGRESS_TTY';
|
||||
|
||||
interface CliProgressTerminal {
|
||||
cursorSave(): void;
|
||||
cursorRestore(): void;
|
||||
cursor(enabled: boolean): void;
|
||||
lineWrapping(enabled: boolean): void;
|
||||
cursorTo(x?: number | null, y?: number | null): void;
|
||||
cursorRelative(dx?: number | null, dy?: number | null): void;
|
||||
cursorRelativeReset(): void;
|
||||
clearRight(): void;
|
||||
clearLine(): void;
|
||||
clearBottom(): void;
|
||||
newline(): void;
|
||||
write(s: string, rawWrite?: boolean): void;
|
||||
isTTY(): boolean;
|
||||
getWidth(): number;
|
||||
}
|
||||
|
||||
const terminalColumns = (): number => {
|
||||
const parsed = Number(process.env.COLUMNS);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 80;
|
||||
};
|
||||
|
||||
const ANSI_ESCAPE_PATTERN =
|
||||
/\x1B(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\)|[PX^_][\s\S]*?\x1B\\|[78]|[@-Z\\-_])/y;
|
||||
|
||||
interface IntlSegmenterLike {
|
||||
segment(input: string): Iterable<{ segment: string }>;
|
||||
}
|
||||
|
||||
type IntlWithOptionalSegmenter = typeof Intl & {
|
||||
Segmenter?: new (
|
||||
locales?: string | string[],
|
||||
options?: { granularity?: 'grapheme' },
|
||||
) => IntlSegmenterLike;
|
||||
};
|
||||
|
||||
const splitGraphemes = (text: string): string[] => {
|
||||
const Segmenter = (Intl as IntlWithOptionalSegmenter).Segmenter;
|
||||
if (Segmenter) {
|
||||
return Array.from(
|
||||
new Segmenter(undefined, { granularity: 'grapheme' }).segment(text),
|
||||
(s) => s.segment,
|
||||
);
|
||||
}
|
||||
return Array.from(text);
|
||||
};
|
||||
|
||||
const isZeroWidthCodePoint = (codePoint: number): boolean =>
|
||||
codePoint === 0x200d ||
|
||||
(codePoint >= 0x0300 && codePoint <= 0x036f) ||
|
||||
(codePoint >= 0x1ab0 && codePoint <= 0x1aff) ||
|
||||
(codePoint >= 0x1dc0 && codePoint <= 0x1dff) ||
|
||||
(codePoint >= 0x20d0 && codePoint <= 0x20ff) ||
|
||||
(codePoint >= 0xfe00 && codePoint <= 0xfe0f) ||
|
||||
(codePoint >= 0xfe20 && codePoint <= 0xfe2f);
|
||||
|
||||
const isWideCodePoint = (codePoint: number): boolean =>
|
||||
codePoint >= 0x1100 &&
|
||||
(codePoint <= 0x115f ||
|
||||
codePoint === 0x2329 ||
|
||||
codePoint === 0x232a ||
|
||||
(codePoint >= 0x2e80 && codePoint <= 0xa4cf && codePoint !== 0x303f) ||
|
||||
(codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
|
||||
(codePoint >= 0xf900 && codePoint <= 0xfaff) ||
|
||||
(codePoint >= 0xfe10 && codePoint <= 0xfe19) ||
|
||||
(codePoint >= 0xfe30 && codePoint <= 0xfe6f) ||
|
||||
(codePoint >= 0xff00 && codePoint <= 0xff60) ||
|
||||
(codePoint >= 0xffe0 && codePoint <= 0xffe6) ||
|
||||
(codePoint >= 0x1f300 && codePoint <= 0x1faff) ||
|
||||
(codePoint >= 0x20000 && codePoint <= 0x3fffd));
|
||||
|
||||
const visibleColumns = (text: string): number => {
|
||||
let columns = 0;
|
||||
for (const char of Array.from(text)) {
|
||||
const codePoint = char.codePointAt(0);
|
||||
if (codePoint === undefined || isZeroWidthCodePoint(codePoint)) continue;
|
||||
columns += isWideCodePoint(codePoint) ? 2 : 1;
|
||||
}
|
||||
return columns;
|
||||
};
|
||||
|
||||
const readAnsiEscapeAt = (text: string, index: number): string | undefined => {
|
||||
ANSI_ESCAPE_PATTERN.lastIndex = index;
|
||||
return ANSI_ESCAPE_PATTERN.exec(text)?.[0];
|
||||
};
|
||||
|
||||
const truncateAnsiToColumns = (text: string, maxColumns: number): string => {
|
||||
if (!Number.isFinite(maxColumns) || maxColumns <= 0) return '';
|
||||
|
||||
let output = '';
|
||||
let columns = 0;
|
||||
let index = 0;
|
||||
|
||||
while (index < text.length) {
|
||||
const escape = readAnsiEscapeAt(text, index);
|
||||
if (escape) {
|
||||
output += escape;
|
||||
index += escape.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextEscapeIndex = text.indexOf('\x1B', index);
|
||||
const plainEnd = nextEscapeIndex === -1 ? text.length : nextEscapeIndex;
|
||||
const plainText = text.slice(index, plainEnd);
|
||||
|
||||
for (const segment of splitGraphemes(plainText)) {
|
||||
const width = visibleColumns(segment);
|
||||
if (width > 0 && columns + width > maxColumns) return output;
|
||||
output += segment;
|
||||
columns += width;
|
||||
}
|
||||
|
||||
index = plainEnd;
|
||||
}
|
||||
|
||||
return output;
|
||||
};
|
||||
|
||||
const createAnsiPipeTerminal = (stream: NodeJS.WriteStream): CliProgressTerminal => {
|
||||
let linewrap = true;
|
||||
let dy = 0;
|
||||
const write = (s: string): void => {
|
||||
stream.write(s);
|
||||
};
|
||||
const moveVertical = (delta: number): void => {
|
||||
if (delta > 0) write(`\x1B[${delta}B`);
|
||||
else if (delta < 0) write(`\x1B[${Math.abs(delta)}A`);
|
||||
};
|
||||
|
||||
return {
|
||||
cursorSave: () => write('\x1B7'),
|
||||
cursorRestore: () => write('\x1B8'),
|
||||
cursor: (enabled) => write(enabled ? '\x1B[?25h' : '\x1B[?25l'),
|
||||
lineWrapping: (enabled) => {
|
||||
linewrap = enabled;
|
||||
write(enabled ? '\x1B[?7h' : '\x1B[?7l');
|
||||
},
|
||||
cursorTo: (x = null, y = null) => {
|
||||
if (typeof y === 'number' && typeof x === 'number') {
|
||||
write(`\x1B[${y + 1};${x + 1}H`);
|
||||
return;
|
||||
}
|
||||
if (typeof x === 'number') {
|
||||
write(x === 0 ? '\r' : `\x1B[${x + 1}G`);
|
||||
}
|
||||
},
|
||||
cursorRelative: (dx = null, nextDy = null) => {
|
||||
if (typeof dx === 'number' && dx !== 0) {
|
||||
write(dx > 0 ? `\x1B[${dx}C` : `\x1B[${Math.abs(dx)}D`);
|
||||
}
|
||||
if (typeof nextDy === 'number' && nextDy !== 0) {
|
||||
dy += nextDy;
|
||||
moveVertical(nextDy);
|
||||
}
|
||||
},
|
||||
cursorRelativeReset: () => {
|
||||
moveVertical(-dy);
|
||||
write('\r');
|
||||
dy = 0;
|
||||
},
|
||||
clearRight: () => write('\x1B[0K'),
|
||||
clearLine: () => write('\x1B[2K'),
|
||||
clearBottom: () => write('\x1B[0J'),
|
||||
newline: () => {
|
||||
write('\n');
|
||||
dy++;
|
||||
},
|
||||
write: (s, rawWrite = false) => {
|
||||
const width = terminalColumns();
|
||||
write(linewrap && rawWrite === false ? truncateAnsiToColumns(s, width) : s);
|
||||
},
|
||||
isTTY: () => true,
|
||||
getWidth: terminalColumns,
|
||||
};
|
||||
};
|
||||
|
||||
const shouldBridgeRespawnProgressTty = (): boolean =>
|
||||
process.stderr.isTTY === true || process.stdout.isTTY === true;
|
||||
|
||||
interface RespawnExit {
|
||||
status?: number | null;
|
||||
signal?: NodeJS.Signals | null;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
const appendOutputTail = (tail: string, chunk: unknown): string => {
|
||||
const text = Buffer.isBuffer(chunk)
|
||||
? chunk.toString('utf8')
|
||||
: typeof chunk === 'string'
|
||||
? chunk
|
||||
: String(chunk ?? '');
|
||||
if (!text) return tail;
|
||||
const next = tail + text;
|
||||
return next.length > RESPAWN_OUTPUT_TAIL_CHARS ? next.slice(-RESPAWN_OUTPUT_TAIL_CHARS) : next;
|
||||
};
|
||||
|
||||
/**
|
||||
* Run the respawned analyzer while teeing child output through to the parent
|
||||
* and keeping a bounded tail for crash classification.
|
||||
*
|
||||
* `execFileSync(..., { stdio: 'inherit' })` preserved live progress but hid
|
||||
* stderr/stdout from the parent on abnormal exits. That made every
|
||||
* SIGABRT/status-134 child look like an output-less V8 heap OOM, even when the
|
||||
* terminal had already shown a native crash such as
|
||||
* `libc++abi: ... Napi::Error`. Piped streams plus an explicit tee keeps the UX
|
||||
* and gives `childProcessLikelyOom` the evidence it needs.
|
||||
*/
|
||||
const runRespawnedAnalyze = (
|
||||
args: readonly string[],
|
||||
env: NodeJS.ProcessEnv,
|
||||
): Promise<RespawnExit> =>
|
||||
new Promise((resolve) => {
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let settled = false;
|
||||
const finish = (exit: RespawnExit): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(exit);
|
||||
};
|
||||
|
||||
const child = spawn(process.execPath, [...args], {
|
||||
stdio: ['inherit', 'pipe', 'pipe'],
|
||||
env,
|
||||
});
|
||||
|
||||
child.stdout?.on('data', (chunk) => {
|
||||
stdout = appendOutputTail(stdout, chunk);
|
||||
realStdoutWrite(chunk);
|
||||
});
|
||||
child.stderr?.on('data', (chunk) => {
|
||||
stderr = appendOutputTail(stderr, chunk);
|
||||
realStderrWrite(chunk);
|
||||
});
|
||||
child.on('error', (err) => {
|
||||
finish({
|
||||
status: 1,
|
||||
signal: null,
|
||||
stdout,
|
||||
stderr,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
});
|
||||
child.on('close', (status, signal) => {
|
||||
finish({
|
||||
status,
|
||||
signal,
|
||||
stdout,
|
||||
stderr,
|
||||
message: `Command failed: ${process.execPath} ${args.join(' ')}`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Heuristic for "child re-exec likely died from V8 OOM".
|
||||
*
|
||||
* Platform-independent detection is best-effort: V8/Node usually emit
|
||||
* stable heap-exhaustion phrases in stderr/message across Linux/macOS/Windows
|
||||
* (for example "JavaScript heap out of memory" or "Reached heap limit"),
|
||||
* while some environments only expose status/signal (e.g. 134/SIGABRT).
|
||||
* We combine both text signatures and process-exit signatures.
|
||||
* Platform-independent detection is best-effort: V8/Node usually emit stable
|
||||
* heap-exhaustion phrases in stderr/message across Linux/macOS/Windows (for
|
||||
* example "JavaScript heap out of memory" or "Reached heap limit"). When the
|
||||
* child produced no output at all, we still treat status 134/SIGABRT as likely
|
||||
* heap OOM. If stderr/stdout contains a native crash diagnostic, the output
|
||||
* evidence wins and we do not print heap guidance.
|
||||
*/
|
||||
const childProcessLikelyOom = (err: unknown): boolean => {
|
||||
if (!err || typeof err !== 'object') return false;
|
||||
|
|
@ -122,6 +387,31 @@ const childProcessLikelyOom = (err: unknown): boolean => {
|
|||
return e.status === 134 || e.signal === 'SIGABRT';
|
||||
};
|
||||
|
||||
const childProcessLikelyNativeAbort = (err: unknown): boolean => {
|
||||
if (!err || typeof err !== 'object') return false;
|
||||
const e = err as {
|
||||
stderr?: unknown;
|
||||
stdout?: unknown;
|
||||
message?: unknown;
|
||||
};
|
||||
const hasNativeAbortSignature = (v: unknown): boolean => {
|
||||
const text = (
|
||||
Buffer.isBuffer(v) ? v.toString('utf8') : typeof v === 'string' ? v : ''
|
||||
).toLowerCase();
|
||||
if (!text) return false;
|
||||
return (
|
||||
text.includes('napi::error') ||
|
||||
text.includes('libc++abi: terminating') ||
|
||||
text.includes('abort trap') ||
|
||||
text.includes('native stack') ||
|
||||
text.includes('native worker') ||
|
||||
text.includes('native binding')
|
||||
);
|
||||
};
|
||||
|
||||
return [e.message, e.stderr, e.stdout].some((v) => hasNativeAbortSignature(v));
|
||||
};
|
||||
|
||||
const forceHeapOOMForTestIfEnabled = (): void => {
|
||||
if (process.env.GITNEXUS_TEST_FORCE_HEAP_OOM !== '1') return;
|
||||
// Allocate JS strings (not Buffers) so pressure lands on V8 heap itself.
|
||||
|
|
@ -130,8 +420,17 @@ const forceHeapOOMForTestIfEnabled = (): void => {
|
|||
for (;;) chunks.push('x'.repeat(1024 * 1024));
|
||||
};
|
||||
|
||||
// 64 MiB keeps auto-checkpoint enabled but triggers less frequently than
|
||||
// Ladybug's stock ~16 MiB threshold, reducing rename/remove churn on large
|
||||
// runs. Also matches the GitNexus default in `lbug-config.ts`.
|
||||
//
|
||||
// IMPORTANT: keep README examples (`README.md`, `gitnexus/README.md`) and
|
||||
// the `DEFAULT_WAL_CHECKPOINT_THRESHOLD` constant in
|
||||
// `gitnexus/src/core/lbug/lbug-config.ts` in sync with this value.
|
||||
const RECOMMENDED_WAL_CHECKPOINT_THRESHOLD = 64 * 1024 * 1024;
|
||||
|
||||
/** Re-exec the process with a 16GB heap and larger stack if we're currently below that. */
|
||||
function ensureHeap(): boolean {
|
||||
async function ensureHeap(): Promise<boolean> {
|
||||
const nodeOpts = process.env.NODE_OPTIONS || '';
|
||||
if (nodeOpts.includes('--max-old-space-size')) return false;
|
||||
|
||||
|
|
@ -143,13 +442,15 @@ function ensureHeap(): boolean {
|
|||
const cliFlags = [HEAP_FLAG];
|
||||
if (!nodeOpts.includes('--stack-size')) cliFlags.push(STACK_FLAG);
|
||||
|
||||
try {
|
||||
execFileSync(process.execPath, [...cliFlags, ...process.argv.slice(1)], {
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, NODE_OPTIONS: `${nodeOpts} ${HEAP_FLAG}`.trim() },
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (childProcessLikelyOom(e)) {
|
||||
const childArgs = [...cliFlags, ...process.argv.slice(1)];
|
||||
const childEnv = {
|
||||
...process.env,
|
||||
NODE_OPTIONS: `${nodeOpts} ${HEAP_FLAG}`.trim(),
|
||||
};
|
||||
if (shouldBridgeRespawnProgressTty()) childEnv[RESPAWN_PROGRESS_ENV] = '1';
|
||||
const childExit = await runRespawnedAnalyze(childArgs, childEnv);
|
||||
if (childExit.status !== 0 || childExit.signal) {
|
||||
if (childProcessLikelyOom(childExit)) {
|
||||
cliError(
|
||||
` Analysis likely ran out of memory.\n` +
|
||||
` Retry with a larger heap if your machine allows it:\n` +
|
||||
|
|
@ -158,14 +459,66 @@ function ensureHeap(): boolean {
|
|||
` If this persists, it may be a native crash unrelated to heap size.\n`,
|
||||
{ recoveryHint: 'heap-oom-respawn' },
|
||||
);
|
||||
} else if (childProcessLikelyNativeAbort(childExit)) {
|
||||
cliError(
|
||||
` Analysis aborted in a native worker or native binding path.\n` +
|
||||
` Try one of these recovery paths:\n` +
|
||||
` gitnexus analyze --workers 0\n` +
|
||||
` npm uninstall -g gitnexus && npm install -g gitnexus@latest\n` +
|
||||
` Use Node 22 LTS if you are on a newer non-LTS runtime.\n`,
|
||||
{ recoveryHint: 'native-worker-abort' },
|
||||
);
|
||||
}
|
||||
process.exitCode = e.status ?? 1;
|
||||
const status =
|
||||
typeof childExit.status === 'number' && childExit.status !== 0 ? childExit.status : 1;
|
||||
process.exitCode = status;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* GITNEXUS_* env vars that `analyzeCommand` writes for backward-compatible
|
||||
* downstream consumption. Snapshotted at function entry and restored in the
|
||||
* finally block so that programmatic callers (tests, long-running hosts)
|
||||
* don't see leaked state across invocations. `GITNEXUS_WORKER_POOL_SIZE` is
|
||||
* NOT in this list: that knob is threaded through `runFullAnalysis` options
|
||||
* (see `workerPoolSize` plumbing) so the CLI never has to mutate `process.env`
|
||||
* for it in the first place.
|
||||
*/
|
||||
const ANALYZE_CLI_ENV_KEYS = [
|
||||
'GITNEXUS_VERBOSE',
|
||||
'GITNEXUS_PROFILE_DEFERRED',
|
||||
'GITNEXUS_PROFILE_DEFERRED_SLOW_MS',
|
||||
'GITNEXUS_MAX_FILE_SIZE',
|
||||
'GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS',
|
||||
'GITNEXUS_WAL_CHECKPOINT_THRESHOLD',
|
||||
'GITNEXUS_WAL_MANUAL_CHECKPOINT',
|
||||
'GITNEXUS_EMBEDDING_THREADS',
|
||||
'GITNEXUS_EMBEDDING_BATCH_SIZE',
|
||||
'GITNEXUS_EMBEDDING_SUB_BATCH_SIZE',
|
||||
'GITNEXUS_EMBEDDING_DEVICE',
|
||||
'GITNEXUS_ANALYZE_PROGRESS_ACTIVE',
|
||||
] as const;
|
||||
|
||||
type AnalyzeEnvSnapshot = Record<(typeof ANALYZE_CLI_ENV_KEYS)[number], string | undefined>;
|
||||
|
||||
const snapshotAnalyzeEnv = (): AnalyzeEnvSnapshot => {
|
||||
const snap = {} as AnalyzeEnvSnapshot;
|
||||
for (const k of ANALYZE_CLI_ENV_KEYS) snap[k] = process.env[k];
|
||||
return snap;
|
||||
};
|
||||
|
||||
const restoreAnalyzeEnv = (snap: AnalyzeEnvSnapshot): void => {
|
||||
for (const k of ANALYZE_CLI_ENV_KEYS) {
|
||||
const v = snap[k];
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
};
|
||||
|
||||
export interface AnalyzeOptions {
|
||||
force?: boolean;
|
||||
repairFts?: boolean;
|
||||
/**
|
||||
* Embedding generation toggle. Commander parses `--embeddings [limit]` as:
|
||||
* - `undefined` when the flag is omitted
|
||||
|
|
@ -225,6 +578,10 @@ export interface AnalyzeOptions {
|
|||
maxFileSize?: string;
|
||||
/** Override worker sub-batch idle timeout in seconds. */
|
||||
workerTimeout?: string;
|
||||
/** Control LadybugDB WAL auto-checkpoint threshold during analyze. */
|
||||
walCheckpointThreshold?: string;
|
||||
/** Parse worker pool size; 0 disables workers (sequential fallback). */
|
||||
workers?: string;
|
||||
embeddingThreads?: string;
|
||||
embeddingBatchSize?: string;
|
||||
embeddingSubBatchSize?: string;
|
||||
|
|
@ -250,7 +607,7 @@ export const shouldGenerateCommunitySkillFiles = (
|
|||
): boolean => Boolean(options?.skills && pipelineResult && !options?.indexOnly);
|
||||
|
||||
export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOptions) => {
|
||||
if (ensureHeap()) return;
|
||||
if (await ensureHeap()) return;
|
||||
forceHeapOOMForTestIfEnabled();
|
||||
|
||||
// Install fatal handlers immediately after re-exec resolution so any
|
||||
|
|
@ -258,6 +615,22 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
|||
// a stack trace and a non-zero exit code instead of a silent exit 0.
|
||||
installFatalHandlers();
|
||||
|
||||
// Snapshot the GITNEXUS_* env vars that the impl writes for downstream
|
||||
// consumption, so they don't leak across `analyzeCommand` invocations in
|
||||
// programmatic callers (tests, long-running hosts). `process.exit(0)` on
|
||||
// the success path bypasses `finally` — intentional: when the process is
|
||||
// exiting, restoration is moot. For early-return paths (validation
|
||||
// errors) and the alreadyUpToDate fast path the finally restores the
|
||||
// pre-call values.
|
||||
const envSnap = snapshotAnalyzeEnv();
|
||||
try {
|
||||
await analyzeCommandImpl(inputPath, options);
|
||||
} finally {
|
||||
restoreAnalyzeEnv(envSnap);
|
||||
}
|
||||
};
|
||||
|
||||
const analyzeCommandImpl = async (inputPath?: string, options?: AnalyzeOptions): Promise<void> => {
|
||||
if (options?.verbose) {
|
||||
process.env.GITNEXUS_VERBOSE = '1';
|
||||
}
|
||||
|
|
@ -278,6 +651,36 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
|||
);
|
||||
}
|
||||
|
||||
if (options?.walCheckpointThreshold !== undefined) {
|
||||
const parsed = parseWalCheckpointThreshold(options.walCheckpointThreshold);
|
||||
if (parsed === undefined) {
|
||||
cliError(' --wal-checkpoint-threshold must be an integer >= -1.\n');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
process.env.GITNEXUS_WAL_CHECKPOINT_THRESHOLD = String(parsed);
|
||||
}
|
||||
|
||||
// `--workers` is threaded through `runFullAnalysis` options → PipelineOptions
|
||||
// → createWorkerPool, intentionally bypassing the GITNEXUS_WORKER_POOL_SIZE
|
||||
// env channel so this CLI surface never mutates `process.env` for pool size.
|
||||
// Tests can therefore re-invoke analyzeCommand with different --workers
|
||||
// values back-to-back and observe the value they passed, not whatever the
|
||||
// previous call leaked.
|
||||
let workerPoolSize: number | undefined;
|
||||
if (options?.workers !== undefined) {
|
||||
const parsedWorkers = Number(options.workers);
|
||||
if (!Number.isInteger(parsedWorkers) || parsedWorkers < 0) {
|
||||
cliError(
|
||||
' --workers must be a non-negative integer. ' +
|
||||
'Pass 0 to disable the worker pool (sequential fallback).\n',
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
workerPoolSize = parsedWorkers;
|
||||
}
|
||||
|
||||
// Parse `--embeddings [limit]`: `true` → default cap, string → numeric cap
|
||||
// (0 disables the cap entirely). Validated up here so failures match the
|
||||
// sibling-validation pattern (exit before bar.start() — otherwise
|
||||
|
|
@ -343,6 +746,15 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
|||
process.env.GITNEXUS_EMBEDDING_DEVICE = options.embeddingDevice;
|
||||
}
|
||||
|
||||
if (options?.repairFts && options?.force) {
|
||||
cliError(
|
||||
' Cannot combine `--repair-fts` with `--force`. ' +
|
||||
'Use `--repair-fts` for fast FTS-only repair, or `--force` for a full rebuild.\n',
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('\n GitNexus Analyzer\n');
|
||||
|
||||
// `--index-only` is the stronger contract — it suppresses every form of file
|
||||
|
|
@ -428,19 +840,25 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
|||
}
|
||||
|
||||
// ── CLI progress bar setup ─────────────────────────────────────────
|
||||
const bar = new cliProgress.SingleBar(
|
||||
{
|
||||
format: ' {bar} {percentage}% | {phase}',
|
||||
barCompleteChar: '\u2588',
|
||||
barIncompleteChar: '\u2591',
|
||||
hideCursor: true,
|
||||
barGlue: '',
|
||||
autopadding: true,
|
||||
clearOnComplete: false,
|
||||
stopOnComplete: false,
|
||||
},
|
||||
cliProgress.Presets.shades_grey,
|
||||
);
|
||||
const barOptions: cliProgress.Options & { terminal?: CliProgressTerminal } = {
|
||||
format: ' {bar} {percentage}% | {phase}',
|
||||
barCompleteChar: '\u2588',
|
||||
barIncompleteChar: '\u2591',
|
||||
hideCursor: true,
|
||||
barGlue: '',
|
||||
autopadding: true,
|
||||
clearOnComplete: false,
|
||||
stopOnComplete: false,
|
||||
};
|
||||
if (process.env[RESPAWN_PROGRESS_ENV] === '1' && process.stderr.isTTY !== true) {
|
||||
// Heap respawn pipes stderr so the parent can classify native/OOM crashes.
|
||||
// The parent was a real TTY when it opted into this env var, so forward
|
||||
// ANSI cursor controls through the pipe instead of cli-progress' non-TTY
|
||||
// newline mode. That keeps one-line redraw UX while retaining stderr tail
|
||||
// capture for diagnostics.
|
||||
barOptions.terminal = createAnsiPipeTerminal(process.stderr);
|
||||
}
|
||||
const bar = new cliProgress.SingleBar(barOptions, cliProgress.Presets.shades_grey);
|
||||
|
||||
bar.start(100, 0, { phase: 'Initializing...' });
|
||||
|
||||
|
|
@ -474,7 +892,7 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
|||
// eslint-disable-next-line no-console -- intentional console-routing for progress bar UX
|
||||
const origError = console.error.bind(console);
|
||||
let barCurrentValue = 0;
|
||||
const barLog = (...args: any[]) => {
|
||||
const barLog = (...args: unknown[]) => {
|
||||
process.stdout.write('\x1b[2K\r');
|
||||
origLog(args.map((a) => (typeof a === 'string' ? a : String(a))).join(' '));
|
||||
bar.update(barCurrentValue);
|
||||
|
|
@ -484,6 +902,7 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
|||
console.warn = barLog;
|
||||
// eslint-disable-next-line no-console -- intentional console-routing for progress bar UX
|
||||
console.error = barLog;
|
||||
process.env.GITNEXUS_ANALYZE_PROGRESS_ACTIVE = '1';
|
||||
|
||||
// Track elapsed time per phase
|
||||
let lastPhaseLabel = 'Initializing...';
|
||||
|
|
@ -521,9 +940,11 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
|||
// needs a fresh pipelineResult. Has no bearing on the registry
|
||||
// collision guard (see allowDuplicateName below).
|
||||
force: options?.force || options?.skills,
|
||||
repairFts: options?.repairFts,
|
||||
embeddings: embeddingsEnabled,
|
||||
embeddingsNodeLimit,
|
||||
dropEmbeddings: options?.dropEmbeddings,
|
||||
verbose: options?.verbose,
|
||||
skipGit: options?.skipGit,
|
||||
skipAgentsMd,
|
||||
skipSkills,
|
||||
|
|
@ -539,6 +960,10 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
|||
// be able to accept the duplicate name without also paying the
|
||||
// cost of a full pipeline re-index. See #829 review round 2.
|
||||
allowDuplicateName: options?.allowDuplicateName,
|
||||
// Worker pool size threaded from --workers, replacing the previous
|
||||
// GITNEXUS_WORKER_POOL_SIZE env mutation. `undefined` defers to the
|
||||
// env / auto-formula fallback inside the pipeline.
|
||||
workerPoolSize,
|
||||
},
|
||||
{
|
||||
onProgress: (_phase, percent, message) => {
|
||||
|
|
@ -568,6 +993,19 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
|||
return;
|
||||
}
|
||||
|
||||
if (result.ftsRepairedOnly) {
|
||||
clearInterval(elapsedTimer);
|
||||
process.removeListener('SIGINT', sigintHandler);
|
||||
console.log = origLog;
|
||||
// eslint-disable-next-line no-console -- restoring after intentional progress-bar routing
|
||||
console.warn = origWarn;
|
||||
// eslint-disable-next-line no-console -- restoring after intentional progress-bar routing
|
||||
console.error = origError;
|
||||
bar.stop();
|
||||
console.log(' FTS indexes repaired successfully\n');
|
||||
return;
|
||||
}
|
||||
|
||||
// Post-finalize invariant (#1169): runFullAnalysis nominally writes
|
||||
// meta.json and registers the repo, but on Windows it has been
|
||||
// observed to return successfully with neither artifact present
|
||||
|
|
@ -663,7 +1101,7 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
|||
}
|
||||
|
||||
console.log('');
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
clearInterval(elapsedTimer);
|
||||
process.removeListener('SIGINT', sigintHandler);
|
||||
console.log = origLog;
|
||||
|
|
@ -673,7 +1111,7 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
|||
console.error = origError;
|
||||
bar.stop();
|
||||
|
||||
const msg = err.message || String(err);
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
|
||||
// Registry name-collision from --name (#829) — surface as an
|
||||
// actionable error rather than a generic stack-trace.
|
||||
|
|
@ -720,6 +1158,20 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
|||
return;
|
||||
}
|
||||
|
||||
if (isLbugCheckpointIoError(err)) {
|
||||
cliError(
|
||||
` LadybugDB failed while rotating/removing WAL checkpoint files.\n` +
|
||||
` This can happen when auto-checkpoint runs at the default threshold (~16MB).\n` +
|
||||
` Retry with a larger checkpoint threshold to reduce checkpoint frequency:\n` +
|
||||
` gitnexus analyze --wal-checkpoint-threshold ${RECOMMENDED_WAL_CHECKPOINT_THRESHOLD}\n` +
|
||||
` (or set GITNEXUS_WAL_CHECKPOINT_THRESHOLD=${RECOMMENDED_WAL_CHECKPOINT_THRESHOLD})\n` +
|
||||
` (Try 33554432 = 32 MiB on small-disk / CI runners.)\n`,
|
||||
{ recoveryHint: 'wal-checkpoint-threshold' },
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// HF download failure — show clean guidance without the raw stack trace.
|
||||
// Checked before writeFatalToStderr so the user sees one focused message
|
||||
// rather than a stack-trace dump followed by a second remediation block.
|
||||
|
|
|
|||
|
|
@ -29,6 +29,38 @@
|
|||
*/
|
||||
import { logger } from '../core/logger.js';
|
||||
|
||||
/**
|
||||
* String-literal union of all `recoveryHint` tags emitted by the CLI.
|
||||
*
|
||||
* Centralized so a new recovery branch added in `analyze.ts` cannot land
|
||||
* without updating this union — TypeScript will reject the unknown literal
|
||||
* passed via `cliError({ recoveryHint: '...' })`. To add a new hint:
|
||||
* 1. Add the tag string to this union.
|
||||
* 2. Pass it as the `recoveryHint` field at the relevant `cliError`
|
||||
* call site.
|
||||
*
|
||||
* Consumers can import this type to narrow log-record `recoveryHint`
|
||||
* fields without restating the literal list.
|
||||
*/
|
||||
export type RecoveryHint =
|
||||
| 'wal-corruption'
|
||||
| 'wal-checkpoint-threshold'
|
||||
| 'heap-oom-respawn'
|
||||
| 'native-worker-abort'
|
||||
| 'hf-endpoint-unreachable'
|
||||
| 'large-repo'
|
||||
| 'npm-resolution'
|
||||
| 'module-not-found';
|
||||
|
||||
/**
|
||||
* Common shape for the optional structured-field bag passed to
|
||||
* `cliError`/`cliWarn`/`cliInfo`. Typed so the `recoveryHint` slot is
|
||||
* checked against the {@link RecoveryHint} union.
|
||||
*/
|
||||
export interface CliMessageFields extends Record<string, unknown> {
|
||||
recoveryHint?: RecoveryHint;
|
||||
}
|
||||
|
||||
function writeStderr(msg: string): void {
|
||||
// Direct write — bypassing `console.*` so it cannot be intercepted by
|
||||
// progress-bar redirection (see `cli/analyze.ts:barLog`) or other
|
||||
|
|
@ -41,7 +73,7 @@ function writeStderr(msg: string): void {
|
|||
* User-facing informational message. Use for banners, listening URLs,
|
||||
* and any message the user expects to read in plain text.
|
||||
*/
|
||||
export function cliInfo(msg: string, fields?: Record<string, unknown>): void {
|
||||
export function cliInfo(msg: string, fields?: CliMessageFields): void {
|
||||
writeStderr(msg);
|
||||
logger.info(fields ?? {}, msg);
|
||||
}
|
||||
|
|
@ -50,7 +82,7 @@ export function cliInfo(msg: string, fields?: Record<string, unknown>): void {
|
|||
* User-facing warning. Operator-actionable but non-fatal — `cliWarn`
|
||||
* indicates the command can still proceed in some form.
|
||||
*/
|
||||
export function cliWarn(msg: string, fields?: Record<string, unknown>): void {
|
||||
export function cliWarn(msg: string, fields?: CliMessageFields): void {
|
||||
writeStderr(msg);
|
||||
logger.warn(fields ?? {}, msg);
|
||||
}
|
||||
|
|
@ -59,7 +91,7 @@ export function cliWarn(msg: string, fields?: Record<string, unknown>): void {
|
|||
* User-facing error. Indicates the command cannot proceed; usually
|
||||
* paired with a non-zero exit code at the call site.
|
||||
*/
|
||||
export function cliError(msg: string, fields?: Record<string, unknown>): void {
|
||||
export function cliError(msg: string, fields?: CliMessageFields): void {
|
||||
writeStderr(msg);
|
||||
logger.error(fields ?? {}, msg);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,10 +44,12 @@ export interface EvalServerOptions {
|
|||
|
||||
/**
|
||||
* Validate the --host value. Accepts IPv4, IPv6, or "localhost".
|
||||
* Returns the normalised host string, or null if invalid.
|
||||
* Returns the host string unchanged, or null if invalid.
|
||||
* "localhost" is passed through so the OS resolves it to the correct loopback
|
||||
* address (127.0.0.1 or ::1) at bind time rather than forcing IPv4.
|
||||
*/
|
||||
export function validateHost(raw: string): string | null {
|
||||
if (raw === 'localhost') return '127.0.0.1';
|
||||
if (raw === 'localhost') return raw;
|
||||
if (isIPv4(raw) || isIPv6(raw)) return raw;
|
||||
return null;
|
||||
}
|
||||
|
|
@ -470,12 +472,14 @@ export async function evalServerCommand(options?: EvalServerOptions): Promise<vo
|
|||
{ code: err.code, port, host },
|
||||
);
|
||||
} else if (err.code === 'EADDRNOTAVAIL') {
|
||||
const isIPv6Host = isIPv6(host);
|
||||
// "localhost" may resolve to ::1 on IPv6-only systems; treat it as
|
||||
// potentially IPv6 so the user gets the right diagnostic hint.
|
||||
const isIPv6Host = isIPv6(host) || host === 'localhost';
|
||||
cliError(
|
||||
`\nGitNexus eval-server failed to start:\n` +
|
||||
` Address ${host} is not available on this machine.\n\n` +
|
||||
(isIPv6Host
|
||||
? ` IPv6 address ${host} is not reachable — IPv6 may be disabled on this system or container.\n` +
|
||||
? ` Address ${host} resolved but is not reachable — IPv6 may be disabled, or the loopback interface may be unavailable.\n` +
|
||||
` Docker containers and many CI environments disable IPv6 by default.\n\n`
|
||||
: ` The --host value must be an IP assigned to a local network interface.\n` +
|
||||
` Run \`ip addr\` (Linux) or \`ipconfig\` (Windows) to list available addresses.\n\n`) +
|
||||
|
|
@ -506,10 +510,21 @@ export async function evalServerCommand(options?: EvalServerOptions): Promise<vo
|
|||
// Plain-text banner for the human watching stderr; structured record
|
||||
// for log aggregation (split into two so the user sees a real banner
|
||||
// not `{"level":30,"msg":"...","port":4747,"endpoints":[...]}`).
|
||||
// Use server.address().port so --port 0 (OS-assigned) emits the real port.
|
||||
// Use server.address() so the banner and READY signal reflect what the OS
|
||||
// actually bound to, not the input host string. This matters when "localhost"
|
||||
// is passed: the OS may resolve it to ::1 on some systems.
|
||||
const addr = server.address();
|
||||
const boundPort = typeof addr === 'object' && addr !== null ? addr.port : port;
|
||||
const displayHost = host.includes(':') ? `[${host}]` : host;
|
||||
// server.listen callback only fires after a successful TCP bind, so
|
||||
// server.address() is guaranteed to return an AddressInfo object here.
|
||||
if (typeof addr !== 'object' || addr === null) {
|
||||
cliError(
|
||||
`\nGitNexus eval-server: unexpected server.address() value after bind: ${JSON.stringify(addr)}\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const boundPort = addr.port;
|
||||
const boundAddress = addr.address;
|
||||
const displayHost = boundAddress.includes(':') ? `[${boundAddress}]` : boundAddress;
|
||||
const bannerLines = [
|
||||
`GitNexus eval-server: listening on http://${displayHost}:${boundPort}`,
|
||||
` POST /tool/query — search execution flows`,
|
||||
|
|
@ -537,8 +552,7 @@ export async function evalServerCommand(options?: EvalServerOptions): Promise<vo
|
|||
});
|
||||
try {
|
||||
// Use fd 1 directly — LadybugDB captures process.stdout (#324)
|
||||
const readyHost = host.includes(':') ? `[${host}]` : host;
|
||||
writeSync(1, `GITNEXUS_EVAL_SERVER_READY:${readyHost}:${boundPort}\n`);
|
||||
writeSync(1, `GITNEXUS_EVAL_SERVER_READY:${displayHost}:${boundPort}\n`);
|
||||
} catch {
|
||||
// stdout may not be available (e.g., broken pipe)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ program
|
|||
.command('analyze [path]')
|
||||
.description('Index a repository (full analysis)')
|
||||
.option('-f, --force', 'Force full re-index even if up to date')
|
||||
.option('--repair-fts', 'Repair/rebuild search FTS indexes without full re-analysis')
|
||||
.option(
|
||||
'--embeddings [limit]',
|
||||
'Enable embedding generation for semantic search (off by default). ' +
|
||||
|
|
@ -70,6 +71,15 @@ program
|
|||
'--worker-timeout <seconds>',
|
||||
'Worker sub-batch idle timeout before retry/fallback. Default: 30.',
|
||||
)
|
||||
.option(
|
||||
'--wal-checkpoint-threshold <bytes>',
|
||||
'LadybugDB WAL auto-checkpoint threshold in bytes during analyze ' +
|
||||
'(integer >= -1; default: 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB).',
|
||||
)
|
||||
.option(
|
||||
'--workers <n>',
|
||||
'Parse worker pool size. Default: cores-1 capped at 16. Pass 0 to disable workers (sequential).',
|
||||
)
|
||||
.option('--embedding-threads <n>', 'Limit local ONNX embedding CPU threads')
|
||||
.option('--embedding-batch-size <n>', 'Number of nodes per embedding batch')
|
||||
.option('--embedding-sub-batch-size <n>', 'Number of chunks per embedding model call')
|
||||
|
|
@ -80,9 +90,16 @@ program
|
|||
' 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_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL auto-checkpoint threshold in bytes (default 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB).\n' +
|
||||
' GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker job byte budget. Default 8388608.\n' +
|
||||
' GITNEXUS_WORKER_POOL_SIZE=N Parse worker count override. Default cores-1 capped at 16.\n' +
|
||||
' GITNEXUS_PARSE_CHUNK_CONCURRENCY=N Concurrent in-flight parse chunks. Default 2.\n' +
|
||||
' GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N Max replacement spawns per slot before drop. Default 3.\n' +
|
||||
' GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N Total retry wall-time per job. Default 5x sub-batch timeout.\n' +
|
||||
' GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N Per-slot deaths to trip circuit breaker. Default max(3, poolSize).\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' +
|
||||
'\nFlags override the corresponding env vars when both are provided.\n' +
|
||||
'\nTip: `.gitnexusignore` supports `.gitignore`-style negation. Add e.g.\n' +
|
||||
' `!__tests__/` to index a directory that is auto-filtered by default (#771).',
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,15 +1,18 @@
|
|||
/**
|
||||
* Optional grammar availability check.
|
||||
*
|
||||
* tree-sitter-dart and tree-sitter-proto are optionalDependencies that
|
||||
* require a `node-gyp rebuild` at install time. The build can be skipped
|
||||
* via GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1 (postinstall scripts), or it can
|
||||
* silently soft-fail when the C++ toolchain is missing.
|
||||
* tree-sitter-dart, tree-sitter-proto, and tree-sitter-swift are vendored
|
||||
* under vendor/ and materialized into node_modules/ at postinstall. Dart
|
||||
* and Proto are built from source with node-gyp; Swift ships platform
|
||||
* prebuilds activated via node-gyp-build. All three can be skipped via
|
||||
* GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1 (postinstall scripts), or can silently
|
||||
* soft-fail when the toolchain is missing (Dart/Proto) or no prebuild
|
||||
* matches the host platform (Swift).
|
||||
*
|
||||
* Either path produces the same observable: the .node binding is absent
|
||||
* at runtime. This helper detects that condition and surfaces a single
|
||||
* stderr line per missing grammar so users learn why .dart/.proto support
|
||||
* is unavailable instead of silently getting a degraded index.
|
||||
* stderr line per missing grammar so users learn why .dart/.proto/.swift
|
||||
* support is unavailable instead of silently getting a degraded index.
|
||||
*/
|
||||
|
||||
import { createRequire } from 'module';
|
||||
|
|
@ -29,6 +32,7 @@ interface OptionalGrammar {
|
|||
const OPTIONAL_GRAMMARS: OptionalGrammar[] = [
|
||||
{ name: 'tree-sitter-dart', pkg: 'tree-sitter-dart', extensions: ['.dart'] },
|
||||
{ name: 'tree-sitter-proto', pkg: 'tree-sitter-proto', extensions: ['.proto'] },
|
||||
{ name: 'tree-sitter-swift', pkg: 'tree-sitter-swift', extensions: ['.swift'] },
|
||||
];
|
||||
|
||||
export interface MissingGrammar {
|
||||
|
|
@ -40,8 +44,8 @@ export interface MissingGrammar {
|
|||
* Returns the list of optional grammars whose native binding cannot be
|
||||
* loaded. Actually `require()`s the package — `require.resolve` would
|
||||
* locate the entry path even when the `.node` binding is absent (the
|
||||
* `file:` package directory is installed regardless of postinstall
|
||||
* outcome), giving false negatives for the exact users we want to warn:
|
||||
* package directory exists without a working `.node` binding), giving false
|
||||
* negatives for the exact users we want to warn:
|
||||
* those who installed with `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` or whose
|
||||
* native rebuild soft-failed for missing toolchain.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -61,11 +61,13 @@ function resolveGitnexusBin(): string | null {
|
|||
.filter(Boolean);
|
||||
|
||||
if (isWin) {
|
||||
// On Windows, `where` returns multiple entries (e.g. the POSIX shell
|
||||
// script AND the .cmd/.bat wrapper). Prefer the wrapper because
|
||||
// child_process.spawn() cannot execute a shell script directly.
|
||||
// On Windows, npm global installs can surface multiple launchers for the
|
||||
// same package (e.g. a POSIX shell shim plus .cmd/.bat wrappers). Claude
|
||||
// and the other MCP hosts need a directly spawnable command path, so only
|
||||
// accept the Windows wrapper. If it is missing, fall back to the slower
|
||||
// npx entry instead of persisting a non-spawnable shim path.
|
||||
const cmdLine = lines.find((l) => /\.(cmd|bat)$/i.test(l));
|
||||
return cmdLine || lines[0] || null;
|
||||
return cmdLine || null;
|
||||
}
|
||||
|
||||
return lines[0] || null;
|
||||
|
|
|
|||
|
|
@ -107,6 +107,24 @@ function prompt(question: string, hide = false): Promise<string> {
|
|||
}
|
||||
|
||||
export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptions) => {
|
||||
// Snapshot GITNEXUS_VERBOSE at entry — wikiCommand mutates it (the impl
|
||||
// below) so cursor-client (process.env-driven) sees the right value during
|
||||
// this run. Restored in finally so back-to-back wiki calls in long-running
|
||||
// hosts don't leak verbose state from one invocation to the next. Pairs
|
||||
// with the same snapshot/restore pattern in `analyzeCommand`.
|
||||
const originalVerbose = process.env.GITNEXUS_VERBOSE;
|
||||
try {
|
||||
await wikiCommandImpl(inputPath, options);
|
||||
} finally {
|
||||
if (originalVerbose === undefined) {
|
||||
delete process.env.GITNEXUS_VERBOSE;
|
||||
} else {
|
||||
process.env.GITNEXUS_VERBOSE = originalVerbose;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions): Promise<void> => {
|
||||
// Set verbose mode globally for cursor-client to pick up
|
||||
if (options?.verbose) {
|
||||
process.env.GITNEXUS_VERBOSE = '1';
|
||||
|
|
|
|||
|
|
@ -13,7 +13,12 @@ import type { HttpDetection, HttpLanguagePlugin } from './types.js';
|
|||
* - FastAPI `@app.get("/path")` provider decorators
|
||||
* - `requests.get/post/...("url")` consumer calls
|
||||
* - Generic `requests.request("METHOD", "url")` consumer calls
|
||||
* - `httpx.AsyncClient` instances calling `.get/.post/...("url")`
|
||||
* - `httpx.AsyncClient` instances calling `.get/.post/...("url")`, including
|
||||
* aliased imports such as `import httpx as hx`,
|
||||
* `from httpx import AsyncClient`, and
|
||||
* `from httpx import AsyncClient as HttpxAsyncClient`.
|
||||
* Locally rebound names (e.g. `AsyncClient = mock_factory()` inside a
|
||||
* function) are excluded to avoid false-positive consumer contracts.
|
||||
*/
|
||||
|
||||
const FASTAPI_VERBS: Record<string, string> = {
|
||||
|
|
@ -80,12 +85,50 @@ const REQUESTS_GENERIC_PATTERNS = compilePatterns({
|
|||
} satisfies LanguagePatterns<Record<string, never>>);
|
||||
|
||||
// ─── Consumer: httpx.AsyncClient assignments ────────────────────────
|
||||
// NOTE: This targeted detector only tracks explicit `httpx.AsyncClient(...)`
|
||||
// construction. Direct imports (`from httpx import AsyncClient`) and module
|
||||
// aliases (`import httpx as hx`) and annotated assignments (`client: httpx.AsyncClient = ...`)
|
||||
// are intentionally left for a follow-up. Module-scope clients are only matched
|
||||
// Module-scope clients are only matched
|
||||
// at module scope; calls inside functions require a function/class-local tracked
|
||||
// client to avoid false positives from same-name local variables.
|
||||
const HTTPX_MODULE_IMPORT_PATTERNS = compilePatterns({
|
||||
name: 'python-httpx-module-imports',
|
||||
language: Python,
|
||||
patterns: [
|
||||
{
|
||||
meta: {},
|
||||
query: `
|
||||
(import_statement
|
||||
name: (aliased_import
|
||||
name: (dotted_name (identifier) @module)
|
||||
alias: (identifier) @alias))
|
||||
`,
|
||||
},
|
||||
],
|
||||
} satisfies LanguagePatterns<Record<string, never>>);
|
||||
|
||||
const HTTPX_ASYNC_CLIENT_IMPORT_PATTERNS = compilePatterns({
|
||||
name: 'python-httpx-async-client-imports',
|
||||
language: Python,
|
||||
patterns: [
|
||||
{
|
||||
meta: {},
|
||||
query: `
|
||||
(import_from_statement
|
||||
module_name: (dotted_name (identifier) @module)
|
||||
name: (dotted_name (identifier) @client_class))
|
||||
`,
|
||||
},
|
||||
{
|
||||
meta: {},
|
||||
query: `
|
||||
(import_from_statement
|
||||
module_name: (dotted_name (identifier) @module)
|
||||
name: (aliased_import
|
||||
name: (dotted_name (identifier) @client_class)
|
||||
alias: (identifier) @alias))
|
||||
`,
|
||||
},
|
||||
],
|
||||
} satisfies LanguagePatterns<Record<string, never>>);
|
||||
|
||||
const HTTPX_ASYNC_CLIENT_ASSIGN_PATTERNS = compilePatterns({
|
||||
name: 'python-httpx-async-client-assign',
|
||||
language: Python,
|
||||
|
|
@ -97,8 +140,24 @@ const HTTPX_ASYNC_CLIENT_ASSIGN_PATTERNS = compilePatterns({
|
|||
left: (_) @client
|
||||
right: (call
|
||||
function: (attribute
|
||||
object: (identifier) @module (#eq? @module "httpx")
|
||||
attribute: (identifier) @client_class (#eq? @client_class "AsyncClient"))))
|
||||
object: (identifier) @module
|
||||
attribute: (identifier) @client_class)))
|
||||
`,
|
||||
},
|
||||
],
|
||||
} satisfies LanguagePatterns<Record<string, never>>);
|
||||
|
||||
const HTTPX_ASYNC_CLIENT_DIRECT_ASSIGN_PATTERNS = compilePatterns({
|
||||
name: 'python-httpx-async-client-direct-assign',
|
||||
language: Python,
|
||||
patterns: [
|
||||
{
|
||||
meta: {},
|
||||
query: `
|
||||
(assignment
|
||||
left: (_) @client
|
||||
right: (call
|
||||
function: (identifier) @client_class))
|
||||
`,
|
||||
},
|
||||
],
|
||||
|
|
@ -115,8 +174,24 @@ const HTTPX_ASYNC_CLIENT_WITH_ALIAS_PATTERNS = compilePatterns({
|
|||
(as_pattern
|
||||
(call
|
||||
function: (attribute
|
||||
object: (identifier) @module (#eq? @module "httpx")
|
||||
attribute: (identifier) @client_class (#eq? @client_class "AsyncClient")))
|
||||
object: (identifier) @module
|
||||
attribute: (identifier) @client_class))
|
||||
(as_pattern_target (identifier) @client))
|
||||
`,
|
||||
},
|
||||
],
|
||||
} satisfies LanguagePatterns<Record<string, never>>);
|
||||
|
||||
const HTTPX_ASYNC_CLIENT_DIRECT_WITH_ALIAS_PATTERNS = compilePatterns({
|
||||
name: 'python-httpx-async-client-direct-with-alias',
|
||||
language: Python,
|
||||
patterns: [
|
||||
{
|
||||
meta: {},
|
||||
query: `
|
||||
(as_pattern
|
||||
(call
|
||||
function: (identifier) @client_class)
|
||||
(as_pattern_target (identifier) @client))
|
||||
`,
|
||||
},
|
||||
|
|
@ -150,17 +225,137 @@ function trackedClientScopeKey(clientNode: Parser.SyntaxNode): string {
|
|||
}
|
||||
|
||||
function callScopeKeys(clientNode: Parser.SyntaxNode): string[] {
|
||||
const keys = new Set<string>();
|
||||
const preferClass = clientNode.text.includes('.');
|
||||
const nearestScope = getScopeKey(clientNode.parent, preferClass);
|
||||
return [getScopeKey(clientNode.parent, clientNode.text.includes('.'))];
|
||||
}
|
||||
|
||||
keys.add(nearestScope);
|
||||
// Returns the scope key that a rebind of an imported alias would shadow under
|
||||
// Python LEGB rules, or `null` when the rebind does not shadow anything that
|
||||
// could produce a false-positive consumer detection.
|
||||
// - Rebind inside a function/method → that function's scope.
|
||||
// - Rebind at module top level → 'module' (shadows the whole file).
|
||||
// - Rebind in a class body without an enclosing function → null. Python
|
||||
// class attributes do not shadow bare-name lookups inside methods (methods
|
||||
// see the module binding, not the class attribute), so we must not poison
|
||||
// them.
|
||||
function shadowScopeKey(node: Parser.SyntaxNode | null): string | null {
|
||||
let current = node;
|
||||
let passedThroughClass = false;
|
||||
while (current) {
|
||||
if (current.type === 'function_definition') {
|
||||
// Reuse getScopeKey's key format so the two helpers cannot drift apart.
|
||||
return getScopeKey(current);
|
||||
}
|
||||
if (current.type === 'class_definition') {
|
||||
passedThroughClass = true;
|
||||
}
|
||||
current = current.parent;
|
||||
}
|
||||
return passedThroughClass ? null : 'module';
|
||||
}
|
||||
|
||||
return [...keys];
|
||||
function collectHttpxImportAliases(tree: Parser.Tree): {
|
||||
moduleAliases: Set<string>;
|
||||
asyncClientAliases: Set<string>;
|
||||
} {
|
||||
const moduleAliases = new Set<string>(['httpx']);
|
||||
const asyncClientAliases = new Set<string>();
|
||||
|
||||
// The @module capture is a single identifier inside a `dotted_name`, so for
|
||||
// `import package.httpx as hx` the pattern would match the inner `httpx`
|
||||
// segment. Check the full `dotted_name` text via `parent` to anchor the match.
|
||||
for (const match of runCompiledPatterns(HTTPX_MODULE_IMPORT_PATTERNS, tree)) {
|
||||
const moduleNode = match.captures.module;
|
||||
const aliasNode = match.captures.alias;
|
||||
if (moduleNode?.parent?.text === 'httpx' && aliasNode) moduleAliases.add(aliasNode.text);
|
||||
}
|
||||
|
||||
for (const match of runCompiledPatterns(HTTPX_ASYNC_CLIENT_IMPORT_PATTERNS, tree)) {
|
||||
const moduleNode = match.captures.module;
|
||||
const classNode = match.captures.client_class;
|
||||
if (moduleNode?.parent?.text !== 'httpx' || classNode?.text !== 'AsyncClient') continue;
|
||||
asyncClientAliases.add(match.captures.alias?.text ?? classNode.text);
|
||||
}
|
||||
|
||||
return { moduleAliases, asyncClientAliases };
|
||||
}
|
||||
|
||||
// Tracks local rebindings (`AsyncClient = ...`, `hx = ...`) that shadow an
|
||||
// imported alias. We treat the whole enclosing scope (module, class, or
|
||||
// function) as shadowed for that alias name, so subsequent constructions in
|
||||
// that scope are not falsely detected as httpx consumers. Covers bare-identifier
|
||||
// targets and the common tuple / list destructuring shapes.
|
||||
const ALIAS_SHADOW_PATTERNS = compilePatterns({
|
||||
name: 'python-httpx-alias-shadow',
|
||||
language: Python,
|
||||
patterns: [
|
||||
{
|
||||
meta: {},
|
||||
query: `(assignment left: (identifier) @name)`,
|
||||
},
|
||||
{
|
||||
meta: {},
|
||||
query: `(assignment left: (pattern_list (identifier) @name))`,
|
||||
},
|
||||
{
|
||||
meta: {},
|
||||
query: `(assignment left: (tuple_pattern (identifier) @name))`,
|
||||
},
|
||||
{
|
||||
meta: {},
|
||||
query: `(assignment left: (list_pattern (identifier) @name))`,
|
||||
},
|
||||
],
|
||||
} satisfies LanguagePatterns<Record<string, never>>);
|
||||
|
||||
function collectAliasShadowScopes(
|
||||
tree: Parser.Tree,
|
||||
aliases: Set<string>,
|
||||
): Map<string, Set<string>> {
|
||||
const shadowed = new Map<string, Set<string>>();
|
||||
if (aliases.size === 0) return shadowed;
|
||||
|
||||
for (const match of runCompiledPatterns(ALIAS_SHADOW_PATTERNS, tree)) {
|
||||
const nameNode = match.captures.name;
|
||||
if (!nameNode || !aliases.has(nameNode.text)) continue;
|
||||
const scopeKey = shadowScopeKey(nameNode.parent);
|
||||
if (scopeKey === null) continue;
|
||||
const set = shadowed.get(nameNode.text) ?? new Set<string>();
|
||||
set.add(scopeKey);
|
||||
shadowed.set(nameNode.text, set);
|
||||
}
|
||||
|
||||
return shadowed;
|
||||
}
|
||||
|
||||
function isAliasShadowed(
|
||||
shadowed: Map<string, Set<string>>,
|
||||
aliasName: string,
|
||||
node: Parser.SyntaxNode,
|
||||
): boolean {
|
||||
const scopes = shadowed.get(aliasName);
|
||||
if (!scopes || scopes.size === 0) return false;
|
||||
let current: Parser.SyntaxNode | null = node.parent;
|
||||
while (current) {
|
||||
if (current.type === 'function_definition') {
|
||||
// Reuse getScopeKey's key format so the two helpers cannot drift apart.
|
||||
if (scopes.has(getScopeKey(current))) return true;
|
||||
}
|
||||
current = current.parent;
|
||||
}
|
||||
// A module-level rebind shadows the alias for the entire file.
|
||||
return scopes.has('module');
|
||||
}
|
||||
|
||||
function collectHttpxAsyncClients(tree: Parser.Tree): Map<string, Set<string>> {
|
||||
const clients = new Map<string, Set<string>>();
|
||||
const { moduleAliases, asyncClientAliases } = collectHttpxImportAliases(tree);
|
||||
// Module aliases (`hx`) and AsyncClient aliases (`AsyncClient`,
|
||||
// `HttpxAsyncClient`) share disjoint name spaces, so one shadow map keyed by
|
||||
// alias name serves both lookups and we only walk the tree for rebinds once.
|
||||
const shadowed = collectAliasShadowScopes(
|
||||
tree,
|
||||
new Set([...moduleAliases, ...asyncClientAliases]),
|
||||
);
|
||||
|
||||
const addClient = (clientNode: Parser.SyntaxNode | undefined) => {
|
||||
if (!clientNode) return;
|
||||
|
|
@ -172,10 +367,34 @@ function collectHttpxAsyncClients(tree: Parser.Tree): Map<string, Set<string>> {
|
|||
};
|
||||
|
||||
for (const match of runCompiledPatterns(HTTPX_ASYNC_CLIENT_ASSIGN_PATTERNS, tree)) {
|
||||
const moduleNode = match.captures.module;
|
||||
const classNode = match.captures.client_class;
|
||||
if (!moduleNode || !classNode) continue;
|
||||
if (!moduleAliases.has(moduleNode.text) || classNode.text !== 'AsyncClient') continue;
|
||||
if (isAliasShadowed(shadowed, moduleNode.text, moduleNode)) continue;
|
||||
addClient(match.captures.client);
|
||||
}
|
||||
|
||||
for (const match of runCompiledPatterns(HTTPX_ASYNC_CLIENT_DIRECT_ASSIGN_PATTERNS, tree)) {
|
||||
const classNode = match.captures.client_class;
|
||||
if (!classNode || !asyncClientAliases.has(classNode.text)) continue;
|
||||
if (isAliasShadowed(shadowed, classNode.text, classNode)) continue;
|
||||
addClient(match.captures.client);
|
||||
}
|
||||
|
||||
for (const match of runCompiledPatterns(HTTPX_ASYNC_CLIENT_WITH_ALIAS_PATTERNS, tree)) {
|
||||
const moduleNode = match.captures.module;
|
||||
const classNode = match.captures.client_class;
|
||||
if (!moduleNode || !classNode) continue;
|
||||
if (!moduleAliases.has(moduleNode.text) || classNode.text !== 'AsyncClient') continue;
|
||||
if (isAliasShadowed(shadowed, moduleNode.text, moduleNode)) continue;
|
||||
addClient(match.captures.client);
|
||||
}
|
||||
|
||||
for (const match of runCompiledPatterns(HTTPX_ASYNC_CLIENT_DIRECT_WITH_ALIAS_PATTERNS, tree)) {
|
||||
const classNode = match.captures.client_class;
|
||||
if (!classNode || !asyncClientAliases.has(classNode.text)) continue;
|
||||
if (isAliasShadowed(shadowed, classNode.text, classNode)) continue;
|
||||
addClient(match.captures.client);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,12 +18,14 @@ import { getPluginForFile, HTTP_SCAN_GLOB, type HttpDetection } from './http-pat
|
|||
* the preferred path because the graph has richer symbol metadata
|
||||
* (real uids, class/method structure, etc.).
|
||||
*
|
||||
* 2. **Source-scan fallback (Strategy B)** — parse files directly with
|
||||
* the per-language plugin registry in `./http-patterns/`. Used when
|
||||
* the graph has no routes/fetches for this repo (e.g. a repo that
|
||||
* hasn't been indexed yet, or whose indexer doesn't know the
|
||||
* framework). Each plugin owns its tree-sitter grammar and query
|
||||
* sources — this orchestrator imports NO grammars or query strings.
|
||||
* 2. **Source-scan supplement (Strategy B)** — parse files directly with
|
||||
* the per-language plugin registry in `./http-patterns/`. Used to
|
||||
* fill gaps when graph extraction only covers part of a polyglot repo
|
||||
* (e.g. Java graph routes plus Go source-scan routes). Graph entries
|
||||
* remain authoritative for duplicate contract IDs because they carry
|
||||
* richer symbol metadata. Each plugin owns its tree-sitter grammar
|
||||
* and query sources — this orchestrator imports NO grammars or query
|
||||
* strings.
|
||||
*
|
||||
* Adding a new language for Strategy B is a one-file edit in
|
||||
* `http-patterns/index.ts`: register a new `HttpLanguagePlugin` and
|
||||
|
|
@ -194,17 +196,19 @@ export class HttpRouteExtractor implements ContractExtractor {
|
|||
|
||||
const graphProviders =
|
||||
dbExecutor != null ? await this.extractProvidersGraph(dbExecutor, getDetections) : [];
|
||||
const providers =
|
||||
graphProviders.length > 0
|
||||
? graphProviders
|
||||
: this.extractProvidersSourceScan(await getScannedFiles(), getDetections);
|
||||
// Source scan always runs to capture routes in languages/files not covered
|
||||
// by graph edges; the glob and per-file parse results are cached above.
|
||||
const providers = this.mergeGraphAndSourceContracts(
|
||||
graphProviders,
|
||||
this.extractProvidersSourceScan(await getScannedFiles(), getDetections),
|
||||
);
|
||||
|
||||
const graphConsumers =
|
||||
dbExecutor != null ? await this.extractConsumersGraph(dbExecutor, getDetections) : [];
|
||||
const consumers =
|
||||
graphConsumers.length > 0
|
||||
? graphConsumers
|
||||
: this.extractConsumersSourceScan(await getScannedFiles(), getDetections);
|
||||
const consumers = this.mergeGraphAndSourceContracts(
|
||||
graphConsumers,
|
||||
this.extractConsumersSourceScan(await getScannedFiles(), getDetections),
|
||||
);
|
||||
|
||||
return [...providers, ...consumers];
|
||||
}
|
||||
|
|
@ -473,4 +477,18 @@ export class HttpRouteExtractor implements ContractExtractor {
|
|||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private mergeGraphAndSourceContracts(
|
||||
graphContracts: ExtractedContract[],
|
||||
sourceContracts: ExtractedContract[],
|
||||
): ExtractedContract[] {
|
||||
const seenContractIds = new Set(graphContracts.map((c) => c.contractId));
|
||||
const out = [...graphContracts];
|
||||
for (const contract of sourceContracts) {
|
||||
if (seenContractIds.has(contract.contractId)) continue;
|
||||
seenContractIds.add(contract.contractId);
|
||||
out.push(contract);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type { CallExtractionConfig } from '../../call-types.js';
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
|
||||
export const cCallConfig: CallExtractionConfig = {
|
||||
language: SupportedLanguages.C,
|
||||
|
|
@ -9,4 +10,168 @@ export const cCallConfig: CallExtractionConfig = {
|
|||
|
||||
export const cppCallConfig: CallExtractionConfig = {
|
||||
language: SupportedLanguages.CPlusPlus,
|
||||
extractLanguageCallSite(callNode) {
|
||||
return extractCppOperatorCallSite(callNode);
|
||||
},
|
||||
};
|
||||
|
||||
function extractCppOperatorCallSite(callNode: SyntaxNode) {
|
||||
if (callNode.type !== 'binary_expression') return null;
|
||||
if (isPrimitiveOnlyBinaryOperatorCall(callNode)) return null;
|
||||
|
||||
const operator = callNode.childForFieldName('operator')?.text.trim();
|
||||
// Keep the legacy DAG conservative: only simple identifier operands are
|
||||
// modeled here. Complex expressions stay unresolved instead of guessed.
|
||||
if (operator === '+') {
|
||||
const left = callNode.childForFieldName('left');
|
||||
const right = callNode.childForFieldName('right');
|
||||
if (left?.type !== 'identifier' || right?.type !== 'identifier') return null;
|
||||
return {
|
||||
calledName: 'operator+',
|
||||
callForm: 'member' as const,
|
||||
receiverName: left.text,
|
||||
argCount: 1,
|
||||
};
|
||||
}
|
||||
|
||||
if (operator === '<<') {
|
||||
const right = callNode.childForFieldName('right');
|
||||
if (right?.type !== 'identifier') return null;
|
||||
return {
|
||||
calledName: 'operator<<',
|
||||
callForm: 'free' as const,
|
||||
argCount: 2,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPrimitiveOnlyBinaryOperatorCall(callNode: SyntaxNode): boolean {
|
||||
const left = callNode.childForFieldName('left');
|
||||
const right = callNode.childForFieldName('right');
|
||||
if (left === null || right === null) return false;
|
||||
return isBuiltinOperatorOperand(left) && isBuiltinOperatorOperand(right);
|
||||
}
|
||||
|
||||
function isBuiltinOperatorOperand(node: SyntaxNode): boolean {
|
||||
return isBuiltinOperatorType(inferCppOperatorOperandType(node));
|
||||
}
|
||||
|
||||
function inferCppOperatorOperandType(node: SyntaxNode): string {
|
||||
const literalType = inferCppLiteralType(node);
|
||||
if (literalType !== '') return literalType;
|
||||
if (node.type === 'identifier') return lookupCppIdentifierType(node);
|
||||
return '';
|
||||
}
|
||||
|
||||
function inferCppLiteralType(node: SyntaxNode): string {
|
||||
if (node.type === 'number_literal') return node.text.includes('.') ? 'double' : 'int';
|
||||
if (node.type === 'char_literal') return 'char';
|
||||
if (node.type === 'true' || node.type === 'false') return 'bool';
|
||||
return '';
|
||||
}
|
||||
|
||||
function lookupCppIdentifierType(identNode: SyntaxNode): string {
|
||||
const varName = identNode.text;
|
||||
let scope: SyntaxNode | null = identNode.parent;
|
||||
while (
|
||||
scope !== null &&
|
||||
scope.type !== 'compound_statement' &&
|
||||
scope.type !== 'translation_unit'
|
||||
) {
|
||||
scope = scope.parent;
|
||||
}
|
||||
if (scope === null) return '';
|
||||
|
||||
const parameterType = lookupCppFunctionParameterType(scope, varName);
|
||||
if (parameterType !== '') return parameterType;
|
||||
|
||||
for (let i = 0; i < scope.childCount; i++) {
|
||||
const stmt = scope.child(i);
|
||||
if (stmt === null || stmt.type !== 'declaration') continue;
|
||||
const typeNode = stmt.childForFieldName('type');
|
||||
const declarator = stmt.childForFieldName('declarator');
|
||||
if (typeNode === null || declarator === null) continue;
|
||||
if (extractDeclaratorLeafName(declarator) === varName)
|
||||
return normalizeCppTypeText(typeNode.text);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function lookupCppFunctionParameterType(scope: SyntaxNode, varName: string): string {
|
||||
let node: SyntaxNode | null = scope.parent;
|
||||
while (node !== null) {
|
||||
if (node.type === 'function_definition' || node.type === 'function_declarator') {
|
||||
const fnDecl =
|
||||
node.type === 'function_declarator'
|
||||
? node
|
||||
: findFirstDescendantOfType(node, 'function_declarator');
|
||||
const params = fnDecl?.childForFieldName('parameters') ?? null;
|
||||
if (params === null) return '';
|
||||
for (let i = 0; i < params.namedChildCount; i++) {
|
||||
const param = params.namedChild(i);
|
||||
if (param === null || param.type !== 'parameter_declaration') continue;
|
||||
const declarator = param.childForFieldName('declarator');
|
||||
const typeNode = param.childForFieldName('type');
|
||||
if (
|
||||
declarator !== null &&
|
||||
typeNode !== null &&
|
||||
extractDeclaratorLeafName(declarator) === varName
|
||||
) {
|
||||
return normalizeCppTypeText(typeNode.text);
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
node = node.parent;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function findFirstDescendantOfType(node: SyntaxNode, type: string): SyntaxNode | null {
|
||||
if (node.type === type) return node;
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const found = findFirstDescendantOfType(node.namedChild(i)!, type);
|
||||
if (found !== null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractDeclaratorLeafName(node: SyntaxNode): string {
|
||||
if (
|
||||
node.type === 'identifier' ||
|
||||
node.type === 'field_identifier' ||
|
||||
node.type === 'operator_name'
|
||||
) {
|
||||
return node.text;
|
||||
}
|
||||
|
||||
const named = node.namedChildren;
|
||||
for (let i = named.length - 1; i >= 0; i--) {
|
||||
const name = extractDeclaratorLeafName(named[i]!);
|
||||
if (name !== '') return name;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalizeCppTypeText(text: string): string {
|
||||
return text
|
||||
.replace(/\b(const|volatile|static|extern|register|mutable|inline|constexpr)\b/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function isBuiltinOperatorType(type: string): boolean {
|
||||
return (
|
||||
type === 'bool' ||
|
||||
type === 'char' ||
|
||||
type === 'double' ||
|
||||
type === 'float' ||
|
||||
type === 'int' ||
|
||||
type === 'long' ||
|
||||
type === 'short' ||
|
||||
type === 'signed' ||
|
||||
type === 'unsigned'
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,16 @@ import { generateId } from '../../lib/utils.js';
|
|||
import { getLanguageFromFilename, SupportedLanguages } from 'gitnexus-shared';
|
||||
import { isRegistryPrimary } from './registry-primary-flag.js';
|
||||
import { isVerboseIngestionEnabled } from './utils/verbose.js';
|
||||
import {
|
||||
deferredCallFileSlowMs,
|
||||
deferredCallLogEveryN,
|
||||
getDeferredProfileDroppedCount,
|
||||
isDeferredResolutionProfileEnabled,
|
||||
logDeferredProfile,
|
||||
profileElapsedMs,
|
||||
resetDeferredProfileDroppedCount,
|
||||
startTimer,
|
||||
} from './utils/deferred-resolution-profile.js';
|
||||
import { yieldToEventLoop } from './utils/event-loop.js';
|
||||
import { parseSourceSafe } from '../tree-sitter/safe-parse.js';
|
||||
import {
|
||||
|
|
@ -2909,6 +2919,39 @@ export const processCallsFromExtracted = async (
|
|||
}
|
||||
const totalFiles = byFile.size;
|
||||
let filesProcessed = 0;
|
||||
// Counts only files that survived the registry-primary skip — what the user
|
||||
// is actually waiting on. Keyed by this counter, the first per-file progress
|
||||
// log fires on the first *resolved* file rather than file #1 of byFile,
|
||||
// which would silently land inside the skip block on mixed Python+JVM repos
|
||||
// where the skipped language sorts first.
|
||||
let resolvedFiles = 0;
|
||||
const profileCalls = isDeferredResolutionProfileEnabled();
|
||||
const slowFileMs = profileCalls ? deferredCallFileSlowMs() : 0;
|
||||
const logEveryN = profileCalls ? deferredCallLogEveryN() : 0;
|
||||
let skippedRegistryPrimaryFiles = 0;
|
||||
|
||||
// Fresh dropped-log counter per analyze run — the module-private counter
|
||||
// in deferred-resolution-profile.ts is process-lived, so without a reset
|
||||
// here it would accumulate across consecutive analyze invocations in the
|
||||
// same Node process (e.g., the MCP server, eval harness, integration
|
||||
// tests).
|
||||
if (profileCalls) resetDeferredProfileDroppedCount();
|
||||
|
||||
// One-pass pre-count of the eventual non-skipped total so the live progress
|
||||
// denominator stays stable as the loop iterates. Otherwise `${totalFiles -
|
||||
// skippedRegistryPrimaryFiles}` drifts upward — files iterated before later
|
||||
// registry-primary skips have been seen carry an inflated denominator, and
|
||||
// the ratio only self-corrects after every file has been classified. Pre-
|
||||
// count runs only on the enabled path so the disabled path stays free of
|
||||
// the extra Map iteration. Defaults to 0 on the disabled path; the live log
|
||||
// gate is also disabled there, so the value is never read.
|
||||
let resolvedTotal = 0;
|
||||
if (profileCalls) {
|
||||
for (const filePath of byFile.keys()) {
|
||||
const lang = getLanguageFromFilename(filePath);
|
||||
if (!lang || !isRegistryPrimary(lang)) resolvedTotal++;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [filePath, calls] of byFile) {
|
||||
filesProcessed++;
|
||||
|
|
@ -2920,7 +2963,19 @@ export const processCallsFromExtracted = async (
|
|||
// Registry-primary gate: skip Python (etc.) entirely when the
|
||||
// scope-based phase owns CALLS for this language.
|
||||
const fileLanguage = getLanguageFromFilename(filePath);
|
||||
if (fileLanguage && isRegistryPrimary(fileLanguage)) continue;
|
||||
if (fileLanguage && isRegistryPrimary(fileLanguage)) {
|
||||
skippedRegistryPrimaryFiles++;
|
||||
continue;
|
||||
}
|
||||
|
||||
resolvedFiles++;
|
||||
const tFile = startTimer(profileCalls);
|
||||
|
||||
if (profileCalls && (resolvedFiles === 1 || resolvedFiles % logEveryN === 0)) {
|
||||
logDeferredProfile(
|
||||
`calls ${resolvedFiles}/${resolvedTotal} file=${filePath} sites=${calls.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
ctx.enableCache(filePath);
|
||||
const widenCache: WidenCache = new Map();
|
||||
|
|
@ -3079,6 +3134,25 @@ export const processCallsFromExtracted = async (
|
|||
}
|
||||
|
||||
ctx.clearCache();
|
||||
|
||||
if (tFile !== null) {
|
||||
const elapsed = profileElapsedMs(tFile);
|
||||
if (elapsed >= slowFileMs) {
|
||||
logDeferredProfile(
|
||||
`slow file ${elapsed.toFixed(0)}ms path=${filePath} calls=${calls.length} lang=${fileLanguage ?? 'unknown'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (profileCalls) {
|
||||
logDeferredProfile(
|
||||
`processCallsFromExtracted done: ${totalFiles} files, ${extractedCalls.length} call sites, skipped registry-primary files=${skippedRegistryPrimaryFiles}`,
|
||||
);
|
||||
const droppedCount = getDeferredProfileDroppedCount();
|
||||
if (droppedCount > 0) {
|
||||
logDeferredProfile(`note: ${droppedCount} profile log lines dropped (logger errors)`);
|
||||
}
|
||||
}
|
||||
|
||||
onProgress?.(totalFiles, totalFiles);
|
||||
|
|
|
|||
|
|
@ -23,6 +23,21 @@ export interface FilePath {
|
|||
}
|
||||
|
||||
const READ_CONCURRENCY = 32;
|
||||
const ANALYZE_PROGRESS_ACTIVE_ENV = 'GITNEXUS_ANALYZE_PROGRESS_ACTIVE';
|
||||
|
||||
const warnLargeFileSkip = (message: string): void => {
|
||||
if (process.env[ANALYZE_PROGRESS_ACTIVE_ENV] === '1') {
|
||||
// analyze.ts routes console.warn through the progress bar logger while
|
||||
// the bar is active. Emitting the operator-facing large-file notice there
|
||||
// avoids raw pino NDJSON corrupting the one-line progress display in the
|
||||
// heap-respawn child, whose stderr is intentionally piped for crash
|
||||
// classification.
|
||||
// eslint-disable-next-line no-console -- intentionally routed by analyze progress UI
|
||||
console.warn(message);
|
||||
return;
|
||||
}
|
||||
logger.warn(message);
|
||||
};
|
||||
|
||||
/**
|
||||
* Phase 1: Scan repository — stat files to get paths + sizes, no content loaded.
|
||||
|
|
@ -74,12 +89,35 @@ export const walkRepositoryPaths = async (
|
|||
|
||||
if (skippedLarge > 0) {
|
||||
const isDefault = maxFileSizeBytes === DEFAULT_MAX_FILE_SIZE_BYTES;
|
||||
const isOverrideUnset = !process.env.GITNEXUS_MAX_FILE_SIZE;
|
||||
const suffix = isDefault ? ', likely generated/vendored' : '';
|
||||
logger.warn(` Skipped ${skippedLarge} large files (>${maxFileSizeBytes / 1024}KB${suffix})`);
|
||||
if (isVerboseIngestionEnabled()) {
|
||||
for (const p of skippedLargePaths) {
|
||||
logger.warn(` - ${p}`);
|
||||
}
|
||||
warnLargeFileSkip(
|
||||
` Skipped ${skippedLarge} large files (>${maxFileSizeBytes / 1024}KB${suffix})`,
|
||||
);
|
||||
|
||||
// Always show at least the first few paths so users can diagnose why
|
||||
// edges are missing from a specific file (issue #1659). The full list is
|
||||
// gated behind GITNEXUS_VERBOSE=1 to avoid flooding output on repos with
|
||||
// many generated/vendored blobs. Sort before slicing so the preview is
|
||||
// stable across runs (fs.stat callbacks race within each batch).
|
||||
skippedLargePaths.sort();
|
||||
const SKIPPED_PREVIEW_CAP = 5;
|
||||
const showAll = isVerboseIngestionEnabled() || skippedLargePaths.length <= SKIPPED_PREVIEW_CAP;
|
||||
const preview = showAll ? skippedLargePaths : skippedLargePaths.slice(0, SKIPPED_PREVIEW_CAP);
|
||||
for (const p of preview) {
|
||||
warnLargeFileSkip(` - ${p}`);
|
||||
}
|
||||
if (!showAll) {
|
||||
const remaining = skippedLargePaths.length - SKIPPED_PREVIEW_CAP;
|
||||
warnLargeFileSkip(` ...and ${remaining} more (set GITNEXUS_VERBOSE=1 to list them all)`);
|
||||
}
|
||||
// Only hint about the env var when the user has not set it at all. An
|
||||
// explicit GITNEXUS_MAX_FILE_SIZE=512 happens to resolve to the same
|
||||
// bytes as the default but the operator clearly already knows the knob.
|
||||
if (isDefault && isOverrideUnset) {
|
||||
warnLargeFileSkip(
|
||||
` Set GITNEXUS_MAX_FILE_SIZE=<KB> to include files above the default cap.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -231,6 +231,7 @@ const cCppExtractFunctionName = (
|
|||
c?.type === 'qualified_identifier' ||
|
||||
c?.type === 'identifier' ||
|
||||
c?.type === 'field_identifier' ||
|
||||
c?.type === 'operator_name' ||
|
||||
c?.type === 'parenthesized_declarator'
|
||||
) {
|
||||
innerDeclarator = c;
|
||||
|
|
@ -244,7 +245,7 @@ const cCppExtractFunctionName = (
|
|||
if (!nameNode) {
|
||||
for (let i = 0; i < innerDeclarator.childCount; i++) {
|
||||
const c = innerDeclarator.child(i);
|
||||
if (c?.type === 'identifier') {
|
||||
if (c?.type === 'identifier' || c?.type === 'operator_name') {
|
||||
nameNode = c;
|
||||
break;
|
||||
}
|
||||
|
|
@ -256,7 +257,8 @@ const cCppExtractFunctionName = (
|
|||
}
|
||||
} else if (
|
||||
innerDeclarator?.type === 'identifier' ||
|
||||
innerDeclarator?.type === 'field_identifier'
|
||||
innerDeclarator?.type === 'field_identifier' ||
|
||||
innerDeclarator?.type === 'operator_name'
|
||||
) {
|
||||
// field_identifier is used for method names inside C++ class bodies
|
||||
funcName = innerDeclarator.text;
|
||||
|
|
@ -275,7 +277,7 @@ const cCppExtractFunctionName = (
|
|||
if (!nameNode) {
|
||||
for (let i = 0; i < nestedId.childCount; i++) {
|
||||
const c = nestedId.child(i);
|
||||
if (c?.type === 'identifier') {
|
||||
if (c?.type === 'identifier' || c?.type === 'operator_name') {
|
||||
nameNode = c;
|
||||
break;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -182,17 +182,41 @@ export function emitCppScopeCaptures(
|
|||
grouped['@reference.call.free'] ??
|
||||
grouped['@reference.call.member'] ??
|
||||
grouped['@reference.call.qualified'];
|
||||
const operatorAnchor = grouped['@reference.operator'];
|
||||
if (operatorAnchor !== undefined) {
|
||||
const operatorNode =
|
||||
callAnchor !== undefined
|
||||
? findNodeAtRange(tree.rootNode, callAnchor.range, 'binary_expression')
|
||||
: null;
|
||||
if (operatorNode !== null && isPrimitiveOnlyBinaryOperator(operatorNode)) continue;
|
||||
}
|
||||
if (callAnchor !== undefined && grouped['@reference.arity'] === undefined) {
|
||||
const callNode = findNodeAtRange(tree.rootNode, callAnchor.range, 'call_expression');
|
||||
if (callNode !== null) {
|
||||
const callNode =
|
||||
findNodeAtRange(tree.rootNode, callAnchor.range, 'call_expression') ??
|
||||
findNodeAtRange(tree.rootNode, callAnchor.range, 'binary_expression');
|
||||
if (callNode?.type === 'call_expression') {
|
||||
grouped['@reference.arity'] = syntheticCapture(
|
||||
'@reference.arity',
|
||||
callNode,
|
||||
String(computeCppCallArity(callNode)),
|
||||
);
|
||||
} else if (callNode?.type === 'binary_expression') {
|
||||
grouped['@reference.arity'] = syntheticCapture(
|
||||
'@reference.arity',
|
||||
callNode,
|
||||
grouped['@reference.call.member'] !== undefined ? '1' : '2',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (operatorAnchor !== undefined && grouped['@reference.name'] === undefined) {
|
||||
grouped['@reference.name'] = syntheticCapture(
|
||||
'@reference.name',
|
||||
findNodeAtRange(tree.rootNode, operatorAnchor.range, operatorAnchor.text) ?? tree.rootNode,
|
||||
`operator${operatorAnchor.text}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Enrich constructor calls (new Foo()) with arity ─────────────
|
||||
const ctorCallAnchor = grouped['@reference.call.constructor'];
|
||||
if (ctorCallAnchor !== undefined && grouped['@reference.arity'] === undefined) {
|
||||
|
|
@ -211,9 +235,13 @@ export function emitCppScopeCaptures(
|
|||
if (anyCallAnchor !== undefined && grouped['@reference.parameter-types'] === undefined) {
|
||||
const cNode =
|
||||
findNodeAtRange(tree.rootNode, anyCallAnchor.range, 'call_expression') ??
|
||||
findNodeAtRange(tree.rootNode, anyCallAnchor.range, 'new_expression');
|
||||
findNodeAtRange(tree.rootNode, anyCallAnchor.range, 'new_expression') ??
|
||||
findNodeAtRange(tree.rootNode, anyCallAnchor.range, 'binary_expression');
|
||||
if (cNode !== null) {
|
||||
const argTypes = inferCppCallArgTypes(cNode);
|
||||
const argTypes =
|
||||
cNode.type === 'binary_expression'
|
||||
? inferCppBinaryOperatorArgTypes(cNode, grouped['@reference.call.free'] !== undefined)
|
||||
: inferCppCallArgTypes(cNode);
|
||||
if (argTypes !== undefined && argTypes.length > 0) {
|
||||
grouped['@reference.parameter-types'] = syntheticCapture(
|
||||
'@reference.parameter-types',
|
||||
|
|
@ -221,7 +249,13 @@ export function emitCppScopeCaptures(
|
|||
JSON.stringify(argTypes),
|
||||
);
|
||||
}
|
||||
const argTypeClasses = inferCppCallArgTypeClasses(cNode);
|
||||
const argTypeClasses =
|
||||
cNode.type === 'binary_expression'
|
||||
? inferCppBinaryOperatorArgTypeClasses(
|
||||
cNode,
|
||||
grouped['@reference.call.free'] !== undefined,
|
||||
)
|
||||
: inferCppCallArgTypeClasses(cNode);
|
||||
if (argTypeClasses !== undefined && argTypeClasses.length > 0) {
|
||||
grouped['@reference.parameter-type-classes'] = syntheticCapture(
|
||||
'@reference.parameter-type-classes',
|
||||
|
|
@ -716,6 +750,69 @@ function inferCppCallArgTypeClasses(node: SyntaxNode): ParameterTypeClass[] | un
|
|||
return classes.length > 0 ? classes : undefined;
|
||||
}
|
||||
|
||||
function inferCppBinaryOperatorArgTypes(
|
||||
node: SyntaxNode,
|
||||
includeLeftOperand: boolean,
|
||||
): string[] | undefined {
|
||||
const operands = binaryOperatorOperands(node, includeLeftOperand);
|
||||
if (operands.length === 0) return undefined;
|
||||
const types = operands.map(inferCppExpressionType);
|
||||
return types.length > 0 ? types : undefined;
|
||||
}
|
||||
|
||||
function inferCppBinaryOperatorArgTypeClasses(
|
||||
node: SyntaxNode,
|
||||
includeLeftOperand: boolean,
|
||||
): ParameterTypeClass[] | undefined {
|
||||
const operands = binaryOperatorOperands(node, includeLeftOperand);
|
||||
if (operands.length === 0) return undefined;
|
||||
const classes = operands.map(inferCppExpressionTypeClass);
|
||||
return classes.length > 0 ? classes : undefined;
|
||||
}
|
||||
|
||||
function binaryOperatorOperands(node: SyntaxNode, includeLeftOperand: boolean): SyntaxNode[] {
|
||||
const operands: SyntaxNode[] = [];
|
||||
const left = node.childForFieldName('left');
|
||||
const right = node.childForFieldName('right');
|
||||
if (includeLeftOperand && left !== null) operands.push(left);
|
||||
if (right !== null) operands.push(right);
|
||||
return operands;
|
||||
}
|
||||
|
||||
function isPrimitiveOnlyBinaryOperator(node: SyntaxNode): boolean {
|
||||
const operands = binaryOperatorOperands(node, true);
|
||||
return operands.length > 0 && operands.every((operand) => isBuiltinOperatorType(operand));
|
||||
}
|
||||
|
||||
function isBuiltinOperatorType(node: SyntaxNode): boolean {
|
||||
const type = inferCppExpressionType(node);
|
||||
return (
|
||||
type === 'bool' ||
|
||||
type === 'char' ||
|
||||
type === 'double' ||
|
||||
type === 'float' ||
|
||||
type === 'int' ||
|
||||
type === 'long' ||
|
||||
type === 'short' ||
|
||||
type === 'signed' ||
|
||||
type === 'unsigned'
|
||||
);
|
||||
}
|
||||
|
||||
function inferCppExpressionType(node: SyntaxNode): string {
|
||||
const litType = inferCppLiteralType(node);
|
||||
if (litType !== '') return litType;
|
||||
if (node.type === 'identifier') return lookupDeclaredTypeForIdentifier(node);
|
||||
return '';
|
||||
}
|
||||
|
||||
function inferCppExpressionTypeClass(node: SyntaxNode): ParameterTypeClass {
|
||||
const litType = inferCppLiteralType(node);
|
||||
if (litType !== '') return valueTypeClass(litType);
|
||||
if (node.type === 'identifier') return lookupDeclaredTypeClassForIdentifier(node);
|
||||
return unknownTypeClass('unknown');
|
||||
}
|
||||
|
||||
function valueTypeClass(base: string): ParameterTypeClass {
|
||||
return { base, cv: 'none', indirection: 'value', pointerDepth: 0 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,32 +1,30 @@
|
|||
/**
|
||||
* C++ conversion-rank scoring for overload resolution (#1578).
|
||||
* C++ conversion-rank scoring for overload resolution (#1578, #1637).
|
||||
*
|
||||
* Operates on **normalized** type strings (output of
|
||||
* `normalizeCppParamType` in `arity-metadata.ts`). After normalization:
|
||||
* - int/long/short/unsigned → 'int'
|
||||
* - float/double → 'double'
|
||||
* - char → 'char', bool → 'bool'
|
||||
*
|
||||
* Because the normalizer collapses promotion pairs (int↔long,
|
||||
* float↔double) to the same string, those promotions are invisible at
|
||||
* this layer — they appear as exact matches (rank 0).
|
||||
* Operates on normalized type strings (output of `normalizeCppParamType`
|
||||
* in `arity-metadata.ts`) plus optional shape sidecars from #1630.
|
||||
* Normalization intentionally collapses cv/ref/pointer spelling for stable
|
||||
* graph IDs, so pointer/nullptr rules must consult `ParameterTypeClass`.
|
||||
*
|
||||
* Post-normalization ranking:
|
||||
* - rank 0 — exact (same normalized type)
|
||||
* - rank 1 — integral promotion (char→int, bool→int)
|
||||
* - rank 2 — standard arithmetic conversion (int↔double, char→double,
|
||||
* bool→double)
|
||||
* - Infinity — mismatch (string↔int, user types, pointers, etc.)
|
||||
* - rank 0: exact (same normalized type)
|
||||
* - rank 1: integral promotion (char -> int, bool -> int)
|
||||
* - rank 2: standard conversion (arithmetic, nullptr -> T*, T* -> bool,
|
||||
* T* -> void*)
|
||||
* - rank 3: nullptr -> bool (kept worse than nullptr -> T*)
|
||||
* - rank 4: ellipsis conversion (worst viable)
|
||||
* - Infinity: mismatch (string -> int, user types, unsupported shapes)
|
||||
*
|
||||
* This function is intentionally C++-specific (issue #1578 pitfall:
|
||||
* keep conversion-rank tables out of shared overload-narrowing). Other
|
||||
* languages may define their own `ConversionRankFn` in the future.
|
||||
* This function is intentionally C++-specific. Other languages may define
|
||||
* their own `ConversionRankFn` in the future.
|
||||
*/
|
||||
|
||||
import type { ParameterTypeClass } from 'gitnexus-shared';
|
||||
|
||||
/** Set of normalized arithmetic types that support implicit conversion. */
|
||||
const ARITHMETIC = new Set(['int', 'double', 'char', 'bool']);
|
||||
|
||||
/** Integral promotion targets: char→int and bool→int are rank 1. */
|
||||
/** Integral promotion targets: char -> int and bool -> int are rank 1. */
|
||||
const INTEGRAL_PROMOTION = new Map([
|
||||
['char', 'int'],
|
||||
['bool', 'int'],
|
||||
|
|
@ -35,13 +33,40 @@ const INTEGRAL_PROMOTION = new Map([
|
|||
/**
|
||||
* Return the conversion rank from `argType` to `paramType`.
|
||||
*
|
||||
* @returns 0 for exact match, 1 for integral promotion (char/bool→int),
|
||||
* 2 for standard arithmetic conversion, Infinity for mismatch.
|
||||
* @returns 0 for exact match, 1 for integral promotion, 2 for standard
|
||||
* conversion, 3 for nullptr -> bool, 4 for ellipsis, Infinity
|
||||
* for mismatch.
|
||||
*/
|
||||
export function cppConversionRank(argType: string, paramType: string): number {
|
||||
if (argType === paramType) return 0;
|
||||
// Integral promotions: char→int, bool→int (ISO C++ [conv.prom])
|
||||
export function cppConversionRank(
|
||||
argType: string,
|
||||
paramType: string,
|
||||
argTypeClass?: ParameterTypeClass,
|
||||
paramTypeClass?: ParameterTypeClass,
|
||||
): number {
|
||||
if (argType === paramType) {
|
||||
return exactShapeCompatible(argTypeClass, paramTypeClass) ? 0 : Infinity;
|
||||
}
|
||||
if (paramType === '...') return 4;
|
||||
if (INTEGRAL_PROMOTION.get(argType) === paramType) return 1;
|
||||
if (ARITHMETIC.has(argType) && ARITHMETIC.has(paramType)) return 2;
|
||||
if (argType === 'null' && isPointer(paramTypeClass)) return 2;
|
||||
if (argType === 'null' && paramType === 'bool') return 3;
|
||||
if (isPointer(argTypeClass) && paramType === 'bool') return 2;
|
||||
if (isPointer(argTypeClass) && isPointer(paramTypeClass) && paramType === 'void') return 2;
|
||||
return Infinity;
|
||||
}
|
||||
|
||||
function isPointer(typeClass: ParameterTypeClass | undefined): boolean {
|
||||
return typeClass?.indirection === 'pointer' && typeClass.pointerDepth > 0;
|
||||
}
|
||||
|
||||
function exactShapeCompatible(
|
||||
argTypeClass: ParameterTypeClass | undefined,
|
||||
paramTypeClass: ParameterTypeClass | undefined,
|
||||
): boolean {
|
||||
if (argTypeClass === undefined || paramTypeClass === undefined) return true;
|
||||
if (argTypeClass.indirection === 'unknown' || paramTypeClass.indirection === 'unknown') {
|
||||
return true;
|
||||
}
|
||||
return isPointer(argTypeClass) === isPointer(paramTypeClass);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,6 +98,12 @@ const CPP_SCOPE_QUERY = `
|
|||
declarator: (qualified_identifier
|
||||
name: (identifier) @declaration.name))) @declaration.method
|
||||
|
||||
;; Out-of-class operator method: Point::operator+(...)
|
||||
(function_definition
|
||||
declarator: (function_declarator
|
||||
declarator: (qualified_identifier
|
||||
name: (operator_name) @declaration.name))) @declaration.method
|
||||
|
||||
;; ─── Declarations — out-of-class method with pointer return ─────────
|
||||
(function_definition
|
||||
declarator: (pointer_declarator
|
||||
|
|
@ -130,6 +136,11 @@ const CPP_SCOPE_QUERY = `
|
|||
declarator: (function_declarator
|
||||
declarator: (field_identifier) @declaration.name)) @declaration.method
|
||||
|
||||
;; Inline operator method in class body: Point operator+(Point) const { ... }
|
||||
(function_definition
|
||||
declarator: (function_declarator
|
||||
declarator: (operator_name) @declaration.name)) @declaration.method
|
||||
|
||||
;; ─── Declarations — inline method with pointer return (field_identifier) ──
|
||||
;; Covers: User* lookup(int id) { ... } inside a class body
|
||||
;; AST: function_definition > pointer_declarator > function_declarator > field_identifier
|
||||
|
|
@ -145,17 +156,49 @@ const CPP_SCOPE_QUERY = `
|
|||
(function_declarator
|
||||
declarator: (field_identifier) @declaration.name))) @declaration.method
|
||||
|
||||
;; Inline operator method with reference return: Point& operator+=(Point) { ... }
|
||||
(field_declaration_list
|
||||
(function_definition
|
||||
declarator: (reference_declarator
|
||||
(function_declarator
|
||||
declarator: (operator_name) @declaration.name))) @declaration.method)
|
||||
|
||||
;; Free operator definition with reference return: std::ostream& operator<<(...) { ... }
|
||||
(translation_unit
|
||||
(function_definition
|
||||
declarator: (reference_declarator
|
||||
(function_declarator
|
||||
declarator: (operator_name) @declaration.name))) @declaration.function)
|
||||
|
||||
(namespace_definition
|
||||
body: (declaration_list
|
||||
(function_definition
|
||||
declarator: (reference_declarator
|
||||
(function_declarator
|
||||
declarator: (operator_name) @declaration.name))) @declaration.function))
|
||||
|
||||
;; ─── Declarations — function prototype (forward declaration) ────────
|
||||
(declaration
|
||||
declarator: (function_declarator
|
||||
declarator: (identifier) @declaration.name)) @declaration.function
|
||||
|
||||
;; Free operator prototype: std::ostream& operator<<(std::ostream&, T)
|
||||
(declaration
|
||||
declarator: (function_declarator
|
||||
declarator: (operator_name) @declaration.name)) @declaration.function
|
||||
|
||||
;; ─── Declarations — function prototype with pointer return ──────────
|
||||
(declaration
|
||||
declarator: (pointer_declarator
|
||||
declarator: (function_declarator
|
||||
declarator: (identifier) @declaration.name))) @declaration.function
|
||||
|
||||
;; Free operator prototype with reference return.
|
||||
(declaration
|
||||
declarator: (reference_declarator
|
||||
(function_declarator
|
||||
declarator: (operator_name) @declaration.name))) @declaration.function
|
||||
|
||||
;; ─── Declarations — typedef ─────────────────────────────────────────
|
||||
(type_definition
|
||||
declarator: (type_identifier) @declaration.name) @declaration.typedef
|
||||
|
|
@ -171,6 +214,11 @@ const CPP_SCOPE_QUERY = `
|
|||
declarator: (function_declarator
|
||||
declarator: (field_identifier) @declaration.name)) @declaration.method
|
||||
|
||||
;; Operator method prototype in class body: Point operator+(Point) const;
|
||||
(field_declaration
|
||||
declarator: (function_declarator
|
||||
declarator: (operator_name) @declaration.name)) @declaration.method
|
||||
|
||||
;; Method prototype with pointer return: User* lookup(int id);
|
||||
(field_declaration
|
||||
declarator: (pointer_declarator
|
||||
|
|
@ -183,6 +231,11 @@ const CPP_SCOPE_QUERY = `
|
|||
(function_declarator
|
||||
declarator: (field_identifier) @declaration.name))) @declaration.method
|
||||
|
||||
(field_declaration
|
||||
declarator: (reference_declarator
|
||||
(function_declarator
|
||||
declarator: (operator_name) @declaration.name))) @declaration.method
|
||||
|
||||
;; ─── Declarations — fields ──────────────────────────────────────────
|
||||
(field_declaration
|
||||
declarator: (field_identifier) @declaration.name) @declaration.field
|
||||
|
|
@ -473,6 +526,22 @@ const CPP_SCOPE_QUERY = `
|
|||
argument: (_) @reference.receiver
|
||||
field: (field_identifier) @reference.name)) @reference.call.member
|
||||
|
||||
;; Conservative operator-call support (#1636): model a + b as a
|
||||
;; member-style operator+ lookup, and lhs << rhs as a free
|
||||
;; operator<< lookup. Free operator+(T,T), member operator<<, and
|
||||
;; complex operand expressions remain false negatives for now.
|
||||
;; Built-in operators remain unresolved because no user-defined
|
||||
;; operator target exists.
|
||||
(binary_expression
|
||||
left: (_) @reference.receiver
|
||||
operator: "+" @reference.operator
|
||||
right: (_)) @reference.call.member
|
||||
|
||||
(binary_expression
|
||||
left: (_)
|
||||
operator: "<<" @reference.operator
|
||||
right: (_)) @reference.call.free
|
||||
|
||||
;; ─── References — template calls (func<T>()) ────────────────────────
|
||||
(call_expression
|
||||
function: (template_function
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import { javaMethodConfig } from '../method-extractors/configs/jvm.js';
|
|||
import { createVariableExtractor } from '../variable-extractors/generic.js';
|
||||
import { javaVariableConfig } from '../variable-extractors/configs/jvm.js';
|
||||
import { createHeritageExtractor } from '../heritage-extractors/generic.js';
|
||||
import type { SymbolDefinition } from 'gitnexus-shared';
|
||||
import {
|
||||
emitJavaScopeCaptures,
|
||||
interpretJavaImport,
|
||||
|
|
@ -39,6 +40,48 @@ import {
|
|||
resolveJavaImportTarget,
|
||||
} from './java/index.js';
|
||||
|
||||
const orderJavaSameNameTypeCandidates = ({
|
||||
callSiteFilePath,
|
||||
candidates,
|
||||
}: {
|
||||
readonly typeName: string;
|
||||
readonly callSiteFilePath: string;
|
||||
readonly candidates: readonly SymbolDefinition[];
|
||||
}): readonly SymbolDefinition[] | null => {
|
||||
if (!callSiteFilePath.endsWith('.java')) return null;
|
||||
if (candidates.length <= 1) return null;
|
||||
const callerDir = splitDirectorySegments(callSiteFilePath);
|
||||
|
||||
const scored = candidates.map((candidate, index) => ({
|
||||
candidate,
|
||||
index,
|
||||
score: sharedPrefixLength(callerDir, splitDirectorySegments(candidate.filePath)),
|
||||
}));
|
||||
const bestScore = Math.max(...scored.map((entry) => entry.score));
|
||||
// When all candidates tie, we have no structural signal to prefer one path.
|
||||
// Returning null keeps downstream ambiguity handling conservative.
|
||||
if (scored.every((entry) => entry.score === bestScore)) return null;
|
||||
|
||||
const ordered = [...scored]
|
||||
.sort((a, b) => b.score - a.score || a.index - b.index)
|
||||
.map((entry) => entry.candidate);
|
||||
return ordered;
|
||||
};
|
||||
|
||||
const splitDirectorySegments = (filePath: string): string[] => {
|
||||
const normalized = filePath.replace(/\\/g, '/');
|
||||
// Remove empty segments from leading/trailing/multiple slashes, then drop filename.
|
||||
const segments = normalized.split('/').filter(Boolean);
|
||||
return segments.slice(0, -1);
|
||||
};
|
||||
|
||||
const sharedPrefixLength = (left: readonly string[], right: readonly string[]): number => {
|
||||
const max = Math.min(left.length, right.length);
|
||||
let idx = 0;
|
||||
while (idx < max && left[idx] === right[idx]) idx += 1;
|
||||
return idx;
|
||||
};
|
||||
|
||||
export const javaProvider = defineLanguage({
|
||||
id: SupportedLanguages.Java,
|
||||
extensions: ['.java'],
|
||||
|
|
@ -87,4 +130,5 @@ export const javaProvider = defineLanguage({
|
|||
receiverBinding: javaReceiverBinding,
|
||||
arityCompatibility: javaArityCompatibility,
|
||||
resolveImportTarget: resolveJavaImportTarget,
|
||||
orderSameNameTypeCandidates: orderJavaSameNameTypeCandidates,
|
||||
});
|
||||
|
|
|
|||
12
gitnexus/src/core/ingestion/languages/javascript/arity.ts
Normal file
12
gitnexus/src/core/ingestion/languages/javascript/arity.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
/**
|
||||
* Arity compatibility for JavaScript.
|
||||
*
|
||||
* Delegates to `typescriptArityCompatibility` unchanged — JavaScript
|
||||
* supports the same arity constructs (rest parameters `...args`, default
|
||||
* parameters `p = v`) and the metadata shape (`parameterCount`,
|
||||
* `requiredParameterCount`, `parameterTypes`) is synthesized by the same
|
||||
* `computeTsArityMetadata` function (which understands both TS and JS
|
||||
* parameter node types via `extractTsJsParameters`).
|
||||
*/
|
||||
|
||||
export { typescriptArityCompatibility as jsArityCompatibility } from '../typescript/arity.js';
|
||||
722
gitnexus/src/core/ingestion/languages/javascript/captures.ts
Normal file
722
gitnexus/src/core/ingestion/languages/javascript/captures.ts
Normal file
|
|
@ -0,0 +1,722 @@
|
|||
/**
|
||||
* `emitScopeCaptures` for JavaScript.
|
||||
*
|
||||
* Adapts `emitTsScopeCaptures` for the JavaScript grammar:
|
||||
*
|
||||
* 1. **JS grammar** — uses `tree-sitter-javascript` instead of
|
||||
* `tree-sitter-typescript`. The JS scope query is a subset of the
|
||||
* TypeScript one (TypeScript-only node types dropped).
|
||||
*
|
||||
* 2. **CJS `require()` decomposition** — `const { X } = require('./m')`
|
||||
* and `const X = require('./m')` are walked in a post-query pass and
|
||||
* synthesized as `@import.kind/name/alias/source` markers so that
|
||||
* `interpretJsImport` can recover a `ParsedImport` using the same
|
||||
* shape as the TypeScript ESM decomposer.
|
||||
*
|
||||
* 3. **JSDoc type bindings** — JavaScript has no static type annotations
|
||||
* so `@type-binding.parameter` / `@type-binding.return` must be
|
||||
* inferred from leading JSDoc comments. A lightweight regex scanner
|
||||
* (`parseJsDocParams` / `parseJsDocReturn`) extracts `@param {T} n`
|
||||
* and `@returns {T}` tags and emits synthetic captures positioned on
|
||||
* the annotated function node.
|
||||
*
|
||||
* 4. **Shared synthesis passes** — destructuring, for-of map-tuple, and
|
||||
* instanceof narrowing passes are duplicated from `typescript/captures.ts`
|
||||
* (they are pure AST operations with no grammar-specific logic).
|
||||
*
|
||||
* 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 '../typescript/import-decomposer.js';
|
||||
import { getJsParser, getJsScopeQuery, jsCachedTreeMatchesGrammar } from './query.js';
|
||||
import { computeTsArityMetadata } from '../typescript/arity-metadata.js';
|
||||
import { synthesizeTsReceiverBinding } from '../typescript/receiver-binding.js';
|
||||
import { getTreeSitterBufferSize } from '../../constants.js';
|
||||
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
|
||||
|
||||
/** JS function-like node types that may carry a synthesized `this` binding.
|
||||
* Kept in sync with the `@scope.function` patterns in `query.ts`. */
|
||||
const FUNCTION_NODE_TYPES = [
|
||||
'method_definition',
|
||||
'arrow_function',
|
||||
'function_expression',
|
||||
'function_declaration',
|
||||
'generator_function_declaration',
|
||||
] 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;
|
||||
}
|
||||
|
||||
/** Filter `@reference.read.member` in non-read contexts (same logic as TS). */
|
||||
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;
|
||||
case 'jsx_self_closing_element':
|
||||
case 'jsx_opening_element':
|
||||
return parent.childForFieldName('name')?.id !== memberNode.id;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Find the first JS function-like node at the given range. */
|
||||
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;
|
||||
}
|
||||
|
||||
/** Infer a callsite argument's static type from literal shapes. */
|
||||
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 '';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── CJS require() decomposition ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Walk the AST and synthesize `@import.*` captures for CJS `require()` calls:
|
||||
*
|
||||
* - `const { X, Y } = require('./m')` → one match per destructured name,
|
||||
* `@import.kind = 'named'`, `@import.name = X / Y`.
|
||||
* - `const X = require('./m')` → `@import.kind = 'namespace'`,
|
||||
* `@import.alias = X` (the whole module is bound to X).
|
||||
* - `require('./m')` as a bare expression-statement → side-effect.
|
||||
*
|
||||
* CJS named-alias form (`const { X: alias } = require('./m')`) emits
|
||||
* `@import.kind = 'named-alias'` with `@import.name = X` and
|
||||
* `@import.alias = alias`.
|
||||
*
|
||||
* The synthesized markers are identical to those produced by
|
||||
* `splitImportStatement` for ESM, so `interpretJsImport` can delegate
|
||||
* unchanged to `interpretTsImport` for all cases.
|
||||
*/
|
||||
function synthesizeCjsImports(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 !== 'call_expression') continue;
|
||||
|
||||
// Require call: function must be bare identifier "require".
|
||||
const fn = node.childForFieldName('function');
|
||||
if (fn === null || fn.type !== 'identifier' || fn.text !== 'require') continue;
|
||||
|
||||
const argsNode = node.childForFieldName('arguments');
|
||||
if (argsNode === null) continue;
|
||||
|
||||
// Source must be a string literal.
|
||||
const firstArg = argsNode.namedChild(0);
|
||||
if (firstArg === null || firstArg.type !== 'string') continue;
|
||||
const rawSource = firstArg.text; // includes surrounding quotes
|
||||
const source = firstArg.namedChild(0)?.text ?? rawSource.slice(1, -1);
|
||||
|
||||
const parent = node.parent;
|
||||
|
||||
// Case 1: const { X } = require('./m') OR const X = require('./m')
|
||||
if (parent?.type === 'variable_declarator') {
|
||||
const nameNode = parent.childForFieldName('name');
|
||||
if (nameNode === null) continue;
|
||||
|
||||
if (nameNode.type === 'object_pattern') {
|
||||
// Destructured: emit one match per specifier.
|
||||
for (const field of nameNode.namedChildren) {
|
||||
if (field === null) continue;
|
||||
if (field.type === 'shorthand_property_identifier_pattern') {
|
||||
const name = field.text;
|
||||
out.push({
|
||||
'@import.statement': syntheticCapture('@import.statement', node, rawSource),
|
||||
'@import.kind': syntheticCapture('@import.kind', node, 'named'),
|
||||
'@import.name': syntheticCapture('@import.name', field, name),
|
||||
'@import.source': syntheticCapture('@import.source', firstArg, source),
|
||||
});
|
||||
} else if (field.type === 'pair_pattern') {
|
||||
const key = field.childForFieldName('key');
|
||||
const value = field.childForFieldName('value');
|
||||
if (key === null || value === null || value.type !== 'identifier') continue;
|
||||
out.push({
|
||||
'@import.statement': syntheticCapture('@import.statement', node, rawSource),
|
||||
'@import.kind': syntheticCapture('@import.kind', node, 'named-alias'),
|
||||
'@import.name': syntheticCapture('@import.name', key, key.text),
|
||||
'@import.alias': syntheticCapture('@import.alias', value, value.text),
|
||||
'@import.source': syntheticCapture('@import.source', firstArg, source),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (nameNode.type === 'identifier') {
|
||||
// Namespace-style: const X = require('./m') → bind whole module to X.
|
||||
out.push({
|
||||
'@import.statement': syntheticCapture('@import.statement', node, rawSource),
|
||||
'@import.kind': syntheticCapture('@import.kind', node, 'namespace'),
|
||||
'@import.alias': syntheticCapture('@import.alias', nameNode, nameNode.text),
|
||||
'@import.source': syntheticCapture('@import.source', firstArg, source),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Case 2: bare require('./m') — side-effect import.
|
||||
if (parent?.type === 'expression_statement') {
|
||||
out.push({
|
||||
'@import.statement': syntheticCapture('@import.statement', node, rawSource),
|
||||
'@import.kind': syntheticCapture('@import.kind', node, 'side-effect'),
|
||||
'@import.source': syntheticCapture('@import.source', firstArg, source),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── JSDoc type binding synthesis ────────────────────────────────────────
|
||||
|
||||
interface JsDocParam {
|
||||
readonly name: string;
|
||||
readonly type: string;
|
||||
}
|
||||
|
||||
/** Extract `@param {Type} name` entries from a JSDoc comment block. */
|
||||
function parseJsDocParams(text: string): readonly JsDocParam[] {
|
||||
const results: JsDocParam[] = [];
|
||||
// Match @param {Type} name or @param {Type} [name] (optional)
|
||||
const re = /@param\s+\{([^}]+)\}\s+\[?(\w+)\]?/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
results.push({ type: m[1].trim(), name: m[2].trim() });
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Extract `@returns {Type}` or `@return {Type}` from a JSDoc comment. */
|
||||
function parseJsDocReturn(text: string): string | null {
|
||||
const m = /@returns?\s+\{([^}]+)\}/.exec(text);
|
||||
return m ? m[1].trim() : null;
|
||||
}
|
||||
|
||||
/** Extract `@type {Type}` from a JSDoc comment (variable-level annotation). */
|
||||
function parseJsDocType(text: string): string | null {
|
||||
const m = /@type\s+\{([^}]+)\}/.exec(text);
|
||||
return m ? m[1].trim() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the AST and synthesize `@type-binding.*` captures from JSDoc
|
||||
* comments immediately preceding function declarations / expressions.
|
||||
*
|
||||
* Only `/** … */` block comments are scanned. Line comments (`//`) are
|
||||
* intentionally excluded — JSDoc lives in block comments.
|
||||
*
|
||||
* Emits:
|
||||
* - `@type-binding.parameter` for each `@param {T} n` tag.
|
||||
* - `@type-binding.return` for `@returns {T}` / `@return {T}`.
|
||||
* - `@type-binding.annotation` for `@type {T}` on `let`/`const`/`var`
|
||||
* declarations — covers the common `/** @type {User} */ const u = …`
|
||||
* pattern (ECMA-262 §14.3.1/§14.3.2 variable declarations).
|
||||
*
|
||||
* The binding is anchored on the function node so `tsBindingScopeFor`
|
||||
* can hoist method return-type bindings to Module scope (matching the
|
||||
* TypeScript path where `hoistTypeBindingsToModule: true`).
|
||||
*/
|
||||
function synthesizeJsDocBindings(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);
|
||||
}
|
||||
|
||||
const isFnDecl =
|
||||
node.type === 'function_declaration' || node.type === 'generator_function_declaration';
|
||||
const isMethodDef = node.type === 'method_definition';
|
||||
// Also check lexical_declaration containing an arrow/fn-expression
|
||||
const isLexDecl = node.type === 'lexical_declaration' || node.type === 'variable_declaration';
|
||||
|
||||
if (!isFnDecl && !isMethodDef && !isLexDecl) continue;
|
||||
|
||||
// For `export function foo() { ... }`, the JSDoc comment precedes the
|
||||
// wrapping export_statement, not the inner function_declaration.
|
||||
// Walk up to the export_statement so the preceding-sibling search finds it.
|
||||
const lookupNode =
|
||||
(isFnDecl || isLexDecl) && node.parent?.type === 'export_statement' ? node.parent : node;
|
||||
|
||||
// Find the preceding sibling comment.
|
||||
let sibling = lookupNode.previousNamedSibling;
|
||||
while (sibling !== null && sibling.type === 'comment') {
|
||||
const text = sibling.text;
|
||||
if (text.startsWith('/**')) {
|
||||
// Found a JSDoc block.
|
||||
const params = parseJsDocParams(text);
|
||||
const retType = parseJsDocReturn(text);
|
||||
const varType = isLexDecl ? parseJsDocType(text) : null;
|
||||
|
||||
// Determine the anchor node (the function-like node, for hoisting).
|
||||
const anchor = node;
|
||||
|
||||
for (const p of params) {
|
||||
out.push({
|
||||
'@type-binding.name': syntheticCapture('@type-binding.name', anchor, p.name),
|
||||
'@type-binding.type': syntheticCapture('@type-binding.type', anchor, p.type),
|
||||
'@type-binding.parameter': syntheticCapture('@type-binding.parameter', anchor, '1'),
|
||||
});
|
||||
}
|
||||
|
||||
if (retType !== null) {
|
||||
// For named functions, use the function name as the binding name so
|
||||
// `hoistTypeBindingsToModule` knows which function's return type this is.
|
||||
let fnName: string | null = null;
|
||||
if (isFnDecl) {
|
||||
fnName = node.childForFieldName('name')?.text ?? null;
|
||||
} else if (isMethodDef) {
|
||||
// method_definition uses `name:` field for the method name
|
||||
const nameNode = node.childForFieldName('name');
|
||||
if (nameNode?.type === 'property_identifier') fnName = nameNode.text;
|
||||
} else if (isLexDecl) {
|
||||
const declarator = node.namedChild(0);
|
||||
const nameNode = declarator?.childForFieldName('name');
|
||||
if (nameNode?.type === 'identifier') fnName = nameNode.text;
|
||||
}
|
||||
if (fnName !== null) {
|
||||
out.push({
|
||||
'@type-binding.name': syntheticCapture('@type-binding.name', anchor, fnName),
|
||||
'@type-binding.type': syntheticCapture('@type-binding.type', anchor, retType),
|
||||
'@type-binding.return': syntheticCapture('@type-binding.return', anchor, '1'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// @type {T} on let/const/var: `/** @type {User} */ const u = getUser()`.
|
||||
// Emits annotation-strength binding (source = 'annotation') so it
|
||||
// overrides any weaker constructor/alias inference on the same name.
|
||||
if (varType !== null) {
|
||||
for (const declarator of node.namedChildren) {
|
||||
if (declarator === null || declarator.type !== 'variable_declarator') continue;
|
||||
const nameNode = declarator.childForFieldName('name');
|
||||
if (nameNode === null || nameNode.type !== 'identifier') continue;
|
||||
out.push({
|
||||
'@type-binding.name': syntheticCapture('@type-binding.name', nameNode, nameNode.text),
|
||||
'@type-binding.type': syntheticCapture('@type-binding.type', nameNode, varType),
|
||||
'@type-binding.annotation': syntheticCapture(
|
||||
'@type-binding.annotation',
|
||||
nameNode,
|
||||
'1',
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
sibling = sibling.previousNamedSibling;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Destructuring / for-of / instanceof (shared with TS captures) ───────
|
||||
|
||||
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 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 key = fieldNode.childForFieldName('key');
|
||||
const value = fieldNode.childForFieldName('value');
|
||||
if (key === null || value === null || 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,
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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') 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',
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Constructor field type bindings ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Synthesize class-scope type bindings from `this.X = new Y()` assignments
|
||||
* inside constructor method bodies. Covers the traditional ES5+ OOP pattern:
|
||||
*
|
||||
* class User {
|
||||
* constructor() {
|
||||
* /** @type {Address} *\/
|
||||
* this.address = new Address();
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* The emitted `@type-binding.class-field` is hoisted to the Class scope by
|
||||
* `tsBindingScopeFor` so that compound-receiver resolution can look up
|
||||
* `User.address → Address` when resolving `user.address.save()`.
|
||||
*
|
||||
* Type source priority:
|
||||
* 1. JSDoc `@type {T}` comment immediately preceding the statement
|
||||
* 2. `new Y()` constructor inference
|
||||
*/
|
||||
function synthesizeConstructorFieldBindings(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);
|
||||
}
|
||||
// Only process constructor method definitions
|
||||
if (node.type !== 'method_definition') continue;
|
||||
const nameNode = node.childForFieldName('name');
|
||||
if (nameNode?.text !== 'constructor') continue;
|
||||
|
||||
const body = node.childForFieldName('body');
|
||||
if (body === null) continue;
|
||||
|
||||
for (const stmt of body.namedChildren) {
|
||||
if (stmt === null || stmt.type !== 'expression_statement') continue;
|
||||
const expr = stmt.namedChild(0);
|
||||
if (expr === null || expr.type !== 'assignment_expression') continue;
|
||||
|
||||
const left = expr.childForFieldName('left');
|
||||
const right = expr.childForFieldName('right');
|
||||
if (left === null || right === null) continue;
|
||||
if (left.type !== 'member_expression') continue;
|
||||
|
||||
const obj = left.childForFieldName('object');
|
||||
const prop = left.childForFieldName('property');
|
||||
if (obj === null || prop === null) continue;
|
||||
if (obj.text !== 'this' || prop.type !== 'property_identifier') continue;
|
||||
|
||||
const fieldName = prop.text;
|
||||
|
||||
// Prefer JSDoc @type annotation on the preceding sibling comment.
|
||||
let typeName: string | null = null;
|
||||
const prevSib: SyntaxNode | null = stmt.previousNamedSibling;
|
||||
if (prevSib !== null && prevSib.type === 'comment') {
|
||||
const m = /@type\s*\{([^}]+)\}/.exec(prevSib.text);
|
||||
if (m?.[1]) typeName = m[1].trim();
|
||||
}
|
||||
// Fall back to constructor inference from `new Y()`.
|
||||
if (typeName === null && right.type === 'new_expression') {
|
||||
const ctor = right.childForFieldName('constructor');
|
||||
if (ctor !== null && ctor.type === 'identifier') typeName = ctor.text;
|
||||
}
|
||||
if (typeName === null) continue;
|
||||
|
||||
out.push({
|
||||
'@type-binding.name': syntheticCapture('@type-binding.name', prop, fieldName),
|
||||
'@type-binding.type': syntheticCapture('@type-binding.type', prop, typeName),
|
||||
// Anchor: positioned inside the constructor body so tsBindingScopeFor
|
||||
// can walk up from the Function (constructor) scope to the Class scope.
|
||||
'@type-binding.class-field': syntheticCapture('@type-binding.class-field', stmt, '1'),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Main emitter ──────────────────────────────────────────────────────────
|
||||
|
||||
export function emitJsScopeCaptures(
|
||||
sourceText: string,
|
||||
filePath: string,
|
||||
cachedTree?: unknown,
|
||||
): readonly CaptureMatch[] {
|
||||
let tree = cachedTree as ReturnType<ReturnType<typeof getJsParser>['parse']> | undefined;
|
||||
if (tree !== undefined && !jsCachedTreeMatchesGrammar(tree)) {
|
||||
tree = undefined;
|
||||
}
|
||||
if (tree === undefined) {
|
||||
tree = parseSourceSafe(getJsParser(filePath), sourceText, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(sourceText),
|
||||
});
|
||||
}
|
||||
|
||||
const rawMatches = getJsScopeQuery(filePath).matches(tree.rootNode);
|
||||
const out: CaptureMatch[] = [];
|
||||
|
||||
for (const m of rawMatches) {
|
||||
const grouped: Record<string, Capture> = {};
|
||||
for (const c of m.captures) {
|
||||
const tag = '@' + c.name;
|
||||
grouped[tag] = nodeToCapture(tag, c.node);
|
||||
}
|
||||
if (Object.keys(grouped).length === 0) continue;
|
||||
|
||||
// Decompose ESM import_statement / re-export export_statement.
|
||||
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);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Decompose dynamic import() calls.
|
||||
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 @reference.read.member false-positives.
|
||||
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 declarations.
|
||||
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 callsites.
|
||||
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),
|
||||
);
|
||||
grouped['@reference.parameter-types'] = syntheticCapture(
|
||||
'@reference.parameter-types',
|
||||
callNode,
|
||||
JSON.stringify(args.map(inferArgType)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
out.push(grouped);
|
||||
|
||||
// Synthesize `this` receiver type-bindings on class member functions.
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Post-query synthesis passes.
|
||||
synthesizeCjsImports(tree.rootNode, out);
|
||||
synthesizeJsDocBindings(tree.rootNode, out);
|
||||
synthesizeConstructorFieldBindings(tree.rootNode, out);
|
||||
synthesizeDestructuringBindings(tree.rootNode, out);
|
||||
synthesizeForOfMapTupleBindings(tree.rootNode, out);
|
||||
synthesizeInstanceofNarrowings(tree.rootNode, out);
|
||||
|
||||
return out;
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
/**
|
||||
* Import-target resolver for JavaScript.
|
||||
*
|
||||
* Delegates to the TypeScript `resolveTsTarget` standard-strategy resolver
|
||||
* with `language: SupportedLanguages.JavaScript` so the resolver tries
|
||||
* `.js` / `.jsx` extensions in addition to (or instead of) `.ts` / `.tsx`.
|
||||
*
|
||||
* The `TsResolveContext.language` flag already exists in `import-target.ts`
|
||||
* and the resolver (`resolveImportPath`) already branches on it — this
|
||||
* adapter just wires the right value in.
|
||||
*
|
||||
* CJS `require()` calls reference the same module-path strings as ESM
|
||||
* `import` statements, so the resolver handles them uniformly without any
|
||||
* CJS-specific logic here.
|
||||
*
|
||||
* No `tsconfig.json` path-alias support (JavaScript projects don't use
|
||||
* `tsconfig.json` compilerOptions.paths in general). Projects that DO use
|
||||
* tsconfig-based aliases alongside JavaScript can still resolve via the
|
||||
* standard extension-suffix fallback; the alias branch is a no-op when
|
||||
* `tsconfigPaths` is null.
|
||||
*/
|
||||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { resolveTsTarget, type TsResolveContext } from '../typescript/import-target.js';
|
||||
|
||||
export type JsResolveContext = TsResolveContext;
|
||||
|
||||
type PassCache = {
|
||||
readonly key: ReadonlySet<string>;
|
||||
readonly allFilePaths: Set<string>;
|
||||
readonly allFileList: readonly string[];
|
||||
readonly normalizedFileList: readonly string[];
|
||||
readonly resolveCache: Map<string, string | null>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a memoized `resolveImportTarget` adapter for JavaScript.
|
||||
* Caches the derived arrays and per-pass resolve cache across
|
||||
* `resolveImportTarget` calls within a single workspace pass.
|
||||
*/
|
||||
export function makeJsResolveImportTarget(): (
|
||||
targetRaw: string,
|
||||
fromFile: string,
|
||||
allFilePaths: ReadonlySet<string>,
|
||||
resolutionConfig?: unknown,
|
||||
) => string | readonly string[] | null {
|
||||
let cached: PassCache | null = null;
|
||||
|
||||
return (targetRaw, fromFile, allFilePaths) => {
|
||||
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 ws: JsResolveContext = {
|
||||
fromFile,
|
||||
language: SupportedLanguages.JavaScript,
|
||||
allFilePaths: cached.allFilePaths,
|
||||
allFileList: cached.allFileList,
|
||||
normalizedFileList: cached.normalizedFileList,
|
||||
resolveCache: cached.resolveCache,
|
||||
tsconfigPaths: null,
|
||||
};
|
||||
return resolveTsTarget(targetRaw, ws);
|
||||
};
|
||||
}
|
||||
49
gitnexus/src/core/ingestion/languages/javascript/index.ts
Normal file
49
gitnexus/src/core/ingestion/languages/javascript/index.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/**
|
||||
* JavaScript scope-resolution hooks (RFC #909 Ring 3, issue #928).
|
||||
*
|
||||
* Public API barrel. Consumers should import from this file rather
|
||||
* than the individual modules.
|
||||
*
|
||||
* Module layout (each file is a single concern):
|
||||
*
|
||||
* - `query.ts` — JS scope query string + lazy parser/query
|
||||
* singletons (`getJsParser`, `getJsScopeQuery`)
|
||||
* - `captures.ts` — `emitJsScopeCaptures` — runs the JS scope query,
|
||||
* synthesizes CJS require() imports and JSDoc-
|
||||
* derived type bindings, delegates arity synthesis
|
||||
* and destructuring/instanceof passes to shared
|
||||
* or TypeScript utilities
|
||||
* - `interpret.ts` — `interpretJsImport` / `interpretJsTypeBinding`
|
||||
* (delegate to TypeScript interpreters — same
|
||||
* capture-marker vocabulary)
|
||||
* - `simple-hooks.ts` — `jsBindingScopeFor` (var hoisting),
|
||||
* `jsImportOwningScope`, `jsReceiverBinding`
|
||||
* (all delegate to TypeScript counterparts)
|
||||
* - `merge-bindings.ts` — `jsMergeBindings` (LEGB via typescriptMergeBindings)
|
||||
* - `arity.ts` — `jsArityCompatibility` (delegates to TS function)
|
||||
* - `import-target.ts` — `makeJsResolveImportTarget` (memoized adapter)
|
||||
* - `scope-resolver.ts` — `javascriptScopeResolver` wiring object
|
||||
*
|
||||
* ## Known limitations
|
||||
*
|
||||
* 1. **JSDoc coverage** — `@param {T} name`, `@returns {T}` / `@return {T}`,
|
||||
* and `@type {T}` on variable declarations are synthesized. `@typedef`
|
||||
* is not yet synthesized (tracked in #1646).
|
||||
* 2. **CJS chained destructuring** — `const { X: { Y } } = require(...)`
|
||||
* (nested destructuring) emits only the outer `X` binding; `Y` is not
|
||||
* resolved.
|
||||
* 3. **Dynamic require** — `require(computedPath)` is skipped (non-literal
|
||||
* argument — cannot statically resolve the target).
|
||||
* 4. **`module.exports` / `exports.X`** — CJS export forms are not yet
|
||||
* modeled as re-exports. The finalize algorithm treats the exporting
|
||||
* module as a namespace; importers that do `const X = require('./m')`
|
||||
* bind the module namespace, and member-call resolution walks the
|
||||
* class graph from there.
|
||||
*/
|
||||
|
||||
export { emitJsScopeCaptures } from './captures.js';
|
||||
export { interpretJsImport, interpretJsTypeBinding } from './interpret.js';
|
||||
export { jsMergeBindings } from './merge-bindings.js';
|
||||
export { jsArityCompatibility } from './arity.js';
|
||||
export { makeJsResolveImportTarget } from './import-target.js';
|
||||
export { jsBindingScopeFor, jsImportOwningScope, jsReceiverBinding } from './simple-hooks.js';
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
/**
|
||||
* Capture-match → semantic-shape interpreters for JavaScript.
|
||||
*
|
||||
* `interpretJsImport` delegates to `interpretTsImport` for all cases
|
||||
* because `emitJsScopeCaptures` synthesizes the same
|
||||
* `@import.kind/name/alias/source` markers for both ESM and CJS imports.
|
||||
*
|
||||
* The `@import.kind` values emitted for CJS by `captures.ts`:
|
||||
*
|
||||
* - `'named'` : `const { X } = require('./m')` → named import
|
||||
* - `'named-alias'` : `const { X: Y } = require('./m')` → aliased import
|
||||
* - `'namespace'` : `const X = require('./m')` → namespace import
|
||||
* - `'side-effect'` : `require('./m')` bare expression → side-effect
|
||||
*
|
||||
* These match the kinds `interpretTsImport` already handles for ESM
|
||||
* (`import { X }`, `import { X as Y }`, `import * as X`, `import './m'`),
|
||||
* so no new branch is needed here.
|
||||
*
|
||||
* `interpretJsTypeBinding` handles the JS-only `@type-binding.class-field`
|
||||
* tag before delegating to `interpretTsTypeBinding`. The class-field tag
|
||||
* is emitted by `synthesizeConstructorFieldBindings` and should produce
|
||||
* `source = 'annotation'` — the same strength as an explicit type
|
||||
* annotation. Remapping it to `@type-binding.annotation` achieves this
|
||||
* without adding a JS-specific branch to the shared TS interpreter
|
||||
* (DoD.md §2.2).
|
||||
*/
|
||||
|
||||
import type { CaptureMatch, ParsedImport, ParsedTypeBinding } from 'gitnexus-shared';
|
||||
import { interpretTsImport, interpretTsTypeBinding } from '../typescript/interpret.js';
|
||||
|
||||
export function interpretJsImport(captures: CaptureMatch): ParsedImport | null {
|
||||
return interpretTsImport(captures);
|
||||
}
|
||||
|
||||
export function interpretJsTypeBinding(captures: CaptureMatch): ParsedTypeBinding | null {
|
||||
// @type-binding.class-field is a JS-only tag emitted by
|
||||
// synthesizeConstructorFieldBindings. Remap it to the standard
|
||||
// @type-binding.annotation tag so interpretTsTypeBinding assigns
|
||||
// source = 'annotation' without a JS-specific branch in shared code.
|
||||
if (captures['@type-binding.class-field'] !== undefined) {
|
||||
const { '@type-binding.class-field': classField, ...rest } = captures;
|
||||
return interpretTsTypeBinding({ ...rest, '@type-binding.annotation': classField });
|
||||
}
|
||||
return interpretTsTypeBinding(captures);
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
/**
|
||||
* Binding-merge precedence for JavaScript.
|
||||
*
|
||||
* JavaScript has no TypeScript declaration-merging (no `interface + class`
|
||||
* coexisting in the same scope, no `namespace + class` dual-space declarations).
|
||||
* However, `typescriptMergeBindings` handles these by falling back to
|
||||
* `['value']` for any `NodeLabel` not explicitly mapped to multiple spaces —
|
||||
* which is what every JavaScript declaration produces. The result is pure
|
||||
* LEGB precedence without any cross-space logic, which is exactly what
|
||||
* JavaScript needs.
|
||||
*
|
||||
* Reuse rather than reimplementing to keep the single source of truth for
|
||||
* the tier (local 0 / import-namespace-reexport 1 / wildcard 2) ordering.
|
||||
*/
|
||||
|
||||
import type { BindingRef } from 'gitnexus-shared';
|
||||
import { typescriptMergeBindings } from '../typescript/merge-bindings.js';
|
||||
|
||||
export function jsMergeBindings(bindings: readonly BindingRef[]): readonly BindingRef[] {
|
||||
return typescriptMergeBindings(bindings);
|
||||
}
|
||||
421
gitnexus/src/core/ingestion/languages/javascript/query.ts
Normal file
421
gitnexus/src/core/ingestion/languages/javascript/query.ts
Normal file
|
|
@ -0,0 +1,421 @@
|
|||
/**
|
||||
* Tree-sitter query for JavaScript scope captures (RFC §5.1, Ring 3).
|
||||
*
|
||||
* Subset of the TypeScript scope query (`languages/typescript/query.ts`)
|
||||
* compiled against `tree-sitter-javascript`. TypeScript-only node types
|
||||
* (`interface_declaration`, `type_alias_declaration`, `enum_declaration`,
|
||||
* `internal_module`, `abstract_class_declaration`, `function_signature`,
|
||||
* `method_signature`, `abstract_method_signature`, `type_annotation`,
|
||||
* `public_field_definition`) are dropped because:
|
||||
*
|
||||
* 1. The JS grammar doesn't define them — the query compiler would
|
||||
* throw `InvalidNodeType` if they were included.
|
||||
* 2. JavaScript has no static type annotations, so the `@type-binding.*`
|
||||
* patterns derived from TS annotation nodes don't apply.
|
||||
*
|
||||
* What IS shared with the TypeScript query:
|
||||
*
|
||||
* - Scope patterns: `program`, `class_declaration`, `(class)` (the JS
|
||||
* grammar node for class expressions — NOT `class_expression`, which
|
||||
* does not exist in `tree-sitter-javascript`), `function_declaration`,
|
||||
* `generator_function_declaration`, `function_expression`,
|
||||
* `arrow_function`, `method_definition`.
|
||||
* - Declaration patterns for functions, classes, const/let/var,
|
||||
* object-property arrows (Zustand, TanStack, etc.), and HOC-wrapped
|
||||
* variable declarations (forwardRef / memo / useCallback / useMemo).
|
||||
* - Import patterns: `import_statement`, `export_statement` re-exports,
|
||||
* and dynamic `import()` (represented as `call_expression(import)` in
|
||||
* both grammars — the `import` leaf node exists in tree-sitter-javascript
|
||||
* as well as tree-sitter-typescript).
|
||||
* - Type-binding patterns that work without static annotations:
|
||||
* constructor inference (`new User()`), call-result alias
|
||||
* (`const u = getUser()`), member-access alias (`const a = u.addr`),
|
||||
* identifier alias, assignment rebind, and for-of element bindings.
|
||||
* JSDoc-derived type bindings (`@param {User} u`, `@returns {User}`)
|
||||
* are handled separately in `captures.ts` via comment-node scanning.
|
||||
* - Reference patterns: free calls, member calls, constructor calls,
|
||||
* write-access, read-access, and dynamic import.
|
||||
*
|
||||
* CJS `require()` is NOT captured here; it is handled in `captures.ts`
|
||||
* by scanning parent context (destructured vs. namespace) of `call_expression`
|
||||
* nodes whose callee is the identifier `require`.
|
||||
*
|
||||
* Grammar version: `tree-sitter-javascript` 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 JS from 'tree-sitter-javascript';
|
||||
|
||||
const JS_GRAMMAR = JS as Parameters<Parser['setLanguage']>[0];
|
||||
|
||||
/** True when the file should be parsed with the JSX-extended query. */
|
||||
function isJsxFile(filePath: string): boolean {
|
||||
return filePath.endsWith('.jsx');
|
||||
}
|
||||
|
||||
const JAVASCRIPT_SCOPE_QUERY = `
|
||||
;; Scopes — module / class-likes / function-likes
|
||||
(program) @scope.module
|
||||
|
||||
(class_declaration) @scope.class
|
||||
(class) @scope.class
|
||||
|
||||
(function_declaration) @scope.function
|
||||
(generator_function_declaration) @scope.function
|
||||
(function_expression) @scope.function
|
||||
(arrow_function) @scope.function
|
||||
(method_definition) @scope.function
|
||||
|
||||
;; Declarations — classes
|
||||
(class_declaration
|
||||
name: (identifier) @declaration.name) @declaration.class
|
||||
|
||||
;; Declarations — methods (inside class bodies)
|
||||
(method_definition
|
||||
name: (property_identifier) @declaration.name) @declaration.method
|
||||
|
||||
;; Declarations — class fields (JS uses field_definition, not public_field_definition)
|
||||
(field_definition
|
||||
property: (property_identifier) @declaration.name) @declaration.property
|
||||
|
||||
;; Declarations — free functions
|
||||
(function_declaration
|
||||
name: (identifier) @declaration.name) @declaration.function
|
||||
|
||||
(generator_function_declaration
|
||||
name: (identifier) @declaration.name) @declaration.function
|
||||
|
||||
;; Arrow / function-expression assigned to a const/let/var.
|
||||
;; Anchor discipline: @declaration.function sits on the INNER arrow or
|
||||
;; function_expression, NOT on the lexical_declaration wrapper. This
|
||||
;; aligns anchor.range with the @scope.function range so
|
||||
;; pass2AttachDeclarations resolves the innermost scope correctly and
|
||||
;; resolveCallerGraphId walks up to the right caller anchor.
|
||||
(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))
|
||||
|
||||
(export_statement
|
||||
declaration: (lexical_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @declaration.name
|
||||
value: (arrow_function) @declaration.function)))
|
||||
|
||||
(export_statement
|
||||
declaration: (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))
|
||||
|
||||
;; Object-property arrows / function expressions named by their pair key.
|
||||
;; Same anchor discipline as the lexical_declaration block above: the
|
||||
;; @declaration.function capture must sit on the INNER arrow/fn-expression.
|
||||
(pair
|
||||
key: (property_identifier) @declaration.name
|
||||
value: (arrow_function) @declaration.function)
|
||||
|
||||
(pair
|
||||
key: (property_identifier) @declaration.name
|
||||
value: (function_expression) @declaration.function)
|
||||
|
||||
(pair
|
||||
key: (string (string_fragment) @declaration.name)
|
||||
value: (arrow_function) @declaration.function)
|
||||
|
||||
(pair
|
||||
key: (string (string_fragment) @declaration.name)
|
||||
value: (function_expression) @declaration.function)
|
||||
|
||||
;; HOC-wrapped variable declarations: const X = HOC((args) => { ... }).
|
||||
;; Covers React.forwardRef, memo, useCallback, useMemo, observer,
|
||||
;; debounce, and any user-defined HOC factory.
|
||||
(lexical_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @declaration.name
|
||||
value: (call_expression
|
||||
arguments: (arguments
|
||||
(arrow_function) @declaration.function))))
|
||||
|
||||
(lexical_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @declaration.name
|
||||
value: (call_expression
|
||||
arguments: (arguments
|
||||
(function_expression) @declaration.function))))
|
||||
|
||||
(export_statement
|
||||
declaration: (lexical_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @declaration.name
|
||||
value: (call_expression
|
||||
arguments: (arguments
|
||||
(arrow_function) @declaration.function)))))
|
||||
|
||||
(export_statement
|
||||
declaration: (lexical_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @declaration.name
|
||||
value: (call_expression
|
||||
arguments: (arguments
|
||||
(function_expression) @declaration.function)))))
|
||||
|
||||
(variable_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @declaration.name
|
||||
value: (call_expression
|
||||
arguments: (arguments
|
||||
(arrow_function) @declaration.function))))
|
||||
|
||||
(variable_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @declaration.name
|
||||
value: (call_expression
|
||||
arguments: (arguments
|
||||
(function_expression) @declaration.function))))
|
||||
|
||||
;; Variable / constant declarations (non-function values).
|
||||
(lexical_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @declaration.name)) @declaration.const
|
||||
|
||||
(export_statement
|
||||
declaration: (lexical_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @declaration.name))) @declaration.const
|
||||
|
||||
(variable_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @declaration.name)) @declaration.variable
|
||||
|
||||
;; Imports (ESM) — single anchor per statement; decomposer emits per-specifier markers.
|
||||
(import_statement) @import.statement
|
||||
|
||||
;; Re-exports with a source clause.
|
||||
(export_statement
|
||||
source: (string)) @import.statement
|
||||
|
||||
;; Dynamic imports: import('./m') — tree-sitter-javascript represents this
|
||||
;; as call_expression with a named import leaf as the function field,
|
||||
;; identical to tree-sitter-typescript.
|
||||
(call_expression
|
||||
function: (import)) @import.dynamic
|
||||
|
||||
;; ── Type bindings (no static annotations in JS; inferred from AST shape) ──
|
||||
|
||||
;; Constructor-inferred: const u = new User()
|
||||
(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()
|
||||
(variable_declarator
|
||||
name: (identifier) @type-binding.name
|
||||
value: (new_expression
|
||||
constructor: (member_expression) @type-binding.type)) @type-binding.constructor
|
||||
|
||||
;; Call-result alias: const u = getUser()
|
||||
(variable_declarator
|
||||
name: (identifier) @type-binding.name
|
||||
value: (call_expression
|
||||
function: (identifier) @type-binding.type)) @type-binding.alias
|
||||
|
||||
;; Member-call alias: const u = svc.getUser()
|
||||
(variable_declarator
|
||||
name: (identifier) @type-binding.name
|
||||
value: (call_expression
|
||||
function: (member_expression) @type-binding.type)) @type-binding.alias
|
||||
|
||||
;; Await chain: const u = await getUser() / await svc.getUser()
|
||||
(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
|
||||
|
||||
;; Member-access alias: const addr = user.address
|
||||
(variable_declarator
|
||||
name: (identifier) @type-binding.name
|
||||
value: (member_expression) @type-binding.type) @type-binding.member-alias
|
||||
|
||||
;; Identifier alias: const alias = user
|
||||
(variable_declarator
|
||||
name: (identifier) @type-binding.name
|
||||
value: (identifier) @type-binding.type) @type-binding.alias
|
||||
|
||||
;; Assignment rebind: u = new User() / u = getUser()
|
||||
(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
|
||||
|
||||
;; For-of element: for (const u of users) / for (const u of getUsers())
|
||||
(for_in_statement
|
||||
left: (identifier) @type-binding.name
|
||||
right: (identifier) @type-binding.type) @type-binding.alias
|
||||
|
||||
(for_in_statement
|
||||
left: (identifier) @type-binding.name
|
||||
right: (call_expression
|
||||
function: (identifier) @type-binding.type)) @type-binding.alias
|
||||
|
||||
(for_in_statement
|
||||
left: (identifier) @type-binding.name
|
||||
right: (call_expression
|
||||
function: (member_expression) @type-binding.type)) @type-binding.alias
|
||||
|
||||
(for_in_statement
|
||||
left: (identifier) @type-binding.name
|
||||
right: (member_expression
|
||||
property: (property_identifier) @type-binding.type)) @type-binding.alias
|
||||
|
||||
;; ── References ────────────────────────────────────────────────────────────
|
||||
|
||||
;; Free calls: fn(args). The dynamic-import filter runs in captures.ts.
|
||||
(call_expression
|
||||
function: (identifier) @reference.name) @reference.call.free
|
||||
|
||||
;; Awaited free call: await fn<T>(...) re-associated by tree-sitter.
|
||||
(call_expression
|
||||
function: (await_expression
|
||||
(identifier) @reference.name)) @reference.call.free
|
||||
|
||||
;; Member calls: obj.method() (includes optional chain).
|
||||
(call_expression
|
||||
function: (member_expression
|
||||
object: (_) @reference.receiver
|
||||
property: (property_identifier) @reference.name)) @reference.call.member
|
||||
|
||||
;; Awaited member call: await svc.m<T>(...)
|
||||
(call_expression
|
||||
function: (await_expression
|
||||
(member_expression
|
||||
object: (_) @reference.receiver
|
||||
property: (property_identifier) @reference.name))) @reference.call.member
|
||||
|
||||
;; 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
|
||||
|
||||
;; 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
|
||||
|
||||
;; Read access: obj.field (in read context; captures.ts filters non-reads).
|
||||
(member_expression
|
||||
object: (_) @reference.receiver
|
||||
property: (property_identifier) @reference.name) @reference.read.member
|
||||
`;
|
||||
|
||||
/** JSX-only suffix — appended when compiling against the JSX grammar for .jsx files. */
|
||||
const JSX_QUERY_SUFFIX = `
|
||||
;; <Foo />
|
||||
((jsx_self_closing_element
|
||||
name: (identifier) @reference.name) @reference.call.free
|
||||
(#match? @reference.name "^[A-Z]"))
|
||||
|
||||
;; <Foo> ... </Foo>
|
||||
((jsx_opening_element
|
||||
name: (identifier) @reference.name) @reference.call.free
|
||||
(#match? @reference.name "^[A-Z]"))
|
||||
|
||||
;; <Foo.Bar />
|
||||
(jsx_self_closing_element
|
||||
name: (member_expression
|
||||
object: (_) @reference.receiver
|
||||
property: (property_identifier) @reference.name)) @reference.call.member
|
||||
|
||||
(jsx_opening_element
|
||||
name: (member_expression
|
||||
object: (_) @reference.receiver
|
||||
property: (property_identifier) @reference.name)) @reference.call.member
|
||||
`;
|
||||
|
||||
let _jsParser: Parser | null = null;
|
||||
let _jsQuery: Parser.Query | null = null;
|
||||
let _jsxParser: Parser | null = null;
|
||||
let _jsxQuery: Parser.Query | null = null;
|
||||
|
||||
export function getJsParser(filePath?: string): Parser {
|
||||
// JSX files use the same JavaScript grammar in tree-sitter-javascript;
|
||||
// both .js and .jsx parse with the same grammar object. We keep separate
|
||||
// singletons only to mirror the TypeScript pattern and in case a future
|
||||
// version of the grammar diverges.
|
||||
if (filePath !== undefined && isJsxFile(filePath)) {
|
||||
if (_jsxParser === null) {
|
||||
_jsxParser = new Parser();
|
||||
_jsxParser.setLanguage(JS_GRAMMAR);
|
||||
}
|
||||
return _jsxParser;
|
||||
}
|
||||
if (_jsParser === null) {
|
||||
_jsParser = new Parser();
|
||||
_jsParser.setLanguage(JS_GRAMMAR);
|
||||
}
|
||||
return _jsParser;
|
||||
}
|
||||
|
||||
export function getJsScopeQuery(filePath?: string): Parser.Query {
|
||||
if (filePath !== undefined && isJsxFile(filePath)) {
|
||||
if (_jsxQuery === null) {
|
||||
_jsxQuery = new Parser.Query(JS_GRAMMAR, JAVASCRIPT_SCOPE_QUERY + JSX_QUERY_SUFFIX);
|
||||
}
|
||||
return _jsxQuery;
|
||||
}
|
||||
if (_jsQuery === null) {
|
||||
_jsQuery = new Parser.Query(JS_GRAMMAR, JAVASCRIPT_SCOPE_QUERY);
|
||||
}
|
||||
return _jsQuery;
|
||||
}
|
||||
|
||||
/** Validate that a cached Tree was produced by the JS grammar. */
|
||||
export function jsCachedTreeMatchesGrammar(tree: unknown): boolean {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const lang = (tree as any)?.getLanguage?.();
|
||||
if (lang === undefined || lang === null) return true;
|
||||
return lang === JS_GRAMMAR;
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
/**
|
||||
* JavaScript `ScopeResolver` registered in `SCOPE_RESOLVERS` and
|
||||
* consumed by the generic `runScopeResolution` orchestrator
|
||||
* (RFC #909 Ring 3, issue #928).
|
||||
*
|
||||
* Follows the same minimal wiring-only pattern as TypeScript (the third
|
||||
* migration). Per-hook logic lives in sibling modules:
|
||||
*
|
||||
* - `query.ts` — JS scope query + parser/query singletons
|
||||
* - `captures.ts` — `emitJsScopeCaptures` (JS grammar, CJS, JSDoc)
|
||||
* - `interpret.ts` — `interpretJsImport` (delegates to TS interpreter)
|
||||
* - `simple-hooks.ts` — `jsBindingScopeFor`, `jsImportOwningScope`,
|
||||
* `jsReceiverBinding` (all delegate to TS hooks)
|
||||
* - `merge-bindings.ts` — `jsMergeBindings` (delegates to TS function)
|
||||
* - `arity.ts` — `jsArityCompatibility` (delegates to TS function)
|
||||
* - `import-target.ts` — `makeJsResolveImportTarget` (TS resolver, JS extensions)
|
||||
*
|
||||
* See `./index.ts` for the full per-module rationale.
|
||||
*
|
||||
* ## Key differences from TypeScript resolver
|
||||
*
|
||||
* - `fieldFallbackOnMethodLookup: true` — JavaScript is dynamically typed;
|
||||
* the field-fallback heuristic is ENABLED (unlike TypeScript, which
|
||||
* disables it because the type-binding layer is precise).
|
||||
* - `allowGlobalFreeCallFallback: true` — CJS `require` patterns and
|
||||
* global helpers (e.g. `process`, `console`) benefit from workspace-
|
||||
* wide unique-name fallback. TypeScript uses explicit imports.
|
||||
* - `loadResolutionConfig` is omitted — JavaScript projects don't use
|
||||
* `tsconfig.json` path aliases in general. `tsconfigPaths: null` is
|
||||
* threaded through the resolver adapter.
|
||||
* - `hoistTypeBindingsToModule: true` — JSDoc `@returns {T}` bindings are
|
||||
* synthesized on the function scope and hoisted, matching TypeScript's
|
||||
* method return-type hoisting strategy for cross-file chain resolution.
|
||||
*/
|
||||
|
||||
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 { javascriptProvider } from '../typescript.js';
|
||||
import { jsMergeBindings } from './merge-bindings.js';
|
||||
import { jsArityCompatibility } from './arity.js';
|
||||
import { makeJsResolveImportTarget } from './import-target.js';
|
||||
|
||||
const javascriptScopeResolver: ScopeResolver = {
|
||||
language: SupportedLanguages.JavaScript,
|
||||
languageProvider: javascriptProvider,
|
||||
importEdgeReason: 'javascript-scope: import',
|
||||
|
||||
resolveImportTarget: makeJsResolveImportTarget(),
|
||||
|
||||
// JavaScript LEGB — same tier ordering as TypeScript; no declaration-
|
||||
// merging across type/value/namespace spaces.
|
||||
mergeBindings: (existing, incoming) => [...jsMergeBindings([...existing, ...incoming])],
|
||||
|
||||
// Adapter: jsArityCompatibility uses (def, callsite); contract is (callsite, def).
|
||||
arityCompatibility: (callsite, def) => jsArityCompatibility(def, callsite),
|
||||
|
||||
buildMro: (graph, parsedFiles, nodeLookup) =>
|
||||
buildMro(graph, parsedFiles, nodeLookup, defaultLinearize),
|
||||
|
||||
populateOwners: (parsed: ParsedFile) => populateClassOwnedMembers(parsed),
|
||||
|
||||
// JavaScript `super` keyword: same pattern as TypeScript.
|
||||
isSuperReceiver: (text) => /^super(\s*\(|\s*\.|\s*\[|\s*$)/.test(text.trim()),
|
||||
|
||||
// JavaScript is dynamically typed — enable the field-fallback heuristic
|
||||
// so member-call receivers without type annotations can still resolve
|
||||
// through declared class fields (e.g. JSDoc-typed fields).
|
||||
fieldFallbackOnMethodLookup: true,
|
||||
|
||||
// Return-type propagation (across ESM imports) mirrors TypeScript's
|
||||
// default behavior. JSDoc @returns bindings are hoisted to Module scope
|
||||
// and propagated to importers via the standard mechanism.
|
||||
propagatesReturnTypesAcrossImports: true,
|
||||
|
||||
// JSDoc @returns bindings are synthesized on the function/method node
|
||||
// and hoisted to Module scope by `jsBindingScopeFor` (identical to the
|
||||
// TypeScript `tsBindingScopeFor` `@type-binding.return` branch).
|
||||
hoistTypeBindingsToModule: true,
|
||||
|
||||
// CJS-heavy codebases often have utility functions exported without
|
||||
// explicit imports at the call site. Workspace-wide unique-name fallback
|
||||
// recovers these edges.
|
||||
allowGlobalFreeCallFallback: true,
|
||||
};
|
||||
|
||||
export { javascriptScopeResolver };
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* Simple hooks for the JavaScript scope-resolution provider.
|
||||
*
|
||||
* `jsBindingScopeFor` wraps `tsBindingScopeFor` and adds the JS-only
|
||||
* `@type-binding.class-field` hoisting rule. The other two hooks
|
||||
* (`jsImportOwningScope`, `jsReceiverBinding`) are identical to their
|
||||
* TypeScript counterparts and are re-exported directly.
|
||||
*
|
||||
* ## Why class-field hoisting lives here (not in `tsBindingScopeFor`)
|
||||
*
|
||||
* `@type-binding.class-field` is emitted exclusively by
|
||||
* `synthesizeConstructorFieldBindings` in `captures.ts`, which is a
|
||||
* JavaScript-only synthesis pass. TypeScript uses
|
||||
* `@type-binding.parameter-property` for constructor parameter
|
||||
* properties instead. Keeping the JS-only rule in the JS hook file
|
||||
* prevents language-specific logic from leaking into shared TypeScript
|
||||
* infrastructure (DoD.md §2.2).
|
||||
*/
|
||||
|
||||
import type { CaptureMatch, Scope, ScopeId, ScopeTree } from 'gitnexus-shared';
|
||||
import { tsBindingScopeFor, walkToScope } from '../typescript/simple-hooks.js';
|
||||
|
||||
export {
|
||||
tsImportOwningScope as jsImportOwningScope,
|
||||
tsReceiverBinding as jsReceiverBinding,
|
||||
} from '../typescript/simple-hooks.js';
|
||||
|
||||
/**
|
||||
* Like `tsBindingScopeFor` but additionally hoists
|
||||
* `@type-binding.class-field` captures to the enclosing Class scope.
|
||||
*
|
||||
* `@type-binding.class-field` is anchored inside the constructor body
|
||||
* (by `synthesizeConstructorFieldBindings`) so that `walkToScope` can
|
||||
* walk up from the Function (constructor) scope to the Class scope.
|
||||
* This puts `User.address → Address` in the class's typeBindings so
|
||||
* compound-receiver resolution finds it when resolving
|
||||
* `user.address.save()`.
|
||||
*/
|
||||
export function jsBindingScopeFor(
|
||||
decl: CaptureMatch,
|
||||
innermost: Scope,
|
||||
tree: ScopeTree,
|
||||
): ScopeId | null {
|
||||
if (decl['@type-binding.class-field'] !== undefined) {
|
||||
return walkToScope(innermost, tree, 'Class');
|
||||
}
|
||||
return tsBindingScopeFor(decl, innermost, tree);
|
||||
}
|
||||
|
|
@ -29,6 +29,16 @@ import { kotlinMethodConfig } from '../method-extractors/configs/jvm.js';
|
|||
import { createVariableExtractor } from '../variable-extractors/generic.js';
|
||||
import { kotlinVariableConfig } from '../variable-extractors/configs/jvm.js';
|
||||
import { createHeritageExtractor } from '../heritage-extractors/generic.js';
|
||||
import {
|
||||
emitKotlinScopeCaptures,
|
||||
interpretKotlinImport,
|
||||
interpretKotlinTypeBinding,
|
||||
kotlinArityCompatibility,
|
||||
kotlinBindingScopeFor,
|
||||
kotlinImportOwningScope,
|
||||
kotlinMergeBindings,
|
||||
kotlinReceiverBinding,
|
||||
} from './kotlin/index.js';
|
||||
|
||||
/** Check if a Kotlin function_declaration capture is inside a class_body (i.e., a method).
|
||||
* Kotlin grammar uses function_declaration for both top-level functions and class methods.
|
||||
|
|
@ -166,4 +176,14 @@ export const kotlinProvider = defineLanguage({
|
|||
if (isKotlinClassMethod(functionNode)) return 'Method';
|
||||
return defaultLabel;
|
||||
},
|
||||
|
||||
// ── RFC #909 Ring 3: scope-based resolution hooks ──
|
||||
emitScopeCaptures: emitKotlinScopeCaptures,
|
||||
interpretImport: interpretKotlinImport,
|
||||
interpretTypeBinding: interpretKotlinTypeBinding,
|
||||
bindingScopeFor: kotlinBindingScopeFor,
|
||||
importOwningScope: kotlinImportOwningScope,
|
||||
mergeBindings: (_scope, bindings) => kotlinMergeBindings(bindings),
|
||||
receiverBinding: kotlinReceiverBinding,
|
||||
arityCompatibility: kotlinArityCompatibility,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
import { kotlinMethodConfig } from '../../method-extractors/configs/jvm.js';
|
||||
|
||||
export interface KotlinArityMetadata {
|
||||
readonly parameterCount: number | undefined;
|
||||
readonly requiredParameterCount: number | undefined;
|
||||
readonly parameterTypes: readonly string[] | undefined;
|
||||
}
|
||||
|
||||
export function computeKotlinArityMetadata(fnNode: SyntaxNode): KotlinArityMetadata {
|
||||
const params = kotlinMethodConfig.extractParameters?.(fnNode) ?? [];
|
||||
let hasVararg = false;
|
||||
const parameterTypes: string[] = [];
|
||||
for (const param of params) {
|
||||
if (param.isVariadic) hasVararg = true;
|
||||
if (param.type !== null) parameterTypes.push(param.type);
|
||||
}
|
||||
if (hasVararg) parameterTypes.push('vararg');
|
||||
|
||||
const required = params.filter((p) => !p.isOptional && !p.isVariadic).length;
|
||||
return {
|
||||
parameterCount: hasVararg ? undefined : params.length,
|
||||
requiredParameterCount: required,
|
||||
parameterTypes: parameterTypes.length > 0 ? parameterTypes : undefined,
|
||||
};
|
||||
}
|
||||
18
gitnexus/src/core/ingestion/languages/kotlin/arity.ts
Normal file
18
gitnexus/src/core/ingestion/languages/kotlin/arity.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import type { Callsite, SymbolDefinition } from 'gitnexus-shared';
|
||||
|
||||
export function kotlinArityCompatibility(
|
||||
def: SymbolDefinition,
|
||||
callsite: Callsite,
|
||||
): 'compatible' | 'unknown' | 'incompatible' {
|
||||
const min = def.requiredParameterCount;
|
||||
const max = def.parameterCount;
|
||||
if (min === undefined && max === undefined) return 'unknown';
|
||||
|
||||
const argCount = callsite.arity;
|
||||
if (!Number.isFinite(argCount) || argCount < 0) return 'unknown';
|
||||
|
||||
const hasVararg = def.parameterTypes?.some((t) => t === 'vararg') ?? false;
|
||||
if (min !== undefined && argCount < min) return 'incompatible';
|
||||
if (max !== undefined && argCount > max && !hasVararg) return 'incompatible';
|
||||
return 'compatible';
|
||||
}
|
||||
19
gitnexus/src/core/ingestion/languages/kotlin/cache-stats.ts
Normal file
19
gitnexus/src/core/ingestion/languages/kotlin/cache-stats.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
let hits = 0;
|
||||
let misses = 0;
|
||||
|
||||
export function recordKotlinCacheHit(): void {
|
||||
hits += 1;
|
||||
}
|
||||
|
||||
export function recordKotlinCacheMiss(): void {
|
||||
misses += 1;
|
||||
}
|
||||
|
||||
export function getKotlinCaptureCacheStats(): { readonly hits: number; readonly misses: number } {
|
||||
return { hits, misses };
|
||||
}
|
||||
|
||||
export function resetKotlinCaptureCacheStats(): void {
|
||||
hits = 0;
|
||||
misses = 0;
|
||||
}
|
||||
760
gitnexus/src/core/ingestion/languages/kotlin/captures.ts
Normal file
760
gitnexus/src/core/ingestion/languages/kotlin/captures.ts
Normal file
|
|
@ -0,0 +1,760 @@
|
|||
import type { Capture, CaptureMatch, Range } from 'gitnexus-shared';
|
||||
import {
|
||||
findNodeAtRange,
|
||||
nodeToCapture,
|
||||
syntheticCapture,
|
||||
type SyntaxNode,
|
||||
} from '../../utils/ast-helpers.js';
|
||||
import { getTreeSitterBufferSize } from '../../constants.js';
|
||||
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
|
||||
import { computeKotlinArityMetadata } from './arity-metadata.js';
|
||||
import { splitKotlinImportHeader } from './import-decomposer.js';
|
||||
import { recordKotlinCacheHit, recordKotlinCacheMiss } from './cache-stats.js';
|
||||
import { normalizeKotlinType } from './interpret.js';
|
||||
import { synthesizeKotlinReceiverBinding } from './receiver-binding.js';
|
||||
import { getKotlinParser, getKotlinScopeQuery } from './query.js';
|
||||
|
||||
const FUNCTION_DECL_TAGS = ['@declaration.function'] as const;
|
||||
|
||||
export function emitKotlinScopeCaptures(
|
||||
sourceText: string,
|
||||
_filePath: string,
|
||||
cachedTree?: unknown,
|
||||
): readonly CaptureMatch[] {
|
||||
let tree = cachedTree as ReturnType<ReturnType<typeof getKotlinParser>['parse']> | undefined;
|
||||
if (tree === undefined) {
|
||||
tree = parseSourceSafe(getKotlinParser(), sourceText, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(sourceText),
|
||||
});
|
||||
recordKotlinCacheMiss();
|
||||
} else {
|
||||
recordKotlinCacheHit();
|
||||
}
|
||||
|
||||
const out: CaptureMatch[] = [];
|
||||
const returnTypes = collectKotlinReturnTypeTexts(tree.rootNode);
|
||||
out.push(...synthesizeKotlinLocalAssignmentBindings(tree.rootNode, returnTypes));
|
||||
out.push(...synthesizeKotlinLoopBindings(tree.rootNode, returnTypes));
|
||||
out.push(...synthesizeKotlinSmartCastBindings(tree.rootNode));
|
||||
|
||||
for (const match of getKotlinScopeQuery().matches(tree.rootNode)) {
|
||||
const grouped: Record<string, Capture> = {};
|
||||
for (const capture of match.captures) {
|
||||
const tag = '@' + capture.name;
|
||||
grouped[tag] = nodeToCapture(tag, capture.node);
|
||||
}
|
||||
if (Object.keys(grouped).length === 0) continue;
|
||||
|
||||
if (grouped['@import.statement'] !== undefined) {
|
||||
const importNode = findNodeAtRange(
|
||||
tree.rootNode,
|
||||
grouped['@import.statement']!.range,
|
||||
'import_header',
|
||||
);
|
||||
if (importNode !== null) {
|
||||
const decomposed = splitKotlinImportHeader(importNode);
|
||||
if (decomposed !== null) {
|
||||
out.push(decomposed);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
grouped['@reference.call.free'] !== undefined &&
|
||||
grouped['@reference.receiver'] !== undefined
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (grouped['@reference.read.member'] !== undefined) {
|
||||
const anchor = grouped['@reference.read.member']!;
|
||||
const navNode = findNodeAtRange(tree.rootNode, anchor.range, 'navigation_expression');
|
||||
if (navNode === null || !shouldEmitReadMember(navNode)) continue;
|
||||
}
|
||||
|
||||
// Virtual dispatch via constructor type (#1762). When a property
|
||||
// declaration carries BOTH an explicit type annotation AND a
|
||||
// constructor-style call value (e.g. `val animal: Animal = Dog()`),
|
||||
// suppress the annotation capture so the constructor-inferred
|
||||
// binding wins. This matches Kotlin's virtual dispatch semantics:
|
||||
// `animal.speak()` should resolve to the overriding `Dog.speak`
|
||||
// (the dynamic type), not `Animal.speak` (the static annotation).
|
||||
//
|
||||
// The annotation source has higher precedence than constructor-
|
||||
// inferred in the generic scope-extractor (see
|
||||
// `typeBindingStrength` in scope-extractor.ts), so the only way to
|
||||
// make the constructor type prevail is to drop the annotation at
|
||||
// emission time.
|
||||
if (
|
||||
grouped['@type-binding.annotation'] !== undefined &&
|
||||
grouped['@type-binding.name'] !== undefined &&
|
||||
grouped['@type-binding.type'] !== undefined
|
||||
) {
|
||||
const annotation = grouped['@type-binding.annotation']!;
|
||||
if (propertyDeclHasConstructorValue(tree.rootNode, annotation.range)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (grouped['@scope.function'] !== undefined) {
|
||||
out.push(grouped);
|
||||
const fnNode = findNodeAtRange(
|
||||
tree.rootNode,
|
||||
grouped['@scope.function']!.range,
|
||||
'function_declaration',
|
||||
);
|
||||
if (fnNode !== null) {
|
||||
out.push(...synthesizeKotlinReceiverBinding(fnNode));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const declTag = FUNCTION_DECL_TAGS.find((tag) => grouped[tag] !== undefined);
|
||||
if (declTag !== undefined) {
|
||||
const fnNode = findNodeAtRange(
|
||||
tree.rootNode,
|
||||
grouped[declTag]!.range,
|
||||
'function_declaration',
|
||||
);
|
||||
if (fnNode !== null) {
|
||||
const arity = computeKotlinArityMetadata(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),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const callTag = (
|
||||
['@reference.call.free', '@reference.call.member', '@reference.call.constructor'] as const
|
||||
).find((tag) => grouped[tag] !== undefined);
|
||||
if (callTag !== undefined && grouped['@reference.arity'] === undefined) {
|
||||
const callNode = findNodeAtRange(tree.rootNode, grouped[callTag]!.range, 'call_expression');
|
||||
if (callNode !== null) {
|
||||
const args = callArguments(callNode);
|
||||
grouped['@reference.arity'] = syntheticCapture(
|
||||
'@reference.arity',
|
||||
callNode,
|
||||
String(args.length),
|
||||
);
|
||||
grouped['@reference.parameter-types'] = syntheticCapture(
|
||||
'@reference.parameter-types',
|
||||
callNode,
|
||||
JSON.stringify(args.map(inferArgType)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
out.push(grouped);
|
||||
|
||||
const extensionFallback = extensionFreeCallFallback(grouped, tree.rootNode);
|
||||
if (extensionFallback !== null) out.push(extensionFallback);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function synthesizeKotlinLoopBindings(
|
||||
rootNode: SyntaxNode,
|
||||
returnTypes: ReadonlyMap<string, string>,
|
||||
): CaptureMatch[] {
|
||||
const out: CaptureMatch[] = [];
|
||||
for (const fnNode of descendantsOfType(rootNode, 'function_declaration')) {
|
||||
const localTypes = collectKotlinLocalTypeTexts(fnNode, returnTypes);
|
||||
for (const forNode of descendantsOfType(fnNode, 'for_statement')) {
|
||||
const variable = forNode.namedChildren.find((child) => child.type === 'variable_declaration');
|
||||
const name = variable?.namedChildren.find((child) => child.type === 'simple_identifier');
|
||||
if (variable === undefined || name === undefined) continue;
|
||||
|
||||
const explicitType = variable.namedChildren.find((child) => isKotlinTypeNode(child));
|
||||
const iterable = forNode.namedChildren.find(
|
||||
(child) => child.id !== variable.id && child.type !== 'control_structure_body',
|
||||
);
|
||||
const rawType =
|
||||
explicitType?.text ??
|
||||
(iterable === undefined
|
||||
? null
|
||||
: inferKotlinIterableElementType(iterable, localTypes, returnTypes));
|
||||
if (rawType === null || rawType.trim() === '') continue;
|
||||
|
||||
const anchor =
|
||||
forNode.namedChildren.find((child) => child.type === 'control_structure_body') ?? forNode;
|
||||
out.push({
|
||||
'@type-binding.annotation': nodeToCapture('@type-binding.annotation', anchor),
|
||||
'@type-binding.name': syntheticCapture('@type-binding.name', name, name.text),
|
||||
'@type-binding.type': syntheticCapture(
|
||||
'@type-binding.type',
|
||||
explicitType ?? iterable ?? name,
|
||||
normalizeKotlinType(rawType),
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synthesize narrowed type-bindings for Kotlin smart-cast forms — issue #1758.
|
||||
*
|
||||
* For each `when (x) { is T -> body }` and `if (x is T) body`, emits a
|
||||
* `@type-binding.annotation` capture binding `x → T` anchored on the body
|
||||
* node. The capture lands in the matching `@scope.block` scope (see query.ts
|
||||
* smart-cast scopes), shadowing the outer parameter binding for calls inside
|
||||
* the body without leaking across sibling arms or to `else`.
|
||||
*
|
||||
* Only narrows when:
|
||||
* - the `when` subject is a `simple_identifier` (not a call or field chain);
|
||||
* - the `when_entry` condition is exactly one `type_test` (skips `!is`,
|
||||
* compound conditions, range/`in`/value patterns);
|
||||
* - the `if_expression` condition is a `check_expression` of the form
|
||||
* `<simple_identifier> is <user_type>` and the then-branch is a
|
||||
* `control_structure_body`.
|
||||
*
|
||||
* `else` arms and non-narrowing conditions emit nothing — the fall-through to
|
||||
* the outer scope's declared type is the correct semantic.
|
||||
*/
|
||||
function synthesizeKotlinSmartCastBindings(rootNode: SyntaxNode): CaptureMatch[] {
|
||||
const out: CaptureMatch[] = [];
|
||||
|
||||
for (const whenNode of descendantsOfType(rootNode, 'when_expression')) {
|
||||
const subjectName = extractWhenSubjectIdentifier(whenNode);
|
||||
if (subjectName === null) continue;
|
||||
|
||||
for (const entry of whenNode.namedChildren) {
|
||||
if (entry.type !== 'when_entry') continue;
|
||||
const narrowedType = extractIsTestTargetType(entry);
|
||||
if (narrowedType === null) continue;
|
||||
const body = entry.namedChildren.find((child) => child.type === 'control_structure_body');
|
||||
if (body === undefined) continue;
|
||||
out.push(buildNarrowedTypeBindingCapture(subjectName.node, body, narrowedType));
|
||||
}
|
||||
}
|
||||
|
||||
for (const ifNode of descendantsOfType(rootNode, 'if_expression')) {
|
||||
const check = ifNode.namedChildren.find((child) => child.type === 'check_expression');
|
||||
if (check === undefined) continue;
|
||||
const subject = check.namedChildren.find((child) => child.type === 'simple_identifier');
|
||||
const typeNode = check.namedChildren.find((child) => isKotlinTypeNode(child));
|
||||
if (subject === undefined || typeNode === undefined) continue;
|
||||
// The first control_structure_body sibling is the then-branch; else
|
||||
// branches (when present) appear as the second control_structure_body
|
||||
// and are intentionally not narrowed.
|
||||
const body = ifNode.namedChildren.find((child) => child.type === 'control_structure_body');
|
||||
if (body === undefined) continue;
|
||||
out.push(buildNarrowedTypeBindingCapture(subject, body, typeNode));
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function extractWhenSubjectIdentifier(whenNode: SyntaxNode): { node: SyntaxNode } | null {
|
||||
const subject = whenNode.namedChildren.find((child) => child.type === 'when_subject');
|
||||
if (subject === undefined) return null;
|
||||
const ident = subject.namedChildren.find((child) => child.type === 'simple_identifier');
|
||||
return ident === undefined ? null : { node: ident };
|
||||
}
|
||||
|
||||
function extractIsTestTargetType(whenEntry: SyntaxNode): SyntaxNode | null {
|
||||
const condition = whenEntry.namedChildren.find((child) => child.type === 'when_condition');
|
||||
if (condition === undefined) return null;
|
||||
// Exactly one when_condition child must be a positive type_test.
|
||||
// Compound conditions (multiple `when_condition` siblings joined with
|
||||
// commas in some grammars) or negated `!is` are not safe to narrow.
|
||||
if (condition.namedChildCount !== 1) return null;
|
||||
const test = condition.namedChild(0);
|
||||
if (test === null || test.type !== 'type_test') return null;
|
||||
// `!is` produces a different node (`negated_type_test` in some grammars,
|
||||
// or an extra `!` child in others) — defend by checking text prefix.
|
||||
if (test.text.trim().startsWith('!')) return null;
|
||||
return test.namedChildren.find((child) => isKotlinTypeNode(child)) ?? null;
|
||||
}
|
||||
|
||||
function buildNarrowedTypeBindingCapture(
|
||||
subject: SyntaxNode,
|
||||
bodyAnchor: SyntaxNode,
|
||||
typeNode: SyntaxNode,
|
||||
): CaptureMatch {
|
||||
return {
|
||||
'@type-binding.annotation': nodeToCapture('@type-binding.annotation', bodyAnchor),
|
||||
'@type-binding.name': syntheticCapture('@type-binding.name', subject, subject.text),
|
||||
'@type-binding.type': syntheticCapture(
|
||||
'@type-binding.type',
|
||||
typeNode,
|
||||
normalizeKotlinType(typeNode.text),
|
||||
),
|
||||
// Marker consumed by `kotlinBindingScopeFor` in simple-hooks.ts to
|
||||
// override the scope-extractor's auto-hoist. Unbraced arm bodies
|
||||
// (`is User -> obj.save()`) make the body anchor coincide with the
|
||||
// Block scope's range; without this marker the binding would hoist
|
||||
// to the enclosing function scope and lose its arm-local narrowing.
|
||||
'@type-binding.narrowed': syntheticCapture('@type-binding.narrowed', bodyAnchor, '1'),
|
||||
};
|
||||
}
|
||||
|
||||
function synthesizeKotlinLocalAssignmentBindings(
|
||||
rootNode: SyntaxNode,
|
||||
returnTypes: ReadonlyMap<string, string>,
|
||||
): CaptureMatch[] {
|
||||
const out: CaptureMatch[] = [];
|
||||
const classMembers = collectKotlinClassMembers(rootNode);
|
||||
for (const fnNode of descendantsOfType(rootNode, 'function_declaration')) {
|
||||
const localTypes = new Map<string, string>();
|
||||
for (const prop of descendantsOfType(fnNode, 'property_declaration')) {
|
||||
const inferred = inferKotlinPropertyType(prop, localTypes, returnTypes, classMembers);
|
||||
if (inferred === null) continue;
|
||||
localTypes.set(inferred.name.text, inferred.rawType);
|
||||
if (inferred.synthetic) {
|
||||
out.push({
|
||||
'@type-binding.annotation': nodeToCapture('@type-binding.annotation', prop),
|
||||
'@type-binding.name': syntheticCapture(
|
||||
'@type-binding.name',
|
||||
inferred.name,
|
||||
inferred.name.text,
|
||||
),
|
||||
'@type-binding.type': syntheticCapture(
|
||||
'@type-binding.type',
|
||||
inferred.source,
|
||||
normalizeKotlinType(inferred.rawType),
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
interface KotlinClassMembers {
|
||||
/** className → fieldName → raw type text */
|
||||
readonly fields: ReadonlyMap<string, ReadonlyMap<string, string>>;
|
||||
/** className → methodName → raw return type text */
|
||||
readonly methods: ReadonlyMap<string, ReadonlyMap<string, string>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-file class-member index — primary-constructor `val`/`var` params,
|
||||
* body property declarations, and method return types. Used by
|
||||
* `inferKotlinPropertyType` to walk single-level field and method chains
|
||||
* like `val addr = user.address` and `val city = addr.getCity()` (#1760).
|
||||
*
|
||||
* Indexes by simple class name only. Multi-class collisions inside a
|
||||
* single file will pick whichever class was visited last for that name
|
||||
* — acceptable because Kotlin forbids same-name top-level classes in
|
||||
* one file and per-file resolution is the design boundary here.
|
||||
*/
|
||||
function collectKotlinClassMembers(rootNode: SyntaxNode): KotlinClassMembers {
|
||||
const fields = new Map<string, Map<string, string>>();
|
||||
const methods = new Map<string, Map<string, string>>();
|
||||
for (const cls of descendantsOfType(rootNode, 'class_declaration')) {
|
||||
const className = cls.namedChildren.find((child) => child.type === 'type_identifier')?.text;
|
||||
if (className === undefined) continue;
|
||||
const fmap = fields.get(className) ?? new Map<string, string>();
|
||||
const mmap = methods.get(className) ?? new Map<string, string>();
|
||||
|
||||
const primary = cls.namedChildren.find((child) => child.type === 'primary_constructor');
|
||||
if (primary !== undefined) {
|
||||
for (const param of primary.namedChildren) {
|
||||
if (param.type !== 'class_parameter') continue;
|
||||
// Constructor params are class fields ONLY when prefixed with
|
||||
// `val`/`var` (binding_pattern_kind). Plain `fn(x: Int)`-style
|
||||
// params remain locals to the constructor.
|
||||
if (param.namedChildren.find((c) => c.type === 'binding_pattern_kind') === undefined) {
|
||||
continue;
|
||||
}
|
||||
const fname = param.namedChildren.find((c) => c.type === 'simple_identifier')?.text;
|
||||
const ftype = param.namedChildren.find((c) => isKotlinTypeNode(c))?.text;
|
||||
if (fname !== undefined && ftype !== undefined) fmap.set(fname, ftype);
|
||||
}
|
||||
}
|
||||
|
||||
const body = cls.namedChildren.find((child) => child.type === 'class_body');
|
||||
if (body !== undefined) {
|
||||
for (const member of body.namedChildren) {
|
||||
if (member.type === 'property_declaration') {
|
||||
const v = member.namedChildren.find((c) => c.type === 'variable_declaration');
|
||||
const fname = v?.namedChildren.find((c) => c.type === 'simple_identifier')?.text;
|
||||
const ftype = v?.namedChildren.find((c) => isKotlinTypeNode(c))?.text;
|
||||
if (fname !== undefined && ftype !== undefined) fmap.set(fname, ftype);
|
||||
} else if (member.type === 'function_declaration') {
|
||||
const mname = member.namedChildren.find((c) => c.type === 'simple_identifier')?.text;
|
||||
const paramsIdx = member.namedChildren.findIndex(
|
||||
(c) => c.type === 'function_value_parameters',
|
||||
);
|
||||
const rtype =
|
||||
paramsIdx < 0
|
||||
? undefined
|
||||
: member.namedChildren.slice(paramsIdx + 1).find((c) => isKotlinTypeNode(c))?.text;
|
||||
if (mname !== undefined && rtype !== undefined) mmap.set(mname, rtype);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fields.set(className, fmap);
|
||||
methods.set(className, mmap);
|
||||
}
|
||||
return { fields, methods };
|
||||
}
|
||||
|
||||
function collectKotlinLocalTypeTexts(
|
||||
fnNode: SyntaxNode,
|
||||
returnTypes: ReadonlyMap<string, string>,
|
||||
): Map<string, string> {
|
||||
const out = new Map<string, string>();
|
||||
for (const node of descendants(fnNode)) {
|
||||
if (node.type === 'parameter') {
|
||||
const name = descendantsOfType(node, 'simple_identifier')[0];
|
||||
const type = node.namedChildren.find((child) => isKotlinTypeNode(child));
|
||||
if (name !== undefined && type !== undefined) out.set(name.text, type.text);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (node.type === 'property_declaration') {
|
||||
const inferred = inferKotlinPropertyType(node, out, returnTypes);
|
||||
if (inferred !== null) out.set(inferred.name.text, inferred.rawType);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function collectKotlinReturnTypeTexts(rootNode: SyntaxNode): Map<string, string> {
|
||||
const out = new Map<string, string>();
|
||||
for (const fnNode of descendantsOfType(rootNode, 'function_declaration')) {
|
||||
const name = fnNode.namedChildren.find((child) => child.type === 'simple_identifier');
|
||||
const paramsIndex = fnNode.namedChildren.findIndex(
|
||||
(child) => child.type === 'function_value_parameters',
|
||||
);
|
||||
const type =
|
||||
paramsIndex < 0
|
||||
? undefined
|
||||
: fnNode.namedChildren.slice(paramsIndex + 1).find((child) => isKotlinTypeNode(child));
|
||||
if (name !== undefined && type !== undefined) out.set(name.text, type.text);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function inferKotlinPropertyType(
|
||||
prop: SyntaxNode,
|
||||
localTypes: ReadonlyMap<string, string>,
|
||||
returnTypes: ReadonlyMap<string, string>,
|
||||
classMembers?: KotlinClassMembers,
|
||||
): { name: SyntaxNode; rawType: string; source: SyntaxNode; synthetic: boolean } | null {
|
||||
const variable = prop.namedChildren.find((child) => child.type === 'variable_declaration');
|
||||
const name = variable?.namedChildren.find((child) => child.type === 'simple_identifier');
|
||||
if (variable === undefined || name === undefined) return null;
|
||||
|
||||
const explicitType = variable.namedChildren.find((child) => isKotlinTypeNode(child));
|
||||
if (explicitType !== undefined) {
|
||||
return { name, rawType: explicitType.text, source: explicitType, synthetic: false };
|
||||
}
|
||||
|
||||
const value = prop.namedChildren.find(
|
||||
(child) => child.id !== variable.id && child.type !== 'binding_pattern_kind',
|
||||
);
|
||||
if (value?.type === 'simple_identifier') {
|
||||
const rawType = localTypes.get(value.text);
|
||||
return rawType === undefined ? null : { name, rawType, source: value, synthetic: true };
|
||||
}
|
||||
|
||||
if (value?.type === 'navigation_expression') {
|
||||
// `val addr = user.address` — receiver type → field on that class (#1760).
|
||||
const chained = inferKotlinNavigationFieldType(value, localTypes, classMembers);
|
||||
if (chained === null) return null;
|
||||
return { name, rawType: chained, source: value, synthetic: true };
|
||||
}
|
||||
|
||||
if (value?.type === 'call_expression') {
|
||||
const callee = value.namedChildren.find(
|
||||
(child) => child.type === 'simple_identifier' || child.type === 'navigation_expression',
|
||||
);
|
||||
if (callee === undefined) return null;
|
||||
if (callee.type === 'simple_identifier') {
|
||||
const rawType =
|
||||
returnTypes.get(callee.text) ?? (isUppercaseName(callee.text) ? callee.text : null);
|
||||
if (rawType === null) return null;
|
||||
return { name, rawType, source: callee, synthetic: true };
|
||||
}
|
||||
// `val city = addr.getCity()` — receiver type → method return on that class (#1760).
|
||||
const chained = inferKotlinNavigationCallReturnType(callee, localTypes, classMembers);
|
||||
if (chained === null) return null;
|
||||
return { name, rawType: chained, source: callee, synthetic: true };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Resolve `receiver.field` → field's declared type, where `receiver`
|
||||
* is a simple identifier whose type is in `localTypes` and `field`
|
||||
* is declared on that type in `classMembers.fields`. Returns null
|
||||
* when any link in the chain is unknown — safe over-conservative. */
|
||||
function inferKotlinNavigationFieldType(
|
||||
nav: SyntaxNode,
|
||||
localTypes: ReadonlyMap<string, string>,
|
||||
classMembers: KotlinClassMembers | undefined,
|
||||
): string | null {
|
||||
if (classMembers === undefined) return null;
|
||||
const receiver = nav.namedChild(0);
|
||||
if (receiver === null || receiver.type !== 'simple_identifier') return null;
|
||||
const member = nav.namedChildren
|
||||
.find((c) => c.type === 'navigation_suffix')
|
||||
?.namedChildren.find((c) => c.type === 'simple_identifier')?.text;
|
||||
if (member === undefined) return null;
|
||||
const recvType = localTypes.get(receiver.text);
|
||||
if (recvType === undefined) return null;
|
||||
return classMembers.fields.get(normalizeKotlinType(recvType))?.get(member) ?? null;
|
||||
}
|
||||
|
||||
/** Resolve `receiver.method()` → method's declared return type, where
|
||||
* `receiver` is a simple identifier whose type is in `localTypes` and
|
||||
* `method` is declared on that type in `classMembers.methods`. */
|
||||
function inferKotlinNavigationCallReturnType(
|
||||
navCallee: SyntaxNode,
|
||||
localTypes: ReadonlyMap<string, string>,
|
||||
classMembers: KotlinClassMembers | undefined,
|
||||
): string | null {
|
||||
if (classMembers === undefined) return null;
|
||||
const receiver = navCallee.namedChild(0);
|
||||
if (receiver === null || receiver.type !== 'simple_identifier') return null;
|
||||
const methodName = navCallee.namedChildren
|
||||
.find((c) => c.type === 'navigation_suffix')
|
||||
?.namedChildren.find((c) => c.type === 'simple_identifier')?.text;
|
||||
if (methodName === undefined) return null;
|
||||
const recvType = localTypes.get(receiver.text);
|
||||
if (recvType === undefined) return null;
|
||||
return classMembers.methods.get(normalizeKotlinType(recvType))?.get(methodName) ?? null;
|
||||
}
|
||||
|
||||
function inferKotlinIterableElementType(
|
||||
iterable: SyntaxNode,
|
||||
localTypes: ReadonlyMap<string, string>,
|
||||
returnTypes: ReadonlyMap<string, string>,
|
||||
): string | null {
|
||||
if (iterable.type === 'simple_identifier') {
|
||||
const raw = localTypes.get(iterable.text);
|
||||
return raw === undefined ? null : kotlinContainerElementType(raw, 'values');
|
||||
}
|
||||
|
||||
if (iterable.type === 'navigation_expression') {
|
||||
const receiver = iterable.namedChildren[0];
|
||||
const member = iterable.namedChildren
|
||||
.find((child) => child.type === 'navigation_suffix')
|
||||
?.namedChildren.find((child) => child.type === 'simple_identifier')?.text;
|
||||
if (receiver?.type !== 'simple_identifier') return null;
|
||||
const raw = localTypes.get(receiver.text);
|
||||
return raw === undefined ? null : kotlinContainerElementType(raw, member ?? 'values');
|
||||
}
|
||||
|
||||
if (iterable.type === 'call_expression') {
|
||||
const callee = iterable.namedChildren.find((child) => child.type === 'simple_identifier');
|
||||
if (callee === undefined) return null;
|
||||
const raw = returnTypes.get(callee.text);
|
||||
if (raw !== undefined) return kotlinContainerElementType(raw, 'values');
|
||||
// Cross-file fallback (#1759): the callee's return type is unknown
|
||||
// locally because the function lives in another file. Emit the
|
||||
// callee name itself as the binding's rawName; `propagateImported
|
||||
// ReturnTypes` will chain-follow `loopvar → callee → <ElementType>`
|
||||
// once the imported module's `callee → ElementType` mirror lands at
|
||||
// module scope. If `callee` isn't actually an imported callable
|
||||
// (e.g. a local lambda or unrelated symbol), chain-follow fails
|
||||
// safely and no edge is emitted.
|
||||
return callee.text;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isUppercaseName(text: string): boolean {
|
||||
return /^[A-Z]/.test(text);
|
||||
}
|
||||
|
||||
function kotlinContainerElementType(rawType: string, member: string): string | null {
|
||||
const parsed = parseKotlinGeneric(rawType);
|
||||
if (parsed === null) return normalizeKotlinType(rawType);
|
||||
|
||||
const base = parsed.base.split('.').pop() ?? parsed.base;
|
||||
if (isKotlinMapType(base)) {
|
||||
if (member === 'keys') return parsed.args[0] ?? null;
|
||||
return parsed.args[1] ?? null;
|
||||
}
|
||||
if (isKotlinIterableType(base)) return parsed.args[0] ?? null;
|
||||
return normalizeKotlinType(rawType);
|
||||
}
|
||||
|
||||
function parseKotlinGeneric(text: string): { base: string; args: string[] } | null {
|
||||
const trimmed = text.trim().replace(/\?$/, '');
|
||||
const open = trimmed.indexOf('<');
|
||||
const close = trimmed.lastIndexOf('>');
|
||||
if (open < 0 || close < open) return null;
|
||||
return {
|
||||
base: trimmed.slice(0, open).trim(),
|
||||
args: splitTopLevelKotlinArgs(trimmed.slice(open + 1, close)),
|
||||
};
|
||||
}
|
||||
|
||||
function splitTopLevelKotlinArgs(text: string): string[] {
|
||||
const out: string[] = [];
|
||||
let depth = 0;
|
||||
let start = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
if (ch === '<') depth++;
|
||||
else if (ch === '>') depth--;
|
||||
else if (ch === ',' && depth === 0) {
|
||||
out.push(text.slice(start, i).trim());
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
out.push(text.slice(start).trim());
|
||||
return out.filter((arg) => arg.length > 0);
|
||||
}
|
||||
|
||||
function isKotlinMapType(base: string): boolean {
|
||||
return ['Map', 'MutableMap', 'HashMap', 'LinkedHashMap'].includes(base);
|
||||
}
|
||||
|
||||
function isKotlinIterableType(base: string): boolean {
|
||||
return [
|
||||
'List',
|
||||
'MutableList',
|
||||
'ArrayList',
|
||||
'Set',
|
||||
'MutableSet',
|
||||
'Collection',
|
||||
'Iterable',
|
||||
'Sequence',
|
||||
'Array',
|
||||
].includes(base);
|
||||
}
|
||||
|
||||
function isKotlinTypeNode(node: SyntaxNode): boolean {
|
||||
return (
|
||||
node.type === 'user_type' || node.type === 'nullable_type' || node.type === 'function_type'
|
||||
);
|
||||
}
|
||||
|
||||
function descendantsOfType(node: SyntaxNode, type: string): SyntaxNode[] {
|
||||
return descendants(node).filter((child) => child.type === type);
|
||||
}
|
||||
|
||||
function descendants(node: SyntaxNode): SyntaxNode[] {
|
||||
const out: SyntaxNode[] = [];
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (child === null) continue;
|
||||
out.push(child, ...descendants(child));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function shouldEmitReadMember(navNode: SyntaxNode): boolean {
|
||||
const parent = navNode.parent;
|
||||
if (parent === null) return true;
|
||||
if (parent.type === 'call_expression') return false;
|
||||
if (parent.type === 'directly_assignable_expression') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** True when the property_declaration anchored at `range` has a
|
||||
* `call_expression` value sibling (i.e. `val x: T = Foo()`). Used to
|
||||
* suppress the explicit-annotation type-binding capture so the
|
||||
* constructor-inferred binding wins (#1762). */
|
||||
function propertyDeclHasConstructorValue(rootNode: SyntaxNode, range: Range): boolean {
|
||||
const propNode = findNodeAtRange(rootNode, range, 'property_declaration');
|
||||
if (propNode === null) return false;
|
||||
const variable = propNode.namedChildren.find((c) => c.type === 'variable_declaration');
|
||||
if (variable === undefined) return false;
|
||||
const value = propNode.namedChildren.find(
|
||||
(c) => c.id !== variable.id && c.type !== 'binding_pattern_kind',
|
||||
);
|
||||
return value?.type === 'call_expression';
|
||||
}
|
||||
|
||||
function callArguments(callNode: SyntaxNode): SyntaxNode[] {
|
||||
const suffix = callNode.namedChildren.find((child) => child.type === 'call_suffix');
|
||||
if (suffix === undefined) return [];
|
||||
|
||||
const valueArgs = suffix?.namedChildren.find((child) => child.type === 'value_arguments');
|
||||
const args = valueArgs?.namedChildren.filter((child) => child.type === 'value_argument') ?? [];
|
||||
const trailingLambdas = suffix.namedChildren.filter((child) => child.type === 'annotated_lambda');
|
||||
return [...args, ...trailingLambdas];
|
||||
}
|
||||
|
||||
function inferArgType(argNode: SyntaxNode): string {
|
||||
const value = argNode.namedChild(0) ?? argNode;
|
||||
switch (value.type) {
|
||||
case 'integer_literal':
|
||||
case 'long_literal':
|
||||
return 'Int';
|
||||
case 'real_literal':
|
||||
return 'Double';
|
||||
case 'string_literal':
|
||||
case 'line_string_literal':
|
||||
case 'multi_line_string_literal':
|
||||
return 'String';
|
||||
case 'character_literal':
|
||||
return 'Char';
|
||||
case 'boolean_literal':
|
||||
return 'Boolean';
|
||||
case 'call_expression': {
|
||||
const first = value.namedChild(0);
|
||||
return first?.type === 'simple_identifier' ? first.text : '';
|
||||
}
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function extensionFreeCallFallback(
|
||||
grouped: Record<string, Capture>,
|
||||
rootNode: SyntaxNode,
|
||||
): CaptureMatch | null {
|
||||
const member = grouped['@reference.call.member'];
|
||||
const receiver = grouped['@reference.receiver'];
|
||||
const name = grouped['@reference.name'];
|
||||
if (member === undefined || receiver === undefined || name === undefined) return null;
|
||||
|
||||
const callNode = findNodeAtRange(rootNode, member.range, 'call_expression');
|
||||
if (callNode === null) return null;
|
||||
const receiverNode = findNodeAtRange(rootNode, receiver.range);
|
||||
if (receiverNode === null || !isLiteralReceiver(receiverNode)) return null;
|
||||
|
||||
const out: Record<string, Capture> = {
|
||||
'@reference.call.free': syntheticCapture('@reference.call.free', callNode, callNode.text),
|
||||
'@reference.name': syntheticCapture('@reference.name', callNode, name.text),
|
||||
};
|
||||
if (grouped['@reference.arity'] !== undefined)
|
||||
out['@reference.arity'] = grouped['@reference.arity'];
|
||||
if (grouped['@reference.parameter-types'] !== undefined) {
|
||||
out['@reference.parameter-types'] = grouped['@reference.parameter-types'];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function isLiteralReceiver(node: SyntaxNode): boolean {
|
||||
return [
|
||||
'integer_literal',
|
||||
'long_literal',
|
||||
'real_literal',
|
||||
'string_literal',
|
||||
'line_string_literal',
|
||||
'multi_line_string_literal',
|
||||
'character_literal',
|
||||
'boolean_literal',
|
||||
].includes(node.type);
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import type { Capture, CaptureMatch } from 'gitnexus-shared';
|
||||
import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
|
||||
type KotlinImportKind = 'named' | 'alias' | 'wildcard';
|
||||
|
||||
interface KotlinImportSpec {
|
||||
readonly kind: KotlinImportKind;
|
||||
readonly source: string;
|
||||
readonly name: string;
|
||||
readonly alias?: string;
|
||||
readonly atNode: SyntaxNode;
|
||||
}
|
||||
|
||||
export function splitKotlinImportHeader(importNode: SyntaxNode): CaptureMatch | null {
|
||||
if (importNode.type !== 'import_header') return null;
|
||||
const spec = parseKotlinImport(importNode);
|
||||
if (spec === null) return null;
|
||||
|
||||
const out: Record<string, Capture> = {
|
||||
'@import.statement': nodeToCapture('@import.statement', importNode),
|
||||
'@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) {
|
||||
out['@import.alias'] = syntheticCapture('@import.alias', spec.atNode, spec.alias);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseKotlinImport(node: SyntaxNode): KotlinImportSpec | null {
|
||||
const identifier = node.namedChildren.find((child) => child.type === 'identifier');
|
||||
if (identifier === undefined) return null;
|
||||
const source = identifier.text.trim();
|
||||
if (source.length === 0) return null;
|
||||
|
||||
const hasWildcard = node.namedChildren.some((child) => child.type === 'wildcard_import');
|
||||
if (hasWildcard) {
|
||||
return { kind: 'wildcard', source, name: '*', atNode: node };
|
||||
}
|
||||
|
||||
const aliasNode = node.namedChildren.find((child) => child.type === 'import_alias');
|
||||
const alias = aliasNode?.namedChildren.find((child) => child.type === 'type_identifier')?.text;
|
||||
const importedName = source.split('.').pop() ?? source;
|
||||
if (alias !== undefined && alias.length > 0) {
|
||||
return { kind: 'alias', source, name: importedName, alias, atNode: node };
|
||||
}
|
||||
return { kind: 'named', source, name: importedName, atNode: node };
|
||||
}
|
||||
158
gitnexus/src/core/ingestion/languages/kotlin/import-target.ts
Normal file
158
gitnexus/src/core/ingestion/languages/kotlin/import-target.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared';
|
||||
|
||||
export interface KotlinResolveContext {
|
||||
readonly fromFile: string;
|
||||
readonly allFilePaths: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
export function resolveKotlinImportTarget(
|
||||
parsedImport: ParsedImport,
|
||||
workspaceIndex: WorkspaceIndex,
|
||||
): string | readonly string[] | null {
|
||||
const ctx = workspaceIndex as KotlinResolveContext | 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;
|
||||
|
||||
const target = parsedImport.targetRaw.endsWith('.*')
|
||||
? parsedImport.targetRaw.slice(0, -2)
|
||||
: parsedImport.targetRaw;
|
||||
const pathLike = target.replace(/\./g, '/');
|
||||
|
||||
// Resolution tiers, most-specific first:
|
||||
// 1. The full `pathLike` matches a `.kt`/`.kts` file directly
|
||||
// (`import util.User` → `util/User.kt`).
|
||||
// 2. Stripped (last-segment removed) `pathLike` matches a file
|
||||
// directly (`import util.OneArg.writeAudit` → `util/OneArg.kt`,
|
||||
// a class-or-object holding `writeAudit`).
|
||||
// 3. Stripped `pathLike` matches a *package directory* — fan out to
|
||||
// every `.kt`/`.kts` file inside it (`import models.getRepo` →
|
||||
// `[models/User.kt, models/Repo.kt]`). The finalize pass walks
|
||||
// each candidate and picks the one whose `localDefs` actually
|
||||
// export the imported name (#1759).
|
||||
// 4. Progressive prefix strip for deeper namespace aliases that
|
||||
// don't map 1:1 to directories.
|
||||
const stripped = pathLike.split('/').slice(0, -1).join('/');
|
||||
return (
|
||||
findKotlinFile(ctx.allFilePaths, pathLike) ??
|
||||
findKotlinExactOrSuffix(ctx.allFilePaths, stripped) ??
|
||||
findKotlinPackageFiles(ctx.allFilePaths, stripped) ??
|
||||
findByProgressivePrefixStrip(ctx.allFilePaths, pathLike)
|
||||
);
|
||||
}
|
||||
|
||||
function findKotlinFile(allFilePaths: ReadonlySet<string>, pathLike: string): string | null {
|
||||
return (
|
||||
findKotlinExactOrSuffix(allFilePaths, pathLike) ??
|
||||
findKotlinDirectoryChild(allFilePaths, pathLike)
|
||||
);
|
||||
}
|
||||
|
||||
/** Exact (`file === pathLike+ext`) or suffix (`file ends with /pathLike+ext`)
|
||||
* match — does NOT fall back to picking an arbitrary file inside a
|
||||
* `pathLike/` directory. Used by the stripped-path tier in
|
||||
* `resolveKotlinImportTarget` so a package import like `models.getRepo`
|
||||
* delegates to `findKotlinPackageFiles` (multi-file fan-out) instead of
|
||||
* silently committing to the first directory child. */
|
||||
function findKotlinExactOrSuffix(
|
||||
allFilePaths: ReadonlySet<string>,
|
||||
pathLike: string,
|
||||
): string | null {
|
||||
if (pathLike === '') return null;
|
||||
const extensions = ['.kt', '.kts'];
|
||||
const suffix = `/${pathLike}`;
|
||||
let suffixFile: string | null = null;
|
||||
|
||||
for (const raw of allFilePaths) {
|
||||
const file = raw.replace(/\\/g, '/');
|
||||
if (!extensions.some((ext) => file.endsWith(ext))) continue;
|
||||
for (const ext of extensions) {
|
||||
if (file === `${pathLike}${ext}`) return raw;
|
||||
if (suffixFile === null && file.endsWith(`${suffix}${ext}`)) suffixFile = raw;
|
||||
}
|
||||
}
|
||||
|
||||
return suffixFile;
|
||||
}
|
||||
|
||||
/** First directory child of `pathLike/` — preserves the legacy single-
|
||||
* file fallback for cases where `pathLike` itself is an unqualified
|
||||
* package reference (rare in real Kotlin code; some fixtures rely on
|
||||
* it). Multi-file package fan-out goes through
|
||||
* `findKotlinPackageFiles` instead. */
|
||||
function findKotlinDirectoryChild(
|
||||
allFilePaths: ReadonlySet<string>,
|
||||
pathLike: string,
|
||||
): string | null {
|
||||
if (pathLike === '') return null;
|
||||
const extensions = ['.kt', '.kts'];
|
||||
const dirPrefix = `${pathLike}/`;
|
||||
const suffixDirPrefix = `/${dirPrefix}`;
|
||||
|
||||
for (const raw of allFilePaths) {
|
||||
const file = raw.replace(/\\/g, '/');
|
||||
if (!extensions.some((ext) => file.endsWith(ext))) continue;
|
||||
const atRoot = file.startsWith(dirPrefix);
|
||||
const atNested = file.includes(suffixDirPrefix);
|
||||
if (!atRoot && !atNested) continue;
|
||||
const idx = atRoot ? 0 : file.indexOf(suffixDirPrefix) + 1;
|
||||
const after = file.slice(idx + dirPrefix.length);
|
||||
if (after.length > 0 && !after.includes('/')) return raw;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return every `.kt`/`.kts` file inside the package directory `dirPath`
|
||||
* (e.g. `models` → `['models/User.kt', 'models/Repo.kt']`). Used as a
|
||||
* fallback when an import like `models.getRepo` does not resolve to a
|
||||
* file named after the symbol — in Kotlin the symbol can live in any
|
||||
* file inside the package directory. The finalize pass walks each
|
||||
* candidate and picks the one whose `localDefs` actually export the
|
||||
* imported name (#1759).
|
||||
*/
|
||||
function findKotlinPackageFiles(
|
||||
allFilePaths: ReadonlySet<string>,
|
||||
dirPath: string,
|
||||
): readonly string[] | null {
|
||||
if (dirPath === '') return null;
|
||||
const extensions = ['.kt', '.kts'];
|
||||
const dirPrefix = `${dirPath}/`;
|
||||
const suffixDirPrefix = `/${dirPrefix}`;
|
||||
const out: string[] = [];
|
||||
|
||||
for (const raw of allFilePaths) {
|
||||
const file = raw.replace(/\\/g, '/');
|
||||
if (!extensions.some((ext) => file.endsWith(ext))) continue;
|
||||
const atRoot = file.startsWith(dirPrefix);
|
||||
const atNested = file.includes(suffixDirPrefix);
|
||||
if (!atRoot && !atNested) continue;
|
||||
const idx = atRoot ? 0 : file.indexOf(suffixDirPrefix) + 1;
|
||||
const after = file.slice(idx + dirPrefix.length);
|
||||
// Direct children only — `models/sub/Util.kt` is a different package
|
||||
// (`models.sub`) and must not be merged with `models`.
|
||||
if (after.length === 0 || after.includes('/')) continue;
|
||||
out.push(raw);
|
||||
}
|
||||
|
||||
return out.length === 0 ? null : out;
|
||||
}
|
||||
|
||||
function findByProgressivePrefixStrip(
|
||||
allFilePaths: ReadonlySet<string>,
|
||||
pathLike: string,
|
||||
): string | null {
|
||||
const segments = pathLike.split('/').filter(Boolean);
|
||||
for (let skip = 1; skip < segments.length; skip++) {
|
||||
const found = findKotlinFile(allFilePaths, segments.slice(skip).join('/'));
|
||||
if (found !== null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
12
gitnexus/src/core/ingestion/languages/kotlin/index.ts
Normal file
12
gitnexus/src/core/ingestion/languages/kotlin/index.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
export { emitKotlinScopeCaptures } from './captures.js';
|
||||
export { getKotlinCaptureCacheStats, resetKotlinCaptureCacheStats } from './cache-stats.js';
|
||||
export { interpretKotlinImport, interpretKotlinTypeBinding } from './interpret.js';
|
||||
export { kotlinArityCompatibility } from './arity.js';
|
||||
export { resolveKotlinImportTarget, type KotlinResolveContext } from './import-target.js';
|
||||
export { kotlinMergeBindings } from './merge-bindings.js';
|
||||
export { populateKotlinOwners } from './owners.js';
|
||||
export {
|
||||
kotlinBindingScopeFor,
|
||||
kotlinImportOwningScope,
|
||||
kotlinReceiverBinding,
|
||||
} from './simple-hooks.js';
|
||||
71
gitnexus/src/core/ingestion/languages/kotlin/interpret.ts
Normal file
71
gitnexus/src/core/ingestion/languages/kotlin/interpret.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'gitnexus-shared';
|
||||
|
||||
export function interpretKotlinImport(captures: CaptureMatch): ParsedImport | null {
|
||||
const kind = captures['@import.kind']?.text;
|
||||
const source = captures['@import.source']?.text;
|
||||
const name = captures['@import.name']?.text;
|
||||
if (kind === undefined || source === undefined) return null;
|
||||
|
||||
switch (kind) {
|
||||
case 'named':
|
||||
return {
|
||||
kind: 'named',
|
||||
localName: name ?? source.split('.').pop() ?? source,
|
||||
importedName: name ?? source.split('.').pop() ?? source,
|
||||
targetRaw: source,
|
||||
};
|
||||
case 'alias': {
|
||||
const alias = captures['@import.alias']?.text;
|
||||
if (alias === undefined || name === undefined) return null;
|
||||
return {
|
||||
kind: 'alias',
|
||||
localName: alias,
|
||||
importedName: name,
|
||||
alias,
|
||||
targetRaw: source,
|
||||
};
|
||||
}
|
||||
case 'wildcard':
|
||||
return { kind: 'wildcard', targetRaw: source.endsWith('.*') ? source : `${source}.*` };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function interpretKotlinTypeBinding(captures: CaptureMatch): ParsedTypeBinding | null {
|
||||
const nameCap = captures['@type-binding.name'];
|
||||
const typeCap = captures['@type-binding.type'];
|
||||
if (nameCap === undefined || typeCap === undefined) return null;
|
||||
|
||||
let source: TypeRef['source'] = 'annotation';
|
||||
if (captures['@type-binding.self'] !== undefined) source = 'self';
|
||||
else if (captures['@type-binding.parameter'] !== undefined) source = 'parameter-annotation';
|
||||
else if (captures['@type-binding.return'] !== undefined) source = 'return-annotation';
|
||||
else if (captures['@type-binding.constructor'] !== undefined) source = 'constructor-inferred';
|
||||
|
||||
return {
|
||||
boundName: nameCap.text,
|
||||
rawTypeName: normalizeKotlinType(typeCap.text),
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeKotlinType(text: string): string {
|
||||
let out = text.trim();
|
||||
while (out.endsWith('?')) out = out.slice(0, -1).trim();
|
||||
const lastDot = out.lastIndexOf('.');
|
||||
if (lastDot >= 0) out = out.slice(lastDot + 1);
|
||||
|
||||
const collection = out.match(
|
||||
/^(?:List|MutableList|ArrayList|Set|MutableSet|Collection|Iterable|Sequence|Array)<([^,<>]+)>$/,
|
||||
);
|
||||
if (collection !== null) return normalizeKotlinType(collection[1]!);
|
||||
|
||||
const map = out.match(/^(?:Map|MutableMap|HashMap|LinkedHashMap)<[^,<>]+,\s*([^,<>]+)>$/);
|
||||
if (map !== null) return normalizeKotlinType(map[1]!);
|
||||
|
||||
const erased = out.match(/^([A-Za-z_][A-Za-z0-9_]*)<.+>$/s);
|
||||
if (erased !== null) return erased[1]!;
|
||||
|
||||
return out;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import type { BindingRef } from 'gitnexus-shared';
|
||||
|
||||
function tierOf(binding: BindingRef): number {
|
||||
switch (binding.origin) {
|
||||
case 'local':
|
||||
return 0;
|
||||
case 'import':
|
||||
case 'namespace':
|
||||
case 'reexport':
|
||||
return 1;
|
||||
case 'wildcard':
|
||||
return 2;
|
||||
default:
|
||||
return 3;
|
||||
}
|
||||
}
|
||||
|
||||
export function kotlinMergeBindings(bindings: readonly BindingRef[]): readonly BindingRef[] {
|
||||
if (bindings.length === 0) return bindings;
|
||||
const best = Math.min(...bindings.map(tierOf));
|
||||
const seen = new Map<string, BindingRef>();
|
||||
for (const binding of bindings) {
|
||||
if (tierOf(binding) === best) seen.set(binding.def.nodeId, binding);
|
||||
}
|
||||
return [...seen.values()];
|
||||
}
|
||||
73
gitnexus/src/core/ingestion/languages/kotlin/owners.ts
Normal file
73
gitnexus/src/core/ingestion/languages/kotlin/owners.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import type { ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared';
|
||||
import { isClassLike, populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js';
|
||||
|
||||
export function populateKotlinOwners(parsed: ParsedFile): void {
|
||||
populateClassOwnedMembers(parsed);
|
||||
populateCompanionMembersOnEnclosingClass(parsed);
|
||||
upgradeClassOwnedFunctionsToMethods(parsed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Align scope-resolution `def.type` with the graph's node-label
|
||||
* conventions: a `Function` def that lives inside a class body becomes
|
||||
* a `Method`. The Kotlin extractor labels every `function_declaration`
|
||||
* as `Function`, but the graph parsing-processor emits a `Method`
|
||||
* graph-node label for class members. Without this realignment,
|
||||
* `resolveDefGraphId`'s parameter-typed key lookup (gated on
|
||||
* `def.type === 'Method'`) falls through to the simple-name fallback
|
||||
* for class methods, collapsing same-name same-arity overloads onto
|
||||
* the first-registered node (#1761).
|
||||
*
|
||||
* Only Method-bearing types are touched. Methods have a class owner
|
||||
* (set by `populateClassOwnedMembers`) and a class-qualified name.
|
||||
*/
|
||||
function upgradeClassOwnedFunctionsToMethods(parsed: ParsedFile): void {
|
||||
for (const def of parsed.localDefs) {
|
||||
if (def.type !== 'Function') continue;
|
||||
if (def.ownerId === undefined) continue;
|
||||
(def as { type: SymbolDefinition['type'] }).type = 'Method';
|
||||
}
|
||||
}
|
||||
|
||||
function populateCompanionMembersOnEnclosingClass(parsed: ParsedFile): void {
|
||||
const scopesById = new Map<ScopeId, ParsedFile['scopes'][number]>();
|
||||
for (const scope of parsed.scopes) scopesById.set(scope.id, scope);
|
||||
|
||||
for (const scope of parsed.scopes) {
|
||||
if (scope.kind !== 'Function' || scope.parent === null) continue;
|
||||
const parent = scopesById.get(scope.parent);
|
||||
if (parent === undefined || parent.kind !== 'Class') continue;
|
||||
if (parent.ownedDefs.some((def) => isClassLike(def.type))) continue;
|
||||
|
||||
const enclosing = findEnclosingClassWithDef(parent.parent, scopesById);
|
||||
if (enclosing === undefined) continue;
|
||||
for (const def of scope.ownedDefs) {
|
||||
if (def.ownerId !== undefined) continue;
|
||||
(def as { ownerId?: string }).ownerId = enclosing.nodeId;
|
||||
qualify(def, enclosing);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findEnclosingClassWithDef(
|
||||
start: ScopeId | null,
|
||||
scopesById: ReadonlyMap<ScopeId, ParsedFile['scopes'][number]>,
|
||||
): SymbolDefinition | undefined {
|
||||
let current = start;
|
||||
while (current !== null) {
|
||||
const scope = scopesById.get(current);
|
||||
if (scope === undefined) return undefined;
|
||||
if (scope.kind === 'Class') {
|
||||
const classDef = scope.ownedDefs.find((def) => isClassLike(def.type));
|
||||
if (classDef !== undefined) return classDef;
|
||||
}
|
||||
current = scope.parent;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function qualify(def: SymbolDefinition, owner: SymbolDefinition): void {
|
||||
if (def.qualifiedName === undefined || def.qualifiedName.includes('.')) return;
|
||||
if (owner.qualifiedName === undefined || owner.qualifiedName.length === 0) return;
|
||||
(def as { qualifiedName: string }).qualifiedName = `${owner.qualifiedName}.${def.qualifiedName}`;
|
||||
}
|
||||
129
gitnexus/src/core/ingestion/languages/kotlin/query.ts
Normal file
129
gitnexus/src/core/ingestion/languages/kotlin/query.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import Parser from 'tree-sitter';
|
||||
import Kotlin from 'tree-sitter-kotlin';
|
||||
|
||||
const KOTLIN_SCOPE_QUERY = `
|
||||
;; Scopes
|
||||
(source_file) @scope.module
|
||||
(class_declaration) @scope.class
|
||||
(object_declaration) @scope.class
|
||||
(companion_object) @scope.class
|
||||
(function_declaration) @scope.function
|
||||
|
||||
;; Smart-cast narrowing scopes (RFC #909 Ring 3, issue #1758).
|
||||
;; Each is-test arm body and each if-then body becomes its own Block
|
||||
;; scope so synthesized narrowed type-bindings (see captures.ts
|
||||
;; synthesizeKotlinSmartCastBindings) shadow the outer parameter
|
||||
;; binding for calls inside the body — without leaking across arms.
|
||||
(when_entry
|
||||
(when_condition (type_test))
|
||||
(control_structure_body) @scope.block)
|
||||
|
||||
(if_expression
|
||||
(check_expression)
|
||||
(control_structure_body) @scope.block)
|
||||
|
||||
;; Declarations — types
|
||||
(class_declaration
|
||||
"interface"
|
||||
(type_identifier) @declaration.name) @declaration.interface
|
||||
|
||||
(class_declaration
|
||||
"class"
|
||||
(type_identifier) @declaration.name) @declaration.class
|
||||
|
||||
(object_declaration
|
||||
(type_identifier) @declaration.name) @declaration.class
|
||||
|
||||
(companion_object
|
||||
(type_identifier) @declaration.name) @declaration.class
|
||||
|
||||
(type_alias
|
||||
(type_identifier) @declaration.name) @declaration.type_alias
|
||||
|
||||
;; Declarations — functions / methods / properties
|
||||
(function_declaration
|
||||
(simple_identifier) @declaration.name) @declaration.function
|
||||
|
||||
(property_declaration
|
||||
(variable_declaration
|
||||
(simple_identifier) @declaration.name)) @declaration.property
|
||||
|
||||
(class_parameter
|
||||
(binding_pattern_kind)
|
||||
(simple_identifier) @declaration.name) @declaration.property
|
||||
|
||||
;; Imports
|
||||
(import_header) @import.statement
|
||||
|
||||
;; Type bindings — parameters
|
||||
(parameter
|
||||
(simple_identifier) @type-binding.name
|
||||
[(user_type) (nullable_type) (function_type)] @type-binding.type) @type-binding.parameter
|
||||
|
||||
;; Type bindings — property / local annotations
|
||||
(property_declaration
|
||||
(variable_declaration
|
||||
(simple_identifier) @type-binding.name
|
||||
[(user_type) (nullable_type) (function_type)] @type-binding.type)) @type-binding.annotation
|
||||
|
||||
(class_parameter
|
||||
(binding_pattern_kind)
|
||||
(simple_identifier) @type-binding.name
|
||||
[(user_type) (nullable_type) (function_type)] @type-binding.type) @type-binding.annotation
|
||||
|
||||
;; Type bindings — constructor-inferred val user = User(...)
|
||||
(property_declaration
|
||||
(variable_declaration
|
||||
(simple_identifier) @type-binding.name)
|
||||
(call_expression
|
||||
(simple_identifier) @type-binding.type)) @type-binding.constructor
|
||||
|
||||
;; Type bindings — return annotations after function parameters
|
||||
(function_declaration
|
||||
(simple_identifier) @type-binding.name
|
||||
(function_value_parameters)
|
||||
[(user_type) (nullable_type) (function_type)] @type-binding.type) @type-binding.return
|
||||
|
||||
;; References — direct calls / constructor syntax
|
||||
(call_expression
|
||||
(simple_identifier) @reference.name) @reference.call.free
|
||||
|
||||
;; References — member calls: obj.method()
|
||||
(call_expression
|
||||
(navigation_expression
|
||||
(_) @reference.receiver
|
||||
(navigation_suffix
|
||||
(simple_identifier) @reference.name))) @reference.call.member
|
||||
|
||||
;; References — property writes
|
||||
(assignment
|
||||
(directly_assignable_expression
|
||||
(_) @reference.receiver
|
||||
(navigation_suffix
|
||||
(simple_identifier) @reference.name))
|
||||
(_)) @reference.write.member
|
||||
|
||||
;; References — property reads
|
||||
(navigation_expression
|
||||
(_) @reference.receiver
|
||||
(navigation_suffix
|
||||
(simple_identifier) @reference.name)) @reference.read.member
|
||||
`;
|
||||
|
||||
let parser: Parser | null = null;
|
||||
let query: Parser.Query | null = null;
|
||||
|
||||
export function getKotlinParser(): Parser {
|
||||
if (parser === null) {
|
||||
parser = new Parser();
|
||||
parser.setLanguage(Kotlin as Parameters<Parser['setLanguage']>[0]);
|
||||
}
|
||||
return parser;
|
||||
}
|
||||
|
||||
export function getKotlinScopeQuery(): Parser.Query {
|
||||
if (query === null) {
|
||||
query = new Parser.Query(Kotlin as Parameters<Parser['setLanguage']>[0], KOTLIN_SCOPE_QUERY);
|
||||
}
|
||||
return query;
|
||||
}
|
||||
106
gitnexus/src/core/ingestion/languages/kotlin/receiver-binding.ts
Normal file
106
gitnexus/src/core/ingestion/languages/kotlin/receiver-binding.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import type { Capture, CaptureMatch } from 'gitnexus-shared';
|
||||
import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
import { normalizeKotlinType } from './interpret.js';
|
||||
|
||||
const TYPE_DECL_NODE_TYPES = new Set([
|
||||
'class_declaration',
|
||||
'object_declaration',
|
||||
'companion_object',
|
||||
]);
|
||||
|
||||
export function synthesizeKotlinReceiverBinding(fnNode: SyntaxNode): CaptureMatch[] {
|
||||
if (fnNode.type !== 'function_declaration') return [];
|
||||
|
||||
const anchorNode = findFunctionBody(fnNode);
|
||||
if (anchorNode === null) return [];
|
||||
|
||||
const extensionReceiver = extensionReceiverType(fnNode);
|
||||
if (extensionReceiver !== null) {
|
||||
return [buildReceiverMatch(anchorNode, 'this', extensionReceiver)];
|
||||
}
|
||||
|
||||
const enclosingType = findEnclosingTypeDeclaration(fnNode);
|
||||
if (enclosingType === null) return [];
|
||||
|
||||
const enclosingName = typeDeclarationName(enclosingType);
|
||||
if (enclosingName === null) return [];
|
||||
|
||||
const out = [buildReceiverMatch(anchorNode, 'this', enclosingName)];
|
||||
const superName = firstSuperclassText(enclosingType);
|
||||
if (superName !== null) out.push(buildReceiverMatch(anchorNode, 'super', superName));
|
||||
return out;
|
||||
}
|
||||
|
||||
function findFunctionBody(fnNode: SyntaxNode): SyntaxNode | null {
|
||||
for (let i = 0; i < fnNode.namedChildCount; i++) {
|
||||
const child = fnNode.namedChild(i);
|
||||
if (child?.type === 'function_body') return child;
|
||||
}
|
||||
return fnNode;
|
||||
}
|
||||
|
||||
function extensionReceiverType(fnNode: SyntaxNode): string | null {
|
||||
for (let i = 0; i < fnNode.namedChildCount; i++) {
|
||||
const child = fnNode.namedChild(i);
|
||||
if (child === null) continue;
|
||||
if (child.type === 'simple_identifier') return null;
|
||||
if (child.type === 'user_type' || child.type === 'nullable_type') {
|
||||
return normalizeKotlinType(child.text);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findEnclosingTypeDeclaration(node: SyntaxNode): SyntaxNode | null {
|
||||
let current = node.parent;
|
||||
while (current !== null) {
|
||||
if (TYPE_DECL_NODE_TYPES.has(current.type)) return current;
|
||||
current = current.parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function typeDeclarationName(typeNode: SyntaxNode): string | null {
|
||||
if (typeNode.type === 'companion_object') {
|
||||
return (
|
||||
typeNode.namedChildren.find((child) => child.type === 'type_identifier')?.text ??
|
||||
enclosingNonCompanionTypeName(typeNode) ??
|
||||
'Companion'
|
||||
);
|
||||
}
|
||||
return typeNode.namedChildren.find((child) => child.type === 'type_identifier')?.text ?? null;
|
||||
}
|
||||
|
||||
function enclosingNonCompanionTypeName(node: SyntaxNode): string | null {
|
||||
let current = node.parent;
|
||||
while (current !== null) {
|
||||
if (current.type === 'class_declaration' || current.type === 'object_declaration') {
|
||||
return current.namedChildren.find((child) => child.type === 'type_identifier')?.text ?? null;
|
||||
}
|
||||
current = current.parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function firstSuperclassText(typeNode: SyntaxNode): string | null {
|
||||
if (typeNode.type !== 'class_declaration') return null;
|
||||
for (const child of typeNode.namedChildren) {
|
||||
if (child.type !== 'delegation_specifier') continue;
|
||||
const ctor = child.namedChildren.find((n) => n.type === 'constructor_invocation');
|
||||
const userType =
|
||||
ctor?.namedChildren.find((n) => n.type === 'user_type') ??
|
||||
child.namedChildren.find((n) => n.type === 'user_type');
|
||||
const name = userType?.namedChildren.find((n) => n.type === 'type_identifier')?.text;
|
||||
if (name !== undefined) return normalizeKotlinType(name);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildReceiverMatch(anchorNode: SyntaxNode, name: string, typeText: string): CaptureMatch {
|
||||
const out: Record<string, Capture> = {
|
||||
'@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 out;
|
||||
}
|
||||
162
gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts
Normal file
162
gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import { SupportedLanguages, type ParsedFile } from 'gitnexus-shared';
|
||||
import type { KnowledgeGraph } from '../../../graph/types.js';
|
||||
import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js';
|
||||
import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
|
||||
import { resolveDefGraphId } from '../../scope-resolution/graph-bridge/ids.js';
|
||||
import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js';
|
||||
import { isClassLike } from '../../scope-resolution/scope/walkers.js';
|
||||
import { kotlinProvider } from '../kotlin.js';
|
||||
import {
|
||||
kotlinArityCompatibility,
|
||||
kotlinMergeBindings,
|
||||
populateKotlinOwners,
|
||||
resolveKotlinImportTarget,
|
||||
type KotlinResolveContext,
|
||||
} from './index.js';
|
||||
|
||||
/**
|
||||
* Kotlin scope resolver for RFC #909 Ring 3.
|
||||
*
|
||||
* Kotlin is intentionally registered but not yet listed in
|
||||
* `MIGRATED_LANGUAGES`, matching the Java migration pattern from #1482:
|
||||
* the resolver can run in shadow/forced mode, while production default
|
||||
* stays on the legacy DAG until the RFC flip criteria in #1746 are met.
|
||||
*
|
||||
* **Forced-mode parity (`REGISTRY_PRIMARY_KOTLIN=1`):** 175/175 fixtures
|
||||
* after the migration sub-issues #1758–#1763 closed. Covers core
|
||||
* import, receiver, companion, default-param, vararg, constructor,
|
||||
* local assignment-chain, collection-iteration, smart casts
|
||||
* (`when (x) { is T -> … }` and `if (x is T)` — #1758), cross-file
|
||||
* iterable return propagation (#1759), single-level method-chain
|
||||
* fixpoint receiver types (#1760), parameter-type-narrowed overload
|
||||
* target-id selection (#1761), virtual dispatch via constructor RHS
|
||||
* (`val x: Animal = Dog()` — #1762), and interface default-method
|
||||
* dispatch via implements-split MRO (#1763).
|
||||
*
|
||||
* **Remaining pre-flip blockers (#1746):** #1755 (forced-mode preview
|
||||
* CI workflow — obviated once Kotlin lands in `MIGRATED_LANGUAGES`
|
||||
* because the existing scope-parity matrix auto-discovers it), #1756
|
||||
* (companion vs instance member dispatch), and #1757 (lambda scopes
|
||||
* and lambda-parameter bindings). The flip PR adds
|
||||
* `SupportedLanguages.Kotlin` to `MIGRATED_LANGUAGES` after the named
|
||||
* blockers close.
|
||||
*/
|
||||
export const kotlinScopeResolver: ScopeResolver = {
|
||||
language: SupportedLanguages.Kotlin,
|
||||
languageProvider: kotlinProvider,
|
||||
importEdgeReason: 'kotlin-scope: import',
|
||||
|
||||
resolveImportTarget: (targetRaw, fromFile, allFilePaths) => {
|
||||
const ws: KotlinResolveContext = { fromFile, allFilePaths };
|
||||
return resolveKotlinImportTarget(
|
||||
{ kind: 'named', localName: '_', importedName: '_', targetRaw },
|
||||
ws,
|
||||
);
|
||||
},
|
||||
|
||||
mergeBindings: (existing, incoming) => [...kotlinMergeBindings([...existing, ...incoming])],
|
||||
|
||||
arityCompatibility: (callsite, def) => kotlinArityCompatibility(def, callsite),
|
||||
|
||||
buildMro: (graph, parsedFiles, nodeLookup) => buildKotlinMro(graph, parsedFiles, nodeLookup),
|
||||
|
||||
populateOwners: (parsed: ParsedFile) => populateKotlinOwners(parsed),
|
||||
|
||||
isSuperReceiver: (text) => text.trim() === 'super',
|
||||
|
||||
fieldFallbackOnMethodLookup: false,
|
||||
propagatesReturnTypesAcrossImports: true,
|
||||
collapseMemberCallsByCallerTarget: false,
|
||||
hoistTypeBindingsToModule: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Kotlin MRO builder — extends `defaultLinearize` (EXTENDS-only) with
|
||||
* interface ancestors discovered via `IMPLEMENTS` edges. Interface
|
||||
* default methods (`interface Validator { fun validate(): Boolean = true }`)
|
||||
* are inherited by implementing classes without an explicit override;
|
||||
* the generic MRO would not surface them because the implementor has
|
||||
* no `EXTENDS` link to the interface (#1763).
|
||||
*
|
||||
* Interfaces are appended after the EXTENDS chain (Kotlin resolves
|
||||
* conflicts by requiring an explicit override, so first-seen-in-MRO
|
||||
* ordering is a reasonable approximation for method lookup). Transitive
|
||||
* interface inheritance (`interface A : B`) is closed via BFS.
|
||||
*/
|
||||
function buildKotlinMro(
|
||||
graph: KnowledgeGraph,
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
nodeLookup: GraphNodeLookup,
|
||||
): Map<string, string[]> {
|
||||
const mro = buildMro(graph, parsedFiles, nodeLookup, defaultLinearize);
|
||||
|
||||
const defIdByGraphId = new Map<string, string>();
|
||||
for (const parsed of parsedFiles) {
|
||||
for (const def of parsed.localDefs) {
|
||||
if (!isClassLike(def.type)) continue;
|
||||
const graphId = resolveDefGraphId(parsed.filePath, def, nodeLookup);
|
||||
if (graphId !== undefined) defIdByGraphId.set(graphId, def.nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
// Direct IMPLEMENTS targets per class-like def.
|
||||
const directImpls = new Map<string, string[]>();
|
||||
for (const rel of graph.iterRelationshipsByType('IMPLEMENTS')) {
|
||||
const source = defIdByGraphId.get(rel.sourceId);
|
||||
const target = defIdByGraphId.get(rel.targetId);
|
||||
if (source === undefined || target === undefined) continue;
|
||||
let list = directImpls.get(source);
|
||||
if (list === undefined) {
|
||||
list = [];
|
||||
directImpls.set(source, list);
|
||||
}
|
||||
if (!list.includes(target)) list.push(target);
|
||||
}
|
||||
|
||||
// For each class, append the transitive closure of interfaces reachable
|
||||
// through its own + ancestor classes' IMPLEMENTS edges. Walking
|
||||
// ancestors picks up interfaces inherited via the EXTENDS chain
|
||||
// (e.g. `class C : B; class B : A; interface A` — C inherits A's
|
||||
// interface methods through B).
|
||||
for (const [classDefId, extendsMro] of mro) {
|
||||
const ancestorChain = [classDefId, ...extendsMro];
|
||||
const seeds: string[] = [];
|
||||
for (const ancestorId of ancestorChain) {
|
||||
for (const ifaceId of directImpls.get(ancestorId) ?? []) {
|
||||
seeds.push(ifaceId);
|
||||
}
|
||||
}
|
||||
if (seeds.length === 0) continue;
|
||||
const interfaces = closeInterfaces(seeds, directImpls);
|
||||
mro.set(classDefId, [...extendsMro, ...interfaces.filter((i) => !extendsMro.includes(i))]);
|
||||
}
|
||||
|
||||
// Classes with no EXTENDS still need an MRO entry when they implement
|
||||
// interfaces (e.g. `class User : Validator` — no `mro` entry from the
|
||||
// EXTENDS-only pass because no EXTENDS edges exist).
|
||||
for (const [classDefId, ifaces] of directImpls) {
|
||||
if (mro.has(classDefId)) continue;
|
||||
mro.set(classDefId, closeInterfaces([...ifaces], directImpls));
|
||||
}
|
||||
|
||||
return mro;
|
||||
}
|
||||
|
||||
function closeInterfaces(
|
||||
seeds: readonly string[],
|
||||
directImpls: ReadonlyMap<string, readonly string[]>,
|
||||
): string[] {
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const queue: string[] = [...seeds];
|
||||
while (queue.length > 0) {
|
||||
const cur = queue.shift()!;
|
||||
if (seen.has(cur)) continue;
|
||||
seen.add(cur);
|
||||
out.push(cur);
|
||||
for (const next of directImpls.get(cur) ?? []) {
|
||||
if (!seen.has(next)) queue.push(next);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
43
gitnexus/src/core/ingestion/languages/kotlin/simple-hooks.ts
Normal file
43
gitnexus/src/core/ingestion/languages/kotlin/simple-hooks.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import type {
|
||||
CaptureMatch,
|
||||
ParsedImport,
|
||||
Scope,
|
||||
ScopeId,
|
||||
ScopeTree,
|
||||
TypeRef,
|
||||
} from 'gitnexus-shared';
|
||||
|
||||
export function kotlinBindingScopeFor(
|
||||
decl: CaptureMatch,
|
||||
innermost: Scope,
|
||||
tree: ScopeTree,
|
||||
): ScopeId | null {
|
||||
// Smart-cast narrowed bindings (issue #1758) must stay at the innermost
|
||||
// (Block) scope. Their anchor coincides with the Block's range for
|
||||
// unbraced arm bodies (`is User -> obj.save()`), which would otherwise
|
||||
// trigger scope-extractor auto-hoist into the enclosing function scope
|
||||
// and erase the arm-local narrowing.
|
||||
if (decl['@type-binding.narrowed'] !== undefined) return innermost.id;
|
||||
|
||||
if (decl['@type-binding.return'] === undefined) return null;
|
||||
|
||||
let current: Scope | undefined = innermost;
|
||||
while (current !== undefined && current.kind !== 'Module') {
|
||||
if (current.parent === null) break;
|
||||
current = tree.getScope(current.parent);
|
||||
}
|
||||
return current?.kind === 'Module' ? current.id : null;
|
||||
}
|
||||
|
||||
export function kotlinImportOwningScope(
|
||||
_imp: ParsedImport,
|
||||
_innermost: Scope,
|
||||
_tree: ScopeTree,
|
||||
): ScopeId | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function kotlinReceiverBinding(functionScope: Scope): TypeRef | null {
|
||||
if (functionScope.kind !== 'Function') return null;
|
||||
return functionScope.typeBindings.get('this') ?? functionScope.typeBindings.get('super') ?? null;
|
||||
}
|
||||
|
|
@ -56,6 +56,16 @@ import {
|
|||
typescriptArityCompatibility,
|
||||
resolveTsImportTarget,
|
||||
} from './typescript/index.js';
|
||||
import {
|
||||
emitJsScopeCaptures,
|
||||
interpretJsImport,
|
||||
interpretJsTypeBinding,
|
||||
jsBindingScopeFor,
|
||||
jsImportOwningScope,
|
||||
jsReceiverBinding,
|
||||
jsMergeBindings,
|
||||
jsArityCompatibility,
|
||||
} from './javascript/index.js';
|
||||
|
||||
/**
|
||||
* TypeScript/JavaScript: arrow_function and function_expression are
|
||||
|
|
@ -359,4 +369,19 @@ export const javascriptProvider = defineLanguage({
|
|||
classExtractor: createClassExtractor(javascriptClassConfig),
|
||||
heritageExtractor: createHeritageExtractor(SupportedLanguages.JavaScript),
|
||||
builtInNames: BUILT_INS,
|
||||
|
||||
// ── RFC #909 Ring 3: scope-based resolution hooks (RFC §5) ──────────
|
||||
// JavaScript is the fourth migration after Python, C#, and TypeScript.
|
||||
// Hooks are thin wrappers over the TypeScript implementations where
|
||||
// semantics are identical; JS-specific additions (CJS require(),
|
||||
// JSDoc type bindings) live in ./javascript/captures.ts.
|
||||
// See ./javascript/index.ts for the full per-module rationale.
|
||||
emitScopeCaptures: emitJsScopeCaptures,
|
||||
interpretImport: interpretJsImport,
|
||||
interpretTypeBinding: interpretJsTypeBinding,
|
||||
bindingScopeFor: jsBindingScopeFor,
|
||||
importOwningScope: jsImportOwningScope,
|
||||
mergeBindings: (_scope, bindings) => jsMergeBindings(bindings),
|
||||
receiverBinding: jsReceiverBinding,
|
||||
arityCompatibility: jsArityCompatibility,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ const CALL_TAGS = [
|
|||
'@reference.call.constructor',
|
||||
] as const;
|
||||
|
||||
function pickFirstDefined(grouped: CaptureMatch, tags: readonly string[]): Capture | undefined {
|
||||
function pickFirstCapture(grouped: CaptureMatch, tags: readonly string[]): Capture | undefined {
|
||||
for (const tag of tags) {
|
||||
const cap = grouped[tag];
|
||||
if (cap !== undefined) return cap;
|
||||
|
|
@ -72,6 +72,17 @@ function pickFirstDefined(grouped: CaptureMatch, tags: readonly string[]): Captu
|
|||
return undefined;
|
||||
}
|
||||
|
||||
function pickFirstNode(
|
||||
grouped: Record<string, SyntaxNode | undefined>,
|
||||
tags: readonly string[],
|
||||
): SyntaxNode | undefined {
|
||||
for (const tag of tags) {
|
||||
const node = grouped[tag];
|
||||
if (node !== undefined) return node;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop `@reference.read.member` matches whose underlying `member_expression`
|
||||
* is NOT actually a read context:
|
||||
|
|
@ -113,6 +124,34 @@ function shouldEmitReadMember(memberNode: SyntaxNode): boolean {
|
|||
}
|
||||
}
|
||||
|
||||
/** Walks the parent chain from `node` (inclusive), returning the first node
|
||||
* whose type matches, or null. Faster than `findNodeAtRange` when the caller
|
||||
* already holds the anchor node — avoids re-scanning the tree from the root. */
|
||||
function findSelfOrAncestorOfType(node: SyntaxNode | undefined, type: string): SyntaxNode | null {
|
||||
if (node === undefined) return null;
|
||||
let current: SyntaxNode | null = node;
|
||||
while (current !== null) {
|
||||
if (current.type === type) return current;
|
||||
current = current.parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Walks the parent chain from `node` (inclusive), returning the first node
|
||||
* whose type is in the set, or null. Plural form of {@link findSelfOrAncestorOfType}. */
|
||||
function findSelfOrAncestorOfTypes(
|
||||
node: SyntaxNode | undefined,
|
||||
types: readonly string[],
|
||||
): SyntaxNode | null {
|
||||
if (node === undefined) return null;
|
||||
let current: SyntaxNode | null = node;
|
||||
while (current !== null) {
|
||||
if (types.includes(current.type)) return current;
|
||||
current = current.parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function emitTsScopeCaptures(
|
||||
sourceText: string,
|
||||
filePath: string,
|
||||
|
|
@ -151,9 +190,11 @@ export function emitTsScopeCaptures(
|
|||
// `@`; we put it back so the central extractor's prefix lookups
|
||||
// (`@scope.`, `@declaration.`, …) work.
|
||||
const grouped: Record<string, Capture> = {};
|
||||
const groupedNodes: Record<string, SyntaxNode> = {};
|
||||
for (const c of m.captures) {
|
||||
const tag = '@' + c.name;
|
||||
grouped[tag] = nodeToCapture(tag, c.node);
|
||||
groupedNodes[tag] = c.node;
|
||||
}
|
||||
if (Object.keys(grouped).length === 0) continue;
|
||||
|
||||
|
|
@ -165,6 +206,10 @@ export function emitTsScopeCaptures(
|
|||
if (grouped['@import.statement'] !== undefined) {
|
||||
const stmtCapture = grouped['@import.statement'];
|
||||
const stmtNode =
|
||||
findSelfOrAncestorOfTypes(groupedNodes['@import.statement'], [
|
||||
'import_statement',
|
||||
'export_statement',
|
||||
]) ??
|
||||
findNodeAtRange(tree.rootNode, stmtCapture.range, 'import_statement') ??
|
||||
findNodeAtRange(tree.rootNode, stmtCapture.range, 'export_statement');
|
||||
if (stmtNode !== null) {
|
||||
|
|
@ -183,7 +228,9 @@ export function emitTsScopeCaptures(
|
|||
// `splitDynamicImport` branch consumes.
|
||||
if (grouped['@import.dynamic'] !== undefined) {
|
||||
const dynCapture = grouped['@import.dynamic'];
|
||||
const callNode = findNodeAtRange(tree.rootNode, dynCapture.range, 'call_expression');
|
||||
const callNode =
|
||||
findSelfOrAncestorOfType(groupedNodes['@import.dynamic'], 'call_expression') ??
|
||||
findNodeAtRange(tree.rootNode, dynCapture.range, 'call_expression');
|
||||
if (callNode !== null) {
|
||||
const decomposed = splitImportStatement(callNode);
|
||||
for (const d of decomposed) out.push(d);
|
||||
|
|
@ -197,7 +244,9 @@ export function emitTsScopeCaptures(
|
|||
// 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');
|
||||
const memberNode =
|
||||
findSelfOrAncestorOfType(groupedNodes['@reference.read.member'], 'member_expression') ??
|
||||
findNodeAtRange(tree.rootNode, anchor.range, 'member_expression');
|
||||
if (memberNode === null || !shouldEmitReadMember(memberNode)) {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -208,9 +257,10 @@ export function emitTsScopeCaptures(
|
|||
// overloads — TypeScript supports overload signatures via
|
||||
// function_signature, so `parameterTypes` is populated when
|
||||
// available.
|
||||
const declAnchor = pickFirstDefined(grouped, FUNCTION_DECL_TAGS);
|
||||
const declAnchor = pickFirstCapture(grouped, FUNCTION_DECL_TAGS);
|
||||
const declAnchorNode = pickFirstNode(groupedNodes, FUNCTION_DECL_TAGS);
|
||||
if (declAnchor !== undefined) {
|
||||
const fnNode = findFunctionNode(tree.rootNode, declAnchor.range);
|
||||
const fnNode = findFunctionNode(tree.rootNode, declAnchor.range, declAnchorNode);
|
||||
if (fnNode !== null) {
|
||||
const arity = computeTsArityMetadata(fnNode);
|
||||
if (arity.parameterCount !== undefined) {
|
||||
|
|
@ -255,9 +305,11 @@ export function emitTsScopeCaptures(
|
|||
// calls to disambiguate by props-arity, a JSX-aware arity
|
||||
// synthesizer would need to count `jsx_attribute` children of the
|
||||
// opening tag instead of `arguments`.
|
||||
const callAnchor = pickFirstDefined(grouped, CALL_TAGS);
|
||||
const callAnchor = pickFirstCapture(grouped, CALL_TAGS);
|
||||
const callAnchorNode = pickFirstNode(groupedNodes, CALL_TAGS);
|
||||
if (callAnchor !== undefined && grouped['@reference.arity'] === undefined) {
|
||||
const callNode =
|
||||
findSelfOrAncestorOfTypes(callAnchorNode, ['call_expression', 'new_expression']) ??
|
||||
findNodeAtRange(tree.rootNode, callAnchor.range, 'call_expression') ??
|
||||
findNodeAtRange(tree.rootNode, callAnchor.range, 'new_expression');
|
||||
if (callNode !== null) {
|
||||
|
|
@ -293,7 +345,11 @@ export function emitTsScopeCaptures(
|
|||
// lookup instead of synthesis — covered by `tsReceiverBinding`.
|
||||
const scopeFnAnchor = grouped['@scope.function'];
|
||||
if (scopeFnAnchor !== undefined) {
|
||||
const fnNode = findFunctionNode(tree.rootNode, scopeFnAnchor.range);
|
||||
const fnNode = findFunctionNode(
|
||||
tree.rootNode,
|
||||
scopeFnAnchor.range,
|
||||
groupedNodes['@scope.function'],
|
||||
);
|
||||
if (fnNode !== null) {
|
||||
const synth = synthesizeTsReceiverBinding(fnNode);
|
||||
if (synth !== null) out.push(synth);
|
||||
|
|
@ -518,7 +574,13 @@ function inferArgType(argNode: SyntaxNode): string {
|
|||
* 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 {
|
||||
function findFunctionNode(
|
||||
rootNode: SyntaxNode,
|
||||
range: Capture['range'],
|
||||
anchorNode?: SyntaxNode,
|
||||
): SyntaxNode | null {
|
||||
const fromAnchor = findSelfOrAncestorOfTypes(anchorNode, FUNCTION_NODE_TYPES);
|
||||
if (fromAnchor !== null) return fromAnchor;
|
||||
for (const nodeType of FUNCTION_NODE_TYPES) {
|
||||
const n = findNodeAtRange(rootNode, range, nodeType);
|
||||
if (n !== null) return n;
|
||||
|
|
|
|||
|
|
@ -75,8 +75,11 @@ export function tsBindingScopeFor(
|
|||
* 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).
|
||||
*
|
||||
* Exported so language-specific hook wrappers (e.g. `jsBindingScopeFor`)
|
||||
* can reuse it without duplicating the traversal logic.
|
||||
*/
|
||||
function walkToScope(
|
||||
export function walkToScope(
|
||||
from: Scope,
|
||||
tree: ScopeTree,
|
||||
...kinds: readonly Scope['kind'][]
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@
|
|||
|
||||
import type { ResolutionContext } from './resolution-context.js';
|
||||
import { getLanguageFromFilename, type SupportedLanguages } from 'gitnexus-shared';
|
||||
import {
|
||||
isDeferredResolutionProfileEnabled,
|
||||
logDeferredProfile,
|
||||
} from '../utils/deferred-resolution-profile.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ExtractedHeritage — the shape produced by the parse worker / heritage
|
||||
|
|
@ -176,11 +180,35 @@ export const buildHeritageMap = (
|
|||
// interfaceName → Set<filePath> (implementor lookup for interface dispatch)
|
||||
const implementorFiles = new Map<string, Set<string>>();
|
||||
|
||||
const profileHeritage = isDeferredResolutionProfileEnabled();
|
||||
let maxNameCartesian = 0;
|
||||
let ambiguousHeritageRecords = 0;
|
||||
let unresolvedChildLookups = 0;
|
||||
let unresolvedParentLookups = 0;
|
||||
|
||||
for (const h of heritage) {
|
||||
// ── Parent lookup (nodeId-based) ────────────────────────────────
|
||||
const childDefs = ctx.model.types.lookupClassByName(h.className);
|
||||
const parentDefs = ctx.model.types.lookupClassByName(h.parentName);
|
||||
|
||||
// Unresolved-side counters live in a separate guard so they observe
|
||||
// records the ambiguity block below skips. On JVM monorepos the
|
||||
// pathological fan-out case is precisely "many same-named children
|
||||
// with an unresolved external supertype" (or the inverse) — both
|
||||
// sides non-empty is the case `ambiguousHeritageRecords` already
|
||||
// covers; the unresolved cases were silently dropped from the
|
||||
// metric before this counter.
|
||||
if (profileHeritage) {
|
||||
if (childDefs.length === 0) unresolvedChildLookups++;
|
||||
if (parentDefs.length === 0) unresolvedParentLookups++;
|
||||
}
|
||||
|
||||
if (profileHeritage && childDefs.length > 0 && parentDefs.length > 0) {
|
||||
const product = childDefs.length * parentDefs.length;
|
||||
if (product > 1) ambiguousHeritageRecords++;
|
||||
if (product > maxNameCartesian) maxNameCartesian = product;
|
||||
}
|
||||
|
||||
if (childDefs.length > 0 && parentDefs.length > 0) {
|
||||
for (const child of childDefs) {
|
||||
for (const parent of parentDefs) {
|
||||
|
|
@ -368,6 +396,17 @@ export const buildHeritageMap = (
|
|||
return implementorFiles.get(interfaceName) ?? EMPTY_SET;
|
||||
};
|
||||
|
||||
if (profileHeritage) {
|
||||
logDeferredProfile(
|
||||
`buildHeritageMap: ${heritage.length} heritage records, ` +
|
||||
`${ambiguousHeritageRecords} with child×parent lookup product >1, ` +
|
||||
`max product ${maxNameCartesian}, ` +
|
||||
`${unresolvedChildLookups} unresolved child lookups, ` +
|
||||
`${unresolvedParentLookups} unresolved parent lookups, ` +
|
||||
`${implementorFiles.size} interface implementor keys`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
getParents,
|
||||
getAncestors,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { isVerboseIngestionEnabled } from './utils/verbose.js';
|
|||
import {
|
||||
getDefinitionNodeFromCaptures,
|
||||
findEnclosingClassInfo,
|
||||
findObjectLiteralBindingInfo,
|
||||
getLabelFromCaptures,
|
||||
CLASS_CONTAINER_TYPES,
|
||||
type SyntaxNode,
|
||||
|
|
@ -531,6 +532,10 @@ const processParsingSequential = async (
|
|||
)
|
||||
: null;
|
||||
const enclosingClassId = enclosingClassInfo?.classId ?? null;
|
||||
const objectLiteralOwnerInfo =
|
||||
!enclosingClassId && nodeLabel === 'Method' && definitionNode
|
||||
? findObjectLiteralBindingInfo(definitionNode, file.path)
|
||||
: null;
|
||||
|
||||
// Qualify method/property IDs with enclosing class name to avoid collisions
|
||||
// e.g. "Method:animal.dart:Animal.speak" vs "Method:animal.dart:Dog.speak"
|
||||
|
|
@ -785,7 +790,7 @@ const processParsingSequential = async (
|
|||
returnType: methodProps.returnType as string | undefined,
|
||||
declaredType,
|
||||
templateArguments: classTemplateArguments,
|
||||
ownerId: enclosingClassId ?? undefined,
|
||||
ownerId: enclosingClassId ?? objectLiteralOwnerInfo?.ownerId ?? undefined,
|
||||
qualifiedName: qualifiedTypeName,
|
||||
});
|
||||
|
||||
|
|
@ -805,15 +810,18 @@ const processParsingSequential = async (
|
|||
graph.addRelationship(relationship);
|
||||
|
||||
// ── HAS_METHOD / HAS_PROPERTY: link member to enclosing class ──
|
||||
if (enclosingClassId) {
|
||||
const ownerIdForMemberEdge = enclosingClassId ?? objectLiteralOwnerInfo?.ownerId ?? null;
|
||||
if (ownerIdForMemberEdge) {
|
||||
const memberEdgeType = nodeLabel === 'Property' ? 'HAS_PROPERTY' : 'HAS_METHOD';
|
||||
graph.addRelationship({
|
||||
id: generateId(memberEdgeType, `${enclosingClassId}->${nodeId}`),
|
||||
sourceId: enclosingClassId,
|
||||
id: generateId(memberEdgeType, `${ownerIdForMemberEdge}->${nodeId}`),
|
||||
sourceId: ownerIdForMemberEdge,
|
||||
targetId: nodeId,
|
||||
type: memberEdgeType,
|
||||
confidence: 1.0,
|
||||
reason: '',
|
||||
reason: objectLiteralOwnerInfo
|
||||
? 'object literal method belongs to exported object binding'
|
||||
: '',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -832,6 +840,14 @@ const processParsingSequential = async (
|
|||
// Public API
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Per-`WorkerPool` log-dedup state for quarantine reporting. Keyed on the
|
||||
* pool instance so multiple concurrent pools (test fixtures, future
|
||||
* multi-pool callers) each get their own seen-set. WeakMap entries vanish
|
||||
* when the pool is garbage-collected.
|
||||
*/
|
||||
const loggedQuarantineByPool = new WeakMap<WorkerPool, Set<string>>();
|
||||
|
||||
export const processParsing = async (
|
||||
graph: KnowledgeGraph,
|
||||
files: { path: string; content: string }[],
|
||||
|
|
@ -874,25 +890,75 @@ export const processParsing = async (
|
|||
`[scope-resolution prof] worker pool engaged for ${files.length} files — cross-phase tree cache will be empty; scope-resolution re-parses.`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
return await processParsingWithWorkers(
|
||||
graph,
|
||||
files,
|
||||
symbolTable,
|
||||
astCache,
|
||||
workerPool,
|
||||
reportProgress,
|
||||
outRawResults,
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logger.warn({ message }, 'Worker pool parsing stopped; continuing with sequential parser:');
|
||||
reportProgress?.(
|
||||
lastProgress,
|
||||
files.length,
|
||||
`Sequential fallback after worker issue: ${message}`,
|
||||
);
|
||||
// U20 design pivot: the worker pool's resilience layers
|
||||
// (respawn budget, circuit breaker, quarantine, slot-attribution,
|
||||
// cumulative timeout) are the SOLE contract for handling worker
|
||||
// failures. There is no sequential-parser fallback for either
|
||||
// partial quarantine or full pool failure — the operator must see
|
||||
// a clear hard signal when workers can't recover, instead of a
|
||||
// silently-degraded graph from a possibly-crashing main-thread
|
||||
// sequential parser. A failing tree-sitter native binding that
|
||||
// quarantined a worker would, under the previous design, re-trigger
|
||||
// the same SIGSEGV on the main thread; we avoid that risk entirely.
|
||||
//
|
||||
// - Partial quarantine: the file is missing from this run's graph;
|
||||
// the per-chunk warn log below surfaces it; U2's chunk-cache
|
||||
// write-guard in parse-impl.ts keeps the chunk uncached so the
|
||||
// next analyze gets a cache miss and a fresh pool retries.
|
||||
// - Full pool failure: `WorkerPoolDispatchError` propagates from
|
||||
// `processParsingWithWorkers` up through this function. The
|
||||
// analyze run errors out instead of falling back to sequential.
|
||||
const data = await processParsingWithWorkers(
|
||||
graph,
|
||||
files,
|
||||
symbolTable,
|
||||
astCache,
|
||||
workerPool,
|
||||
reportProgress,
|
||||
outRawResults,
|
||||
);
|
||||
// Session-scoped quarantine (worker-pool resilience Layer 3): surface
|
||||
// any files this pool has decided are unsafe for workers so the
|
||||
// operator can see what was skipped. The pool already filtered them
|
||||
// out of dispatch; we only need to log + progress-report. Quarantine
|
||||
// is session-scoped per pool instance — a fresh `createWorkerPool`
|
||||
// call clears it.
|
||||
//
|
||||
// Dedup: log full path list only for entries newly quarantined since
|
||||
// the previous dispatch on the same pool. The per-chunk progress
|
||||
// message still surfaces the count for UX continuity, but the
|
||||
// structured `quarantinedFiles` payload is only emitted when there
|
||||
// is new signal — prevents O(quarantine × chunks) log spam.
|
||||
const quarantineSnapshot = workerPool.getQuarantinedPaths?.() ?? [];
|
||||
const quarantineSet = new Set(quarantineSnapshot);
|
||||
if (quarantineSet.size > 0) {
|
||||
const quarantinedInChunk = files.filter((file) => quarantineSet.has(file.path));
|
||||
if (quarantinedInChunk.length > 0) {
|
||||
const seenForPool = loggedQuarantineByPool.get(workerPool) ?? new Set<string>();
|
||||
const newlyQuarantined = quarantinedInChunk
|
||||
.map((file) => file.path)
|
||||
.filter((p) => !seenForPool.has(p));
|
||||
for (const p of newlyQuarantined) seenForPool.add(p);
|
||||
loggedQuarantineByPool.set(workerPool, seenForPool);
|
||||
if (newlyQuarantined.length > 0) {
|
||||
logger.warn(
|
||||
{
|
||||
newlyQuarantined,
|
||||
cumulativeQuarantine: quarantineSet.size,
|
||||
chunkSkipped: quarantinedInChunk.length,
|
||||
},
|
||||
`Worker quarantine: ${newlyQuarantined.length} new file(s) skipped this chunk ` +
|
||||
`(${quarantinedInChunk.length} skipped total, ${quarantineSet.size} cumulative).`,
|
||||
);
|
||||
}
|
||||
reportProgress?.(
|
||||
lastProgress,
|
||||
files.length,
|
||||
`${quarantinedInChunk.length} worker-quarantined file(s) skipped`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
// Fallback: sequential parsing (no pre-extracted data)
|
||||
|
|
|
|||
|
|
@ -48,13 +48,14 @@ import { ASTCache, createASTCache } from '../ast-cache.js';
|
|||
import { type PipelineProgress, getLanguageFromFilename } from 'gitnexus-shared';
|
||||
import { readFileContents } from '../filesystem-walker.js';
|
||||
import { isLanguageAvailable } from '../../tree-sitter/parser-loader.js';
|
||||
import { createWorkerPool } from '../workers/worker-pool.js';
|
||||
import { createWorkerPool, WorkerPoolInitializationError } from '../workers/worker-pool.js';
|
||||
import type { WorkerPool } from '../workers/worker-pool.js';
|
||||
import type {
|
||||
ExtractedAssignment,
|
||||
ExtractedCall,
|
||||
ExtractedDecoratorRoute,
|
||||
ExtractedFetchCall,
|
||||
ExtractedImport,
|
||||
ExtractedORMQuery,
|
||||
ExtractedRoute,
|
||||
ExtractedToolDef,
|
||||
|
|
@ -69,6 +70,13 @@ import path from 'node:path';
|
|||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
import { isDev } from '../utils/env.js';
|
||||
import { isVerboseIngestionEnabled } from '../utils/verbose.js';
|
||||
import {
|
||||
endTimer,
|
||||
isDeferredResolutionProfileEnabled,
|
||||
logDeferredProfile,
|
||||
startTimer,
|
||||
} from '../utils/deferred-resolution-profile.js';
|
||||
import { synthesizeWildcardImportBindings, needsSynthesis } from './wildcard-synthesis.js';
|
||||
import { extractORMQueriesInline } from './orm-extraction.js';
|
||||
|
||||
|
|
@ -85,11 +93,24 @@ import { logger } from '../../logger.js';
|
|||
* gives a useful invalidation floor (~1/N chunks on a multi-MB repo)
|
||||
* while keeping worker dispatch overhead under 5% on cold runs.
|
||||
*/
|
||||
const CHUNK_BYTE_BUDGET = (() => {
|
||||
/**
|
||||
* Built-in chunk byte budget when neither `PipelineOptions.chunkByteBudget`
|
||||
* nor `GITNEXUS_CHUNK_BYTE_BUDGET` is set. Tuned to give a useful
|
||||
* cache-invalidation floor (~1/N chunks on a multi-MB repo) while keeping
|
||||
* worker dispatch overhead under 5% on cold runs. Resolution happens at
|
||||
* call time inside `runChunkedParseAndResolve` (U14 from PR #1693 review)
|
||||
* — previously this was a module-load IIFE, which froze the env value at
|
||||
* import time and meant per-call option threading silently no-op'd.
|
||||
*/
|
||||
const DEFAULT_CHUNK_BYTE_BUDGET = 2 * 1024 * 1024;
|
||||
|
||||
function resolveChunkByteBudget(options?: PipelineOptions): number {
|
||||
const opt = options?.chunkByteBudget;
|
||||
if (typeof opt === 'number' && Number.isFinite(opt) && opt > 0) return opt;
|
||||
const env = Number(process.env.GITNEXUS_CHUNK_BYTE_BUDGET);
|
||||
if (Number.isFinite(env) && env > 0) return env;
|
||||
return 2 * 1024 * 1024;
|
||||
})();
|
||||
return DEFAULT_CHUNK_BYTE_BUDGET;
|
||||
}
|
||||
|
||||
// ── Main parse + resolve function ──────────────────────────────────────────
|
||||
|
||||
|
|
@ -177,18 +198,28 @@ export async function runChunkedParseAndResolve(
|
|||
if (totalParseable === 0) {
|
||||
onProgress({
|
||||
phase: 'parsing',
|
||||
percent: 82,
|
||||
// Skip directly to the end of the parse-phase progress band (M2 from PR
|
||||
// #1693 review). Parse 20-70%, deferred 70-95%; nothing in either runs
|
||||
// when there's no parseable file, so jump to 95.
|
||||
percent: 95,
|
||||
message: 'No parseable files found — skipping parsing phase',
|
||||
stats: { filesProcessed: 0, totalFiles: 0, nodesCreated: graph.nodeCount },
|
||||
});
|
||||
}
|
||||
|
||||
// Build byte-budget chunks
|
||||
// Build byte-budget chunks. The budget is resolved per-call (U14): options
|
||||
// first, then env, then the built-in default. Pre-U14 this was a
|
||||
// module-load IIFE constant, which froze the env value at import time
|
||||
// and made `PipelineOptions.chunkByteBudget` silently no-op on warm test
|
||||
// runs. Resolving in the function body restores per-call configurability
|
||||
// and matches the pattern used by resolveAutoPoolSize and the U1
|
||||
// parseChunkConcurrency resolver.
|
||||
const chunkByteBudget = resolveChunkByteBudget(options);
|
||||
const chunks: string[][] = [];
|
||||
let currentChunk: string[] = [];
|
||||
let currentBytes = 0;
|
||||
for (const file of parseableScanned) {
|
||||
if (currentChunk.length > 0 && currentBytes + file.size > CHUNK_BYTE_BUDGET) {
|
||||
if (currentChunk.length > 0 && currentBytes + file.size > chunkByteBudget) {
|
||||
chunks.push(currentChunk);
|
||||
currentChunk = [];
|
||||
currentBytes = 0;
|
||||
|
|
@ -203,16 +234,22 @@ export async function runChunkedParseAndResolve(
|
|||
if (isDev) {
|
||||
const totalMB = parseableScanned.reduce((s, f) => s + f.size, 0) / (1024 * 1024);
|
||||
logger.info(
|
||||
`📂 Scan: ${totalFiles} paths, ${totalParseable} parseable (${totalMB.toFixed(0)}MB), ${numChunks} chunks @ ${CHUNK_BYTE_BUDGET / (1024 * 1024)}MB budget`,
|
||||
`📂 Scan: ${totalFiles} paths, ${totalParseable} parseable (${totalMB.toFixed(0)}MB), ${numChunks} chunks @ ${chunkByteBudget / (1024 * 1024)}MB budget`,
|
||||
);
|
||||
}
|
||||
|
||||
onProgress({
|
||||
phase: 'parsing',
|
||||
percent: 20,
|
||||
message: `Parsing ${totalParseable} files in ${numChunks} chunk${numChunks !== 1 ? 's' : ''}...`,
|
||||
stats: { filesProcessed: 0, totalFiles: totalParseable, nodesCreated: graph.nodeCount },
|
||||
});
|
||||
// Skip the "Parsing N files..." announcement when there's nothing to parse
|
||||
// — the early-return branch above already emitted percent 95 ("skipping
|
||||
// parsing phase"), and emitting percent 20 here would regress the
|
||||
// progress stream non-monotonically (M2 from PR #1693 review).
|
||||
if (totalParseable > 0) {
|
||||
onProgress({
|
||||
phase: 'parsing',
|
||||
percent: 20,
|
||||
message: `Parsing ${totalParseable} files in ${numChunks} chunk${numChunks !== 1 ? 's' : ''}...`,
|
||||
stats: { filesProcessed: 0, totalFiles: totalParseable, nodesCreated: graph.nodeCount },
|
||||
});
|
||||
}
|
||||
|
||||
// Don't spawn workers for tiny repos — overhead exceeds benefit.
|
||||
// Test suites may lower the thresholds via `options.workerThresholdsForTest`
|
||||
|
|
@ -221,18 +258,36 @@ export async function runChunkedParseAndResolve(
|
|||
const MIN_BYTES_FOR_WORKERS = options?.workerThresholdsForTest?.minBytes ?? 512 * 1024;
|
||||
const totalBytes = parseableScanned.reduce((s, f) => s + f.size, 0);
|
||||
|
||||
// Create worker pool once, reuse across chunks
|
||||
let workerPool: WorkerPool | undefined;
|
||||
if (
|
||||
// Create worker pool lazily, reuse across cache-miss chunks.
|
||||
//
|
||||
// `workerPoolSize === 0` is a programmatic equivalent of `skipWorkers:
|
||||
// true` per the `PipelineOptions.workerPoolSize` contract. Short-
|
||||
// circuiting here avoids constructing a useless pool. The pool is
|
||||
// intentionally NOT created before parse-cache lookup: a warm-cache
|
||||
// all-hit run should replay cached worker output without loading
|
||||
// parse-worker.js or any tree-sitter/N-API native bindings.
|
||||
const shouldUseWorkers =
|
||||
!options?.skipWorkers &&
|
||||
(totalParseable >= MIN_FILES_FOR_WORKERS || totalBytes >= MIN_BYTES_FOR_WORKERS)
|
||||
) {
|
||||
options?.workerPoolSize !== 0 &&
|
||||
(totalParseable >= MIN_FILES_FOR_WORKERS || totalBytes >= MIN_BYTES_FOR_WORKERS);
|
||||
let workerPool: WorkerPool | undefined;
|
||||
let workerPoolDisabled = false;
|
||||
const getOrCreateWorkerPool = (): WorkerPool | undefined => {
|
||||
if (!shouldUseWorkers || workerPoolDisabled) return undefined;
|
||||
if (workerPool) return workerPool;
|
||||
try {
|
||||
let workerUrl = new URL('../workers/parse-worker.js', import.meta.url);
|
||||
// U20.U3 test-only injection: integration tests pass a custom
|
||||
// worker script URL via `workerUrlForTest` (mirrors the
|
||||
// `workerThresholdsForTest` precedent) so they can drive the
|
||||
// chunk-loop with deterministically-misbehaving workers without
|
||||
// mocking the module import graph. When unset, the normal src/
|
||||
// → dist/ resolution runs.
|
||||
let workerUrl =
|
||||
options?.workerUrlForTest ?? new URL('../workers/parse-worker.js', import.meta.url);
|
||||
// When running under vitest, import.meta.url points to src/ where no .js exists.
|
||||
// Fall back to the compiled dist/ worker so the pool can spawn real worker threads.
|
||||
const thisDir = fileURLToPath(new URL('.', import.meta.url));
|
||||
if (!fs.existsSync(fileURLToPath(workerUrl))) {
|
||||
if (!options?.workerUrlForTest && !fs.existsSync(fileURLToPath(workerUrl))) {
|
||||
const distWorker = path.resolve(
|
||||
thisDir,
|
||||
'..',
|
||||
|
|
@ -249,14 +304,17 @@ export async function runChunkedParseAndResolve(
|
|||
workerUrl = pathToFileURL(distWorker);
|
||||
}
|
||||
}
|
||||
workerPool = createWorkerPool(workerUrl);
|
||||
workerPool = createWorkerPool(workerUrl, options?.workerPoolSize);
|
||||
return workerPool;
|
||||
} catch (err) {
|
||||
workerPoolDisabled = true;
|
||||
logger.warn(
|
||||
{ err: (err as Error).message },
|
||||
'Worker pool creation failed, using sequential fallback:',
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let filesParsedSoFar = 0;
|
||||
|
||||
|
|
@ -301,6 +359,16 @@ export async function runChunkedParseAndResolve(
|
|||
const deferredWorkerHeritage: ExtractedHeritage[] = [];
|
||||
const deferredConstructorBindings: FileConstructorBindings[] = [];
|
||||
const deferredAssignments: ExtractedAssignment[] = [];
|
||||
// Imports accumulated across chunks. Previously processed per-chunk
|
||||
// via `processImportsFromExtracted` inside the chunk loop, which
|
||||
// forced workers to sit idle on the main thread's extraction pass
|
||||
// between chunk dispatches (4-5% CPU utilization symptom). Deferring
|
||||
// to a single end-of-loop pass lets the worker pool start chunk N+1
|
||||
// immediately after chunk N's worker dispatch returns. Resolution is
|
||||
// strictly-more-information at end-of-loop because graph now has
|
||||
// every chunk's symbols — improves cross-chunk import targets.
|
||||
const deferredWorkerImports: ExtractedImport[] = [];
|
||||
let anyChunkNeedsWildcardSynth = false;
|
||||
// Aggregated per-file ParsedFile artifacts produced by workers' calls
|
||||
// to `extractParsedFile`. Threaded through to the scope-resolution
|
||||
// phase so it can SKIP its own re-extraction on cache hits — this is
|
||||
|
|
@ -317,13 +385,63 @@ export async function runChunkedParseAndResolve(
|
|||
let chunkCacheMisses = 0;
|
||||
|
||||
try {
|
||||
// U1 — bounded chunk concurrency (B1 from PR #1693 review): pre-fetch
|
||||
// chunk file contents up to `parseChunkConcurrency` chunks ahead of the
|
||||
// dispatch cursor so file I/O overlaps with worker compute. Worker
|
||||
// dispatch itself stays serial because `WorkerPool.dispatch` is not
|
||||
// reentrant (concurrent calls would race on the shared per-slot
|
||||
// busy/in-flight state). With concurrency=1 behavior is identical to
|
||||
// the pure-serial loop. F4: deferred-state aggregation still happens
|
||||
// in chunkIdx order (the for-loop below iterates sequentially), so
|
||||
// cross-chunk processors see deterministic input regardless of
|
||||
// file-read completion order. Honors options.parseChunkConcurrency
|
||||
// (threaded from the CLI), then GITNEXUS_PARSE_CHUNK_CONCURRENCY env
|
||||
// (default 2 — matches the help text the CLI advertises).
|
||||
const parseChunkConcurrency = ((): number => {
|
||||
const opt = options?.parseChunkConcurrency;
|
||||
if (typeof opt === 'number' && Number.isInteger(opt) && opt >= 1) return opt;
|
||||
const env = Number(process.env.GITNEXUS_PARSE_CHUNK_CONCURRENCY);
|
||||
if (Number.isInteger(env) && env >= 1) return env;
|
||||
return 2;
|
||||
})();
|
||||
const chunkContentPromises = new Array<Promise<Map<string, string>> | undefined>(numChunks);
|
||||
const startChunkPrefetch = (i: number): void => {
|
||||
if (i >= numChunks || chunkContentPromises[i] !== undefined) return;
|
||||
chunkContentPromises[i] = readFileContents(repoPath, chunks[i]);
|
||||
};
|
||||
for (let i = 0; i < Math.min(parseChunkConcurrency, numChunks); i++) {
|
||||
startChunkPrefetch(i);
|
||||
}
|
||||
|
||||
// Hoisted loop-invariant: GITNEXUS_VERBOSE / NODE_ENV are read once
|
||||
// (not on every chunk). Previously evaluated at the top of the loop
|
||||
// body, which re-read process.env on every iteration even though
|
||||
// the env can't change mid-run.
|
||||
const verboseThroughputLog = isDev || isVerboseIngestionEnabled();
|
||||
|
||||
for (let chunkIdx = 0; chunkIdx < numChunks; chunkIdx++) {
|
||||
const chunkPaths = chunks[chunkIdx];
|
||||
// Start wall-clock for the per-chunk throughput log emitted at end
|
||||
// of this iteration. The gate is computed once above; here we just
|
||||
// sample the clock if the gate is on. Computed when either
|
||||
// NODE_ENV=development OR the operator passed `--verbose`
|
||||
// (GITNEXUS_VERBOSE) — the previous `isDev`-only gate meant
|
||||
// operators running `gitnexus analyze --verbose` in production
|
||||
// never saw the log (M3 from PR #1693 review).
|
||||
const chunkStartMs: number | null = verboseThroughputLog ? Date.now() : null;
|
||||
|
||||
const chunkContents = await readFileContents(repoPath, chunkPaths);
|
||||
const chunkFiles = chunkPaths
|
||||
.filter((p) => chunkContents.has(p))
|
||||
.map((p) => ({ path: p, content: chunkContents.get(p)! }));
|
||||
const chunkContentPromise = chunkContentPromises[chunkIdx];
|
||||
if (!chunkContentPromise) {
|
||||
throw new Error(`Missing prefetched parse chunk ${chunkIdx + 1}/${numChunks}`);
|
||||
}
|
||||
const chunkContents = await chunkContentPromise;
|
||||
chunkContentPromises[chunkIdx] = undefined; // release the in-memory copy
|
||||
startChunkPrefetch(chunkIdx + parseChunkConcurrency);
|
||||
const chunkFiles: Array<{ path: string; content: string }> = [];
|
||||
for (const p of chunkPaths) {
|
||||
const content = chunkContents.get(p);
|
||||
if (content !== undefined) chunkFiles.push({ path: p, content });
|
||||
}
|
||||
|
||||
// Compute the chunk's content-hash signature (if cache available).
|
||||
let chunkHash: string | null = null;
|
||||
|
|
@ -336,7 +454,7 @@ export async function runChunkedParseAndResolve(
|
|||
}
|
||||
|
||||
let chunkWorkerData: WorkerExtractedData | null;
|
||||
const cachedRaw = chunkHash ? parseCache!.entries.get(chunkHash) : undefined;
|
||||
const cachedRaw = chunkHash && parseCache ? parseCache.entries.get(chunkHash) : undefined;
|
||||
|
||||
// Track every chunk hash we touched so the orchestrator can
|
||||
// prune stale entries (chunks whose composition no longer
|
||||
|
|
@ -350,14 +468,18 @@ export async function runChunkedParseAndResolve(
|
|||
chunkWorkerData = mergeChunkResults(graph, symbolTable, cachedRaw);
|
||||
if (isDev) {
|
||||
logger.info(
|
||||
`📦 parse-cache HIT: chunk ${chunkIdx + 1}/${numChunks} (${chunkFiles.length} files, ${chunkHash!.slice(0, 8)})`,
|
||||
`📦 parse-cache HIT: chunk ${chunkIdx + 1}/${numChunks} (${chunkFiles.length} files, ${chunkHash?.slice(0, 8) ?? 'unknown'})`,
|
||||
);
|
||||
}
|
||||
// Progress update so UI advances even on a cache hit.
|
||||
const cachedFiles = chunkFiles.length;
|
||||
onProgress({
|
||||
phase: 'parsing',
|
||||
percent: Math.round(20 + ((filesParsedSoFar + cachedFiles) / totalParseable) * 62),
|
||||
// Parse phase covers 20-70 (50 points). Deferred extraction below
|
||||
// takes 70-95 so the UI advances through the (potentially long)
|
||||
// resolution stages instead of holding at 82 (M2 from PR #1693
|
||||
// review).
|
||||
percent: Math.round(20 + ((filesParsedSoFar + cachedFiles) / totalParseable) * 50),
|
||||
message: `Parsing chunk ${chunkIdx + 1}/${numChunks} (cache)...`,
|
||||
stats: {
|
||||
filesProcessed: filesParsedSoFar + cachedFiles,
|
||||
|
|
@ -370,85 +492,121 @@ export async function runChunkedParseAndResolve(
|
|||
// them under the chunk hash for the next run.
|
||||
chunkCacheMisses++;
|
||||
const rawResults: ParseWorkerResult[] = [];
|
||||
chunkWorkerData = await processParsing(
|
||||
graph,
|
||||
chunkFiles,
|
||||
symbolTable,
|
||||
astCache,
|
||||
scopeTreeCache,
|
||||
(current, _total, filePath) => {
|
||||
const globalCurrent = filesParsedSoFar + current;
|
||||
const parsingProgress = 20 + (globalCurrent / totalParseable) * 62;
|
||||
onProgress({
|
||||
phase: 'parsing',
|
||||
percent: Math.round(parsingProgress),
|
||||
message: `Parsing chunk ${chunkIdx + 1}/${numChunks}...`,
|
||||
detail: filePath,
|
||||
stats: {
|
||||
filesProcessed: globalCurrent,
|
||||
totalFiles: totalParseable,
|
||||
nodesCreated: graph.nodeCount,
|
||||
},
|
||||
});
|
||||
},
|
||||
workerPool,
|
||||
// Capture raw results only when we have a cache to write to —
|
||||
// otherwise we'd retain extra arrays for nothing.
|
||||
parseCache && chunkHash ? rawResults : undefined,
|
||||
);
|
||||
const progressForChunk = (current: number, _total: number, filePath: string) => {
|
||||
const globalCurrent = filesParsedSoFar + current;
|
||||
// Parse phase covers 20-70 (M2). Deferred extraction handles 70-95.
|
||||
const parsingProgress = 20 + (globalCurrent / totalParseable) * 50;
|
||||
onProgress({
|
||||
phase: 'parsing',
|
||||
percent: Math.round(parsingProgress),
|
||||
message: `Parsing chunk ${chunkIdx + 1}/${numChunks}...`,
|
||||
detail: filePath,
|
||||
stats: {
|
||||
filesProcessed: globalCurrent,
|
||||
totalFiles: totalParseable,
|
||||
nodesCreated: graph.nodeCount,
|
||||
},
|
||||
});
|
||||
};
|
||||
const activeWorkerPool = getOrCreateWorkerPool();
|
||||
try {
|
||||
chunkWorkerData = await processParsing(
|
||||
graph,
|
||||
chunkFiles,
|
||||
symbolTable,
|
||||
astCache,
|
||||
scopeTreeCache,
|
||||
progressForChunk,
|
||||
activeWorkerPool,
|
||||
// Capture raw results only when we have a cache to write to —
|
||||
// otherwise we'd retain extra arrays for nothing.
|
||||
parseCache && chunkHash && activeWorkerPool ? rawResults : undefined,
|
||||
);
|
||||
} catch (err) {
|
||||
if (!(err instanceof WorkerPoolInitializationError)) throw err;
|
||||
logger.warn(
|
||||
{
|
||||
err: err.message,
|
||||
readinessFailures: err.readinessFailures,
|
||||
},
|
||||
'Worker pool initialization failed, using sequential fallback:',
|
||||
);
|
||||
rawResults.length = 0;
|
||||
workerPoolDisabled = true;
|
||||
const failedPool = workerPool;
|
||||
workerPool = undefined;
|
||||
await failedPool?.terminate().catch(() => undefined);
|
||||
chunkWorkerData = await processParsing(
|
||||
graph,
|
||||
chunkFiles,
|
||||
symbolTable,
|
||||
astCache,
|
||||
scopeTreeCache,
|
||||
progressForChunk,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
// Persist the raw results for this chunk hash. Sequential path
|
||||
// doesn't populate rawResults (it writes directly to graph), so
|
||||
// small repos without worker pool simply don't cache. That's fine.
|
||||
//
|
||||
// U20.U2: refuse the write when any chunk file is in the
|
||||
// worker pool's cumulative quarantine snapshot. The chunkHash
|
||||
// is computed from EVERY file in the chunk, but the pool's
|
||||
// Layer 3 quarantine filters quarantined files out of dispatch
|
||||
// — so `rawResults` is narrower than the chunkHash key implies.
|
||||
// Caching it would silently replay incomplete results on the
|
||||
// next run with unchanged content (the corruption class Codex's
|
||||
// adversarial review of PR #1693 flagged).
|
||||
//
|
||||
// Skipping the write means the next analyze gets a cache miss
|
||||
// for this chunk and re-dispatches against a fresh worker pool
|
||||
// (quarantine is session-scoped — `createQuarantine` is called
|
||||
// per-pool at worker-pool.ts), giving the quarantined file
|
||||
// another chance. If quarantine fires again, U20.U1's
|
||||
// sequential gap-fill still produces a complete graph for this
|
||||
// run; the cache just stays empty for this chunk until a fully-
|
||||
// clean dispatch lands.
|
||||
if (parseCache && chunkHash && rawResults.length > 0) {
|
||||
parseCache.entries.set(chunkHash, rawResults);
|
||||
if (isDev) {
|
||||
logger.info(
|
||||
`📦 parse-cache MISS+store: chunk ${chunkIdx + 1}/${numChunks} (${chunkFiles.length} files, ${chunkHash.slice(0, 8)})`,
|
||||
);
|
||||
const quarantineSnapshot = workerPool?.getQuarantinedPaths?.() ?? [];
|
||||
const quarantineSet = new Set(quarantineSnapshot);
|
||||
const chunkHadQuarantine = chunkFiles.some((f) => quarantineSet.has(f.path));
|
||||
if (chunkHadQuarantine) {
|
||||
if (isDev) {
|
||||
const quarantinedInChunk = chunkFiles.filter((f) => quarantineSet.has(f.path)).length;
|
||||
logger.info(
|
||||
`📦 parse-cache SKIP: chunk ${chunkIdx + 1}/${numChunks} ` +
|
||||
`had ${quarantinedInChunk} worker-quarantined file(s); ` +
|
||||
`next run will rediscover (${chunkHash.slice(0, 8)})`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
parseCache.entries.set(chunkHash, rawResults);
|
||||
if (isDev) {
|
||||
logger.info(
|
||||
`📦 parse-cache MISS+store: chunk ${chunkIdx + 1}/${numChunks} (${chunkFiles.length} files, ${chunkHash.slice(0, 8)})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const chunkBasePercent = 20 + (filesParsedSoFar / totalParseable) * 62;
|
||||
|
||||
// Per-chunk extraction passes (processImportsFromExtracted,
|
||||
// processHeritageFromExtracted, processRoutesFromExtracted,
|
||||
// synthesizeWildcardImportBindings, seedCrossFileReceiverTypes)
|
||||
// moved out of the chunk loop into a single end-of-loop pass below.
|
||||
// Reason: per-chunk extraction blocked the chunk loop on
|
||||
// main-thread work between worker dispatches — workers sat idle
|
||||
// and total CPU utilization plateaued at 4-5% on multi-core boxes.
|
||||
// Deferring keeps workers busy chunk-after-chunk; resolution sees
|
||||
// strictly-more-information (full repo graph) so cross-chunk import
|
||||
// and heritage targets resolve at least as well as before.
|
||||
if (chunkWorkerData) {
|
||||
await processImportsFromExtracted(
|
||||
graph,
|
||||
allPathObjects,
|
||||
chunkWorkerData.imports,
|
||||
ctx,
|
||||
(current, total) => {
|
||||
onProgress({
|
||||
phase: 'parsing',
|
||||
percent: Math.round(chunkBasePercent),
|
||||
message: `Resolving imports (chunk ${chunkIdx + 1}/${numChunks})...`,
|
||||
detail: `${current}/${total} files`,
|
||||
stats: {
|
||||
filesProcessed: filesParsedSoFar,
|
||||
totalFiles: totalParseable,
|
||||
nodesCreated: graph.nodeCount,
|
||||
},
|
||||
});
|
||||
},
|
||||
repoPath,
|
||||
importCtx,
|
||||
);
|
||||
if (chunkNeedsSynthesis[chunkIdx]) {
|
||||
synthesizeWildcardImportBindings(graph, ctx);
|
||||
hasSynthesized = true;
|
||||
}
|
||||
if (exportedTypeMap.size > 0 && ctx.namedImportMap.size > 0) {
|
||||
const { enrichedCount } = seedCrossFileReceiverTypes(
|
||||
chunkWorkerData.calls,
|
||||
ctx.namedImportMap,
|
||||
exportedTypeMap,
|
||||
);
|
||||
if (isDev && enrichedCount > 0) {
|
||||
logger.info(
|
||||
`🔗 E1: Seeded ${enrichedCount} cross-file receiver types (chunk ${chunkIdx + 1})`,
|
||||
);
|
||||
}
|
||||
anyChunkNeedsWildcardSynth = true;
|
||||
}
|
||||
for (const item of chunkWorkerData.imports) deferredWorkerImports.push(item);
|
||||
for (const item of chunkWorkerData.calls) deferredWorkerCalls.push(item);
|
||||
for (const item of chunkWorkerData.heritage) deferredWorkerHeritage.push(item);
|
||||
for (const item of chunkWorkerData.constructorBindings)
|
||||
|
|
@ -463,35 +621,6 @@ export async function runChunkedParseAndResolve(
|
|||
for (const item of chunkWorkerData.assignments) deferredAssignments.push(item);
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
processHeritageFromExtracted(graph, chunkWorkerData.heritage, ctx, (current, total) => {
|
||||
onProgress({
|
||||
phase: 'parsing',
|
||||
percent: Math.round(chunkBasePercent),
|
||||
message: `Resolving heritage (chunk ${chunkIdx + 1}/${numChunks})...`,
|
||||
detail: `${current}/${total} records`,
|
||||
stats: {
|
||||
filesProcessed: filesParsedSoFar,
|
||||
totalFiles: totalParseable,
|
||||
nodesCreated: graph.nodeCount,
|
||||
},
|
||||
});
|
||||
}),
|
||||
processRoutesFromExtracted(graph, chunkWorkerData.routes ?? [], ctx, (current, total) => {
|
||||
onProgress({
|
||||
phase: 'parsing',
|
||||
percent: Math.round(chunkBasePercent),
|
||||
message: `Resolving routes (chunk ${chunkIdx + 1}/${numChunks})...`,
|
||||
detail: `${current}/${total} routes`,
|
||||
stats: {
|
||||
filesProcessed: filesParsedSoFar,
|
||||
totalFiles: totalParseable,
|
||||
nodesCreated: graph.nodeCount,
|
||||
},
|
||||
});
|
||||
}),
|
||||
]);
|
||||
|
||||
if (chunkWorkerData.fileScopeBindings?.length) {
|
||||
for (const { filePath, bindings } of chunkWorkerData.fileScopeBindings) {
|
||||
if (typeof filePath !== 'string' || filePath.length === 0) continue;
|
||||
|
|
@ -530,6 +659,24 @@ export async function runChunkedParseAndResolve(
|
|||
|
||||
filesParsedSoFar += chunkFiles.length;
|
||||
astCache.clear();
|
||||
|
||||
// Throughput observability (U3): emit a per-chunk metrics line
|
||||
// under verbose ingestion mode so operators can verify CPU
|
||||
// utilization moved + tune `--workers` / batch sizes without
|
||||
// guessing. Cheap snapshot — just reads pool closure state.
|
||||
if (verboseThroughputLog && chunkStartMs !== null) {
|
||||
const elapsedMs = Date.now() - chunkStartMs;
|
||||
const filesPerSec = elapsedMs > 0 ? (chunkFiles.length * 1000) / elapsedMs : 0;
|
||||
const stats = workerPool?.getStats?.();
|
||||
const poolFrag = stats
|
||||
? ` pool: ${stats.activeSlots}/${stats.size} active, ` +
|
||||
`${stats.quarantined} quarantined${stats.poolBroken ? ', BROKEN' : ''}`
|
||||
: ' (sequential)';
|
||||
logger.info(
|
||||
`📊 chunk ${chunkIdx + 1}/${numChunks}: ${chunkFiles.length} files in ${elapsedMs}ms ` +
|
||||
`(${filesPerSec.toFixed(1)} files/s)${poolFrag}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isDev && parseCache && (chunkCacheHits > 0 || chunkCacheMisses > 0)) {
|
||||
|
|
@ -538,20 +685,194 @@ export async function runChunkedParseAndResolve(
|
|||
);
|
||||
}
|
||||
|
||||
const fullWorkerHeritageMap =
|
||||
deferredWorkerHeritage.length > 0
|
||||
? buildHeritageMap(deferredWorkerHeritage, ctx, getHeritageStrategyForLanguage)
|
||||
: undefined;
|
||||
// Deferred end-of-loop extraction (moved out of the per-chunk block):
|
||||
// 1. processImportsFromExtracted on all chunks' imports
|
||||
// 2. synthesizeWildcardImportBindings (if any chunk had wildcards)
|
||||
// 3. seedCrossFileReceiverTypes on deferred calls (depends on
|
||||
// namedImportMap populated by step 1)
|
||||
// 4. processHeritageFromExtracted on all chunks' heritage
|
||||
// 5. processRoutesFromExtracted on all chunks' routes
|
||||
// Same logic as the prior per-chunk passes, just batched — resolution
|
||||
// sees the full repo graph instead of just current-and-earlier chunks.
|
||||
// Deferred extraction band (M2 from PR #1693 review): the 4 stages below
|
||||
// each get their own 5-10 point slice of the 70-95 range so percent
|
||||
// advances monotonically through the (potentially long) resolution work
|
||||
// instead of holding flat at 82. Stages that are skipped (zero-length
|
||||
// input) leave their band as a no-op jump — the next stage still starts
|
||||
// at its own band, preserving monotonicity.
|
||||
// imports: 70 -> 75 (5)
|
||||
// heritage: 75 -> 80 (5)
|
||||
// routes: 80 -> 85 (5)
|
||||
// calls: 85 -> 95 (10)
|
||||
const deferredProfile = isDeferredResolutionProfileEnabled();
|
||||
if (deferredProfile) {
|
||||
logDeferredProfile(
|
||||
`deferred band start: imports=${deferredWorkerImports.length} heritage=${deferredWorkerHeritage.length} ` +
|
||||
`calls=${deferredWorkerCalls.length} routes=${allExtractedRoutes.length}`,
|
||||
);
|
||||
}
|
||||
if (deferredWorkerImports.length > 0) {
|
||||
const tImports = startTimer(deferredProfile);
|
||||
await processImportsFromExtracted(
|
||||
graph,
|
||||
allPathObjects,
|
||||
deferredWorkerImports,
|
||||
ctx,
|
||||
(current, total) => {
|
||||
const ratio = total > 0 ? current / total : 1;
|
||||
onProgress({
|
||||
phase: 'parsing',
|
||||
percent: 70 + Math.round(ratio * 5),
|
||||
message: 'Resolving imports (all chunks)...',
|
||||
detail: `${current}/${total} files`,
|
||||
stats: {
|
||||
filesProcessed: filesParsedSoFar,
|
||||
totalFiles: totalParseable,
|
||||
nodesCreated: graph.nodeCount,
|
||||
},
|
||||
});
|
||||
},
|
||||
repoPath,
|
||||
importCtx,
|
||||
);
|
||||
endTimer(
|
||||
tImports,
|
||||
(ms) =>
|
||||
`processImportsFromExtracted: ${ms.toFixed(0)}ms (${deferredWorkerImports.length} import batches before drain)`,
|
||||
);
|
||||
// U15 (lightweight M1): processImportsFromExtracted is the sole
|
||||
// consumer of `deferredWorkerImports`. Free the array now so the
|
||||
// GC can reclaim the per-file ExtractedImport records before the
|
||||
// heavier downstream stages run (heritage, routes, calls). Peak
|
||||
// accumulator memory drops from O(repo) to O(repo - imports) for
|
||||
// the remainder of the deferred phase. The future per-chunk
|
||||
// streaming upgrade can rewrite this with the same correctness
|
||||
// contract once profile data shows it's warranted.
|
||||
deferredWorkerImports.length = 0;
|
||||
}
|
||||
if (anyChunkNeedsWildcardSynth) {
|
||||
const tWildcard = startTimer(deferredProfile);
|
||||
synthesizeWildcardImportBindings(graph, ctx);
|
||||
hasSynthesized = true;
|
||||
endTimer(tWildcard, (ms) => `synthesizeWildcardImportBindings: ${ms.toFixed(0)}ms`);
|
||||
}
|
||||
// L5 from PR #1693 review: populate `exportedTypeMap` from the in-progress
|
||||
// graph BEFORE `seedCrossFileReceiverTypes` runs. Previously the seeding
|
||||
// branch below was reached with `exportedTypeMap.size === 0` in the
|
||||
// worker path (the map was only built at the post-parse block far below,
|
||||
// AFTER the seeding branch), so the seed dead-coded itself silently and
|
||||
// call resolution never got the cross-file receiver-type enrichment.
|
||||
// The post-parse builder still runs as a defensive fallback on the
|
||||
// sequential path; its `size === 0` guard means we don't pay the cost
|
||||
// twice on the worker path.
|
||||
if (exportedTypeMap.size === 0 && graph.nodeCount > 0) {
|
||||
const graphExports = buildExportedTypeMapFromGraph(graph, ctx.model.symbols);
|
||||
for (const [fp, exports] of graphExports) exportedTypeMap.set(fp, exports);
|
||||
}
|
||||
if (exportedTypeMap.size > 0 && ctx.namedImportMap.size > 0 && deferredWorkerCalls.length > 0) {
|
||||
const { enrichedCount } = seedCrossFileReceiverTypes(
|
||||
deferredWorkerCalls,
|
||||
ctx.namedImportMap,
|
||||
exportedTypeMap,
|
||||
);
|
||||
if (enrichedCount > 0) {
|
||||
// Two independent gates, not else-if: when both isDev AND
|
||||
// deferredProfile are active, BOTH lines fire — log scrapers keyed
|
||||
// on the original "🔗 E1" emoji marker keep matching, AND operators
|
||||
// grepping the [deferred-profile] prefix see no gap between the
|
||||
// wildcard-synth and heritage timings.
|
||||
if (isDev) {
|
||||
logger.info(`🔗 E1: Seeded ${enrichedCount} cross-file receiver types (all chunks)`);
|
||||
}
|
||||
if (deferredProfile) {
|
||||
logDeferredProfile(`E1: seeded ${enrichedCount} cross-file receiver types (all chunks)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (deferredWorkerHeritage.length > 0) {
|
||||
const tHeritage = startTimer(deferredProfile);
|
||||
await processHeritageFromExtracted(graph, deferredWorkerHeritage, ctx, (current, total) => {
|
||||
const ratio = total > 0 ? current / total : 1;
|
||||
onProgress({
|
||||
phase: 'parsing',
|
||||
percent: 75 + Math.round(ratio * 5),
|
||||
message: 'Resolving heritage (all chunks)...',
|
||||
detail: `${current}/${total} records`,
|
||||
stats: {
|
||||
filesProcessed: filesParsedSoFar,
|
||||
totalFiles: totalParseable,
|
||||
nodesCreated: graph.nodeCount,
|
||||
},
|
||||
});
|
||||
});
|
||||
endTimer(
|
||||
tHeritage,
|
||||
(ms) =>
|
||||
`processHeritageFromExtracted: ${ms.toFixed(0)}ms (${deferredWorkerHeritage.length} records)`,
|
||||
);
|
||||
}
|
||||
if (allExtractedRoutes.length > 0) {
|
||||
const tRoutes = startTimer(deferredProfile);
|
||||
await processRoutesFromExtracted(graph, allExtractedRoutes, ctx, (current, total) => {
|
||||
const ratio = total > 0 ? current / total : 1;
|
||||
onProgress({
|
||||
phase: 'parsing',
|
||||
percent: 80 + Math.round(ratio * 5),
|
||||
message: 'Resolving routes (all chunks)...',
|
||||
detail: `${current}/${total} routes`,
|
||||
stats: {
|
||||
filesProcessed: filesParsedSoFar,
|
||||
totalFiles: totalParseable,
|
||||
nodesCreated: graph.nodeCount,
|
||||
},
|
||||
});
|
||||
});
|
||||
endTimer(
|
||||
tRoutes,
|
||||
(ms) =>
|
||||
`processRoutesFromExtracted: ${ms.toFixed(0)}ms (${allExtractedRoutes.length} routes)`,
|
||||
);
|
||||
}
|
||||
|
||||
let fullWorkerHeritageMap: ReturnType<typeof buildHeritageMap> | undefined;
|
||||
if (deferredWorkerHeritage.length > 0) {
|
||||
const tBuildHeritage = startTimer(deferredProfile);
|
||||
fullWorkerHeritageMap = buildHeritageMap(
|
||||
deferredWorkerHeritage,
|
||||
ctx,
|
||||
getHeritageStrategyForLanguage,
|
||||
);
|
||||
endTimer(tBuildHeritage, (ms) => `buildHeritageMap wall: ${ms.toFixed(0)}ms`);
|
||||
} else if (deferredProfile) {
|
||||
logDeferredProfile('buildHeritageMap: skipped (no heritage records)');
|
||||
}
|
||||
// U15 (lightweight M1): buildHeritageMap is the LAST consumer of the
|
||||
// raw `deferredWorkerHeritage` records — processCallsFromExtracted
|
||||
// below reads from the derived `fullWorkerHeritageMap` instead. Free
|
||||
// the raw heritage array now so the GC can reclaim it before the
|
||||
// (potentially long) call-resolution stage. processHeritageFromExtracted
|
||||
// earlier was a read-only consumer (pushed to graph, didn't drain).
|
||||
deferredWorkerHeritage.length = 0;
|
||||
|
||||
if (deferredWorkerCalls.length > 0) {
|
||||
if (deferredProfile) {
|
||||
logDeferredProfile(
|
||||
`processCallsFromExtracted: starting (${deferredWorkerCalls.length} call sites, heritageMap=${fullWorkerHeritageMap !== undefined})`,
|
||||
);
|
||||
}
|
||||
const tCalls = startTimer(deferredProfile);
|
||||
await processCallsFromExtracted(
|
||||
graph,
|
||||
deferredWorkerCalls,
|
||||
ctx,
|
||||
(current, total) => {
|
||||
const ratio = total > 0 ? current / total : 1;
|
||||
onProgress({
|
||||
phase: 'parsing',
|
||||
percent: 82,
|
||||
// Calls is the longest deferred stage on real repos — give it the
|
||||
// 10-point tail 85-95 so the progress bar visibly advances during
|
||||
// call resolution instead of holding at 82 (M2).
|
||||
percent: 85 + Math.round(ratio * 10),
|
||||
message: 'Resolving calls (all chunks)...',
|
||||
detail: `${current}/${total} files`,
|
||||
stats: {
|
||||
|
|
@ -565,6 +886,7 @@ export async function runChunkedParseAndResolve(
|
|||
fullWorkerHeritageMap,
|
||||
bindingAccumulator,
|
||||
);
|
||||
endTimer(tCalls, (ms) => `processCallsFromExtracted: ${ms.toFixed(0)}ms total`);
|
||||
}
|
||||
|
||||
if (deferredAssignments.length > 0) {
|
||||
|
|
@ -576,6 +898,20 @@ export async function runChunkedParseAndResolve(
|
|||
bindingAccumulator,
|
||||
);
|
||||
}
|
||||
// U15 (lightweight M1): all three arrays have had their last consumer
|
||||
// by the time we reach this point — processCallsFromExtracted drained
|
||||
// `deferredWorkerCalls` and read `deferredConstructorBindings`;
|
||||
// processAssignmentsFromExtracted drained `deferredAssignments` and
|
||||
// also read `deferredConstructorBindings`. Free them now so the
|
||||
// function-scope references die before downstream graph-build /
|
||||
// scope-resolution starts using its own working memory. Note: arrays
|
||||
// returned in the function result object (allFetchCalls,
|
||||
// allExtractedRoutes, allDecoratorRoutes, allToolDefs, allORMQueries,
|
||||
// allParsedFiles) intentionally stay live — downstream consumers
|
||||
// need them.
|
||||
deferredWorkerCalls.length = 0;
|
||||
deferredConstructorBindings.length = 0;
|
||||
deferredAssignments.length = 0;
|
||||
} finally {
|
||||
await workerPool?.terminate();
|
||||
}
|
||||
|
|
@ -605,9 +941,11 @@ export async function runChunkedParseAndResolve(
|
|||
const cachedSequentialChunkFiles: Array<Array<{ path: string; content: string }>> = [];
|
||||
for (const chunkPaths of sequentialChunkPaths) {
|
||||
const chunkContents = await readFileContents(repoPath, chunkPaths);
|
||||
const chunkFiles = chunkPaths
|
||||
.filter((p) => chunkContents.has(p))
|
||||
.map((p) => ({ path: p, content: chunkContents.get(p)! }));
|
||||
const chunkFiles: Array<{ path: string; content: string }> = [];
|
||||
for (const p of chunkPaths) {
|
||||
const content = chunkContents.get(p);
|
||||
if (content !== undefined) chunkFiles.push({ path: p, content });
|
||||
}
|
||||
cachedSequentialChunkFiles.push(chunkFiles);
|
||||
astCache = createASTCache(chunkFiles.length);
|
||||
const sequentialHeritage = await extractExtractedHeritageFromFiles(chunkFiles, astCache);
|
||||
|
|
|
|||
|
|
@ -55,6 +55,16 @@ export interface PipelineOptions {
|
|||
minFiles?: number;
|
||||
minBytes?: number;
|
||||
};
|
||||
/**
|
||||
* @internal Test-only override for the worker script URL the pool
|
||||
* spawns. When unset, parse-impl resolves `parse-worker.js` from the
|
||||
* adjacent `workers/` directory (or the compiled `dist/` fallback
|
||||
* under vitest). Integration tests use this to inject a custom
|
||||
* worker script that deterministically triggers worker-pool
|
||||
* resilience paths (e.g., crash-on-poison-file) — same precedent as
|
||||
* `workerThresholdsForTest`. Do not use from production call sites.
|
||||
*/
|
||||
workerUrlForTest?: URL;
|
||||
/**
|
||||
* Incremental-indexing parse cache. When provided:
|
||||
* - The parse phase looks up each chunk's content hash in
|
||||
|
|
@ -68,6 +78,46 @@ export interface PipelineOptions {
|
|||
* See `gitnexus/src/storage/parse-cache.ts`.
|
||||
*/
|
||||
parseCache?: import('../../storage/parse-cache.js').ParseCache;
|
||||
/**
|
||||
* Worker pool size override, threaded from the CLI `--workers` flag
|
||||
* via `AnalyzeOptions`. When set, parse-impl passes this directly to
|
||||
* `createWorkerPool` so the pool sizing bypasses the env-var fallback
|
||||
* in `resolveAutoPoolSize`. The env-var channel
|
||||
* (`GITNEXUS_WORKER_POOL_SIZE`) remains as a back-compat fallback when
|
||||
* this field is undefined. Setting `workerPoolSize: 0` disables the
|
||||
* pool entirely (sequential fallback) — equivalent to `skipWorkers`
|
||||
* but expressed in the same units as `--workers <N>` so long-running
|
||||
* hosts (eval-server, MCP daemon) can size per-call without leaking
|
||||
* `process.env` state across analyze invocations.
|
||||
*/
|
||||
workerPoolSize?: number;
|
||||
/**
|
||||
* Number of chunks whose file contents may be read into memory in
|
||||
* parallel while the worker pool is busy dispatching the current
|
||||
* chunk. Pre-fetching overlaps disk I/O for chunk N+1..N+K with the
|
||||
* worker compute on chunk N — modest but real wall-clock win on
|
||||
* repos large enough to chunk. Worker dispatch itself remains serial
|
||||
* because `WorkerPool.dispatch` is not reentrant (concurrent calls
|
||||
* would race on the shared per-slot busy/in-flight state).
|
||||
*
|
||||
* `1` matches today's pure-serial behavior; `2` is the documented
|
||||
* default (`GITNEXUS_PARSE_CHUNK_CONCURRENCY`). Falls back to the
|
||||
* env var when undefined; defaults to 2 when neither is set.
|
||||
*/
|
||||
parseChunkConcurrency?: number;
|
||||
/**
|
||||
* Byte budget per parse chunk (in bytes). When set, parse-impl uses
|
||||
* this instead of the `GITNEXUS_CHUNK_BYTE_BUDGET` env var or the
|
||||
* built-in 2 MB default. Smaller values produce more chunks (finer
|
||||
* cache-hit granularity, more worker dispatches); larger values
|
||||
* batch more files per dispatch.
|
||||
*
|
||||
* Threading the value through options instead of the env var lets
|
||||
* tests vary the chunk layout per-call without `vi.resetModules` and
|
||||
* lets long-running hosts (eval-server, MCP daemon) size per-call
|
||||
* without leaking `process.env` state across invocations.
|
||||
*/
|
||||
chunkByteBudget?: number;
|
||||
}
|
||||
|
||||
// ── Phase registry ─────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
*/
|
||||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { parseTruthyEnv } from './utils/env.js';
|
||||
|
||||
/**
|
||||
* Languages whose RFC #909 Ring 3 scope-resolution migration is complete.
|
||||
|
|
@ -74,6 +75,7 @@ export const MIGRATED_LANGUAGES: ReadonlySet<SupportedLanguages> = new Set<Suppo
|
|||
SupportedLanguages.C,
|
||||
SupportedLanguages.CPlusPlus,
|
||||
SupportedLanguages.PHP,
|
||||
SupportedLanguages.JavaScript,
|
||||
]);
|
||||
|
||||
/**
|
||||
|
|
@ -114,10 +116,6 @@ export function primaryLanguages(): ReadonlySet<SupportedLanguages> {
|
|||
|
||||
// ─── Internal ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Accepted truthy strings (case-insensitive, trimmed). */
|
||||
const TRUTHY_VALUES: ReadonlySet<string> = new Set(['true', '1', 'yes']);
|
||||
|
||||
function parseFlag(raw: string | undefined): boolean {
|
||||
if (raw === undefined) return false;
|
||||
return TRUTHY_VALUES.has(raw.trim().toLowerCase());
|
||||
return parseTruthyEnv(raw);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -693,8 +693,14 @@ function normalizeNodeLabel(kindStr: string): SymbolDefinition['type'] | undefin
|
|||
case 'property':
|
||||
return 'Property';
|
||||
case 'variable':
|
||||
case 'const':
|
||||
return 'Variable';
|
||||
// `const` / `let` declarations align with the legacy DAG parse phase,
|
||||
// which emits `Const` graph nodes via `@definition.const` capture for
|
||||
// `lexical_declaration`. Returning `'Const'` here lets resolveDefGraphId's
|
||||
// qualified-key path succeed for value receivers without relying on the
|
||||
// simple-key fallback (PR #1718 review Finding 1 / 2026-05-21-002 U4).
|
||||
case 'const':
|
||||
return 'Const';
|
||||
case 'typealias':
|
||||
case 'type_alias':
|
||||
return 'TypeAlias';
|
||||
|
|
@ -1042,6 +1048,7 @@ const KNOWN_SUB_TAGS: ReadonlySet<string> = new Set<string>([
|
|||
'@type-binding.type',
|
||||
'@reference.name',
|
||||
'@reference.receiver',
|
||||
'@reference.operator',
|
||||
'@reference.arity',
|
||||
'@reference.parameter-types',
|
||||
'@reference.parameter-type-classes',
|
||||
|
|
|
|||
|
|
@ -98,3 +98,54 @@ export function tryEmitEdge(
|
|||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Variant of `tryEmitEdge` that takes a pre-resolved target graph id
|
||||
* instead of resolving it from a `SymbolDefinition`. Used by the
|
||||
* value-receiver-owner bridge (`receiver-bound-calls.ts` Case 5) where
|
||||
* the picked owner-indexed method def carries no `qualifiedName` (object
|
||||
* literals have no class owner to seed it) and therefore cannot
|
||||
* round-trip through `resolveDefGraphId`. The def's `nodeId` IS the
|
||||
* canonical graph node id (written by the parse phase), so the caller
|
||||
* passes it directly.
|
||||
*
|
||||
* All other invariants of `tryEmitEdge` apply: dedup key shape, collapse
|
||||
* flag honoring, edge-type mapping, caller-id resolution.
|
||||
*/
|
||||
export function tryEmitEdgeWithExplicitTargetId(
|
||||
graph: KnowledgeGraph,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
nodeLookup: GraphNodeLookup,
|
||||
site: {
|
||||
readonly inScope: ScopeId;
|
||||
readonly atRange: { startLine: number; startCol: number };
|
||||
readonly kind: string;
|
||||
},
|
||||
targetGraphId: string,
|
||||
reason: string,
|
||||
seen: Set<string>,
|
||||
confidence = 0.85,
|
||||
collapseByCallerTarget = false,
|
||||
): boolean {
|
||||
const callerGraphId = resolveCallerGraphId(site.inScope, scopes, nodeLookup);
|
||||
const edgeType = mapReferenceKindToEdgeType(site.kind as Reference['kind']);
|
||||
if (callerGraphId === undefined) return false;
|
||||
if (edgeType === undefined) return false;
|
||||
|
||||
const useCollapsed = collapseByCallerTarget && edgeType === 'CALLS';
|
||||
const dedupKey = useCollapsed
|
||||
? `${edgeType}:${callerGraphId}->${targetGraphId}`
|
||||
: `${edgeType}:${callerGraphId}->${targetGraphId}:${site.atRange.startLine}:${site.atRange.startCol}`;
|
||||
if (seen.has(dedupKey)) return false;
|
||||
seen.add(dedupKey);
|
||||
|
||||
graph.addRelationship({
|
||||
id: `rel:${dedupKey}`,
|
||||
sourceId: callerGraphId,
|
||||
targetId: targetGraphId,
|
||||
type: edgeType,
|
||||
confidence,
|
||||
reason,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -159,6 +159,12 @@ export function isLinkableLabel(label: NodeLabel): boolean {
|
|||
// ACCESSES edges target field nodes (e.g. `user.name = "x"` →
|
||||
// ACCESSES edge to User's `name` Variable/Property node).
|
||||
label === 'Variable' ||
|
||||
label === 'Property'
|
||||
label === 'Property' ||
|
||||
// Const is linkable so the value-receiver-owner bridge in
|
||||
// `receiver-bound-calls.ts` Case 5 can translate the scope-resolution
|
||||
// `Variable` def for `export const fooService = {...}` to the canonical
|
||||
// `Const:filePath:name` graph node id, against which object-literal
|
||||
// method symbols register their `ownerId` (PR #1718 / issue #1358).
|
||||
label === 'Const'
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,13 @@
|
|||
* generalization plan.
|
||||
*/
|
||||
|
||||
import type { ParsedFile, Reference, ScopeId, SymbolDefinition } from 'gitnexus-shared';
|
||||
import type {
|
||||
ParameterTypeClass,
|
||||
ParsedFile,
|
||||
Reference,
|
||||
ScopeId,
|
||||
SymbolDefinition,
|
||||
} from 'gitnexus-shared';
|
||||
import type { KnowledgeGraph } from '../../../graph/types.js';
|
||||
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
import type { SemanticModel } from '../../model/semantic-model.js';
|
||||
|
|
@ -277,6 +283,7 @@ export function emitFreeCallFallback(
|
|||
})
|
||||
: undefined,
|
||||
site.argumentTypes,
|
||||
site.argumentTypeClasses,
|
||||
options.conversionRankFn,
|
||||
);
|
||||
}
|
||||
|
|
@ -342,6 +349,7 @@ function pickUniqueGlobalCallable(
|
|||
callArity?: number,
|
||||
isCallerVisible?: (candidate: SymbolDefinition) => boolean,
|
||||
callArgTypes?: readonly string[],
|
||||
callArgTypeClasses?: readonly ParameterTypeClass[],
|
||||
conversionRankFn?: ConversionRankFn,
|
||||
): SymbolDefinition | undefined {
|
||||
const scopeDefs: SymbolDefinition[] = [];
|
||||
|
|
@ -380,6 +388,7 @@ function pickUniqueGlobalCallable(
|
|||
// disambiguate (e.g., `f(int)` vs `f(double)` called with `f(2.5)`).
|
||||
if (scopeDefs.length > 1) {
|
||||
const narrowed = narrowOverloadCandidates(scopeDefs, callArity, callArgTypes, {
|
||||
argumentTypeClasses: callArgTypeClasses,
|
||||
conversionRankFn,
|
||||
});
|
||||
if (narrowed.length === 1) return narrowed[0];
|
||||
|
|
@ -420,6 +429,7 @@ function pickUniqueGlobalCallable(
|
|||
// Same argument-type + conversion-rank narrowing for the model pool.
|
||||
if (defs.length > 1) {
|
||||
const narrowed = narrowOverloadCandidates(defs, callArity, callArgTypes, {
|
||||
argumentTypeClasses: callArgTypeClasses,
|
||||
conversionRankFn,
|
||||
});
|
||||
if (narrowed.length === 1) return narrowed[0];
|
||||
|
|
|
|||
|
|
@ -38,7 +38,13 @@
|
|||
* 5. Empty input returns empty output.
|
||||
*/
|
||||
|
||||
import type { ArityVerdict, Callsite, ConstraintContext, SymbolDefinition } from 'gitnexus-shared';
|
||||
import type {
|
||||
ArityVerdict,
|
||||
Callsite,
|
||||
ConstraintContext,
|
||||
ParameterTypeClass,
|
||||
SymbolDefinition,
|
||||
} from 'gitnexus-shared';
|
||||
|
||||
/**
|
||||
* Per-slot conversion-rank function. Returns a numeric cost for
|
||||
|
|
@ -51,7 +57,12 @@ import type { ArityVerdict, Callsite, ConstraintContext, SymbolDefinition } from
|
|||
* Each language provides its own implementation. The function operates
|
||||
* on normalized type strings (output of the language's type normalizer).
|
||||
*/
|
||||
export type ConversionRankFn = (argType: string, paramType: string) => number;
|
||||
export type ConversionRankFn = (
|
||||
argType: string,
|
||||
paramType: string,
|
||||
argTypeClass?: ParameterTypeClass,
|
||||
paramTypeClass?: ParameterTypeClass,
|
||||
) => number;
|
||||
|
||||
/**
|
||||
* Optional hook bundle for narrowing extension points. Threaded in
|
||||
|
|
@ -130,7 +141,16 @@ export function narrowOverloadCandidates(
|
|||
if (params === undefined) return false;
|
||||
for (let i = 0; i < argTypes.length && i < params.length; i++) {
|
||||
if (argTypes[i] === '') continue;
|
||||
if (argTypes[i] !== params[i]) return false;
|
||||
if (
|
||||
!exactTypeSlotMatches(
|
||||
argTypes[i],
|
||||
params[i],
|
||||
hookCtx?.argumentTypeClasses?.[i],
|
||||
d.parameterTypeClasses?.[i],
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
|
@ -144,7 +164,12 @@ export function narrowOverloadCandidates(
|
|||
// are returned; multiple survivors are genuinely ambiguous. When
|
||||
// ranking also yields empty, fall through to the arity-filtered
|
||||
// `candidates` set — matches pre-#1606 behavior.
|
||||
const ranked = rankByConversion(candidates, argTypes, hookCtx.conversionRankFn);
|
||||
const ranked = rankByConversion(
|
||||
candidates,
|
||||
argTypes,
|
||||
hookCtx.conversionRankFn,
|
||||
hookCtx.argumentTypeClasses,
|
||||
);
|
||||
if (ranked.length > 0) result = ranked;
|
||||
}
|
||||
}
|
||||
|
|
@ -183,6 +208,27 @@ export function narrowOverloadCandidates(
|
|||
return result;
|
||||
}
|
||||
|
||||
function exactTypeSlotMatches(
|
||||
argType: string,
|
||||
paramType: string,
|
||||
argTypeClass?: ParameterTypeClass,
|
||||
paramTypeClass?: ParameterTypeClass,
|
||||
): boolean {
|
||||
if (argType !== paramType) return false;
|
||||
// C++ normalizes away pointer markers (`int*` -> `int`). When both sides
|
||||
// provide shape sidecars, do not let that collapse make `int` exactly match
|
||||
// `int*`. Unknown sidecar evidence preserves the previous string-only path.
|
||||
if (argTypeClass === undefined || paramTypeClass === undefined) return true;
|
||||
if (argTypeClass.indirection === 'unknown' || paramTypeClass.indirection === 'unknown') {
|
||||
return true;
|
||||
}
|
||||
return isPointerShape(argTypeClass) === isPointerShape(paramTypeClass);
|
||||
}
|
||||
|
||||
function isPointerShape(typeClass: ParameterTypeClass): boolean {
|
||||
return typeClass.indirection === 'pointer' && typeClass.pointerDepth > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pairwise dominance comparison (ISO C++ [over.ics.rank]).
|
||||
*
|
||||
|
|
@ -199,6 +245,7 @@ function rankByConversion(
|
|||
candidates: readonly SymbolDefinition[],
|
||||
argTypes: readonly string[],
|
||||
rankFn: ConversionRankFn,
|
||||
argTypeClasses?: readonly ParameterTypeClass[],
|
||||
): readonly SymbolDefinition[] {
|
||||
// Step 1: compute per-slot ranks and exclude non-viable candidates.
|
||||
const viable: Array<{ def: SymbolDefinition; ranks: number[] }> = [];
|
||||
|
|
@ -207,12 +254,22 @@ function rankByConversion(
|
|||
if (params === undefined) continue;
|
||||
const ranks: number[] = [];
|
||||
let ok = true;
|
||||
for (let i = 0; i < argTypes.length && i < params.length; i++) {
|
||||
for (let i = 0; i < argTypes.length; i++) {
|
||||
const paramType = parameterTypeAt(params, i);
|
||||
if (paramType === undefined) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
if (argTypes[i] === '') {
|
||||
ranks.push(0); // unknown arg → any-match (rank 0)
|
||||
continue;
|
||||
}
|
||||
const r = rankFn(argTypes[i], params[i]);
|
||||
const r = rankFn(
|
||||
argTypes[i],
|
||||
paramType,
|
||||
argTypeClasses?.[i],
|
||||
parameterTypeClassAt(d.parameterTypeClasses, i),
|
||||
);
|
||||
if (!isFinite(r)) {
|
||||
ok = false;
|
||||
break;
|
||||
|
|
@ -239,6 +296,20 @@ function rankByConversion(
|
|||
return viable.filter((_, idx) => !dominated.has(idx)).map((v) => v.def);
|
||||
}
|
||||
|
||||
function parameterTypeAt(params: readonly string[], argIndex: number): string | undefined {
|
||||
if (argIndex < params.length) return params[argIndex];
|
||||
return params[params.length - 1] === '...' ? '...' : undefined;
|
||||
}
|
||||
|
||||
function parameterTypeClassAt(
|
||||
params: readonly ParameterTypeClass[] | undefined,
|
||||
argIndex: number,
|
||||
): ParameterTypeClass | undefined {
|
||||
if (params === undefined) return undefined;
|
||||
if (argIndex < params.length) return params[argIndex];
|
||||
return params[params.length - 1]?.base === '...' ? params[params.length - 1] : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two per-slot rank vectors.
|
||||
* Returns -1 if `a` dominates `b` (not worse everywhere, better somewhere),
|
||||
|
|
|
|||
|
|
@ -21,6 +21,11 @@
|
|||
* but not a namespace prefix → compound resolver
|
||||
* 7. **Case 4 (simple typeBinding)** — `typeRef.rawName` has no dot →
|
||||
* MRO walk + `findOwnedMember`
|
||||
* 8. **Case 5 (value-receiver bridge)** — receiver is a `Const`/`Variable`
|
||||
* whose `nodeId` is referenced as an `ownerId` in `model.methods`
|
||||
* (object-literal services). Last-resort fallback for lowercase
|
||||
* receivers with no class-like or type-binding match. Mirrors
|
||||
* the legacy DAG bridge in `call-processor.ts`.
|
||||
*
|
||||
* Reordering or merging cases changes resolution semantics.
|
||||
*
|
||||
|
|
@ -46,9 +51,10 @@ import {
|
|||
findExportedDef,
|
||||
findOwnedMember,
|
||||
findReceiverTypeBinding,
|
||||
findValueBindingInScope,
|
||||
isClassLike,
|
||||
} from '../scope/walkers.js';
|
||||
import { tryEmitEdge } from '../graph-bridge/edges.js';
|
||||
import { tryEmitEdge, tryEmitEdgeWithExplicitTargetId } from '../graph-bridge/edges.js';
|
||||
import { resolveCompoundReceiverClass } from '../passes/compound-receiver.js';
|
||||
import { resolveDefGraphId } from '../graph-bridge/ids.js';
|
||||
import {
|
||||
|
|
@ -706,6 +712,61 @@ export function emitReceiverBoundCalls(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Case 5: value-receiver bridge (object-literal services) ──
|
||||
// When prior cases couldn't resolve the receiver as a class or
|
||||
// type binding, fall back to value-binding resolution. Covers:
|
||||
//
|
||||
// export const fooService = { getUser(id) {...} };
|
||||
// import { fooService } from './service';
|
||||
// fooService.getUser(id); // ← resolve here
|
||||
//
|
||||
// `fooService` is a `Const`/`Variable` (not class-like, no typeBinding
|
||||
// for unannotated literals), so Cases 2-4 skip it. Scope-resolution
|
||||
// defs for non-class values carry a synthetic id, so we translate to
|
||||
// the canonical graph node ID via `resolveDefGraphId` before owner-
|
||||
// indexed lookup — the parser writes the graph node ID as `ownerId`
|
||||
// on the method symbol-table entry to match.
|
||||
//
|
||||
// Object-literal methods do not carry a `qualifiedName` (no class
|
||||
// owner to seed it), so the picked def cannot round-trip through
|
||||
// `tryEmitEdge` → `resolveDefGraphId`. We use
|
||||
// `tryEmitEdgeWithExplicitTargetId` instead, passing `picked.nodeId`
|
||||
// directly — same dedup-key shape, collapse-flag honoring, and
|
||||
// caller resolution as `tryEmitEdge`.
|
||||
const valueDef = findValueBindingInScope(site.inScope, receiverName, scopes);
|
||||
if (valueDef !== undefined) {
|
||||
const ownerGraphId =
|
||||
resolveDefGraphId(valueDef.filePath, valueDef, nodeLookup) ?? valueDef.nodeId;
|
||||
const picked = pickOverload(ownerGraphId, memberName, site, model, provider);
|
||||
if (picked === OVERLOAD_AMBIGUOUS) {
|
||||
handledSites.add(siteKey);
|
||||
continue;
|
||||
}
|
||||
if (picked !== undefined) {
|
||||
const reason =
|
||||
site.kind === 'write' || site.kind === 'read'
|
||||
? site.kind
|
||||
: picked.filePath !== parsed.filePath
|
||||
? 'import-resolved'
|
||||
: 'global';
|
||||
const confidence = site.kind === 'write' || site.kind === 'read' ? 1.0 : 0.85;
|
||||
const ok = tryEmitEdgeWithExplicitTargetId(
|
||||
graph,
|
||||
scopes,
|
||||
nodeLookup,
|
||||
site,
|
||||
picked.nodeId,
|
||||
reason,
|
||||
seen,
|
||||
confidence,
|
||||
collapse,
|
||||
);
|
||||
if (ok) emitted++;
|
||||
handledSites.add(siteKey);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ import { javaScopeResolver } from '../../languages/java/scope-resolver.js';
|
|||
import { cScopeResolver } from '../../languages/c/scope-resolver.js';
|
||||
import { cppScopeResolver } from '../../languages/cpp/scope-resolver.js';
|
||||
import { phpScopeResolver } from '../../languages/php/scope-resolver.js';
|
||||
import { javascriptScopeResolver } from '../../languages/javascript/scope-resolver.js';
|
||||
import { kotlinScopeResolver } from '../../languages/kotlin/scope-resolver.js';
|
||||
|
||||
/** Map of `SupportedLanguages` → `ScopeResolver`. The phase iterates
|
||||
* this map intersected with `MIGRATED_LANGUAGES` (the per-language
|
||||
|
|
@ -36,4 +38,6 @@ export const SCOPE_RESOLVERS: ReadonlyMap<SupportedLanguages, ScopeResolver> = n
|
|||
[SupportedLanguages.C, cScopeResolver],
|
||||
[SupportedLanguages.CPlusPlus, cppScopeResolver],
|
||||
[SupportedLanguages.PHP, phpScopeResolver],
|
||||
[SupportedLanguages.JavaScript, javascriptScopeResolver],
|
||||
[SupportedLanguages.Kotlin, kotlinScopeResolver],
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -165,28 +165,9 @@ export function findClassBindingInScope(
|
|||
receiverName: string,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
): SymbolDefinition | undefined {
|
||||
let currentId: ScopeId | null = startScope;
|
||||
const visited = new Set<ScopeId>();
|
||||
while (currentId !== null) {
|
||||
if (visited.has(currentId)) return undefined;
|
||||
visited.add(currentId);
|
||||
const scope = scopes.scopeTree.getScope(currentId);
|
||||
if (scope === undefined) return undefined;
|
||||
const local = walkScopeChain(startScope, receiverName, scopes, (def) => isClassLike(def.type));
|
||||
if (local !== undefined) return local;
|
||||
|
||||
const localBindings = scope.bindings.get(receiverName);
|
||||
if (localBindings !== undefined) {
|
||||
for (const b of localBindings) {
|
||||
if (isClassLike(b.def.type)) return b.def;
|
||||
}
|
||||
}
|
||||
|
||||
const importedBindings = lookupBindingsAt(currentId, receiverName, scopes);
|
||||
for (const b of importedBindings) {
|
||||
if (isClassLike(b.def.type)) return b.def;
|
||||
}
|
||||
|
||||
currentId = scope.parent;
|
||||
}
|
||||
// Fallback for languages (Go) where namespace-style imports don't
|
||||
// create scope bindings: resolve via QualifiedNameIndex. Only fires
|
||||
// when the scope-chain walk found nothing; single-match wins.
|
||||
|
|
@ -211,6 +192,89 @@ export function findClassBindingInScope(
|
|||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Predicate for value-receiver bridge: the labels for which
|
||||
* `reconcileOwnership` registers methods/fields under the def's
|
||||
* `nodeId` as the `ownerId`. Explicit allowlist so future NodeLabel
|
||||
* additions (Module, Namespace, TypeAlias, EnumMember, etc.) do NOT
|
||||
* silently widen the bridge — adding a new ownerable label requires
|
||||
* touching both this predicate and `reconcileOwnership`.
|
||||
*
|
||||
* See: `scope-resolution/pipeline/reconcile-ownership.ts` Property /
|
||||
* Variable / Const / Static registration block.
|
||||
*/
|
||||
export function isOwnableValueLabel(t: string): boolean {
|
||||
return t === 'Const' || t === 'Variable' || t === 'Property' || t === 'Static';
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a value-binding (Const/Variable/Property/Static) by name in
|
||||
* the given scope's chain. Used by the value-receiver-owner bridge
|
||||
* for object-literal services such as:
|
||||
*
|
||||
* export const fooService = { getUser(id) {...} };
|
||||
*
|
||||
* where `fooService` is a `Const`/`Variable` whose `nodeId` is the
|
||||
* `ownerId` of the member method. Neither `findClassBindingInScope`
|
||||
* (rejects non-class-like) nor `findReceiverTypeBinding` (no typeBinding
|
||||
* for an unannotated literal) finds it.
|
||||
*
|
||||
* Mirrors `findClassBindingInScope` exactly; only the accepted def-type
|
||||
* predicate differs.
|
||||
*/
|
||||
export function findValueBindingInScope(
|
||||
startScope: ScopeId,
|
||||
receiverName: string,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
): SymbolDefinition | undefined {
|
||||
return walkScopeChain(startScope, receiverName, scopes, (def) => isOwnableValueLabel(def.type));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic scope-chain walker. Walks from `startScope` toward the root,
|
||||
* consulting both the local `scope.bindings` channel and the dual-source
|
||||
* `lookupBindingsAt` view (finalized + augmented). At each scope, local
|
||||
* bindings are exhausted BEFORE imported/augmented bindings — preserves
|
||||
* JavaScript-style lexical scoping where a local `const x` shadows an
|
||||
* imported `x` of the same name.
|
||||
*
|
||||
* Returns the first binding `def` matching `predicate`. Cycles in the
|
||||
* scope graph terminate the walk (defensive — should not occur in
|
||||
* well-formed inputs).
|
||||
*/
|
||||
function walkScopeChain(
|
||||
startScope: ScopeId,
|
||||
name: string,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
predicate: (def: SymbolDefinition) => boolean,
|
||||
): SymbolDefinition | undefined {
|
||||
let currentId: ScopeId | null = startScope;
|
||||
const visited = new Set<ScopeId>();
|
||||
while (currentId !== null) {
|
||||
if (visited.has(currentId)) return undefined;
|
||||
visited.add(currentId);
|
||||
const scope = scopes.scopeTree.getScope(currentId);
|
||||
if (scope === undefined) return undefined;
|
||||
|
||||
// Local first: a `const x` in this scope shadows any imported `x`.
|
||||
const localBindings = scope.bindings.get(name);
|
||||
if (localBindings !== undefined) {
|
||||
for (const b of localBindings) {
|
||||
if (predicate(b.def)) return b.def;
|
||||
}
|
||||
}
|
||||
|
||||
// Then imported/augmented bindings — only consulted when no local match.
|
||||
const importedBindings = lookupBindingsAt(currentId, name, scopes);
|
||||
for (const b of importedBindings) {
|
||||
if (predicate(b.def)) return b.def;
|
||||
}
|
||||
|
||||
currentId = scope.parent;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a callable (Function/Method/Constructor) by name in the
|
||||
* given scope's chain. Uses the dual-source pattern (scope.bindings +
|
||||
|
|
|
|||
|
|
@ -702,7 +702,9 @@ export const CPP_QUERIES = `
|
|||
|
||||
; Functions & Methods (direct declarator)
|
||||
(function_definition declarator: (function_declarator declarator: (identifier) @name)) @definition.function
|
||||
(function_definition declarator: (function_declarator declarator: (operator_name) @name)) @definition.function
|
||||
(function_definition declarator: (function_declarator declarator: (qualified_identifier name: (identifier) @name))) @definition.method
|
||||
(function_definition declarator: (function_declarator declarator: (qualified_identifier name: (operator_name) @name))) @definition.method
|
||||
|
||||
; Functions/methods returning pointers (pointer_declarator wraps function_declarator)
|
||||
(function_definition declarator: (pointer_declarator declarator: (function_declarator declarator: (identifier) @name))) @definition.function
|
||||
|
|
@ -714,14 +716,18 @@ export const CPP_QUERIES = `
|
|||
|
||||
; Functions/methods returning references (reference_declarator wraps function_declarator)
|
||||
(function_definition declarator: (reference_declarator (function_declarator declarator: (identifier) @name))) @definition.function
|
||||
(function_definition declarator: (reference_declarator (function_declarator declarator: (operator_name) @name))) @definition.function
|
||||
(function_definition declarator: (reference_declarator (function_declarator declarator: (qualified_identifier name: (identifier) @name)))) @definition.method
|
||||
(function_definition declarator: (reference_declarator (function_declarator declarator: (qualified_identifier name: (operator_name) @name)))) @definition.method
|
||||
|
||||
; Destructors (destructor_name is distinct from identifier in tree-sitter-cpp)
|
||||
(function_definition declarator: (function_declarator declarator: (qualified_identifier name: (destructor_name) @name))) @definition.method
|
||||
|
||||
; Function declarations / prototypes (common in headers)
|
||||
(declaration declarator: (function_declarator declarator: (identifier) @name)) @definition.function
|
||||
(declaration declarator: (function_declarator declarator: (operator_name) @name)) @definition.function
|
||||
(declaration declarator: (pointer_declarator declarator: (function_declarator declarator: (identifier) @name))) @definition.function
|
||||
(declaration declarator: (reference_declarator (function_declarator declarator: (operator_name) @name))) @definition.function
|
||||
|
||||
; Class/struct data member fields (Address address; int count;)
|
||||
; Uses field_identifier to exclude method declarations (which use function_declarator)
|
||||
|
|
@ -740,13 +746,13 @@ export const CPP_QUERIES = `
|
|||
|
||||
; Inline class method declarations (inside class body, no body: void save();)
|
||||
; tree-sitter-cpp uses field_identifier (not identifier) for names inside class bodies
|
||||
(field_declaration declarator: (function_declarator declarator: [(field_identifier) (identifier)] @name)) @definition.method
|
||||
(field_declaration declarator: (function_declarator declarator: [(field_identifier) (identifier) (operator_name)] @name)) @definition.method
|
||||
|
||||
; Inline class method declarations returning a pointer (User* lookup();)
|
||||
(field_declaration declarator: (pointer_declarator declarator: (function_declarator declarator: [(field_identifier) (identifier)] @name))) @definition.method
|
||||
|
||||
; Inline class method declarations returning a reference (User& lookup();)
|
||||
(field_declaration declarator: (reference_declarator (function_declarator declarator: [(field_identifier) (identifier)] @name))) @definition.method
|
||||
(field_declaration declarator: (reference_declarator (function_declarator declarator: [(field_identifier) (identifier) (operator_name)] @name))) @definition.method
|
||||
|
||||
; Inline class method definitions (inside class body, with body: void Foo() { ... })
|
||||
(field_declaration_list
|
||||
|
|
@ -785,6 +791,8 @@ export const CPP_QUERIES = `
|
|||
(call_expression function: (field_expression field: (field_identifier) @call.name)) @call
|
||||
(call_expression function: (qualified_identifier name: (identifier) @call.name)) @call
|
||||
(call_expression function: (template_function name: (identifier) @call.name)) @call
|
||||
(binary_expression operator: "+" @call.name) @call
|
||||
(binary_expression operator: "<<" @call.name) @call
|
||||
|
||||
; Constructor calls: new User()
|
||||
(new_expression type: (type_identifier) @call.name) @call
|
||||
|
|
|
|||
|
|
@ -411,6 +411,123 @@ export const findEnclosingClassInfo = (
|
|||
return null;
|
||||
};
|
||||
|
||||
/** Object literal binding info for TS/JS shorthand methods. */
|
||||
export interface ObjectLiteralBindingInfo {
|
||||
ownerId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Block-statement AST types that disqualify an object-literal binding from
|
||||
* carrying a HAS_METHOD edge. A `const` declared inside one of these is block-
|
||||
* scoped and cannot be imported, so attributing methods to it would create
|
||||
* false-positive cross-file edges.
|
||||
*/
|
||||
const BLOCK_SCOPE_BOUNDARY_TYPES = new Set([
|
||||
'statement_block',
|
||||
'if_statement',
|
||||
'else_clause',
|
||||
'for_statement',
|
||||
'for_in_statement',
|
||||
'for_of_statement',
|
||||
'while_statement',
|
||||
'do_statement',
|
||||
'try_statement',
|
||||
'catch_clause',
|
||||
'finally_clause',
|
||||
'switch_statement',
|
||||
'switch_case',
|
||||
'switch_default',
|
||||
'with_statement',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Find the file-scope variable that owns an object literal method definition.
|
||||
*
|
||||
* Covers TypeScript/JavaScript shorthand object methods such as:
|
||||
*
|
||||
* export const service = { async load() {} };
|
||||
*
|
||||
* tree-sitter represents `load` as a `method_definition` inside an `object`,
|
||||
* not inside a class container. Without this fallback, ingestion emits a
|
||||
* top-level `Method` node but no edge from the exported `service` value to
|
||||
* that method, so impact queries cannot discover `service.load`.
|
||||
*
|
||||
* Two-phase walk:
|
||||
* Phase A walks up from `node` tracking how many `object` ancestors we
|
||||
* cross. The first `variable_declarator` reached with `objectDepth >= 1`
|
||||
* is the candidate owner — unless `objectDepth > 1` (the method belongs
|
||||
* to a nested object literal; we return null rather than misattribute
|
||||
* to the outer binding). Hitting a function/class container before the
|
||||
* declarator returns null (catches IIFE-wrapped literals).
|
||||
* Phase B walks the declarator's own ancestors. Any function or class
|
||||
* ancestor before reaching `program`/`export_statement` returns null
|
||||
* (catches `const` declared inside a function body). Any block-statement
|
||||
* ancestor also returns null (catches block-scoped declarations inside
|
||||
* top-level `if`/`for`/`try`/etc., which cannot be imported).
|
||||
*/
|
||||
export const findObjectLiteralBindingInfo = (
|
||||
node: SyntaxNode,
|
||||
filePath: string,
|
||||
): ObjectLiteralBindingInfo | null => {
|
||||
// ── Phase A: walk up from node, count `object` ancestors, find declarator
|
||||
let current: SyntaxNode | null = node;
|
||||
let objectDepth = 0;
|
||||
let declarator: SyntaxNode | null = null;
|
||||
|
||||
while (current) {
|
||||
if (current.type === 'object') {
|
||||
objectDepth += 1;
|
||||
}
|
||||
|
||||
if (current.type === 'variable_declarator' && objectDepth >= 1) {
|
||||
if (objectDepth > 1) {
|
||||
// Method belongs to a nested object literal; safe under-approximation.
|
||||
return null;
|
||||
}
|
||||
declarator = current;
|
||||
break;
|
||||
}
|
||||
|
||||
if (
|
||||
current !== node &&
|
||||
(FUNCTION_NODE_TYPES.has(current.type) || CLASS_CONTAINER_TYPES.has(current.type))
|
||||
) {
|
||||
// Function/class container encountered before owning declarator
|
||||
// (e.g. IIFE-wrapped object literal). Bail out.
|
||||
return null;
|
||||
}
|
||||
|
||||
current = current.parent;
|
||||
}
|
||||
|
||||
if (!declarator) return null;
|
||||
|
||||
// ── Phase B: declarator must live at file scope (program / export_statement)
|
||||
// with no function, class, or block-statement ancestor in between.
|
||||
let anc: SyntaxNode | null = declarator.parent;
|
||||
while (anc) {
|
||||
if (anc.type === 'program' || anc.type === 'export_statement') {
|
||||
break;
|
||||
}
|
||||
if (FUNCTION_NODE_TYPES.has(anc.type) || CLASS_CONTAINER_TYPES.has(anc.type)) {
|
||||
return null;
|
||||
}
|
||||
if (BLOCK_SCOPE_BOUNDARY_TYPES.has(anc.type)) {
|
||||
return null;
|
||||
}
|
||||
anc = anc.parent;
|
||||
}
|
||||
|
||||
const nameNode = declarator.childForFieldName?.('name');
|
||||
if (!nameNode || nameNode.type !== 'identifier') return null;
|
||||
|
||||
const declaration = declarator.parent;
|
||||
const ownerLabel = declaration?.type === 'variable_declaration' ? 'Variable' : 'Const';
|
||||
return {
|
||||
ownerId: generateId(ownerLabel, `${filePath}:${nameNode.text}`),
|
||||
};
|
||||
};
|
||||
|
||||
/** Convenience wrapper: returns just the class ID string (backward compat). */
|
||||
export const findEnclosingClassId = (node: SyntaxNode, filePath: string): string | null => {
|
||||
return findEnclosingClassInfo(node, filePath)?.classId ?? null;
|
||||
|
|
|
|||
120
gitnexus/src/core/ingestion/utils/deferred-resolution-profile.ts
Normal file
120
gitnexus/src/core/ingestion/utils/deferred-resolution-profile.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
/**
|
||||
* Wall-clock logging for the post-chunk deferred resolution band
|
||||
* (imports → heritage → heritage map → legacy call resolution).
|
||||
*
|
||||
* Enabled when either:
|
||||
* - `GITNEXUS_VERBOSE=1` / `gitnexus analyze -v` (primary path for #1741), or
|
||||
* - `GITNEXUS_PROFILE_DEFERRED=1` (force on without full verbose ingestion noise)
|
||||
*
|
||||
* Issue #1741: large Java/Kotlin repos appear stuck at "Resolving calls"
|
||||
* because the UI progress bar updates every 100 files and intermediate
|
||||
* stages emit little to the log.
|
||||
*/
|
||||
|
||||
import { logger } from '../../logger.js';
|
||||
import { parseTruthyEnv } from './env.js';
|
||||
import { isVerboseIngestionEnabled } from './verbose.js';
|
||||
|
||||
// Module-private tuning constants for the gates below. Not exported — these
|
||||
// are internal knobs, not part of the module's API surface.
|
||||
const LOG_EVERY_N_VERBOSE = 10;
|
||||
const LOG_EVERY_N_PROFILE = 100;
|
||||
const DEFAULT_SLOW_MS_VERBOSE = 3_000;
|
||||
const DEFAULT_SLOW_MS = 5_000;
|
||||
|
||||
/** True when deferred-stage timing / progress logs should emit. */
|
||||
export const isDeferredResolutionProfileEnabled = (): boolean =>
|
||||
isVerboseIngestionEnabled() || parseTruthyEnv(process.env.GITNEXUS_PROFILE_DEFERRED);
|
||||
|
||||
/** Log a call-resolution progress line every N files (finer when verbose). */
|
||||
export const deferredCallLogEveryN = (): number =>
|
||||
isVerboseIngestionEnabled() ? LOG_EVERY_N_VERBOSE : LOG_EVERY_N_PROFILE;
|
||||
|
||||
/** Per-file call-resolution log threshold (ms). Lower default when verbose. */
|
||||
export const deferredCallFileSlowMs = (): number => {
|
||||
const raw = process.env.GITNEXUS_PROFILE_DEFERRED_SLOW_MS;
|
||||
if (raw) {
|
||||
// Use Number() not parseInt: parseInt('1e9', 10) === 1 (prefix-parses, drops the exponent),
|
||||
// which would turn a user-intended "effectively disabled" threshold into a 1 ms log storm.
|
||||
const n = Number(raw);
|
||||
if (Number.isFinite(n) && n > 0) return n;
|
||||
}
|
||||
return isVerboseIngestionEnabled() ? DEFAULT_SLOW_MS_VERBOSE : DEFAULT_SLOW_MS;
|
||||
};
|
||||
|
||||
export const profileNow = (): bigint => process.hrtime.bigint();
|
||||
|
||||
export const profileElapsedMs = (start: bigint): number =>
|
||||
Number(process.hrtime.bigint() - start) / 1e6;
|
||||
|
||||
// Module-private counter for `[deferred-profile]` log lines the underlying
|
||||
// logger refused to accept. Pino's SonicBoom transport is sync:false today,
|
||||
// so steady-state `logger.info(string)` calls don't throw — but first-use
|
||||
// construction paths (pino-pretty resolve, level validation) and any future
|
||||
// transport reconfiguration could. The wrap below catches and counts so a
|
||||
// failing logger cannot abort the deferred band, and the count surfaces in
|
||||
// the deferred-band done-summary (see processCallsFromExtracted) so the
|
||||
// failure is visible rather than silently swallowed (DoD §2.8).
|
||||
let droppedLogLines = 0;
|
||||
|
||||
/**
|
||||
* Number of `logDeferredProfile` calls whose underlying `logger.info` threw.
|
||||
* Surfaced in the deferred-band done-summary when greater than zero.
|
||||
*/
|
||||
export const getDeferredProfileDroppedCount = (): number => droppedLogLines;
|
||||
|
||||
/**
|
||||
* Reset the dropped-line counter. Call from test `afterEach` to keep the
|
||||
* module-private state from leaking across tests. Also used inside
|
||||
* `processCallsFromExtracted` at function entry so each analyze run gets
|
||||
* a fresh count rather than accumulating across the process lifetime.
|
||||
*/
|
||||
export const resetDeferredProfileDroppedCount = (): void => {
|
||||
droppedLogLines = 0;
|
||||
};
|
||||
|
||||
export const logDeferredProfile = (message: string): void => {
|
||||
try {
|
||||
logger.info(`[deferred-profile] ${message}`);
|
||||
} catch {
|
||||
// Do not call the failing logger from the handler — that would risk
|
||||
// an infinite loop if the failure mode is steady-state. Just count.
|
||||
droppedLogLines++;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Capture a monotonic timestamp when profiling is enabled; otherwise return null.
|
||||
* Pair with `endTimer` so the type system narrows correctly — using `null` instead
|
||||
* of a `0n` sentinel makes "profiling disabled" structurally distinct from
|
||||
* "zero elapsed time" and lets TypeScript catch missing guards.
|
||||
*/
|
||||
export const startTimer = (enabled: boolean): bigint | null =>
|
||||
enabled ? process.hrtime.bigint() : null;
|
||||
|
||||
/**
|
||||
* Emit a `[deferred-profile]` log line for a captured timer. No-op when the
|
||||
* timer is `null` (profiling was disabled at capture time). The formatter
|
||||
* receives elapsed ms so the call sites stay readable.
|
||||
*
|
||||
* The format callback runs inside a try/catch so a throwing formatter
|
||||
* (custom toString, JSON.stringify on a circular object) cannot abort the
|
||||
* deferred resolution band — observability code must never escalate to a
|
||||
* load-bearing failure. On catch we emit a single `formatter error: …`
|
||||
* line via logDeferredProfile and return; the caller's stage continues
|
||||
* as if profiling had no-op'd for this timer. DoD §2.8 ("no silent
|
||||
* catches that swallow diagnostics") is satisfied by surfacing the
|
||||
* failure message rather than dropping it.
|
||||
*/
|
||||
export const endTimer = (start: bigint | null, format: (elapsedMs: number) => string): void => {
|
||||
if (start === null) return;
|
||||
const elapsedMs = profileElapsedMs(start);
|
||||
let message: string;
|
||||
try {
|
||||
message = format(elapsedMs);
|
||||
} catch (err) {
|
||||
logDeferredProfile(`formatter error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return;
|
||||
}
|
||||
logDeferredProfile(message);
|
||||
};
|
||||
|
|
@ -10,6 +10,24 @@
|
|||
/** Whether we're running in development mode (enables verbose console logging). */
|
||||
export const isDev = process.env.NODE_ENV === 'development';
|
||||
|
||||
/**
|
||||
* Parse a narrow-form truthy env-var value. Accepts `'1'`, `'true'`, `'yes'`
|
||||
* (case-insensitive, whitespace-trimmed). Anything else — including
|
||||
* `undefined`, empty string, `'0'`, `'false'`, `'no'`, or unknown tokens —
|
||||
* returns `false`.
|
||||
*
|
||||
* This is the shared helper for narrow-form truthy parsing across the
|
||||
* ingestion module. `logger.ts` uses a broader negative-list form
|
||||
* (`isTruthyEnv`) that intentionally accepts anything except a small set of
|
||||
* falsy tokens — that lives separately because it follows pino-debug
|
||||
* conventions and serves a different purpose.
|
||||
*/
|
||||
export const parseTruthyEnv = (raw: string | undefined): boolean => {
|
||||
if (raw === undefined) return false;
|
||||
const value = raw.trim().toLowerCase();
|
||||
return value === '1' || value === 'true' || value === 'yes';
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether scope-resolution dev validators (e.g. `validateBindingsImmutability`)
|
||||
* should run AND emit warnings. Off by default in CLI runs to avoid silent
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
export const isVerboseIngestionEnabled = (): boolean => {
|
||||
const raw = process.env.GITNEXUS_VERBOSE;
|
||||
if (!raw) return false;
|
||||
const value = raw.toLowerCase();
|
||||
return value === '1' || value === 'true' || value === 'yes';
|
||||
};
|
||||
import { parseTruthyEnv } from './env.js';
|
||||
|
||||
export const isVerboseIngestionEnabled = (): boolean =>
|
||||
parseTruthyEnv(process.env.GITNEXUS_VERBOSE);
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ import {
|
|||
FUNCTION_NODE_TYPES,
|
||||
getDefinitionNodeFromCaptures,
|
||||
findEnclosingClassInfo,
|
||||
findObjectLiteralBindingInfo,
|
||||
type EnclosingClassInfo,
|
||||
getLabelFromCaptures,
|
||||
findDescendant,
|
||||
|
|
@ -301,10 +302,7 @@ export interface ParseWorkerInput {
|
|||
content: string;
|
||||
}
|
||||
|
||||
type WorkerIncomingMessage =
|
||||
| { type: 'sub-batch'; files: ParseWorkerInput[] }
|
||||
| { type: 'flush' }
|
||||
| ParseWorkerInput[];
|
||||
type WorkerIncomingMessage = { type: 'sub-batch'; files: ParseWorkerInput[] } | { type: 'flush' };
|
||||
|
||||
// ============================================================================
|
||||
// Worker-local parser + language map
|
||||
|
|
@ -1401,6 +1399,15 @@ const processFileGroup = (
|
|||
// Skip files larger than the max tree-sitter buffer (32 MB)
|
||||
if (getTreeSitterContentByteLength(file.content) > TREE_SITTER_MAX_BUFFER) continue;
|
||||
|
||||
// Authoritative in-flight signal for the pool: lets `WorkerPool` exclude
|
||||
// exactly this file if the worker dies during parse/extract, instead of
|
||||
// guessing from `items[lastProgress]` (which the language-grouped order
|
||||
// here would defeat). The pool gracefully ignores this when running an
|
||||
// older worker build that doesn't emit it.
|
||||
if (parentPort) {
|
||||
parentPort.postMessage({ type: 'starting-file', path: file.path });
|
||||
}
|
||||
|
||||
// Vue SFC preprocessing: extract <script> block content
|
||||
let parseContent = file.content;
|
||||
let lineOffset = 0;
|
||||
|
|
@ -1458,8 +1465,11 @@ const processFileGroup = (
|
|||
parseContent,
|
||||
file.path,
|
||||
(message) => {
|
||||
if (parentPort) parentPort.postMessage({ type: 'warning', message });
|
||||
else logger.warn(message);
|
||||
if (parentPort) {
|
||||
parentPort.postMessage({ type: 'warning', message });
|
||||
} else {
|
||||
logger.warn(message);
|
||||
}
|
||||
},
|
||||
tree,
|
||||
);
|
||||
|
|
@ -2059,6 +2069,10 @@ const processFileGroup = (
|
|||
)
|
||||
: null;
|
||||
const enclosingClassId = enclosingClassInfo?.classId ?? null;
|
||||
const objectLiteralOwnerInfo =
|
||||
!enclosingClassId && nodeLabel === 'Method' && definitionNode
|
||||
? findObjectLiteralBindingInfo(definitionNode, file.path)
|
||||
: null;
|
||||
|
||||
// Qualify method/property IDs with enclosing class name to avoid collisions
|
||||
const qualifiedName = enclosingClassInfo
|
||||
|
|
@ -2297,6 +2311,7 @@ const processFileGroup = (
|
|||
});
|
||||
|
||||
// enclosingClassId already computed above (before nodeId generation)
|
||||
const ownerId = enclosingClassId ?? objectLiteralOwnerInfo?.ownerId;
|
||||
|
||||
result.symbols.push({
|
||||
filePath: file.path,
|
||||
|
|
@ -2313,7 +2328,7 @@ const processFileGroup = (
|
|||
...(classTemplateArguments !== undefined && classTemplateArguments.length > 0
|
||||
? { templateArguments: classTemplateArguments }
|
||||
: {}),
|
||||
...(enclosingClassId ? { ownerId: enclosingClassId } : {}),
|
||||
...(ownerId !== undefined ? { ownerId } : {}),
|
||||
visibility: methodProps.visibility as string | undefined,
|
||||
isStatic: methodProps.isStatic as boolean | undefined,
|
||||
isReadonly: methodProps.isReadonly as boolean | undefined,
|
||||
|
|
@ -2346,15 +2361,17 @@ const processFileGroup = (
|
|||
});
|
||||
|
||||
// ── HAS_METHOD / HAS_PROPERTY: link member to enclosing class ──
|
||||
if (enclosingClassId) {
|
||||
if (ownerId !== undefined) {
|
||||
const memberEdgeType = nodeLabel === 'Property' ? 'HAS_PROPERTY' : 'HAS_METHOD';
|
||||
result.relationships.push({
|
||||
id: generateId(memberEdgeType, `${enclosingClassId}->${nodeId}`),
|
||||
sourceId: enclosingClassId,
|
||||
id: generateId(memberEdgeType, `${ownerId}->${nodeId}`),
|
||||
sourceId: ownerId,
|
||||
targetId: nodeId,
|
||||
type: memberEdgeType,
|
||||
confidence: 1.0,
|
||||
reason: '',
|
||||
reason: objectLiteralOwnerInfo
|
||||
? 'object literal method belongs to exported object binding'
|
||||
: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -2438,20 +2455,59 @@ const mergeResult = (target: ParseWorkerResult, src: ParseWorkerResult) => {
|
|||
target.fileCount += src.fileCount;
|
||||
};
|
||||
|
||||
// Signal the pool that worker-side initialization (parser imports, language
|
||||
// grammars, type-env setup, all helper modules) is complete and the message
|
||||
// handler below is about to be attached. The pool's `waitForWorkerReady`
|
||||
// resolves on this handshake — without it, a worker that crashes during
|
||||
// top-of-script init slips past pool startup (Node's `online` event fires
|
||||
// before the script body runs) and the pool only notices via the first
|
||||
// dispatch's idle timeout (~30s). Emit once; the dispatch handler treats
|
||||
// any subsequent `ready` message as a benign no-op.
|
||||
//
|
||||
// Native postMessage carries the ready handshake — Node's structured
|
||||
// clone delivers `{type:'ready'}` to the pool's waitForWorkerReady
|
||||
// listener directly. The pool drops the slot if this isn't seen within
|
||||
// `WORKER_READY_TIMEOUT_MS` (5s), so emitting it AFTER all top-of-script
|
||||
// init (imports, native binding loads, type-env setup) completes is the
|
||||
// load-bearing signal that this worker is ready for dispatch.
|
||||
parentPort!.postMessage({ type: 'ready' });
|
||||
|
||||
// Module-scope `TextDecoder` for sub-batch content. The pool sends each
|
||||
// file's content as a `Uint8Array` (zero-copy ArrayBuffer transfer); we
|
||||
// decode to string lazily here, once per file, before handing to
|
||||
// tree-sitter. Hoisted to module scope so we don't allocate a new
|
||||
// ICU-backed decoder per sub-batch — `TextDecoder.decode()` is
|
||||
// stateless across calls and safe to share.
|
||||
const sharedContentDecoder = new TextDecoder('utf-8');
|
||||
|
||||
/**
|
||||
* Convert the pool's sub-batch `files` array (content as `Uint8Array`,
|
||||
* transferred zero-copy) into the `ParseWorkerInput[]` shape
|
||||
* `processBatch` expects (content as `string`). This is the one place
|
||||
* the UTF-8 decode happens — runs on the worker thread in parallel with
|
||||
* continued main-thread work.
|
||||
*/
|
||||
function decodeSubBatchFiles(
|
||||
files: Array<{ path: string; content: Uint8Array | string }>,
|
||||
): ParseWorkerInput[] {
|
||||
return files.map((f) => ({
|
||||
path: f.path,
|
||||
// Test scaffolding (the writeReadyWorker preamble that wraps
|
||||
// parentPort.on) may already convert content to string before
|
||||
// calling here; tolerate both shapes so the same worker code
|
||||
// exercises real and synthetic dispatches.
|
||||
content: typeof f.content === 'string' ? f.content : sharedContentDecoder.decode(f.content),
|
||||
}));
|
||||
}
|
||||
|
||||
parentPort!.on('message', (msg: WorkerIncomingMessage) => {
|
||||
try {
|
||||
// Legacy single-message mode (backward compat): array of files
|
||||
if (Array.isArray(msg)) {
|
||||
const result = processBatch(msg, (filesProcessed) => {
|
||||
parentPort!.postMessage({ type: 'progress', filesProcessed });
|
||||
});
|
||||
parentPort!.postMessage({ type: 'result', data: result });
|
||||
return;
|
||||
}
|
||||
|
||||
// Sub-batch mode: { type: 'sub-batch', files: [...] }
|
||||
if (msg.type === 'sub-batch') {
|
||||
const result = processBatch(msg.files, (filesProcessed) => {
|
||||
const files = decodeSubBatchFiles(
|
||||
msg.files as Array<{ path: string; content: Uint8Array | string }>,
|
||||
);
|
||||
const result = processBatch(files, (filesProcessed) => {
|
||||
parentPort!.postMessage({
|
||||
type: 'progress',
|
||||
filesProcessed: cumulativeProcessed + filesProcessed,
|
||||
|
|
|
|||
59
gitnexus/src/core/ingestion/workers/quarantine.ts
Normal file
59
gitnexus/src/core/ingestion/workers/quarantine.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
/**
|
||||
* Quarantine layer (Layer 3 of the worker-pool resilience model).
|
||||
*
|
||||
* Tracks paths that caused a worker death this pool lifetime and must
|
||||
* not be re-dispatched to a worker. Session-scoped — created once per
|
||||
* `createWorkerPool` invocation and discarded with the pool.
|
||||
*
|
||||
* This module is the first piece of the U13 layer-extraction work. The
|
||||
* doc-review's A10 finding flagged the full 5-module split as
|
||||
* abstraction-without-multi-consumer-demand, so the rest of the
|
||||
* extraction is deferred until a real second consumer emerges (e.g., a
|
||||
* non-parse worker pool that reuses the same resilience layers).
|
||||
* Extracting the smallest self-contained layer first validates the
|
||||
* factory + interface pattern with minimal risk: behavior is unchanged,
|
||||
* the worker-pool.ts public API is unchanged, and existing tests act as
|
||||
* the regression net.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Operations a {@link createQuarantine} instance exposes to the worker
|
||||
* pool. Intentionally tiny — anything more would invite the abstraction
|
||||
* overhead doc-review A10 cautioned against. Snapshot returns a fresh
|
||||
* `string[]` (not a `Set` or iterator) so callers can pass it directly
|
||||
* to `WorkerPoolDispatchError` without an `Array.from` dance and so
|
||||
* mutations to the returned array can't accidentally leak back into the
|
||||
* internal set.
|
||||
*/
|
||||
export interface Quarantine {
|
||||
/** Mark `path` as known-bad for the remainder of this pool's life. */
|
||||
add(path: string): void;
|
||||
/** Whether `path` has been quarantined. */
|
||||
has(path: string): boolean;
|
||||
/** Defensive copy of every quarantined path. */
|
||||
snapshot(): string[];
|
||||
/** How many distinct paths are currently quarantined. */
|
||||
readonly size: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a fresh quarantine. Each `createWorkerPool` invocation gets
|
||||
* its own instance — quarantines never outlive the pool that created
|
||||
* them. The implementation is a thin wrapper around `Set<string>`; the
|
||||
* named interface exists to make the resilience layer addressable as a
|
||||
* unit (named module, dedicated tests) instead of an inline Set field
|
||||
* tangled into 1100+ LOC of pool plumbing.
|
||||
*/
|
||||
export function createQuarantine(): Quarantine {
|
||||
const paths = new Set<string>();
|
||||
return {
|
||||
add: (path) => {
|
||||
paths.add(path);
|
||||
},
|
||||
has: (path) => paths.has(path),
|
||||
snapshot: () => Array.from(paths),
|
||||
get size() {
|
||||
return paths.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -27,6 +27,16 @@ import {
|
|||
waitForWindowsHandleRelease,
|
||||
type LbugConnectionHandle,
|
||||
} from './lbug-config.js';
|
||||
import {
|
||||
finalizeLbugSidecarsAfterClose,
|
||||
inspectLbugSidecars,
|
||||
isMissingShadowSidecarError,
|
||||
isReadOnlyShadowReplayError,
|
||||
preflightLbugSidecars,
|
||||
quarantineWalForMissingShadow,
|
||||
renameFailureMessage,
|
||||
shadowSidecarRecoveryMessage,
|
||||
} from './sidecar-recovery.js';
|
||||
import { isVectorExtensionSupportedByPlatform } from '../platform/capabilities.js';
|
||||
|
||||
import { logger } from '../logger.js';
|
||||
|
|
@ -437,6 +447,180 @@ const queryAndDrain = async (targetConn: lbug.Connection, cypher: string): Promi
|
|||
await drainQueryResult(queryResult);
|
||||
};
|
||||
|
||||
const READ_ONLY_SHADOW_REPLAY_PROBE = 'MATCH (n) RETURN n LIMIT 1';
|
||||
|
||||
/**
|
||||
* Reject the quarantine path when the orphan WAL is too large to safely
|
||||
* discard (>TINY_ORPHAN_WAL_BYTES). Mirrors the preflight policy at
|
||||
* sidecar-recovery.ts:153-160 ("warn, do not quarantine"). Symmetric across
|
||||
* read-only and writable recovery paths (PR #1747 review D2).
|
||||
*
|
||||
* Throws shadowSidecarRecoveryMessage immediately when the WAL is large,
|
||||
* preserving the uncheckpointed pages for explicit operator recovery.
|
||||
* Returns silently when the WAL is absent, tiny, or in any other state
|
||||
* where the existing recovery path is safe to proceed.
|
||||
*/
|
||||
const refuseLargeWalQuarantine = async (
|
||||
dbPath: string,
|
||||
mode: 'read-only' | 'writable',
|
||||
triggeringErr: unknown,
|
||||
): Promise<void> => {
|
||||
const state = await inspectLbugSidecars(dbPath);
|
||||
if (state.kind === 'orphan-wal') {
|
||||
logger.warn(
|
||||
`GitNexus: refusing to quarantine large WAL (${state.walBytes} bytes) at ${dbPath}.wal during ${mode} recovery; ` +
|
||||
'manual recovery required — run `gitnexus analyze --force <repo-path> --index-only`.',
|
||||
);
|
||||
throw new Error(shadowSidecarRecoveryMessage(dbPath, triggeringErr));
|
||||
}
|
||||
};
|
||||
|
||||
const reopenReadOnlyAfterMissingShadow = async (
|
||||
dbPath: string,
|
||||
err: unknown,
|
||||
): Promise<LbugConnectionHandle> => {
|
||||
await refuseLargeWalQuarantine(dbPath, 'read-only', err);
|
||||
try {
|
||||
await quarantineWalForMissingShadow(dbPath, {
|
||||
logger,
|
||||
level: 'warn',
|
||||
reason: 'read-only recovery',
|
||||
});
|
||||
} catch (renameErr) {
|
||||
throw new Error(renameFailureMessage(dbPath, renameErr));
|
||||
}
|
||||
|
||||
const reopened = await openLbugConnection(lbug, dbPath, { readOnly: true });
|
||||
try {
|
||||
await queryAndDrain(reopened.conn, READ_ONLY_SHADOW_REPLAY_PROBE);
|
||||
return reopened;
|
||||
} catch (retryErr) {
|
||||
await closeLbugConnection(reopened);
|
||||
if (isMissingShadowSidecarError(retryErr) || isReadOnlyShadowReplayError(retryErr)) {
|
||||
throw new Error(shadowSidecarRecoveryMessage(dbPath, retryErr));
|
||||
}
|
||||
throw retryErr;
|
||||
}
|
||||
};
|
||||
|
||||
const reopenWritableAfterMissingShadow = async (
|
||||
dbPath: string,
|
||||
err: unknown,
|
||||
): Promise<LbugConnectionHandle> => {
|
||||
await refuseLargeWalQuarantine(dbPath, 'writable', err);
|
||||
try {
|
||||
await quarantineWalForMissingShadow(dbPath, {
|
||||
logger,
|
||||
level: 'warn',
|
||||
reason: 'writable recovery',
|
||||
});
|
||||
} catch (renameErr) {
|
||||
throw new Error(renameFailureMessage(dbPath, renameErr));
|
||||
}
|
||||
|
||||
return await openLbugConnection(lbug, dbPath);
|
||||
};
|
||||
|
||||
const ensureReadOnlyConnectionUsable = async (
|
||||
dbPath: string,
|
||||
handle: LbugConnectionHandle,
|
||||
): Promise<LbugConnectionHandle> => {
|
||||
try {
|
||||
await queryAndDrain(handle.conn, READ_ONLY_SHADOW_REPLAY_PROBE);
|
||||
return handle;
|
||||
} catch (err) {
|
||||
if (isMissingShadowSidecarError(err)) {
|
||||
await closeLbugConnection(handle);
|
||||
return await reopenReadOnlyAfterMissingShadow(dbPath, err);
|
||||
}
|
||||
if (!isReadOnlyShadowReplayError(err)) {
|
||||
await closeLbugConnection(handle);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
await closeLbugConnection(handle);
|
||||
|
||||
const writable = await openLbugConnection(lbug, dbPath);
|
||||
let missingShadowError: unknown;
|
||||
try {
|
||||
await queryAndDrain(writable.conn, READ_ONLY_SHADOW_REPLAY_PROBE);
|
||||
} catch (err) {
|
||||
if (isMissingShadowSidecarError(err)) {
|
||||
missingShadowError = err;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
} finally {
|
||||
await closeLbugConnection(writable);
|
||||
}
|
||||
if (missingShadowError) {
|
||||
return await reopenReadOnlyAfterMissingShadow(dbPath, missingShadowError);
|
||||
}
|
||||
|
||||
const reopened = await openLbugConnection(lbug, dbPath, { readOnly: true });
|
||||
try {
|
||||
await queryAndDrain(reopened.conn, READ_ONLY_SHADOW_REPLAY_PROBE);
|
||||
return reopened;
|
||||
} catch (err) {
|
||||
await closeLbugConnection(reopened);
|
||||
if (isMissingShadowSidecarError(err)) {
|
||||
throw new Error(shadowSidecarRecoveryMessage(dbPath, err));
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const resetOpenConnectionState = (): void => {
|
||||
currentDbPath = null;
|
||||
ftsLoaded = false;
|
||||
vectorExtensionLoaded = false;
|
||||
ensuredFTSIndexes.clear();
|
||||
};
|
||||
|
||||
const runSchemaCreationQueries = async (dbPath: string): Promise<unknown | null> => {
|
||||
for (const schemaQuery of SCHEMA_QUERIES) {
|
||||
try {
|
||||
await queryAndDrain(conn, schemaQuery);
|
||||
} catch (err) {
|
||||
if (isMissingShadowSidecarError(err)) {
|
||||
return err;
|
||||
}
|
||||
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
// Suppression list:
|
||||
// - "already exists": expected idempotent re-create on existing DBs
|
||||
// - "could not set lock on file": LadybugDB v0.16.1 emits this on
|
||||
// Windows when CREATE NODE TABLE runs against a path that was
|
||||
// just opened (the WAL handle from a fresh Database briefly
|
||||
// contests the table's first-write lock). The table is created
|
||||
// anyway and any genuine cross-process lock contention surfaces
|
||||
// on the next operation via withLbugDb's retry. Logging it here
|
||||
// would just be noise in CI.
|
||||
//
|
||||
// WAL corruption: the first DDL write after DB open triggers WAL
|
||||
// replay — if the WAL file was left in a corrupt state by an
|
||||
// interrupted previous run, the native engine throws here. Rather
|
||||
// than logging a WARN and continuing in a broken state, close the
|
||||
// DB cleanly and surface an actionable error so the caller (serve,
|
||||
// MCP, analyze) can exit with a clear recovery message.
|
||||
if (isWalCorruptionError(err)) {
|
||||
await safeClose();
|
||||
resetOpenConnectionState();
|
||||
throw new Error(
|
||||
`LadybugDB WAL corruption detected at ${dbPath}. ${WAL_RECOVERY_SUGGESTION}\n` +
|
||||
` Original error: ${msg.slice(0, 200)}`,
|
||||
);
|
||||
}
|
||||
if (!msg.includes('already exists') && !isDbBusyError(err) && !isReadOnlyDbError(err)) {
|
||||
logger.warn(`⚠️ Schema creation warning: ${msg.slice(0, 120)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const initLbug = async (dbPath: string) => {
|
||||
return runWithSessionLock(() => ensureLbugInitialized(dbPath));
|
||||
};
|
||||
|
|
@ -580,58 +764,46 @@ const doInitLbug = async (dbPath: string, readOnly: boolean = false) => {
|
|||
// Ensure parent directory exists
|
||||
const parentDir = path.dirname(dbPath);
|
||||
await fs.mkdir(parentDir, { recursive: true });
|
||||
await preflightLbugSidecars(dbPath, {
|
||||
mode: readOnly ? 'read-only' : 'write',
|
||||
logger,
|
||||
allowQuarantine: true,
|
||||
});
|
||||
|
||||
const opened = readOnly
|
||||
? await openLbugConnection(lbug, dbPath, { readOnly: true })
|
||||
: await openLbugConnection(lbug, dbPath);
|
||||
db = opened.db;
|
||||
conn = opened.conn;
|
||||
const usable = readOnly ? await ensureReadOnlyConnectionUsable(dbPath, opened) : opened;
|
||||
db = usable.db;
|
||||
conn = usable.conn;
|
||||
currentDbReadOnly = readOnly;
|
||||
} finally {
|
||||
await releaseInitLock();
|
||||
}
|
||||
|
||||
for (const schemaQuery of SCHEMA_QUERIES) {
|
||||
try {
|
||||
await queryAndDrain(conn, schemaQuery);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
// Suppression list:
|
||||
// - "already exists": expected idempotent re-create on existing DBs
|
||||
// - "could not set lock on file": LadybugDB v0.16.1 emits this on
|
||||
// Windows when CREATE NODE TABLE runs against a path that was
|
||||
// just opened (the WAL handle from a fresh Database briefly
|
||||
// contests the table's first-write lock). The table is created
|
||||
// anyway and any genuine cross-process lock contention surfaces
|
||||
// on the next operation via withLbugDb's retry. Logging it here
|
||||
// would just be noise in CI.
|
||||
//
|
||||
// WAL corruption: the first DDL write after DB open triggers WAL
|
||||
// replay — if the WAL file was left in a corrupt state by an
|
||||
// interrupted previous run, the native engine throws here. Rather
|
||||
// than logging a WARN and continuing in a broken state, close the
|
||||
// DB cleanly and surface an actionable error so the caller (serve,
|
||||
// MCP, analyze) can exit with a clear recovery message.
|
||||
if (isWalCorruptionError(err)) {
|
||||
if (!readOnly) {
|
||||
const missingShadowError = await runSchemaCreationQueries(dbPath);
|
||||
if (missingShadowError) {
|
||||
await safeClose();
|
||||
resetOpenConnectionState();
|
||||
const reopened = await reopenWritableAfterMissingShadow(dbPath, missingShadowError);
|
||||
db = reopened.db;
|
||||
conn = reopened.conn;
|
||||
currentDbReadOnly = false;
|
||||
|
||||
const retryMissingShadowError = await runSchemaCreationQueries(dbPath);
|
||||
if (retryMissingShadowError) {
|
||||
await safeClose();
|
||||
currentDbPath = null;
|
||||
ftsLoaded = false;
|
||||
vectorExtensionLoaded = false;
|
||||
ensuredFTSIndexes.clear();
|
||||
throw new Error(
|
||||
`LadybugDB WAL corruption detected at ${dbPath}. ${WAL_RECOVERY_SUGGESTION}\n` +
|
||||
` Original error: ${msg.slice(0, 200)}`,
|
||||
);
|
||||
}
|
||||
if (!msg.includes('already exists') && !isDbBusyError(err) && !isReadOnlyDbError(err)) {
|
||||
logger.warn(`⚠️ Schema creation warning: ${msg.slice(0, 120)}`);
|
||||
resetOpenConnectionState();
|
||||
throw new Error(shadowSidecarRecoveryMessage(dbPath, retryMissingShadowError));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FTS powers baseline search, so initialize it with the core DB. VECTOR is
|
||||
// only required for semantic embeddings and is probed lazily there.
|
||||
await loadFTSExtension();
|
||||
// FTS powers baseline search, so initialize it with the core DB. Read-only
|
||||
// serve/MCP paths must never run DDL or trigger network INSTALL; analyze owns
|
||||
// schema/index creation and extension installation.
|
||||
await loadFTSExtension(undefined, readOnly ? { policy: 'load-only' } : {});
|
||||
|
||||
currentDbPath = dbPath;
|
||||
return { db, conn };
|
||||
|
|
@ -1348,11 +1520,34 @@ export const flushWAL = async (): Promise<void> => {
|
|||
try {
|
||||
const checkpointResult = await conn.query('CHECKPOINT');
|
||||
await drainQueryResult(checkpointResult);
|
||||
} catch {
|
||||
/* ignore — older LadybugDB or schemaless DB may not accept it */
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
`GitNexus: LadybugDB CHECKPOINT skipped/failed during WAL flush: ${summarizeError(err)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Issue a manual `CHECKPOINT` against the current connection and surface
|
||||
* any engine error to the caller. Unlike {@link flushWAL}, this variant
|
||||
* does NOT swallow Ladybug rename/remove IO failures — the manual
|
||||
* checkpoint driver (`wal-checkpoint-driver.ts`) relies on the rejection
|
||||
* to drive its bounded retry loop. Returns `false` when no connection is
|
||||
* open (the caller treats this as a no-op success — there is no WAL to
|
||||
* flush). Returns `true` after a successful CHECKPOINT + drain.
|
||||
*
|
||||
* The split from `flushWAL` is deliberate: every other CHECKPOINT site
|
||||
* (server flush, safeClose) is best-effort and prefers a silent skip;
|
||||
* the manual driver, by contrast, must observe failures to decide
|
||||
* whether to retry.
|
||||
*/
|
||||
export const tryFlushWAL = async (): Promise<boolean> => {
|
||||
if (!conn) return false;
|
||||
const checkpointResult = await conn.query('CHECKPOINT');
|
||||
await drainQueryResult(checkpointResult);
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Flush the WAL and close the connection and database handles.
|
||||
*
|
||||
|
|
@ -1404,6 +1599,9 @@ export const safeClose = async (): Promise<void> => {
|
|||
);
|
||||
}
|
||||
}
|
||||
if (closingDbPath) {
|
||||
await finalizeLbugSidecarsAfterClose(closingDbPath, { logger });
|
||||
}
|
||||
};
|
||||
|
||||
export const closeLbug = async (): Promise<void> => {
|
||||
|
|
@ -1651,7 +1849,10 @@ export const createFTSIndex = async (
|
|||
if (ensuredFTSIndexes.has(key)) return;
|
||||
|
||||
if (!(await loadFTSExtension())) {
|
||||
return;
|
||||
throw new Error(
|
||||
`FTS extension unavailable - cannot create FTS index ${tableName}.${indexName}. ` +
|
||||
'Run `gitnexus doctor` and ensure the LadybugDB FTS extension is installed and loadable on this machine.',
|
||||
);
|
||||
}
|
||||
|
||||
const propList = properties.map((p) => `'${p}'`).join(', ');
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import fs from 'fs/promises';
|
|||
import os from 'os';
|
||||
import path from 'path';
|
||||
import type lbug from '@ladybugdb/core';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
/**
|
||||
* Shared configuration for `@ladybugdb/core` `Database` construction.
|
||||
|
|
@ -45,6 +46,44 @@ export const LBUG_MAX_DB_SIZE: number = (() => {
|
|||
return 16 * 1024 * 1024 * 1024;
|
||||
})();
|
||||
|
||||
export const parseWalCheckpointThreshold = (raw: string | undefined): number | undefined => {
|
||||
if (raw === undefined) return undefined;
|
||||
const normalized = raw.trim();
|
||||
if (normalized.length === 0) return undefined;
|
||||
const parsed = Number(normalized);
|
||||
if (!Number.isInteger(parsed) || parsed < -1) return undefined;
|
||||
return parsed;
|
||||
};
|
||||
|
||||
/**
|
||||
* Default GitNexus WAL auto-checkpoint threshold in bytes (64 MiB).
|
||||
*
|
||||
* Larger than Ladybug's stock ~16 MiB to reduce checkpoint rename/remove
|
||||
* churn under heavy analyze write load — the original race that motivated
|
||||
* issue #1741 triggered at the stock threshold. README examples in
|
||||
* `README.md` and `gitnexus/README.md` and the recovery hint in
|
||||
* `analyze.ts` MUST stay in sync with this value.
|
||||
*/
|
||||
const DEFAULT_WAL_CHECKPOINT_THRESHOLD = 64 * 1024 * 1024;
|
||||
|
||||
const resolveCheckpointThreshold = (): number => {
|
||||
const raw = process.env.GITNEXUS_WAL_CHECKPOINT_THRESHOLD;
|
||||
if (raw === undefined) return DEFAULT_WAL_CHECKPOINT_THRESHOLD;
|
||||
const parsed = parseWalCheckpointThreshold(raw);
|
||||
if (parsed !== undefined) return parsed;
|
||||
// Non-empty but unparseable input: warn the operator and fall back. Mirrors
|
||||
// the CLI's `--wal-checkpoint-threshold` validation (which hard-errors)
|
||||
// but the env-var path stays soft to preserve "set once in your shell"
|
||||
// ergonomics across mixed-version invocations.
|
||||
if (raw.trim().length > 0) {
|
||||
logger.warn(
|
||||
{ rawValue: raw, fallback: DEFAULT_WAL_CHECKPOINT_THRESHOLD },
|
||||
`Ignoring invalid GITNEXUS_WAL_CHECKPOINT_THRESHOLD=${raw}; expected integer >= -1; falling back to default (${DEFAULT_WAL_CHECKPOINT_THRESHOLD}).`,
|
||||
);
|
||||
}
|
||||
return DEFAULT_WAL_CHECKPOINT_THRESHOLD;
|
||||
};
|
||||
|
||||
/** Matches WAL corruption errors from the LadybugDB engine. */
|
||||
const WAL_CORRUPTION_RE = /corrupt(ed)?\s+wal|invalid\s+wal\s+record|wal.*corrupt|checksum.*wal/i;
|
||||
|
||||
|
|
@ -57,6 +96,50 @@ export function isWalCorruptionError(err: unknown): boolean {
|
|||
return WAL_CORRUPTION_RE.test(msg);
|
||||
}
|
||||
|
||||
// ─── Ladybug WAL checkpoint IO error matchers ───────────────────────────────
|
||||
//
|
||||
// Matched against LadybugDB v0.16.1 (see `gitnexus/package.json`
|
||||
// @ladybugdb/core). Strict regexes encode local_file_system.cpp wording
|
||||
// verified at that version. Two-tier strategy: strict matchers first so we
|
||||
// only fire on real checkpoint-rotation shapes; a permissive fallback
|
||||
// catches future Ladybug message drift so the recovery hint keeps surfacing
|
||||
// even if upstream wording changes.
|
||||
//
|
||||
// From Ladybug native LocalFileSystem exceptions (`local_file_system.cpp`),
|
||||
// surfaced in Node as:
|
||||
// "Runtime exception: IO exception: Error renaming file ..."
|
||||
// "Runtime exception: IO exception: Error removing directory or file ..."
|
||||
// We only match checkpoint-rotation shapes:
|
||||
// - "<db>.wal -> <db>.wal.checkpoint" rename failures
|
||||
// - "<db>.wal.checkpoint" remove failures
|
||||
// Example matches:
|
||||
// "Runtime exception: IO exception: Error renaming file /x/lbug.wal to /x/lbug.wal.checkpoint. ErrorMessage: Permission denied"
|
||||
// "Runtime exception: IO exception: Error removing directory or file /x/lbug.wal.checkpoint. Error Message: Permission denied"
|
||||
// Matching is case-insensitive to remain robust across wrappers/platforms.
|
||||
const LBUG_CHECKPOINT_RENAME_RE =
|
||||
/^runtime exception: io exception:\s*error renaming file\s+.+?\.wal\s+to\s+.+?\.wal\.checkpoint(?:\.|\s|$)/i;
|
||||
const LBUG_CHECKPOINT_REMOVE_RE =
|
||||
/^runtime exception: io exception:\s*error removing directory or file\s+.+?\.wal\.checkpoint(?:\.|\s|$)/i;
|
||||
/**
|
||||
* Permissive fallback: any IO-exception-shaped message that mentions a
|
||||
* `.wal.checkpoint` path. Catches future Ladybug message drift (different
|
||||
* verb, additional preamble, locale variation) so the recovery hint keeps
|
||||
* surfacing even if the strict regexes go stale.
|
||||
*/
|
||||
const LBUG_CHECKPOINT_PERMISSIVE_RE = /io exception.*\.wal\.checkpoint/i;
|
||||
|
||||
/**
|
||||
* True when `err` looks like a Ladybug WAL-checkpoint rotation/remove IO
|
||||
* failure. Tries strict matchers first (renames + removes), then falls
|
||||
* back to the permissive matcher.
|
||||
*/
|
||||
export const isLbugCheckpointIoError = (err: unknown): boolean => {
|
||||
if (!err) return false;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (LBUG_CHECKPOINT_RENAME_RE.test(msg) || LBUG_CHECKPOINT_REMOVE_RE.test(msg)) return true;
|
||||
return LBUG_CHECKPOINT_PERMISSIVE_RE.test(msg);
|
||||
};
|
||||
|
||||
type LbugModule = typeof lbug;
|
||||
|
||||
export interface LbugDatabaseOptions {
|
||||
|
|
@ -103,8 +186,8 @@ export function createLbugDatabase(
|
|||
false, // enableCompression (pinned for v0.16.0)
|
||||
options.readOnly ?? false,
|
||||
LBUG_MAX_DB_SIZE,
|
||||
true, // autoCheckpoint
|
||||
-1, // checkpointThreshold
|
||||
true, // autoCheckpoint (always on)
|
||||
resolveCheckpointThreshold(), // checkpointThreshold (default 64 MiB; override with GITNEXUS_WAL_CHECKPOINT_THRESHOLD; -1 keeps Ladybug stock ~16 MiB)
|
||||
options.throwOnWalReplayFailure ?? true,
|
||||
true, // enableChecksums
|
||||
) as lbug.Database;
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@
|
|||
*/
|
||||
|
||||
import fs from 'fs/promises';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import lbug from '@ladybugdb/core';
|
||||
import { isReadOnlyDbError, loadFTSExtension } from './lbug-adapter.js';
|
||||
import {
|
||||
|
|
@ -23,6 +25,51 @@ import {
|
|||
isWalCorruptionError,
|
||||
WAL_RECOVERY_SUGGESTION,
|
||||
} from './lbug-config.js';
|
||||
import {
|
||||
isMissingFsError,
|
||||
isMissingShadowSidecarError,
|
||||
isReadOnlyShadowReplayError,
|
||||
preflightLbugSidecars,
|
||||
quarantineWalForMissingShadow,
|
||||
renameFailureMessage,
|
||||
statIfExists,
|
||||
} from './sidecar-recovery.js';
|
||||
|
||||
/**
|
||||
* Probe whether a Windows FTS extension binary is locally installed under
|
||||
* ~/.lbdb/extension/<any-version>/win_amd64/fts/. Returns true on the first
|
||||
* version dir whose libfts.lbug_extension exists on disk; false if the
|
||||
* extension root is missing or contains no FTS binary.
|
||||
*
|
||||
* Gates the Windows skip-FTS-load guard below so we only skip the load
|
||||
* when no extension binary is present. When at least one binary exists,
|
||||
* loadFTSExtension is called with policy: 'load-only' — LadybugDB resolves
|
||||
* LOAD EXTENSION fts to its version-specific path internally, and the
|
||||
* ExtensionManager's tryLoad try/catch handles version-mismatch errors
|
||||
* cleanly without ever attempting dlopen of a stale binary. The install
|
||||
* path that the #1199/#1217 SIGSEGV documented is never exercised at
|
||||
* query time.
|
||||
*
|
||||
* Exported so unit tests can exercise the probe directly against a
|
||||
* temp-dir plus spied `os.homedir()` — see lbug-pool-win-fts-probe.test.ts.
|
||||
*/
|
||||
export async function hasLocalWinFtsExtension(): Promise<boolean> {
|
||||
try {
|
||||
const extRoot = path.join(os.homedir(), '.lbdb', 'extension');
|
||||
const versions = await fs.readdir(extRoot);
|
||||
for (const v of versions) {
|
||||
try {
|
||||
await fs.stat(path.join(extRoot, v, 'win_amd64', 'fts', 'libfts.lbug_extension'));
|
||||
return true;
|
||||
} catch {
|
||||
/* missing for this version, keep looking */
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* no .lbdb/extension dir */
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Per-repo pool: one Database, many Connections */
|
||||
interface PoolEntry {
|
||||
|
|
@ -266,16 +313,148 @@ const WAITER_TIMEOUT_MS = 15_000;
|
|||
|
||||
const LOCK_RETRY_ATTEMPTS = 3;
|
||||
const LOCK_RETRY_DELAY_MS = 2000;
|
||||
const SHADOW_REPLAY_PROBE_QUERY = 'MATCH (n) RETURN n LIMIT 1';
|
||||
|
||||
const poolSidecarLogger = {
|
||||
warn: (message: string): void => {
|
||||
realStderrWrite(`${message}\n`);
|
||||
},
|
||||
debug: (_message: string): void => {},
|
||||
info: (message: string): void => {
|
||||
realStderrWrite(`${message}\n`);
|
||||
},
|
||||
};
|
||||
|
||||
type TryQuarantineResult = { kind: 'quarantined'; path: string } | { kind: 'peer-handled' };
|
||||
|
||||
/**
|
||||
* Pool-local quarantine guard that tolerates the concurrent-peer race the
|
||||
* direct adapter does NOT face (the direct adapter holds `acquireInitLock`,
|
||||
* a cross-process file lock, around its quarantine calls — so any ENOENT
|
||||
* there is a real bug, not a benign race).
|
||||
*
|
||||
* On ENOENT from `fs.rename`, re-inspects via `statIfExists` to confirm the
|
||||
* WAL really is gone. If gone, returns `{ kind: 'peer-handled' }`. If the
|
||||
* WAL is somehow still present after the ENOENT (filesystem race we don't
|
||||
* fully model), re-throws as a classified error rather than silently
|
||||
* returning success — preserves the lock-invariant principle at the pool
|
||||
* sites too.
|
||||
*
|
||||
* On any non-ENOENT failure, classifies through `renameFailureMessage`:
|
||||
* EACCES/EPERM/EBUSY → permission-specific message; everything else
|
||||
* (including the LadybugDB missing-shadow error if it ever propagates here)
|
||||
* → `shadowSidecarRecoveryMessage`.
|
||||
*
|
||||
* See plan: docs/plans/2026-05-21-001-fix-pr-1747-quarantine-enoent-and-large-wal-plan.md (U2)
|
||||
*/
|
||||
async function tryQuarantineForMissingShadow(
|
||||
dbPath: string,
|
||||
opts: { reason: string },
|
||||
): Promise<TryQuarantineResult> {
|
||||
try {
|
||||
const quarantinePath = await quarantineWalForMissingShadow(dbPath, {
|
||||
logger: poolSidecarLogger,
|
||||
level: 'warn',
|
||||
reason: opts.reason,
|
||||
});
|
||||
return { kind: 'quarantined', path: quarantinePath };
|
||||
} catch (err) {
|
||||
if (isMissingFsError(err)) {
|
||||
const walStat = await statIfExists(`${dbPath}.wal`);
|
||||
if (walStat === null) {
|
||||
return { kind: 'peer-handled' };
|
||||
}
|
||||
// Defensive: ENOENT during rename but WAL still present afterwards.
|
||||
// Don't silently swallow — surface a classified error. ENOENT falls
|
||||
// through to shadowSidecarRecoveryMessage in renameFailureMessage.
|
||||
throw new Error(renameFailureMessage(dbPath, err));
|
||||
}
|
||||
// Classify the rename failure itself — EACCES/EPERM/EBUSY get the
|
||||
// permission-specific message; everything else falls through.
|
||||
throw new Error(renameFailureMessage(dbPath, err));
|
||||
}
|
||||
}
|
||||
|
||||
async function probeDatabaseForShadowReplay(db: lbug.Database): Promise<void> {
|
||||
const conn = createConnection(db);
|
||||
try {
|
||||
const queryResult = await conn.query(SHADOW_REPLAY_PROBE_QUERY);
|
||||
const result = Array.isArray(queryResult) ? queryResult[0] : queryResult;
|
||||
await result.getAll();
|
||||
result.close?.();
|
||||
} finally {
|
||||
await conn.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function replayShadowPagesWithWritableOpen(dbPath: string): Promise<void> {
|
||||
let db: lbug.Database | undefined;
|
||||
try {
|
||||
db = createLbugDatabase(lbug, dbPath, { throwOnWalReplayFailure: false });
|
||||
await db.init();
|
||||
await probeDatabaseForShadowReplay(db);
|
||||
} catch (err) {
|
||||
if (isMissingShadowSidecarError(err)) {
|
||||
await tryQuarantineForMissingShadow(dbPath, {
|
||||
reason: 'pool writable replay recovery',
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
if (db) await db.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function openReadOnlyDatabase(dbPath: string): Promise<lbug.Database> {
|
||||
let db: lbug.Database | undefined;
|
||||
silenceStdout();
|
||||
try {
|
||||
await preflightLbugSidecars(dbPath, {
|
||||
mode: 'read-only',
|
||||
logger: poolSidecarLogger,
|
||||
allowQuarantine: true,
|
||||
});
|
||||
db = createLbugDatabase(lbug, dbPath, {
|
||||
readOnly: true,
|
||||
throwOnWalReplayFailure: false,
|
||||
});
|
||||
await db.init();
|
||||
try {
|
||||
await probeDatabaseForShadowReplay(db);
|
||||
} catch (err) {
|
||||
if (isMissingShadowSidecarError(err)) {
|
||||
await db.close().catch(() => {});
|
||||
db = undefined;
|
||||
await tryQuarantineForMissingShadow(dbPath, {
|
||||
reason: 'pool read-only recovery',
|
||||
});
|
||||
await preflightLbugSidecars(dbPath, {
|
||||
mode: 'read-only',
|
||||
logger: poolSidecarLogger,
|
||||
allowQuarantine: true,
|
||||
});
|
||||
db = createLbugDatabase(lbug, dbPath, {
|
||||
readOnly: true,
|
||||
throwOnWalReplayFailure: false,
|
||||
});
|
||||
await db.init();
|
||||
await probeDatabaseForShadowReplay(db);
|
||||
return db;
|
||||
}
|
||||
if (!isReadOnlyShadowReplayError(err)) {
|
||||
throw err;
|
||||
}
|
||||
await db.close().catch(() => {});
|
||||
db = undefined;
|
||||
await replayShadowPagesWithWritableOpen(dbPath);
|
||||
db = createLbugDatabase(lbug, dbPath, {
|
||||
readOnly: true,
|
||||
throwOnWalReplayFailure: false,
|
||||
});
|
||||
await db.init();
|
||||
await probeDatabaseForShadowReplay(db);
|
||||
}
|
||||
return db;
|
||||
} catch (err) {
|
||||
if (db) await db.close().catch(() => {});
|
||||
|
|
@ -385,8 +564,17 @@ async function doInitLbug(repoId: string, dbPath: string): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
if (
|
||||
lastError.message.startsWith('LadybugDB checkpoint sidecar is missing') ||
|
||||
lastError.message.startsWith('GitNexus could not move the LadybugDB WAL sidecar') ||
|
||||
isMissingShadowSidecarError(lastError)
|
||||
) {
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
const isLockError =
|
||||
lastError.message.includes('Could not set lock') || lastError.message.includes('lock');
|
||||
lastError.message.includes('Could not set lock') ||
|
||||
/\block(\b|ed|ing)/i.test(lastError.message);
|
||||
if (!isLockError || attempt === LOCK_RETRY_ATTEMPTS) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_DELAY_MS * attempt));
|
||||
}
|
||||
|
|
@ -423,14 +611,24 @@ async function doInitLbug(repoId: string, dbPath: string): Promise<void> {
|
|||
// install; analyze owns extension installation. If LOAD fails, search
|
||||
// features degrade gracefully and the user-facing query path proceeds.
|
||||
if (!shared.ftsLoaded) {
|
||||
// Windows guard: LOAD EXTENSION fts crashes with SIGSEGV on Windows when
|
||||
// the FTS extension binary is not installed locally (@ladybugdb/core native
|
||||
// bug — the extension loader hits an unhandled error path that signals SIGSEGV
|
||||
// rather than throwing a JS exception, so try/catch cannot protect here).
|
||||
// Skip the load on Windows; bm25-index.js catches the resulting Kuzu catalog
|
||||
// errors and returns empty BM25 results gracefully. Graph queries are unaffected.
|
||||
// Windows guard: LOAD EXTENSION fts crashes with SIGSEGV on Windows during
|
||||
// *install* — the @ladybugdb/core out-of-process installer hits an unhandled
|
||||
// error path that signals SIGSEGV instead of throwing (see #1199, #1217).
|
||||
// The previous unconditional skip was over-broad: it also disabled FTS on
|
||||
// hosts where the binary was already on disk and only needed LOAD, leaving
|
||||
// BM25 silently degraded with no error path (see #1690).
|
||||
//
|
||||
// Probe ~/.lbdb/extension/*/win_amd64/fts/ first. If any binary is on disk
|
||||
// we run loadFTSExtension(..., 'load-only'); the install path is never
|
||||
// exercised, and LadybugDB's version-specific resolution + ExtensionManager
|
||||
// try/catch handle stale/zero-byte siblings cleanly (verified empirically
|
||||
// on Win10 + Node 22.19 + gitnexus 1.6.5 + @ladybugdb/core 0.16.1). With
|
||||
// no binary at all, we fall back to the upstream skip so install-time
|
||||
// SIGSEGV continues to be avoided.
|
||||
if (process.platform === 'win32') {
|
||||
shared.ftsLoaded = true;
|
||||
shared.ftsLoaded = (await hasLocalWinFtsExtension())
|
||||
? await loadFTSExtension(available[0], { policy: 'load-only' })
|
||||
: true;
|
||||
} else {
|
||||
shared.ftsLoaded = await loadFTSExtension(available[0], { policy: 'load-only' });
|
||||
}
|
||||
|
|
@ -497,10 +695,12 @@ export async function initLbugWithDb(
|
|||
// Load FTS extension if not already loaded on this Database.
|
||||
// policy: 'load-only' — same contract as initLbug above; the read pool
|
||||
// must not block on a network install during query execution.
|
||||
// Windows guard: same SIGSEGV risk as doInitLbug above — skip on Windows.
|
||||
// Windows guard: same probe-then-load policy as doInitLbug above.
|
||||
if (!shared.ftsLoaded) {
|
||||
if (process.platform === 'win32') {
|
||||
shared.ftsLoaded = true;
|
||||
shared.ftsLoaded = (await hasLocalWinFtsExtension())
|
||||
? await loadFTSExtension(available[0], { policy: 'load-only' })
|
||||
: true;
|
||||
} else {
|
||||
shared.ftsLoaded = await loadFTSExtension(available[0], { policy: 'load-only' });
|
||||
}
|
||||
|
|
|
|||
353
gitnexus/src/core/lbug/sidecar-recovery.ts
Normal file
353
gitnexus/src/core/lbug/sidecar-recovery.ts
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
export type LbugSidecarState =
|
||||
| { kind: 'clean'; dbPath: string }
|
||||
| { kind: 'wal-with-shadow'; dbPath: string; walBytes: number; shadowBytes: number }
|
||||
| { kind: 'tiny-orphan-wal'; dbPath: string; walBytes: number }
|
||||
| { kind: 'orphan-wal'; dbPath: string; walBytes: number }
|
||||
| { kind: 'orphan-shadow'; dbPath: string; shadowBytes: number };
|
||||
|
||||
export interface SidecarRecoveryLogger {
|
||||
warn: (message: string) => void;
|
||||
info?: (message: string) => void;
|
||||
debug?: (message: string) => void;
|
||||
}
|
||||
|
||||
export const TINY_ORPHAN_WAL_BYTES = 4 * 1024;
|
||||
|
||||
/**
|
||||
* Counter-based warn anti-spam (PR #1747 review, Finding 6).
|
||||
*
|
||||
* The previous design (`warnedKeys: Set<string>`) warned exactly once per key
|
||||
* per process and silently downgraded all subsequent occurrences to debug. In
|
||||
* a long-lived `gitnexus serve` process touching the same dbPath repeatedly,
|
||||
* a persistent condition produced one warn at the first occurrence and then
|
||||
* 99+ silent debug lines — invisible to operators reading warn-level logs.
|
||||
*
|
||||
* The counter-based design warns on logarithmic milestones so persistence
|
||||
* stays visible. Geometric spacing keeps total warn count bounded at O(log N)
|
||||
* for a condition that fires N times.
|
||||
*/
|
||||
const warnedKeyCounts = new Map<string, number>();
|
||||
|
||||
const WARN_MILESTONES = [1, 10, 100, 1000, 10000] as const;
|
||||
|
||||
const ordinal = (n: number): string => {
|
||||
switch (n) {
|
||||
case 1:
|
||||
return '1st';
|
||||
case 10:
|
||||
return '10th';
|
||||
case 100:
|
||||
return '100th';
|
||||
case 1000:
|
||||
return '1000th';
|
||||
case 10000:
|
||||
return '10000th';
|
||||
default:
|
||||
return `${n}th`;
|
||||
}
|
||||
};
|
||||
|
||||
export const isMissingFsError = (err: unknown): boolean =>
|
||||
(err as NodeJS.ErrnoException | undefined)?.code === 'ENOENT';
|
||||
|
||||
const missing = isMissingFsError;
|
||||
|
||||
const sidecarPreflightDisabled = (): boolean =>
|
||||
/^(1|true|yes|on)$/i.test(process.env.GITNEXUS_DISABLE_LBUG_SIDECAR_PREFLIGHT ?? '');
|
||||
|
||||
export const statIfExists = async (filePath: string): Promise<{ size: number } | null> => {
|
||||
try {
|
||||
const statFn = (fs as typeof fs & { stat?: typeof fs.stat }).stat;
|
||||
if (typeof statFn === 'function') {
|
||||
const stat = await statFn(filePath);
|
||||
return { size: stat.size };
|
||||
}
|
||||
// Some focused unit tests provide a deliberately tiny fs mock. Treat a
|
||||
// path as present only when access succeeds, with an unknown/zero size.
|
||||
await fs.access(filePath);
|
||||
return { size: 0 };
|
||||
} catch (err) {
|
||||
if (missing(err)) return null;
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const logDebug = (logger: SidecarRecoveryLogger, message: string): void => {
|
||||
if (logger.debug) logger.debug(message);
|
||||
};
|
||||
|
||||
const logInfo = (logger: SidecarRecoveryLogger, message: string): void => {
|
||||
if (logger.info) logger.info(message);
|
||||
else logDebug(logger, message);
|
||||
};
|
||||
|
||||
/**
|
||||
* Log at warn-level on logarithmic milestone occurrences (1st, 10th, 100th,
|
||||
* 1000th, 10000th); debug-level otherwise. Past the first occurrence the warn
|
||||
* message is suffixed with the occurrence count so operators can see the
|
||||
* condition's persistence at a glance.
|
||||
*
|
||||
* The signature and key convention (`${dbPath}:suffix`) are unchanged from the
|
||||
* previous warn-once implementation — call sites need no edits.
|
||||
*/
|
||||
const warnOnce = (logger: SidecarRecoveryLogger, key: string, message: string): void => {
|
||||
const next = (warnedKeyCounts.get(key) ?? 0) + 1;
|
||||
warnedKeyCounts.set(key, next);
|
||||
const isMilestone = (WARN_MILESTONES as readonly number[]).includes(next);
|
||||
if (!isMilestone) {
|
||||
logDebug(logger, message);
|
||||
return;
|
||||
}
|
||||
if (next === 1) {
|
||||
logger.warn(message);
|
||||
return;
|
||||
}
|
||||
logger.warn(`${message} (${ordinal(next)} occurrence of this condition)`);
|
||||
};
|
||||
|
||||
// LADYBUGDB-CONTRACT: matches @ladybugdb/core ^0.16.1 native error text.
|
||||
// When bumping LadybugDB, re-validate this regex against the new error format
|
||||
// — `git grep "LADYBUGDB-CONTRACT"` enumerates every version-coupled spot.
|
||||
export const isMissingShadowSidecarError = (err: unknown): boolean => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return /Cannot open file .*\.shadow: No such file or directory/i.test(msg);
|
||||
};
|
||||
|
||||
// LADYBUGDB-CONTRACT: matches @ladybugdb/core ^0.16.1 native error text.
|
||||
// When bumping LadybugDB, re-validate this regex against the new error format
|
||||
// — `git grep "LADYBUGDB-CONTRACT"` enumerates every version-coupled spot.
|
||||
export const isReadOnlyShadowReplayError = (err: unknown): boolean => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return /replay shadow pages under read-only mode/i.test(msg);
|
||||
};
|
||||
|
||||
export const shadowSidecarRecoveryMessage = (dbPath: string, err: unknown): string => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return (
|
||||
`LadybugDB checkpoint sidecar is missing for ${dbPath}. ` +
|
||||
'Rebuild the index with `gitnexus analyze --force <repo-path> --index-only` and restart `gitnexus serve`.' +
|
||||
`\n Original error: ${msg.slice(0, 200)}`
|
||||
);
|
||||
};
|
||||
|
||||
const PERMISSION_RENAME_CODES = new Set(['EACCES', 'EPERM', 'EBUSY']);
|
||||
|
||||
export const isPermissionRenameError = (err: unknown): boolean => {
|
||||
const code = (err as NodeJS.ErrnoException | undefined)?.code;
|
||||
return typeof code === 'string' && PERMISSION_RENAME_CODES.has(code);
|
||||
};
|
||||
|
||||
/**
|
||||
* Classify a failure surfaced by quarantine rename into an actionable user-facing
|
||||
* message.
|
||||
*
|
||||
* - EACCES / EPERM / EBUSY → permission-specific message pointing at filesystem
|
||||
* ACLs, AV exclusions, and file-locks. Importantly does NOT instruct the user
|
||||
* to rebuild the index — the underlying problem is environmental, not data
|
||||
* integrity, and re-running after fixing the lock/permission will succeed.
|
||||
* - Everything else (including the LadybugDB "Cannot open file *.shadow"
|
||||
* missing-shadow error, ENOSPC, EROFS, EIO, and any other thrown Error) →
|
||||
* falls back to `shadowSidecarRecoveryMessage`, preserving today's behavior.
|
||||
*
|
||||
* Use at caller catches around `quarantineWalForMissingShadow` and any other
|
||||
* path where an `fs.rename`-class failure may surface to operators.
|
||||
*/
|
||||
export const renameFailureMessage = (dbPath: string, err: unknown): string => {
|
||||
if (isPermissionRenameError(err)) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return (
|
||||
`GitNexus could not move the LadybugDB WAL sidecar at ${dbPath}.wal because of a ` +
|
||||
`filesystem permission or file-lock error (${code}). ` +
|
||||
'Check filesystem ACLs, antivirus exclusions for the index directory, and ' +
|
||||
'whether another process holds an open handle on the file. ' +
|
||||
'The index does not need to be rebuilt — re-running the failing command after ' +
|
||||
'resolving the lock or permission should succeed.' +
|
||||
`\n Original error: ${msg.slice(0, 200)}`
|
||||
);
|
||||
}
|
||||
return shadowSidecarRecoveryMessage(dbPath, err);
|
||||
};
|
||||
|
||||
export async function inspectLbugSidecars(dbPath: string): Promise<LbugSidecarState> {
|
||||
const wal = await statIfExists(`${dbPath}.wal`);
|
||||
const shadow = await statIfExists(`${dbPath}.shadow`);
|
||||
|
||||
if (wal && shadow) {
|
||||
return { kind: 'wal-with-shadow', dbPath, walBytes: wal.size, shadowBytes: shadow.size };
|
||||
}
|
||||
if (wal) {
|
||||
if (wal.size <= TINY_ORPHAN_WAL_BYTES) {
|
||||
return { kind: 'tiny-orphan-wal', dbPath, walBytes: wal.size };
|
||||
}
|
||||
return { kind: 'orphan-wal', dbPath, walBytes: wal.size };
|
||||
}
|
||||
if (shadow) {
|
||||
return { kind: 'orphan-shadow', dbPath, shadowBytes: shadow.size };
|
||||
}
|
||||
return { kind: 'clean', dbPath };
|
||||
}
|
||||
|
||||
export async function quarantineWalForMissingShadow(
|
||||
dbPath: string,
|
||||
options: {
|
||||
logger: SidecarRecoveryLogger;
|
||||
level?: 'debug' | 'info' | 'warn';
|
||||
reason?: string;
|
||||
},
|
||||
): Promise<string> {
|
||||
const walPath = `${dbPath}.wal`;
|
||||
const quarantinePath = `${walPath}.missing-shadow.${Date.now()}-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2)}`;
|
||||
await fs.rename(walPath, quarantinePath);
|
||||
|
||||
const message =
|
||||
`GitNexus: quarantined WAL ${path.basename(quarantinePath)} because LadybugDB shadow sidecar was missing; ` +
|
||||
`continuing from last checkpoint${options.reason ? ` (${options.reason})` : ''}`;
|
||||
|
||||
if (options.level === 'warn') {
|
||||
warnOnce(options.logger, `${dbPath}:missing-shadow-quarantine`, message);
|
||||
} else if (options.level === 'info') {
|
||||
logInfo(options.logger, message);
|
||||
} else {
|
||||
logDebug(options.logger, message);
|
||||
}
|
||||
|
||||
return quarantinePath;
|
||||
}
|
||||
|
||||
export async function preflightLbugSidecars(
|
||||
dbPath: string,
|
||||
options: {
|
||||
mode: 'read-only' | 'write';
|
||||
logger: SidecarRecoveryLogger;
|
||||
allowQuarantine: boolean;
|
||||
},
|
||||
): Promise<LbugSidecarState> {
|
||||
let state: LbugSidecarState;
|
||||
try {
|
||||
state = await inspectLbugSidecars(dbPath);
|
||||
} catch (err) {
|
||||
logDebug(
|
||||
options.logger,
|
||||
`GitNexus: unable to inspect LadybugDB sidecars before ${options.mode} open; continuing without preflight repair: ${(err as Error).message}`,
|
||||
);
|
||||
return { kind: 'clean', dbPath };
|
||||
}
|
||||
if (sidecarPreflightDisabled() || !options.allowQuarantine) return state;
|
||||
|
||||
if (state.kind === 'tiny-orphan-wal') {
|
||||
await quarantineWalForMissingShadow(dbPath, {
|
||||
logger: options.logger,
|
||||
level: 'debug',
|
||||
reason: `${options.mode} preflight tiny orphan WAL (${state.walBytes} bytes)`,
|
||||
});
|
||||
return inspectLbugSidecars(dbPath);
|
||||
}
|
||||
|
||||
if (state.kind === 'orphan-wal') {
|
||||
warnOnce(
|
||||
options.logger,
|
||||
`${dbPath}:orphan-wal-preflight:${options.mode}`,
|
||||
`GitNexus: found ${state.walBytes} byte lbug.wal without lbug.shadow before ${options.mode} open; ` +
|
||||
'will rely on LadybugDB replay/recovery instead of deleting pending WAL data.',
|
||||
);
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
export async function finalizeLbugSidecarsAfterClose(
|
||||
dbPath: string,
|
||||
options: { logger: SidecarRecoveryLogger },
|
||||
): Promise<void> {
|
||||
if (sidecarPreflightDisabled()) return;
|
||||
|
||||
let state: LbugSidecarState;
|
||||
try {
|
||||
state = await inspectLbugSidecars(dbPath);
|
||||
} catch (err) {
|
||||
logDebug(
|
||||
options.logger,
|
||||
`GitNexus: unable to inspect LadybugDB sidecars after close; skipping post-close repair: ${(err as Error).message}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (state.kind === 'clean' || state.kind === 'wal-with-shadow') return;
|
||||
|
||||
for (const delayMs of [25, 50, 100]) {
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
try {
|
||||
state = await inspectLbugSidecars(dbPath);
|
||||
} catch (err) {
|
||||
logDebug(
|
||||
options.logger,
|
||||
`GitNexus: unable to inspect LadybugDB sidecars after close; skipping post-close repair: ${(err as Error).message}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (state.kind === 'clean' || state.kind === 'wal-with-shadow') return;
|
||||
}
|
||||
|
||||
if (state.kind === 'tiny-orphan-wal') {
|
||||
try {
|
||||
await quarantineWalForMissingShadow(dbPath, {
|
||||
logger: options.logger,
|
||||
level: 'debug',
|
||||
reason: `post-close tiny orphan WAL (${state.walBytes} bytes)`,
|
||||
});
|
||||
} catch (err) {
|
||||
if (!missing(err)) {
|
||||
warnOnce(
|
||||
options.logger,
|
||||
`${dbPath}:post-close-tiny-quarantine-failed`,
|
||||
`GitNexus: failed to quarantine tiny orphan WAL after close (${(err as Error).message}); next read may recover reactively.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.kind === 'orphan-wal') {
|
||||
warnOnce(
|
||||
options.logger,
|
||||
`${dbPath}:post-close-orphan-wal`,
|
||||
`GitNexus: lbug.wal (${state.walBytes} bytes) remains without lbug.shadow after close; ` +
|
||||
'keeping it for recovery. If this repeats, run `gitnexus analyze --force --index-only` or the sidecar repair command.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function listQuarantinedMissingShadowWals(dbPath: string): Promise<string[]> {
|
||||
const dir = path.dirname(dbPath);
|
||||
const base = path.basename(dbPath);
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await fs.readdir(dir);
|
||||
} catch (err) {
|
||||
if (missing(err)) return [];
|
||||
throw err;
|
||||
}
|
||||
return entries
|
||||
.filter((entry) => entry.startsWith(`${base}.wal.missing-shadow.`))
|
||||
.map((entry) => path.join(dir, entry))
|
||||
.sort();
|
||||
}
|
||||
|
||||
export async function cleanQuarantinedMissingShadowWals(dbPath: string): Promise<string[]> {
|
||||
const files = await listQuarantinedMissingShadowWals(dbPath);
|
||||
const deleted: string[] = [];
|
||||
for (const file of files) {
|
||||
await fs.unlink(file);
|
||||
deleted.push(file);
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
export const _resetSidecarRecoveryWarningsForTest = (): void => {
|
||||
warnedKeyCounts.clear();
|
||||
};
|
||||
232
gitnexus/src/core/lbug/wal-checkpoint-driver.ts
Normal file
232
gitnexus/src/core/lbug/wal-checkpoint-driver.ts
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
/**
|
||||
* Manual WAL checkpoint driver with bounded retry (#1741 follow-up).
|
||||
*
|
||||
* Background
|
||||
* ----------
|
||||
* LadybugDB's native auto-checkpoint runs from inside the C++ engine on a
|
||||
* background path that has no JS-side hook for mid-write rotation. When
|
||||
* the rename of `<db>.wal` → `<db>.wal.checkpoint` races a transient file
|
||||
* lock (Windows Defender, AV scanner, NTFS shadow copy) the engine raises
|
||||
* a `Runtime exception: IO exception: Error renaming file …` that aborts
|
||||
* the in-flight write. There is no engine-level retry.
|
||||
*
|
||||
* The auto-checkpoint cannot be made retryable from JS, but a *manual*
|
||||
* `CHECKPOINT` query that the JS layer issues itself CAN be wrapped in a
|
||||
* bounded retry. By draining the WAL on a tight cadence — more often than
|
||||
* the native threshold — the auto-checkpoint almost never has work left
|
||||
* to do, so the un-retriable native rename race is moved into the
|
||||
* JS-controlled path where this module's retry absorbs it.
|
||||
*
|
||||
* Design contract
|
||||
* ---------------
|
||||
* - `autoCheckpoint` stays on (maintainer requirement). This driver is
|
||||
* additive: it preempts the native checkpoint, it does not replace it.
|
||||
* - The driver runs ONLY during analyze (callers opt-in explicitly). MCP
|
||||
* and other long-lived flows continue to rely on the close-time
|
||||
* CHECKPOINT in `safeClose`.
|
||||
* - Opt-out is via `GITNEXUS_WAL_MANUAL_CHECKPOINT=0`. Default is on.
|
||||
* - Retries only fire on `isLbugCheckpointIoError` — every other error
|
||||
* surfaces immediately. The retry budget is small (3 attempts) with
|
||||
* jittered backoff so a chronic rename failure escalates fast.
|
||||
* - Retry attempts log at `debug`; only the final, exhausted failure
|
||||
* surfaces to the caller (and is logged at `warn` here for operators).
|
||||
*/
|
||||
|
||||
import { logger } from '../logger.js';
|
||||
import { tryFlushWAL } from './lbug-adapter.js';
|
||||
import { isLbugCheckpointIoError } from './lbug-config.js';
|
||||
|
||||
/**
|
||||
* Bounded retry budget. Total worst-case wall time is dominated by the
|
||||
* three sleeps below (~750 ms before jitter) plus three CHECKPOINT round
|
||||
* trips — small enough to stay invisible during a large analyze, large
|
||||
* enough to ride out a single AV scanner sweep on Windows.
|
||||
*/
|
||||
const CHECKPOINT_RETRY_ATTEMPTS = 3;
|
||||
|
||||
/**
|
||||
* Base back-off in ms. Each attempt waits `BASE_DELAYS[attempt-1]`
|
||||
* milliseconds before the next try, plus a small jitter to avoid
|
||||
* synchronized retries when multiple analyzers ever share a host.
|
||||
*/
|
||||
const BASE_DELAYS_MS: readonly number[] = [50, 200, 500];
|
||||
|
||||
/** Maximum jitter added on top of each base delay. */
|
||||
const JITTER_MAX_MS = 50;
|
||||
|
||||
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
/**
|
||||
* Run a single CHECKPOINT with bounded retry on
|
||||
* `isLbugCheckpointIoError`. Returns the number of attempts actually
|
||||
* spent (1-`CHECKPOINT_RETRY_ATTEMPTS`) on success, or rethrows the last
|
||||
* checkpoint error after exhausting the budget. Non-checkpoint errors
|
||||
* (e.g. WAL corruption, lock-busy) propagate immediately on the first
|
||||
* attempt — those are not what this retry is designed to absorb.
|
||||
*
|
||||
* The split from `flushWAL` is deliberate: `flushWAL` is the swallow-and-
|
||||
* log helper used by `safeClose` and the server's best-effort flush,
|
||||
* which by contract cannot fail the surrounding operation. The manual
|
||||
* driver MUST observe failures to decide whether to retry, and that is
|
||||
* the role of `tryFlushWAL`.
|
||||
*
|
||||
* Exported for direct unit testing — production callers use
|
||||
* {@link startWalCheckpointDriver} or {@link checkpointOnce}.
|
||||
*/
|
||||
export const runCheckpointWithRetry = async (
|
||||
options: {
|
||||
/** Override the sleep implementation for tests. */
|
||||
sleepFn?: (ms: number) => Promise<void>;
|
||||
/** Override the CHECKPOINT call for tests. */
|
||||
checkpointFn?: () => Promise<boolean>;
|
||||
/** Override the jitter source for tests. Returns a value in [0, 1). */
|
||||
randomFn?: () => number;
|
||||
} = {},
|
||||
): Promise<{ attempts: number; flushed: boolean }> => {
|
||||
const sleepImpl = options.sleepFn ?? sleep;
|
||||
const checkpointImpl = options.checkpointFn ?? tryFlushWAL;
|
||||
const randomImpl = options.randomFn ?? Math.random;
|
||||
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= CHECKPOINT_RETRY_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
const flushed = await checkpointImpl();
|
||||
return { attempts: attempt, flushed };
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
if (!isLbugCheckpointIoError(err)) {
|
||||
// Non-checkpoint error — propagate immediately. Examples:
|
||||
// WAL corruption, missing connection, query syntax failure.
|
||||
// Retrying these would only mask the real signal.
|
||||
throw err;
|
||||
}
|
||||
if (attempt === CHECKPOINT_RETRY_ATTEMPTS) break;
|
||||
const base = BASE_DELAYS_MS[Math.min(attempt - 1, BASE_DELAYS_MS.length - 1)] ?? 500;
|
||||
// randomImpl defaults to Math.random — non-cryptographic by design; jitter only avoids
|
||||
// synchronized retries between concurrent analyzers.
|
||||
const delayMs = base + Math.floor(randomImpl() * JITTER_MAX_MS);
|
||||
logger.debug(
|
||||
{ attempt, totalAttempts: CHECKPOINT_RETRY_ATTEMPTS, delayMs },
|
||||
'GitNexus: WAL checkpoint IO error — retrying',
|
||||
);
|
||||
await sleepImpl(delayMs);
|
||||
}
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
{ attempts: CHECKPOINT_RETRY_ATTEMPTS },
|
||||
'GitNexus: manual WAL checkpoint exhausted retry budget — surfacing IO error to caller',
|
||||
);
|
||||
throw lastError;
|
||||
};
|
||||
|
||||
/**
|
||||
* Single-shot manual checkpoint. Use this when the caller drives the
|
||||
* cadence itself (e.g. a phase boundary in `runFullAnalysis`).
|
||||
*
|
||||
* Honors the `GITNEXUS_WAL_MANUAL_CHECKPOINT=0` opt-out so operators can
|
||||
* disable the manual path if it ever interacts badly with a future
|
||||
* Ladybug release.
|
||||
*/
|
||||
export const checkpointOnce = async (): Promise<void> => {
|
||||
if (!isManualCheckpointEnabled()) return;
|
||||
await runCheckpointWithRetry();
|
||||
};
|
||||
|
||||
/** Default cadence (ms) for the periodic driver. */
|
||||
const DEFAULT_PERIOD_MS = 5_000;
|
||||
|
||||
/**
|
||||
* Start a periodic manual checkpoint driver. The returned handle has a
|
||||
* `stop()` method that resolves once the in-flight checkpoint (if any)
|
||||
* settles, so callers can `await driver.stop()` before close-time
|
||||
* `safeClose` and avoid racing the final flush.
|
||||
*
|
||||
* The first checkpoint fires after `periodMs` (not immediately) so a
|
||||
* cold analyze does not pay a CHECKPOINT round trip before any writes
|
||||
* have happened.
|
||||
*/
|
||||
export interface WalCheckpointDriver {
|
||||
/** Stop the driver and await any in-flight checkpoint. Idempotent. */
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
export const startWalCheckpointDriver = (
|
||||
options: { periodMs?: number } = {},
|
||||
): WalCheckpointDriver => {
|
||||
if (!isManualCheckpointEnabled()) {
|
||||
return { stop: async () => undefined };
|
||||
}
|
||||
|
||||
const periodMs = options.periodMs ?? DEFAULT_PERIOD_MS;
|
||||
let stopped = false;
|
||||
let inflight: Promise<void> | null = null;
|
||||
|
||||
const tick = async (): Promise<void> => {
|
||||
if (stopped) return;
|
||||
inflight = runCheckpointWithRetry()
|
||||
.then(() => undefined)
|
||||
.catch((err) => {
|
||||
// The retry budget exhausted. The caller's surrounding write
|
||||
// will see the same engine error on its next operation and the
|
||||
// `analyzeCommand` catch block will emit the recovery hint.
|
||||
// Logging here keeps the operator-visible trail without
|
||||
// double-logging the user-facing message.
|
||||
logger.warn(
|
||||
{ err: err instanceof Error ? err.message : String(err) },
|
||||
'GitNexus: manual WAL checkpoint failed after retries',
|
||||
);
|
||||
});
|
||||
try {
|
||||
await inflight;
|
||||
} finally {
|
||||
inflight = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handle = setInterval(() => {
|
||||
// Fire-and-forget: setInterval cannot await directly. The next tick
|
||||
// is guarded by `stopped` and the `inflight` reference.
|
||||
void tick();
|
||||
}, periodMs);
|
||||
// `setInterval` returned by Node is a `Timeout` object with `.unref()`
|
||||
// so a hung driver never prevents process exit.
|
||||
if (typeof (handle as NodeJS.Timeout).unref === 'function') {
|
||||
(handle as NodeJS.Timeout).unref();
|
||||
}
|
||||
|
||||
return {
|
||||
stop: async () => {
|
||||
if (stopped) {
|
||||
if (inflight) await inflight;
|
||||
return;
|
||||
}
|
||||
stopped = true;
|
||||
clearInterval(handle);
|
||||
if (inflight) {
|
||||
try {
|
||||
await inflight;
|
||||
} catch {
|
||||
/* swallowed in tick() — surface path is the surrounding write */
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Reading `GITNEXUS_WAL_MANUAL_CHECKPOINT` at every call site (rather
|
||||
* than caching at module load) keeps `analyzeCommand` env restoration
|
||||
* honest: tests that toggle the flag between invocations see the live
|
||||
* value, matching the `ANALYZE_CLI_ENV_KEYS` snapshot/restore contract
|
||||
* in `analyze.ts`.
|
||||
*
|
||||
* Accepted opt-out values: '0', 'false', 'off', 'no' (case-insensitive).
|
||||
* Anything else — including undefined — leaves the driver enabled.
|
||||
*/
|
||||
export const isManualCheckpointEnabled = (): boolean => {
|
||||
const raw = process.env.GITNEXUS_WAL_MANUAL_CHECKPOINT;
|
||||
if (raw === undefined) return true;
|
||||
const normalized = raw.trim().toLowerCase();
|
||||
return !['0', 'false', 'off', 'no'].includes(normalized);
|
||||
};
|
||||
|
|
@ -25,7 +25,11 @@ import {
|
|||
deleteAllCommunitiesAndProcesses,
|
||||
queryImporters,
|
||||
} from './lbug/lbug-adapter.js';
|
||||
import { createSearchFTSIndexes } from './search/fts-indexes.js';
|
||||
import { createSearchFTSIndexes, verifySearchFTSIndexes } from './search/fts-indexes.js';
|
||||
import {
|
||||
startWalCheckpointDriver,
|
||||
type WalCheckpointDriver,
|
||||
} from './lbug/wal-checkpoint-driver.js';
|
||||
import {
|
||||
getStoragePaths,
|
||||
saveMeta,
|
||||
|
|
@ -71,6 +75,10 @@ export interface AnalyzeOptions {
|
|||
* bypass. See `allowDuplicateName` below.
|
||||
*/
|
||||
force?: boolean;
|
||||
/** Repair only search indexes without re-running full parsing/indexing. */
|
||||
repairFts?: boolean;
|
||||
/** Emit per-index FTS create logs. */
|
||||
verbose?: boolean;
|
||||
embeddings?: boolean;
|
||||
/**
|
||||
* Override the auto-skip node-count cap for embedding generation.
|
||||
|
|
@ -110,6 +118,14 @@ export interface AnalyzeOptions {
|
|||
* of a pipeline re-index.
|
||||
*/
|
||||
allowDuplicateName?: boolean;
|
||||
/**
|
||||
* Worker pool size override, threaded from the CLI `--workers` flag.
|
||||
* Forwarded to `PipelineOptions.workerPoolSize` so the parse phase
|
||||
* sizes the pool without `analyzeCommand` mutating `process.env`.
|
||||
* `0` disables the pool (sequential fallback); positive integer sets
|
||||
* the count; `undefined` defers to the env / auto-formula fallback.
|
||||
*/
|
||||
workerPoolSize?: number;
|
||||
}
|
||||
|
||||
export interface AnalyzeResult {
|
||||
|
|
@ -126,6 +142,8 @@ export interface AnalyzeResult {
|
|||
alreadyUpToDate?: boolean;
|
||||
/** The raw pipeline result — only populated when needed by callers (e.g. skill generation). */
|
||||
pipelineResult?: any;
|
||||
/** True when analyze only repaired FTS indexes and skipped pipeline re-analysis. */
|
||||
ftsRepairedOnly?: boolean;
|
||||
}
|
||||
|
||||
// Re-export the pure flag-derivation helper so external callers (and tests)
|
||||
|
|
@ -190,6 +208,78 @@ export async function runFullAnalysis(
|
|||
const currentCommit = repoHasGit ? getCurrentCommit(repoPath) : '';
|
||||
const existingMeta = await loadMeta(storagePath);
|
||||
|
||||
// ── FTS-only repair path ────────────────────────────────────────────
|
||||
if (options.repairFts) {
|
||||
if (!existingMeta) {
|
||||
throw new Error(
|
||||
'Cannot repair FTS indexes because this repository has not been analyzed yet. ' +
|
||||
'Run `gitnexus analyze` first to create the initial index, then retry `--repair-fts`.',
|
||||
);
|
||||
}
|
||||
let lbugStat;
|
||||
try {
|
||||
lbugStat = await fs.lstat(lbugPath);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Cannot repair FTS indexes: graph store at ${lbugPath} is missing. ` +
|
||||
'Run `gitnexus analyze` (full) to rebuild from scratch.',
|
||||
);
|
||||
}
|
||||
if (!lbugStat.isFile()) {
|
||||
const foundType = lbugStat.isDirectory()
|
||||
? 'a directory'
|
||||
: lbugStat.isSymbolicLink()
|
||||
? 'a symbolic link'
|
||||
: lbugStat.isSocket()
|
||||
? 'a socket'
|
||||
: lbugStat.isBlockDevice()
|
||||
? 'a block device'
|
||||
: lbugStat.isCharacterDevice()
|
||||
? 'a character device'
|
||||
: lbugStat.isFIFO()
|
||||
? 'a FIFO'
|
||||
: 'not a regular file';
|
||||
throw new Error(
|
||||
`Cannot repair FTS indexes: graph store at ${lbugPath} is ${foundType} (expected a file). ` +
|
||||
'Run `gitnexus analyze` (full) to rebuild from scratch.',
|
||||
);
|
||||
}
|
||||
try {
|
||||
await initLbug(lbugPath);
|
||||
progress('fts', 85, 'Repairing search indexes...');
|
||||
await createSearchFTSIndexes({
|
||||
onIndexStart: options.verbose
|
||||
? (table, indexName) => log(`FTS: creating ${table}.${indexName}`)
|
||||
: undefined,
|
||||
onIndexReady: options.verbose
|
||||
? (table, indexName) => log(`FTS: ready ${table}.${indexName}`)
|
||||
: undefined,
|
||||
});
|
||||
const missing = await verifySearchFTSIndexes(executeQuery);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`FTS repair failed - missing indexes after rebuild: ${missing.join(', ')}. ` +
|
||||
'Run `gitnexus analyze --force` to perform a full graph+FTS rebuild; ' +
|
||||
'if that also fails, verify FTS extension availability via `gitnexus doctor`.',
|
||||
);
|
||||
}
|
||||
await ensureGitNexusIgnored(repoPath);
|
||||
progress('fts', 90, 'Search indexes ready');
|
||||
progress('done', 100, 'Done');
|
||||
return {
|
||||
repoName:
|
||||
options.registryName ??
|
||||
getInferredRepoName(repoPath) ??
|
||||
path.basename(resolveRepoIdentityRoot(repoPath)),
|
||||
repoPath,
|
||||
stats: existingMeta.stats ?? {},
|
||||
ftsRepairedOnly: true,
|
||||
};
|
||||
} finally {
|
||||
await closeLbug().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Crash recovery: dirty flag forces full rebuild ────────────────
|
||||
// If the previous incremental run set incrementalInProgress and didn't
|
||||
// clear it, the on-disk index may be in a half-state. Cheapest path
|
||||
|
|
@ -366,7 +456,7 @@ export async function runFullAnalysis(
|
|||
: p.message || phaseLabel;
|
||||
progress(p.phase, scaled, message);
|
||||
},
|
||||
{ parseCache },
|
||||
{ parseCache, workerPoolSize: options.workerPoolSize },
|
||||
);
|
||||
|
||||
// ── Phase 2: LadybugDB (60–85%) ──────────────────────────────────
|
||||
|
|
@ -435,6 +525,16 @@ export async function runFullAnalysis(
|
|||
}
|
||||
|
||||
await initLbug(lbugPath);
|
||||
|
||||
// Manual WAL checkpoint driver (#1741): periodically drain the WAL
|
||||
// from JS so the un-retriable native auto-checkpoint almost never
|
||||
// has work left to do. Failures of the manual CHECKPOINT are absorbed
|
||||
// by the driver's bounded retry; the final un-recoverable error still
|
||||
// surfaces via the surrounding write that follows the failed flush.
|
||||
// Opt-out via `GITNEXUS_WAL_MANUAL_CHECKPOINT=0` (the driver itself
|
||||
// returns a no-op handle when disabled). Analyze-only: MCP and serve
|
||||
// paths continue to rely on the close-time CHECKPOINT in `safeClose`.
|
||||
const walCheckpointDriver: WalCheckpointDriver = startWalCheckpointDriver();
|
||||
try {
|
||||
// All work after initLbug is wrapped in try/finally to ensure closeLbug()
|
||||
// is called even if an error occurs — the module-level singleton DB handle
|
||||
|
|
@ -583,7 +683,21 @@ export async function runFullAnalysis(
|
|||
|
||||
// ── Phase 3: FTS (85–90%) ─────────────────────────────────────────
|
||||
progress('fts', 85, 'Creating search indexes...');
|
||||
await createSearchFTSIndexes();
|
||||
await createSearchFTSIndexes({
|
||||
onIndexStart: options.verbose
|
||||
? (table, indexName) => log(`FTS: creating ${table}.${indexName}`)
|
||||
: undefined,
|
||||
onIndexReady: options.verbose
|
||||
? (table, indexName) => log(`FTS: ready ${table}.${indexName}`)
|
||||
: undefined,
|
||||
});
|
||||
const missingIndexNames = await verifySearchFTSIndexes(executeQuery);
|
||||
if (missingIndexNames.length > 0) {
|
||||
throw new Error(
|
||||
`FTS verification failed - missing indexes after analyze: ${missingIndexNames.join(', ')}. ` +
|
||||
'Check FTS extension availability, then retry `gitnexus analyze --force` for a full rebuild.',
|
||||
);
|
||||
}
|
||||
progress('fts', 90, 'Search indexes ready');
|
||||
|
||||
// ── Phase 3.5: Re-insert cached embeddings ────────────────────────
|
||||
|
|
@ -861,6 +975,9 @@ export async function runFullAnalysis(
|
|||
}
|
||||
|
||||
// ── Close LadybugDB ──────────────────────────────────────────────
|
||||
// Stop the manual checkpoint driver before closeLbug so its
|
||||
// in-flight CHECKPOINT cannot race the `safeClose` CHECKPOINT.
|
||||
await walCheckpointDriver.stop();
|
||||
await closeLbug();
|
||||
|
||||
progress('done', 100, 'Done');
|
||||
|
|
@ -872,7 +989,13 @@ export async function runFullAnalysis(
|
|||
pipelineResult,
|
||||
};
|
||||
} catch (err) {
|
||||
// Ensure LadybugDB is closed even on error
|
||||
// Ensure LadybugDB is closed even on error. Stop the driver first
|
||||
// so its retry loop cannot extend an already-failing analyze.
|
||||
try {
|
||||
await walCheckpointDriver.stop();
|
||||
} catch {
|
||||
/* swallow — surface path is the rethrow below */
|
||||
}
|
||||
try {
|
||||
await closeLbug();
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,45 @@
|
|||
import { createFTSIndex } from '../lbug/lbug-adapter.js';
|
||||
import { FTS_INDEXES } from './fts-schema.js';
|
||||
|
||||
export async function createSearchFTSIndexes(): Promise<void> {
|
||||
export interface CreateSearchFTSIndexesOptions {
|
||||
onIndexStart?: (table: string, indexName: string) => void;
|
||||
onIndexReady?: (table: string, indexName: string) => void;
|
||||
}
|
||||
|
||||
export async function createSearchFTSIndexes(
|
||||
options?: CreateSearchFTSIndexesOptions,
|
||||
): Promise<void> {
|
||||
for (const { table, indexName, properties } of FTS_INDEXES) {
|
||||
options?.onIndexStart?.(table, indexName);
|
||||
await createFTSIndex(table, indexName, [...properties]);
|
||||
options?.onIndexReady?.(table, indexName);
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifySearchFTSIndexes(
|
||||
executeQuery: (cypher: string) => Promise<unknown[]>,
|
||||
): Promise<string[]> {
|
||||
const safeIdentifier = (value: string): string => {
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
|
||||
throw new Error(`Invalid FTS identifier: ${value}`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const missing: string[] = [];
|
||||
for (const { table, indexName } of FTS_INDEXES) {
|
||||
const safeTable = safeIdentifier(table);
|
||||
const safeIndex = safeIdentifier(indexName);
|
||||
const probe = `
|
||||
CALL QUERY_FTS_INDEX('${safeTable}', '${safeIndex}', '__gitnexus_fts_probe__', conjunctive := false)
|
||||
RETURN score
|
||||
LIMIT 1
|
||||
`;
|
||||
try {
|
||||
await executeQuery(probe);
|
||||
} catch {
|
||||
missing.push(`${table}.${indexName}`);
|
||||
}
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue