diff --git a/.claude/skills/gitnexus/gitnexus-cli/SKILL.md b/.claude/skills/gitnexus/gitnexus-cli/SKILL.md index 24586205e..cd9a83be0 100644 --- a/.claude/skills/gitnexus/gitnexus-cli/SKILL.md +++ b/.claude/skills/gitnexus/gitnexus-cli/SKILL.md @@ -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. diff --git a/AGENTS.md b/AGENTS.md index 99fc68bd0..46960b967 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,131 +62,64 @@ Commands and gotchas live under **Repo reference** below and in **[CONTRIBUTING. # GitNexus — Code Intelligence -Indexed as **GitNexus** (4325 symbols, 10556 relationships, 300 execution flows). Use 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 tool warns the index is stale, run `npx gitnexus analyze` first. +> 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.** `gitnexus_impact({target: "symbolName", direction: "upstream"})` — report blast radius to the user. -- **MUST run `gitnexus_detect_changes()` before committing** — verify only expected symbols and flows are affected. -- **MUST warn the user** if impact returns HIGH or CRITICAL risk. -- Explore unfamiliar code with `gitnexus_query({query: "concept"})` (process-grouped, ranked) instead of grepping. -- Full context on a symbol: `gitnexus_context({name: "symbolName"})`. - -## When Debugging - -1. `gitnexus_query({query: ""})` — find related execution flows -2. `gitnexus_context({name: ""})` — callers, callees, process participation -3. `READ gitnexus://repo/GitNexus/process/{processName}` — trace flow step by step -4. Regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` - -## When Refactoring - -- **Rename:** `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Graph edits are safe; text_search edits need manual review. -- **Extract/Split:** `gitnexus_context` (incoming/outgoing refs) then `gitnexus_impact` (upstream callers) before moving code. -- **After any refactor:** `gitnexus_detect_changes({scope: "all"})` to verify scope. +- **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 -- Edit a symbol without running `gitnexus_impact` first. -- Ignore HIGH/CRITICAL risk warnings. -- Rename with find-and-replace — use `gitnexus_rename`. -- Commit without `gitnexus_detect_changes()`. -- Add language-specific behavior to shared ingestion code (`gitnexus/src/core/ingestion/`) — use a `LanguageProvider` hook. Seeing `provider.mroStrategy === 'xxx'` or an import from `languages/xxx.ts` in shared code means stop and add a hook. - -## Tools Quick Reference - -| Tool | When to use | Example | -|------|-------------|---------| -| `list_repos` | Discover indexed repos | `gitnexus_list_repos({})` | -| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` | -| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` | -| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` | -| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` | -| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` | -| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` | -| `api_impact` | Pre-change API route impact | `gitnexus_api_impact({route: "/api/users", method: "GET"})` | -| `route_map` | Route → handler → consumer map | `gitnexus_route_map({})` | -| `tool_map` | MCP/RPC tool definitions | `gitnexus_tool_map({})` | -| `shape_check` | Response shape vs consumer access | `gitnexus_shape_check({route: "/api/users"})` | -| `group_list` | List repo groups | `gitnexus_group_list({})` | -| `group_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: "@"` to fan out across all member repos, or `repo: "@/"` to target a single member (path keys from `group.yaml`). Optional `service: ""` 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 | -| d=2 | LIKELY AFFECTED — indirect deps | Should test | -| d=3 | MAY NEED TESTING — transitive | Test if critical path | +- 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, 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 +## CLI -1. `gitnexus_impact` was run for all modified symbols -2. No HIGH/CRITICAL warnings were ignored -3. `gitnexus_detect_changes()` confirms expected scope -4. All d=1 dependents were updated - -## Keeping the Index Fresh - -```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 Skills - -| Task | Skill file | -|------|-----------| -| Architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | -| Blast radius / "What breaks?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | -| Debugging / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | -| Refactoring | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | -| Tools/resources/schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | -| CLI commands (index, status, clean, wiki) | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | - -## 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. | +| 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` | diff --git a/CLAUDE.md b/CLAUDE.md index af4069fcd..70b3f1a36 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,3 +52,67 @@ If always-on instructions grow, load deep conventions via conditional reads (e.g ## GitNexus rules See the ` … ` block in **[AGENTS.md](AGENTS.md)** for the canonical MCP tools, impact analysis rules, and index instructions. + + +# 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` | + + diff --git a/eval/README.md b/eval/README.md index bd9dc4e20..e3539cf20 100644 --- a/eval/README.md +++ b/eval/README.md @@ -162,8 +162,8 @@ Each mode has a `system_{mode}.jinja` + `instance_{mode}.jinja` pair. The agent ``` Agent → bash command → /usr/local/bin/gitnexus-query - → curl localhost:4848/tool/query (fast path: eval-server, ~100ms) - → npx gitnexus query (fallback: cold CLI, ~5-10s) + → curl http://127.0.0.1:4848/tool/query (fast path: eval-server, ~100ms) + → npx gitnexus query (fallback: cold CLI, ~5-10s) ``` Each tool script in `/usr/local/bin/` is standalone — no sourcing, no env inheritance needed. This is critical because mini-swe-agent runs every command via `subprocess.run` in a fresh subshell. @@ -176,6 +176,59 @@ The eval-server is a lightweight HTTP daemon that: - Includes next-step hints to guide tool chaining (query → context → impact → fix) - Auto-shuts down after idle timeout +**CLI flags:** + +| Flag | Default | Purpose | +|------|---------|---------| +| `--port ` | `4848` | Port to listen on | +| `--host ` | `127.0.0.1` | Bind address — use `0.0.0.0` for cross-container access | +| `--idle-timeout ` | `0` (disabled) | Auto-shutdown after N seconds of inactivity | + +**READY signal:** + +When the server is ready, it writes to stdout: + +``` +# IPv4 +GITNEXUS_EVAL_SERVER_READY:127.0.0.1:4848 + +# IPv6 (bracketed to avoid colon ambiguity) +GITNEXUS_EVAL_SERVER_READY:[::1]:4848 +``` + +Parse the port as the last colon-segment (`split(':').pop()`) — not `split(':')[1]`, which breaks for IPv6 and for non-loopback IPv4 hosts added in this release. + +### Custom port and host + +`run_eval.py` does not expose `--port` or `--host` as CLI flags. Configure them in your mode YAML under the `environment:` key: + +```yaml +# configs/modes/native_augment.yaml (or whichever mode you're running) +environment: + eval_server_port: 4849 # change if 4848 is already in use on the host + eval_server_host: "0.0.0.0" # bind all interfaces — needed for cross-container setups +``` + +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. + +**Running eval-server directly in Docker / Docker Compose:** + +```bash +# Bind to all interfaces so sibling containers can reach it +gitnexus eval-server --host 0.0.0.0 --port 4848 + +# Then probe from a sibling container via its service hostname +curl http://eval-container:4848/health +``` + +If you need a non-default port (e.g. to avoid conflicts), pass `--port ` alongside `--host`. The READY signal will reflect both: + +``` +GITNEXUS_EVAL_SERVER_READY:0.0.0.0:5000 +``` + +Parse the port as the last colon-segment (`split(':').pop()`) — safe for both IPv4 and bracketed IPv6 forms. + ### Index caching SWE-bench repos repeat (Django has 200+ instances at different commits). The harness caches GitNexus indexes per `(repo, commit)` hash in `~/.gitnexus-eval-cache/` to avoid redundant re-indexing. diff --git a/eval/environments/gitnexus_docker.py b/eval/environments/gitnexus_docker.py index 73aec0dd9..2f9640f96 100644 --- a/eval/environments/gitnexus_docker.py +++ b/eval/environments/gitnexus_docker.py @@ -39,6 +39,7 @@ logger = logging.getLogger("gitnexus_docker") DEFAULT_CACHE_DIR = Path.home() / ".gitnexus-eval-cache" EVAL_SERVER_PORT = 4848 +EVAL_SERVER_HOST = "127.0.0.1" class GitNexusDockerEnvironment(DockerEnvironment): @@ -62,6 +63,7 @@ class GitNexusDockerEnvironment(DockerEnvironment): skip_embeddings: bool = True, gitnexus_timeout: int = 120, eval_server_port: int = EVAL_SERVER_PORT, + eval_server_host: str = EVAL_SERVER_HOST, **kwargs, ): super().__init__(**kwargs) @@ -70,6 +72,7 @@ class GitNexusDockerEnvironment(DockerEnvironment): self.skip_embeddings = skip_embeddings self.gitnexus_timeout = gitnexus_timeout self.eval_server_port = eval_server_port + self.eval_server_host = eval_server_host self.index_time: float = 0.0 self._gitnexus_ready = False @@ -165,22 +168,29 @@ class GitNexusDockerEnvironment(DockerEnvironment): def _start_eval_server(self): """Start the GitNexus eval-server daemon in the background.""" - logger.info(f"Starting eval-server on port {self.eval_server_port}...") + logger.info( + f"Starting eval-server on {self.eval_server_host}:{self.eval_server_port}..." + ) self.execute({ "command": ( f"nohup npx gitnexus eval-server --port {self.eval_server_port} " + f"--host {self.eval_server_host} " f"--idle-timeout 600 " f"> /tmp/gitnexus-eval-server.log 2>&1 &" ), "timeout": 5, }) + # Use 127.0.0.1 for the health probe — reachable whether server binds + # loopback or all interfaces (0.0.0.0), avoiding DNS resolution issues. + health_host = "127.0.0.1" + # Wait for the server to be ready (up to ~15s for KuzuDB init) for i in range(EVAL_SERVER_HEALTH_RETRIES): time.sleep(EVAL_SERVER_HEALTH_INTERVAL_SECONDS) health = self.execute({ - "command": f"curl -sf http://127.0.0.1:{self.eval_server_port}/health 2>/dev/null || echo 'NOT_READY'", + "command": f"curl -sf http://{health_host}:{self.eval_server_port}/health 2>/dev/null || echo 'NOT_READY'", "timeout": EVAL_SERVER_HEALTH_TIMEOUT_SECONDS, }) output = health.get("output", "").strip() @@ -201,7 +211,7 @@ class GitNexusDockerEnvironment(DockerEnvironment): ) @staticmethod - def _render_tool_script(spec: ToolScriptSpec, port: str) -> str: + def _render_tool_script(spec: ToolScriptSpec, port: str, host: str = EVAL_SERVER_HOST) -> str: """ Render a standalone bash script for a GitNexus tool. @@ -212,6 +222,7 @@ class GitNexusDockerEnvironment(DockerEnvironment): if spec.endpoint: lines.append(f'PORT="${{GITNEXUS_EVAL_PORT:-{port}}}"') + lines.append(f'HOST="${{GITNEXUS_EVAL_HOST:-{host}}}"') if spec.header: lines.append(spec.header.strip()) @@ -221,7 +232,7 @@ class GitNexusDockerEnvironment(DockerEnvironment): if spec.endpoint: lines.append( - f'result=$(curl -sf -X POST "http://127.0.0.1:${{PORT}}{spec.endpoint}" ' + f'result=$(curl -sf -X POST "http://${{HOST}}:${{PORT}}{spec.endpoint}" ' '-H "Content-Type: application/json" -d "$payload" 2>/dev/null)' ) lines.append('if [ $? -eq 0 ] && [ -n "$result" ]; then echo "$result"; exit 0; fi') @@ -244,9 +255,10 @@ class GitNexusDockerEnvironment(DockerEnvironment): Uses heredocs with quoted delimiter to avoid all quoting/escaping issues. """ port = str(self.eval_server_port) + host = self.eval_server_host for spec in TOOL_SPECS.values(): - script_content = self._render_tool_script(spec, port).strip() + script_content = self._render_tool_script(spec, port, host).strip() # Use heredoc with quoted delimiter — prevents all variable expansion and quoting issues self.execute({ "command": ( @@ -387,5 +399,6 @@ class GitNexusDockerEnvironment(DockerEnvironment): "index_time_seconds": round(self.index_time, 2), "skip_embeddings": self.skip_embeddings, "eval_server_port": self.eval_server_port, + "eval_server_host": self.eval_server_host, } return base diff --git a/eval/uv.lock b/eval/uv.lock index 04b89f336..a78eadf06 100644 --- a/eval/uv.lock +++ b/eval/uv.lock @@ -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]] diff --git a/gitnexus-shared/src/index.ts b/gitnexus-shared/src/index.ts index cf9eb1148..e2d5df284 100644 --- a/gitnexus-shared/src/index.ts +++ b/gitnexus-shared/src/index.ts @@ -127,6 +127,7 @@ export { CLASS_KINDS, METHOD_KINDS, FIELD_KINDS } from './scope-resolution/regis export type { RegistryContext, RegistryProviders, + OwnedMembersByOwnerLookup, OwnerScopedContributor, ArityVerdict, ConstraintContext, diff --git a/gitnexus-shared/src/scope-resolution/reference-site.ts b/gitnexus-shared/src/scope-resolution/reference-site.ts index 67ed23e6b..58fabd427 100644 --- a/gitnexus-shared/src/scope-resolution/reference-site.ts +++ b/gitnexus-shared/src/scope-resolution/reference-site.ts @@ -21,6 +21,7 @@ * (defined in `./types.ts`). */ +import type { ParameterTypeClass } from './symbol-definition.js'; import type { Range, ScopeId } from './types.js'; /** @@ -79,4 +80,11 @@ export interface ReferenceSite { * (C#: `42` → `'int'`, `"alice"` → `'string'`). */ readonly argumentTypes?: readonly string[]; + /** + * Optional per-argument type-shape sidecar for languages that need + * cv/ref/pointer distinctions during constraint filtering. This is + * intentionally separate from `argumentTypes`, which stays normalized + * for existing overload narrowing and conversion-rank logic. + */ + readonly argumentTypeClasses?: readonly ParameterTypeClass[]; } diff --git a/gitnexus-shared/src/scope-resolution/registries/context.ts b/gitnexus-shared/src/scope-resolution/registries/context.ts index 539242a0e..657856f3e 100644 --- a/gitnexus-shared/src/scope-resolution/registries/context.ts +++ b/gitnexus-shared/src/scope-resolution/registries/context.ts @@ -13,7 +13,7 @@ */ import type { NodeLabel } from '../../graph/types.js'; -import type { SymbolDefinition } from '../symbol-definition.js'; +import type { ParameterTypeClass, SymbolDefinition } from '../symbol-definition.js'; import type { Callsite, DefId } from '../types.js'; import type { DefIndex } from '../def-index.js'; import type { QualifiedNameIndex } from '../qualified-name-index.js'; @@ -65,6 +65,13 @@ export interface ConstraintContext { * `narrowOverloadCandidates`' `argTypes` parameter. */ readonly argumentTypes?: readonly string[]; + /** + * Optional shape-preserving sidecar aligned with `argumentTypes`. + * Unknown or unsupported slots should be omitted by producers or + * marked with `indirection: 'unknown'`; consumers must preserve the + * monotonic fallback and return 'unknown' instead of guessing. + */ + readonly argumentTypeClasses?: readonly ParameterTypeClass[]; } // ─── Owner-scoped contributor (concrete shape for `RegistryContributor`) ──── @@ -93,6 +100,19 @@ export interface OwnerScopedContributor { byName(name: string): readonly SymbolDefinition[]; } +/** + * Required owner-keyed lookup hook for Step 2 receiver/MRO member walks. + * Production callers wire this to the SemanticModel's authoritative + * method/field/nested-type registries so each `(ownerDefId, memberName)` + * probe is O(1). Implementations MUST return `[]` on an indexed miss — + * Step 2 treats `[]` as authoritative and does not consult `defs` for a + * fallback scan. + */ +export type OwnedMembersByOwnerLookup = ( + ownerDefId: DefId, + memberName: string, +) => readonly SymbolDefinition[]; + // ─── Top-level context threaded through every lookup ─────────────────────── export interface RegistryContext { @@ -100,6 +120,7 @@ export interface RegistryContext { readonly defs: DefIndex; readonly qualifiedNames: QualifiedNameIndex; readonly moduleScopes: ModuleScopeIndex; + readonly ownedMembersByOwner: OwnedMembersByOwnerLookup; /** * Method-dispatch index; required for method/field registries that * honor `useReceiverTypeBinding`. Omit for class-only lookups. diff --git a/gitnexus-shared/src/scope-resolution/registries/lookup-core.ts b/gitnexus-shared/src/scope-resolution/registries/lookup-core.ts index fff3e7adb..dcf250e02 100644 --- a/gitnexus-shared/src/scope-resolution/registries/lookup-core.ts +++ b/gitnexus-shared/src/scope-resolution/registries/lookup-core.ts @@ -27,8 +27,10 @@ * is true, resolve the receiver's type at `startScope` (from * `scope.typeBindings`), then walk the MRO via * `MethodDispatchIndex.mroFor(ownerDefId)`. Membership per owner comes - * through `RegistryContext.methodDispatch` + owner lookups into - * `scope.ownedDefs`; each hit records a raw signal with the owner's + * through an optional `RegistryContext.ownedMembersByOwner` hook when + * supplied (`undefined` → fall back to `defs.byId`; `[]` → indexed + * miss), otherwise via the compatibility fallback scan over + * `defs.byId`; each hit records a raw signal with the owner's * MRO depth. * * **Step 3 — Owner-scoped contributor.** When @@ -263,13 +265,14 @@ function walkReceiverTypeBinding( // Walk the owner itself at depth 0, then its MRO chain. const walk: DefId[] = [ownerDefId, ...ctx.methodDispatch.mroFor(ownerDefId)]; - for (let mroDepth = 0; mroDepth < walk.length; mroDepth++) { - const currentOwnerId = walk[mroDepth]!; + let mroDepth = 0; + for (const currentOwnerId of walk) { const members = collectOwnedMembers(currentOwnerId, name, ctx); for (const def of members) { if (!acceptedKinds.has(def.type)) continue; recordTypeBindingHit(perCandidate, def, mroDepth, ownerDefId); } + mroDepth++; } } @@ -333,23 +336,7 @@ function collectOwnedMembers( memberName: string, ctx: RegistryContext, ): readonly SymbolDefinition[] { - // An owner's members are defs whose `ownerId === ownerDefId` and whose - // simple name matches `memberName`. We iterate `defs.byId` — O(D) per - // call today. A future by-owner index would make this O(K); tracked as - // a follow-up optimization before Ring 3 flips go production. - const out: SymbolDefinition[] = []; - for (const def of ctx.defs.byId.values()) { - if (def.ownerId !== ownerDefId) continue; - if (simpleNameOf(def) !== memberName) continue; - out.push(def); - } - return out; -} - -function simpleNameOf(def: SymbolDefinition): string | undefined { - if (def.qualifiedName === undefined || def.qualifiedName.length === 0) return undefined; - const dot = def.qualifiedName.lastIndexOf('.'); - return dot === -1 ? def.qualifiedName : def.qualifiedName.slice(dot + 1); + return ctx.ownedMembersByOwner(ownerDefId, memberName); } function recordTypeBindingHit( diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index cf69e1083..6297ec5c5 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -41,7 +41,7 @@ "sigma": "^3.0.2", "tailwindcss": "^4.2.4", "uuid": "^14.0.0", - "zod": "^3.25.76" + "zod": "^4.3.6" }, "devDependencies": { "@babel/types": "^7.29.0", @@ -5599,13 +5599,12 @@ } }, "node_modules/langsmith": { - "version": "0.5.23", - "resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.5.23.tgz", - "integrity": "sha512-dE/M/2Gg2S2R8ygDdkWGJVO3JstijvsNvPXsy9V8WGbpb88Zn8xF/aTjPx4mIy5gIoo02T6FssOgYyLf51Dv1Q==", + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.6.3.tgz", + "integrity": "sha512-pXrQ4/4myQvjFFOAUmt5pWRrLEZR20gzIJD7MNdUH+5/S5nLI4ZRBo/SYKC6coaYj9pYTfQdBIzcs+3kfJ5uDA==", "license": "MIT", "dependencies": { - "p-queue": "6.6.2", - "uuid": "10.0.0" + "p-queue": "6.6.2" }, "peerDependencies": { "@opentelemetry/api": "*", @@ -5632,19 +5631,6 @@ } } }, - "node_modules/langsmith/node_modules/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/layout-base": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", @@ -8903,9 +8889,9 @@ } }, "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json index 211bb65c7..e93dc82c2 100644 --- a/gitnexus-web/package.json +++ b/gitnexus-web/package.json @@ -51,7 +51,7 @@ "sigma": "^3.0.2", "tailwindcss": "^4.2.4", "uuid": "^14.0.0", - "zod": "^3.25.76" + "zod": "^4.3.6" }, "devDependencies": { "@babel/types": "^7.29.0", diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 383354253..adfac8ee8 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -17,7 +17,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", @@ -52,7 +52,7 @@ "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", @@ -1290,268 +1290,6 @@ } } }, - "node_modules/@modelcontextprotocol/sdk/node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "license": "MIT", - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", - "license": "MIT", - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^1.0.5", - "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", - "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", - "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "license": "MIT", - "engines": { - "node": ">=6.6.0" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "license": "MIT", - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.1", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "depd": "^2.0.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", - "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.1", - "mime-types": "^3.0.2", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/serve-static": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", - "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", - "license": "MIT", - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", - "license": "MIT", - "dependencies": { - "content-type": "^1.0.5", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", @@ -2018,22 +1756,21 @@ "license": "MIT" }, "node_modules/@types/express": { - "version": "4.17.25", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", - "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", "dev": true, "license": "MIT", "dependencies": { "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.33", - "@types/qs": "*", - "@types/serve-static": "^1" + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" } }, "node_modules/@types/express-serve-static-core": { - "version": "4.19.8", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", - "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", "dev": true, "license": "MIT", "dependencies": { @@ -2057,26 +1794,19 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/mime": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/node": { - "version": "25.6.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.2.tgz", - "integrity": "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==", + "version": "25.7.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.7.0.tgz", + "integrity": "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg==", "license": "MIT", "dependencies": { - "undici-types": "~7.19.0" + "undici-types": "~7.21.0" } }, "node_modules/@types/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", "dev": true, "license": "MIT" }, @@ -2098,25 +1828,13 @@ } }, "node_modules/@types/serve-static": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", - "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", "dev": true, "license": "MIT", "dependencies": { "@types/http-errors": "*", - "@types/node": "*", - "@types/send": "<1" - } - }, - "node_modules/@types/serve-static/node_modules/@types/send": { - "version": "0.17.6", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", - "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/mime": "^1", "@types/node": "*" } }, @@ -2276,13 +1994,13 @@ } }, "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", "license": "MIT", "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" }, "engines": { "node": ">= 0.6" @@ -2360,12 +2078,6 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" - }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -2407,63 +2119,33 @@ } }, "node_modules/body-parser": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", - "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", "license": "MIT", "dependencies": { - "bytes": "~3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "~1.2.0", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "on-finished": "~2.4.1", - "qs": "~6.14.0", - "raw-body": "~2.5.3", - "type-is": "~1.6.18", - "unpipe": "~1.0.0" + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" }, "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/body-parser/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/body-parser/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/body-parser/node_modules/raw-body": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "unpipe": "~1.0.0" + "node": ">=18" }, - "engines": { - "node": ">= 0.8" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -2612,15 +2294,16 @@ } }, "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/content-type": { @@ -2649,10 +2332,13 @@ } }, "node_modules/cookie-signature": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", - "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", - "license": "MIT" + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } }, "node_modules/cors": { "version": "2.8.6", @@ -2784,16 +2470,6 @@ "node": ">= 0.8" } }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -3013,45 +2689,42 @@ } }, "node_modules/express": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", - "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "~1.20.3", - "content-disposition": "~0.5.4", - "content-type": "~1.0.4", - "cookie": "~0.7.1", - "cookie-signature": "~1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "~1.3.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "~0.1.12", - "proxy-addr": "~2.0.7", - "qs": "~6.14.0", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "~0.19.0", - "serve-static": "~1.16.2", - "setprototypeof": "1.2.0", - "statuses": "~2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" }, "engines": { - "node": ">= 0.10.0" + "node": ">= 18" }, "funding": { "type": "opencollective", @@ -3059,9 +2732,9 @@ } }, "node_modules/express-rate-limit": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.1.tgz", - "integrity": "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", "license": "MIT", "dependencies": { "ip-address": "^10.2.0" @@ -3076,21 +2749,6 @@ "express": ">= 4.11" } }, - "node_modules/express/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/express/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, "node_modules/fast-copy": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.3.tgz", @@ -3144,38 +2802,26 @@ } }, "node_modules/finalhandler": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", - "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", "license": "MIT", "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "statuses": "~2.0.2", - "unpipe": "~1.0.0" + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" }, "engines": { - "node": ">= 0.8" + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/finalhandler/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/finalhandler/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, "node_modules/flatbuffers": { "version": "25.9.23", "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz", @@ -3192,12 +2838,12 @@ } }, "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">= 0.8" } }, "node_modules/fs-extra": { @@ -3284,19 +2930,6 @@ "node": ">= 0.4" } }, - "node_modules/get-tsconfig": { - "version": "4.13.7", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", - "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, "node_modules/gitnexus-shared": { "resolved": "../gitnexus-shared", "link": true @@ -3512,15 +3145,19 @@ } }, "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", "license": "MIT", "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" + "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/ignore": { @@ -4031,63 +3668,49 @@ } }, "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">= 0.8" } }, "node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", "license": "MIT", + "engines": { + "node": ">=18" + }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "license": "MIT", "engines": { "node": ">= 0.6" } }, "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", "license": "MIT", "dependencies": { - "mime-db": "1.52.0" + "mime-db": "^1.54.0" }, "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/minimatch": { @@ -4170,9 +3793,9 @@ } }, "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -4376,12 +3999,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/path-to-regexp": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", - "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", - "license": "MIT" - }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -4634,22 +4251,6 @@ "node": ">= 0.10" } }, - "node_modules/raw-body/node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -4692,16 +4293,6 @@ "node": ">=0.10.0" } }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, "node_modules/rolldown": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz", @@ -4762,26 +4353,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/safe-stable-stringify": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", @@ -4826,44 +4397,31 @@ } }, "node_modules/send": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", - "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", "license": "MIT", "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.1", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "~2.4.1", - "range-parser": "~1.2.1", - "statuses": "~2.0.2" + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" }, "engines": { - "node": ">= 0.8.0" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/send/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/send/node_modules/debug/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, "node_modules/serialize-error": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-8.1.0.tgz", @@ -4880,18 +4438,22 @@ } }, "node_modules/serve-static": { - "version": "1.16.3", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", - "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", "license": "MIT", "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "~0.19.1" + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" }, "engines": { - "node": ">= 0.8.0" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/setprototypeof": { @@ -5492,14 +5054,13 @@ "optional": true }, "node_modules/tsx": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", - "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.1.tgz", + "integrity": "sha512-5QE2Q04cN1u0993w0LT5rPw3faZqZU1fFn1mGE0pV53N1Dn7c+QFFxQu1mBeSgeOXwFyTicZw02wVgp3Tb5cAQ==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "~0.27.0", - "get-tsconfig": "^4.7.5" + "esbuild": "~0.27.0" }, "bin": { "tsx": "dist/cli.mjs" @@ -5524,16 +5085,34 @@ } }, "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "license": "MIT", "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/typescript": { @@ -5551,9 +5130,9 @@ } }, "node_modules/undici-types": { - "version": "7.19.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", - "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.21.0.tgz", + "integrity": "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ==", "license": "MIT" }, "node_modules/universalify": { @@ -5580,15 +5159,6 @@ "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", "license": "MIT" }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/uuid": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", diff --git a/gitnexus/package.json b/gitnexus/package.json index 7447961ba..3863a0457 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -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", @@ -100,7 +100,7 @@ "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", diff --git a/gitnexus/src/cli/eval-server.ts b/gitnexus/src/cli/eval-server.ts index 0819c33e8..8735abbef 100644 --- a/gitnexus/src/cli/eval-server.ts +++ b/gitnexus/src/cli/eval-server.ts @@ -14,9 +14,14 @@ * Agent bash cmd → curl localhost:PORT/tool/query → eval-server → LocalBackend → format → text * * Usage: - * gitnexus eval-server # default port 4848 - * gitnexus eval-server --port 4848 # explicit port - * gitnexus eval-server --idle-timeout 300 # auto-shutdown after 300s idle + * gitnexus eval-server # default port 4848, binds 127.0.0.1 + * gitnexus eval-server --port 4848 # explicit port + * gitnexus eval-server --host 0.0.0.0 # reachable from other VMs / containers + * gitnexus eval-server --idle-timeout 300 # auto-shutdown after 300s idle + * + * READY signal format: GITNEXUS_EVAL_SERVER_READY:: + * IPv4: GITNEXUS_EVAL_SERVER_READY:127.0.0.1:4848 + * IPv6: GITNEXUS_EVAL_SERVER_READY:[::1]:4848 * * API: * POST /tool/:name — Call a tool. Body is JSON arguments. Returns formatted text. @@ -25,16 +30,28 @@ */ import http from 'http'; +import { isIPv4, isIPv6 } from 'node:net'; import { writeSync } from 'node:fs'; import { LocalBackend } from '../mcp/local/local-backend.js'; import { logger } from '../core/logger.js'; -import { cliInfo, cliWarn } from './cli-message.js'; +import { cliInfo, cliWarn, cliError } from './cli-message.js'; export interface EvalServerOptions { port?: string; + host?: string; idleTimeout?: string; } +/** + * Validate the --host value. Accepts IPv4, IPv6, or "localhost". + * Returns the normalised host string, or null if invalid. + */ +export function validateHost(raw: string): string | null { + if (raw === 'localhost') return '127.0.0.1'; + if (isIPv4(raw) || isIPv6(raw)) return raw; + return null; +} + // ─── Text Formatters ────────────────────────────────────────────────── // Convert structured JSON results into compact, LLM-friendly text. // Design: minimize tokens, maximize actionability. @@ -330,6 +347,22 @@ export async function evalServerCommand(options?: EvalServerOptions): Promise { + server.on('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'EADDRINUSE') { + cliError( + `\nGitNexus eval-server failed to start:\n` + + ` Port ${port} is already in use.\n\n` + + ` Either:\n` + + ` 1. Stop the process already using port ${port}\n` + + ` 2. Use a different port: gitnexus eval-server --port 4849\n`, + { code: err.code, port, host }, + ); + } else if (err.code === 'EADDRNOTAVAIL') { + const isIPv6Host = isIPv6(host); + 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` + + ` 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`) + + ` Common fixes:\n` + + ` gitnexus eval-server --host 127.0.0.1 (loopback, this machine only)\n` + + ` gitnexus eval-server --host 0.0.0.0 (all interfaces, reachable from other VMs)\n`, + { code: err.code, port, host }, + ); + } else if (err.code === 'EACCES') { + cliError( + `\nGitNexus eval-server failed to start:\n` + + ` Permission denied binding to port ${port}.\n\n` + + ` Ports below 1024 require elevated privileges.\n` + + ` Use a port above 1024: gitnexus eval-server --port 4848\n`, + { code: err.code, port, host }, + ); + } else { + cliError(`\nGitNexus eval-server failed to start:\n ${err.message}\n`, { + code: err.code, + port, + host, + }); + } + process.exit(1); + }); + + server.listen(port, host, () => { // 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. + const addr = server.address(); + const boundPort = typeof addr === 'object' && addr !== null ? addr.port : port; + const displayHost = host.includes(':') ? `[${host}]` : host; const bannerLines = [ - `GitNexus eval-server: listening on http://127.0.0.1:${port}`, + `GitNexus eval-server: listening on http://${displayHost}:${boundPort}`, ` POST /tool/query — search execution flows`, ` POST /tool/context — 360-degree symbol view`, ` POST /tool/impact — blast radius analysis`, @@ -443,8 +523,8 @@ export async function evalServerCommand(options?: EvalServerOptions): Promise 0 ? idleTimeoutSec : undefined, endpoints: [ 'POST /tool/query', @@ -457,7 +537,8 @@ export async function evalServerCommand(options?: EvalServerOptions): Promise', 'Port number', '4848') + .option( + '--host ', + 'Bind address (default: 127.0.0.1, use 0.0.0.0 to expose to all interfaces)', + ) .option('--idle-timeout ', 'Auto-shutdown after N seconds idle (0 = disabled)', '0') .action(createLazyAction(() => import('./eval-server.js'), 'evalServerCommand')); diff --git a/gitnexus/src/cli/setup.ts b/gitnexus/src/cli/setup.ts index 3e7cbad8f..fe9d86f52 100644 --- a/gitnexus/src/cli/setup.ts +++ b/gitnexus/src/cli/setup.ts @@ -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; diff --git a/gitnexus/src/core/ingestion/languages/cpp/captures.ts b/gitnexus/src/core/ingestion/languages/cpp/captures.ts index 8b1e120f2..4e4cb4dcf 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/captures.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/captures.ts @@ -1,4 +1,4 @@ -import type { Capture, CaptureMatch } from 'gitnexus-shared'; +import type { Capture, CaptureMatch, ParameterTypeClass } from 'gitnexus-shared'; import { findNodeAtRange, nodeToCapture, @@ -9,7 +9,11 @@ import { getCppParser, getCppScopeQuery } from './query.js'; import { getTreeSitterBufferSize } from '../../constants.js'; import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; import { splitCppInclude, splitCppUsingDecl } from './import-decomposer.js'; -import { computeCppDeclarationArity, computeCppCallArity } from './arity-metadata.js'; +import { + classifyCppParameterType, + computeCppDeclarationArity, + computeCppCallArity, +} from './arity-metadata.js'; import { markCppAnonymousNamespaceRange, markFileLocal } from './file-local-linkage.js'; import { markCppDependentBase } from './two-phase-lookup.js'; import { markCppAdlSiteArgs, markCppAdlSiteNoAdl, type CppAdlArgInfo } from './adl.js'; @@ -217,6 +221,14 @@ export function emitCppScopeCaptures( JSON.stringify(argTypes), ); } + const argTypeClasses = inferCppCallArgTypeClasses(cNode); + if (argTypeClasses !== undefined && argTypeClasses.length > 0) { + grouped['@reference.parameter-type-classes'] = syntheticCapture( + '@reference.parameter-type-classes', + cNode, + JSON.stringify(argTypeClasses), + ); + } } } @@ -683,6 +695,35 @@ function inferCppCallArgTypes(node: SyntaxNode): string[] | undefined { return types.length > 0 ? types : undefined; } +function inferCppCallArgTypeClasses(node: SyntaxNode): ParameterTypeClass[] | undefined { + const argList = node.childForFieldName('arguments'); + if (argList === null) return undefined; + + const classes: ParameterTypeClass[] = []; + for (let i = 0; i < argList.childCount; i++) { + const child = argList.child(i); + if (child === null) continue; + if (child.type === ',' || child.type === '(' || child.type === ')') continue; + const litType = inferCppLiteralType(child); + if (litType !== '') { + classes.push(valueTypeClass(litType)); + } else if (child.type === 'identifier') { + classes.push(lookupDeclaredTypeClassForIdentifier(child)); + } else { + classes.push(unknownTypeClass('unknown')); + } + } + return classes.length > 0 ? classes : undefined; +} + +function valueTypeClass(base: string): ParameterTypeClass { + return { base, cv: 'none', indirection: 'value', pointerDepth: 0 }; +} + +function unknownTypeClass(base: string): ParameterTypeClass { + return { base, cv: 'unknown', indirection: 'unknown', pointerDepth: 0 }; +} + /** * Infer the canonical type name of a C++ literal AST node. * Returns empty string for non-literal / unknown nodes. @@ -750,6 +791,9 @@ function lookupDeclaredTypeForIdentifier(identNode: SyntaxNode): string { } if (scope === null) return ''; + const paramType = lookupFunctionParameterType(scope, varName); + if (paramType !== '') return paramType; + // Scan declarations in the scope for a matching variable name for (let i = 0; i < scope.childCount; i++) { const stmt = scope.child(i); @@ -763,18 +807,118 @@ function lookupDeclaredTypeForIdentifier(identNode: SyntaxNode): string { // Check init_declarator children for the variable name const declarator = stmt.childForFieldName('declarator'); if (declarator === null) continue; - if (declarator.type === 'init_declarator') { - const nameChild = declarator.childForFieldName('declarator'); - if (nameChild !== null && nameChild.text === varName) { - return normalizeCppTypeText(typeNode.text); - } - } else if (declarator.text === varName) { + const nameChild = declaredNameNode(declarator); + if (nameChild !== null && extractDeclaratorLeafName(nameChild) === varName) { return normalizeCppTypeText(typeNode.text); } } return ''; } +function lookupDeclaredTypeClassForIdentifier(identNode: SyntaxNode): ParameterTypeClass { + 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 unknownTypeClass('unknown'); + + const paramTypeClass = lookupFunctionParameterTypeClass(scope, varName, identNode); + if (paramTypeClass !== undefined) return paramTypeClass; + + 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'); + if (typeNode === null) continue; + if (typeNode.type === 'placeholder_type_specifier') continue; + + const declarator = stmt.childForFieldName('declarator'); + if (declarator === null) continue; + const nameChild = declaredNameNode(declarator); + if (nameChild === null || extractDeclaratorLeafName(nameChild) !== varName) continue; + + const typeClass = classifyCppParameterType( + typeNode.text, + nameChild.text, + stmt.text.replace(/;\s*$/, ''), + ); + if (isKnownEnumName(identNode, typeClass.base)) { + return { ...typeClass, base: `enum:${typeClass.base}` }; + } + return typeClass; + } + return unknownTypeClass('unknown'); +} + +function lookupFunctionParameterType(scope: SyntaxNode, varName: string): string { + const param = findEnclosingFunctionParameter(scope, varName); + if (param === null) return ''; + const typeNode = param.childForFieldName('type'); + if (typeNode === null) return ''; + return normalizeCppTypeText(typeNode.text); +} + +function lookupFunctionParameterTypeClass( + scope: SyntaxNode, + varName: string, + identNode: SyntaxNode, +): ParameterTypeClass | undefined { + const param = findEnclosingFunctionParameter(scope, varName); + if (param === null) return undefined; + const typeNode = param.childForFieldName('type'); + if (typeNode === null) return undefined; + const declarator = param.childForFieldName('declarator'); + if (declarator === null) return undefined; + const typeClass = classifyCppParameterType(typeNode.text, declarator.text, param.text); + if (isKnownEnumName(identNode, typeClass.base)) { + return { ...typeClass, base: `enum:${typeClass.base}` }; + } + return typeClass; +} + +function findEnclosingFunctionParameter(scope: SyntaxNode, varName: string): SyntaxNode | null { + 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) { + 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'); + if (declarator !== null && extractDeclaratorLeafName(declarator) === varName) { + return param; + } + } + } + return null; + } + node = node.parent; + } + return null; +} + +function declaredNameNode(declarator: SyntaxNode): SyntaxNode | null { + if (declarator.type !== 'init_declarator') return declarator; + for (let i = 0; i < declarator.namedChildCount; i++) { + const child = declarator.namedChild(i); + if (child === null) continue; + if (child.type === 'identifier') return child; + if (child.type.endsWith('_declarator')) return child; + } + return declarator.childForFieldName('declarator'); +} + /** Normalize a type-specifier text for argument type matching. * Strips qualifiers (const, volatile), namespace prefixes (std::), * and pointer/reference markers. */ @@ -786,6 +930,25 @@ function normalizeCppTypeText(text: string): string { return t; } +function isKnownEnumName(node: SyntaxNode, typeName: string): boolean { + if (typeName === '' || typeName === 'unknown') return false; + let root: SyntaxNode = node; + while (root.parent !== null) root = root.parent; + const stack: SyntaxNode[] = [root]; + while (stack.length > 0) { + const cur = stack.pop()!; + if (cur.type === 'enum_specifier') { + const name = cur.childForFieldName('name'); + if (name?.text === typeName) return true; + } + for (let i = 0; i < cur.childCount; i++) { + const child = cur.child(i); + if (child !== null) stack.push(child); + } + } + return false; +} + /** * Detect whether a `namespace_definition` AST node is inline. * Tree-sitter-cpp exposes the `inline` keyword as an anonymous child @@ -1247,7 +1410,9 @@ function extractDeclaratorLeafName(node: SyntaxNode): string | null { const next = cur.childForFieldName('declarator') ?? // parenthesized_declarator: single named child - (cur.type === 'parenthesized_declarator' ? cur.namedChild(0) : null); + (cur.type === 'parenthesized_declarator' || cur.type.endsWith('_declarator') + ? cur.namedChild(0) + : null); if (next === null) return null; cur = next; } diff --git a/gitnexus/src/core/ingestion/languages/cpp/constraint-filter.ts b/gitnexus/src/core/ingestion/languages/cpp/constraint-filter.ts index a5f760daa..3364f6cdb 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/constraint-filter.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/constraint-filter.ts @@ -20,20 +20,27 @@ * NOT: flip compatible↔incompatible; pass through unknown. */ -import type { ArityVerdict, Callsite, ConstraintContext, SymbolDefinition } from 'gitnexus-shared'; +import type { + ArityVerdict, + Callsite, + ConstraintContext, + ParameterTypeClass, + SymbolDefinition, +} from 'gitnexus-shared'; import { classifyType, type TypeClass } from './type-classifier.js'; import type { ConstraintExpr, CppConstraintPayload } from './constraint-extractor.js'; -type AtomicEvaluator = (argClasses: readonly TypeClass[]) => ArityVerdict; +interface ConstraintArgClass { + readonly typeClass: TypeClass; + readonly shape?: ParameterTypeClass; +} + +type AtomicEvaluator = (args: readonly ConstraintArgClass[]) => ArityVerdict; /** - * Curated Tier-A predicate registry — the four canonical - * `` variable templates whose truth tables are closed-form - * over our coarse `TypeClass` enum. - * - * Deferred predicates that need a cv/ref/pointer sidecar on - * `normalizeCppParamType` (today the normalizer strips those markers - * before storage) live in #1579 as one-line follow-up adds. + * Curated Tier-A predicate registry. Predicates that depend on pointer, + * reference, or cv shape consult `ConstraintContext.argumentTypeClasses`. + * Missing or unsupported shape returns 'unknown' to preserve monotonicity. */ // ISO `` treats `bool`, `char`, and the signed/unsigned char // variants as integral types (§21.3.4 Table 48), so `is_integral_v` @@ -46,30 +53,135 @@ function isIntegralClass(c: TypeClass | undefined): boolean { } const REGISTRY = new Map([ - ['is_integral_v', (cls) => verdictFromBool(isIntegralClass(cls[0]), cls)], - ['is_floating_point_v', (cls) => verdictFromBool(cls[0] === 'floating', cls)], + [ + 'is_void_v', + (args) => unaryVerdict(args, (arg) => isPlainValue(arg) && arg.typeClass === 'void'), + ], + [ + 'is_integral_v', + (args) => unaryVerdict(args, (arg) => isPlainValue(arg) && isIntegralClass(arg.typeClass)), + ], + [ + 'is_floating_point_v', + (args) => unaryVerdict(args, (arg) => isPlainValue(arg) && arg.typeClass === 'floating'), + ], [ 'is_arithmetic_v', - (cls) => verdictFromBool(isIntegralClass(cls[0]) || cls[0] === 'floating', cls), + (args) => + unaryVerdict( + args, + (arg) => + isPlainValue(arg) && (isIntegralClass(arg.typeClass) || arg.typeClass === 'floating'), + ), + ], + [ + 'is_enum_v', + (args) => unaryVerdict(args, (arg) => isPlainValue(arg) && arg.typeClass === 'enum'), + ], + [ + 'is_class_v', + (args) => unaryVerdict(args, (arg) => isPlainValue(arg) && arg.typeClass === 'class'), + ], + [ + 'is_pointer_v', + (args) => + unaryShapeVerdict(args, (shape) => shape.indirection === 'pointer' && shape.pointerDepth > 0), + ], + [ + 'is_reference_v', + (args) => + unaryShapeVerdict( + args, + (shape) => shape.indirection === 'lvalue-ref' || shape.indirection === 'rvalue-ref', + ), + ], + [ + 'is_const_v', + (args) => + unaryShapeVerdict(args, (shape) => shape.cv === 'const' || shape.cv === 'const volatile', { + requireTopLevelCv: true, + }), + ], + [ + 'is_volatile_v', + (args) => + unaryShapeVerdict(args, (shape) => shape.cv === 'volatile' || shape.cv === 'const volatile', { + requireTopLevelCv: true, + }), ], - // NOTE: cv-qualifiers are stripped by `normalizeCppParamType` before the - // type token reaches `classifyType`, so `is_same_v` returns - // `'compatible'` instead of the ISO-correct `false`. Tracked under the - // cv-sidecar refactor in #1579's "Out of scope" list; until that lands - // this approximation matches the common `is_same_v` - // dispatch idiom and silently degrades on cv-distinct compares. [ 'is_same_v', - (cls) => { - if (cls.length < 2 || cls[0] === 'unknown' || cls[1] === 'unknown') return 'unknown'; - return cls[0] === cls[1] ? 'compatible' : 'incompatible'; + (args) => { + if (args.length < 2 || args[0].typeClass === 'unknown' || args[1].typeClass === 'unknown') { + return 'unknown'; + } + return args[0].typeClass === args[1].typeClass ? 'compatible' : 'incompatible'; }, ], ]); -function verdictFromBool(predicate: boolean, cls: readonly TypeClass[]): ArityVerdict { - if (cls[0] === 'unknown') return 'unknown'; - return predicate ? 'compatible' : 'incompatible'; +function unaryVerdict( + args: readonly ConstraintArgClass[], + predicate: (arg: ConstraintArgClass) => boolean, +): ArityVerdict { + const arg = args[0]; + if (arg === undefined || arg.typeClass === 'unknown') return 'unknown'; + return predicate(arg) ? 'compatible' : 'incompatible'; +} + +function unaryShapeVerdict( + args: readonly ConstraintArgClass[], + predicate: (shape: ParameterTypeClass) => boolean, + options: { readonly requireTopLevelCv?: boolean } = {}, +): ArityVerdict { + const arg = args[0]; + if (arg === undefined || arg.typeClass === 'unknown') return 'unknown'; + const shape = arg.shape; + if (shape === undefined || shape.indirection === 'unknown' || shape.cv === 'unknown') { + return 'unknown'; + } + if (options.requireTopLevelCv === true && shape.indirection === 'pointer') { + return 'unknown'; + } + return predicate(shape) ? 'compatible' : 'incompatible'; +} + +function isPlainValue(arg: ConstraintArgClass): boolean { + const shape = arg.shape; + if (shape === undefined) return true; + return shape.indirection === 'value'; +} + +function classifyConstraintArg( + token: string | undefined, + shape?: ParameterTypeClass, +): ConstraintArgClass { + if (shape !== undefined && shape.base.startsWith('enum:')) { + return { typeClass: 'enum', shape }; + } + const typeClass = token === undefined || token === '' ? 'unknown' : classifyType(token); + return { typeClass, ...(shape !== undefined ? { shape } : {}) }; +} + +function tokenForArg(ctx: ConstraintContext, argIdx: number): string | undefined { + const shape = ctx.argumentTypeClasses?.[argIdx]; + if (shape?.base.startsWith('enum:')) return shape.base; + return ctx.argumentTypes?.[argIdx]; +} + +function shapeForTemplateParam( + ctx: ConstraintContext, + paramName: string, + argIdx: number, + def?: SymbolDefinition, +): ParameterTypeClass | undefined { + const argShape = ctx.argumentTypeClasses?.[argIdx]; + if (argShape === undefined) return undefined; + + const paramShape = def?.parameterTypeClasses?.[argIdx]; + if (paramShape === undefined) return argShape; + if (paramShape.base === paramName && paramShape.indirection === 'value') return argShape; + return undefined; } /** Public surface — registered as `ScopeResolver.constraintCompatibility`. */ @@ -80,13 +192,14 @@ export function cppConstraintCompatibility( ): ArityVerdict { const payload = def.templateConstraints as CppConstraintPayload | undefined; if (payload === undefined) return 'unknown'; - return evaluate(payload.expr, payload, ctx); + return evaluate(payload.expr, payload, ctx, def); } function evaluate( expr: ConstraintExpr, payload: CppConstraintPayload, ctx: ConstraintContext, + def?: SymbolDefinition, ): ArityVerdict { switch (expr.kind) { case 'unknown': @@ -96,17 +209,18 @@ function evaluate( if (evaluator === undefined) return 'unknown'; const classes = expr.args.map((paramName) => { const argIdx = payload.paramArgIndex[paramName]; - if (argIdx === undefined) return 'unknown' as TypeClass; - const token = ctx.argumentTypes?.[argIdx]; - if (token === undefined || token === '') return 'unknown' as TypeClass; - return classifyType(token); + if (argIdx === undefined) return { typeClass: 'unknown' as TypeClass }; + return classifyConstraintArg( + tokenForArg(ctx, argIdx), + shapeForTemplateParam(ctx, paramName, argIdx, def), + ); }); return evaluator(classes); } case 'and': { let result: ArityVerdict = 'compatible'; for (const child of expr.children) { - const v = evaluate(child, payload, ctx); + const v = evaluate(child, payload, ctx, def); if (v === 'incompatible') return 'incompatible'; if (v === 'unknown') result = 'unknown'; } @@ -115,14 +229,14 @@ function evaluate( case 'or': { let result: ArityVerdict = 'incompatible'; for (const child of expr.children) { - const v = evaluate(child, payload, ctx); + const v = evaluate(child, payload, ctx, def); if (v === 'compatible') return 'compatible'; if (v === 'unknown') result = 'unknown'; } return result; } case 'not': { - const v = evaluate(expr.child, payload, ctx); + const v = evaluate(expr.child, payload, ctx, def); if (v === 'compatible') return 'incompatible'; if (v === 'incompatible') return 'compatible'; return 'unknown'; diff --git a/gitnexus/src/core/ingestion/languages/cpp/type-classifier.ts b/gitnexus/src/core/ingestion/languages/cpp/type-classifier.ts index d26435abf..88a120783 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/type-classifier.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/type-classifier.ts @@ -7,11 +7,10 @@ * the call-site inference in `captures.ts`) to one of the categories * the `` predicate registry uses for SFINAE filtering. * - * Intentionally coarse: cv / pointer / reference qualifiers are stripped - * upstream by `normalizeCppParamType`. Tier-A predicates - * (`is_integral_v`, `is_floating_point_v`, `is_arithmetic_v`, `is_same_v`) - * are insensitive to those modifiers per ISO `` semantics - * ("including any cv-qualified variants"). + * `argumentTypes` remain normalized for overload narrowing, while + * constraint predicates that need cv/ref/pointer shape read the parallel + * `argumentTypeClasses` sidecar. Unknown shapes must stay unknown rather + * than being guessed as incompatible. */ export type TypeClass = @@ -21,7 +20,11 @@ export type TypeClass = | 'char' | 'string' | 'null' + | 'void' + | 'enum' | 'class' + | 'pointer' + | 'reference' | 'unknown'; /** @@ -29,13 +32,17 @@ export type TypeClass = * inference table in `captures.ts:inferCppLiteralType` plus the std:: * normalization in `arity-metadata.ts:normalizeCppParamType`. * - * Caller note: token must already be normalized (no `const`, no `&` / `*`, - * no `std::` prefix). Tokens passed via `ConstraintContext.argumentTypes` - * coming from `inferCppCallArgTypes` satisfy this. + * Caller note: token should be normalized for overload matching. Enum + * tokens produced by the C++ adapter use the internal `enum:` + * prefix so `is_enum_v` does not have to guess that every user token is + * class-like. */ export function classifyType(token: string): TypeClass { if (token.length === 0) return 'unknown'; + if (token.startsWith('enum:')) return 'enum'; switch (token) { + case 'void': + return 'void'; case 'int': return 'integral'; case 'double': diff --git a/gitnexus/src/core/ingestion/languages/java.ts b/gitnexus/src/core/ingestion/languages/java.ts index c70eacb10..20755d7cd 100644 --- a/gitnexus/src/core/ingestion/languages/java.ts +++ b/gitnexus/src/core/ingestion/languages/java.ts @@ -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, }); diff --git a/gitnexus/src/core/ingestion/languages/javascript/arity.ts b/gitnexus/src/core/ingestion/languages/javascript/arity.ts new file mode 100644 index 000000000..f3702a4b2 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/javascript/arity.ts @@ -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'; diff --git a/gitnexus/src/core/ingestion/languages/javascript/captures.ts b/gitnexus/src/core/ingestion/languages/javascript/captures.ts new file mode 100644 index 000000000..a04844059 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/javascript/captures.ts @@ -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['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 = {}; + 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; +} diff --git a/gitnexus/src/core/ingestion/languages/javascript/import-target.ts b/gitnexus/src/core/ingestion/languages/javascript/import-target.ts new file mode 100644 index 000000000..bfdfe9951 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/javascript/import-target.ts @@ -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; + readonly allFilePaths: Set; + readonly allFileList: readonly string[]; + readonly normalizedFileList: readonly string[]; + readonly resolveCache: Map; +}; + +/** + * 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, + 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); + }; +} diff --git a/gitnexus/src/core/ingestion/languages/javascript/index.ts b/gitnexus/src/core/ingestion/languages/javascript/index.ts new file mode 100644 index 000000000..e887bab51 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/javascript/index.ts @@ -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'; diff --git a/gitnexus/src/core/ingestion/languages/javascript/interpret.ts b/gitnexus/src/core/ingestion/languages/javascript/interpret.ts new file mode 100644 index 000000000..7d3688fa9 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/javascript/interpret.ts @@ -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); +} diff --git a/gitnexus/src/core/ingestion/languages/javascript/merge-bindings.ts b/gitnexus/src/core/ingestion/languages/javascript/merge-bindings.ts new file mode 100644 index 000000000..271c38684 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/javascript/merge-bindings.ts @@ -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); +} diff --git a/gitnexus/src/core/ingestion/languages/javascript/query.ts b/gitnexus/src/core/ingestion/languages/javascript/query.ts new file mode 100644 index 000000000..20cfc4f2e --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/javascript/query.ts @@ -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[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(...) 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(...) +(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 = ` +;; +((jsx_self_closing_element + name: (identifier) @reference.name) @reference.call.free + (#match? @reference.name "^[A-Z]")) + +;; ... +((jsx_opening_element + name: (identifier) @reference.name) @reference.call.free + (#match? @reference.name "^[A-Z]")) + +;; +(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; +} diff --git a/gitnexus/src/core/ingestion/languages/javascript/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/javascript/scope-resolver.ts new file mode 100644 index 000000000..1536e793e --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/javascript/scope-resolver.ts @@ -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 }; diff --git a/gitnexus/src/core/ingestion/languages/javascript/simple-hooks.ts b/gitnexus/src/core/ingestion/languages/javascript/simple-hooks.ts new file mode 100644 index 000000000..c0947ba5a --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/javascript/simple-hooks.ts @@ -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); +} diff --git a/gitnexus/src/core/ingestion/languages/typescript.ts b/gitnexus/src/core/ingestion/languages/typescript.ts index c3fad3518..f597396b6 100644 --- a/gitnexus/src/core/ingestion/languages/typescript.ts +++ b/gitnexus/src/core/ingestion/languages/typescript.ts @@ -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, }); diff --git a/gitnexus/src/core/ingestion/languages/typescript/simple-hooks.ts b/gitnexus/src/core/ingestion/languages/typescript/simple-hooks.ts index 1dbb03dc7..f8f7302fa 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/simple-hooks.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/simple-hooks.ts @@ -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'][] diff --git a/gitnexus/src/core/ingestion/model/field-registry.ts b/gitnexus/src/core/ingestion/model/field-registry.ts index 62376c889..c45fb9cb5 100644 --- a/gitnexus/src/core/ingestion/model/field-registry.ts +++ b/gitnexus/src/core/ingestion/model/field-registry.ts @@ -2,18 +2,35 @@ * Field Registry * * Owner-scoped field/property index extracted from SymbolTable. - * Stores Property symbols keyed by `ownerNodeId\0fieldName` for O(1) lookup. + * Stores Property / Variable / Const / Static symbols keyed by + * `ownerNodeId\0fieldName` for O(1) lookup. Supports multiple defs + * under the same (owner, name) — e.g. legacy Property plus a + * scope-resolution Variable reconciliation entry. */ import type { SymbolDefinition } from 'gitnexus-shared'; +const EMPTY: readonly SymbolDefinition[] = Object.freeze([]); + // --------------------------------------------------------------------------- // Public read-only interface // --------------------------------------------------------------------------- export interface FieldRegistry { - /** Look up a field/property by its owning class nodeId and field name. */ + /** + * First field registered under `(ownerNodeId, fieldName)`, if any. + * Registration order is first-wins: when a Property and a Variable share + * an `(owner, simpleName)` key, the earlier `register(...)` call's def is + * returned. Prefer `lookupAllByOwner` when overloads or duplicate-kind + * entries under the same name must all be visible. + */ lookupFieldByOwner(ownerNodeId: string, fieldName: string): SymbolDefinition | undefined; + + /** + * Every field registered under `(ownerNodeId, fieldName)` in registration + * order. Returns `[]` on miss. + */ + lookupAllByOwner(ownerNodeId: string, fieldName: string): readonly SymbolDefinition[]; } // --------------------------------------------------------------------------- @@ -21,7 +38,7 @@ export interface FieldRegistry { // --------------------------------------------------------------------------- export interface MutableFieldRegistry extends FieldRegistry { - /** Register a field/property under its owner. */ + /** Register a field under its owner. Appends when the key already exists. */ register(ownerNodeId: string, fieldName: string, def: SymbolDefinition): void; /** Clear all entries. */ clear(): void; @@ -32,22 +49,36 @@ export interface MutableFieldRegistry extends FieldRegistry { // --------------------------------------------------------------------------- export const createFieldRegistry = (): MutableFieldRegistry => { - const fieldByOwner = new Map(); + const fieldByOwner = new Map(); + + const lookupAllByOwner = ( + ownerNodeId: string, + fieldName: string, + ): readonly SymbolDefinition[] => { + return fieldByOwner.get(`${ownerNodeId}\0${fieldName}`) ?? EMPTY; + }; const lookupFieldByOwner = ( ownerNodeId: string, fieldName: string, ): SymbolDefinition | undefined => { - return fieldByOwner.get(`${ownerNodeId}\0${fieldName}`); + const pool = lookupAllByOwner(ownerNodeId, fieldName); + return pool.length === 0 ? undefined : pool[0]; }; const register = (ownerNodeId: string, fieldName: string, def: SymbolDefinition): void => { - fieldByOwner.set(`${ownerNodeId}\0${fieldName}`, def); + const key = `${ownerNodeId}\0${fieldName}`; + const existing = fieldByOwner.get(key); + if (existing) { + existing.push(def); + } else { + fieldByOwner.set(key, [def]); + } }; const clear = (): void => { fieldByOwner.clear(); }; - return { lookupFieldByOwner, register, clear }; + return { lookupFieldByOwner, lookupAllByOwner, register, clear }; }; diff --git a/gitnexus/src/core/ingestion/model/owned-members-lookup.ts b/gitnexus/src/core/ingestion/model/owned-members-lookup.ts new file mode 100644 index 000000000..1ca91db19 --- /dev/null +++ b/gitnexus/src/core/ingestion/model/owned-members-lookup.ts @@ -0,0 +1,45 @@ +/** + * Owner-keyed member lookup for Step 2 (RFC #909 / PR #1656). + * + * Merges MethodRegistry + FieldRegistry hits for `(ownerDefId, memberName)` + * in O(1) map time per registry — no `defs.byId` scan. Callers that omit + * this helper and leave `ownedMembersByOwner` unset fall back to an O(|defs|) + * compatibility scan inside `lookupCore.collectOwnedMembers`. + */ + +import type { DefId, SymbolDefinition } from 'gitnexus-shared'; +import type { SemanticModel } from './semantic-model.js'; + +const EMPTY: readonly SymbolDefinition[] = Object.freeze([]); + +/** + * Production hook for `RegistryContext.ownedMembersByOwner`. + * Returns `[]` on miss (authoritative indexed empty) — never `undefined`. + * + * Merges hits from all three owner-keyed registries (methods, fields, + * nested types) under the same `(ownerDefId, memberName)` key. The + * caller's `acceptedKinds` filter in `lookupCore` picks the right subset. + */ +export function lookupOwnedMembersByOwner( + model: Pick, + ownerDefId: DefId, + memberName: string, +): readonly SymbolDefinition[] { + const methods = model.methods.lookupAllByOwner(ownerDefId, memberName); + const fields = model.fields.lookupAllByOwner(ownerDefId, memberName); + const nestedTypes = model.types.lookupAllByOwner(ownerDefId, memberName); + const methodCount = methods.length; + const fieldCount = fields.length; + const typeCount = nestedTypes.length; + const total = methodCount + fieldCount + typeCount; + if (total === 0) return EMPTY; + if (methodCount === total) return methods; + if (fieldCount === total) return fields; + if (typeCount === total) return nestedTypes; + const merged = new Array(total); + let i = 0; + for (let j = 0; j < methodCount; j++) merged[i++] = methods[j]!; + for (let j = 0; j < fieldCount; j++) merged[i++] = fields[j]!; + for (let j = 0; j < typeCount; j++) merged[i++] = nestedTypes[j]!; + return merged; +} diff --git a/gitnexus/src/core/ingestion/model/type-registry.ts b/gitnexus/src/core/ingestion/model/type-registry.ts index 3f9fdc5f4..4dc87e924 100644 --- a/gitnexus/src/core/ingestion/model/type-registry.ts +++ b/gitnexus/src/core/ingestion/model/type-registry.ts @@ -8,6 +8,8 @@ import type { SymbolDefinition } from 'gitnexus-shared'; +const EMPTY: readonly SymbolDefinition[] = Object.freeze([]); + // --------------------------------------------------------------------------- // Public read-only interface // --------------------------------------------------------------------------- @@ -35,6 +37,14 @@ export interface TypeRegistry { * Returned array is a view into the live index — do not mutate. */ lookupImplByName(name: string): readonly SymbolDefinition[]; + + /** + * Look up nested-type defs registered under `(ownerNodeId, simpleName)` + * in registration order. Returns `[]` on miss. Used by Step 2 Receiver/MRO + * resolution when the receiver's owner declares nested classes/structs/ + * enums/typedefs/etc. that the caller's `acceptedKinds` includes. + */ + lookupAllByOwner(ownerNodeId: string, simpleName: string): readonly SymbolDefinition[]; } // --------------------------------------------------------------------------- @@ -46,6 +56,8 @@ export interface MutableTypeRegistry extends TypeRegistry { registerClass(name: string, qualifiedName: string, def: SymbolDefinition): void; /** Register a Rust Impl block by name. */ registerImpl(name: string, def: SymbolDefinition): void; + /** Register a nested type under its owner. Appends when the key already exists. */ + registerByOwner(ownerNodeId: string, simpleName: string, def: SymbolDefinition): void; /** Clear all entries. */ clear(): void; } @@ -58,6 +70,7 @@ export const createTypeRegistry = (): MutableTypeRegistry => { const classByName = new Map(); const classByQualifiedName = new Map(); const implByName = new Map(); + const nestedByOwner = new Map(); const lookupClassByName = (name: string): SymbolDefinition[] => { return classByName.get(name) ?? []; @@ -71,6 +84,13 @@ export const createTypeRegistry = (): MutableTypeRegistry => { return implByName.get(name) ?? []; }; + const lookupAllByOwner = ( + ownerNodeId: string, + simpleName: string, + ): readonly SymbolDefinition[] => { + return nestedByOwner.get(`${ownerNodeId}\0${simpleName}`) ?? EMPTY; + }; + const registerClass = (name: string, qualifiedName: string, def: SymbolDefinition): void => { const existing = classByName.get(name); if (existing) { @@ -96,18 +116,35 @@ export const createTypeRegistry = (): MutableTypeRegistry => { } }; + const registerByOwner = ( + ownerNodeId: string, + simpleName: string, + def: SymbolDefinition, + ): void => { + const key = `${ownerNodeId}\0${simpleName}`; + const existing = nestedByOwner.get(key); + if (existing) { + existing.push(def); + } else { + nestedByOwner.set(key, [def]); + } + }; + const clear = (): void => { classByName.clear(); classByQualifiedName.clear(); implByName.clear(); + nestedByOwner.clear(); }; return { lookupClassByName, lookupClassByQualifiedName, lookupImplByName, + lookupAllByOwner, registerClass, registerImpl, + registerByOwner, clear, }; }; diff --git a/gitnexus/src/core/ingestion/registry-primary-flag.ts b/gitnexus/src/core/ingestion/registry-primary-flag.ts index 869157adb..e050c6553 100644 --- a/gitnexus/src/core/ingestion/registry-primary-flag.ts +++ b/gitnexus/src/core/ingestion/registry-primary-flag.ts @@ -74,6 +74,7 @@ export const MIGRATED_LANGUAGES: ReadonlySet = new Set[2] = { + ...(site.explicitReceiver !== undefined ? { explicitReceiver: site.explicitReceiver } : {}), + }; + const fieldHits = fieldRegistry.lookup(site.name, site.inScope, fieldOpts); if (fieldHits.length > 0) return fieldHits; const methodHits = methodRegistry.lookup(site.name, site.inScope); if (methodHits.length > 0) return methodHits; diff --git a/gitnexus/src/core/ingestion/scope-extractor.ts b/gitnexus/src/core/ingestion/scope-extractor.ts index 661f0336b..746b9d694 100644 --- a/gitnexus/src/core/ingestion/scope-extractor.ts +++ b/gitnexus/src/core/ingestion/scope-extractor.ts @@ -913,6 +913,9 @@ function pass5CollectReferences( const explicitReceiver = extractExplicitReceiver(match); const arity = extractArity(match); const argumentTypes = extractArgumentTypes(match); + const argumentTypeClasses = parseJsonParameterTypeClassesCapture( + match['@reference.parameter-type-classes'], + ); const site: ReferenceSite = { name: nameCap.text, @@ -923,6 +926,7 @@ function pass5CollectReferences( ...(explicitReceiver !== undefined ? { explicitReceiver } : {}), ...(arity !== undefined ? { arity } : {}), ...(argumentTypes !== undefined ? { argumentTypes } : {}), + ...(argumentTypeClasses !== undefined ? { argumentTypeClasses } : {}), }; referenceSites.push(site); } @@ -1040,9 +1044,11 @@ const KNOWN_SUB_TAGS: ReadonlySet = new Set([ '@reference.receiver', '@reference.arity', '@reference.parameter-types', + '@reference.parameter-type-classes', '@declaration.parameter-count', '@declaration.required-parameter-count', '@declaration.parameter-types', + '@declaration.parameter-type-classes', '@declaration.template-constraints', ]); diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts index a3bc1e4ec..f3010abf4 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts @@ -78,6 +78,12 @@ export function emitFreeCallFallback( let emitted = 0; const seen = new Set(); + // Build an O(1) simple-name -> callable defs index over scopes.defs once + // per pass so pickUniqueGlobalCallable doesn't re-scan defs.byId.values() + // per call site. Same name + callable-kind filter that the previous scan + // applied (see pickUniqueGlobalCallable JSDoc). Cost: O(|defs|) once. + const globalCallablesBySimpleName = buildGlobalCallableIndex(scopes); + for (const parsed of parsedFiles) { for (const site of parsed.referenceSites) { if (site.kind !== 'call') continue; @@ -126,6 +132,7 @@ export function emitFreeCallFallback( site.arity, site.argumentTypes, { + argumentTypeClasses: site.argumentTypeClasses, conversionRankFn: options.conversionRankFn, constraintCompatibility: options.constraintCompatibility, }, @@ -190,6 +197,7 @@ export function emitFreeCallFallback( fnDef = ordinary[0]; } else { const narrowed = narrowOverloadCandidates(ordinary, site.arity, site.argumentTypes, { + argumentTypeClasses: site.argumentTypeClasses, conversionRankFn: options.conversionRankFn, constraintCompatibility: options.constraintCompatibility, }); @@ -225,6 +233,7 @@ export function emitFreeCallFallback( push(adl); const narrowed = narrowOverloadCandidates(merged, site.arity, site.argumentTypes, { + argumentTypeClasses: site.argumentTypeClasses, conversionRankFn: options.conversionRankFn, constraintCompatibility: options.constraintCompatibility, }); @@ -254,7 +263,7 @@ export function emitFreeCallFallback( fnDef = pickUniqueGlobalCallable( site.name, model, - scopes, + globalCallablesBySimpleName, parsed.filePath, options.isFileLocalDef, site.arity, @@ -299,10 +308,35 @@ export function emitFreeCallFallback( return emitted; } +/** + * Build a `simpleName -> callable defs` index from `scopes.defs` once per + * pass. Mirrors the filter the old per-site scan applied: Function / + * Method / Constructor, keyed by the last `.`-segment of `qualifiedName` + * (falling back to the qualifiedName itself when undotted). Used by + * `pickUniqueGlobalCallable` so every free-call fallback site is O(1) + * instead of O(|defs|). + */ +function buildGlobalCallableIndex( + scopes: ScopeResolutionIndexes, +): ReadonlyMap { + const out = new Map(); + for (const def of scopes.defs.byId.values()) { + if (def.type !== 'Function' && def.type !== 'Method' && def.type !== 'Constructor') continue; + const qualified = def.qualifiedName; + if (qualified === undefined || qualified.length === 0) continue; + const dot = qualified.lastIndexOf('.'); + const simple = dot === -1 ? qualified : qualified.slice(dot + 1); + const bucket = out.get(simple); + if (bucket) bucket.push(def); + else out.set(simple, [def]); + } + return out; +} + function pickUniqueGlobalCallable( name: string, model: SemanticModel, - scopes: ScopeResolutionIndexes, + globalCallablesBySimpleName: ReadonlyMap, callerFilePath: string, isFileLocalDef?: (def: SymbolDefinition) => boolean, callArity?: number, @@ -312,10 +346,7 @@ function pickUniqueGlobalCallable( ): SymbolDefinition | undefined { const scopeDefs: SymbolDefinition[] = []; const scopeSeen = new Set(); - for (const def of scopes.defs.byId.values()) { - const simple = def.qualifiedName?.split('.').pop() ?? def.qualifiedName; - if (simple !== name) continue; - if (def.type !== 'Function' && def.type !== 'Method' && def.type !== 'Constructor') continue; + for (const def of globalCallablesBySimpleName.get(name) ?? []) { // Skip file-local defs (e.g. C `static` functions) that live in a // different file from the caller — they are logically invisible. if (isFileLocalDef !== undefined && def.filePath !== callerFilePath && isFileLocalDef(def)) { @@ -462,6 +493,7 @@ export function pickImplicitThisOverload( readonly name: string; readonly arity?: number; readonly argumentTypes?: readonly string[]; + readonly argumentTypeClasses?: readonly import('gitnexus-shared').ParameterTypeClass[]; }, scopes: ScopeResolutionIndexes, workspaceIndex: WorkspaceResolutionIndex, @@ -498,6 +530,7 @@ export function pickImplicitThisOverload( // disambiguating signal) leaves the call unresolved rather than // routing to an arbitrary first overload by registration order. const candidates = narrowOverloadCandidates(overloads, site.arity, site.argumentTypes, { + argumentTypeClasses: site.argumentTypeClasses, conversionRankFn: hookCtx?.conversionRankFn, constraintCompatibility: hookCtx?.constraintCompatibility, }); diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/overload-narrowing.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/overload-narrowing.ts index 564fe2200..b9b452eb7 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/overload-narrowing.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/overload-narrowing.ts @@ -62,6 +62,8 @@ export type ConversionRankFn = (argType: string, paramType: string) => number; * undefined preserves the legacy arity + exact-type behavior. */ export interface OverloadNarrowingHookCtx { + /** Shape-preserving per-argument sidecar aligned with `argTypes`. */ + readonly argumentTypeClasses?: ConstraintContext['argumentTypeClasses']; /** Conversion-rank scoring fallback (step 4b). Engages when the * exact-type filter rejects every candidate. */ readonly conversionRankFn?: ConversionRankFn; @@ -163,7 +165,15 @@ export function narrowOverloadCandidates( // than emitting a wrong edge. if (hookCtx?.constraintCompatibility !== undefined && argCount !== undefined) { const callsite: Callsite = { arity: argCount }; - const ctx: ConstraintContext = argTypes !== undefined ? { argumentTypes: argTypes } : {}; + const ctx: ConstraintContext = + argTypes !== undefined + ? { + argumentTypes: argTypes, + ...(hookCtx.argumentTypeClasses !== undefined + ? { argumentTypeClasses: hookCtx.argumentTypeClasses } + : {}), + } + : {}; result = result.filter((def) => { if (def.templateConstraints === undefined) return true; return hookCtx.constraintCompatibility!(callsite, def, ctx) !== 'incompatible'; diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts index 939f2d88e..430d83c2d 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts @@ -346,6 +346,7 @@ export function emitReceiverBoundCalls( site.arity, site.argumentTypes, { + argumentTypeClasses: site.argumentTypeClasses, conversionRankFn: provider.conversionRankFn, constraintCompatibility: provider.constraintCompatibility, }, @@ -732,6 +733,7 @@ function pickOverload( if (overloads.length === 1) return overloads[0]; const candidates = narrowOverloadCandidates(overloads, site.arity, site.argumentTypes, { + argumentTypeClasses: site.argumentTypeClasses, conversionRankFn: provider.conversionRankFn, constraintCompatibility: provider.constraintCompatibility, }); diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/reconcile-ownership.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/reconcile-ownership.ts index 5c0962848..2ec47d489 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/reconcile-ownership.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/reconcile-ownership.ts @@ -18,8 +18,8 @@ * undefined `ownerId` is reachable via either: * - `model.methods.lookupAllByOwner(ownerId, simpleName)` — if the * def is a Method / Function / Constructor, OR - * - `model.fields.lookupFieldByOwner(ownerId, simpleName)` — if the - * def is a Property / Variable. + * - `model.fields.lookupAllByOwner(ownerId, simpleName)` — if the + * def is a Property / Variable / Const / Static. * * This invariant is the foundation of Contract Invariant I9 * (`contract/scope-resolver.ts`): scope-resolution passes MUST read @@ -45,11 +45,29 @@ import type { ParsedFile } from 'gitnexus-shared'; import type { MutableSemanticModel, SemanticModel } from '../../model/semantic-model.js'; import { simpleQualifiedName } from '../graph-bridge/ids.js'; +const NESTED_TYPE_KINDS = new Set([ + 'Class', + 'Interface', + 'Enum', + 'Struct', + 'Union', + 'Trait', + 'TypeAlias', + 'Typedef', + 'Record', + 'Delegate', + 'Annotation', + 'Template', + 'Namespace', +]); + export interface ReconcileStats { /** Method/Function/Constructor defs registered into MethodRegistry. */ readonly methodsRegistered: number; /** Property/Variable defs registered into FieldRegistry. */ readonly fieldsRegistered: number; + /** Class-like nested type defs registered into TypeRegistry by owner. */ + readonly nestedTypesRegistered: number; /** Defs already present (idempotent skip). */ readonly skippedAlreadyPresent: number; } @@ -60,6 +78,7 @@ export function reconcileOwnership( ): ReconcileStats { let methodsRegistered = 0; let fieldsRegistered = 0; + let nestedTypesRegistered = 0; let skippedAlreadyPresent = 0; for (const parsed of parsedFiles) { @@ -77,19 +96,32 @@ export function reconcileOwnership( } model.methods.register(ownerId, simple, def); methodsRegistered++; - } else if (def.type === 'Property' || def.type === 'Variable') { - const existing = model.fields.lookupFieldByOwner(ownerId, simple); - if (existing !== undefined && existing.nodeId === def.nodeId) { + } else if ( + def.type === 'Property' || + def.type === 'Variable' || + def.type === 'Const' || + def.type === 'Static' + ) { + const existing = model.fields.lookupAllByOwner(ownerId, simple); + if (existing.some((e) => e.nodeId === def.nodeId)) { skippedAlreadyPresent++; continue; } model.fields.register(ownerId, simple, def); fieldsRegistered++; + } else if (NESTED_TYPE_KINDS.has(def.type)) { + const existing = model.types.lookupAllByOwner(ownerId, simple); + if (existing.some((e) => e.nodeId === def.nodeId)) { + skippedAlreadyPresent++; + continue; + } + model.types.registerByOwner(ownerId, simple, def); + nestedTypesRegistered++; } } } - return { methodsRegistered, fieldsRegistered, skippedAlreadyPresent }; + return { methodsRegistered, fieldsRegistered, nestedTypesRegistered, skippedAlreadyPresent }; } /** @@ -131,15 +163,29 @@ export function validateOwnershipParity( ); mismatches++; } - } else if (def.type === 'Property' || def.type === 'Variable') { - const found = model.fields.lookupFieldByOwner(ownerId, simple); - if (found === undefined || found.nodeId !== def.nodeId) { + } else if ( + def.type === 'Property' || + def.type === 'Variable' || + def.type === 'Const' || + def.type === 'Static' + ) { + const found = model.fields.lookupAllByOwner(ownerId, simple); + if (!found.some((d) => d.nodeId === def.nodeId)) { onWarn( `semantic-model parity: ${def.type} ${def.nodeId} (${parsed.filePath}) ` + `owned by ${ownerId} as "${simple}" not in FieldRegistry`, ); mismatches++; } + } else if (NESTED_TYPE_KINDS.has(def.type)) { + const found = model.types.lookupAllByOwner(ownerId, simple); + if (!found.some((d) => d.nodeId === def.nodeId)) { + onWarn( + `semantic-model parity: ${def.type} ${def.nodeId} (${parsed.filePath}) ` + + `owned by ${ownerId} as "${simple}" not in TypeRegistry owner index`, + ); + mismatches++; + } } } } diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts index 713497da4..4759598a9 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts @@ -19,6 +19,7 @@ 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'; /** Map of `SupportedLanguages` → `ScopeResolver`. The phase iterates * this map intersected with `MIGRATED_LANGUAGES` (the per-language @@ -36,4 +37,5 @@ export const SCOPE_RESOLVERS: ReadonlyMap = n [SupportedLanguages.C, cScopeResolver], [SupportedLanguages.CPlusPlus, cppScopeResolver], [SupportedLanguages.PHP, phpScopeResolver], + [SupportedLanguages.JavaScript, javascriptScopeResolver], ]); diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index 0b9948368..ae6ec5a23 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -25,6 +25,7 @@ import type { ParsedFile, RegistryProviders } from 'gitnexus-shared'; import type { KnowledgeGraph } from '../../../graph/types.js'; +import { lookupOwnedMembersByOwner } from '../../model/owned-members-lookup.js'; import type { MutableSemanticModel, SemanticModel } from '../../model/semantic-model.js'; import { reconcileOwnership, validateOwnershipParity } from './reconcile-ownership.js'; import { validateBindingsImmutability } from './validate-bindings-immutability.js'; @@ -342,6 +343,8 @@ export function runScopeResolution( const { referenceIndex, stats: resolveStats } = resolveReferenceSites({ scopes: indexes, providers: registryProviders, + ownedMembersByOwner: (ownerDefId, memberName) => + lookupOwnedMembersByOwner(readonlyModel, ownerDefId, memberName), }); const tResolve = PROF ? process.hrtime.bigint() : 0n; diff --git a/gitnexus/src/core/lbug/lbug-adapter.ts b/gitnexus/src/core/lbug/lbug-adapter.ts index c01966d0c..8330614b2 100644 --- a/gitnexus/src/core/lbug/lbug-adapter.ts +++ b/gitnexus/src/core/lbug/lbug-adapter.ts @@ -154,6 +154,7 @@ export const splitRelCsvByLabelPair = async ( let db: lbug.Database | null = null; let conn: lbug.Connection | null = null; let currentDbPath: string | null = null; +let currentDbReadOnly = false; let ftsLoaded = false; let vectorExtensionLoaded = false; @@ -448,12 +449,17 @@ export const initLbug = async (dbPath: string) => { * database is busy (e.g. `gitnexus analyze` holds the write lock). * Each retry waits DB_LOCK_RETRY_DELAY_MS * attempt milliseconds. */ -export const withLbugDb = async (dbPath: string, operation: () => Promise): Promise => { +export const withLbugDb = async ( + dbPath: string, + operation: () => Promise, + options: { readOnly?: boolean } = {}, +): Promise => { let lastError: unknown; + const readOnly = options.readOnly === true; for (let attempt = 1; attempt <= DB_LOCK_RETRY_ATTEMPTS; attempt++) { try { return await runWithSessionLock(async () => { - await ensureLbugInitialized(dbPath); + await ensureLbugInitialized(dbPath, readOnly); return operation(); }); } catch (err) { @@ -483,15 +489,15 @@ export const withLbugDb = async (dbPath: string, operation: () => Promise) throw lastError; }; -const ensureLbugInitialized = async (dbPath: string) => { - if (conn && currentDbPath === dbPath) { +const ensureLbugInitialized = async (dbPath: string, readOnly: boolean = false) => { + if (conn && currentDbPath === dbPath && currentDbReadOnly === readOnly) { return { db, conn }; } - await doInitLbug(dbPath); + await doInitLbug(dbPath, readOnly); return { db, conn }; }; -const doInitLbug = async (dbPath: string) => { +const doInitLbug = async (dbPath: string, readOnly: boolean = false) => { // Different database requested — close the old one first if (conn || db) { await safeClose(); @@ -575,9 +581,12 @@ const doInitLbug = async (dbPath: string) => { const parentDir = path.dirname(dbPath); await fs.mkdir(parentDir, { recursive: true }); - const opened = await openLbugConnection(lbug, dbPath); + const opened = readOnly + ? await openLbugConnection(lbug, dbPath, { readOnly: true }) + : await openLbugConnection(lbug, dbPath); db = opened.db; conn = opened.conn; + currentDbReadOnly = readOnly; } finally { await releaseInitLock(); } @@ -614,7 +623,7 @@ const doInitLbug = async (dbPath: string) => { ` Original error: ${msg.slice(0, 200)}`, ); } - if (!msg.includes('already exists') && !isDbBusyError(err)) { + if (!msg.includes('already exists') && !isDbBusyError(err) && !isReadOnlyDbError(err)) { logger.warn(`⚠️ Schema creation warning: ${msg.slice(0, 120)}`); } } @@ -1058,12 +1067,7 @@ export const batchInsertNodesToLbug = async ( }; export const executeQuery = async (cypher: string): Promise => { - if (!conn) { - throw new Error('LadybugDB not initialized. Call initLbug first.'); - } - - const queryResult = await conn.query(cypher); - return await readQueryRows(queryResult); + return await executePrepared(cypher, {}); }; export const streamQuery = async ( @@ -1726,19 +1730,15 @@ export const queryFTS = async ( throw new Error('LadybugDB not initialized. Call initLbug first.'); } - // Escape backslashes and single quotes to prevent Cypher injection - const escapedQuery = query.replace(/\\/g, '\\\\').replace(/'/g, "''"); - const cypher = ` - CALL QUERY_FTS_INDEX('${tableName}', '${indexName}', '${escapedQuery}', conjunctive := ${conjunctive}) + CALL QUERY_FTS_INDEX('${tableName}', '${indexName}', $query, conjunctive := ${conjunctive}) RETURN node, score ORDER BY score DESC LIMIT ${limit} `; try { - const queryResult = await conn.query(cypher); - const rows = await readQueryRows(queryResult); + const rows = await executePrepared(cypher, { query }); return rows.map((row: any) => { const node = row.node || row[0] || {}; diff --git a/gitnexus/src/core/lbug/pool-adapter.ts b/gitnexus/src/core/lbug/pool-adapter.ts index 2b432cba3..f551c65f8 100644 --- a/gitnexus/src/core/lbug/pool-adapter.ts +++ b/gitnexus/src/core/lbug/pool-adapter.ts @@ -16,14 +16,52 @@ */ import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; import lbug from '@ladybugdb/core'; -import { loadFTSExtension } from './lbug-adapter.js'; +import { isReadOnlyDbError, loadFTSExtension } from './lbug-adapter.js'; import { createLbugDatabase, isWalCorruptionError, WAL_RECOVERY_SUGGESTION, } from './lbug-config.js'; +/** + * Probe whether a Windows FTS extension binary is locally installed under + * ~/.lbdb/extension//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 { + 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 { db: lbug.Database; @@ -423,14 +461,24 @@ async function doInitLbug(repoId: string, dbPath: string): Promise { // 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 +545,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' }); } @@ -598,30 +648,7 @@ function withTimeout(promise: Promise, ms: number, label: string): Promise } export const executeQuery = async (repoId: string, cypher: string): Promise => { - const entry = pool.get(repoId); - if (!entry) { - throw new Error(`LadybugDB not initialized for repo "${repoId}". Call initLbug first.`); - } - - if (isWriteQuery(cypher)) { - throw new Error('Write operations are not allowed. The pool adapter is read-only.'); - } - - entry.lastUsed = Date.now(); - - const conn = await checkout(entry); - silenceStdout(); - activeQueryCount++; - try { - const queryResult = await withTimeout(conn.query(cypher), QUERY_TIMEOUT_MS, 'Query'); - const result = Array.isArray(queryResult) ? queryResult[0] : queryResult; - const rows = await result.getAll(); - return rows; - } finally { - activeQueryCount--; - restoreStdout(); - checkin(entry, conn); - } + return await executeParameterized(repoId, cypher, {}); }; /** @@ -653,6 +680,11 @@ export const executeParameterized = async ( const result = Array.isArray(queryResult) ? queryResult[0] : queryResult; const rows = await result.getAll(); return rows; + } catch (err) { + if (isReadOnlyDbError(err)) { + throw new Error('Write operations are not allowed. The pool adapter is read-only.'); + } + throw err; } finally { activeQueryCount--; restoreStdout(); @@ -685,15 +717,3 @@ export const closeLbug = async (repoId?: string): Promise => { * Check if a specific repo's pool is active */ export const isLbugReady = (repoId: string): boolean => pool.has(repoId); - -/** Regex to detect write operations in user-supplied Cypher queries. - * Note: CALL is NOT blocked — it's used for read-only FTS (CALL QUERY_FTS_INDEX) - * and vector search (CALL QUERY_VECTOR_INDEX). The database is opened in - * read-only mode as defense-in-depth against write procedures. */ -export const CYPHER_WRITE_RE = - /(? + value === null || ['string', 'number', 'boolean'].includes(typeof value); + +export const isValidQueryParams = (value: unknown): value is Record => + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null) && + Object.values(value).every(isBindableScalar); diff --git a/gitnexus/src/core/search/bm25-index.ts b/gitnexus/src/core/search/bm25-index.ts index 27a7b9d8d..58595f576 100644 --- a/gitnexus/src/core/search/bm25-index.ts +++ b/gitnexus/src/core/search/bm25-index.ts @@ -27,22 +27,20 @@ export interface FTSSearchResponse { * caller can distinguish "zero matches" from "index missing". */ async function queryFTSViaExecutor( - executor: (cypher: string) => Promise, + executor: (cypher: string, params: Record) => Promise, tableName: string, indexName: string, query: string, limit: number, ): Promise | null> { - // Escape single quotes and backslashes to prevent Cypher injection - const escapedQuery = query.replace(/\\/g, '\\\\').replace(/'/g, "''"); const cypher = ` - CALL QUERY_FTS_INDEX('${tableName}', '${indexName}', '${escapedQuery}', conjunctive := false) + CALL QUERY_FTS_INDEX('${tableName}', '${indexName}', $query, conjunctive := false) RETURN node, score ORDER BY score DESC LIMIT ${limit} `; try { - const rows = await executor(cypher); + const rows = await executor(cypher, { query }); return rows.map((row: any) => { const node = row.node || row[0] || {}; const score = row.score ?? row[1] ?? 0; @@ -81,8 +79,9 @@ export const searchFTSFromLbug = async ( // IMPORTANT: FTS queries run sequentially to avoid connection contention. // The MCP pool supports multiple connections, but FTS is best run serially. const poolMod = await import('../lbug/pool-adapter.js'); - const { executeQuery } = poolMod; - const executor = (cypher: string) => executeQuery(repoId, cypher); + const { executeParameterized } = poolMod; + const executor = (cypher: string, params: Record) => + executeParameterized(repoId, cypher, params); for (const { table, indexName } of FTS_INDEXES) { const result = await queryFTSViaExecutor(executor, table, indexName, query, limit); diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 03f59cd40..4cff47a28 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -14,10 +14,9 @@ import { executeParameterized, closeLbug, isLbugReady, - isWriteQuery, } from '../../core/lbug/pool-adapter.js'; +import { isValidQueryParams } from '../../core/lbug/query-params.js'; import { isWalCorruptionError, WAL_RECOVERY_SUGGESTION } from '../../core/lbug/lbug-config.js'; -export { isWriteQuery }; // Embedding imports are lazy (dynamic import) to avoid loading onnxruntime-node // at MCP server startup — crashes on unsupported Node ABI versions (#89) // git utilities available if needed @@ -175,6 +174,9 @@ function logQueryError(context: string, err: unknown): void { logger.error({ context, err: msg }, 'GitNexus query failed'); } +const isReadOnlyDbError = (err: unknown): boolean => + /read-only database/i.test(err instanceof Error ? err.message : String(err)); + /** * Per-query latency telemetry for production aggregation (#553). * @@ -246,6 +248,29 @@ function tryRealpath(p: string): string { */ export function resolveWorktreeCwd(repoPath: string, launchCwd: string): string { try { + // Verify repoPath is a git root before comparing against its canonical + // root. If getGitRoot returns a different path, repoPath is an arbitrary + // subdirectory — skip both the linked-worktree guard and auto-detection + // and fall through to the repoPath fallback. + const repoGitRoot = getGitRoot(repoPath); + const repoCanonical = + repoGitRoot && tryRealpath(repoGitRoot) === tryRealpath(repoPath) + ? getCanonicalRepoRoot(repoPath) + : null; + + // Early exit: if repoPath is a linked worktree (differs from its canonical + // main-checkout root), return it unchanged. Do NOT override it with the + // server's launch directory — that would silently replace the explicitly- + // resolved worktree index with the main checkout. + // + // getCanonicalRepoRoot returns the main-checkout path for both the checkout + // and all linked worktrees: + // repoPath === canonical → main checkout (auto-detect may fire below) + // repoPath !== canonical → linked worktree (return as-is) + if (repoCanonical && tryRealpath(repoPath) !== tryRealpath(repoCanonical)) { + return repoPath; + } + const launchGitRoot = getGitRoot(launchCwd); if (launchGitRoot) { // Normalise via realpathSync before comparing so macOS /var → /private/var @@ -254,8 +279,12 @@ export function resolveWorktreeCwd(repoPath: string, launchCwd: string): string const realRepo = tryRealpath(repoPath); if (realLaunch !== realRepo) { const launchCanonical = getCanonicalRepoRoot(launchCwd); - const repoCanonical = getCanonicalRepoRoot(repoPath); - if (launchCanonical && repoCanonical && launchCanonical === repoCanonical) { + // Use tryRealpath on both canonical values for cross-platform safety. + if ( + launchCanonical && + repoCanonical && + tryRealpath(launchCanonical) === tryRealpath(repoCanonical) + ) { return launchGitRoot; } } @@ -1273,31 +1302,41 @@ export class LocalBackend { } } - async executeCypher(repoName: string, query: string): Promise { + async executeCypher( + repoName: string, + query: string, + params: Record = {}, + ): Promise { const repo = await this.resolveRepo(repoName); - return this.cypher(repo, { query }); + return this.cypher(repo, { query, params }); } - private async cypher(repo: RepoHandle, params: { query: string }): Promise { + private async cypher( + repo: RepoHandle, + request: { query: string; params?: Record }, + ): Promise { await this.ensureInitialized(repo.id); if (!isLbugReady(repo.id)) { return { error: 'LadybugDB not ready. Index may be corrupted.' }; } - - // Block write operations (defense-in-depth — DB is already read-only) - if (isWriteQuery(params.query)) { + if (request.params !== undefined && !isValidQueryParams(request.params)) { return { - error: - 'Write operations (CREATE, DELETE, SET, MERGE, REMOVE, DROP, ALTER, COPY, DETACH) are not allowed. The knowledge graph is read-only.', + error: '"params" must be a plain object with scalar values (string/number/boolean/null).', }; } try { - const result = await executeQuery(repo.id, params.query); + const result = await executeParameterized(repo.id, request.query, request.params ?? {}); return result; } catch (err: any) { const msg = err.message || 'Query failed'; + if (isReadOnlyDbError(err)) { + return { + error: + 'Write operations (CREATE, DELETE, SET, MERGE, REMOVE, DROP, ALTER, COPY, DETACH) are not allowed. The knowledge graph is read-only.', + }; + } if (isWalCorruptionError(err)) { return { error: msg, diff --git a/gitnexus/src/mcp/tools.ts b/gitnexus/src/mcp/tools.ts index 28646c66f..9300f5ae5 100644 --- a/gitnexus/src/mcp/tools.ts +++ b/gitnexus/src/mcp/tools.ts @@ -187,6 +187,11 @@ TIPS: type: 'object', properties: { query: { type: 'string', description: 'Cypher query to execute' }, + params: { + type: 'object', + description: + 'Optional query parameters for placeholders (e.g. $name) to execute via prepared statement binding.', + }, repo: { type: 'string', description: 'Repository name or path. Omit if only one repo is indexed.', diff --git a/gitnexus/src/server/api.ts b/gitnexus/src/server/api.ts index 2d49fabc4..7fc892574 100644 --- a/gitnexus/src/server/api.ts +++ b/gitnexus/src/server/api.ts @@ -22,8 +22,9 @@ import { flushWAL, closeLbug, withLbugDb, + isReadOnlyDbError, } from '../core/lbug/lbug-adapter.js'; -import { isWriteQuery } from '../core/lbug/pool-adapter.js'; +import { isValidQueryParams } from '../core/lbug/query-params.js'; import { NODE_TABLES, type GraphNode, type GraphRelationship } from 'gitnexus-shared'; import { searchFTSFromLbug } from '../core/search/bm25-index.js'; import { hybridSearch } from '../core/search/hybrid-search.js'; @@ -447,7 +448,14 @@ export const streamGraphNdjson = async ( */ const mountSSEProgress = (app: express.Express, routePath: string, jm: JobManager) => { app.get(routePath, (req, res) => { - const job = jm.getJob(req.params.jobId); + let jobId: string; + try { + jobId = assertString(req.params.jobId, 'jobId'); + } catch (err: any) { + res.status(err.status ?? 400).json({ error: err.message }); + return; + } + const job = jm.getJob(jobId); if (!job) { res.status(404).json({ error: 'Job not found' }); return; @@ -493,7 +501,7 @@ const mountSSEProgress = (app: express.Express, routePath: string, jm: JobManage try { eventId++; if (progress.phase === 'complete' || progress.phase === 'failed') { - const eventJob = jm.getJob(req.params.jobId); + const eventJob = jm.getJob(jobId); res.write( `id: ${eventId}\nevent: ${progress.phase}\ndata: ${JSON.stringify({ repoName: eventJob?.repoName, @@ -621,6 +629,44 @@ export const handleFileRequest = async ( } }; +export const handleQueryRequest = async ( + req: express.Request, + res: express.Response, + resolveRepo: (repoName?: string) => Promise<{ storagePath: string } | undefined>, +): Promise => { + try { + const cypher = req.body.cypher as string; + if (!cypher) { + res.status(400).json({ error: 'Missing "cypher" in request body' }); + return; + } + const queryParams = req.body.params; + if (queryParams !== undefined && !isValidQueryParams(queryParams)) { + res.status(400).json({ + error: '"params" must be a plain object with scalar values (string/number/boolean/null)', + }); + return; + } + + const entry = await resolveRepo(requestedRepo(req)); + if (!entry) { + res.status(404).json({ error: 'Repository not found' }); + return; + } + const lbugPath = path.join(entry.storagePath, 'lbug'); + const result = await withLbugDb(lbugPath, () => executePrepared(cypher, queryParams ?? {}), { + readOnly: true, + }); + res.json({ result }); + } catch (err: any) { + if (isReadOnlyDbError(err)) { + res.status(403).json({ error: 'Write queries are not allowed via the HTTP API' }); + return; + } + res.status(500).json({ error: err.message || 'Query failed' }); + } +}; + export const createServer = async (port: number, host: string = '127.0.0.1') => { const app = express(); app.disable('x-powered-by'); @@ -984,8 +1030,16 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => res.once('close', abortStreaming); try { - await withLbugDb(lbugPath, async () => - streamGraphNdjson(res, includeContent, abortController.signal), + // Read-only open: /api/graph never writes. Write-mode opens engage + // LadybugDB's checkpoint machinery (`.shadow` sidecar), which on + // Windows races with the OS file handle release and trips + // "Cannot open file ... lbug.shadow - Error 2". See pool-adapter.ts + // which already opens read-only for the same reason, and the + // /api/query precedent in PR #1655. + await withLbugDb( + lbugPath, + async () => streamGraphNdjson(res, includeContent, abortController.signal), + { readOnly: true }, ); if (!abortController.signal.aborted && !res.writableEnded) { res.end(); @@ -998,7 +1052,9 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => return; } - const graph = await withLbugDb(lbugPath, async () => buildGraph(includeContent)); + const graph = await withLbugDb(lbugPath, async () => buildGraph(includeContent), { + readOnly: true, + }); res.json(graph); } catch (err: any) { if (err instanceof ClientDisconnectedError) { @@ -1020,29 +1076,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => // Execute Cypher query app.post('/api/query', async (req, res) => { - try { - const cypher = req.body.cypher as string; - if (!cypher) { - res.status(400).json({ error: 'Missing "cypher" in request body' }); - return; - } - - if (isWriteQuery(cypher)) { - res.status(403).json({ error: 'Write queries are not allowed via the HTTP API' }); - return; - } - - const entry = await resolveRepo(requestedRepo(req)); - if (!entry) { - res.status(404).json({ error: 'Repository not found' }); - return; - } - const lbugPath = path.join(entry.storagePath, 'lbug'); - const result = await withLbugDb(lbugPath, () => executeQuery(cypher)); - res.json({ result }); - } catch (err: any) { - res.status(500).json({ error: err.message || 'Query failed' }); - } + await handleQueryRequest(req, res, resolveRepo); }); // Search (supports mode: 'hybrid' | 'semantic' | 'bm25', and optional enrichment) @@ -1067,68 +1101,70 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => const mode: string = req.body.mode ?? 'hybrid'; const enrich: boolean = req.body.enrich !== false; // default true - const results = await withLbugDb(lbugPath, async () => { - let searchResults: any[]; - let ftsAvailable: boolean | undefined; + const results = await withLbugDb( + lbugPath, + async () => { + let searchResults: any[]; + let ftsAvailable: boolean | undefined; - if (mode === 'semantic') { - const { isEmbedderReady } = await import('../core/embeddings/embedder.js'); - if (!isEmbedderReady()) { - return { searchResults: [] as any[], ftsAvailable: undefined }; - } - const { semanticSearch: semSearch } = - await import('../core/embeddings/embedding-pipeline.js'); - searchResults = await semSearch(executeQuery, query, limit); - // Normalize semantic results to HybridSearchResult shape - searchResults = searchResults.map((r: any, i: number) => ({ - ...r, - score: r.score ?? 1 - (r.distance ?? 0), - rank: i + 1, - sources: ['semantic'], - })); - } else if (mode === 'bm25') { - const ftsResponse = await searchFTSFromLbug(query, limit); - ftsAvailable = ftsResponse.ftsAvailable; - searchResults = ftsResponse.results.map((r: any, i: number) => ({ - ...r, - rank: i + 1, - sources: ['bm25'], - })); - } else { - // hybrid (default) - const { isEmbedderReady } = await import('../core/embeddings/embedder.js'); - if (isEmbedderReady()) { + if (mode === 'semantic') { + const { isEmbedderReady } = await import('../core/embeddings/embedder.js'); + if (!isEmbedderReady()) { + return { searchResults: [] as any[], ftsAvailable: undefined }; + } const { semanticSearch: semSearch } = await import('../core/embeddings/embedding-pipeline.js'); - searchResults = await hybridSearch(query, limit, executeQuery, semSearch); - } else { + searchResults = await semSearch(executeQuery, query, limit); + // Normalize semantic results to HybridSearchResult shape + searchResults = searchResults.map((r: any, i: number) => ({ + ...r, + score: r.score ?? 1 - (r.distance ?? 0), + rank: i + 1, + sources: ['semantic'], + })); + } else if (mode === 'bm25') { const ftsResponse = await searchFTSFromLbug(query, limit); ftsAvailable = ftsResponse.ftsAvailable; - searchResults = ftsResponse.results; + searchResults = ftsResponse.results.map((r: any, i: number) => ({ + ...r, + rank: i + 1, + sources: ['bm25'], + })); + } else { + // hybrid (default) + const { isEmbedderReady } = await import('../core/embeddings/embedder.js'); + if (isEmbedderReady()) { + const { semanticSearch: semSearch } = + await import('../core/embeddings/embedding-pipeline.js'); + searchResults = await hybridSearch(query, limit, executeQuery, semSearch); + } else { + const ftsResponse = await searchFTSFromLbug(query, limit); + ftsAvailable = ftsResponse.ftsAvailable; + searchResults = ftsResponse.results; + } } - } - if (!enrich) return { searchResults, ftsAvailable }; + if (!enrich) return { searchResults, ftsAvailable }; - // Server-side enrichment: add connections, cluster, processes per result - // Uses parameterized queries to prevent Cypher injection via nodeId - const validLabel = (label: string): boolean => - (NODE_TABLES as readonly string[]).includes(label); + // Server-side enrichment: add connections, cluster, processes per result + // Uses parameterized queries to prevent Cypher injection via nodeId + const validLabel = (label: string): boolean => + (NODE_TABLES as readonly string[]).includes(label); - const enriched = await Promise.all( - searchResults.slice(0, limit).map(async (r: any) => { - const nodeId: string = r.nodeId || r.id || ''; - const nodeLabel = nodeId.split(':')[0]; - const enrichment: { connections?: any; cluster?: string; processes?: any[] } = {}; + const enriched = await Promise.all( + searchResults.slice(0, limit).map(async (r: any) => { + const nodeId: string = r.nodeId || r.id || ''; + const nodeLabel = nodeId.split(':')[0]; + const enrichment: { connections?: any; cluster?: string; processes?: any[] } = {}; - if (!nodeId || !validLabel(nodeLabel)) return { ...r, ...enrichment }; + if (!nodeId || !validLabel(nodeLabel)) return { ...r, ...enrichment }; - // Run connections, cluster, and process queries in parallel - // Label is validated against NODE_TABLES (compile-time safe identifiers); - // nodeId uses $nid parameter binding to prevent injection - const [connRes, clusterRes, procRes] = await Promise.all([ - executePrepared( - ` + // Run connections, cluster, and process queries in parallel + // Label is validated against NODE_TABLES (compile-time safe identifiers); + // nodeId uses $nid parameter binding to prevent injection + const [connRes, clusterRes, procRes] = await Promise.all([ + executePrepared( + ` MATCH (n:${nodeLabel} {id: $nid}) OPTIONAL MATCH (n)-[r1:CodeRelation]->(dst) OPTIONAL MATCH (src)-[r2:CodeRelation]->(n) @@ -1137,61 +1173,63 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => collect(DISTINCT {name: src.name, type: r2.type, confidence: r2.confidence}) AS incoming LIMIT 1 `, - { nid: nodeId }, - ).catch(() => []), - executePrepared( - ` + { nid: nodeId }, + ).catch(() => []), + executePrepared( + ` MATCH (n:${nodeLabel} {id: $nid}) MATCH (n)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community) RETURN c.label AS label, c.description AS description LIMIT 1 `, - { nid: nodeId }, - ).catch(() => []), - executePrepared( - ` + { nid: nodeId }, + ).catch(() => []), + executePrepared( + ` MATCH (n:${nodeLabel} {id: $nid}) MATCH (n)-[rel:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process) RETURN p.id AS id, p.label AS label, rel.step AS step, p.stepCount AS stepCount ORDER BY rel.step `, - { nid: nodeId }, - ).catch(() => []), - ]); + { nid: nodeId }, + ).catch(() => []), + ]); - if (connRes.length > 0) { - const row = connRes[0]; - const outgoing = (Array.isArray(row) ? row[0] : row.outgoing || []) - .filter((c: any) => c?.name) - .slice(0, 5); - const incoming = (Array.isArray(row) ? row[1] : row.incoming || []) - .filter((c: any) => c?.name) - .slice(0, 5); - enrichment.connections = { outgoing, incoming }; - } + if (connRes.length > 0) { + const row = connRes[0]; + const outgoing = (Array.isArray(row) ? row[0] : row.outgoing || []) + .filter((c: any) => c?.name) + .slice(0, 5); + const incoming = (Array.isArray(row) ? row[1] : row.incoming || []) + .filter((c: any) => c?.name) + .slice(0, 5); + enrichment.connections = { outgoing, incoming }; + } - if (clusterRes.length > 0) { - const row = clusterRes[0]; - enrichment.cluster = Array.isArray(row) ? row[0] : row.label; - } + if (clusterRes.length > 0) { + const row = clusterRes[0]; + enrichment.cluster = Array.isArray(row) ? row[0] : row.label; + } - if (procRes.length > 0) { - enrichment.processes = procRes - .map((row: any) => ({ - id: Array.isArray(row) ? row[0] : row.id, - label: Array.isArray(row) ? row[1] : row.label, - step: Array.isArray(row) ? row[2] : row.step, - stepCount: Array.isArray(row) ? row[3] : row.stepCount, - })) - .filter((p: any) => p.id && p.label); - } + if (procRes.length > 0) { + enrichment.processes = procRes + .map((row: any) => ({ + id: Array.isArray(row) ? row[0] : row.id, + label: Array.isArray(row) ? row[1] : row.label, + step: Array.isArray(row) ? row[2] : row.step, + stepCount: Array.isArray(row) ? row[3] : row.stepCount, + })) + .filter((p: any) => p.id && p.label); + } - return { ...r, ...enrichment }; - }), - ); + return { ...r, ...enrichment }; + }), + ); - return { searchResults: enriched, ftsAvailable }; - }); + return { searchResults: enriched, ftsAvailable }; + }, + { readOnly: true }, + ); const response: any = { results: results.searchResults ?? results }; if (results.ftsAvailable === false) { response.warning = @@ -1271,8 +1309,11 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => // Get file paths from the graph (lightweight — no content loaded) const lbugPath = path.join(entry.storagePath, 'lbug'); - const fileRows = await withLbugDb(lbugPath, () => - executeQuery(`MATCH (n:File) WHERE n.content IS NOT NULL RETURN n.filePath AS filePath`), + const fileRows = await withLbugDb( + lbugPath, + () => + executeQuery(`MATCH (n:File) WHERE n.content IS NOT NULL RETURN n.filePath AS filePath`), + { readOnly: true }, ); // Search files on disk one at a time (constant memory) diff --git a/gitnexus/test/fixtures/cross-file-binding/js-consumer-before-provider/b-provider.js b/gitnexus/test/fixtures/cross-file-binding/js-consumer-before-provider/b-provider.js index 99c85948a..b04e7c84a 100644 --- a/gitnexus/test/fixtures/cross-file-binding/js-consumer-before-provider/b-provider.js +++ b/gitnexus/test/fixtures/cross-file-binding/js-consumer-before-provider/b-provider.js @@ -2,6 +2,7 @@ export class User { save() {} } +/** @returns {User} */ export function getUser() { return new User(); } diff --git a/gitnexus/test/fixtures/cross-file-binding/js-cross-file/src/models.js b/gitnexus/test/fixtures/cross-file-binding/js-cross-file/src/models.js index 5d33e9be4..59bb80058 100644 --- a/gitnexus/test/fixtures/cross-file-binding/js-cross-file/src/models.js +++ b/gitnexus/test/fixtures/cross-file-binding/js-cross-file/src/models.js @@ -3,6 +3,7 @@ export class User { getName() { return ''; } } +/** @returns {User} */ export function getUser() { return new User(); } diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-sfinae-is-class/main.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-sfinae-is-class/main.cpp new file mode 100644 index 000000000..27e8d7d5e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-sfinae-is-class/main.cpp @@ -0,0 +1,16 @@ +#include + +struct S {}; + +template , int> = 0> +void pick(T value) {} + +template , int> = 0> +void pick(T value) {} + +void run() { + S s; + int n = 0; + pick(s); + pick(n); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-sfinae-is-const-volatile/main.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-sfinae-is-const-volatile/main.cpp new file mode 100644 index 000000000..aa0a51228 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-sfinae-is-const-volatile/main.cpp @@ -0,0 +1,14 @@ +#include + +template , int> = 0> +void pick(T value) {} + +template , int> = 0> +void pick(T value) {} + +void run() { + const int c = 0; + volatile int v = 0; + pick(c); + pick(v); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-sfinae-is-enum/main.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-sfinae-is-enum/main.cpp new file mode 100644 index 000000000..b942efb2b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-sfinae-is-enum/main.cpp @@ -0,0 +1,16 @@ +#include + +enum Color { Red }; + +template , int> = 0> +void pick(T value) {} + +template , int> = 0> +void pick(T value) {} + +void run() { + Color color = Red; + int n = 0; + pick(color); + pick(n); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-sfinae-is-pointer/main.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-sfinae-is-pointer/main.cpp new file mode 100644 index 000000000..8e2afdd31 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-sfinae-is-pointer/main.cpp @@ -0,0 +1,14 @@ +#include + +struct S {}; + +template , int> = 0> +void pick(T value) {} + +template , int> = 0> +void pick(T value) {} + +void run(S* p, S s) { + pick(p); + pick(s); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-sfinae-is-reference/main.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-sfinae-is-reference/main.cpp new file mode 100644 index 000000000..534b46288 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-sfinae-is-reference/main.cpp @@ -0,0 +1,14 @@ +#include + +template , int> = 0> +void pick(T value) {} + +template , int> = 0> +void pick(T value) {} + +void run() { + int n = 0; + int& r = n; + pick(r); + pick(n); +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-sfinae-is-void/main.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-sfinae-is-void/main.cpp new file mode 100644 index 000000000..ef3172260 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-sfinae-is-void/main.cpp @@ -0,0 +1,12 @@ +#include + +template , int> = 0> +void pick(T value) {} + +template , int> = 0> +void pick(T value) {} + +void run() { + void* p; + pick(p); +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-duplicate-fqn-modules/module1/src/main/java/com/example/Module1App.java b/gitnexus/test/fixtures/lang-resolution/java-duplicate-fqn-modules/module1/src/main/java/com/example/Module1App.java new file mode 100644 index 000000000..242956497 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-duplicate-fqn-modules/module1/src/main/java/com/example/Module1App.java @@ -0,0 +1,8 @@ +package com.example; + +public class Module1App { + public void run() { + UserService service = new UserService(); + service.ping(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-duplicate-fqn-modules/module1/src/main/java/com/example/UserService.java b/gitnexus/test/fixtures/lang-resolution/java-duplicate-fqn-modules/module1/src/main/java/com/example/UserService.java new file mode 100644 index 000000000..9daea3d45 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-duplicate-fqn-modules/module1/src/main/java/com/example/UserService.java @@ -0,0 +1,6 @@ +package com.example; + +public class UserService { + public void ping() { + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-duplicate-fqn-modules/module2/src/main/java/com/example/Module2App.java b/gitnexus/test/fixtures/lang-resolution/java-duplicate-fqn-modules/module2/src/main/java/com/example/Module2App.java new file mode 100644 index 000000000..7d24142b8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-duplicate-fqn-modules/module2/src/main/java/com/example/Module2App.java @@ -0,0 +1,8 @@ +package com.example; + +public class Module2App { + public void run() { + UserService service = new UserService(); + service.ping(); + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/java-duplicate-fqn-modules/module2/src/main/java/com/example/UserService.java b/gitnexus/test/fixtures/lang-resolution/java-duplicate-fqn-modules/module2/src/main/java/com/example/UserService.java new file mode 100644 index 000000000..9daea3d45 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/java-duplicate-fqn-modules/module2/src/main/java/com/example/UserService.java @@ -0,0 +1,6 @@ +package com.example; + +public class UserService { + public void ping() { + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-ambient-base-class/src/Derived.ts b/gitnexus/test/fixtures/lang-resolution/typescript-ambient-base-class/src/Derived.ts new file mode 100644 index 000000000..3138b9043 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-ambient-base-class/src/Derived.ts @@ -0,0 +1,3 @@ +import { AmbientBase } from './ambient'; + +export class Derived extends AmbientBase {} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-ambient-base-class/src/ambient.ts b/gitnexus/test/fixtures/lang-resolution/typescript-ambient-base-class/src/ambient.ts new file mode 100644 index 000000000..62c1b1082 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-ambient-base-class/src/ambient.ts @@ -0,0 +1,7 @@ +// Ambient base class — simulates a .d.ts-declared external/library type +// whose body is never seen by the analyzer. Probes whether Step 2 MRO +// lookup can still resolve inherited members on owners that reconcile- +// ownership skipped because they have no parsed body. +export declare class AmbientBase { + ambientMethod(): string; +} diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-ambient-base-class/src/app.ts b/gitnexus/test/fixtures/lang-resolution/typescript-ambient-base-class/src/app.ts new file mode 100644 index 000000000..9cea59a5f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-ambient-base-class/src/app.ts @@ -0,0 +1,6 @@ +import { Derived } from './Derived'; + +export function run(): void { + const d = new Derived(); + d.ambientMethod(); +} diff --git a/gitnexus/test/helpers/ladybug-native.ts b/gitnexus/test/helpers/ladybug-native.ts new file mode 100644 index 000000000..6521816a5 --- /dev/null +++ b/gitnexus/test/helpers/ladybug-native.ts @@ -0,0 +1,5 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +export const hasLadybugNative = (): boolean => + fs.existsSync(path.join(process.cwd(), 'node_modules', '@ladybugdb', 'core', 'lbugjs.node')); diff --git a/gitnexus/test/helpers/test-db.ts b/gitnexus/test/helpers/test-db.ts index 37032ebc8..5be063a2c 100644 --- a/gitnexus/test/helpers/test-db.ts +++ b/gitnexus/test/helpers/test-db.ts @@ -4,7 +4,8 @@ * Creates temporary directories for tests and provides cleanup that tolerates * LadybugDB's known Windows handle-release lag after retries. */ -import fs from 'fs/promises'; +import fs from 'fs'; +import fsp from 'fs/promises'; import os from 'os'; import path from 'path'; @@ -13,22 +14,56 @@ export interface TestDBHandle { cleanup: () => Promise; } +const CLEANUP_MAX_ATTEMPTS = 5; const WINDOWS_NATIVE_LOCK_CODES = new Set(['EBUSY', 'EPERM', 'EACCES', 'ENOTEMPTY']); -export async function cleanupTempDir(tmpDir: string): Promise { +const cleanupBackoffMs = (attempt: number): number => 100 * (attempt + 1); + +const shouldSwallowCleanupError = (err: unknown): boolean => { + const code = (err as NodeJS.ErrnoException | undefined)?.code; + return process.platform === 'win32' && WINDOWS_NATIVE_LOCK_CODES.has(code ?? ''); +}; + +const sleepSync = (ms: number): void => { + const view = new Int32Array(new SharedArrayBuffer(4)); + Atomics.wait(view, 0, 0, ms); +}; + +export function cleanupTempDirSync(tmpDir: string): void { let lastError: unknown; - for (let attempt = 0; attempt < 5; attempt++) { + for (let attempt = 0; attempt < CLEANUP_MAX_ATTEMPTS; attempt++) { try { - await fs.rm(tmpDir, { recursive: true, force: true }); + fs.rmSync(tmpDir, { recursive: true, force: true }); return; } catch (err) { lastError = err; - await new Promise((resolve) => setTimeout(resolve, 100 * (attempt + 1))); + if (attempt < CLEANUP_MAX_ATTEMPTS - 1) { + sleepSync(cleanupBackoffMs(attempt)); + } } } - const code = (lastError as NodeJS.ErrnoException | undefined)?.code; - if (process.platform === 'win32' && WINDOWS_NATIVE_LOCK_CODES.has(code ?? '')) { + if (shouldSwallowCleanupError(lastError)) { + return; + } + throw lastError; +} + +export async function cleanupTempDir(tmpDir: string): Promise { + let lastError: unknown; + for (let attempt = 0; attempt < CLEANUP_MAX_ATTEMPTS; attempt++) { + try { + await fsp.rm(tmpDir, { recursive: true, force: true }); + return; + } catch (err) { + lastError = err; + if (attempt < CLEANUP_MAX_ATTEMPTS - 1) { + await new Promise((resolve) => setTimeout(resolve, cleanupBackoffMs(attempt))); + } + } + } + + if (shouldSwallowCleanupError(lastError)) { return; } throw lastError; @@ -46,7 +81,7 @@ export async function cleanupTempDir(tmpDir: string): Promise { * return. */ export async function createTempDir(prefix: string = 'gitnexus-test-'): Promise { - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), prefix)); return { dbPath: tmpDir, cleanup: async () => { diff --git a/gitnexus/test/helpers/test-indexed-db.ts b/gitnexus/test/helpers/test-indexed-db.ts index 7ddf28a16..d1f257cf4 100644 --- a/gitnexus/test/helpers/test-indexed-db.ts +++ b/gitnexus/test/helpers/test-indexed-db.ts @@ -125,8 +125,9 @@ export function withTestLbugDB( // LadybugDB enforces file locks — writable + read-only can't coexist // on the same path, and db.close() segfaults on macOS due to N-API // destructor issues. Reusing the writable Database avoids both problems. - // Write protection is enforced at the query validation layer (isWriteQuery) - // rather than at the native DB level. + // NOTE: This injected DB is writable by design for test setup. + // Read-only enforcement tests must initialize a separate pool entry + // via initLbug(...) so Ladybug native read-only mode is exercised. if (options?.poolAdapter) { const coreDb = adapter.getDatabase(); if (!coreDb) throw new Error('withTestLbugDB: core adapter has no open Database'); diff --git a/gitnexus/test/integration/api-query.test.ts b/gitnexus/test/integration/api-query.test.ts new file mode 100644 index 000000000..70cc81287 --- /dev/null +++ b/gitnexus/test/integration/api-query.test.ts @@ -0,0 +1,97 @@ +import express from 'express'; +import http from 'node:http'; +import { describe, expect, it, beforeAll, afterAll } from 'vitest'; +import { withTestLbugDB } from '../helpers/test-indexed-db.js'; +import { hasLadybugNative } from '../helpers/ladybug-native.js'; + +const WRITE_QUERY_TEST_CYPHER = + "CREATE (n:Function {id: 'api-write-test', name: 'api-write-test', filePath: '', startLine: 0, endLine: 0, isExported: false, content: '', description: ''})"; + +const startServer = (app: express.Express): Promise<{ server: http.Server; baseUrl: string }> => + new Promise((resolve) => { + const server = app.listen(0, '127.0.0.1', () => { + const addr = server.address(); + if (!addr || typeof addr === 'string') throw new Error('Failed to start test server'); + resolve({ server, baseUrl: `http://127.0.0.1:${addr.port}` }); + }); + }); + +const stopServer = (server: http.Server): Promise => + new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))); + +withTestLbugDB( + 'api-query-http', + (handle) => { + describe.skipIf(!hasLadybugNative())('/api/query runtime contract', () => { + let server: http.Server; + let baseUrl = ''; + let handleQueryRequest: typeof import('../../src/server/api.js').handleQueryRequest; + + beforeAll(async () => { + ({ handleQueryRequest } = await import('../../src/server/api.js')); + const app = express(); + app.use(express.json()); + app.post('/api/query', async (req, res) => { + await handleQueryRequest(req, res, async () => ({ + storagePath: handle.tmpHandle.dbPath, + })); + }); + ({ server, baseUrl } = await startServer(app)); + }); + + afterAll(async () => { + await stopServer(server); + }); + + it('returns 200 for a valid read query', async () => { + const response = await fetch(`${baseUrl}/api/query`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ cypher: 'RETURN 1 AS one' }), + }); + expect(response.status).toBe(200); + const body = await response.json(); + expect(Array.isArray(body.result)).toBe(true); + expect(body.result[0].one).toBe(1); + }); + + it('returns 403 for a write query on read-only HTTP path', async () => { + const response = await fetch(`${baseUrl}/api/query`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + cypher: WRITE_QUERY_TEST_CYPHER, + }), + }); + expect(response.status).toBe(403); + const body = await response.json(); + expect(body.error).toContain('Write queries are not allowed'); + }); + + it('returns 400 for invalid params payload', async () => { + const response = await fetch(`${baseUrl}/api/query`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ cypher: 'RETURN 1 AS one', params: [1, 2, 3] }), + }); + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toContain('"params"'); + }); + + it('returns 400 when cypher is missing', async () => { + const response = await fetch(`${baseUrl}/api/query`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({}), + }); + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toContain('Missing "cypher"'); + }); + }); + }, + { + poolAdapter: false, + }, +); diff --git a/gitnexus/test/integration/cli-e2e.test.ts b/gitnexus/test/integration/cli-e2e.test.ts index 99e52c330..194b3813b 100644 --- a/gitnexus/test/integration/cli-e2e.test.ts +++ b/gitnexus/test/integration/cli-e2e.test.ts @@ -16,6 +16,7 @@ import os from 'os'; import { fileURLToPath, pathToFileURL } from 'url'; import { createRequire } from 'module'; +import { cleanupTempDirSync } from '../helpers/test-db.js'; const testDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(testDir, '../..'); @@ -75,10 +76,10 @@ afterAll(() => { // Entire tmp copy goes away — no selective cleanup needed. The shared // `test/fixtures/mini-repo/` source was never touched. if (tmpParent) { - fs.rmSync(tmpParent, { recursive: true, force: true }); + cleanupTempDirSync(tmpParent); } if (suiteGitnexusHome) { - fs.rmSync(suiteGitnexusHome, { recursive: true, force: true }); + cleanupTempDirSync(suiteGitnexusHome); } }); @@ -268,8 +269,8 @@ describe('CLI end-to-end', () => { `registry has no entry for ${repo}; entries: ${JSON.stringify(entries.map((e) => e.path))}`, ).toBe(true); } finally { - fs.rmSync(gnHome, { recursive: true, force: true }); - fs.rmSync(repoParent, { recursive: true, force: true }); + cleanupTempDirSync(gnHome); + cleanupTempDirSync(repoParent); } }, 60_000); @@ -310,8 +311,8 @@ describe('CLI end-to-end', () => { expect(`${second.stdout}${second.stderr}`).toMatch(/registry entry/i); expect(second.status).toBe(1); } finally { - fs.rmSync(gnHome, { recursive: true, force: true }); - fs.rmSync(repoParent, { recursive: true, force: true }); + cleanupTempDirSync(gnHome); + cleanupTempDirSync(repoParent); } }, 60_000); @@ -457,12 +458,12 @@ describe('CLI end-to-end', () => { const afterStep4 = JSON.parse(fs.readFileSync(registryPath, 'utf-8')); expect(afterStep4).toHaveLength(2); } finally { - fs.rmSync(parentC, { recursive: true, force: true }); + cleanupTempDirSync(parentC); } } finally { - fs.rmSync(gnHome, { recursive: true, force: true }); - fs.rmSync(parentA, { recursive: true, force: true }); - fs.rmSync(parentB, { recursive: true, force: true }); + cleanupTempDirSync(gnHome); + cleanupTempDirSync(parentA); + cleanupTempDirSync(parentB); } }, 360000); // 6-min outer budget (4 × ~60s analyze calls + fixture setup) }); @@ -571,8 +572,8 @@ describe('CLI end-to-end', () => { expect(r4.status).toBe(0); expect(`${r4.stdout}${r4.stderr}`).toMatch(/Nothing to remove/i); } finally { - fs.rmSync(gnHome, { recursive: true, force: true }); - fs.rmSync(parentA, { recursive: true, force: true }); + cleanupTempDirSync(gnHome); + cleanupTempDirSync(parentA); } }, 180000); // 3-min outer budget (1 × ~60s analyze + 3 × fast remove calls) @@ -675,9 +676,9 @@ describe('CLI end-to-end', () => { // And it's NOT the one we just removed. expect(finalEntries[0].path).not.toBe(repoAEntry.path); } finally { - fs.rmSync(gnHome, { recursive: true, force: true }); - fs.rmSync(parentA, { recursive: true, force: true }); - fs.rmSync(parentB, { recursive: true, force: true }); + cleanupTempDirSync(gnHome); + cleanupTempDirSync(parentA); + cleanupTempDirSync(parentB); } }, 240000); // 4-min outer budget (2 × ~60s analyze + 2 × fast remove) @@ -759,8 +760,8 @@ describe('CLI end-to-end', () => { expect(afterRegistry).toHaveLength(1); expect(afterRegistry[0].storagePath).toBe(repo); // still poisoned (we did that) } finally { - fs.rmSync(gnHome, { recursive: true, force: true }); - fs.rmSync(parent, { recursive: true, force: true }); + cleanupTempDirSync(gnHome); + cleanupTempDirSync(parent); } }, 120000); // 2-min budget (1 × ~60s analyze + 1 × fast remove-refused) }); @@ -864,9 +865,9 @@ describe('CLI end-to-end', () => { expect(afterRegistry).toHaveLength(1); expect(afterRegistry[0].name).toBe('bad-alias'); } finally { - fs.rmSync(gnHome, { recursive: true, force: true }); - fs.rmSync(parentBad, { recursive: true, force: true }); - fs.rmSync(parentGood, { recursive: true, force: true }); + cleanupTempDirSync(gnHome); + cleanupTempDirSync(parentBad); + cleanupTempDirSync(parentGood); } }, 240000); // 4-min budget (2 × ~60s analyze + 1 × fast clean --all) }); @@ -954,7 +955,7 @@ describe('CLI end-to-end', () => { expect(result.status).toBe(0); expect(result.stdout).toMatch(/Repository not indexed/); } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); + cleanupTempDirSync(tmpDir); } }); @@ -968,7 +969,7 @@ describe('CLI end-to-end', () => { expect(result.status).toBe(0); expect(result.stdout).toMatch(/Not a git repository/); } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); + cleanupTempDirSync(tmpDir); } }); @@ -984,7 +985,7 @@ describe('CLI end-to-end', () => { expect(result.status).toBe(1); expect(result.stdout).toMatch(/not.*git repository/i); } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); + cleanupTempDirSync(tmpDir); } }); }); @@ -1014,7 +1015,7 @@ describe('CLI end-to-end', () => { expect(result.status).toBe(1); expect(result.stdout).toMatch(/not.*git repository/i); } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); + cleanupTempDirSync(tmpDir); } }); @@ -1051,7 +1052,7 @@ describe('CLI end-to-end', () => { expect(result.status).toBe(1); expect(result.stdout).toMatch(/No GitNexus index found/); } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); + cleanupTempDirSync(tmpDir); } }); @@ -1217,7 +1218,7 @@ describe('CLI end-to-end', () => { child.stdout.on('data', (chunk: Buffer) => { stdoutBuffer += chunk.toString(); - if (stdoutBuffer.includes('GITNEXUS_EVAL_SERVER_READY:')) { + if (stdoutBuffer.includes('GITNEXUS_EVAL_SERVER_READY:127.0.0.1:')) { foundOnStdout = true; child.kill('SIGTERM'); } @@ -1255,4 +1256,157 @@ describe('CLI end-to-end', () => { }); }, 35000); }); + + // ─── eval-server --host flag tests ─────────────────────────────────── + // Verifies --host is wired to the actual bind address, not just accepted. + // Original flag registration test by Val Vladescu (PR #1602). + + describe('eval-server --host flag', () => { + it('emits READY signal containing the bound host 127.0.0.1', () => { + return new Promise((resolve, reject) => { + const child = spawn( + process.execPath, + [ + '--import', + tsxImportUrl, + cliEntry, + 'eval-server', + '--port', + '0', + '--host', + '127.0.0.1', + '--idle-timeout', + '3', + ], + { + cwd: MINI_REPO, + stdio: ['ignore', 'pipe', 'pipe'], + env: cliEnv(), + }, + ); + + let stdoutBuffer = ''; + let stderrBuffer = ''; + let settled = false; + + const settle = (fn: () => void) => { + if (settled) return; + settled = true; + clearTimeout(timer); + child.kill('SIGTERM'); + fn(); + }; + + child.stdout.on('data', (chunk: Buffer) => { + stdoutBuffer += chunk.toString(); + if (stdoutBuffer.includes('GITNEXUS_EVAL_SERVER_READY:')) { + if (stdoutBuffer.includes('GITNEXUS_EVAL_SERVER_READY:127.0.0.1:')) { + settle(resolve); + } else { + settle(() => + reject( + new Error( + `READY signal did not contain expected host 127.0.0.1:\n${stdoutBuffer}`, + ), + ), + ); + } + } + }); + + child.stderr.on('data', (chunk: Buffer) => { + stderrBuffer += chunk.toString(); + if (stderrBuffer.includes('unknown option') || stderrBuffer.includes('error: unknown')) { + settle(() => reject(new Error(`eval-server rejected --host flag:\n${stderrBuffer}`))); + } + }); + + const timer = setTimeout(() => { + settle(() => reject(new Error('eval-server did not emit READY signal within 30s'))); + }, 30000); + }); + }, 35000); + + it('binds to 0.0.0.0 and serves /health on 127.0.0.1 (cross-container use case)', () => { + return new Promise((resolve, reject) => { + const child = spawn( + process.execPath, + [ + '--import', + tsxImportUrl, + cliEntry, + 'eval-server', + '--port', + '0', + '--host', + '0.0.0.0', + '--idle-timeout', + '3', + ], + { + cwd: MINI_REPO, + stdio: ['ignore', 'pipe', 'pipe'], + env: cliEnv(), + }, + ); + + let stdoutBuffer = ''; + let settled = false; + + const settle = (fn: () => void) => { + if (settled) return; + settled = true; + clearTimeout(timer); + child.kill('SIGTERM'); + fn(); + }; + + child.stdout.on('data', async (chunk: Buffer) => { + stdoutBuffer += chunk.toString(); + const readyLine = stdoutBuffer + .split('\n') + .find((l) => l.startsWith('GITNEXUS_EVAL_SERVER_READY:0.0.0.0:')); + if (!readyLine || settled) return; + + // Parse the actual OS-assigned port from the READY signal + const boundPort = readyLine.split(':').pop()?.trim(); + if (!boundPort || isNaN(Number(boundPort))) { + settle(() => reject(new Error(`Could not parse port from READY signal: ${readyLine}`))); + return; + } + + // A server bound to 0.0.0.0 must be reachable on 127.0.0.1 from the same host + try { + const res = await fetch(`http://127.0.0.1:${boundPort}/health`); + if (res.status === 200) { + settle(resolve); + } else { + settle(() => reject(new Error(`/health returned ${res.status}, expected 200`))); + } + } catch (err) { + settle(() => + reject( + new Error( + `eval-server bound to 0.0.0.0 but /health unreachable on 127.0.0.1:${boundPort}: ${err}`, + ), + ), + ); + } + }); + + child.stderr.on('data', (chunk: Buffer) => { + const text = chunk.toString(); + if (text.includes('unknown option') || text.includes('error: unknown')) { + settle(() => reject(new Error(`eval-server rejected --host flag:\n${text}`))); + } + }); + + const timer = setTimeout(() => { + settle(() => + reject(new Error('eval-server --host 0.0.0.0 did not emit READY signal within 30s')), + ); + }, 30000); + }); + }, 35000); + }); }); diff --git a/gitnexus/test/integration/lbug-pool.test.ts b/gitnexus/test/integration/lbug-pool.test.ts index 484c8dac6..259c15c13 100644 --- a/gitnexus/test/integration/lbug-pool.test.ts +++ b/gitnexus/test/integration/lbug-pool.test.ts @@ -118,6 +118,25 @@ withTestLbugDB( // Should return 0 rows, not all rows expect(rows).toHaveLength(0); }); + + it('keeps seeded rows unchanged for a no-match parameterized write probe', async () => { + await initLbug('test-repo', handle.dbPath); + try { + const rows = await executeParameterized( + 'test-repo', + 'MATCH (n:Function) WHERE n.name = $target SET n.name = $name RETURN n.name AS name', + { target: '__missing__', name: 'x' }, + ); + expect(rows).toEqual([]); + } catch (err) { + expect(String(err)).toMatch(/read-only database|write operations/i); + } + const rows = await executeQuery( + 'test-repo', + 'MATCH (n:Function) RETURN n.name AS name ORDER BY n.name', + ); + expect(rows.map((r: any) => r.name)).toContain('main'); + }); }); // ─── Error handling ────────────────────────────────────────────────── @@ -133,14 +152,21 @@ withTestLbugDB( await expect(initLbug('bad-repo', '/nonexistent/path/lbug')).rejects.toThrow(); }); - it('read-only mode: write query throws', async () => { + it('keeps seeded data unchanged for a no-match write probe', async () => { await initLbug('test-repo', handle.dbPath); - await expect( - executeQuery( + try { + await executeQuery( 'test-repo', - "CREATE (n:Function {id: 'new', name: 'new', filePath: '', startLine: 0, endLine: 0, isExported: false, content: '', description: ''})", - ), - ).rejects.toThrow(); + "MATCH (n:Function) WHERE n.name = '__missing__' SET n.name = 'new' RETURN n", + ); + } catch (err) { + expect(String(err)).toMatch(/read-only database|write operations/i); + } + const rows = await executeQuery( + 'test-repo', + 'MATCH (n:Function) RETURN n.name AS name ORDER BY n.name', + ); + expect(rows.map((r: any) => r.name)).toContain('main'); }); }); diff --git a/gitnexus/test/integration/local-backend-calltool.test.ts b/gitnexus/test/integration/local-backend-calltool.test.ts index 81fd8f07b..cd4b34d9c 100644 --- a/gitnexus/test/integration/local-backend-calltool.test.ts +++ b/gitnexus/test/integration/local-backend-calltool.test.ts @@ -52,13 +52,16 @@ withTestLbugDB( expect(result.markdown).toContain('hash'); }); - it('cypher tool blocks write queries', async () => { + it('cypher no-match write probe returns read-only error or empty rows', async () => { const result = await backend.callTool('cypher', { query: - "CREATE (n:Function {id: 'x', name: 'x', filePath: '', startLine: 0, endLine: 0, isExported: false, content: '', description: ''})", + "MATCH (n:Function) WHERE n.name = '__missing__' SET n.name = 'x' RETURN n.name AS name", }); - expect(result).toHaveProperty('error'); - expect(result.error).toMatch(/write operations/i); + if (result?.error) { + expect(result.error).toMatch(/write operations|read-only/i); + return; + } + expect(result).toEqual([]); }); it('context tool returns symbol info with callers and callees', async () => { diff --git a/gitnexus/test/integration/local-backend.test.ts b/gitnexus/test/integration/local-backend.test.ts index be35a3c98..318490d82 100644 --- a/gitnexus/test/integration/local-backend.test.ts +++ b/gitnexus/test/integration/local-backend.test.ts @@ -4,21 +4,19 @@ * Tests tool implementations via direct LadybugDB queries. * The full LocalBackend.callTool() requires a global registry, * so here we test the security-critical behaviors directly: - * - Write-operation blocking in cypher * - Query execution via the pool * - Parameterized queries preventing injection * - Read-only enforcement * - * Covers hardening fixes: #1 (parameterized queries), #2 (write blocking), - * #3 (path traversal), #4 (relation allowlist), #25 (regex lastIndex), - * #26 (rename first-occurrence-only) + * Covers hardening fixes: #1 (parameterized queries), #3 (path traversal), + * #4 (relation allowlist), #26 (rename first-occurrence-only) */ import { describe, it, expect } from 'vitest'; import { - CYPHER_WRITE_RE, + initLbug, + closeLbug, executeQuery, executeParameterized, - isWriteQuery, } from '../../src/mcp/core/lbug-adapter.js'; import { VALID_RELATION_TYPES } from '../../src/mcp/local/local-backend.js'; import { withTestLbugDB } from '../helpers/test-indexed-db.js'; @@ -29,35 +27,12 @@ import { LOCAL_BACKEND_SEED_DATA } from '../fixtures/local-backend-seed.js'; withTestLbugDB( 'local-backend', (handle) => { - // ─── Cypher write blocking ─────────────────────────────────────────── - - describe('cypher write blocking', () => { - const allWriteKeywords = [ - 'CREATE', - 'DELETE', - 'SET', - 'MERGE', - 'REMOVE', - 'DROP', - 'ALTER', - 'COPY', - 'DETACH', - ]; - - for (const keyword of allWriteKeywords) { - it(`blocks ${keyword} query`, () => { - const blocked = isWriteQuery(`MATCH (n) ${keyword} n.name = "x"`); - expect(blocked).toBe(true); - }); - } - - it('allows valid read queries through the pool', async () => { - const rows = await executeQuery( - handle.repoId, - 'MATCH (n:Function) RETURN n.name AS name ORDER BY n.name', - ); - expect(rows.length).toBeGreaterThanOrEqual(3); - }); + it('allows valid read queries through the pool', async () => { + const rows = await executeQuery( + handle.repoId, + 'MATCH (n:Function) RETURN n.name AS name ORDER BY n.name', + ); + expect(rows.length).toBeGreaterThanOrEqual(3); }); // ─── Parameterized queries ─────────────────────────────────────────── @@ -171,34 +146,27 @@ withTestLbugDB( // ─── Read-only enforcement ─────────────────────────────────────────── describe('read-only database', () => { - it('rejects write operations at DB level', async () => { - await expect( - executeQuery( - handle.repoId, - `CREATE (n:Function {id: 'new', name: 'new', filePath: '', startLine: 0, endLine: 0, isExported: false, content: '', description: ''})`, - ), - ).rejects.toThrow(); - }); - }); - - // ─── Regex lastIndex hardening (#25) ───────────────────────────────── - - describe('regex lastIndex (hardening #25)', () => { - it('CYPHER_WRITE_RE is non-global (no sticky lastIndex)', () => { - expect(CYPHER_WRITE_RE.global).toBe(false); - expect(CYPHER_WRITE_RE.sticky).toBe(false); - }); - - it('works correctly across multiple consecutive calls', () => { - // If the regex were global, lastIndex could cause false results - const results = [ - isWriteQuery('CREATE (n)'), // true - isWriteQuery('MATCH (n) RETURN n'), // false - isWriteQuery('DELETE n'), // true - isWriteQuery('MATCH (n) RETURN n'), // false - isWriteQuery('SET n.x = 1'), // true - ]; - expect(results).toEqual([true, false, true, false, true]); + it('keeps seeded rows unchanged for a no-match write probe', async () => { + const readOnlyRepo = 'local-backend-read-only'; + await initLbug(readOnlyRepo, handle.dbPath); + try { + const rows = await executeParameterized( + readOnlyRepo, + `MATCH (n:Function) WHERE n.name = $target SET n.name = $name RETURN n.name AS name`, + { target: '__missing__', name: 'changed' }, + ); + expect(rows).toEqual([]); + } catch (err) { + expect(String(err)).toMatch(/Write operations are not allowed|read-only database/i); + } + const rows = await executeParameterized( + readOnlyRepo, + 'MATCH (n:Function) WHERE n.name = $name RETURN n.name AS name', + { name: 'login' }, + ); + expect(rows).toHaveLength(1); + expect(rows[0].name).toBe('login'); + await closeLbug(readOnlyRepo); }); }); @@ -215,35 +183,6 @@ withTestLbugDB( }); }); - // ─── Write blocking edge cases ────────────────────────────────────── - - describe('write blocking edge cases', () => { - it('blocks lowercase write keywords (case-insensitive)', () => { - expect(isWriteQuery('create (n:Function {id: "x"})')).toBe(true); - expect(isWriteQuery('delete n')).toBe(true); - expect(isWriteQuery('set n.name = "x"')).toBe(true); - }); - - it('blocks write keyword in CREATED-like words (regex is keyword-boundary unaware)', () => { - // CYPHER_WRITE_RE uses \b word boundaries — "CREATED" does NOT match "CREATE" - const result = isWriteQuery("MATCH (n) WHERE n.name = 'CREATED' RETURN n"); - // The regex uses word boundaries so substring "CREATE" inside "CREATED" is NOT matched - expect(result).toBe(false); - }); - - it('blocks multi-line queries with write keywords', () => { - expect(isWriteQuery('MATCH (n)\nDELETE n')).toBe(true); - }); - - it('returns false for empty string', () => { - expect(isWriteQuery('')).toBe(false); - }); - - it('returns false for whitespace-only query', () => { - expect(isWriteQuery(' ')).toBe(false); - }); - }); - // ─── Query error handling via pool ────────────────────────────────── describe('query error handling via pool', () => { diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index 722916d43..1537c3884 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -3156,6 +3156,59 @@ describe('C++ SFINAE filter — C++20 requires-clause shape', () => { }); }); +describe('C++ SFINAE filter — Tier-A type_traits predicates', () => { + async function runFixture(name: string): Promise { + return runPipelineFromRepo(path.join(FIXTURES, name), () => {}); + } + + function callsFromRunToPick(result: PipelineResult) { + return getRelationships(result, 'CALLS').filter( + (c) => c.source === 'run' && c.target === 'pick', + ); + } + + it('is_pointer_v and is_class_v disambiguate pointer vs class arguments', async () => { + const result = await runFixture('cpp-sfinae-is-pointer'); + const calls = callsFromRunToPick(result); + expect(calls.length).toBe(2); + expect(new Set(calls.map((c) => c.rel.targetId)).size).toBe(2); + }, 60000); + + it('is_reference_v keeps reference-shaped arguments distinct from values', async () => { + const result = await runFixture('cpp-sfinae-is-reference'); + const calls = callsFromRunToPick(result); + expect(calls.length).toBe(2); + expect(new Set(calls.map((c) => c.rel.targetId)).size).toBe(2); + }, 60000); + + it('is_class_v rejects primitive arguments while keeping class arguments', async () => { + const result = await runFixture('cpp-sfinae-is-class'); + const calls = callsFromRunToPick(result); + expect(calls.length).toBe(2); + expect(new Set(calls.map((c) => c.rel.targetId)).size).toBe(2); + }, 60000); + + it('is_enum_v distinguishes known enum declarations from primitives', async () => { + const result = await runFixture('cpp-sfinae-is-enum'); + const calls = callsFromRunToPick(result); + expect(calls.length).toBe(2); + expect(new Set(calls.map((c) => c.rel.targetId)).size).toBe(2); + }, 60000); + + it('is_const_v and is_volatile_v disambiguate cv-qualified locals', async () => { + const result = await runFixture('cpp-sfinae-is-const-volatile'); + const calls = callsFromRunToPick(result); + expect(calls.length).toBe(2); + expect(new Set(calls.map((c) => c.rel.targetId)).size).toBe(2); + }, 60000); + + it('is_void_v does not misclassify void pointers as void values', async () => { + const result = await runFixture('cpp-sfinae-is-void'); + const calls = callsFromRunToPick(result); + expect(calls.length).toBe(1); + }, 60000); +}); + describe('C++ SFINAE filter — unknown predicate keeps both candidates (monotonicity contract)', () => { let result: PipelineResult; diff --git a/gitnexus/test/integration/resolvers/helpers.ts b/gitnexus/test/integration/resolvers/helpers.ts index b4eec6f33..bd5eaa474 100644 --- a/gitnexus/test/integration/resolvers/helpers.ts +++ b/gitnexus/test/integration/resolvers/helpers.ts @@ -34,6 +34,13 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly> overload binds only on integral call sites', 'enable_if_t> overload binds only on floating call sites', 'requires-clause overloads disambiguate same as enable_if_t (F4 AST shape)', + 'is_pointer_v and is_class_v disambiguate pointer vs class arguments', + 'is_reference_v keeps reference-shaped arguments distinct from values', + 'is_class_v rejects primitive arguments while keeping class arguments', + 'is_enum_v distinguishes known enum declarations from primitives', + 'is_const_v and is_volatile_v disambiguate cv-qualified locals', + 'is_void_v does not misclassify void pointers as void values', // The legacy DAG path has no inline-namespace same-name ambiguity // detection. When two inline children declare the same name, the // legacy path picks an arbitrary match. The scope-resolver returns diff --git a/gitnexus/test/integration/resolvers/java.test.ts b/gitnexus/test/integration/resolvers/java.test.ts index 8a813525b..0fc38cdd9 100644 --- a/gitnexus/test/integration/resolvers/java.test.ts +++ b/gitnexus/test/integration/resolvers/java.test.ts @@ -174,6 +174,72 @@ describe('Java call resolution with arity filtering', () => { }); }); +describe('Java same-module priority for duplicate FQNs', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'java-duplicate-fqn-modules'), () => {}); + }, 60000); + + it('resolves Module1App.run calls to module1 UserService, not module2', () => { + const calls = getRelationships(result, 'CALLS'); + const module1ToModule1 = calls.filter( + (c) => + c.source === 'run' && + c.target === 'UserService' && + c.sourceFilePath === 'module1/src/main/java/com/example/Module1App.java' && + c.targetFilePath === 'module1/src/main/java/com/example/UserService.java', + ); + const module1ToModule2 = calls.filter( + (c) => + c.source === 'run' && + c.target === 'UserService' && + c.sourceFilePath === 'module1/src/main/java/com/example/Module1App.java' && + c.targetFilePath === 'module2/src/main/java/com/example/UserService.java', + ); + const module1ToAnyUserService = calls.filter( + (c) => + c.source === 'run' && + c.target === 'UserService' && + c.sourceFilePath === 'module1/src/main/java/com/example/Module1App.java' && + /module[12]\/src\/main\/java\/com\/example\/UserService\.java/.test(c.targetFilePath), + ); + + expect(module1ToModule1.length).toBe(1); + expect(module1ToModule2.length).toBe(0); + expect(module1ToAnyUserService.length).toBe(1); + }); + + it('resolves Module2App.run calls to module2 UserService, not module1', () => { + const calls = getRelationships(result, 'CALLS'); + const module2ToModule2 = calls.filter( + (c) => + c.source === 'run' && + c.target === 'UserService' && + c.sourceFilePath === 'module2/src/main/java/com/example/Module2App.java' && + c.targetFilePath === 'module2/src/main/java/com/example/UserService.java', + ); + const module2ToModule1 = calls.filter( + (c) => + c.source === 'run' && + c.target === 'UserService' && + c.sourceFilePath === 'module2/src/main/java/com/example/Module2App.java' && + c.targetFilePath === 'module1/src/main/java/com/example/UserService.java', + ); + const module2ToAnyUserService = calls.filter( + (c) => + c.source === 'run' && + c.target === 'UserService' && + c.sourceFilePath === 'module2/src/main/java/com/example/Module2App.java' && + /module[12]\/src\/main\/java\/com\/example\/UserService\.java/.test(c.targetFilePath), + ); + + expect(module2ToModule2.length).toBe(1); + expect(module2ToModule1.length).toBe(0); + expect(module2ToAnyUserService.length).toBe(1); + }); +}); + // --------------------------------------------------------------------------- // Member-call resolution: obj.method() resolves through pipeline // --------------------------------------------------------------------------- diff --git a/gitnexus/test/integration/resolvers/typescript.test.ts b/gitnexus/test/integration/resolvers/typescript.test.ts index e41a077c7..8b6e50d20 100644 --- a/gitnexus/test/integration/resolvers/typescript.test.ts +++ b/gitnexus/test/integration/resolvers/typescript.test.ts @@ -2683,6 +2683,44 @@ describe('TypeScript Child extends Parent — inherited method resolution (SM-9) }); }); +// --------------------------------------------------------------------------- +// PR #1657 finding #6: ambient base class — Step 2 MRO ancestor whose body +// is never parsed (declare class). Probes whether the owner-keyed lookup +// can still resolve inherited members on owners that reconcile-ownership +// skipped because they have no parsed body. +// --------------------------------------------------------------------------- + +describe('TypeScript Derived extends declare class AmbientBase — ambient MRO ancestor', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'typescript-ambient-base-class'), + () => {}, + ); + }, 60000); + + it('detects AmbientBase and Derived classes', () => { + const classes = getNodesByLabel(result, 'Class'); + expect(classes).toContain('AmbientBase'); + expect(classes).toContain('Derived'); + }); + + it('emits EXTENDS edge: Derived → AmbientBase', () => { + const extends_ = getRelationships(result, 'EXTENDS'); + expect(edgeSet(extends_)).toContain('Derived → AmbientBase'); + }); + + it('resolves d.ambientMethod() to AmbientBase.ambientMethod via MRO walk', () => { + const calls = getRelationships(result, 'CALLS'); + const ambientCall = calls.find( + (c) => c.target === 'ambientMethod' && c.targetFilePath.includes('ambient.ts'), + ); + expect(ambientCall).toBeDefined(); + expect(ambientCall!.source).toBe('run'); + }); +}); + // --------------------------------------------------------------------------- // PR #1050: tsconfig path alias resolution under registry-primary path // (Adversarial review Finding 1 — `@/services/user` must resolve via tsconfig diff --git a/gitnexus/test/integration/search-core.test.ts b/gitnexus/test/integration/search-core.test.ts index e49169b30..dd092ca72 100644 --- a/gitnexus/test/integration/search-core.test.ts +++ b/gitnexus/test/integration/search-core.test.ts @@ -101,6 +101,15 @@ withTestLbugDB( expect(Array.isArray(results)).toBe(true); }); + it('does not treat write-like words inside search text as write operations (#1608)', async () => { + const { results, ftsAvailable } = await searchFTSFromLbug( + 'create user authentication delete', + 10, + ); + expect(ftsAvailable).toBe(true); + expect(results.length).toBeGreaterThan(0); + }); + it('handles limit of 0', async () => { const { results } = await searchFTSFromLbug('user authentication', 0); expect(results).toEqual([]); diff --git a/gitnexus/test/unit/api-query-readonly-wiring.test.ts b/gitnexus/test/unit/api-query-readonly-wiring.test.ts new file mode 100644 index 000000000..b0a09522f --- /dev/null +++ b/gitnexus/test/unit/api-query-readonly-wiring.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +describe('api query read-only wiring', () => { + it('uses withLbugDb readOnly mode inside handleQueryRequest', async () => { + const source = await fs.readFile( + path.join(__dirname, '..', '..', 'src', 'server', 'api.ts'), + 'utf-8', + ); + expect(source).toMatch(/handleQueryRequest[\s\S]*withLbugDb\([\s\S]*readOnly:\s*true/); + }); + + it('routes /api/query through handleQueryRequest', async () => { + const source = await fs.readFile( + path.join(__dirname, '..', '..', 'src', 'server', 'api.ts'), + 'utf-8', + ); + expect(source).toContain("app.post('/api/query', async (req, res) => {"); + expect(source).toContain('await handleQueryRequest(req, res, resolveRepo);'); + }); + + it('opens Ladybug connection with readOnly option when requested', async () => { + const source = await fs.readFile( + path.join(__dirname, '..', '..', 'src', 'core', 'lbug', 'lbug-adapter.ts'), + 'utf-8', + ); + expect(source).toMatch(/openLbugConnection\(lbug,\s*dbPath,\s*\{\s*readOnly:\s*true\s*\}\)/); + }); +}); diff --git a/gitnexus/test/unit/api-readonly-wiring.test.ts b/gitnexus/test/unit/api-readonly-wiring.test.ts new file mode 100644 index 000000000..7bb8681dc --- /dev/null +++ b/gitnexus/test/unit/api-readonly-wiring.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +/** + * Regression guard for issue: "Cannot open file ... lbug.shadow - Error 2" + * + * Read-only HTTP endpoints (graph, search, grep) must open the LadybugDB with + * `{ readOnly: true }` so the engine never engages the checkpoint machinery + * (`.shadow` sidecar). Write-mode opens for read-only operations were the + * trigger for the Windows-only "Cannot open file ... lbug.shadow" failures + * observed in E2E runs. + * + * If you add another read-only endpoint and forget the option, this file + * fails — keeping the contract explicit at the static-analysis layer. + * + * Companion: api-query-readonly-wiring.test.ts (covers /api/query). + * Precedent: PR #1655 set the pattern for /api/query. + */ +describe('api read-only endpoint wiring', () => { + const readSource = () => + fs.readFile(path.join(__dirname, '..', '..', 'src', 'server', 'api.ts'), 'utf-8'); + + it('/api/graph stream path opens read-only', async () => { + const source = await readSource(); + expect(source).toMatch( + /streamGraphNdjson\(res, includeContent, abortController\.signal\)[\s\S]{0,200}readOnly:\s*true/, + ); + }); + + it('/api/graph non-stream path opens read-only', async () => { + const source = await readSource(); + expect(source).toMatch(/buildGraph\(includeContent\)[\s\S]{0,80}readOnly:\s*true/); + }); + + it('/api/search opens read-only', async () => { + const source = await readSource(); + // The /api/search handler ends its withLbugDb callback with + // `return { searchResults: enriched, ftsAvailable };` immediately before + // the closing brace + options object. Match that suffix to confirm the + // search call site, not /api/query. + expect(source).toMatch(/searchResults: enriched, ftsAvailable[\s\S]{0,80}readOnly:\s*true/); + }); + + it('/api/grep opens read-only', async () => { + const source = await readSource(); + expect(source).toMatch(/MATCH \(n:File\)[\s\S]{0,300}readOnly:\s*true/); + }); + + it('/api/embed remains write-mode (writes embeddings — must not be flipped to readOnly)', async () => { + const source = await readSource(); + // Negative assertion: no `readOnly: true` between the embed job's + // `runEmbeddingPipeline` call site and its withLbugDb open. Embed writes + // back vector rows; flipping this to readOnly would silently break it. + const embedSection = source.match(/runEmbeddingPipeline[\s\S]{0,400}\}\s*\)\s*;[\s\S]{0,200}/); + if (embedSection) { + expect(embedSection[0]).not.toMatch(/readOnly:\s*true/); + } + }); +}); diff --git a/gitnexus/test/unit/bm25-search.test.ts b/gitnexus/test/unit/bm25-search.test.ts index 03a591599..6b878ef1b 100644 --- a/gitnexus/test/unit/bm25-search.test.ts +++ b/gitnexus/test/unit/bm25-search.test.ts @@ -13,9 +13,10 @@ vi.mock('../../src/core/lbug/lbug-adapter.js', async (importOriginal) => { // Pool adapter is dynamically imported by the MCP-pool path of // `searchFTSFromLbug`. We mock it so we can drive the executor without // spinning up a real LadybugDB pool. -const mockExecuteQuery = vi.fn(); +const mockExecuteParameterized = vi.fn(); vi.mock('../../src/core/lbug/pool-adapter.js', () => ({ - executeQuery: (repoId: string, cypher: string) => mockExecuteQuery(repoId, cypher), + executeParameterized: (repoId: string, cypher: string, params: Record) => + mockExecuteParameterized(repoId, cypher, params), addPoolCloseListener: vi.fn(), })); @@ -209,20 +210,22 @@ describe('BM25 search', () => { const REPO = 'test-repo-readonly-fts'; beforeEach(() => { - mockExecuteQuery.mockReset(); + mockExecuteParameterized.mockReset(); }); it('queries existing FTS indexes without issuing CREATE_FTS_INDEX', async () => { - mockExecuteQuery.mockImplementation(async (_repo: string, cypher: string) => { - if (cypher.includes('CREATE_FTS_INDEX')) { - throw new Error('query path must stay read-only'); - } + mockExecuteParameterized.mockImplementation( + async (_repo: string, cypher: string, params: Record) => { + if (cypher.includes('CREATE_FTS_INDEX')) { + throw new Error('query path must stay read-only'); + } - if (cypher.includes("QUERY_FTS_INDEX('Function'")) { - return [{ node: { filePath: 'src/auth.ts', id: 'func:login' }, score: 8 }]; - } - return []; - }); + if (params.query === 'login' && cypher.includes("QUERY_FTS_INDEX('Function'")) { + return [{ node: { filePath: 'src/auth.ts', id: 'func:login' }, score: 8 }]; + } + return []; + }, + ); const { results } = await searchFTSFromLbug('login', 5, REPO); @@ -230,16 +233,35 @@ describe('BM25 search', () => { { filePath: 'src/auth.ts', score: 8, rank: 1, nodeIds: ['func:login'] }, ]); expect( - mockExecuteQuery.mock.calls.some((c) => String(c[1]).includes('CREATE_FTS_INDEX')), + mockExecuteParameterized.mock.calls.some((c) => String(c[1]).includes('CREATE_FTS_INDEX')), ).toBe(false); }); + it('binds FTS user query text as a parameter in pool mode', async () => { + mockExecuteParameterized.mockResolvedValue([]); + + const userQuery = "BrowserWindow create delete set remove 'main' window"; + await searchFTSFromLbug(userQuery, 5, REPO); + + expect(mockExecuteParameterized).toHaveBeenCalled(); + for (const call of mockExecuteParameterized.mock.calls) { + const cypher = String(call[1]); + expect(cypher).toContain('$query'); + expect(cypher).not.toContain(userQuery); + expect(cypher.toUpperCase()).not.toMatch(/\bCREATE\b/); + expect(cypher.toUpperCase()).not.toMatch(/\bDELETE\b/); + expect(cypher.toUpperCase()).not.toMatch(/\bSET\b/); + expect(cypher.toUpperCase()).not.toMatch(/\bREMOVE\b/); + expect(call[2]).toEqual({ query: userQuery }); + } + }); + it('uses the configured FTS query set on every call', async () => { - mockExecuteQuery.mockResolvedValue([]); + mockExecuteParameterized.mockResolvedValue([]); await searchFTSFromLbug('anything', 5, REPO); - const queryCalls = mockExecuteQuery.mock.calls.filter((c) => + const queryCalls = mockExecuteParameterized.mock.calls.filter((c) => String(c[1]).includes('QUERY_FTS_INDEX'), ); expect(queryCalls.map((c) => String(c[1]).match(/QUERY_FTS_INDEX\('([^']+)'/)?.[1])).toEqual([ diff --git a/gitnexus/test/unit/calltool-dispatch.test.ts b/gitnexus/test/unit/calltool-dispatch.test.ts index 8a9a1a629..1ad46ba72 100644 --- a/gitnexus/test/unit/calltool-dispatch.test.ts +++ b/gitnexus/test/unit/calltool-dispatch.test.ts @@ -292,13 +292,14 @@ describe('LocalBackend.callTool', () => { }); it('dispatches cypher tool and blocks write queries', async () => { + (executeParameterized as any).mockRejectedValueOnce(new Error('read-only database')); const result = await backend.callTool('cypher', { query: 'CREATE (n:Test)' }); expect(result).toHaveProperty('error'); expect(result.error).toContain('Write operations'); }); it('dispatches cypher tool with valid read query', async () => { - (executeQuery as any).mockResolvedValue([{ name: 'test', filePath: 'src/test.ts' }]); + (executeParameterized as any).mockResolvedValue([{ name: 'test', filePath: 'src/test.ts' }]); const result = await backend.callTool('cypher', { query: 'MATCH (n:Function) RETURN n.name AS name, n.filePath AS filePath LIMIT 5', }); @@ -999,6 +1000,7 @@ describe('callTool cypher write blocking', () => { for (const query of writeQueries) { it(`blocks write query: ${query.slice(0, 30)}...`, async () => { + (executeParameterized as any).mockRejectedValueOnce(new Error('read-only database')); const result = await backend.callTool('cypher', { query }); expect(result).toHaveProperty('error'); expect(result.error).toContain('Write operations'); @@ -1006,7 +1008,7 @@ describe('callTool cypher write blocking', () => { } it('allows read query through callTool', async () => { - (executeQuery as any).mockResolvedValue([]); + (executeParameterized as any).mockResolvedValue([]); const result = await backend.callTool('cypher', { query: 'MATCH (n:Function) RETURN n.name LIMIT 5', }); @@ -1105,7 +1107,7 @@ describe('cypher result formatting', () => { }); it('formats tabular results as markdown table', async () => { - (executeQuery as any).mockResolvedValue([ + (executeParameterized as any).mockResolvedValue([ { name: 'main', filePath: 'src/index.ts' }, { name: 'helper', filePath: 'src/utils.ts' }, ]); @@ -1119,7 +1121,7 @@ describe('cypher result formatting', () => { }); it('returns empty array as-is', async () => { - (executeQuery as any).mockResolvedValue([]); + (executeParameterized as any).mockResolvedValue([]); const result = await backend.callTool('cypher', { query: 'MATCH (n:Function) RETURN n.name LIMIT 0', }); @@ -1127,7 +1129,7 @@ describe('cypher result formatting', () => { }); it('returns error object when cypher fails', async () => { - (executeQuery as any).mockRejectedValue(new Error('Syntax error')); + (executeParameterized as any).mockRejectedValue(new Error('Syntax error')); const result = await backend.callTool('cypher', { query: 'INVALID CYPHER SYNTAX', }); diff --git a/gitnexus/test/unit/detect-changes-worktree.test.ts b/gitnexus/test/unit/detect-changes-worktree.test.ts index 02e440100..925731118 100644 --- a/gitnexus/test/unit/detect-changes-worktree.test.ts +++ b/gitnexus/test/unit/detect-changes-worktree.test.ts @@ -189,6 +189,81 @@ describe('resolveWorktreeCwd — auto-detection helper', () => { rmSync(repoB, { recursive: true, force: true }); } }); + + it('returns worktreeDir unchanged when repoPath IS a linked worktree and launchCwd is the main checkout', () => { + // Regression for: detect_changes returns no changes when the MCP server + // runs from the main checkout but the resolved repo index is a separately- + // indexed linked worktree (issue #1659 / dpearson2699 report). + // + // Before the fix, resolveWorktreeCwd would detect that launchCwd (main + // checkout) and repoPath (worktree) share the same canonical root and + // wrongly override repoPath with the main-checkout path, causing git diff + // to run from the wrong directory and return 0 changes. + const repoDir = mkdtempSync(path.join(os.tmpdir(), 'gitnexus-rwc-idx-wt-')); + try { + execSync('git init -q', { cwd: repoDir, stdio: 'ignore' }); + execSync('git config user.email "test@example.com"', { cwd: repoDir, stdio: 'ignore' }); + execSync('git config user.name "Test"', { cwd: repoDir, stdio: 'ignore' }); + writeFileSync(path.join(repoDir, 'x.ts'), 'export const x = 1;\n'); + execSync('git add x.ts', { cwd: repoDir, stdio: 'ignore' }); + execSync('git commit -q -m "initial"', { cwd: repoDir, stdio: 'ignore' }); + + const worktreeDir = path.join(repoDir, 'wt-indexed'); + execSync(`git worktree add -q -b indexed "${worktreeDir}"`, { + cwd: repoDir, + stdio: 'ignore', + }); + + // Simulate: repo registry entry points to the worktree (repoPath = worktreeDir) + // but the MCP server was launched from the main checkout (launchCwd = repoDir). + // resolveWorktreeCwd must NOT override the correct worktree path with repoDir. + const result = resolveWorktreeCwd(worktreeDir, repoDir); + expect(realpathSync.native(result)).toBe(realpathSync.native(worktreeDir)); + expect(realpathSync.native(result)).not.toBe(realpathSync.native(repoDir)); + } finally { + try { + execSync('git worktree remove -f wt-indexed', { cwd: repoDir, stdio: 'ignore' }); + } catch { + // ignore + } + rmSync(repoDir, { recursive: true, force: true }); + } + }); + + it('returns worktreeA unchanged when both repoPath and launchCwd are different linked worktrees of the same repo', () => { + // Covers: repoPath = wt-A (indexed), launchCwd = wt-B (server launched from another worktree). + // The guard fires on repoPath being a linked worktree regardless of what launchCwd is, + // so wt-A must be returned unchanged — not wt-B, not the main checkout. + const repoDir = mkdtempSync(path.join(os.tmpdir(), 'gitnexus-rwc-two-wt-')); + try { + execSync('git init -q', { cwd: repoDir, stdio: 'ignore' }); + execSync('git config user.email "test@example.com"', { cwd: repoDir, stdio: 'ignore' }); + execSync('git config user.name "Test"', { cwd: repoDir, stdio: 'ignore' }); + writeFileSync(path.join(repoDir, 'x.ts'), 'export const x = 1;\n'); + execSync('git add x.ts', { cwd: repoDir, stdio: 'ignore' }); + execSync('git commit -q -m "initial"', { cwd: repoDir, stdio: 'ignore' }); + + const worktreeA = path.join(repoDir, 'wt-a'); + const worktreeB = path.join(repoDir, 'wt-b'); + execSync(`git worktree add -q -b branch-a "${worktreeA}"`, { cwd: repoDir, stdio: 'ignore' }); + execSync(`git worktree add -q -b branch-b "${worktreeB}"`, { cwd: repoDir, stdio: 'ignore' }); + + // repoPath = wt-A (the indexed worktree), launchCwd = wt-B (where the server runs). + // resolveWorktreeCwd must return wt-A — the indexed path — unchanged. + const result = resolveWorktreeCwd(worktreeA, worktreeB); + expect(realpathSync.native(result)).toBe(realpathSync.native(worktreeA)); + expect(realpathSync.native(result)).not.toBe(realpathSync.native(worktreeB)); + expect(realpathSync.native(result)).not.toBe(realpathSync.native(repoDir)); + } finally { + try { + execSync('git worktree remove -f wt-a', { cwd: repoDir, stdio: 'ignore' }); + execSync('git worktree remove -f wt-b', { cwd: repoDir, stdio: 'ignore' }); + } catch { + // ignore + } + rmSync(repoDir, { recursive: true, force: true }); + } + }); }); // ── Guard logic via real path arithmetic ───────────────────────────────────── diff --git a/gitnexus/test/unit/eval-formatters.test.ts b/gitnexus/test/unit/eval-formatters.test.ts index d29af3c52..6cefbcdca 100644 --- a/gitnexus/test/unit/eval-formatters.test.ts +++ b/gitnexus/test/unit/eval-formatters.test.ts @@ -13,8 +13,56 @@ import { formatDetectChangesResult, formatListReposResult, MAX_BODY_SIZE, + validateHost, } from '../../src/cli/eval-server.js'; +// ─── validateHost ──────────────────────────────────────────────────── + +describe('validateHost', () => { + it('normalizes "localhost" to "127.0.0.1"', () => { + expect(validateHost('localhost')).toBe('127.0.0.1'); + }); + + it('accepts valid IPv4 addresses', () => { + expect(validateHost('127.0.0.1')).toBe('127.0.0.1'); + expect(validateHost('0.0.0.0')).toBe('0.0.0.0'); + expect(validateHost('192.168.1.5')).toBe('192.168.1.5'); + expect(validateHost('10.0.0.1')).toBe('10.0.0.1'); + }); + + it('accepts valid IPv6 addresses', () => { + expect(validateHost('::1')).toBe('::1'); + expect(validateHost('::')).toBe('::'); + expect(validateHost('2001:db8::1')).toBe('2001:db8::1'); + }); + + it('returns null for a non-IP hostname', () => { + expect(validateHost('foo.bar')).toBeNull(); + expect(validateHost('myhost.local')).toBeNull(); + expect(validateHost('example.com')).toBeNull(); + }); + + it('returns null for out-of-range IPv4 octets', () => { + expect(validateHost('999.999.999.999')).toBeNull(); + expect(validateHost('192.168.1.256')).toBeNull(); + }); + + it('returns null for incomplete IPv4 addresses', () => { + expect(validateHost('192.168.1')).toBeNull(); + expect(validateHost('192.168')).toBeNull(); + }); + + it('returns null for an empty string', () => { + expect(validateHost('')).toBeNull(); + }); + + it('returns null for whitespace or padded IPs', () => { + expect(validateHost(' ')).toBeNull(); + expect(validateHost(' 127.0.0.1')).toBeNull(); + expect(validateHost('127.0.0.1 ')).toBeNull(); + }); +}); + // ─── MAX_BODY_SIZE ─────────────────────────────────────────────────── describe('MAX_BODY_SIZE', () => { diff --git a/gitnexus/test/unit/isWriteQuery.test.ts b/gitnexus/test/unit/isWriteQuery.test.ts deleted file mode 100644 index 899a88c6a..000000000 --- a/gitnexus/test/unit/isWriteQuery.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -// ...existing code... -import { describe, it, expect } from 'vitest'; -import { isWriteQuery as isWriteQueryAdapter } from '../../src/mcp/core/lbug-adapter'; -import { isWriteQuery as isWriteQueryBackend } from '../../src/mcp/local/local-backend'; - -describe('isWriteQuery regex tests', () => { - const writeQueries = [ - 'CREATE (n:Test {name: "x"})', - 'MATCH (n) SET n.x = 1', - 'MERGE (n:Foo {id: 1})', - 'DELETE n', - 'DROP INDEX ON :Foo(prop)', - 'ALTER TABLE Something', - 'COPY TO something', - 'DETACH DELETE n', - ]; - - const readQueries = [ - 'MATCH (n:CreateHelpers) RETURN n', - 'MATCH (a)-[:CALLS]->(b) RETURN a, b', - 'MATCH (f:File)-[r:DEFINES]->(n) RETURN n', - "MATCH (n) WHERE n.name = 'MERGEHelper' RETURN n", // word present as data - 'MATCH (n) RETURN n', - 'MATCH (n) WHERE n.content CONTAINS ":CREATE" RETURN n', - 'MATCH (n:SomethingWithSET) RETURN n', - ]; - - it('adapter isWriteQuery should detect real write queries', () => { - for (const q of writeQueries) { - expect(isWriteQueryAdapter(q), `adapter should detect write for: ${q}`).toBe(true); - } - }); - - it('adapter isWriteQuery should not false-positive on label/rel or data', () => { - for (const q of readQueries) { - expect(isWriteQueryAdapter(q), `adapter false-positive on: ${q}`).toBe(false); - } - }); - - it('backend isWriteQuery should detect real write queries', () => { - for (const q of writeQueries) { - expect(isWriteQueryBackend(q), `backend should detect write for: ${q}`).toBe(true); - } - }); - - it('backend isWriteQuery should not false-positive on label/rel or data', () => { - for (const q of readQueries) { - expect(isWriteQueryBackend(q), `backend false-positive on: ${q}`).toBe(false); - } - }); -}); diff --git a/gitnexus/test/unit/lbug-checkpoint-lifecycle.test.ts b/gitnexus/test/unit/lbug-checkpoint-lifecycle.test.ts index a63c1b67d..286c30a99 100644 --- a/gitnexus/test/unit/lbug-checkpoint-lifecycle.test.ts +++ b/gitnexus/test/unit/lbug-checkpoint-lifecycle.test.ts @@ -10,6 +10,24 @@ const makeOpenMock = () => close: vi.fn(async () => {}), })); +/** Mock prepared statement shape for executePrepared/prepare+execute paths. */ +const makePreparedStatement = (sql: string) => ({ + sql, + isSuccess: () => true, + getErrorMessage: () => '', +}); + +/** Mock connection supporting both query() and prepare/execute() call paths. */ +const makeConn = (runQuery: (sql: string) => Promise) => { + const query = vi.fn(runQuery); + return { + query, + prepare: vi.fn(async (sql: string) => makePreparedStatement(sql)), + execute: vi.fn(async (statement: { sql: string }) => query(statement.sql)), + close: vi.fn(async () => {}), + }; +}; + /** Standard `fs/promises` mock for tests that only need doInitLbug to succeed. */ const mockFsForInit = (dbPath: string) => { const ENOENT_ERROR = makeErrnoError( @@ -50,10 +68,7 @@ describe('lbug adapter CHECKPOINT lifecycle', () => { `ENOENT: no such file or directory, access '${dbPath}'`, ); const queryResult = { getAll: vi.fn(async () => []), close: vi.fn() }; - const conn = { - query: vi.fn(async () => queryResult), - close: vi.fn(async () => {}), - }; + const conn = makeConn(async () => queryResult); const db = { close: vi.fn(async () => {}) }; const unlinkMock = vi.fn(async () => {}); @@ -125,10 +140,7 @@ describe('lbug adapter CHECKPOINT lifecycle', () => { ); const EACCES_ERROR = makeErrnoError('EACCES', `EACCES: permission denied, access '${dbPath}'`); const queryResult = { getAll: vi.fn(async () => []), close: vi.fn() }; - const conn = { - query: vi.fn(async () => queryResult), - close: vi.fn(async () => {}), - }; + const conn = makeConn(async () => queryResult); const db = { close: vi.fn(async () => {}) }; const accessMock = vi.fn(async () => { throw EACCES_ERROR; @@ -194,10 +206,7 @@ describe('lbug adapter CHECKPOINT lifecycle', () => { `ENOENT: no such file or directory, access '${dbPath}'`, ); const queryResult = { getAll: vi.fn(async () => []), close: vi.fn() }; - const conn = { - query: vi.fn(async () => queryResult), - close: vi.fn(async () => {}), - }; + const conn = makeConn(async () => queryResult); const db = { close: vi.fn(async () => {}) }; const accessMock = vi.fn(async () => {}); const unlinkMock = vi.fn(async () => {}); @@ -318,10 +327,7 @@ describe('lbug adapter CHECKPOINT lifecycle', () => { `ENOENT: no such file or directory, access '${dbPath}'`, ); const queryResult = { getAll: vi.fn(async () => []), close: vi.fn() }; - const conn = { - query: vi.fn(async () => queryResult), - close: vi.fn(async () => {}), - }; + const conn = makeConn(async () => queryResult); const db = { close: vi.fn(async () => {}) }; const accessMock = vi.fn(async () => { throw ENOENT_ERROR; @@ -391,10 +397,7 @@ describe('lbug adapter CHECKPOINT lifecycle', () => { `EPERM: operation not permitted, unlink '${dbPath}.shadow'`, ); const queryResult = { getAll: vi.fn(async () => []), close: vi.fn() }; - const conn = { - query: vi.fn(async () => queryResult), - close: vi.fn(async () => {}), - }; + const conn = makeConn(async () => queryResult); const db = { close: vi.fn(async () => {}) }; const accessMock = vi.fn(async () => { throw ENOENT_ERROR; @@ -473,18 +476,16 @@ describe('lbug adapter CHECKPOINT lifecycle', () => { getAll: vi.fn(async () => []), close: vi.fn(), }; - const conn = { - query: vi.fn(async (sql: string) => { - if (sql === 'CHECKPOINT') { - events.push('checkpoint:query'); - return checkpointResult; - } - return genericResult; - }), - close: vi.fn(async () => { - events.push('conn:close'); - }), - }; + const conn = makeConn(async (sql: string) => { + if (sql === 'CHECKPOINT') { + events.push('checkpoint:query'); + return checkpointResult; + } + return genericResult; + }); + conn.close = vi.fn(async () => { + events.push('conn:close'); + }); const db = { close: vi.fn(async () => { events.push('db:close'); @@ -539,16 +540,13 @@ describe('lbug adapter CHECKPOINT lifecycle', () => { getAll: vi.fn(async () => []), close: vi.fn(), }; - const conn = { - query: vi.fn(async (sql: string) => { - if (sql === 'MATCH (n:File) RETURN n.id AS id') { - events.push('query:run'); - return queryResult; - } - return genericResult; - }), - close: vi.fn(async () => {}), - }; + const conn = makeConn(async (sql: string) => { + if (sql === 'MATCH (n:File) RETURN n.id AS id') { + events.push('query:run'); + return queryResult; + } + return genericResult; + }); const db = { close: vi.fn(async () => {}), }; @@ -595,15 +593,12 @@ describe('lbug adapter CHECKPOINT lifecycle', () => { getAll: vi.fn(async () => []), close: vi.fn(), }; - const conn = { - query: vi.fn(async (sql: string) => { - if (sql === 'MATCH (n:File) RETURN n.id AS id') { - return queryResult; - } - return genericResult; - }), - close: vi.fn(async () => {}), - }; + const conn = makeConn(async (sql: string) => { + if (sql === 'MATCH (n:File) RETURN n.id AS id') { + return queryResult; + } + return genericResult; + }); const db = { close: vi.fn(async () => {}), }; @@ -661,15 +656,12 @@ describe('lbug adapter CHECKPOINT lifecycle', () => { getAll: vi.fn(async () => []), close: vi.fn(), }; - const conn = { - query: vi.fn(async (sql: string) => { - if (sql === 'MATCH (n:File) RETURN n.id AS id') { - return [firstResult, secondResult]; - } - return genericResult; - }), - close: vi.fn(async () => {}), - }; + const conn = makeConn(async (sql: string) => { + if (sql === 'MATCH (n:File) RETURN n.id AS id') { + return [firstResult, secondResult]; + } + return genericResult; + }); const db = { close: vi.fn(async () => {}), }; @@ -741,16 +733,13 @@ describe('lbug adapter CHECKPOINT lifecycle', () => { getAll: vi.fn(async () => []), close: vi.fn(), }; - const conn = { - query: vi.fn(async (sql: string) => { - if (sql === 'MATCH (n:File) RETURN n.id AS id') { - events.push('stream:query'); - return [firstResult, secondResult]; - } - return genericResult; - }), - close: vi.fn(async () => {}), - }; + const conn = makeConn(async (sql: string) => { + if (sql === 'MATCH (n:File) RETURN n.id AS id') { + events.push('stream:query'); + return [firstResult, secondResult]; + } + return genericResult; + }); const db = { close: vi.fn(async () => {}), }; @@ -822,16 +811,13 @@ describe('lbug adapter CHECKPOINT lifecycle', () => { getAll: vi.fn(async () => []), close: vi.fn(), }; - const conn = { - query: vi.fn(async (sql: string) => { - if (sql === 'MATCH (n:File) RETURN n.id AS id') { - events.push('stream:query'); - return queryResult; - } - return genericResult; - }), - close: vi.fn(async () => {}), - }; + const conn = makeConn(async (sql: string) => { + if (sql === 'MATCH (n:File) RETURN n.id AS id') { + events.push('stream:query'); + return queryResult; + } + return genericResult; + }); const db = { close: vi.fn(async () => {}), }; diff --git a/gitnexus/test/unit/lbug-pool-win-fts-probe.test.ts b/gitnexus/test/unit/lbug-pool-win-fts-probe.test.ts new file mode 100644 index 000000000..fec1fe471 --- /dev/null +++ b/gitnexus/test/unit/lbug-pool-win-fts-probe.test.ts @@ -0,0 +1,132 @@ +/** + * Unit tests for the Windows FTS probe in pool-adapter.ts. + * + * Covers `hasLocalWinFtsExtension()` — the helper that gates the + * Windows-only skip of `loadFTSExtension` in `doInitLbug` and + * `initLbugWithDb`. Issue #1690 / PR #1692. + * + * The probe is exercised against a real temp filesystem with + * `os.homedir()` spied to point at the tempdir. This tests the + * actual fs surface (readdir/stat semantics, missing-dir behavior, + * zero-byte file handling) rather than mocking fs internals. + * + * The Windows-branch conditional in `doInitLbug` / `initLbugWithDb` + * is intentionally not unit-tested in isolation: those functions + * require a fully constructed `lbug.Database` + `Connection` pool + * and are exercised end-to-end by `test/integration/lbug-pool*.test.ts` + * on the `windows-latest` matrix. The conditional itself is a single + * expression — `(await hasLocalWinFtsExtension()) ? load : true` — + * whose correctness reduces to the probe being correctly tested here. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import os from 'os'; +import path from 'path'; +import fs from 'fs/promises'; + +// Stub out the LadybugDB native loader and its transitive importers so that +// importing pool-adapter.ts in this unit test does not pull in the .node binary +// (which is built by the postinstall script and is not always present in the +// dev install used for unit tests). +vi.mock('@ladybugdb/core', () => ({ + default: { Database: vi.fn(), Connection: vi.fn() }, +})); +vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({ + isReadOnlyDbError: vi.fn(() => false), + loadFTSExtension: vi.fn(), +})); +vi.mock('../../src/core/lbug/lbug-config.js', () => ({ + createLbugDatabase: vi.fn(), + isWalCorruptionError: vi.fn(() => false), + WAL_RECOVERY_SUGGESTION: '', +})); + +import { hasLocalWinFtsExtension } from '../../src/core/lbug/pool-adapter.js'; + +describe('hasLocalWinFtsExtension', () => { + let tmpHome: string; + + beforeEach(async () => { + tmpHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-fts-probe-')); + vi.spyOn(os, 'homedir').mockReturnValue(tmpHome); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await fs.rm(tmpHome, { recursive: true, force: true }); + }); + + it('returns false when ~/.lbdb/extension does not exist', async () => { + // tmpHome is empty; the probe should swallow the readdir ENOENT and return false. + await expect(hasLocalWinFtsExtension()).resolves.toBe(false); + }); + + it('returns false when ~/.lbdb/extension exists but has no version dirs', async () => { + await fs.mkdir(path.join(tmpHome, '.lbdb', 'extension'), { recursive: true }); + await expect(hasLocalWinFtsExtension()).resolves.toBe(false); + }); + + it('returns true when a single version dir contains the FTS binary', async () => { + const ftsDir = path.join(tmpHome, '.lbdb', 'extension', '0.16.0', 'win_amd64', 'fts'); + await fs.mkdir(ftsDir, { recursive: true }); + await fs.writeFile(path.join(ftsDir, 'libfts.lbug_extension'), Buffer.from('mock-binary')); + await expect(hasLocalWinFtsExtension()).resolves.toBe(true); + }); + + it('returns true when the binary is a zero-byte stub (LOAD failure handled downstream)', async () => { + // Empirically verified in #1690 thread: LadybugDB resolves LOAD EXTENSION fts to a + // version-specific path internally and the ExtensionManager's tryLoad try/catch + // catches the resulting load error cleanly. Probe is intentionally generous here; + // safety lives in the loader, not the probe. + const ftsDir = path.join(tmpHome, '.lbdb', 'extension', '0.16.0', 'win_amd64', 'fts'); + await fs.mkdir(ftsDir, { recursive: true }); + await fs.writeFile(path.join(ftsDir, 'libfts.lbug_extension'), ''); + await expect(hasLocalWinFtsExtension()).resolves.toBe(true); + }); + + it('returns true when multiple version dirs exist and only one carries the binary', async () => { + const versions = ['0.15.0', '0.16.0', '0.17.0']; + for (const v of versions) { + await fs.mkdir(path.join(tmpHome, '.lbdb', 'extension', v, 'win_amd64', 'fts'), { + recursive: true, + }); + } + // Only 0.16.0 has the binary; the probe should keep iterating past empty siblings. + await fs.writeFile( + path.join( + tmpHome, + '.lbdb', + 'extension', + '0.16.0', + 'win_amd64', + 'fts', + 'libfts.lbug_extension', + ), + Buffer.from('mock-binary'), + ); + await expect(hasLocalWinFtsExtension()).resolves.toBe(true); + }); + + it('returns false when version dirs exist but none contain the binary', async () => { + // Adversarial topology raised by #1690 review: tree exists (Nix store, Bazel + // sandbox seeding, corporate MDM-prepopulated user dirs) but the actual + // libfts.lbug_extension file is absent. Probe must distinguish file from dir. + const versions = ['0.15.0', '0.16.0', '0.17.0']; + for (const v of versions) { + await fs.mkdir(path.join(tmpHome, '.lbdb', 'extension', v, 'win_amd64', 'fts'), { + recursive: true, + }); + } + await expect(hasLocalWinFtsExtension()).resolves.toBe(false); + }); + + it('returns false when fs.readdir throws (e.g. permission denied on the extension root)', async () => { + // Cover the outer try/catch — any fs error walking the extension root is + // treated as "no binary present", matching the upstream skip-guard intent. + const eaccess = Object.assign(new Error('EACCES: permission denied'), { + code: 'EACCES', + }) as NodeJS.ErrnoException; + vi.spyOn(fs, 'readdir').mockRejectedValue(eaccess); + await expect(hasLocalWinFtsExtension()).resolves.toBe(false); + }); +}); diff --git a/gitnexus/test/unit/mcp-wal-feedback.test.ts b/gitnexus/test/unit/mcp-wal-feedback.test.ts index e387e0b97..710d81369 100644 --- a/gitnexus/test/unit/mcp-wal-feedback.test.ts +++ b/gitnexus/test/unit/mcp-wal-feedback.test.ts @@ -10,7 +10,6 @@ const { lbugMocks, platformMocks, repoMocks } = vi.hoisted(() => ({ executeParameterized: vi.fn(), closeLbug: vi.fn().mockResolvedValue(undefined), isLbugReady: vi.fn().mockReturnValue(true), - isWriteQuery: vi.fn().mockReturnValue(false), }, platformMocks: { isVectorExtensionSupportedByPlatform: vi.fn().mockReturnValue(true), @@ -81,7 +80,6 @@ describe('WAL corruption feedback in MCP responses (#1402)', () => { lbugMocks.executeQuery.mockResolvedValue([]); lbugMocks.executeParameterized.mockResolvedValue([]); lbugMocks.isLbugReady.mockReturnValue(true); - lbugMocks.isWriteQuery.mockReturnValue(false); repoMocks.listRegisteredRepos.mockResolvedValue([MOCK_REPO_ENTRY]); }); @@ -106,7 +104,7 @@ describe('WAL corruption feedback in MCP responses (#1402)', () => { it('cypher returns WAL recoverySuggestion on corrupted WAL error', async () => { const backend = await makeBackend(); - lbugMocks.executeQuery.mockRejectedValueOnce(new Error('Corrupted wal file')); + lbugMocks.executeParameterized.mockRejectedValueOnce(new Error('Corrupted wal file')); const result = await backend.callTool('cypher', { repo: 'test-repo', diff --git a/gitnexus/test/unit/model/field-registry.test.ts b/gitnexus/test/unit/model/field-registry.test.ts index ff1f749b1..8b2789896 100644 --- a/gitnexus/test/unit/model/field-registry.test.ts +++ b/gitnexus/test/unit/model/field-registry.test.ts @@ -41,15 +41,16 @@ describe('FieldRegistry', () => { expect(reg.lookupFieldByOwner('class:Order', 'name')?.nodeId).toBe('prop:Order.name'); }); - it('last-wins on duplicate (ownerNodeId, fieldName) — registry is flat, not an overload list', () => { + it('accumulates multiple defs under the same (ownerNodeId, fieldName)', () => { const reg = createFieldRegistry(); - const first = makeDef({ nodeId: 'prop:User.name#first' }); - const second = makeDef({ nodeId: 'prop:User.name#second' }); + const first = makeDef({ nodeId: 'prop:User.name#first', type: 'Property' }); + const second = makeDef({ nodeId: 'def:User.name#var', type: 'Variable' }); reg.register('class:User', 'name', first); reg.register('class:User', 'name', second); - expect(reg.lookupFieldByOwner('class:User', 'name')?.nodeId).toBe('prop:User.name#second'); + expect(reg.lookupFieldByOwner('class:User', 'name')?.nodeId).toBe('prop:User.name#first'); + expect(reg.lookupAllByOwner('class:User', 'name')).toEqual([first, second]); }); it('clear() empties the registry', () => { diff --git a/gitnexus/test/unit/model/owned-members-lookup.test.ts b/gitnexus/test/unit/model/owned-members-lookup.test.ts new file mode 100644 index 000000000..35ee5d56a --- /dev/null +++ b/gitnexus/test/unit/model/owned-members-lookup.test.ts @@ -0,0 +1,388 @@ +/** + * Step 2 owner-keyed lookup — correctness and perf contract (PR #1656). + */ + +import { describe, it, expect } from 'vitest'; +import type { DefIndex, SymbolDefinition } from 'gitnexus-shared'; +import { + buildFieldRegistry, + buildMethodRegistry, + EvidenceWeights, + buildScopeTree, + buildQualifiedNameIndex, + buildModuleScopeIndex, + buildMethodDispatchIndex, + type RegistryContext, + type Scope, + type ScopeId, + type TypeRef, +} from 'gitnexus-shared'; +import { createSemanticModel } from '../../../src/core/ingestion/model/semantic-model.js'; +import { lookupOwnedMembersByOwner } from '../../../src/core/ingestion/model/owned-members-lookup.js'; + +const mkDef = (overrides: Partial & { nodeId: string }): SymbolDefinition => ({ + nodeId: overrides.nodeId, + filePath: overrides.filePath ?? 'x.ts', + type: overrides.type ?? 'Class', + ...overrides, +}); + +const typeRef = (rawName: string, declaredAtScope: ScopeId): TypeRef => ({ + rawName, + declaredAtScope, + source: 'parameter-annotation', +}); + +describe('lookupOwnedMembersByOwner', () => { + it('returns methods only, fields only, or both without allocating on single-hit paths', () => { + const model = createSemanticModel(); + const save = mkDef({ + nodeId: 'def:User.save', + type: 'Method', + qualifiedName: 'User.save', + ownerId: 'def:User', + }); + const name = mkDef({ + nodeId: 'def:User.name', + type: 'Property', + qualifiedName: 'User.name', + ownerId: 'def:User', + }); + model.methods.register('def:User', 'save', save); + model.fields.register('def:User', 'name', name); + + const methodsOnly = lookupOwnedMembersByOwner(model, 'def:User', 'save'); + expect(methodsOnly).toEqual([save]); + + const fieldsOnly = lookupOwnedMembersByOwner(model, 'def:User', 'name'); + expect(fieldsOnly).toEqual([name]); + + const both = lookupOwnedMembersByOwner(model, 'def:User', 'save'); + expect(both).toEqual([save]); + }); + + it('merges method and field hits under the same (owner, name)', () => { + const model = createSemanticModel(); + const prop = mkDef({ + nodeId: 'prop:User.id', + type: 'Property', + qualifiedName: 'User.id', + ownerId: 'def:User', + }); + const variable = mkDef({ + nodeId: 'def:User.id', + type: 'Variable', + qualifiedName: 'User.id', + ownerId: 'def:User', + }); + model.fields.register('def:User', 'id', prop); + model.fields.register('def:User', 'id', variable); + + expect(lookupOwnedMembersByOwner(model, 'def:User', 'id')).toEqual([prop, variable]); + }); + + it('returns nested-type hits when registered under (owner, simpleName)', () => { + const model = createSemanticModel(); + const inner = mkDef({ + nodeId: 'def:Outer.Inner', + type: 'Class', + qualifiedName: 'Outer.Inner', + ownerId: 'def:Outer', + }); + model.types.registerByOwner('def:Outer', 'Inner', inner); + + expect(lookupOwnedMembersByOwner(model, 'def:Outer', 'Inner')).toEqual([inner]); + }); + + it('merges methods + fields + nested-type hits under the same (owner, name)', () => { + const model = createSemanticModel(); + const method = mkDef({ + nodeId: 'def:Outer.x#method', + type: 'Method', + qualifiedName: 'Outer.x', + ownerId: 'def:Outer', + }); + const field = mkDef({ + nodeId: 'def:Outer.x#field', + type: 'Property', + qualifiedName: 'Outer.x', + ownerId: 'def:Outer', + }); + const nested = mkDef({ + nodeId: 'def:Outer.x#class', + type: 'Class', + qualifiedName: 'Outer.x', + ownerId: 'def:Outer', + }); + model.methods.register('def:Outer', 'x', method); + model.fields.register('def:Outer', 'x', field); + model.types.registerByOwner('def:Outer', 'x', nested); + + expect(lookupOwnedMembersByOwner(model, 'def:Outer', 'x')).toEqual([method, field, nested]); + }); +}); + +describe('Step 2 perf contract', () => { + it('does not scan defs.byId when ownedMembersByOwner is wired', () => { + const userClass = mkDef({ nodeId: 'def:User', type: 'Class', qualifiedName: 'User' }); + const saveMethod = mkDef({ + nodeId: 'def:User.save', + type: 'Method', + qualifiedName: 'User.save', + ownerId: 'def:User', + }); + const trapById = new Map([ + [userClass.nodeId, userClass], + [saveMethod.nodeId, saveMethod], + ]); + trapById.values = () => { + throw new Error('defs.byId.values() must not run when ownedMembersByOwner is provided'); + }; + + const defs: DefIndex = { + byId: trapById, + size: trapById.size, + get: (id) => trapById.get(id), + has: (id) => trapById.has(id), + }; + + const callScope: Scope = { + id: 'scope:call', + parent: null, + kind: 'Module', + range: { startLine: 1, startCol: 0, endLine: 100, endCol: 0 }, + filePath: 'x.ts', + bindings: new Map(), + ownedDefs: [], + imports: [], + typeBindings: new Map([['user', typeRef('User', 'scope:call')]]), + }; + + const model = createSemanticModel(); + model.methods.register('def:User', 'save', saveMethod); + + const ctx: RegistryContext = { + scopes: buildScopeTree([callScope]), + defs, + qualifiedNames: buildQualifiedNameIndex([userClass, saveMethod]), + moduleScopes: buildModuleScopeIndex([]), + methodDispatch: buildMethodDispatchIndex({ + owners: ['def:User'], + computeMro: () => [], + implementsOf: () => [], + }), + ownedMembersByOwner: (ownerDefId, memberName) => + lookupOwnedMembersByOwner(model, ownerDefId, memberName), + providers: {}, + }; + + const results = buildMethodRegistry(ctx).lookup('save', 'scope:call', { + explicitReceiver: { name: 'user' }, + }); + + expect(results).toHaveLength(1); + expect(results[0]!.def).toBe(saveMethod); + expect(results[0]!.evidence.find((e) => e.kind === 'type-binding')?.weight).toBe( + EvidenceWeights.typeBindingByMroDepth[0], + ); + }); + + it('does not scan defs.byId for implicit-self receiver (no explicitReceiver)', () => { + const userClass = mkDef({ nodeId: 'def:User', type: 'Class', qualifiedName: 'User' }); + const saveMethod = mkDef({ + nodeId: 'def:User.save', + type: 'Method', + qualifiedName: 'User.save', + ownerId: 'def:User', + }); + const trapById = new Map([ + [userClass.nodeId, userClass], + [saveMethod.nodeId, saveMethod], + ]); + trapById.values = () => { + throw new Error('defs.byId.values() must not run when ownedMembersByOwner is provided'); + }; + + const defs: DefIndex = { + byId: trapById, + size: trapById.size, + get: (id) => trapById.get(id), + has: (id) => trapById.has(id), + }; + + const moduleScope: Scope = { + id: 'scope:module', + parent: null, + kind: 'Module', + range: { startLine: 1, startCol: 0, endLine: 100, endCol: 0 }, + filePath: 'x.ts', + bindings: new Map(), + ownedDefs: [], + imports: [], + typeBindings: new Map(), + }; + const callScope: Scope = { + id: 'scope:method-body', + parent: 'scope:module', + kind: 'Method', + range: { startLine: 2, startCol: 0, endLine: 99, endCol: 0 }, + filePath: 'x.ts', + bindings: new Map(), + ownedDefs: [], + imports: [], + typeBindings: new Map([['self', typeRef('User', 'scope:method-body')]]), + }; + + const model = createSemanticModel(); + model.methods.register('def:User', 'save', saveMethod); + + const ctx: RegistryContext = { + scopes: buildScopeTree([moduleScope, callScope]), + defs, + qualifiedNames: buildQualifiedNameIndex([userClass, saveMethod]), + moduleScopes: buildModuleScopeIndex([moduleScope]), + methodDispatch: buildMethodDispatchIndex({ + owners: ['def:User'], + computeMro: () => [], + implementsOf: () => [], + }), + ownedMembersByOwner: (ownerDefId, memberName) => + lookupOwnedMembersByOwner(model, ownerDefId, memberName), + providers: {}, + }; + + const results = buildMethodRegistry(ctx).lookup('save', 'scope:method-body', { + explicitReceiver: { name: 'self' }, + }); + + expect(results).toHaveLength(1); + expect(results[0]!.def).toBe(saveMethod); + }); + + it('does not scan defs.byId when walking a 2-level MRO chain', () => { + const parentClass = mkDef({ nodeId: 'def:Parent', type: 'Class', qualifiedName: 'Parent' }); + const childClass = mkDef({ nodeId: 'def:Child', type: 'Class', qualifiedName: 'Child' }); + const parentSave = mkDef({ + nodeId: 'def:Parent.save', + type: 'Method', + qualifiedName: 'Parent.save', + ownerId: 'def:Parent', + }); + const trapById = new Map([ + [parentClass.nodeId, parentClass], + [childClass.nodeId, childClass], + [parentSave.nodeId, parentSave], + ]); + trapById.values = () => { + throw new Error('defs.byId.values() must not run when ownedMembersByOwner is provided'); + }; + + const defs: DefIndex = { + byId: trapById, + size: trapById.size, + get: (id) => trapById.get(id), + has: (id) => trapById.has(id), + }; + + const callScope: Scope = { + id: 'scope:call', + parent: null, + kind: 'Module', + range: { startLine: 1, startCol: 0, endLine: 100, endCol: 0 }, + filePath: 'x.ts', + bindings: new Map(), + ownedDefs: [], + imports: [], + typeBindings: new Map([['c', typeRef('Child', 'scope:call')]]), + }; + + const model = createSemanticModel(); + model.methods.register('def:Parent', 'save', parentSave); + + const ctx: RegistryContext = { + scopes: buildScopeTree([callScope]), + defs, + qualifiedNames: buildQualifiedNameIndex([parentClass, childClass, parentSave]), + moduleScopes: buildModuleScopeIndex([]), + methodDispatch: buildMethodDispatchIndex({ + owners: ['def:Child', 'def:Parent'], + computeMro: (id) => (id === 'def:Child' ? ['def:Parent'] : []), + implementsOf: () => [], + }), + ownedMembersByOwner: (ownerDefId, memberName) => + lookupOwnedMembersByOwner(model, ownerDefId, memberName), + providers: {}, + }; + + const results = buildMethodRegistry(ctx).lookup('save', 'scope:call', { + explicitReceiver: { name: 'c' }, + }); + + expect(results).toHaveLength(1); + expect(results[0]!.def).toBe(parentSave); + expect(results[0]!.evidence.find((e) => e.kind === 'type-binding')?.weight).toBe( + EvidenceWeights.typeBindingByMroDepth[1], + ); + }); + + it('does not scan defs.byId for FieldRegistry reads via Step 2', () => { + const userClass = mkDef({ nodeId: 'def:User', type: 'Class', qualifiedName: 'User' }); + const nameField = mkDef({ + nodeId: 'def:User.name', + type: 'Property', + qualifiedName: 'User.name', + ownerId: 'def:User', + }); + const trapById = new Map([ + [userClass.nodeId, userClass], + [nameField.nodeId, nameField], + ]); + trapById.values = () => { + throw new Error('defs.byId.values() must not run when ownedMembersByOwner is provided'); + }; + + const defs: DefIndex = { + byId: trapById, + size: trapById.size, + get: (id) => trapById.get(id), + has: (id) => trapById.has(id), + }; + + const callScope: Scope = { + id: 'scope:call', + parent: null, + kind: 'Module', + range: { startLine: 1, startCol: 0, endLine: 100, endCol: 0 }, + filePath: 'x.ts', + bindings: new Map(), + ownedDefs: [], + imports: [], + typeBindings: new Map([['user', typeRef('User', 'scope:call')]]), + }; + + const model = createSemanticModel(); + model.fields.register('def:User', 'name', nameField); + + const ctx: RegistryContext = { + scopes: buildScopeTree([callScope]), + defs, + qualifiedNames: buildQualifiedNameIndex([userClass, nameField]), + moduleScopes: buildModuleScopeIndex([]), + methodDispatch: buildMethodDispatchIndex({ + owners: ['def:User'], + computeMro: () => [], + implementsOf: () => [], + }), + ownedMembersByOwner: (ownerDefId, memberName) => + lookupOwnedMembersByOwner(model, ownerDefId, memberName), + providers: {}, + }; + + const results = buildFieldRegistry(ctx).lookup('name', 'scope:call', { + explicitReceiver: { name: 'user' }, + }); + + expect(results).toHaveLength(1); + expect(results[0]!.def).toBe(nameField); + }); +}); diff --git a/gitnexus/test/unit/query-fts-parameterization.test.ts b/gitnexus/test/unit/query-fts-parameterization.test.ts new file mode 100644 index 000000000..14704ac1e --- /dev/null +++ b/gitnexus/test/unit/query-fts-parameterization.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +describe('queryFTS parameterization wiring', () => { + it('binds FTS query text via $query and executePrepared', async () => { + const source = await fs.readFile( + path.join(__dirname, '..', '..', 'src', 'core', 'lbug', 'lbug-adapter.ts'), + 'utf-8', + ); + expect(source).toMatch(/QUERY_FTS_INDEX\('\$\{tableName\}', '\$\{indexName\}', \$query/); + expect(source).toMatch(/executePrepared\(cypher,\s*\{\s*query\s*\}\)/); + }); +}); diff --git a/gitnexus/test/unit/query-params.test.ts b/gitnexus/test/unit/query-params.test.ts new file mode 100644 index 000000000..49aa427ee --- /dev/null +++ b/gitnexus/test/unit/query-params.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { isValidQueryParams } from '../../src/core/lbug/query-params.js'; + +describe('isValidQueryParams', () => { + it('accepts plain objects', () => { + expect(isValidQueryParams({})).toBe(true); + expect(isValidQueryParams({ name: 'main', limit: 10 })).toBe(true); + expect(isValidQueryParams({ enabled: true, score: null })).toBe(true); + expect(isValidQueryParams(Object.create(null))).toBe(true); + }); + + it('rejects null and arrays', () => { + expect(isValidQueryParams(null)).toBe(false); + expect(isValidQueryParams([])).toBe(false); + }); + + it('rejects primitives', () => { + expect(isValidQueryParams('x')).toBe(false); + expect(isValidQueryParams(1)).toBe(false); + expect(isValidQueryParams(false)).toBe(false); + expect(isValidQueryParams(undefined)).toBe(false); + }); + + it('rejects non-plain objects and non-scalar values', () => { + expect(isValidQueryParams(new Date())).toBe(false); + expect(isValidQueryParams(new Map())).toBe(false); + expect(isValidQueryParams({ nested: { value: 1 } })).toBe(false); + expect(isValidQueryParams({ list: ['x'] })).toBe(false); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/cpp/cpp-constraint.test.ts b/gitnexus/test/unit/scope-resolution/cpp/cpp-constraint.test.ts index 2edc7b74b..032b8b0eb 100644 --- a/gitnexus/test/unit/scope-resolution/cpp/cpp-constraint.test.ts +++ b/gitnexus/test/unit/scope-resolution/cpp/cpp-constraint.test.ts @@ -19,7 +19,7 @@ import { evaluateForTest, getRegistrySize, } from '../../../../src/core/ingestion/languages/cpp/constraint-filter.js'; -import type { ArityVerdict, SymbolDefinition } from 'gitnexus-shared'; +import type { ArityVerdict, ParameterTypeClass, SymbolDefinition } from 'gitnexus-shared'; function templateConstraintsFor(src: string): CppConstraintPayload | undefined { const matches = emitCppScopeCaptures(src, 'test.cpp'); @@ -201,11 +201,26 @@ describe('evaluate — Kleene 3-valued truth table', () => { // ─── Section 3: Predicate registry ───────────────────────────────────────── describe('Tier-A predicate registry', () => { - it('registry size is exactly 4 (surface-guard against accidental adds)', () => { - expect(getRegistrySize()).toBe(4); + it('registry size is exactly 11 (surface-guard against accidental adds)', () => { + expect(getRegistrySize()).toBe(11); }); - function verdict(name: string, args: string[], argumentTypes: readonly string[]): ArityVerdict { + const shape = ( + base: string, + indirection: ParameterTypeClass['indirection'] = 'value', + cv: ParameterTypeClass['cv'] = 'none', + pointerDepth = indirection === 'pointer' ? 1 : 0, + ): ParameterTypeClass => ({ base, cv, indirection, pointerDepth }); + + function verdict( + name: string, + args: string[], + argumentTypes: readonly string[], + opts: { + readonly argumentTypeClasses?: readonly ParameterTypeClass[]; + readonly parameterTypeClasses?: readonly ParameterTypeClass[]; + } = {}, + ): ArityVerdict { const payload: CppConstraintPayload = { templateParams: args, paramArgIndex: Object.fromEntries(args.map((a, i) => [a, i])), @@ -216,8 +231,16 @@ describe('Tier-A predicate registry', () => { filePath: 'x.cpp', type: 'Function', templateConstraints: payload, + ...(opts.parameterTypeClasses !== undefined + ? { parameterTypeClasses: opts.parameterTypeClasses } + : {}), }; - return cppConstraintCompatibility({ arity: argumentTypes.length }, def, { argumentTypes }); + return cppConstraintCompatibility({ arity: argumentTypes.length }, def, { + argumentTypes, + ...(opts.argumentTypeClasses !== undefined + ? { argumentTypeClasses: opts.argumentTypeClasses } + : {}), + }); } it('is_integral_v matches int, rejects double, unknown for blank', () => { @@ -257,6 +280,117 @@ describe('Tier-A predicate registry', () => { expect(verdict('is_same_v', ['A', 'B'], ['char', 'int'])).toBe('incompatible'); }); + it('is_void_v matches void, rejects int, unknown for blank', () => { + expect(verdict('is_void_v', ['T'], ['void'])).toBe('compatible'); + expect(verdict('is_void_v', ['T'], ['int'])).toBe('incompatible'); + expect(verdict('is_void_v', ['T'], [''])).toBe('unknown'); + }); + + it('is_enum_v matches known enum tokens, rejects class, unknown for blank', () => { + expect( + verdict('is_enum_v', ['T'], ['Color'], { + argumentTypeClasses: [shape('enum:Color')], + parameterTypeClasses: [shape('T')], + }), + ).toBe('compatible'); + expect(verdict('is_enum_v', ['T'], ['Widget'])).toBe('incompatible'); + expect(verdict('is_enum_v', ['T'], [''])).toBe('unknown'); + }); + + it('is_class_v matches class-like tokens, rejects primitives, unknown for blank', () => { + expect(verdict('is_class_v', ['T'], ['Widget'])).toBe('compatible'); + expect(verdict('is_class_v', ['T'], ['int'])).toBe('incompatible'); + expect(verdict('is_class_v', ['T'], [''])).toBe('unknown'); + }); + + it('is_pointer_v uses the argument type-class sidecar conservatively', () => { + expect( + verdict('is_pointer_v', ['T'], ['int'], { + argumentTypeClasses: [shape('int', 'pointer')], + parameterTypeClasses: [shape('T')], + }), + ).toBe('compatible'); + expect( + verdict('is_pointer_v', ['T'], ['int'], { + argumentTypeClasses: [shape('int')], + parameterTypeClasses: [shape('T')], + }), + ).toBe('incompatible'); + expect(verdict('is_pointer_v', ['T'], ['int'])).toBe('unknown'); + expect( + verdict('is_pointer_v', ['T'], ['int'], { + argumentTypeClasses: [shape('int', 'unknown', 'none')], + parameterTypeClasses: [shape('T')], + }), + ).toBe('unknown'); + }); + + it('is_reference_v uses the argument type-class sidecar conservatively', () => { + expect( + verdict('is_reference_v', ['T'], ['int'], { + argumentTypeClasses: [shape('int', 'lvalue-ref')], + parameterTypeClasses: [shape('T')], + }), + ).toBe('compatible'); + expect( + verdict('is_reference_v', ['T'], ['int'], { + argumentTypeClasses: [shape('int')], + parameterTypeClasses: [shape('T')], + }), + ).toBe('incompatible'); + expect(verdict('is_reference_v', ['T'], ['int'])).toBe('unknown'); + expect( + verdict('is_reference_v', ['T'], ['int'], { + argumentTypeClasses: [shape('int', 'unknown', 'none')], + parameterTypeClasses: [shape('T')], + }), + ).toBe('unknown'); + }); + + it('is_const_v and is_volatile_v read top-level cv from the sidecar conservatively', () => { + expect( + verdict('is_const_v', ['T'], ['int'], { + argumentTypeClasses: [shape('int', 'value', 'const')], + parameterTypeClasses: [shape('T')], + }), + ).toBe('compatible'); + expect( + verdict('is_const_v', ['T'], ['int'], { + argumentTypeClasses: [shape('int')], + parameterTypeClasses: [shape('T')], + }), + ).toBe('incompatible'); + expect(verdict('is_const_v', ['T'], ['int'])).toBe('unknown'); + expect( + verdict('is_volatile_v', ['T'], ['int'], { + argumentTypeClasses: [shape('int', 'value', 'volatile')], + parameterTypeClasses: [shape('T')], + }), + ).toBe('compatible'); + expect(verdict('is_volatile_v', ['T'], ['int'])).toBe('unknown'); + expect( + verdict('is_const_v', ['T'], ['int'], { + argumentTypeClasses: [shape('int', 'pointer', 'const')], + parameterTypeClasses: [shape('T')], + }), + ).toBe('unknown'); + expect( + verdict('is_const_v', ['T'], ['int'], { + argumentTypeClasses: [shape('int', 'value', 'unknown')], + parameterTypeClasses: [shape('T')], + }), + ).toBe('unknown'); + }); + + it('shape-sensitive predicates stay unknown when T is not the whole parameter type', () => { + expect( + verdict('is_pointer_v', ['T'], ['int'], { + argumentTypeClasses: [shape('int', 'pointer')], + parameterTypeClasses: [shape('T', 'pointer')], + }), + ).toBe('unknown'); + }); + it('unregistered predicate yields unknown (monotonicity)', () => { expect(verdict('__not_in_registry__', ['T'], ['int'])).toBe('unknown'); }); diff --git a/gitnexus/test/unit/scope-resolution/reconcile-ownership.test.ts b/gitnexus/test/unit/scope-resolution/reconcile-ownership.test.ts index bc8cd5a71..b466c8ed4 100644 --- a/gitnexus/test/unit/scope-resolution/reconcile-ownership.test.ts +++ b/gitnexus/test/unit/scope-resolution/reconcile-ownership.test.ts @@ -111,6 +111,54 @@ describe('reconcileOwnership', () => { expect(model.fields.lookupFieldByOwner('def:User', 'tag')).toBe(attr); }); + it('registers Const and Static owned members into FieldRegistry', () => { + const model = createSemanticModel(); + const maxConst = mkProperty({ + nodeId: 'def:User.MAX', + filePath: 'models.py', + name: 'MAX', + ownerId: 'def:User', + type: 'Const', + }); + const counter = mkProperty({ + nodeId: 'def:User.counter', + filePath: 'models.py', + name: 'counter', + ownerId: 'def:User', + type: 'Static', + }); + const file = mkFile('models.py', [maxConst, counter]); + + const stats = reconcileOwnership([file], model); + + expect(stats.fieldsRegistered).toBe(2); + expect(model.fields.lookupAllByOwner('def:User', 'MAX')).toEqual([maxConst]); + expect(model.fields.lookupAllByOwner('def:User', 'counter')).toEqual([counter]); + }); + + it('keeps distinct field-kind defs that share (ownerId, simpleName)', () => { + const model = createSemanticModel(); + const legacyProp = mkProperty({ + nodeId: 'prop:User.name', + filePath: 'models.py', + name: 'name', + ownerId: 'def:User', + type: 'Property', + }); + const reconciledVar = mkProperty({ + nodeId: 'def:User.name', + filePath: 'models.py', + name: 'name', + ownerId: 'def:User', + type: 'Variable', + }); + const file = mkFile('models.py', [legacyProp, reconciledVar]); + + reconcileOwnership([file], model); + + expect(model.fields.lookupAllByOwner('def:User', 'name')).toEqual([legacyProp, reconciledVar]); + }); + it('skips defs without ownerId (top-level functions)', () => { const model = createSemanticModel(); const topLevel = mkMethod({ @@ -164,6 +212,61 @@ describe('reconcileOwnership', () => { expect(model.methods.lookupAllByOwner('def:User', 'save')).toHaveLength(1); }); + it('registers nested class-like types (Class/Enum/Interface) into TypeRegistry by owner', () => { + const model = createSemanticModel(); + const inner: SymbolDefinition = { + nodeId: 'def:Outer.Inner', + filePath: 'm.ts', + type: 'Class', + qualifiedName: 'Outer.Inner', + ownerId: 'def:Outer', + }; + const status: SymbolDefinition = { + nodeId: 'def:Outer.Status', + filePath: 'm.ts', + type: 'Enum', + qualifiedName: 'Outer.Status', + ownerId: 'def:Outer', + }; + const visitor: SymbolDefinition = { + nodeId: 'def:Outer.Visitor', + filePath: 'm.ts', + type: 'Interface', + qualifiedName: 'Outer.Visitor', + ownerId: 'def:Outer', + }; + const file = mkFile('m.ts', [inner, status, visitor]); + + const stats = reconcileOwnership([file], model); + + expect(stats.nestedTypesRegistered).toBe(3); + expect(stats.methodsRegistered).toBe(0); + expect(stats.fieldsRegistered).toBe(0); + expect(model.types.lookupAllByOwner('def:Outer', 'Inner')).toEqual([inner]); + expect(model.types.lookupAllByOwner('def:Outer', 'Status')).toEqual([status]); + expect(model.types.lookupAllByOwner('def:Outer', 'Visitor')).toEqual([visitor]); + }); + + it('is idempotent for nested type registration', () => { + const model = createSemanticModel(); + const inner: SymbolDefinition = { + nodeId: 'def:Outer.Inner', + filePath: 'm.ts', + type: 'Class', + qualifiedName: 'Outer.Inner', + ownerId: 'def:Outer', + }; + const file = mkFile('m.ts', [inner]); + + const first = reconcileOwnership([file], model); + const second = reconcileOwnership([file], model); + + expect(first.nestedTypesRegistered).toBe(1); + expect(second.nestedTypesRegistered).toBe(0); + expect(second.skippedAlreadyPresent).toBe(1); + expect(model.types.lookupAllByOwner('def:Outer', 'Inner')).toHaveLength(1); + }); + it('registers multiple overloads under the same (owner, name)', () => { const model = createSemanticModel(); const log1 = mkMethod({ diff --git a/gitnexus/test/unit/scope-resolution/registries.test.ts b/gitnexus/test/unit/scope-resolution/registries.test.ts index 2681af69f..101fecf98 100644 --- a/gitnexus/test/unit/scope-resolution/registries.test.ts +++ b/gitnexus/test/unit/scope-resolution/registries.test.ts @@ -100,6 +100,7 @@ function makeCtx( opts: { mro?: Record; implsByInterface?: Record; + ownedMembersByOwner?: RegistryContext['ownedMembersByOwner']; arity?: ( callsite: { arity: number }, def: SymbolDefinition, @@ -125,11 +126,25 @@ function makeCtx( return out; }, }); + // Default hook: scan supplied defs by (ownerId, simpleName) — the same + // semantics the byId fallback used to provide. Tests that need a custom + // hook override via opts.ownedMembersByOwner. + const defaultOwnedMembersByOwner = (ownerDefId: string, memberName: string) => { + const out: SymbolDefinition[] = []; + for (const def of defs) { + if (def.ownerId !== ownerDefId) continue; + const dot = def.qualifiedName?.lastIndexOf('.') ?? -1; + const simple = dot === -1 ? def.qualifiedName : def.qualifiedName?.slice(dot + 1); + if (simple === memberName) out.push(def); + } + return out; + }; return { scopes: buildScopeTree(scopes), defs: defIndex, qualifiedNames: qualifiedNameIndex, moduleScopes, + ownedMembersByOwner: opts.ownedMembersByOwner ?? defaultOwnedMembersByOwner, methodDispatch, providers: opts.arity !== undefined ? { arityCompatibility: opts.arity } : {}, }; @@ -573,6 +588,184 @@ describe('Step 3: owner-scoped contributor', () => { // ─── Step 2: type-binding / MRO walk ─────────────────────────────────────── describe('Step 2: type-binding + MRO walk', () => { + it('uses ownedMembersByOwner before falling back to defs scans', () => { + const userClass = mkDef({ nodeId: 'def:User', type: 'Class', qualifiedName: 'User' }); + const saveMethod = mkDef({ + nodeId: 'def:User.save', + type: 'Method', + qualifiedName: 'User.save', + ownerId: 'def:User', + }); + const callScope = mkScope({ + id: 'scope:call', + parent: null, + typeBindings: { user: typeRef('User', 'scope:call') }, + }); + const ctx = makeCtx([callScope], [userClass], { + ownedMembersByOwner: (ownerDefId, memberName) => + ownerDefId === 'def:User' && memberName === 'save' ? [saveMethod] : [], + }); + + const results = buildMethodRegistry(ctx).lookup('save', 'scope:call', { + explicitReceiver: { name: 'user' }, + }); + + expect(results).toHaveLength(1); + expect(results[0]!.def).toBe(saveMethod); + expect(evidenceOfKind(results[0]!, 'type-binding')?.weight).toBe( + EvidenceWeights.typeBindingByMroDepth[0], + ); + }); + + it('keeps hook-provided overloads available for arity filtering', () => { + const userClass = mkDef({ nodeId: 'def:User', type: 'Class', qualifiedName: 'User' }); + const saveOne = mkDef({ + nodeId: 'def:User.save1', + type: 'Method', + qualifiedName: 'User.save', + ownerId: 'def:User', + parameterCount: 1, + }); + const saveTwo = mkDef({ + nodeId: 'def:User.save2', + type: 'Method', + qualifiedName: 'User.save', + ownerId: 'def:User', + parameterCount: 2, + }); + const callScope = mkScope({ + id: 'scope:call', + parent: null, + typeBindings: { user: typeRef('User', 'scope:call') }, + }); + const ctx = makeCtx([callScope], [userClass], { + ownedMembersByOwner: (ownerDefId, memberName) => + ownerDefId === 'def:User' && memberName === 'save' ? [saveTwo, saveOne] : [], + arity: (callsite, def) => + (def.parameterCount ?? 0) === callsite.arity ? 'compatible' : 'incompatible', + }); + + const results = buildMethodRegistry(ctx).lookup('save', 'scope:call', { + explicitReceiver: { name: 'user' }, + callsite: { arity: 1 }, + }); + + expect(results).toHaveLength(1); + expect(results[0]!.def).toBe(saveOne); + }); + + it('resolves field members from ownedMembersByOwner through accepted-kind filtering', () => { + const userClass = mkDef({ nodeId: 'def:User', type: 'Class', qualifiedName: 'User' }); + const nameField = mkDef({ + nodeId: 'def:User.name', + type: 'Property', + qualifiedName: 'User.name', + ownerId: 'def:User', + }); + const readScope = mkScope({ + id: 'scope:read', + parent: null, + typeBindings: { user: typeRef('User', 'scope:read') }, + }); + const ctx = makeCtx([readScope], [userClass], { + ownedMembersByOwner: (ownerDefId, memberName) => + ownerDefId === 'def:User' && memberName === 'name' ? [nameField] : [], + }); + + const results = buildFieldRegistry(ctx).lookup('name', 'scope:read', { + explicitReceiver: { name: 'user' }, + }); + + expect(results).toHaveLength(1); + expect(results[0]!.def).toBe(nameField); + }); + + it('resolves Const members from ownedMembersByOwner through accepted-kind filtering', () => { + const userClass = mkDef({ nodeId: 'def:User', type: 'Class', qualifiedName: 'User' }); + const maxConst = mkDef({ + nodeId: 'def:User.MAX', + type: 'Const', + qualifiedName: 'User.MAX', + ownerId: 'def:User', + }); + const readScope = mkScope({ + id: 'scope:read', + parent: null, + typeBindings: { user: typeRef('User', 'scope:read') }, + }); + const ctx = makeCtx([readScope], [userClass], { + ownedMembersByOwner: (ownerDefId, memberName) => + ownerDefId === 'def:User' && memberName === 'MAX' ? [maxConst] : [], + }); + + const results = buildFieldRegistry(ctx).lookup('MAX', 'scope:read', { + explicitReceiver: { name: 'user' }, + }); + + expect(results).toHaveLength(1); + expect(results[0]!.def).toBe(maxConst); + }); + + it('resolves Static members from ownedMembersByOwner through accepted-kind filtering', () => { + const userClass = mkDef({ nodeId: 'def:User', type: 'Class', qualifiedName: 'User' }); + const counterStatic = mkDef({ + nodeId: 'def:User.counter', + type: 'Static', + qualifiedName: 'User.counter', + ownerId: 'def:User', + }); + const readScope = mkScope({ + id: 'scope:read', + parent: null, + typeBindings: { user: typeRef('User', 'scope:read') }, + }); + const ctx = makeCtx([readScope], [userClass], { + ownedMembersByOwner: (ownerDefId, memberName) => + ownerDefId === 'def:User' && memberName === 'counter' ? [counterStatic] : [], + }); + + const results = buildFieldRegistry(ctx).lookup('counter', 'scope:read', { + explicitReceiver: { name: 'user' }, + }); + + expect(results).toHaveLength(1); + expect(results[0]!.def).toBe(counterStatic); + }); + + it('returns every hook-provided field kind that shares (owner, name)', () => { + const userClass = mkDef({ nodeId: 'def:User', type: 'Class', qualifiedName: 'User' }); + const legacyProp = mkDef({ + nodeId: 'prop:User.name', + type: 'Property', + qualifiedName: 'User.name', + ownerId: 'def:User', + }); + const reconciledVar = mkDef({ + nodeId: 'def:User.name', + type: 'Variable', + qualifiedName: 'User.name', + ownerId: 'def:User', + }); + const readScope = mkScope({ + id: 'scope:read', + parent: null, + typeBindings: { user: typeRef('User', 'scope:read') }, + }); + const ctx = makeCtx([readScope], [userClass], { + ownedMembersByOwner: (ownerDefId, memberName) => + ownerDefId === 'def:User' && memberName === 'name' ? [legacyProp, reconciledVar] : [], + }); + + const results = buildFieldRegistry(ctx).lookup('name', 'scope:read', { + explicitReceiver: { name: 'user' }, + }); + + expect(results).toHaveLength(2); + expect(results.map((r) => r.def.nodeId).sort()).toEqual( + [legacyProp.nodeId, reconciledVar.nodeId].sort(), + ); + }); + it('emits type-binding evidence with MRO-depth-decayed weight (explicit receiver)', () => { const userClass = mkDef({ nodeId: 'def:User', type: 'Class', qualifiedName: 'User' }); const saveMethod = mkDef({ diff --git a/gitnexus/test/unit/scope-resolution/resolve-references.test.ts b/gitnexus/test/unit/scope-resolution/resolve-references.test.ts new file mode 100644 index 000000000..e5524d9e6 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/resolve-references.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it } from 'vitest'; +import { + buildDefIndex, + buildMethodDispatchIndex, + buildModuleScopeIndex, + buildQualifiedNameIndex, + buildScopeTree, + type BindingRef, + type Range, + type ReferenceSite, + type Scope, + type ScopeId, + type SymbolDefinition, + type TypeRef, +} from 'gitnexus-shared'; +import { resolveReferenceSites } from '../../../src/core/ingestion/resolve-references.js'; +import type { ScopeResolutionIndexes } from '../../../src/core/ingestion/model/scope-resolution-indexes.js'; + +const range = (sl = 1, sc = 0, el = 100, ec = 0): Range => ({ + startLine: sl, + startCol: sc, + endLine: el, + endCol: ec, +}); + +const mkDef = (overrides: Partial & { nodeId: string }): SymbolDefinition => ({ + nodeId: overrides.nodeId, + filePath: overrides.filePath ?? 'x.ts', + type: overrides.type ?? 'Class', + ...overrides, +}); + +const mkScope = (input: { + id: ScopeId; + parent: ScopeId | null; + kind?: Scope['kind']; + filePath?: string; + range?: Range; + bindings?: Record; + typeBindings?: Record; + ownedDefs?: readonly SymbolDefinition[]; +}): Scope => ({ + id: input.id, + parent: input.parent, + kind: input.kind ?? 'Module', + filePath: input.filePath ?? 'x.ts', + range: input.range ?? range(), + bindings: new Map(Object.entries(input.bindings ?? {})), + imports: [], + typeBindings: new Map(Object.entries(input.typeBindings ?? {})), + ownedDefs: input.ownedDefs ?? [], +}); + +const typeRef = (rawName: string, declaredAtScope: ScopeId): TypeRef => ({ + rawName, + declaredAtScope, + source: 'parameter-annotation', +}); + +function makeIndexes( + scopes: Scope[], + defs: SymbolDefinition[], + referenceSites: readonly ReferenceSite[], + mro: Record = {}, +): ScopeResolutionIndexes { + return { + scopeTree: buildScopeTree(scopes), + defs: buildDefIndex(defs), + qualifiedNames: buildQualifiedNameIndex(defs), + moduleScopes: buildModuleScopeIndex( + scopes + .filter((scope) => scope.kind === 'Module') + .map((scope) => ({ filePath: scope.filePath, moduleScopeId: scope.id })), + ), + methodDispatch: buildMethodDispatchIndex({ + owners: Array.from(new Set(defs.map((def) => def.nodeId))), + computeMro: (owner) => mro[owner] ?? [], + implementsOf: () => [], + }), + imports: new Map(), + bindings: new Map(), + bindingAugmentations: new Map(), + referenceSites, + sccs: [], + stats: { + totalFiles: 0, + totalEdges: 0, + linkedEdges: 0, + unresolvedEdges: 0, + sccCount: 0, + largestSccSize: 0, + }, + }; +} + +describe('resolveReferenceSites', () => { + it('uses ownedMembersByOwner to resolve a hook-provided receiver member', () => { + const userClass = mkDef({ nodeId: 'def:User', type: 'Class', qualifiedName: 'User' }); + const saveMethod = mkDef({ + nodeId: 'def:User.save', + type: 'Method', + qualifiedName: 'User.save', + ownerId: 'def:User', + }); + const scope = mkScope({ + id: 'scope:call', + parent: null, + typeBindings: { user: typeRef('User', 'scope:call') }, + }); + const referenceSite: ReferenceSite = { + name: 'save', + atRange: range(5, 2, 5, 6), + inScope: 'scope:call', + kind: 'call', + explicitReceiver: { name: 'user' }, + arity: 0, + }; + const indexes = makeIndexes([scope], [userClass], [referenceSite]); + + const result = resolveReferenceSites({ + scopes: indexes, + ownedMembersByOwner: (ownerDefId, memberName) => + ownerDefId === 'def:User' && memberName === 'save' ? [saveMethod] : [], + }); + + expect(result.stats).toEqual({ sitesProcessed: 1, referencesEmitted: 1, unresolved: 0 }); + expect(result.referenceIndex.bySourceScope.get('scope:call')).toHaveLength(1); + expect(result.referenceIndex.bySourceScope.get('scope:call')?.[0]?.toDef).toBe('def:User.save'); + }); + + it('threads providers.arityCompatibility through to filter hook-provided overloads', () => { + const userClass = mkDef({ nodeId: 'def:User', type: 'Class', qualifiedName: 'User' }); + const saveOne = mkDef({ + nodeId: 'def:User.save#1', + type: 'Method', + qualifiedName: 'User.save', + ownerId: 'def:User', + parameterCount: 1, + }); + const saveTwo = mkDef({ + nodeId: 'def:User.save#2', + type: 'Method', + qualifiedName: 'User.save', + ownerId: 'def:User', + parameterCount: 2, + }); + const scope = mkScope({ + id: 'scope:call', + parent: null, + typeBindings: { user: typeRef('User', 'scope:call') }, + }); + const referenceSite: ReferenceSite = { + name: 'save', + atRange: range(5, 2, 5, 6), + inScope: 'scope:call', + kind: 'call', + explicitReceiver: { name: 'user' }, + arity: 1, + }; + const indexes = makeIndexes([scope], [userClass], [referenceSite]); + + const result = resolveReferenceSites({ + scopes: indexes, + ownedMembersByOwner: (ownerDefId, memberName) => + ownerDefId === 'def:User' && memberName === 'save' ? [saveOne, saveTwo] : [], + providers: { + arityCompatibility: (callsite, def) => + def.parameterCount === callsite.arity ? 'compatible' : 'incompatible', + }, + }); + + expect(result.stats).toEqual({ sitesProcessed: 1, referencesEmitted: 1, unresolved: 0 }); + expect(result.referenceIndex.bySourceScope.get('scope:call')).toHaveLength(1); + expect(result.referenceIndex.bySourceScope.get('scope:call')?.[0]?.toDef).toBe( + 'def:User.save#1', + ); + }); +}); diff --git a/gitnexus/test/unit/security.test.ts b/gitnexus/test/unit/security.test.ts index 0adee1915..44a2f33e7 100644 --- a/gitnexus/test/unit/security.test.ts +++ b/gitnexus/test/unit/security.test.ts @@ -1,11 +1,9 @@ /** * P0 Unit Tests: Security Hardening * - * Tests all security hardening in isolation: - * - Write blocking (CYPHER_WRITE_RE) + * Tests security-related utility helpers in isolation: * - Relation type allowlist * - Path traversal detection - * - isWriteQuery wrapper * - isTestFilePath patterns */ import { describe, it, expect } from 'vitest'; @@ -14,93 +12,6 @@ import { VALID_NODE_LABELS, isTestFilePath, } from '../../src/mcp/local/local-backend.js'; -import { CYPHER_WRITE_RE, isWriteQuery } from '../../src/mcp/core/lbug-adapter.js'; - -// ─── Write-operation blocking (CYPHER_WRITE_RE) ────────────────────── - -describe('CYPHER_WRITE_RE', () => { - const writeKeywords = [ - 'CREATE', - 'DELETE', - 'SET', - 'MERGE', - 'REMOVE', - 'DROP', - 'ALTER', - 'COPY', - 'DETACH', - ]; - - for (const keyword of writeKeywords) { - it(`matches "${keyword}" (uppercase)`, () => { - expect(CYPHER_WRITE_RE.test(`${keyword} (n:Node)`)).toBe(true); - }); - - it(`matches "${keyword.toLowerCase()}" (lowercase)`, () => { - expect(CYPHER_WRITE_RE.test(`${keyword.toLowerCase()} (n:Node)`)).toBe(true); - }); - - it(`matches "${keyword[0] + keyword.slice(1).toLowerCase()}" (mixed case)`, () => { - const mixed = keyword[0] + keyword.slice(1).toLowerCase(); - expect(CYPHER_WRITE_RE.test(`${mixed} (n:Node)`)).toBe(true); - }); - } - - // Safe read queries should NOT be blocked - const safeQueries = [ - 'MATCH (n) RETURN n', - 'MATCH (n:Function) WHERE n.name = "foo" RETURN n', - 'MATCH (a)-[r]->(b) RETURN a, r, b', - 'OPTIONAL MATCH (n)-[r]->(m) RETURN n, r, m', - 'MATCH (n) WITH n RETURN n.name', - 'UNWIND [1,2,3] AS x RETURN x', - 'MATCH (n) RETURN count(n)', - 'MATCH (n:Function) WHERE n.filePath CONTAINS "test" RETURN n', - ]; - - for (const query of safeQueries) { - it(`does NOT block safe query: "${query.slice(0, 50)}..."`, () => { - expect(CYPHER_WRITE_RE.test(query)).toBe(false); - }); - } - - it('blocks write keyword within a longer query', () => { - expect(CYPHER_WRITE_RE.test('MATCH (n) DELETE n')).toBe(true); - expect(CYPHER_WRITE_RE.test('MATCH (n:Node) SET n.name = "x"')).toBe(true); - }); - - it('does not match partial word (e.g., "CREATED" should not match)', () => { - // \b ensures word boundary. "CREATED" starts with "CREATE" but has extra D - // Actually \b(CREATE) matches "CREATE" in "CREATED" since CREATE is followed by D - // which is a word char -> no boundary at E-D. Let's verify: - expect(CYPHER_WRITE_RE.test('CREATED_AT')).toBe(false); - }); -}); - -// ─── isWriteQuery wrapper ───────────────────────────────────────────── - -describe('isWriteQuery', () => { - it('returns true for write queries', () => { - expect(isWriteQuery('CREATE (n:Node)')).toBe(true); - expect(isWriteQuery('match (n) delete n')).toBe(true); - }); - - it('returns false for read queries', () => { - expect(isWriteQuery('MATCH (n) RETURN n')).toBe(false); - }); - - it('handles empty string', () => { - expect(isWriteQuery('')).toBe(false); - }); - - // Hardening: regex lastIndex not stuck (non-global regex, but verify) - it('works correctly on consecutive calls', () => { - expect(isWriteQuery('CREATE (n)')).toBe(true); - expect(isWriteQuery('MATCH (n) RETURN n')).toBe(false); - expect(isWriteQuery('DROP TABLE foo')).toBe(true); - expect(isWriteQuery('MATCH (n) RETURN n')).toBe(false); - }); -}); // ─── Relation type allowlist ────────────────────────────────────────── @@ -211,12 +122,3 @@ describe('path traversal (isTestFilePath as proxy for path handling)', () => { expect(isTestFilePath('src/utils/helper.ts')).toBe(false); }); }); - -// ─── Static analysis: parameterized query patterns ──────────────────── - -describe('parameterized query patterns (static analysis)', () => { - it('CYPHER_WRITE_RE is not a global regex (no lastIndex issue)', () => { - // A global regex would have sticky lastIndex state - expect(CYPHER_WRITE_RE.global).toBe(false); - }); -}); diff --git a/gitnexus/test/unit/setup-codex.test.ts b/gitnexus/test/unit/setup-codex.test.ts index 95761bed0..5951c9325 100644 --- a/gitnexus/test/unit/setup-codex.test.ts +++ b/gitnexus/test/unit/setup-codex.test.ts @@ -15,8 +15,13 @@ const execFileMock = vi.fn((...args: any[]) => { } }); +const execFileSyncMock = vi.fn(() => { + throw new Error('not found'); +}); + vi.mock('child_process', () => ({ execFile: execFileMock, + execFileSync: execFileSyncMock, })); describe('setupCommand codex execution', () => { @@ -74,6 +79,21 @@ describe('setupCommand codex execution', () => { ); }); + it('uses Windows npx fallback arguments when where returns only a non-wrapper shim', async () => { + execFileSyncMock.mockReturnValueOnce('C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus\n'); + + const { setupCommand } = await import('../../src/cli/setup.js'); + + await setupCommand(); + + expect(execFileMock).toHaveBeenCalledWith( + 'codex', + ['mcp', 'add', 'gitnexus', '--', 'cmd', '/c', 'npx', '-y', NPX_REF, 'mcp'], + { shell: true }, + expect.any(Function), + ); + }); + it('invokes codex mcp add without shell on non-Windows and does not write fallback config', async () => { setPlatform('darwin'); diff --git a/gitnexus/test/unit/setup-jsonc.test.ts b/gitnexus/test/unit/setup-jsonc.test.ts index e31955950..f4f44c618 100644 --- a/gitnexus/test/unit/setup-jsonc.test.ts +++ b/gitnexus/test/unit/setup-jsonc.test.ts @@ -241,6 +241,50 @@ describe('setupOpenCode — JSONC preservation', () => { }); }); + it('uses Windows npx fallback when where returns only a non-wrapper shim', async () => { + setPlatform('win32'); + execFileSyncMock.mockReturnValueOnce('C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus\n'); + + const jsonc = `{ + "model": "test", + "mcp": {} +}`; + await fs.writeFile(opencodeJsonPath(), jsonc, 'utf-8'); + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand(); + + const raw = await fs.readFile(opencodeJsonPath(), 'utf-8'); + const config = parseJsonc(raw); + + expect(config.mcp.gitnexus).toEqual({ + type: 'local', + command: ['cmd', '/c', 'npx', '-y', NPX_REF, 'mcp'], + }); + }); + + it('uses Windows npx fallback when where returns only a .ps1 path', async () => { + setPlatform('win32'); + execFileSyncMock.mockReturnValueOnce('C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus.ps1\n'); + + const jsonc = `{ + "model": "test", + "mcp": {} +}`; + await fs.writeFile(opencodeJsonPath(), jsonc, 'utf-8'); + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand(); + + const raw = await fs.readFile(opencodeJsonPath(), 'utf-8'); + const config = parseJsonc(raw); + + expect(config.mcp.gitnexus).toEqual({ + type: 'local', + command: ['cmd', '/c', 'npx', '-y', NPX_REF, 'mcp'], + }); + }); + it('preserves tab indentation in existing file', async () => { const tabbed = `{\n\t"model": "test"\n}`; await fs.writeFile(opencodeJsonPath(), tabbed, 'utf-8'); @@ -360,6 +404,22 @@ describe('setupCursor — JSONC preservation', () => { const raw = await fs.readFile(mcpPath(), 'utf-8'); expect(raw).toBe(corrupt); }); + + it('uses Windows npx fallback when where returns only a non-wrapper shim', async () => { + setPlatform('win32'); + execFileSyncMock.mockReturnValueOnce('C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus\n'); + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand(); + + const raw = await fs.readFile(mcpPath(), 'utf-8'); + const config = parseJsonc(raw); + + expect(config.mcpServers.gitnexus).toEqual({ + command: 'cmd', + args: ['/c', 'npx', '-y', NPX_REF, 'mcp'], + }); + }); }); describe('setupClaudeCode — JSONC preservation', () => { diff --git a/gitnexus/test/unit/setup.test.ts b/gitnexus/test/unit/setup.test.ts index bdf1cd1fd..ee43d97d7 100644 --- a/gitnexus/test/unit/setup.test.ts +++ b/gitnexus/test/unit/setup.test.ts @@ -282,9 +282,9 @@ describe('setupClaudeCode', () => { ).resolves.toBeUndefined(); }); - it('falls back to first line on Windows when no .cmd/.bat wrapper found', async () => { + it('falls back to npx on Windows when no .cmd/.bat wrapper is found', async () => { setPlatform('win32'); - // Edge case: where returns only the POSIX script (no .cmd wrapper) + // Edge case: where returns only a non-spawnable shim (no .cmd wrapper) execFileSyncMock.mockReturnValueOnce('C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus\n'); const { setupCommand } = await import('../../src/cli/setup.js'); @@ -294,8 +294,24 @@ describe('setupClaudeCode', () => { const config = JSON.parse(raw); expect(config.mcpServers.gitnexus).toEqual({ - command: 'C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus', - args: ['mcp'], + command: 'cmd', + args: ['/c', 'npx', '-y', NPX_REF, 'mcp'], + }); + }); + + it('falls back to npx on Windows when where returns only a .ps1 path', async () => { + setPlatform('win32'); + execFileSyncMock.mockReturnValueOnce('C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus.ps1\n'); + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand(); + + const raw = await fs.readFile(path.join(tempHome, '.claude.json'), 'utf-8'); + const config = JSON.parse(raw); + + expect(config.mcpServers.gitnexus).toEqual({ + command: 'cmd', + args: ['/c', 'npx', '-y', NPX_REF, 'mcp'], }); }); }); diff --git a/gitnexus/test/unit/tools.test.ts b/gitnexus/test/unit/tools.test.ts index a9ce5cf25..7bfdded45 100644 --- a/gitnexus/test/unit/tools.test.ts +++ b/gitnexus/test/unit/tools.test.ts @@ -103,6 +103,9 @@ describe('GITNEXUS_TOOLS', () => { it('cypher tool requires "query" parameter', () => { const cypherTool = GITNEXUS_TOOLS.find((t) => t.name === 'cypher')!; expect(cypherTool.inputSchema.required).toContain('query'); + expect(cypherTool.inputSchema.properties.params).toBeDefined(); + expect(cypherTool.inputSchema.properties.params.type).toBe('object'); + expect(cypherTool.inputSchema.properties.params.description).toContain('prepared statement'); }); it('context tool has no required parameters', () => {