diff --git a/.factory-plugin/marketplace.json b/.factory-plugin/marketplace.json new file mode 100644 index 000000000..3fb30e1e4 --- /dev/null +++ b/.factory-plugin/marketplace.json @@ -0,0 +1,19 @@ +{ + "name": "gitnexus-marketplace", + "owner": { + "name": "GitNexus", + "email": "nico@gitnexus.dev" + }, + "metadata": { + "description": "Code intelligence powered by a knowledge graph — execution flows, blast radius, and semantic search", + "homepage": "https://github.com/nicosxt/gitnexus" + }, + "plugins": [ + { + "name": "gitnexus", + "version": "1.6.12", + "source": "./gitnexus-factory-plugin", + "description": "Code intelligence powered by a knowledge graph. Provides execution flow tracing, blast radius analysis, and augmented search across your codebase." + } + ] +} diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 6f4bfdbec..ee6f85511 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -523,6 +523,15 @@ jobs: run: node --import tsx bench/kotlin-star-route-constants/measure.mjs --check working-directory: gitnexus + - name: tRPC identifier-mount route extractor guards (#3339) + if: ${{ !cancelled() }} + # Build-free: inline-router control vs identifier-mounted subrouters + # and a transitive mount chain; fingerprints route paths and guards + # scaling + widening overhead (compose must stay linear in procedure + # count, and must not degrade the inline scan). + run: node --import tsx bench/trpc-route-extractor/measure.mjs --check + working-directory: gitnexus + - name: Cross-language scope-capture fingerprint + scaling guards # Runs even after an earlier guard fails (#2895). Every step here was # fail-fast, so the FIRST failing --check aborted the job and every guard @@ -618,6 +627,17 @@ jobs: run: node --import tsx bench/rust-cargo-targets/measure.mjs --check working-directory: gitnexus + - name: Swift Package.swift import-resolve guards (#2964, #2931) + if: ${{ !cancelled() }} + # Build-free: same baseline approach as parse-dispatch-rounds — + # exact declared/SDK/undeclared floors plus a fingerprint, then + # ratio timing only (resolveSwiftImportTarget, swiftPackageStrategy, + # parseSwiftPackageManifest 4n/n). Pins declaration-only resolve, + # https:// factory survival, and #2931 segment-boundary membership. + # See bench/swift-package-imports/measure.mjs. + run: node --import tsx bench/swift-package-imports/measure.mjs --check + working-directory: gitnexus + - name: MCP tools/list countRepos vs listRepos guards (#3259, #3184) if: ${{ !cancelled() }} # Build-free: exact registry cardinality + tool-roster + schema-flag diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 19519cd7c..80e4588a8 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -140,10 +140,10 @@ jobs: # Required for multi-platform (linux/arm64) emulation. - name: Set up QEMU - uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4.3.0 + uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - name: Install Cosign uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ca81460f1..57f50c4fd 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -692,7 +692,20 @@ jobs: git add ../gitnexus-claude-plugin/.claude-plugin/plugin.json \ ../.claude-plugin/marketplace.json \ ../gitnexus-claude-plugin/.codex-plugin/plugin.json \ - ../.agents/plugins/marketplace.json + ../.agents/plugins/marketplace.json \ + ../gitnexus-factory-plugin/.factory-plugin/plugin.json \ + ../gitnexus-factory-plugin/mcp.json \ + ../.factory-plugin/marketplace.json \ + ../gitnexus-claude-plugin/skills/gitnexus-plan/mcp.json \ + ../gitnexus-claude-plugin/skills/gitnexus-work/mcp.json \ + ../gitnexus-claude-plugin/skills/gitnexus-review/mcp.json \ + ../gitnexus-claude-plugin/skills/gitnexus-lfg/mcp.json \ + ../gitnexus-claude-plugin/skills/gitnexus-guide/mcp.json \ + ../gitnexus-claude-plugin/skills/gitnexus-cli/mcp.json \ + ../gitnexus-claude-plugin/skills/gitnexus-debugging/mcp.json \ + ../gitnexus-claude-plugin/skills/gitnexus-exploring/mcp.json \ + ../gitnexus-claude-plugin/skills/gitnexus-impact-analysis/mcp.json \ + ../gitnexus-claude-plugin/skills/gitnexus-refactoring/mcp.json git commit -m "release: ${VTAG}" --allow-empty RELEASE_SHA="$(git rev-parse HEAD)" echo "Detached release commit: $RELEASE_SHA" diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml index 106deaf2b..ce1a1ca96 100644 --- a/.github/workflows/trivy.yml +++ b/.github/workflows/trivy.yml @@ -50,7 +50,7 @@ jobs: persist-credentials: false - name: Setup Buildx - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - name: Build image (load locally for scan) uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 diff --git a/.gitignore b/.gitignore index 1f350e059..873f4082f 100644 --- a/.gitignore +++ b/.gitignore @@ -65,6 +65,7 @@ repomix-output* # Playwright artifacts gitnexus-web/playwright-report/ gitnexus-web/test-results/ +gitnexus-web/e2e/screenshots/ # Python test artifacts eval/.coverage diff --git a/.vercelignore b/.vercelignore new file mode 100644 index 000000000..eab6bb113 --- /dev/null +++ b/.vercelignore @@ -0,0 +1,28 @@ +# Keep Vercel uploads under the 100 MB file limit — SPA only needs web + shared sources. +.git +.gitnexus +.gitnexus/** +node_modules +**/node_modules +gitnexus/** +!gitnexus/package.json +eval +eval/** +.claude +.cursor +.github +docs +Documentation +.devcontainer +gitnexus-claude-plugin +gitnexus-cursor-integration +pr-swarm-review +ci-personas +*.sqlite* +*.db +dist +**/dist +coverage +**/coverage +playwright-report +test-results diff --git a/AGENTS.md b/AGENTS.md index e6ce88e8a..2172825f5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ - - + + -Last reviewed: 2026-09-07 +Last reviewed: 2026-09-24 **Project:** GitNexus · **Environment:** dev · **Maintainer:** repository maintainers (see GitHub) @@ -91,6 +91,8 @@ mirror. `gitnexus/test/unit/shipped-skills-sync.test.ts` guards the copies. Toke | Date | Version | Change | |------|---------|--------| +| 2026-09-24 | 1.17.0 | Clones with the same `origin` URL now share a store automatically; `--no-share` records a lasting opt-out (#3352). | +| 2026-09-24 | 1.16.0 | Documented the shared worktree index store (`/stores/`, `analyze --share-with`, `GITNEXUS_SHARED_STORE=off`) in the storage notes (#3352). | | 2026-09-07 | 1.15.0 | Added the Objective-C provider guide as the required reference before changing Objective-C parsing or resolution. | | 2026-07-20 | 1.14.0 | `gitnexus-review` gains a coordinated swarm: six `ci-personas/` lanes the CI review agent dispatches as subagents (via the `Agent` tool), with a bounded critic gate and sidechain-excluded evidence. | | 2026-07-16 | 1.13.0 | `gitnexus-plan` asks plan depth up front (quick/standard/deep) in interactive runs; `gitnexus-lfg` gate slimmed to proceed/stop (Deepen stays as the route-back mechanism). | @@ -198,4 +200,4 @@ npx gitnexus serve # HTTP API on port 4747 (from any ind - `npm install` in `gitnexus/` triggers `prepare` (builds via `tsc`) and `postinstall` (`build-tree-sitter-grammars.cjs` activates committed prebuilds in place under `vendor/`, and only source-builds when none matches). A C/C++ toolchain (`python3`, `make`, `g++`) is needed only for that source-build fallback. - The vendored grammars `tree-sitter-{c,dart,proto,swift,kotlin,zig}` are handled uniformly: c is required; dart/proto/swift/kotlin/zig are optional and skippable via `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1`. Install warnings appear only when no prebuild matches the platform-arch and no toolchain is present, and are non-fatal — only that language's parsing is unavailable. - ESLint configured via `eslint.config.mjs` (TS, React Hooks, unused-imports). No `npm run lint` script; use `npx eslint .`. Prettier runs via lint-staged. CI checks both in `ci-quality.yml`. -- Index storage defaults to `/.gitnexus/`. `GITNEXUS_STORAGE_PATH` selects one complete external index directory and wins over `GITNEXUS_STORAGE_ROOT`, which creates an isolated `-<12-hex>/` slot per repository. `GITNEXUS_CONTENT_RETENTION` is `full` (default), `symbol`, or `none`. MCP `list_repos`, `gitnexus://repo/{name}/context`, and HTTP `GET /api/repos` / `GET /api/repo` expose `storagePath`, `contentRetention`, and `sourceAvailable`. HTTP `/api/file` and `/api/grep` return 410 unless retention is `full`; MCP `include_content` may still return symbol spans at `symbol`. +- Index storage defaults to `/.gitnexus/`. `GITNEXUS_STORAGE_PATH` selects one complete external index directory and wins over `GITNEXUS_STORAGE_ROOT`, which creates an isolated `-<12-hex>/` slot per repository. Linked worktrees share one store under `/stores//` (one immutable graph per commit, private graphs for checkouts with local changes, shared parse caches); clones with the same `origin` URL join a registered sibling's store automatically (`analyze --share-with` names one, `--no-share` opts out and is remembered), and `GITNEXUS_SHARED_STORE=off` or either storage env var disables sharing (#3352). `GITNEXUS_CONTENT_RETENTION` is `full` (default), `symbol`, or `none`. MCP `list_repos`, `gitnexus://repo/{name}/context`, and HTTP `GET /api/repos` / `GET /api/repo` expose `storagePath`, `contentRetention`, and `sourceAvailable`. HTTP `/api/file` and `/api/grep` return 410 unless retention is `full`; MCP `include_content` may still return symbol spans at `symbol`. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1c6a99c36..8a13abd94 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -485,14 +485,34 @@ CLI (analyze.ts) → runFullAnalysis(repoPath, options, callbacks) ├── lbug.wal # Write-ahead log ├── lbug.shadow # Shadow sidecar (checkpoint staging) ├── lbug.lock # Single-writer lock + ├── lbug.wal.checkpoint, lbug.checkpoint.{intent,apply}.lock # checkpoint-in-flight artifacts; left behind only by an interrupted checkpoint, consumed by the next writable open ├── lbug.{wal,shadow}.dirty-recovery # parked sidecars from a crashed run; safe to delete ├── gitnexus.json # lastCommit, indexedAt, stats (primary metadata file) └── meta.json # legacy mirror of gitnexus.json, kept in sync (see MIGRATION.md) ~/.gitnexus/ - └── registry.json # Global repo registry (MCP discovery) + ├── registry.json # Global repo registry (MCP discovery) + └── stores// # Shared sibling index store (see below) + ├── caches/ # parse cache + durable ParsedFile store + ├── commits/-/ # one immutable graph per commit + settings + └── checkouts// # one checkout's metadata, membership, and + # private graph when it has local edits ``` +The flat `/.gitnexus/` layout applies to a standalone repository and +whenever `GITNEXUS_STORAGE_PATH` / `GITNEXUS_STORAGE_ROOT` is set. A repository +with linked worktrees, and clones with the same `origin` URL, share one +`stores//` automatically (a clone opts out with `analyze --no-share`; +`GITNEXUS_SHARED_STORE=off` turns sharing off entirely). Each sharing checkout +keeps only a `.gitnexus/store.json` pointer to its store. Path resolution lives +in `shared-store.ts`. + +Read-only opens self-heal an interrupted checkpoint: the refusal is +classified and cleared by one writable open (probe + `CHECKPOINT`) before +the read-only open is retried — see `sidecar-recovery.ts` +(`isReadOnlyCheckpointInProgressError`) and the +`lbug-interrupted-checkpoint-recovery` integration test. + Managed by `repo-manager.ts`. ## LadybugDB schema diff --git a/Dockerfile.cli b/Dockerfile.cli index 50b1a14ba..411905afe 100644 --- a/Dockerfile.cli +++ b/Dockerfile.cli @@ -55,8 +55,9 @@ RUN npm run postinstall --prefix gitnexus FROM node:22-bookworm-slim@sha256:9f6d5975c7dca860947d3915877f85607946403fc55349f39b4bc3688448bb6e AS runtime # curl for the healthcheck; git for cloning; procps for watch process identity; -# ca-certificates for TLS verification. -RUN apt-get update && apt-get install -y --no-install-recommends curl git procps ca-certificates && rm -rf /var/lib/apt/lists/* \ +# ca-certificates for TLS verification; openssh-client so auto-sync SSH remotes +# can clone (git invokes `ssh`; --no-install-recommends omits it from git). +RUN apt-get update && apt-get install -y --no-install-recommends curl git procps ca-certificates openssh-client && rm -rf /var/lib/apt/lists/* \ && rm -rf /usr/local/lib/node_modules/npm \ && rm -rf /usr/local/lib/node_modules/corepack \ && rm -f /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack diff --git a/README.md b/README.md index ae6849672..bfcca5117 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# GitNexus (Akon Labs) +# GitNexus
@@ -34,7 +34,6 @@

💬 Discord · 🌐 Web UI · - 🏢 Enterprise (SaaS & self-hosted)

@@ -159,7 +158,7 @@ flowchart TB ## What Your AI Agent Gets -### 17 MCP tools (15 per-repo + 2 group) +### 19 MCP tools (17 per-repo + 2 group) | Tool | What It Does | | ---------------- | ---------------------------------------------------------------------- | @@ -178,10 +177,12 @@ flowchart TB | `api_impact` | Pre-change impact report for an API route handler | | `explain` | Explain persisted taint findings (source→sink flows, `--pdg` indexes) | | `pdg_query` | Query control/data dependence at statement level (`--pdg` indexes) | +| `read_file` | Read a checkout file (optional 0-indexed slice; `maxLines` cap) | +| `grep` | Regex search of the working tree for indexed files (1-based hits) | | `group_list` | List configured repository groups | | `group_sync` | Rebuild a group's Contract Registry and cross-repo links | -> Per-repo read-only tools take an optional `repo` parameter. Omit it when only one repo is indexed, an MCP default is configured, or the GitNexus process cwd is inside a registered path without crossing into an unindexed nested Git checkout; otherwise pass it explicitly. Mutating tools require `repo` when multiple repos are indexed and no MCP default exists. Per-repo tools also take an optional `branch` for indexes pinned with `gitnexus analyze --branch`. Omitting `branch` queries the workspace index, which follows your checked-out working tree — switching branches and re-running `gitnexus analyze` updates it incrementally. `explain` and `pdg_query` need an index built with `gitnexus analyze --pdg`. +> Per-repo read-only tools take an optional `repo` parameter. Omit it when only one repo is indexed, an MCP default is configured, or the GitNexus process cwd is inside a registered path without crossing into an unindexed nested Git checkout; otherwise pass it explicitly. Mutating tools require `repo` when multiple repos are indexed and no MCP default exists. Per-repo tools also take an optional `branch` for indexes pinned with `gitnexus analyze --branch`, except `read_file` and `grep`, which read the checkout and do not accept `branch`. Omitting `branch` queries the workspace index, which follows your checked-out working tree — switching branches and re-running `gitnexus analyze` updates it incrementally. `explain` and `pdg_query` need an index built with `gitnexus analyze --pdg`. ### Resources for instant context @@ -234,12 +235,13 @@ When a repo contains an `.agents/` directory, the standard and generated skills | **Cursor** | Yes | Yes | Yes (postToolUse, [manual install](gitnexus-cursor-integration/README.md#hook-install)) | **Full** | | **Antigravity** (Google) | Yes | Yes | Yes (AfterTool, [Gemini CLI hooks schema](https://geminicli.com/docs/hooks/reference/))[¹](#fn-antigravity-hooks) | **Full** | | **Codex** | Yes | Yes | Yes (PreToolUse + PostToolUse, [Codex hooks](https://developers.openai.com/codex/hooks)) | **Full** | +| **Factory** (Droid) | Yes | Yes | Yes (PostToolUse, [plugin](gitnexus-factory-plugin/)) | **Full** | | **OpenCode** | Yes | Yes | — | MCP + Skills | | **CodeBuddy** (Tencent) | Yes | Yes | — | MCP + Skills | | **Qoder** (Alibaba) | Yes | Yes | — | MCP + Skills | | **Windsurf** | Yes | — | — | MCP | -> **Claude Code** and **Codex** get the deepest integration: MCP tools + agent skills + PreToolUse hooks that enrich searches with graph context + PostToolUse hooks that detect a stale index after commits and prompt the agent to reindex. +> **Full** means MCP tools + agent skills + hooks that enrich searches with graph context. **Claude Code** and **Codex** go deepest: their PreToolUse hooks enrich the search before it runs, and their PostToolUse hooks also detect a stale index after commits and prompt the agent to reindex. **Cursor**, **Antigravity**, and **Factory** augment from a post-tool hook only, so they enrich the result rather than the query and do not carry the stale-index hint. @@ -283,6 +285,21 @@ codex plugin marketplace add abhigyanpatwari/GitNexus > **Codex notes:** SessionStart is intentionally not registered — Codex reads [AGENTS.md natively](https://developers.openai.com/codex/guides/agents-md), which already carries the GitNexus context block. Newly installed hooks need a one-time approval in Codex via `/hooks` before they run. Pick **one** install route (`gitnexus setup -c codex` **or** the plugin): plugin hooks load alongside `~/.codex/hooks.json`, so installing both can fire duplicate hooks per tool call. +**Factory** (Droid) — MCP + skills via `gitnexus setup -c droid`, or add the server manually to `~/.factory/mcp.json` ([user scope](https://docs.factory.ai/cli/configuration/mcp), applies to all projects): + +```json +{ + "mcpServers": { + "gitnexus": { + "command": "npx", + "args": ["-y", "gitnexus@latest", "mcp"] + } + } +} +``` + +`gitnexus setup -c droid` also installs skills to `~/.factory/skills/`. For the PostToolUse search-augment hook, install the bundled [`gitnexus-factory-plugin/`](gitnexus-factory-plugin/) — from a marketplace that includes this repo, run `droid plugin install gitnexus@`, or point Droid at it via `extraKnownMarketplaces` in `.factory/settings.json`. Factory reads [`AGENTS.md` natively](https://docs.factory.ai/), which already carries the GitNexus context block. + **Cursor** (`~/.cursor/mcp.json` — global, works for all projects): ```json @@ -407,7 +424,8 @@ backoff. Invalid `.gitnexusrc` or ignore-file reloads pause ordinary refreshes until the control file is fixed. Stop the watcher with Ctrl+C. Watch mode accepts `--debounce`, `--workers`, `--worker-timeout`, -`--max-file-size`, `--branch`, `--pdg`, `--skip-fts`, `--name`, `--allow-duplicate-name`, and +`--max-file-size`, `--max-processes`, `--max-process-branching`, +`--max-process-trace-depth`, `--max-entry-point-candidates`, `--branch`, `--pdg`, `--skip-fts`, `--name`, `--allow-duplicate-name`, and `--verbose`. Explicit one-shot options such as `--force`, `--repair-fts`, embedding flags, `--skills`, `--self-commit`, `--index-only`, and `--skip-git` are rejected. Unsupported defaults from `.gitnexusrc` are ignored with a @@ -453,8 +471,11 @@ gitnexus analyze --verbose # Log skipped files when parsers are unavailabl gitnexus analyze --worker-timeout 60 # Increase worker idle timeout for slow parses gitnexus analyze --workers # Parse worker pool size (>=1; default: cores-1, capped at 16, # auto-sized to the repo). 0 is rejected — there is no sequential mode. +gitnexus analyze --max-processes # Process-detection process cap (replaces dynamic max(20, round(symbols/10))) +gitnexus analyze --max-entry-point-candidates # Ranked entry-point pool (default 200; raise when the warning names it) gitnexus analyze --spring-actuator ./actuator # Enrich with local Spring Boot Actuator JSON snapshots gitnexus analyze --asyncapi-spec ./docs/asyncapi # Resolve broker addresses from AsyncAPI 3.x documents +gitnexus analyze --memory-budget 3000 # Main-thread V8 heap in MB (>= 200); overrides the auto-sizer and --max-old-space-size gitnexus analyze --wal-checkpoint-threshold 67108864 # LadybugDB WAL auto-checkpoint threshold in bytes # (default 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB) ``` @@ -505,6 +526,8 @@ gitnexus auto-sync reset # Clear failure state; leaves clones and in ```yaml sync_interval_minutes: 10 analyze_timeout: 5m +# Extra hosts beyond github.com, gitlab.com, and gitee.com. Exact names only. +# allowed_hosts: [gitlab.mycompany.com] projects: - local_path: /absolute/path/to/clones branches: [main, master] @@ -518,7 +541,7 @@ projects: ``` - `sync_interval_minutes` must be at least `5`; `local_path` must be an absolute path. Clones are stored below it as `host/namespace/repo`. -- Remote URLs must use SSH SCP form and are limited to GitHub, GitLab, or Gitee. +- Remote URLs may use SSH SCP or HTTPS. Hosts are github.com, gitlab.com, and gitee.com unless listed in top-level `allowed_hosts` (exact DNS names, no wildcards). The CLI image includes OpenSSH; mount keys yourself. Invalid `watch_config.yml` skips auto-sync immediately. Auto-sync honors `.gitnexusrc` embeddings (HTTP embeddings env still required in the image). - `branches` are tried in order. The legacy `branch` field is supported, but do not set both. - Set per-project `pdg: true` to keep the full control-flow, control/data-dependence, and taint layers current. Untouched configs that omit `pdg` preserve an existing index's mode and cannot silently strip PDG data. Do not paste `pdg: false` from this example onto an existing watch file unless you intend to drop PDG; an explicit `false` opt-out logs a warning before removing existing PDG data. Auto-sync requests atomic incremental publication where supported, so readers keep using the previous graph until a successful update is ready and a failed staged analysis leaves it intact; unsupported paths retain the analyzer's existing in-place behavior. - Analysis runs in an isolated worker; `analyze_timeout` defaults to half of `sync_interval_minutes`, but may be longer (for example, a `30m` analysis timeout with `5` minute polling) up to Node's timer limit. If a polling tick arrives while analysis is active, it is coalesced into one immediate follow-up run using the newest commit. If the parent times out and leaves that worker running, the follow-up is deferred to the next interval so a leftover lock holder is not counted as a hard analyze failure. Timeout and `auto-sync stop` request safe cancellation; a worker in native work exits after reaching a JS-visible safe point. Until then, auto-sync reports `cancelling` or `stopping` and retains ownership so another auto-sync cannot take over, for up to 5 seconds — after that the parent stops waiting and leaves the worker to exit on its own rather than killing it mid-write. This behavior is the same on macOS and Windows. `overwrite_local_changes` defaults to `false`, so a dirty local clone is skipped rather than overwritten; setting it to `true` also deletes untracked files in the clone, while keeping ignored paths. @@ -576,15 +599,15 @@ Notes: - The default branch is resolved as: `--default-branch` > `.gitnexusrc` `defaultBranch`/`branch` > auto-detected `origin/HEAD` > `main`. - `skipContextFiles` / `skipAiContext` are aliases for `skipAgentsMd` — they skip the `AGENTS.md` / `CLAUDE.md` block only. They do **not** imply `skipSkills`. `indexOnly` is the stronger option that skips all file injection. -- Supported keys: `defaultBranch` (`branch`), `skipAgentsMd` (`skipContextFiles`, `skipAiContext`), `skipSkills`, `indexOnly`, `stats`/`noStats`, `embeddings`, `dropEmbeddings`, `name`, `allowDuplicateName`, `maxFileSize`, `workerTimeout`, `walCheckpointThreshold`, `workers`, `springActuator`, `embeddingThreads`, `embeddingBatchSize`, `embeddingSubBatchSize`, `embeddingDevice`. -- The file is JSON only. Unknown keys and invalid values fail fast with an actionable error before analysis starts. +- Supported keys: `defaultBranch` (`branch`), `skipAgentsMd` (`skipContextFiles`, `skipAiContext`), `skipSkills`, `indexOnly`, `stats`/`noStats`, `embeddings`, `dropEmbeddings`, `name`, `allowDuplicateName`, `maxFileSize`, `workerTimeout`, `walCheckpointThreshold`, `workers`, `maxProcesses`, `maxProcessBranching`, `maxProcessTraceDepth`, `maxEntryPointCandidates`, `springActuator`, `embeddingThreads`, `embeddingBatchSize`, `embeddingSubBatchSize`, `embeddingDevice`. +- The file is JSON only. Unknown keys and wrong JSON types fail fast with an actionable error before analysis starts. Process-detection knobs (`maxProcesses`, `maxProcessBranching`, `maxProcessTraceDepth`, `maxEntryPointCandidates`) that are not a positive integer warn and fall through to env, then the built-in default.
Environment variables -Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max-file-size`, `--verbose`). Use the env-var form when you'd otherwise repeat the same flag every run, or when invoking GitNexus from a long-running host (MCP server, eval-server, CI shell) that already manages its own environment. CLI flags take precedence over env vars; env vars take precedence over built-in defaults. +Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max-file-size`, `--verbose`). Use the env-var form when you'd otherwise repeat the same flag every run, or when invoking GitNexus from a long-running host (MCP server, eval-server, CI shell) that already manages its own environment. CLI flags take precedence over `.gitnexusrc`, which takes precedence over env vars, which take precedence over built-in defaults. | Variable | Default | Effect | Tune when… | | ----------------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -601,11 +624,16 @@ Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max | `GITNEXUS_PROFILE_DEFERRED_SLOW_MS` | `3000` (verbose) / `5000` | Per-file threshold in ms above which `processCallsFromExtracted` emits a `slow file …` log line. Parsed via `Number()`: accepts integers (`5000`), scientific notation (`2.5e3`), decimals (`.5`), and hex (`0x10`). Non-finite or non-positive values fall back to the default. | Hunting a few outlier files dominating the deferred call-resolution stage; lower to surface more, raise to focus only on the worst. | | `PROF_LBUG_LOAD` | unset | When `1`, emits one `[lbug-load prof]` summary line per `loadGraphToLbug` call breaking the graph-DB persistence wall into stages (`csv-emit` / `copy-nodes` / `copy-rels` / `fallback` / `total`) plus node & edge counts. Zero-cost when unset. | Attributing large-repo analyze wall time across CSV generation vs. LadybugDB `COPY` (issue #2203) — the analyze "emit" timing is the scope-resolution bucket, not this DB-write path. | | `GITNEXUS_MAX_FILE_SIZE` | `512` (KB) | Walker skip threshold in KB. Hard cap is `32768` (tree-sitter buffer ceiling). Equivalent to `--max-file-size `. | Indexing repos with intentionally-large source files (generated parsers, vendored bundles) that should still be parsed. | +| `GITNEXUS_MAX_PROCESSES` | dynamic (`max(20, round(symbols/10))`) | Analyze-time process-detection process cap. Equivalent to `--max-processes ` / `.gitnexusrc` `maxProcesses`. Explicit values replace the dynamic formula (not a multiplier). `0` is invalid, not unlimited. Changing this re-detects flows on the next analyze without `--force`. Distinct from query-time `IMPACT_MAX_CHUNKS`. | `[processes] … whole flows are MISSING` names `--max-processes` after entry points were never traced or flows were dropped. Tracing does not start the next entry once collected traces already reach `maxProcesses * 2`; a started entry can still emit every trace that entry produces. | +| `GITNEXUS_MAX_PROCESS_BRANCHING` | `4` | Analyze-time per-node branching cap during flow tracing. Equivalent to `--max-process-branching `. Shape-only: raising it shortens fewer traces; it does not restore whole missing flows. | A flow is present but `calleesDropped` is high at debug. | +| `GITNEXUS_MAX_PROCESS_TRACE_DEPTH` | `10` | Analyze-time DFS depth cap during flow tracing. Equivalent to `--max-process-trace-depth `. Shape-only. | A reported flow is shorter than the code path (`tracesDepthCapped` at debug). | +| `GITNEXUS_MAX_ENTRY_POINT_CANDIDATES` | `200` | Ranked entry-point candidate pool. Equivalent to `--max-entry-point-candidates `. Raising `--max-processes` alone does not clear `entryPointCandidatesDropped`. Doubling the current cap is the usual first raise; setting it to the full remaining candidate count can exhaust CPU and memory. | The `[processes]` warning reports candidate entry points that never ranked in. | | `GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS` | `30000` | Worker idle timeout in milliseconds before retry/fallback. Equivalent to `--worker-timeout ` × 1000. | Slow-parsing files (large minified JS, deeply-nested TS types) that legitimately need more than 30s. | | `GITNEXUS_WORKER_READY_TIMEOUT_MS` | `5000` | Startup budget in milliseconds for a parse worker to load its grammar bindings and report `{type:'ready'}`. Slots that miss it are treated as startup crashes. | Slow or heavily loaded hosts where a full pool cold-starting concurrently needs more than 5s, and analyze aborts with "did not report ready within 5000ms". | | `GITNEXUS_FTS_STEMMER` | `porter` | Stemmer used when rebuilding BM25/FTS indexes. Use `none` for CJK-heavy repositories, or a language stemmer such as `german`, `french`, or `spanish` for matching repository comments. Re-run `gitnexus analyze --repair-fts` after changing it. | Keyword search quality is poor for non-English comments or identifiers under English stemming. | | `GITNEXUS_STORAGE_PATH` | unset (`/.gitnexus/`) | Complete external index directory. This preserves the existing configuration semantics and takes precedence over `GITNEXUS_STORAGE_ROOT` when both are set. | You already keep one repository index outside its checkout or need one explicit index location. | | `GITNEXUS_STORAGE_ROOT` | unset | Absolute root directory for external indexes. GitNexus creates an isolated `-/` slot beneath it for each repository, then registers the resolved slot so `status`, MCP, and `serve` can reopen it later. | You want to manage multiple repository indexes centrally or keep generated data outside source checkouts. | +| `GITNEXUS_SHARED_STORE` | unset (on) | Set to `off` (or `0`, `false`, `no`) to turn off shared index stores for both linked git worktrees and sibling clones; every checkout then indexes into its own `.gitnexus/`. Sharing is also off whenever `GITNEXUS_STORAGE_PATH` or `GITNEXUS_STORAGE_ROOT` is set. | Disk or memory is not a concern, or you want each worktree's index fully independent. | | `GITNEXUS_CONTENT_RETENTION` | `full` | Source-text retention profile: `full` keeps file and symbol text, `symbol` keeps symbol snippets without full file content, and `none` keeps the structural graph without source body text. | You need to reduce persisted source text while preserving graph structure. | | `GITNEXUS_SKIP_FTS` | unset | When exactly `1`, skips FTS extension loading and keyword index creation during analyze. Equivalent to `--skip-fts`; a later analyze without either option restores FTS. | Graph-only consumers with their own retrieval, or short-lived indexes that do not need keyword search. | | `GITNEXUS_WAL_CHECKPOINT_THRESHOLD` | `67108864` (64 MiB) | LadybugDB WAL auto-checkpoint threshold in bytes. Equivalent to `--wal-checkpoint-threshold `. `-1` keeps LadybugDB's stock threshold (~16 MiB). Larger thresholds reduce checkpoint frequency but increase the WAL size at rotation time — choose a smaller value on disk-constrained environments. | You need a larger or smaller WAL auto-checkpoint threshold for your analyze workload. | @@ -687,6 +715,8 @@ GitNexus uses a **global registry** so one MCP server can serve multiple indexed Each `gitnexus analyze` stores the index in `.gitnexus/` inside the repo by default (portable, gitignored). `GITNEXUS_STORAGE_PATH` selects one complete external index directory and preserves the established configuration behavior. To manage multiple repositories under one external directory, set `GITNEXUS_STORAGE_ROOT`; GitNexus derives an isolated `-/` slot beneath it for each repository. If both variables are set, `GITNEXUS_STORAGE_PATH` takes precedence. GitNexus registers the resolved slot in `~/.gitnexus/registry.json`, allowing later `status`, MCP, and `serve` commands to reopen the index without repeating the environment variable. LadybugDB connections are opened lazily on first query and evicted after 5 minutes of inactivity (max 5 concurrent). Read-only tools can omit `repo` when only one repo is indexed, an MCP default is configured, or the GitNexus process cwd is inside a registered path without crossing into an unindexed nested Git checkout. Outside those paths—and for mutating tools with multiple indexed repos and no MCP default—pass `repo` explicitly. +**Worktrees share one index store.** When a repository has linked worktrees (`git worktree add`), the main checkout and every worktree index into one store at `~/.gitnexus/stores//` instead of each keeping a full `.gitnexus/`. Checkouts at the same commit with no local changes read one shared, read-only graph: one copy on disk and one open database in MCP. A checkout with uncommitted changes gets its own graph, copied from the nearest shared graph and updated incrementally rather than rebuilt. Parse caches are shared too. Each worktree keeps a small `.gitnexus/store.json` pointer, and an index it had before sharing is left in place; `gitnexus status` reports it and `gitnexus clean --local-index --force` removes it. `gitnexus clean` in one worktree removes only that worktree's slot and any shared graph no other checkout uses; `gitnexus clean --gc` also drops slots whose worktree was deleted. Independent clones of one repository share too: when another registered clone has the same `origin` URL, `gitnexus analyze` in a clone joins that clone's store (or starts one the other clone joins on its next analyze). A lone clone keeps its own `.gitnexus/`. `gitnexus analyze --share-with ` joins a specific checkout's store after checking the `origin` URLs match, and `--no-share` moves a clone back to its own `.gitnexus/` and keeps it out until `--share-with`. On filesystems with copy-on-write clones (APFS, btrfs, XFS) a checkout's private graph shares its unchanged pages with the shared graph on disk; elsewhere it is a full copy, and `gitnexus status` says which. Queries cannot combine two graphs, because LadybugDB reads one database per query, so a checkout with edits always has a complete graph of its own. Set `GITNEXUS_SHARED_STORE=off` (or `0`, `false`, `no`) to turn sharing off for worktrees and clones alike. +
Architecture diagram diff --git a/docs/languages/jupyter-notebook.md b/docs/languages/jupyter-notebook.md new file mode 100644 index 000000000..f69c77aaf --- /dev/null +++ b/docs/languages/jupyter-notebook.md @@ -0,0 +1,44 @@ +# Jupyter notebook (.ipynb) indexing + +Status: implemented (Python code cells) + +GitNexus indexes Jupyter notebooks by extracting Python code cells and parsing them with the existing Python language provider. Notebooks are not executed. + +## Goal + +After `analyze`, functions, classes, and imports defined in Python code cells are queryable like ordinary `.py` files. + +## Compatibility + +- `.ipynb` is detected as Python (`gitnexus-shared` `EXTENSION_MAP` and `pythonProvider.extensions`). +- Extraction lives in `gitnexus/src/core/ingestion/ipynb-extractor.ts`. Shared ingestion modules do not name nbformat AST types. +- Notebooks are **not** Python import targets. A notebook may import `.py` modules; `import some_notebook` does not resolve to an `.ipynb`. +- Files over the walker size cap (default 512KB, `GITNEXUS_MAX_FILE_SIZE`) are skipped like any other oversized file. Output-heavy notebooks may need a higher cap. Outputs are not stripped in this slice. +- Group-layer FastAPI/Flask/Django scanners that require a `.py` suffix still ignore notebooks. + +## Kernel and magics + +- Skip the file when `kernelspec.language` or `language_info.name` is present and is not a Python-family name (`python`, `python2`, `python3`, `ipython`, `python 3`, `ipython3`). `python` and `python3` together still index. +- If `kernelspec.language` is missing, `kernelspec.name` is used only when it is an obvious language id (`python3`, `ir`, `julia-1.8`). Conda env names are ignored. +- If `language_info.name` is missing, `language_info.file_extension` (`.py` vs `.r` / `.jl`) is used the same way. +- If those fields are absent, code cells are treated as Python unless a cell's own `language`, `metadata.language`, or `metadata.vscode.languageId` says otherwise. +- A cell whose first non-empty line is a foreign cell magic (`%%bash`, `%%html`, `%%sql`, `%%R`) is skipped. Python-body cell magics (`%%time`, `%%timeit`, `%%capture`, `%%prun`, `%%debug`, `%%px`, `%%python`) stay, with the magic line commented. +- `%run`, `%load`, and `%loadpy` of a local `.py` path become `import module` on that same line so the notebook links to the file. URLs, `..` paths, and `.ipynb` targets stay comments. The notebook is not executed. +- Sage, SageMath, MicroPython, Pyodide, PyPy, and PySpark kernels are indexed as Python. SQL, R, and Julia cells are not. +- A cell with an unclosed string or bracket is commented out so it cannot hide later cells. Those cells are concatenated in order; one syntax error no longer drops the rest of the notebook. +- Line magics (`%`), shell (`!`), and IPython help (`train?`, `?train`) are commented in place so JSON line mapping stays affine. +- nbformat v4 `cells` / `source` is the normal path. nbformat v3 `worksheets[].cells` and code-cell `input` are accepted. A leading UTF-8 BOM is accepted. Markdown and raw cells are not code. + +## Line numbers + +Graph `startLine` / `endLine` are 0-based coordinates in the on-disk `.ipynb` JSON. FTS/MCP symbol snippets reconstruct cell Python; they do not slice raw JSON. + +Concatenating cells in document order is notebook semantics. An earlier cell with a syntax error may cause later definitions to be missed; that is a documented limitation, not a per-cell fallback parse. + +## Tests + +- `gitnexus/test/unit/ipynb-extractor.test.ts` +- `gitnexus/test/unit/ingestion-utils.test.ts` (`.ipynb` detection) +- `gitnexus/test/integration/resolvers/ipynb-python-pipeline.test.ts` + +No Jupyter, nbconvert, or nbformat runtime dependency. diff --git a/eval/uv.lock b/eval/uv.lock index 45c13a3ef..ff06e6830 100644 --- a/eval/uv.lock +++ b/eval/uv.lock @@ -170,15 +170,15 @@ wheels = [ [[package]] name = "anyio" -version = "4.12.1" +version = "4.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, ] [[package]] @@ -2513,11 +2513,11 @@ wheels = [ [[package]] name = "pygments" -version = "2.19.2" +version = "2.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] @@ -2574,7 +2574,7 @@ name = "pyroscope-io" version = "0.8.16" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi", marker = "sys_platform != 'win32'" }, + { name = "cffi" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/a8/50/607b38b120ba8adad954119ba512c53590c793f0cf7f009ba6549e4e1d77/pyroscope_io-0.8.16-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:e07edcfd59f5bdce42948b92c9b118c824edbd551730305f095a6b9af401a9e8", size = 3138869, upload-time = "2026-01-22T06:23:24.664Z" }, diff --git a/gitnexus-claude-plugin/hooks/hook-db-lock-probe.cjs b/gitnexus-claude-plugin/hooks/hook-db-lock-probe.cjs index 4376795e2..3e98f5901 100644 --- a/gitnexus-claude-plugin/hooks/hook-db-lock-probe.cjs +++ b/gitnexus-claude-plugin/hooks/hook-db-lock-probe.cjs @@ -80,9 +80,11 @@ function debugLog(msg) { function resolveHookBinary(tool) { const envKey = tool === 'lsof' ? 'GITNEXUS_HOOK_LSOF_PATH' : 'GITNEXUS_HOOK_PS_PATH'; - const fromEnv = process.env[envKey]; - if (fromEnv && String(fromEnv).trim() && fs.existsSync(String(fromEnv))) { - return String(fromEnv); + // Trim once, exactly as hasMissingHookBinaryOverride does, so a padded but + // valid override (" /tmp/lsof ") is both accepted there and used here. + const fromEnv = process.env[envKey] ? String(process.env[envKey]).trim() : ''; + if (fromEnv && fs.existsSync(fromEnv)) { + return fromEnv; } const candidates = tool === 'lsof' @@ -177,7 +179,13 @@ function resolveUnixGuardTimeout() { const trimmed = fromEnv ? String(fromEnv).trim() : ''; if (trimmed === 'disabled') return unixGuardTimeoutCache; const candidates = []; - if (trimmed && fs.existsSync(trimmed)) candidates.push(trimmed); + // Resolve the override against THIS process's cwd — the directory the + // existsSync check and the self-test run in — so the cached/returned path is + // always absolute. The adapters spawn the wrapper with a different `cwd` + // (the tool request's), where a relative value would resolve elsewhere + // (ENOENT), and a slashless name would switch to a PATH lookup. + const override = trimmed ? path.resolve(trimmed) : ''; + if (override && fs.existsSync(override)) candidates.push(override); for (const builtin of [ '/usr/bin/timeout', '/bin/timeout', @@ -317,15 +325,30 @@ function getProcRoot() { // `node mcp` line // (the `mcp`/`serve` mode token lives at the very tail, so the cap must be large // enough to reach it — see PROC_CMDLINE_FLOOR escalation below). Overridable for -// tests; never goes below PROC_CMDLINE_FLOOR. +// tests via GITNEXUS_HOOK_PROC_CMDLINE_MAX: an integer in +// [PROC_CMDLINE_FLOOR, PROC_CMDLINE_CEIL] is used as-is; a larger integer is +// CLAMPED to PROC_CMDLINE_CEIL; anything else (below the floor, fractional, +// non-numeric, Infinity) falls back to the 16 KiB default. const PROC_CMDLINE_FLOOR = 4096; +// Upper bound for a single cmdline read chunk, and the absolute ceiling of the +// escalation path in readLinuxCmdline (same 256 KiB — no single read may exceed +// what the whole escalation is allowed to collect). Without it, an oversized +// override (e.g. 2**40 — past buffer.constants.MAX_LENGTH on older Node lines +// and unallocatable in practice on any) made Buffer.allocUnsafe throw; readLinuxCmdline's catch turned that into '' (a +// NON-candidate), so a real server owner was silently missed (fail-OPEN, the +// #1492 race). Oversized values are clamped rather than defaulted: the operator +// asked for MORE bytes, and the ceiling is the most the read will ever collect +// anyway, so clamping honours the intent while keeping allocation bounded. +const PROC_CMDLINE_CEIL = 262144; function getCmdlineMaxBytes() { const raw = process.env.GITNEXUS_HOOK_PROC_CMDLINE_MAX; // Number() (not parseInt) so "8e3" reads as 8000, not 8 (parseInt stops at // 'e'). The `raw && String(raw).trim()` guard keeps empty/whitespace on the // default; trailing garbage ("8abc") now -> NaN -> default (stricter). const n = raw && String(raw).trim() ? Number(String(raw).trim()) : NaN; - if (Number.isFinite(n) && n >= PROC_CMDLINE_FLOOR) return n; + // Number.isInteger rejects NaN, +/-Infinity and fractions (a fractional + // Buffer/readSync length is not a byte count). + if (Number.isInteger(n) && n >= PROC_CMDLINE_FLOOR) return Math.min(n, PROC_CMDLINE_CEIL); return 16384; } @@ -381,19 +404,24 @@ const CMDLINE_TIMEOUT = Symbol('gitnexus.cmdline.timeout'); // Bounded /proc//cmdline read for Phase 1. openSync+readSync (not // readFileSync) so a D-state holder cannot stall the hook on a huge or -// never-EOF argv: we read at most `cap` bytes and stop. cmdline separates argv -// with NULs; convert to spaces for isGitNexusServerCommand. +// never-EOF argv: we read in `cap`-sized chunks and stop as soon as the text +// holds both server tokens, at EOF, at PROC_CMDLINE_CEIL, or when the scan +// budget runs out (see below). cmdline separates argv with NULs; convert to +// spaces for isGitNexusServerCommand. // // Owner-miss guard for the 4 KB cap: the `gitnexus` token usually sits in the // first path component while the `mcp`/`serve` mode token is the LAST argv, so // a naive 4 KB read could clip the mode token off a server launched with a very // long interpreter path and silently miss a real owner. We mitigate two ways: -// (a) the default cap (16 KiB) already clears realistic lines; (b) if the first -// read fills the cap AND already contains the `gitnexus` token but no mode -// token yet, we keep reading in bounded chunks (up to a hard ceiling) until the -// mode token appears or the file ends — so a genuine server is never missed for -// want of a few more bytes, while non-candidates still pay only the initial -// bounded read. +// (a) the default cap (16 KiB) already clears realistic lines, so almost every +// process is decided by the first read hitting EOF; (b) a read that fills the +// cap stops early ONLY once it holds BOTH tokens (decided owner). Holding one +// token, or neither, decides nothing: interpreter flags can put a mode-looking +// word first (`node --require mcp .../gitnexus/... serve`) or push the gitnexus +// path past the first chunk. So we keep reading in cap-sized chunks until both +// tokens appear, the file ends, or the hard ceiling is reached. Only processes +// that passed the Phase 0 comm prefilter AND have a cmdline longer than the cap +// ever escalate, and each escalation step is budget-gated (below). // // Budget (F3): the escalation loop above is the one place a SINGLE pathological // candidate could read up to HARD_CEIL (256 KiB) before the next scan-level @@ -411,7 +439,7 @@ function readLinuxCmdline(procRoot, pidStr, cap, outOfBudget) { return ''; } try { - const HARD_CEIL = 262144; // 256 KiB absolute ceiling for the escalation path + const HARD_CEIL = PROC_CMDLINE_CEIL; // 256 KiB absolute ceiling for the escalation path let collected = Buffer.alloc(0); let offset = 0; let chunkCap = cap; @@ -425,16 +453,12 @@ function readLinuxCmdline(procRoot, pidStr, cap, outOfBudget) { collected = Buffer.concat([collected, buf.subarray(0, bytes)]); offset += bytes; const text = collected.toString('utf8').replace(/\0+/g, ' '); - // Stop early when we can already decide "owner": has both the gitnexus - // token and a mode token. Keep going only when gitnexus is present but - // the mode token might be just past the boundary. - const hasGitNexus = - /(?:^|[/\\\s])gitnexus(?:\.cmd)?(?:\s|$)/.test(text) || - /node_modules[/\\]gitnexus[/\\]/.test(text); - const hasMode = /(?:^|\s)(mcp|serve)(?:\s|$)/.test(text); - if (hasMode) break; // decided (positive); isGitNexusServerCommand re-checks below + // Stop early only when the partial read is DECIDED: both the gitnexus + // token and a mode token are present (isGitNexusServerCommand is exactly + // that conjunction). A partial read missing either token is undecided — + // the missing one may lie past the chunk boundary — so it keeps reading. + if (isGitNexusServerCommand(text)) break; // decided (positive) if (bytes < chunkCap) break; // EOF: full cmdline read, definitive - if (!hasGitNexus) break; // not a candidate; do not escalate the read if (offset >= HARD_CEIL) break; // bounded escalation only // Budget gate the escalation: a single huge-argv candidate must not burn // the whole scan deadline before we re-check. Return the timeout sentinel @@ -578,17 +602,24 @@ function linuxProcScanFindGitNexusServer(dbPathAbs, myPid) { ); return 'timeout'; } - // Any other shape (ENOTDIR — fd path is not a directory at all, so this - // is not a plausible live-procfs owner — and the long tail) is treated as - // "this candidate is not an owner": move to the next candidate instead of - // the old blanket 'owned'. If no other candidate owns the lbug the scan - // ends not-owned (dispatcher fail-open) — acceptable because ENOTDIR means - // the fd entry is structurally not a real /proc//fd. + if (code === 'ENOTDIR') { + // The fd path is not a directory at all, so this is structurally not a + // real /proc//fd — not a plausible live owner. Move on. + debugLog( + `fd dir not a directory for candidate pid ${pidStr} (ENOTDIR); ` + + `treating candidate as non-owner -> continue`, + ); + continue; + } + // Any other error (EMFILE, ENFILE, ENOMEM, EINTR, no code, …) says nothing + // about whether this already-identified server candidate holds the lbug. + // Only ENOENT/ENOTDIR above establish non-ownership; everything else is + // inconclusive and fails closed via 'timeout', same as EACCES/EIO. debugLog( - `fd dir not a readable directory for candidate pid ${pidStr} ` + - `(${code || 'unknown'}); treating candidate as non-owner -> continue`, + `fd dir read failed for candidate pid ${pidStr} ` + + `(${code || 'unknown'}); probe inconclusive -> fail-closed (timeout)`, ); - continue; + return 'timeout'; } for (const fd of fds) { if (outOfBudget()) return 'timeout'; @@ -707,16 +738,12 @@ module.exports = { // name is pinned by a source-contract test. linuxProcScanFindGitNexusServer, // #2163 follow-up: the hook adapters wrap the augment CLI in the same - // guard. Returns a self-tested wrapper path — the built-in candidates are - // always absolute; a GITNEXUS_HOOK_TIMEOUT_PATH override is adopted as the - // exact string that passed the self-test. Same string is also the same - // RESOLUTION for absolute paths and for slashless names (PATH lookup is - // cwd-independent); a slash-containing RELATIVE override, however, is - // existsSync-checked and self-tested against this process's cwd while the - // adapters spawn the CLI with a `cwd` option (chdir-before-exec), so such - // a value can pass here yet ENOENT at the augment call site — set the - // override to an absolute path. Returns null when the wrapper is - // disabled/unavailable. Never call on win32 (see its JSDoc). + // guard. Returns a self-tested, always-ABSOLUTE wrapper path: the built-in + // candidates are absolute, and a GITNEXUS_HOOK_TIMEOUT_PATH override is + // path.resolve()d against this process's cwd before its existsSync check + // and self-test, so the adapters can spawn it under any `cwd` option. + // Returns null when the wrapper is disabled/unavailable. Never call on + // win32 (see its JSDoc). resolveUnixGuardTimeout, // Exported for white-box unit tests of the numeric-env parsing (#2183 review): // Number()-not-parseInt so "16e3" reads as 16000, plus the empty/whitespace @@ -725,4 +752,8 @@ module.exports = { // otherwise only observable indirectly through scan timing/escalation. getCmdlineMaxBytes, resolveLinuxProcBudgetMs, + // Exported for white-box tests pinning that the override check and the + // override lookup agree on whitespace-padded GITNEXUS_HOOK_{LSOF,PS}_PATH. + resolveHookBinary, + hasMissingHookBinaryOverride, }; diff --git a/gitnexus-claude-plugin/hooks/hook-lock.js b/gitnexus-claude-plugin/hooks/hook-lock.js index 759856384..05ee20ace 100644 --- a/gitnexus-claude-plugin/hooks/hook-lock.js +++ b/gitnexus-claude-plugin/hooks/hook-lock.js @@ -1,3 +1,4 @@ +const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); @@ -5,6 +6,128 @@ const HOOK_LOCK_SUBDIR = '.hook-locks'; const HOOK_LOCK_MAX_INFLIGHT = 3; const HOOK_LOCK_STALE_MS = 30000; +// An evictor's claim marker older than this belongs to a crashed evictor. +// The critical section it guards is a few syscalls (token read, lstat, +// unlink), so any live evictor finishes orders of magnitude sooner; kept well +// under HOOK_LOCK_STALE_MS so an orphan never blocks a slot for long. +const HOOK_LOCK_EVICT_MARKER_STALE_MS = 5000; + +// Same file iff inode identity AND content metadata match. dev+ino alone is +// not enough: filesystems reuse a freed inode number immediately (ext4), so a +// file recreated after an unlink can carry the old file's ino. bigint stats +// keep Windows' 64-bit file ids exact. +function sameSlotFile(a, b) { + return a.dev === b.dev && a.ino === b.ino && a.size === b.size && a.mtimeNs === b.mtimeNs; +} + +function readMarkerToken(marker) { + try { + return fs.readFileSync(marker, 'utf-8'); + } catch { + return null; + } +} + +// Stat and token of a marker, both taken from one open descriptor so they +// describe the same file (a path stat followed by a path read could straddle +// a replacement). O_NOFOLLOW where the platform has it: a marker is always a +// regular file this module created. Returns null when there is no marker. +function readMarkerSnapshot(marker) { + let fd; + try { + fd = fs.openSync(marker, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0)); + return { stat: fs.fstatSync(fd, { bigint: true }), token: fs.readFileSync(fd, 'utf-8') }; + } catch { + return null; + } finally { + if (fd !== undefined) { + try { + fs.closeSync(fd); + } catch { + /* already closed */ + } + } + } +} + +// Break an evictor's claim marker only if it is an orphan: older than +// HOOK_LOCK_EVICT_MARKER_STALE_MS, and still the exact file (identity and +// owner token) judged old when it is re-checked just before the unlink. A +// marker released and re-created by a new claimant in between is fresh, so +// it fails the check and stays. +function breakOrphanedMarker(marker) { + const seen = readMarkerSnapshot(marker); + if (!seen || Date.now() - Number(seen.stat.mtimeMs) <= HOOK_LOCK_EVICT_MARKER_STALE_MS) return; + const now = readMarkerSnapshot(marker); + if (!now || !sameSlotFile(now.stat, seen.stat) || now.token !== seen.token) return; + try { + fs.unlinkSync(marker); + } catch { + /* another contender already cleared it */ + } +} + +// Evict a slot judged stale from the `inspected` stat. A slot file is only +// ever deleted, never moved, and only by the evictor holding the per-slot +// `.evicting` marker, created O_EXCL with a token unique to this call. +// Every destructive step verifies first: +// - the slot is unlinked only if the marker still carries our token (an +// evictor stalled long enough for its marker to be broken as an orphan has +// lost its claim and backs off) and the slot is still the exact file +// inspected — identical dev/ino/size/mtimeNs means its content and age are +// unchanged, so the stale verdict still holds, while a slot recreated since +// inspection fails the check and its lock stands; +// - our marker is removed only if it still carries our token, so a marker +// that has passed to another claimant is left alone; +// - an orphaned marker is broken only if it is still the old file it was +// judged to be (see breakOrphanedMarker). +// +// Residual windows. POSIX has no conditional unlink, so each check-then- +// unlink pair keeps a gap of two adjacent syscalls: +// (a) Slot: between the lstat identity check and unlinkSync(slot), a live +// owner past HOOK_LOCK_STALE_MS could release and a new hook recreate the +// slot, whose fresh lock would then be deleted. The consequence is at +// most one extra concurrent augment beyond HOOK_LOCK_MAX_INFLIGHT for +// that run — the cap is a load guard, and no data or index state +// depends on it. The victim's release() sees a foreign or missing file +// and leaves it alone. +// (b) Marker: between the token re-read and unlinkSync(marker) (ours or an +// orphan's), the marker could pass to another claimant, whose claim would +// then be removed. That only re-opens the slot to one more evictor, which +// still has to pass the slot identity check before deleting anything. +// Both need a stall of seconds landing on that exact syscall pair, and the +// only thing lost is one run's cap accounting, so they are accepted rather +// than traded for heavier machinery. A crash at any point orphans at most the +// marker, which the next contender breaks after it expires. +function evictStaleSlot(slotPath, inspected) { + const marker = `${slotPath}.evicting`; + breakOrphanedMarker(marker); + const token = `${process.pid}:${crypto.randomBytes(8).toString('hex')}`; + try { + fs.writeFileSync(marker, token, { flag: 'wx' }); + } catch { + return; // Another evictor holds this slot — leave it to that evictor. + } + try { + if ( + readMarkerToken(marker) === token && + sameSlotFile(fs.lstatSync(slotPath, { bigint: true }), inspected) + ) { + fs.unlinkSync(slotPath); + } + } catch { + /* slot already gone — the retry claims it */ + } finally { + if (readMarkerToken(marker) === token) { + try { + fs.unlinkSync(marker); + } catch { + /* already gone */ + } + } + } +} + function acquireHookSlot(gitNexusDir) { const lockDir = path.join(gitNexusDir, HOOK_LOCK_SUBDIR); try { @@ -28,6 +151,7 @@ function acquireHookSlot(gitNexusDir) { const release = () => { if (released) return; released = true; + process.removeListener('exit', release); try { // Only unlink if we still own the slot. If we appeared stale and // another hook took over, the file now belongs to it — leave alone. @@ -52,8 +176,10 @@ function acquireHookSlot(gitNexusDir) { } let isLive = false; let mtimeMs = Date.now(); + let inspected = null; try { - mtimeMs = fs.fstatSync(fd).mtimeMs; + inspected = fs.fstatSync(fd, { bigint: true }); + mtimeMs = Number(inspected.mtimeMs); const buf = Buffer.alloc(32); const n = fs.readSync(fd, buf, 0, 32, 0); const ownerStr = buf.slice(0, n).toString('utf-8').trim(); @@ -98,11 +224,9 @@ function acquireHookSlot(gitNexusDir) { isLive = false; } if (isLive) break; // Try the next slot. - try { - fs.unlinkSync(slotPath); - } catch { - /* another hook beat us to it — retry will hit EEXIST */ - } + // No stat means we cannot prove which file we judged stale; leave it + // (the retry re-inspects it) rather than risk deleting a fresh lock. + if (inspected) evictStaleSlot(slotPath, inspected); // Loop and retry this slot. } } diff --git a/gitnexus-claude-plugin/hooks/registry-query.cjs b/gitnexus-claude-plugin/hooks/registry-query.cjs index 649b363fe..52abc3474 100644 --- a/gitnexus-claude-plugin/hooks/registry-query.cjs +++ b/gitnexus-claude-plugin/hooks/registry-query.cjs @@ -218,11 +218,11 @@ function branchSlug(rawRef) { return `${safe}-${hash}`; } -// Mirror gitnexus/src/storage/storage-resolver.ts storageSlotName exactly +// Mirror gitnexus/src/storage/storage-slot.ts slotNameForCanonicalPath exactly // (sanitize + sha256 of the canonical repo path, 12-hex suffix). function sanitizeSlotBasename(value) { // Cap first, then walk the tail once — same order as - // gitnexus/src/storage/storage-resolver.ts (avoids /[. ]+$/ ReDoS). + // gitnexus/src/storage/storage-slot.ts (avoids /[. ]+$/ ReDoS). const sanitized = value.replace(/[\u0000-\u001f<>:"/\\|?*]/g, '-').slice(0, 80); let end = sanitized.length; while (end > 0) { @@ -231,9 +231,13 @@ function sanitizeSlotBasename(value) { end--; } const candidate = sanitized.slice(0, end) || 'repository'; - return /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(candidate) - ? `repository-${candidate}` - : candidate; + // Windows also reserves device names with an extension (`CON.txt`); same + // platform branch as gitnexus/src/storage/storage-slot.ts. + const reserved = + process.platform === 'win32' + ? /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i + : /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i; + return reserved.test(candidate) ? `repository-${candidate}` : candidate; } function storageSlotName(repoPath) { @@ -248,40 +252,35 @@ function storageSlotName(repoPath) { return `${basename}-${digest}`; } +// Definedness matches the CLI's storage-resolver.ts: any value other than +// undefined (including '') counts as configured. function envOverridesStorage() { - const envPath = process.env[STORAGE_PATH_ENV]; - const envRoot = process.env[STORAGE_ROOT_ENV]; - return ( - (typeof envPath === 'string' && envPath.length > 0) || - (typeof envRoot === 'string' && envRoot.length > 0) - ); + return process.env[STORAGE_PATH_ENV] !== undefined || process.env[STORAGE_ROOT_ENV] !== undefined; } +// A set-but-invalid override (empty, relative, or containing NUL) makes +// storage unresolvable (the CLI's storage-resolver.ts throws); never fall +// back to the registry row. A filesystem root is invalid only for +// GITNEXUS_STORAGE_PATH (validateConfiguredStoragePath rejects it); +// GITNEXUS_STORAGE_ROOT accepts a filesystem root — storagePathFromRoot +// resolves the slot directly under it. function resolveEntryStoragePath(entry) { const envPath = process.env[STORAGE_PATH_ENV]; - if ( - typeof envPath === 'string' && - envPath.length > 0 && - !envPath.includes('\0') && - path.isAbsolute(envPath) - ) { + if (envPath !== undefined) { + if (!envPath || envPath.includes('\0') || !path.isAbsolute(envPath)) return null; const resolved = path.resolve(envPath); - if (path.isAbsolute(resolved)) return resolved; + // validateConfiguredStoragePath rejects a filesystem root. + return path.basename(resolved) ? resolved : null; } const envRoot = process.env[STORAGE_ROOT_ENV]; - if ( - typeof envRoot === 'string' && - envRoot.length > 0 && - !envRoot.includes('\0') && - path.isAbsolute(envRoot) - ) { + if (envRoot !== undefined) { + if (!envRoot || envRoot.includes('\0') || !path.isAbsolute(envRoot)) return null; const root = path.resolve(envRoot); const slot = storageSlotName(entry.path); - if (slot) { - const storagePath = path.join(root, slot); - if (samePath(path.dirname(storagePath), root)) return storagePath; - } + if (!slot) return null; + const storagePath = path.join(root, slot); + return samePath(path.dirname(storagePath), root) ? storagePath : null; } if (entry.storagePath !== undefined) { @@ -298,6 +297,37 @@ function resolveEntryStoragePath(entry) { return path.resolve(path.join(entry.path, GITNEXUS_DIR)); } +// A single path segment: `..repo-` is a legal slot name, `..` is not. +function isDirectChild(parent, child) { + const rel = path.relative(parent, child); + return rel !== '' && rel !== '..' && !path.isAbsolute(rel) && !rel.includes(path.sep); +} + +// Mirror gitnexus/src/storage/shared-store.ts resolveGraphPath (#3352): a +// shared-store checkout slot may read a commit graph in the same store +// instead of owning /lbug. Any other recorded value is ignored. +function resolveGraphPath(storagePath, metadata) { + const own = path.join(storagePath, LBUG_DIRECTORY); + const storesRoot = path.resolve( + process.env.GITNEXUS_HOME || path.join(os.homedir(), '.gitnexus'), + 'stores', + ); + const slot = path.resolve(storagePath); + const checkoutsDir = path.dirname(slot); + const root = path.dirname(checkoutsDir); + if (path.basename(checkoutsDir) !== 'checkouts') return own; + if (!isDirectChild(checkoutsDir, slot) || !isDirectChild(storesRoot, root)) return own; + const recorded = metadata && metadata.graphPath; + if (typeof recorded !== 'string' || !path.isAbsolute(recorded)) return own; + const graph = path.resolve(recorded); + // Only a published `-` dir, never `.publish-*` staging. + const valid = + path.basename(graph) === LBUG_DIRECTORY && + isDirectChild(path.join(root, 'commits'), path.dirname(graph)) && + /^[0-9a-f]{7,64}-[0-9a-f]{8,64}$/.test(path.basename(path.dirname(graph))); + return valid ? graph : own; +} + function hasLocalIndexSignal(storagePath) { try { return ( @@ -387,7 +417,9 @@ function findRegisteredRepo(cwd) { best = { path: entry.path, storagePath, - lbugPath: path.join(indexDir, LBUG_DIRECTORY), + lbugPath: branchIsIndexed + ? path.join(indexDir, LBUG_DIRECTORY) + : resolveGraphPath(storagePath, ownershipMetadata), metadata: branchIsIndexed ? readIndexMetadata(indexDir) : ownershipMetadata, }; } diff --git a/gitnexus-cursor-integration/README.md b/gitnexus-cursor-integration/README.md index 0d044aaa9..0f8d9b119 100644 --- a/gitnexus-cursor-integration/README.md +++ b/gitnexus-cursor-integration/README.md @@ -8,7 +8,7 @@ Static config that adds GitNexus knowledge-graph augmentation and skill files to | Layer | What it does | How it's installed | | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------- | -| **MCP** | `gitnexus` MCP server with 17 tools (`query`, `context`, `impact`, `detect_changes`, `rename`, …) | `npx gitnexus setup` writes `~/.cursor/mcp.json` automatically. | +| **MCP** | `gitnexus` MCP server with 19 tools (`query`, `context`, `impact`, `detect_changes`, `rename`, …) | `npx gitnexus setup` writes `~/.cursor/mcp.json` automatically. | | **Skills** | All bundled markdown skills (`/gitnexus-exploring`, `/gitnexus-debugging`, `/gitnexus-impact-analysis`, `/gitnexus-refactoring`, `/gitnexus-guide`, `/gitnexus-cli`, `/gitnexus-review`, `/gitnexus-plan`, `/gitnexus-work`, `/gitnexus-lfg`, `/gitnexus-pdg-query`, `/gitnexus-taint-analysis`) | `npx gitnexus setup` copies them to `~/.cursor/skills/gitnexus/`. | | **Hooks** _(this README)_ | `postToolUse` hook that enriches `Shell` / `Read` / `Grep` tool calls with graph context — same augmentation Claude Code gets | **Manual** — copy the files described below into your project's `.cursor/`. | diff --git a/gitnexus-cursor-integration/hooks/hook-lock.cjs b/gitnexus-cursor-integration/hooks/hook-lock.cjs index 759856384..05ee20ace 100644 --- a/gitnexus-cursor-integration/hooks/hook-lock.cjs +++ b/gitnexus-cursor-integration/hooks/hook-lock.cjs @@ -1,3 +1,4 @@ +const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); @@ -5,6 +6,128 @@ const HOOK_LOCK_SUBDIR = '.hook-locks'; const HOOK_LOCK_MAX_INFLIGHT = 3; const HOOK_LOCK_STALE_MS = 30000; +// An evictor's claim marker older than this belongs to a crashed evictor. +// The critical section it guards is a few syscalls (token read, lstat, +// unlink), so any live evictor finishes orders of magnitude sooner; kept well +// under HOOK_LOCK_STALE_MS so an orphan never blocks a slot for long. +const HOOK_LOCK_EVICT_MARKER_STALE_MS = 5000; + +// Same file iff inode identity AND content metadata match. dev+ino alone is +// not enough: filesystems reuse a freed inode number immediately (ext4), so a +// file recreated after an unlink can carry the old file's ino. bigint stats +// keep Windows' 64-bit file ids exact. +function sameSlotFile(a, b) { + return a.dev === b.dev && a.ino === b.ino && a.size === b.size && a.mtimeNs === b.mtimeNs; +} + +function readMarkerToken(marker) { + try { + return fs.readFileSync(marker, 'utf-8'); + } catch { + return null; + } +} + +// Stat and token of a marker, both taken from one open descriptor so they +// describe the same file (a path stat followed by a path read could straddle +// a replacement). O_NOFOLLOW where the platform has it: a marker is always a +// regular file this module created. Returns null when there is no marker. +function readMarkerSnapshot(marker) { + let fd; + try { + fd = fs.openSync(marker, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0)); + return { stat: fs.fstatSync(fd, { bigint: true }), token: fs.readFileSync(fd, 'utf-8') }; + } catch { + return null; + } finally { + if (fd !== undefined) { + try { + fs.closeSync(fd); + } catch { + /* already closed */ + } + } + } +} + +// Break an evictor's claim marker only if it is an orphan: older than +// HOOK_LOCK_EVICT_MARKER_STALE_MS, and still the exact file (identity and +// owner token) judged old when it is re-checked just before the unlink. A +// marker released and re-created by a new claimant in between is fresh, so +// it fails the check and stays. +function breakOrphanedMarker(marker) { + const seen = readMarkerSnapshot(marker); + if (!seen || Date.now() - Number(seen.stat.mtimeMs) <= HOOK_LOCK_EVICT_MARKER_STALE_MS) return; + const now = readMarkerSnapshot(marker); + if (!now || !sameSlotFile(now.stat, seen.stat) || now.token !== seen.token) return; + try { + fs.unlinkSync(marker); + } catch { + /* another contender already cleared it */ + } +} + +// Evict a slot judged stale from the `inspected` stat. A slot file is only +// ever deleted, never moved, and only by the evictor holding the per-slot +// `.evicting` marker, created O_EXCL with a token unique to this call. +// Every destructive step verifies first: +// - the slot is unlinked only if the marker still carries our token (an +// evictor stalled long enough for its marker to be broken as an orphan has +// lost its claim and backs off) and the slot is still the exact file +// inspected — identical dev/ino/size/mtimeNs means its content and age are +// unchanged, so the stale verdict still holds, while a slot recreated since +// inspection fails the check and its lock stands; +// - our marker is removed only if it still carries our token, so a marker +// that has passed to another claimant is left alone; +// - an orphaned marker is broken only if it is still the old file it was +// judged to be (see breakOrphanedMarker). +// +// Residual windows. POSIX has no conditional unlink, so each check-then- +// unlink pair keeps a gap of two adjacent syscalls: +// (a) Slot: between the lstat identity check and unlinkSync(slot), a live +// owner past HOOK_LOCK_STALE_MS could release and a new hook recreate the +// slot, whose fresh lock would then be deleted. The consequence is at +// most one extra concurrent augment beyond HOOK_LOCK_MAX_INFLIGHT for +// that run — the cap is a load guard, and no data or index state +// depends on it. The victim's release() sees a foreign or missing file +// and leaves it alone. +// (b) Marker: between the token re-read and unlinkSync(marker) (ours or an +// orphan's), the marker could pass to another claimant, whose claim would +// then be removed. That only re-opens the slot to one more evictor, which +// still has to pass the slot identity check before deleting anything. +// Both need a stall of seconds landing on that exact syscall pair, and the +// only thing lost is one run's cap accounting, so they are accepted rather +// than traded for heavier machinery. A crash at any point orphans at most the +// marker, which the next contender breaks after it expires. +function evictStaleSlot(slotPath, inspected) { + const marker = `${slotPath}.evicting`; + breakOrphanedMarker(marker); + const token = `${process.pid}:${crypto.randomBytes(8).toString('hex')}`; + try { + fs.writeFileSync(marker, token, { flag: 'wx' }); + } catch { + return; // Another evictor holds this slot — leave it to that evictor. + } + try { + if ( + readMarkerToken(marker) === token && + sameSlotFile(fs.lstatSync(slotPath, { bigint: true }), inspected) + ) { + fs.unlinkSync(slotPath); + } + } catch { + /* slot already gone — the retry claims it */ + } finally { + if (readMarkerToken(marker) === token) { + try { + fs.unlinkSync(marker); + } catch { + /* already gone */ + } + } + } +} + function acquireHookSlot(gitNexusDir) { const lockDir = path.join(gitNexusDir, HOOK_LOCK_SUBDIR); try { @@ -28,6 +151,7 @@ function acquireHookSlot(gitNexusDir) { const release = () => { if (released) return; released = true; + process.removeListener('exit', release); try { // Only unlink if we still own the slot. If we appeared stale and // another hook took over, the file now belongs to it — leave alone. @@ -52,8 +176,10 @@ function acquireHookSlot(gitNexusDir) { } let isLive = false; let mtimeMs = Date.now(); + let inspected = null; try { - mtimeMs = fs.fstatSync(fd).mtimeMs; + inspected = fs.fstatSync(fd, { bigint: true }); + mtimeMs = Number(inspected.mtimeMs); const buf = Buffer.alloc(32); const n = fs.readSync(fd, buf, 0, 32, 0); const ownerStr = buf.slice(0, n).toString('utf-8').trim(); @@ -98,11 +224,9 @@ function acquireHookSlot(gitNexusDir) { isLive = false; } if (isLive) break; // Try the next slot. - try { - fs.unlinkSync(slotPath); - } catch { - /* another hook beat us to it — retry will hit EEXIST */ - } + // No stat means we cannot prove which file we judged stale; leave it + // (the retry re-inspects it) rather than risk deleting a fresh lock. + if (inspected) evictStaleSlot(slotPath, inspected); // Loop and retry this slot. } } diff --git a/gitnexus-cursor-integration/hooks/registry-query.cjs b/gitnexus-cursor-integration/hooks/registry-query.cjs index 649b363fe..52abc3474 100644 --- a/gitnexus-cursor-integration/hooks/registry-query.cjs +++ b/gitnexus-cursor-integration/hooks/registry-query.cjs @@ -218,11 +218,11 @@ function branchSlug(rawRef) { return `${safe}-${hash}`; } -// Mirror gitnexus/src/storage/storage-resolver.ts storageSlotName exactly +// Mirror gitnexus/src/storage/storage-slot.ts slotNameForCanonicalPath exactly // (sanitize + sha256 of the canonical repo path, 12-hex suffix). function sanitizeSlotBasename(value) { // Cap first, then walk the tail once — same order as - // gitnexus/src/storage/storage-resolver.ts (avoids /[. ]+$/ ReDoS). + // gitnexus/src/storage/storage-slot.ts (avoids /[. ]+$/ ReDoS). const sanitized = value.replace(/[\u0000-\u001f<>:"/\\|?*]/g, '-').slice(0, 80); let end = sanitized.length; while (end > 0) { @@ -231,9 +231,13 @@ function sanitizeSlotBasename(value) { end--; } const candidate = sanitized.slice(0, end) || 'repository'; - return /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(candidate) - ? `repository-${candidate}` - : candidate; + // Windows also reserves device names with an extension (`CON.txt`); same + // platform branch as gitnexus/src/storage/storage-slot.ts. + const reserved = + process.platform === 'win32' + ? /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i + : /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i; + return reserved.test(candidate) ? `repository-${candidate}` : candidate; } function storageSlotName(repoPath) { @@ -248,40 +252,35 @@ function storageSlotName(repoPath) { return `${basename}-${digest}`; } +// Definedness matches the CLI's storage-resolver.ts: any value other than +// undefined (including '') counts as configured. function envOverridesStorage() { - const envPath = process.env[STORAGE_PATH_ENV]; - const envRoot = process.env[STORAGE_ROOT_ENV]; - return ( - (typeof envPath === 'string' && envPath.length > 0) || - (typeof envRoot === 'string' && envRoot.length > 0) - ); + return process.env[STORAGE_PATH_ENV] !== undefined || process.env[STORAGE_ROOT_ENV] !== undefined; } +// A set-but-invalid override (empty, relative, or containing NUL) makes +// storage unresolvable (the CLI's storage-resolver.ts throws); never fall +// back to the registry row. A filesystem root is invalid only for +// GITNEXUS_STORAGE_PATH (validateConfiguredStoragePath rejects it); +// GITNEXUS_STORAGE_ROOT accepts a filesystem root — storagePathFromRoot +// resolves the slot directly under it. function resolveEntryStoragePath(entry) { const envPath = process.env[STORAGE_PATH_ENV]; - if ( - typeof envPath === 'string' && - envPath.length > 0 && - !envPath.includes('\0') && - path.isAbsolute(envPath) - ) { + if (envPath !== undefined) { + if (!envPath || envPath.includes('\0') || !path.isAbsolute(envPath)) return null; const resolved = path.resolve(envPath); - if (path.isAbsolute(resolved)) return resolved; + // validateConfiguredStoragePath rejects a filesystem root. + return path.basename(resolved) ? resolved : null; } const envRoot = process.env[STORAGE_ROOT_ENV]; - if ( - typeof envRoot === 'string' && - envRoot.length > 0 && - !envRoot.includes('\0') && - path.isAbsolute(envRoot) - ) { + if (envRoot !== undefined) { + if (!envRoot || envRoot.includes('\0') || !path.isAbsolute(envRoot)) return null; const root = path.resolve(envRoot); const slot = storageSlotName(entry.path); - if (slot) { - const storagePath = path.join(root, slot); - if (samePath(path.dirname(storagePath), root)) return storagePath; - } + if (!slot) return null; + const storagePath = path.join(root, slot); + return samePath(path.dirname(storagePath), root) ? storagePath : null; } if (entry.storagePath !== undefined) { @@ -298,6 +297,37 @@ function resolveEntryStoragePath(entry) { return path.resolve(path.join(entry.path, GITNEXUS_DIR)); } +// A single path segment: `..repo-` is a legal slot name, `..` is not. +function isDirectChild(parent, child) { + const rel = path.relative(parent, child); + return rel !== '' && rel !== '..' && !path.isAbsolute(rel) && !rel.includes(path.sep); +} + +// Mirror gitnexus/src/storage/shared-store.ts resolveGraphPath (#3352): a +// shared-store checkout slot may read a commit graph in the same store +// instead of owning /lbug. Any other recorded value is ignored. +function resolveGraphPath(storagePath, metadata) { + const own = path.join(storagePath, LBUG_DIRECTORY); + const storesRoot = path.resolve( + process.env.GITNEXUS_HOME || path.join(os.homedir(), '.gitnexus'), + 'stores', + ); + const slot = path.resolve(storagePath); + const checkoutsDir = path.dirname(slot); + const root = path.dirname(checkoutsDir); + if (path.basename(checkoutsDir) !== 'checkouts') return own; + if (!isDirectChild(checkoutsDir, slot) || !isDirectChild(storesRoot, root)) return own; + const recorded = metadata && metadata.graphPath; + if (typeof recorded !== 'string' || !path.isAbsolute(recorded)) return own; + const graph = path.resolve(recorded); + // Only a published `-` dir, never `.publish-*` staging. + const valid = + path.basename(graph) === LBUG_DIRECTORY && + isDirectChild(path.join(root, 'commits'), path.dirname(graph)) && + /^[0-9a-f]{7,64}-[0-9a-f]{8,64}$/.test(path.basename(path.dirname(graph))); + return valid ? graph : own; +} + function hasLocalIndexSignal(storagePath) { try { return ( @@ -387,7 +417,9 @@ function findRegisteredRepo(cwd) { best = { path: entry.path, storagePath, - lbugPath: path.join(indexDir, LBUG_DIRECTORY), + lbugPath: branchIsIndexed + ? path.join(indexDir, LBUG_DIRECTORY) + : resolveGraphPath(storagePath, ownershipMetadata), metadata: branchIsIndexed ? readIndexMetadata(indexDir) : ownershipMetadata, }; } diff --git a/gitnexus-factory-plugin/.factory-plugin/plugin.json b/gitnexus-factory-plugin/.factory-plugin/plugin.json new file mode 100644 index 000000000..6195fe2b4 --- /dev/null +++ b/gitnexus-factory-plugin/.factory-plugin/plugin.json @@ -0,0 +1,11 @@ +{ + "name": "gitnexus", + "description": "Code intelligence powered by a knowledge graph. Provides execution flow tracing, blast radius analysis, and augmented search across your codebase.", + "version": "1.6.12", + "author": { + "name": "GitNexus" + }, + "homepage": "https://github.com/abhigyanpatwari/GitNexus", + "repository": "https://github.com/abhigyanpatwari/GitNexus", + "keywords": ["code-intelligence", "knowledge-graph", "mcp", "static-analysis"] +} diff --git a/gitnexus-factory-plugin/hooks/gitnexus-hook.js b/gitnexus-factory-plugin/hooks/gitnexus-hook.js new file mode 100644 index 000000000..8b631e4bb --- /dev/null +++ b/gitnexus-factory-plugin/hooks/gitnexus-hook.js @@ -0,0 +1,510 @@ +#!/usr/bin/env node +/** + * GitNexus Factory AI (Droid) plugin hook. + * + * PostToolUse — augments Grep/Glob/Execute searches with graph context and + * returns it via hookSpecificOutput.additionalContext. + * + * Reuses the Claude adapter's guards, bundled byte-identical: acquireHookSlot + * caps concurrent augment children per repo (#1486), and the LadybugDB owner + * probe skips the CLI augment when an MCP/serve process already holds the + * single-writer lock (#2396). The repo and its index storage are resolved via + * the same bundled registry lookup (registry-query.cjs), so external and + * branch-slot indexes work (#3060). On Unix the augment child runs under the + * probe's self-tested coreutils `timeout` guard, as in the Claude adapter + * (#2163), so a hook the runner kills cannot strand the CLI (see runAugment). + */ + +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); +const { acquireHookSlot } = require('./hook-lock.js'); +const { + hasGitNexusDbLockedByGitNexusServer, + resolveUnixGuardTimeout, +} = require('./hook-db-lock-probe.cjs'); +const { resolveHookRepo } = require('./registry-query.cjs'); + +// Pin the CLI instead of tracking `latest`: npm versions are immutable, so only +// a plugin revision can change what the fallback below executes. The release +// stamps this manifest (gitnexus/scripts/sync-plugin-manifests.mjs). +const { version: PINNED_VERSION } = require('../.factory-plugin/plugin.json'); + +function readInput() { + try { + return JSON.parse(fs.readFileSync(0, 'utf-8')); + } catch { + return {}; + } +} + +/** + * Split a command the way a POSIX shell would, so quoted and backslash-escaped + * patterns survive as one token. Kept identical to the Cursor adapter's + * tokenizer (#2938) so the two can collapse into a shared module later. + */ +function tokenizeShellWords(command) { + const tokens = []; + let current = ''; + let quote = null; + let escaped = false; + let hasToken = false; + + for (let index = 0; index < command.length; index += 1) { + const char = command[index]; + if (escaped) { + current += char; + escaped = false; + hasToken = true; + continue; + } + + if (quote === "'") { + if (char === "'") quote = null; + else current += char; + hasToken = true; + continue; + } + + if (quote === '"') { + if (char === '"') { + quote = null; + } else if (char === '\\') { + const next = command[index + 1]; + if (next === '$' || next === '`' || next === '"' || next === '\\') { + escaped = true; + } else { + current += '\\'; + } + } else { + current += char; + } + hasToken = true; + continue; + } + + if (char === '\\') { + const next = command[index + 1]; + if (next === undefined || /\s/.test(next) || next === "'" || next === '"' || next === '\\') { + escaped = true; + } else { + current += '\\' + next; + index += 1; + } + hasToken = true; + } else if (char === "'" || char === '"') { + quote = char; + hasToken = true; + } else if (/\s/.test(char)) { + if (hasToken) tokens.push(current); + current = ''; + hasToken = false; + } else if (char === ';' || char === '|' || char === '&') { + if (hasToken) tokens.push(current); + current = ''; + hasToken = false; + const next = command[index + 1]; + if ((char === '|' || char === '&') && next === char) { + tokens.push(char + char); + index += 1; + } else { + tokens.push(char); + } + } else { + current += char; + hasToken = true; + } + } + + if (escaped) current += '\\'; + if (hasToken) tokens.push(current); + return tokens; +} + +/** Recover the search pattern from an `rg`/`grep` command line. */ +function parseRgGrepPattern(cmd) { + const tokens = tokenizeShellWords(cmd); + let foundCmd = false; + let skipNext = false; + let skipNextAsPattern = false; + let endOfOptions = false; + let explicitPatternSeen = false; + let patternFileSeen = false; + const flagsWithValues = new Set([ + '-e', + '-f', + '--file', + '-m', + '--max-count', + '-A', + '-B', + '-C', + '-g', + '--glob', + '--iglob', + '-t', + '--type', + '--include', + '--exclude', + '--encoding', + '--path', + ]); + const rgValueFlags = new Set(['-r', '--replace']); + const patternFlags = new Set(['-e', '--regexp']); + const connectors = new Set(['&&', '||', ';', '|', '&']); + const wrappers = new Set([ + 'npx', + 'bunx', + 'pnpm', + 'yarn', + 'npm', + 'sudo', + 'env', + 'command', + 'time', + 'nice', + 'xargs', + 'dlx', + 'exec', + 'run', + 'git', + ]); + const wrapperFlagsWithValues = new Set([ + '--package', + '-p', + '--call', + '--prefix', + '--shell', + '--filter', + '--workspace', + '--dir', + '--cwd', + ]); + const basename = (token) => + token + .split(/[\\/]/) + .pop() + ?.replace(/\.(exe|cmd|bat)$/i, ''); + + let previousToken; + let seenWrapper = false; + let searchCommand = null; + for (const token of tokens) { + if (skipNext) { + skipNext = false; + if (skipNextAsPattern) { + skipNextAsPattern = false; + if (token.length >= 3) return token; + } + previousToken = token; + continue; + } + if (!foundCmd) { + if (connectors.has(token)) { + seenWrapper = false; + previousToken = token; + continue; + } + const commandName = basename(token); + if (wrappers.has(commandName)) { + seenWrapper = true; + previousToken = token; + continue; + } + if (seenWrapper && token.startsWith('-')) { + const flagName = token.split('=', 1)[0]; + if (!token.includes('=') && wrapperFlagsWithValues.has(flagName)) skipNext = true; + previousToken = token; + continue; + } + if (seenWrapper && /^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) { + previousToken = token; + continue; + } + const atCommandPosition = + previousToken === undefined || + connectors.has(previousToken) || + wrappers.has(basename(previousToken)) || + seenWrapper; + if (atCommandPosition && (commandName === 'rg' || commandName === 'grep')) { + foundCmd = true; + searchCommand = commandName; + } else if (seenWrapper) { + seenWrapper = false; + } + previousToken = token; + continue; + } + previousToken = token; + if (endOfOptions) { + if (explicitPatternSeen || patternFileSeen) continue; + return token.length >= 3 ? token : null; + } + if (token === '--') { + endOfOptions = true; + continue; + } + if (token.startsWith('-')) { + if (token === '-f' || token === '--file') { + skipNext = true; + patternFileSeen = true; + continue; + } + if (token.startsWith('--file=')) { + patternFileSeen = true; + continue; + } + if (token.startsWith('--regexp=')) { + explicitPatternSeen = true; + const value = token.slice('--regexp='.length); + if (value.length >= 3) return value; + continue; + } + const attachedPattern = token.match(/^-e(.+)$/); + if (attachedPattern) { + explicitPatternSeen = true; + if (attachedPattern[1].length >= 3) return attachedPattern[1]; + continue; + } + if ( + flagsWithValues.has(token) || + patternFlags.has(token) || + (searchCommand === 'rg' && rgValueFlags.has(token)) + ) { + skipNext = true; + skipNextAsPattern = patternFlags.has(token); + if (skipNextAsPattern) explicitPatternSeen = true; + } + continue; + } + if (explicitPatternSeen || patternFileSeen) continue; + return token.length >= 3 ? token : null; + } + return null; +} + +/** Factory's shell tool is `Execute` (Claude's is `Bash`); Grep/Glob match Claude's. */ +function extractPattern(toolName, toolInput) { + if (toolName === 'Grep') { + return toolInput.pattern || null; + } + + if (toolName === 'Glob') { + const raw = toolInput.pattern || ''; + const match = raw.match(/[*\/]([a-zA-Z][a-zA-Z0-9_-]{2,})/); + return match ? match[1] : null; + } + + if (toolName === 'Execute') { + const cmd = toolInput.command || ''; + if (!/\brg\b|\bgrep\b/.test(cmd)) return null; + return parseRgGrepPattern(cmd); + } + + return null; +} + +/** + * Whether opt-in diagnostics should be written to the hook's stderr. Strict + * hook runners (e.g. Codex `PreToolUse`) validate hook output, so normal, + * non-error skip paths must stay silent unless the operator explicitly asks + * for diagnostics via GITNEXUS_DEBUG. See issue #1913. + */ +function isDebugEnabled() { + return process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true'; +} + +/** + * Keep only the augment block: stderr from the first `[GitNexus]` marker on, or + * '' when there is none, so npm/Node/LadybugDB warnings never reach the agent. + * Kept identical to the Claude adapter's copy so the two can be shared later. + */ +function extractAugmentContext(stderr) { + const output = (stderr || '').trim(); + const marker = output.indexOf('[GitNexus]'); + const debug = isDebugEnabled(); + if (debug && output.length > 0) { + // Emit the FULL discarded prefix (everything before the marker, or all of + // it when no marker is present) so suppressed diagnostics — LadybugDB lock + // warnings, parser errors, etc. — remain recoverable on the hook's own + // stderr. The untruncated payload lets operators see exactly what was + // filtered out instead of a 180-char JSON-quoted preview. + const discarded = marker === -1 ? output : output.slice(0, marker).trim(); + if (discarded.length > 0) { + process.stderr.write(`[GitNexus hook] augment stderr discarded prefix:\n${discarded}\n`); + } + } + return marker === -1 ? '' : output.slice(marker).trim(); +} + +/** + * Absolute path of a runnable (regular file, X_OK) `command` on PATH, or null. + * POSIX-only: used where the timeout guard would otherwise mask a missing + * launcher as the guard's own exit 127 instead of a spawn ENOENT. + */ +function findOnPath(command) { + for (const dir of (process.env.PATH || '').split(path.delimiter).filter(Boolean)) { + const candidate = path.join(dir, command); + try { + if (!fs.statSync(candidate).isFile()) continue; + fs.accessSync(candidate, fs.constants.X_OK); + return candidate; + } catch { + /* not a runnable file here */ + } + } + return null; +} + +/** + * Run `gitnexus augment` for `pattern` and return its `[GitNexus]` block — the + * augment CLI writes results to stderr because LadybugDB's native module + * captures stdout at the OS fd level. Launcher noise is filtered out by + * extractAugmentContext, so noise-only stderr yields ''. + * + * GITNEXUS_HOOK_CLI_PATH is tried first and run as `node `, the only form + * that works on Windows, where Node refuses to spawn the `.cmd` shims without a + * shell (CVE-2024-27980). Otherwise a PATH binary, and a version-pinned npx + * only when no PATH binary exists. Exactly one tier runs, so a no-match search + * (exit 0, empty stderr) or a timeout never spends a second 8s budget on npx + * past the 10s hook timeout in hooks.json. + * + * Orphan guard (#2163, ported from the Claude adapter's runGitNexusCli): on + * Unix every tier runs under the probe's self-tested coreutils `timeout`, so a + * hook killed by the runner cannot strand the CLI. The direct tiers (the CLI is + * the guard's child) use `-k 1` TERM-first; npx (guard → npx → CLI grandchild) + * uses `-s KILL`, which group-kills at budget — TERM-first would only kill the + * obedient npx parent and let `timeout` exit before its `-k` escalation, leaving + * a SIGTERM-immune CLI running. Residual gaps are the Claude adapter's: a + * busybox guard signals only its direct child, and when the hook itself is + * alive the inner spawnSync timeout SIGTERMs the guard, which forwards TERM, not + * KILL, to the npx group. Because the guard reports a missing command as its + * own exit 127 rather than ENOENT, the guarded PATH tier decides presence with + * findOnPath first. Windows (no coreutils; the self-test spawns /bin/sh) and an + * unresolved guard (e.g. macOS without Homebrew coreutils, or + * GITNEXUS_HOOK_TIMEOUT_PATH=disabled) keep the plain spawn and the ENOENT + * fallthrough. + * + * SECURITY: `pattern` follows the `--` end-of-options marker and never reaches a + * shell (the Windows fallback invokes `npx.cmd` directly rather than + * `shell: true`), so `-rf` or `$(...)` is inert. + */ +function runAugment(pattern, cwd) { + const isWin = process.platform === 'win32'; + const args = ['augment', '--', pattern]; + const timeoutMs = 8000; + const spawnOpts = { + encoding: 'utf-8', + timeout: timeoutMs, + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }; + // An older bundled probe without the export degrades to the unwrapped spawn. + const guard = + isWin || typeof resolveUnixGuardTimeout !== 'function' ? null : resolveUnixGuardTimeout(); + if (!isWin && !guard && isDebugEnabled()) { + process.stderr.write( + '[GitNexus hook] no usable timeout/gtimeout guard; augment CLI child runs unguarded\n', + ); + } + const guardSecs = String(Math.ceil(timeoutMs / 1000) + 1); + // Only a clean exit 0 yields context; a spawn error, throw or non-zero exit is ''. + // `groupKill` selects the npx arm's `-s KILL` (see the docblock). + const spawnAugment = (cmd, argv, groupKill = false) => { + const [file, fileArgs] = guard + ? [guard, [...(groupKill ? ['-s', 'KILL'] : []), '-k', '1', guardSecs, cmd, ...argv]] + : [cmd, argv]; + try { + const child = spawnSync(file, fileArgs, spawnOpts); + if (!child.error && child.status === 0) return extractAugmentContext(child.stderr); + } catch { + /* graceful failure */ + } + return ''; + }; + + const hookCli = process.env.GITNEXUS_HOOK_CLI_PATH; + if (hookCli && String(hookCli).trim() && fs.existsSync(String(hookCli))) { + return spawnAugment(process.execPath, [String(hookCli), ...args]); + } + + if (guard) { + // Guarded (Unix): only a missing launcher falls through to npx. + const launcher = findOnPath('gitnexus'); + if (launcher) return spawnAugment(launcher, args); + } else { + // Only ENOENT (no launcher on PATH) falls through to npx. Windows EINVAL for + // `gitnexus.cmd` does not: `npx.cmd` would fail the same way without a shell. + try { + const child = spawnSync(isWin ? 'gitnexus.cmd' : 'gitnexus', args, spawnOpts); + if (!child.error || child.error.code !== 'ENOENT') { + return !child.error && child.status === 0 ? extractAugmentContext(child.stderr) : ''; + } + } catch (err) { + if (!err || err.code !== 'ENOENT') return ''; + } + } + + return spawnAugment( + isWin ? 'npx.cmd' : 'npx', + ['-y', `gitnexus@${PINNED_VERSION}`, ...args], + true, + ); +} + +function main() { + try { + const input = readInput(); + if ((input.hook_event_name || '') !== 'PostToolUse') return; + + const cwd = input.cwd || process.cwd(); + if (!path.isAbsolute(cwd)) return; + + const toolName = input.tool_name || ''; + if (toolName !== 'Grep' && toolName !== 'Glob' && toolName !== 'Execute') return; + + const pattern = extractPattern(toolName, input.tool_input || {}); + if (!pattern || pattern.length < 3) return; + + // Registry row first (persisted external storagePath wins); a local owned + // `.gitnexus` is the fallback — same lookup as the Claude/Cursor hooks. + const repo = resolveHookRepo(cwd); + if (!repo) return; + + const release = acquireHookSlot(repo.storagePath); + if (!release) return; // all per-repo augment slots held by concurrent sessions + + let result = ''; + try { + if (hasGitNexusDbLockedByGitNexusServer(repo.lbugPath, process.pid)) { + // #2396: an MCP/serve process owns the single-writer DB, so a competing + // CLI augment would only contend on the lock. Its MCP tools cover + // augmentation instead — skip silently. + return; + } + result = runAugment(pattern, cwd); + } catch { + /* graceful failure */ + } finally { + release(); + } + + if (result && result.trim()) { + console.log( + JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'PostToolUse', + additionalContext: result.trim(), + }, + }), + ); + } + } catch { + /* never let the hook break the tool call */ + } +} + +if (require.main === module) main(); + +module.exports = { parseRgGrepPattern, tokenizeShellWords }; diff --git a/gitnexus-factory-plugin/hooks/hook-db-lock-probe.cjs b/gitnexus-factory-plugin/hooks/hook-db-lock-probe.cjs new file mode 100644 index 000000000..3e98f5901 --- /dev/null +++ b/gitnexus-factory-plugin/hooks/hook-db-lock-probe.cjs @@ -0,0 +1,759 @@ +/** + * Cross-platform best-effort probe: does another process hold dbPath open + * with a command line that looks like a GitNexus MCP/serve server? + * + * Backends (no user-installed Sysinternals): + * - Linux: cmdline-first procfs scan under /proc, no lsof at all (#2180). Three + * phases, cheapest first: (0) read /proc//comm — a tiny task->comm read + * that never touches the target's mm — and keep only PIDs whose comm is a + * plausible node/gitnexus server; (1) read up to GITNEXUS_HOOK_PROC_CMDLINE_MAX + * bytes of /proc//cmdline via openSync+readSync (bounded, so a D-state + * holder stuck on mmap_lock or a giant argv can't wedge the hook) and prefilter + * with isGitNexusServerCommand; (2) only for the 0..N survivors, stat their + * /proc//fd/* and compare dev+inode against the target lbug. The lbug + * handle is fd-visible (a @ladybugdb/core property), so this finds every real + * owner without scanning every fd of every process. + * - macOS / *BSD / etc.: trusted lsof + ps (absolute paths first). + * - Windows: Restart Manager (rstrtmgr) via bundled PowerShell script + + * Win32_Process for command lines; trusted powershell.exe under %SystemRoot%. + * + * Fail matrix: + * - Linux proc scan: owner found -> fail-closed (skip augment); budget exhausted + * (GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS) -> fail-CLOSED (#2180). This is a + * deliberate change from the old "timeout -> fail-open then try lsof" path. + * End-to-end the busy-host outcome is unchanged: the old code's lsof fallback + * ETIMEDOUT'd on the very hosts where the scan ran out of budget and ALSO + * failed closed there — the lsof leg only ever added 1-2s of dead work plus + * the orphan-storm risk it caused (#2163). What changes is that an overloaded + * host now self-throttles immediately (the throttle the incident needed) + * instead of paying for a doomed lsof. Mid-load hosts that used to fall + * through to a successful lsof now answer from the scan directly (faster) or, + * if even the scan can't finish in budget, fail closed (self-throttle) — a + * bounded, documented tradeoff, never an orphan. + * - macOS / other Unix: fail-open on most errors; fail-closed only on lsof + * ETIMEDOUT, matching the hook contract. + * - Windows: fail-closed only on PowerShell ETIMEDOUT. + * + * Unix subprocess containment contract (#2163): + * - lsof/ps are wrapped in coreutils `timeout`/`gtimeout` when a working + * wrapper is found (`timeout -k 1 lsof ...`). If this hook process + * is itself SIGKILLed (e.g. by the runner's 10s hook timeout) the wrapper + * survives, SIGTERMs its child at the budget (2s lsof / 1s ps) and SIGKILLs + * it 1s later — orphan lifetime is bounded at ~3s instead of unbounded. + * - GITNEXUS_HOOK_TIMEOUT_PATH: the sentinel value `disabled` switches the + * wrapper off deterministically; any other value is adopted only when it + * exists AND passes a one-shot `-k` exit-propagation self-test — otherwise + * resolution FALLS THROUGH to the built-in candidate list (first self-test + * pass wins), so no malformed value of any shape can silently disable + * orphan containment. + * - The gitnexus server is lazy-open + sticky-hold: an idle MCP server holds + * ZERO lbug fds until the repo's first MCP query, then keeps the fd open. + * A probe before that first query is therefore always false — a known, + * pre-existing race, not a bug in this probe. + * - resolveUnixGuardTimeout is exported so the hook adapters can wrap the + * `gitnexus augment` CLI child — the longest-lived hook subprocess (7s + * local / 12s npx inner budgets) — in the same guard; see runGitNexusCli + * in the adapters (#2163 follow-up). + */ + +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +function isGitNexusServerCommand(command) { + const hasServerMode = /(?:^|\s)(mcp|serve)(?:\s|$)/.test(command); + const hasGitNexus = + /(?:^|[/\\\s])gitnexus(?:\.cmd)?(?:\s|$)/.test(command) || + /node_modules[/\\]gitnexus[/\\]/.test(command); + return hasServerMode && hasGitNexus; +} + +// GITNEXUS_DEBUG-gated stderr diagnostics. Reuses the exact gating predicate the +// Windows ps1-load warning already uses (===' 1' / ==='true') so there is one +// debug convention in this file, and writes via process.stderr.write (NOT a +// spawn) so it never perturbs the windowsHide spawn-count invariant. +function debugLog(msg) { + if (process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true') { + process.stderr.write(`[GitNexus hook] ${msg}\n`); + } +} + +function resolveHookBinary(tool) { + const envKey = tool === 'lsof' ? 'GITNEXUS_HOOK_LSOF_PATH' : 'GITNEXUS_HOOK_PS_PATH'; + // Trim once, exactly as hasMissingHookBinaryOverride does, so a padded but + // valid override (" /tmp/lsof ") is both accepted there and used here. + const fromEnv = process.env[envKey] ? String(process.env[envKey]).trim() : ''; + if (fromEnv && fs.existsSync(fromEnv)) { + return fromEnv; + } + const candidates = + tool === 'lsof' + ? ['/usr/bin/lsof', '/usr/sbin/lsof', '/sbin/lsof', tool] + : ['/bin/ps', '/usr/bin/ps', tool]; + for (const candidate of candidates) { + if (candidate === tool) return tool; + try { + if (fs.existsSync(candidate)) return candidate; + } catch { + /* ignore */ + } + } + return tool; +} + +function hasMissingHookBinaryOverride(tool) { + const envKey = tool === 'lsof' ? 'GITNEXUS_HOOK_LSOF_PATH' : 'GITNEXUS_HOOK_PS_PATH'; + const fromEnv = process.env[envKey]; + if (!fromEnv || !String(fromEnv).trim()) return false; + try { + return !fs.existsSync(String(fromEnv).trim()); + } catch { + return true; + } +} + +// Sentinel: +// undefined = not resolved yet (resolve lazily, on first lsof/ps fallback) +// string = self-tested coreutils timeout/gtimeout path (use as wrapper) +// null = no usable wrapper (disabled, none found, or self-test failed) +let unixGuardTimeoutCache; + +/** + * Resolve a coreutils `timeout`/`gtimeout` binary to wrap lsof/ps with + * (#2163). Unix-only by contract: the probe's win32 dispatch returns before + * reaching it, and the exported callers (the adapters' runGitNexusCli, + * #2163 follow-up) must check the platform first — the self-test below + * spawns /bin/sh. The memoized result is module-wide, so probe and adapter + * share one lazy self-test per hook process. + * + * GITNEXUS_HOOK_TIMEOUT_PATH semantics: the sentinel `disabled` turns the + * wrapper off; any other value is only a CANDIDATE — an existing file path + * is tried first, but it must pass the `-k` exit-propagation self-test to + * be adopted. On any failure (non-existent path, directory, non-executable + * file, wrapper without `-k` support, always-exit-0 stub, …) resolution + * falls through to the built-in candidates below, tried in order, first + * self-test pass wins. This is strictly stronger than the sibling + * GITNEXUS_HOOK_LSOF_PATH / GITNEXUS_HOOK_PS_PATH overrides (which only + * check existence): no bad env value of ANY shape can silently disable + * orphan containment. + * + * Lazy self-test: candidates are probed only when the lsof/ps fallback is + * first reached, and the result is memoized. A candidate is adopted only + * when `timeout -k 1 1 /bin/sh -c 'exit 42'` exits 42 — i.e. it must RUN + * the wrapped command AND PROPAGATE its exit status. This rejects two + * failure shapes: wrappers without the coreutils `-k` flag — busybox <1.34, + * toybox, broken symlinks — which would exit with a usage error without + * ever running lsof, silently converting the lsof-ETIMEDOUT fail-closed + * contract into fail-open (#1492 regression); and always-exit-0 stubs + * (/bin/true shapes), which would otherwise be adopted and "succeed" every + * wrapped spawn instantly without running it — a constant no-owner probe + * answer and, worse, a silently dead augment (status 0, empty stderr passes + * the adapters' success check with no context; #2163 follow-up review). + * Only when EVERY candidate fails does the probe fall back to the unwrapped + * status quo (memoized null). busybox ≥1.34 passes the test and is fully + * usable for everything THIS file spawns (lsof/ps are the guard's direct + * children) and for the adapters' direct-exec arm. The adapters' npx arm + * additionally relies on coreutils' process-GROUP signalling for its + * `-s KILL` grandchild reaping; busybox signals only its direct child, and + * this self-test deliberately does not probe that capability — see the + * adapter docblocks for the residual-gap statement. + */ +function passesGuardSelfTest(guard) { + try { + const selfTest = spawnSync(guard, ['-k', '1', '1', '/bin/sh', '-c', 'exit 42'], { + encoding: 'utf-8', + timeout: 3000, + stdio: ['ignore', 'ignore', 'ignore'], + windowsHide: true, + }); + return !selfTest.error && selfTest.status === 42; + } catch { + return false; + } +} + +function resolveUnixGuardTimeout() { + if (unixGuardTimeoutCache !== undefined) return unixGuardTimeoutCache; + unixGuardTimeoutCache = null; + const fromEnv = process.env.GITNEXUS_HOOK_TIMEOUT_PATH; + const trimmed = fromEnv ? String(fromEnv).trim() : ''; + if (trimmed === 'disabled') return unixGuardTimeoutCache; + const candidates = []; + // Resolve the override against THIS process's cwd — the directory the + // existsSync check and the self-test run in — so the cached/returned path is + // always absolute. The adapters spawn the wrapper with a different `cwd` + // (the tool request's), where a relative value would resolve elsewhere + // (ENOENT), and a slashless name would switch to a PATH lookup. + const override = trimmed ? path.resolve(trimmed) : ''; + if (override && fs.existsSync(override)) candidates.push(override); + for (const builtin of [ + '/usr/bin/timeout', + '/bin/timeout', + '/opt/homebrew/bin/gtimeout', + '/usr/local/bin/gtimeout', + ]) { + try { + if (fs.existsSync(builtin)) candidates.push(builtin); + } catch { + /* ignore */ + } + } + for (const candidate of candidates) { + if (passesGuardSelfTest(candidate)) { + unixGuardTimeoutCache = candidate; + break; + } + } + return unixGuardTimeoutCache; +} + +function resolveWindowsPowerShellPath() { + const fromEnv = process.env.GITNEXUS_HOOK_POWERSHELL_PATH; + if (fromEnv && String(fromEnv).trim() && fs.existsSync(String(fromEnv).trim())) { + return String(fromEnv).trim(); + } + const root = process.env.SystemRoot || 'C:\\Windows'; + const ps = path.join(root, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'); + if (fs.existsSync(ps)) return ps; + const psWow = path.join(root, 'SysWOW64', 'WindowsPowerShell', 'v1.0', 'powershell.exe'); + if (fs.existsSync(psWow)) return psWow; + return 'powershell.exe'; +} + +// Sentinel: +// undefined = not loaded yet (try the read) +// string = encoded PowerShell command (successful load) +// null = load attempted and failed (do not retry; warning already emitted) +let windowsRmListPsEncodedCommandCache; +let windowsRmListPsLoadFailureWarned = false; +function getWindowsRmListEncodedCommand() { + if (windowsRmListPsEncodedCommandCache !== undefined) { + return windowsRmListPsEncodedCommandCache; + } + try { + const ps1Path = path.join(__dirname, 'win-rm-list-json.ps1'); + const src = fs + .readFileSync(ps1Path, 'utf8') + .replace(/^\uFEFF/, '') + .replace(/\r\n/g, '\n'); + windowsRmListPsEncodedCommandCache = Buffer.from(src, 'utf16le').toString('base64'); + } catch (err) { + windowsRmListPsEncodedCommandCache = null; + if ( + !windowsRmListPsLoadFailureWarned && + (process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true') + ) { + windowsRmListPsLoadFailureWarned = true; + const msg = err && err.message ? String(err.message).slice(0, 200) : 'unknown'; + process.stderr.write(`[GitNexus hook] win-rm-list-json.ps1 load failed: ${msg}\n`); + } + } + return windowsRmListPsEncodedCommandCache; +} + +function hasGitNexusServerOwnerWindows(dbPathAbs, myPid) { + const encoded = getWindowsRmListEncodedCommand(); + if (!encoded) return false; + const psExe = resolveWindowsPowerShellPath(); + const r = spawnSync( + psExe, + [ + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-STA', + '-EncodedCommand', + encoded, + ], + { + encoding: 'utf-8', + timeout: 6000, + stdio: ['ignore', 'pipe', 'ignore'], + windowsHide: true, + env: { ...process.env, GITNEXUS_HOOK_RM_TARGET: dbPathAbs }, + }, + ); + // ETIMEDOUT means the PowerShell probe didn't return in time; treat as 'unresponsive process holds DB' → fail-closed (skip augment). + if (r.error) return r.error.code === 'ETIMEDOUT'; + if (r.status !== 0) return false; + let rows; + try { + rows = JSON.parse(String(r.stdout || '').trim() || '[]'); + } catch { + return false; + } + if (!Array.isArray(rows)) return false; + for (const row of rows) { + const procId = Number(row.pid); + const cmd = String(row.cmd || ''); + if (!Number.isFinite(procId) || procId === myPid) continue; + if (isGitNexusServerCommand(cmd)) return true; + } + return false; +} + +// The procfs root every Linux scan path reads from. Production is always /proc; +// GITNEXUS_HOOK_PROC_ROOT only exists so unit tests can inject a fixture tree +// (comm + cmdline + fd symlinks) and assert the three-phase logic without +// scanning the real, ~hundreds-of-process /proc of the test host. +// +// Test-only gate (F4): the override is honored ONLY under a test runner — +// vitest injects VITEST="true" and NODE_ENV="test" into every worker (verified; +// a production hook is `node .cjs` with neither set). Without the gate, a +// production env that accidentally leaked GITNEXUS_HOOK_PROC_ROOT (pointing at an +// empty/bad tree) would make readdirSync find no pids -> 'not-owned' -> Linux +// owner detection silently OFF (fail-OPEN: augment races the real server for the +// lbug, the #1492 class). Gating to the test signal makes that leak inert in +// production (always /proc) while the fake-procfs unit tests, which run under +// vitest, still inject freely. Unset env (or non-test context) => /proc, so the +// production path is byte-for-byte the historical behavior. +function isTestContext() { + return ( + process.env.VITEST === 'true' || process.env.VITEST === '1' || process.env.NODE_ENV === 'test' + ); +} +function getProcRoot() { + if (!isTestContext()) return '/proc'; + const raw = process.env.GITNEXUS_HOOK_PROC_ROOT; + return raw && String(raw).trim() ? String(raw) : '/proc'; +} + +// Max bytes read from /proc//cmdline in Phase 1. Bounded by default so a +// D-state holder wedged on mmap_lock, or a process with a pathological multi-MB +// argv, can't stall the hook. 16 KiB comfortably clears a realistic +// `node mcp` line +// (the `mcp`/`serve` mode token lives at the very tail, so the cap must be large +// enough to reach it — see PROC_CMDLINE_FLOOR escalation below). Overridable for +// tests via GITNEXUS_HOOK_PROC_CMDLINE_MAX: an integer in +// [PROC_CMDLINE_FLOOR, PROC_CMDLINE_CEIL] is used as-is; a larger integer is +// CLAMPED to PROC_CMDLINE_CEIL; anything else (below the floor, fractional, +// non-numeric, Infinity) falls back to the 16 KiB default. +const PROC_CMDLINE_FLOOR = 4096; +// Upper bound for a single cmdline read chunk, and the absolute ceiling of the +// escalation path in readLinuxCmdline (same 256 KiB — no single read may exceed +// what the whole escalation is allowed to collect). Without it, an oversized +// override (e.g. 2**40 — past buffer.constants.MAX_LENGTH on older Node lines +// and unallocatable in practice on any) made Buffer.allocUnsafe throw; readLinuxCmdline's catch turned that into '' (a +// NON-candidate), so a real server owner was silently missed (fail-OPEN, the +// #1492 race). Oversized values are clamped rather than defaulted: the operator +// asked for MORE bytes, and the ceiling is the most the read will ever collect +// anyway, so clamping honours the intent while keeping allocation bounded. +const PROC_CMDLINE_CEIL = 262144; +function getCmdlineMaxBytes() { + const raw = process.env.GITNEXUS_HOOK_PROC_CMDLINE_MAX; + // Number() (not parseInt) so "8e3" reads as 8000, not 8 (parseInt stops at + // 'e'). The `raw && String(raw).trim()` guard keeps empty/whitespace on the + // default; trailing garbage ("8abc") now -> NaN -> default (stricter). + const n = raw && String(raw).trim() ? Number(String(raw).trim()) : NaN; + // Number.isInteger rejects NaN, +/-Infinity and fractions (a fractional + // Buffer/readSync length is not a byte count). + if (Number.isInteger(n) && n >= PROC_CMDLINE_FLOOR) return Math.min(n, PROC_CMDLINE_CEIL); + return 16384; +} + +// Phase 0 comm prefilter. /proc//comm is the kernel task->comm string, +// capped at 16 bytes INCLUDING the trailing NUL — i.e. at most 15 visible +// chars, truncated by the kernel with no marker. So a process whose real name +// is longer than 15 chars shows a 15-char prefix here. The match below is +// therefore truncation-safe in BOTH directions (a whitelist name that is a +// prefix of comm, or comm that is a prefix of a whitelist name, both count) to +// guarantee we never drop a real owner at this cheap stage — Phase 2's dev+ino +// fd check is the real authority; Phase 0/1 only exist to skip the overwhelming +// majority (kernel threads, shells, editors) cheaply. +// +// The whitelist is calibrated against what a real `gitnexus mcp`/`serve` server +// actually reports for comm. Observed on production hosts: the server renames +// its main thread, so comm reads `MainThread` (via @ladybugdb/core's +// worker_threads setup), NOT `node` — omitting it would blind the probe to +// every real server (#1492-class owner miss). We also keep the plausible +// launcher/runtime basenames in case a future build does not rename the thread. +// Conservative by design: over-collecting a few extra candidates only costs a +// bounded number of Phase 1 cmdline reads. +const COMM_CANDIDATES = ['node', 'gitnexus', 'bun', 'deno', 'npm', 'npx', 'MainThread']; +function commLooksLikeServer(comm) { + const c = comm.trim(); + if (!c) return false; + for (const name of COMM_CANDIDATES) { + if (name === c || name.startsWith(c) || c.startsWith(name)) return true; + } + return false; +} + +function readProcComm(procRoot, pidStr) { + try { + return fs + .readFileSync(path.join(procRoot, pidStr, 'comm'), 'utf8') + .replace(/\0+/g, '') + .trim(); + } catch { + return ''; + } +} + +// Timeout sentinel for readLinuxCmdline (F3). MUST be distinct from the +// "unreadable/empty" return value (''): '' flows through isGitNexusServerCommand +// as a NON-candidate (both regexes are false on ''), so the Phase 1 caller +// `continue`s past it — correct for a raced/openSync-failed pid, but a FAIL-OPEN +// bug if it ever meant "I ran out of budget mid-read" (a real owner whose +// escalation timed out would be silently dropped, racing the lbug -> #1492). A +// unique Symbol can never collide with any cmdline string, so the caller can +// branch on it explicitly and map a mid-read timeout to the tri-state 'timeout' +// (fail-CLOSED) instead of swallowing it as a non-candidate. +const CMDLINE_TIMEOUT = Symbol('gitnexus.cmdline.timeout'); + +// Bounded /proc//cmdline read for Phase 1. openSync+readSync (not +// readFileSync) so a D-state holder cannot stall the hook on a huge or +// never-EOF argv: we read in `cap`-sized chunks and stop as soon as the text +// holds both server tokens, at EOF, at PROC_CMDLINE_CEIL, or when the scan +// budget runs out (see below). cmdline separates argv with NULs; convert to +// spaces for isGitNexusServerCommand. +// +// Owner-miss guard for the 4 KB cap: the `gitnexus` token usually sits in the +// first path component while the `mcp`/`serve` mode token is the LAST argv, so +// a naive 4 KB read could clip the mode token off a server launched with a very +// long interpreter path and silently miss a real owner. We mitigate two ways: +// (a) the default cap (16 KiB) already clears realistic lines, so almost every +// process is decided by the first read hitting EOF; (b) a read that fills the +// cap stops early ONLY once it holds BOTH tokens (decided owner). Holding one +// token, or neither, decides nothing: interpreter flags can put a mode-looking +// word first (`node --require mcp .../gitnexus/... serve`) or push the gitnexus +// path past the first chunk. So we keep reading in cap-sized chunks until both +// tokens appear, the file ends, or the hard ceiling is reached. Only processes +// that passed the Phase 0 comm prefilter AND have a cmdline longer than the cap +// ever escalate, and each escalation step is budget-gated (below). +// +// Budget (F3): the escalation loop above is the one place a SINGLE pathological +// candidate could read up to HARD_CEIL (256 KiB) before the next scan-level +// budget check, weakening the timeout contract. `outOfBudget` (the scan's shared +// deadline callback) is checked once per escalation iteration; on expiry we +// return CMDLINE_TIMEOUT (NOT '') so the caller can fail-closed honestly rather +// than mistake the partial read for a non-candidate. Reads that simply can't +// open / error out still return '' (genuinely "not a readable candidate"). +function readLinuxCmdline(procRoot, pidStr, cap, outOfBudget) { + const file = path.join(procRoot, pidStr, 'cmdline'); + let fd; + try { + fd = fs.openSync(file, 'r'); + } catch { + return ''; + } + try { + const HARD_CEIL = PROC_CMDLINE_CEIL; // 256 KiB absolute ceiling for the escalation path + let collected = Buffer.alloc(0); + let offset = 0; + let chunkCap = cap; + for (;;) { + // allocUnsafe is safe here: readSync fills exactly [0, bytes), only + // buf.subarray(0, bytes) is consumed, and Buffer.concat deep-copies that + // slice into `collected`, so the uninitialized tail never reaches decode. + const buf = Buffer.allocUnsafe(chunkCap); + const bytes = fs.readSync(fd, buf, 0, chunkCap, offset); + if (bytes <= 0) break; + collected = Buffer.concat([collected, buf.subarray(0, bytes)]); + offset += bytes; + const text = collected.toString('utf8').replace(/\0+/g, ' '); + // Stop early only when the partial read is DECIDED: both the gitnexus + // token and a mode token are present (isGitNexusServerCommand is exactly + // that conjunction). A partial read missing either token is undecided — + // the missing one may lie past the chunk boundary — so it keeps reading. + if (isGitNexusServerCommand(text)) break; // decided (positive) + if (bytes < chunkCap) break; // EOF: full cmdline read, definitive + if (offset >= HARD_CEIL) break; // bounded escalation only + // Budget gate the escalation: a single huge-argv candidate must not burn + // the whole scan deadline before we re-check. Return the timeout sentinel + // (never '') so the caller fails closed instead of treating us as a + // non-candidate. The sole caller (linuxProcScanFindGitNexusServer) always + // passes outOfBudget, so no presence guard is needed. + if (outOfBudget()) return CMDLINE_TIMEOUT; + chunkCap = cap; // keep reading more in cap-sized chunks + } + return collected.toString('utf8').replace(/\0+/g, ' ').trim(); + } catch { + return ''; + } finally { + try { + fs.closeSync(fd); + } catch { + /* ignore */ + } + } +} + +function resolveLinuxProcBudgetMs() { + const raw = process.env.GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS; + // Gate on the STRING's emptiness, NOT the parsed number's truthiness — the + // old `Number(raw && trim()) ? ... : 1200` form treated "0" as falsy and + // silently fell back to 1200 (#2180). Use Number() (not parseInt) so "16e3" + // reads as 16000, not 16 (parseInt stops at 'e'). The `&& String(raw).trim()` + // guard is load-bearing: without it a set-but-empty/whitespace value would be + // `Number("")===0` => budget 0 => immediate fail-CLOSED timeout (augment + // permanently skipped). With it, ''/whitespace => NaN => 1200 default, while a + // finite "0" still parses to an explicit, deterministic "no budget" => + // immediate timeout. Non-numeric / unset => default 1200. + const n = raw != null && String(raw).trim() ? Number(String(raw).trim()) : NaN; + if (!Number.isFinite(n)) return 1200; + return n; // may be <= 0, meaning "out of budget on the first check" +} + +// Returns one of: 'owned' (a non-self process with a GitNexus-server cmdline +// holds the target lbug fd), 'not-owned' (scan completed, no such owner), or +// 'timeout' (the per-scan budget was exhausted before a verdict). The name is +// pinned by a source-contract test; only the return TYPE changed (#2180: +// boolean -> tri-state, so the dispatcher can fail-closed on 'timeout'). +function linuxProcScanFindGitNexusServer(dbPathAbs, myPid) { + const budget = resolveLinuxProcBudgetMs(); + // A non-positive budget is an explicit, deterministic "no time to scan" => + // immediate timeout (the #2180 test vector, and the only correct reading of + // the fixed parse: "0" must NOT mean 1200). Returning before any procfs read + // keeps it instantaneous regardless of host load. + if (budget <= 0) return 'timeout'; + const procRoot = getProcRoot(); + const cmdlineCap = getCmdlineMaxBytes(); + const start = Date.now(); + const outOfBudget = () => Date.now() - start > budget; + + let targetStat; + try { + targetStat = fs.statSync(dbPathAbs); + } catch { + // Caller already existsSync'd the path; a stat failure here is a transient + // race, treat as no owner (historical semantics). + return 'not-owned'; + } + + let procEntries; + try { + procEntries = fs.readdirSync(procRoot, { withFileTypes: true }); + } catch { + return 'not-owned'; + } + + // Phase 0 + Phase 1: collect the few PIDs whose comm AND cmdline look like a + // GitNexus server, without touching any fd yet. + const candidates = []; + for (const ent of procEntries) { + if (outOfBudget()) return 'timeout'; + if (!ent.isDirectory() || !/^\d+$/.test(ent.name)) continue; + const pid = Number.parseInt(ent.name, 10); + if (!Number.isFinite(pid) || pid === myPid) continue; + + // Phase 0: cheap comm prefilter. + const comm = readProcComm(procRoot, ent.name); + if (!comm) continue; // unreadable comm (kernel thread, raced exit) -> skip + if (!commLooksLikeServer(comm)) continue; + + // Phase 1: bounded cmdline read + isGitNexusServerCommand prefilter. + if (outOfBudget()) return 'timeout'; + const cmdline = readLinuxCmdline(procRoot, ent.name, cmdlineCap, outOfBudget); + // F3: a mid-read budget timeout returns the CMDLINE_TIMEOUT sentinel (a + // Symbol, never a string). Fail CLOSED on it rather than letting it fall + // through isGitNexusServerCommand as a non-candidate — a real owner whose + // escalation timed out must not be silently dropped (would fail-OPEN). + if (cmdline === CMDLINE_TIMEOUT) return 'timeout'; + if (!isGitNexusServerCommand(cmdline)) continue; + candidates.push(ent.name); + } + + // Phase 2: only now stat the fds of the (typically 0-2) survivors. + for (const pidStr of candidates) { + if (outOfBudget()) return 'timeout'; + const fdDir = path.join(procRoot, pidStr, 'fd'); + let fds; + try { + fds = fs.readdirSync(fdDir); + } catch (err) { + // F1: the old code returned 'owned' for EVERY non-ENOENT error. That was + // a correctness bug: /proc//fd is owner-only (mode 0500), so a + // cross-user/root `gitnexus mcp` serving a DIFFERENT repo passes Phase 0+1 + // (its cmdline matches) and then EACCES'es here — yet its dev+ino was + // NEVER compared against THIS lbug. Claiming 'owned' lets it permanently, + // silently suppress augment for a repo it does not actually lock. We now + // distinguish the failure shapes (all still fail-closed where we can't + // prove non-ownership, but 'timeout' is the HONEST verdict for + // "inconclusive", not the false-positive 'owned'): + const code = err && err.code; + if (code === 'ENOENT') { + // Process raced away between the candidate scan and now -> genuinely no + // longer an owner. Move on. + continue; + } + if (code === 'EACCES' || code === 'EPERM') { + // Permission-denied fd dir: cannot read fds, so ownership is + // UNVERIFIABLE. Fail closed honestly via 'timeout' (the dispatcher maps + // timeout -> true, same protective skip as before) WITHOUT lying that we + // confirmed ownership. Do NOT degrade to not-owned/fail-open: if this + // really is the owner, fail-open re-opens the #1492 lbug race; augment + // is optional context, so a conservative skip costs little. + debugLog( + `fd dir unreadable for candidate pid ${pidStr} (${code}); ownership ` + + `unverifiable, probe inconclusive -> fail-closed (timeout)`, + ); + return 'timeout'; + } + if (code === 'EIO' || code === 'ESTALE') { + // Genuine transient I/O against this candidate's fd dir — not evidence + // it does NOT hold the lbug. Treat as inconclusive and fail closed + // (timeout) rather than continue, so a real owner mid-I/O-blip is not + // dropped (would fail-open). + debugLog( + `fd dir transient I/O error for candidate pid ${pidStr} (${code}); ` + + `probe inconclusive -> fail-closed (timeout)`, + ); + return 'timeout'; + } + if (code === 'ENOTDIR') { + // The fd path is not a directory at all, so this is structurally not a + // real /proc//fd — not a plausible live owner. Move on. + debugLog( + `fd dir not a directory for candidate pid ${pidStr} (ENOTDIR); ` + + `treating candidate as non-owner -> continue`, + ); + continue; + } + // Any other error (EMFILE, ENFILE, ENOMEM, EINTR, no code, …) says nothing + // about whether this already-identified server candidate holds the lbug. + // Only ENOENT/ENOTDIR above establish non-ownership; everything else is + // inconclusive and fails closed via 'timeout', same as EACCES/EIO. + debugLog( + `fd dir read failed for candidate pid ${pidStr} ` + + `(${code || 'unknown'}); probe inconclusive -> fail-closed (timeout)`, + ); + return 'timeout'; + } + for (const fd of fds) { + if (outOfBudget()) return 'timeout'; + try { + const st = fs.statSync(path.join(fdDir, fd)); + if (st.dev === targetStat.dev && st.ino === targetStat.ino) { + return 'owned'; + } + } catch { + /* fd raced closed; ignore */ + } + } + } + + return 'not-owned'; +} + +function unixLsofPsFindGitNexusServer(dbPathAbs, myPid) { + const guard = resolveUnixGuardTimeout(); + // An explicit missing override models ENOENT and must fail open instead of + // falling through to a host binary with different process-table visibility. + if (hasMissingHookBinaryOverride('lsof')) return false; + const lsofPath = resolveHookBinary('lsof'); + // The spawnSync timeouts below (lsof 1000ms / ps 500ms) are deliberately + // SHORTER than the wrapper budgets (2s / 1s): on the supervised path Node's + // SIGTERM always fires first, so `error.code === 'ETIMEDOUT'` and the + // fail-closed contract are untouched. The wrapper only matters once this + // hook process has been SIGKILLed and can no longer deliver that SIGTERM. + const [lsofCmd, lsofArgs] = guard + ? [guard, ['-k', '1', '2', lsofPath, '-nP', '-t', '--', dbPathAbs]] + : [lsofPath, ['-nP', '-t', '--', dbPathAbs]]; + const lsof = spawnSync(lsofCmd, lsofArgs, { + encoding: 'utf-8', + timeout: 1000, + stdio: ['ignore', 'pipe', 'ignore'], + windowsHide: true, + }); + if (lsof.error) return lsof.error.code === 'ETIMEDOUT'; + // Guard-mediated deaths map to "unresponsive holder" (fail-closed). Three + // result shapes, verified against coreutils 9.1: + // - signal-death: when `-k` escalates to SIGKILL, coreutils timeout + // SELF-RAISES the signal, so spawnSync reports {status: null, signal} + // with no .error (spawnSync's own ETIMEDOUT was handled above). The + // same shape appears when this hook is frozen >2s (SIGSTOP, laptop + // suspend) and the guard expires while it sleeps. By construction, a + // guard-wrapped probe that died by signal without spawnSync ETIMEDOUT + // is a budget/kill outcome. + // - 124: budget expired and the child exited after the plain SIGTERM. + // - 137: NOT the coreutils -k path — only exit-code-propagating wrappers, + // or a child SIGKILLed externally (e.g. the OOM killer). + if (guard && lsof.status === null && lsof.signal) return true; + if (guard && (lsof.status === 124 || lsof.status === 137)) return true; + + const pids = (lsof.stdout || '').split(/\s+/).filter(Boolean); + const psMissing = hasMissingHookBinaryOverride('ps'); + const psPath = resolveHookBinary('ps'); + for (const pid of pids) { + if (Number(pid) === myPid) continue; + // Missing ps means we cannot verify that this pid is a GitNexus server. + if (psMissing) continue; + const [psCmd, psArgs] = guard + ? [guard, ['-k', '1', '1', psPath, '-p', pid, '-o', 'command=']] + : [psPath, ['-p', pid, '-o', 'command=']]; + const ps = spawnSync(psCmd, psArgs, { + encoding: 'utf-8', + timeout: 500, + stdio: ['ignore', 'pipe', 'ignore'], + windowsHide: true, + }); + if (ps.error) { + if (ps.error.code === 'ETIMEDOUT') return true; + continue; + } + // Same guard-mediated-death mapping as the lsof call above (signal-death + // from the -k escalation or a frozen hook; 124 budget expiry; 137 only + // for exit-code-propagating wrappers / external SIGKILL). + if (guard && ps.status === null && ps.signal) return true; + if (guard && (ps.status === 124 || ps.status === 137)) return true; + if (isGitNexusServerCommand(ps.stdout || '')) return true; + } + return false; +} + +/** + * @param {string} dbPath Absolute or relative path to the DB file (e.g. .../lbug). + * @param {number} myPid Current process PID (hook runner), excluded from matches. + */ +function hasGitNexusDbLockedByGitNexusServer(dbPath, myPid) { + if (!fs.existsSync(dbPath)) return false; + const dbPathAbs = path.resolve(dbPath); + + if (process.platform === 'win32') { + return hasGitNexusServerOwnerWindows(dbPathAbs, myPid); + } + + if (process.platform === 'linux') { + // #2180: cmdline-first procfs scan, no lsof. 'timeout' fails CLOSED + // (overloaded host self-throttles — the throttle the orphan-storm incident + // needed; the old lsof fallback ETIMEDOUT'd and failed closed on these same + // hosts anyway, only slower and with the orphan risk). 'not-owned' is the + // only false. See the fail matrix in the file header. + const verdict = linuxProcScanFindGitNexusServer(dbPathAbs, myPid); + return verdict !== 'not-owned'; + } + + return unixLsofPsFindGitNexusServer(dbPathAbs, myPid); +} + +module.exports = { + hasGitNexusDbLockedByGitNexusServer, + // Exported for white-box unit tests that must assert the tri-state verdict + // ('owned' | 'not-owned' | 'timeout') directly — the dispatcher collapses + // timeout and owned to the same boolean true, so the boolean API alone cannot + // distinguish the F1 EACCES->timeout fix from the old EACCES->owned bug. The + // Probe interface already declares this optional. Linux-only by contract; the + // name is pinned by a source-contract test. + linuxProcScanFindGitNexusServer, + // #2163 follow-up: the hook adapters wrap the augment CLI in the same + // guard. Returns a self-tested, always-ABSOLUTE wrapper path: the built-in + // candidates are absolute, and a GITNEXUS_HOOK_TIMEOUT_PATH override is + // path.resolve()d against this process's cwd before its existsSync check + // and self-test, so the adapters can spawn it under any `cwd` option. + // Returns null when the wrapper is disabled/unavailable. Never call on + // win32 (see its JSDoc). + resolveUnixGuardTimeout, + // Exported for white-box unit tests of the numeric-env parsing (#2183 review): + // Number()-not-parseInt so "16e3" reads as 16000, plus the empty/whitespace + // guard that keeps a set-but-empty budget on the 1200 default instead of an + // immediate fail-closed timeout. Tested directly because the values are + // otherwise only observable indirectly through scan timing/escalation. + getCmdlineMaxBytes, + resolveLinuxProcBudgetMs, + // Exported for white-box tests pinning that the override check and the + // override lookup agree on whitespace-padded GITNEXUS_HOOK_{LSOF,PS}_PATH. + resolveHookBinary, + hasMissingHookBinaryOverride, +}; diff --git a/gitnexus-factory-plugin/hooks/hook-lock.js b/gitnexus-factory-plugin/hooks/hook-lock.js new file mode 100644 index 000000000..05ee20ace --- /dev/null +++ b/gitnexus-factory-plugin/hooks/hook-lock.js @@ -0,0 +1,243 @@ +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); + +const HOOK_LOCK_SUBDIR = '.hook-locks'; +const HOOK_LOCK_MAX_INFLIGHT = 3; +const HOOK_LOCK_STALE_MS = 30000; + +// An evictor's claim marker older than this belongs to a crashed evictor. +// The critical section it guards is a few syscalls (token read, lstat, +// unlink), so any live evictor finishes orders of magnitude sooner; kept well +// under HOOK_LOCK_STALE_MS so an orphan never blocks a slot for long. +const HOOK_LOCK_EVICT_MARKER_STALE_MS = 5000; + +// Same file iff inode identity AND content metadata match. dev+ino alone is +// not enough: filesystems reuse a freed inode number immediately (ext4), so a +// file recreated after an unlink can carry the old file's ino. bigint stats +// keep Windows' 64-bit file ids exact. +function sameSlotFile(a, b) { + return a.dev === b.dev && a.ino === b.ino && a.size === b.size && a.mtimeNs === b.mtimeNs; +} + +function readMarkerToken(marker) { + try { + return fs.readFileSync(marker, 'utf-8'); + } catch { + return null; + } +} + +// Stat and token of a marker, both taken from one open descriptor so they +// describe the same file (a path stat followed by a path read could straddle +// a replacement). O_NOFOLLOW where the platform has it: a marker is always a +// regular file this module created. Returns null when there is no marker. +function readMarkerSnapshot(marker) { + let fd; + try { + fd = fs.openSync(marker, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0)); + return { stat: fs.fstatSync(fd, { bigint: true }), token: fs.readFileSync(fd, 'utf-8') }; + } catch { + return null; + } finally { + if (fd !== undefined) { + try { + fs.closeSync(fd); + } catch { + /* already closed */ + } + } + } +} + +// Break an evictor's claim marker only if it is an orphan: older than +// HOOK_LOCK_EVICT_MARKER_STALE_MS, and still the exact file (identity and +// owner token) judged old when it is re-checked just before the unlink. A +// marker released and re-created by a new claimant in between is fresh, so +// it fails the check and stays. +function breakOrphanedMarker(marker) { + const seen = readMarkerSnapshot(marker); + if (!seen || Date.now() - Number(seen.stat.mtimeMs) <= HOOK_LOCK_EVICT_MARKER_STALE_MS) return; + const now = readMarkerSnapshot(marker); + if (!now || !sameSlotFile(now.stat, seen.stat) || now.token !== seen.token) return; + try { + fs.unlinkSync(marker); + } catch { + /* another contender already cleared it */ + } +} + +// Evict a slot judged stale from the `inspected` stat. A slot file is only +// ever deleted, never moved, and only by the evictor holding the per-slot +// `.evicting` marker, created O_EXCL with a token unique to this call. +// Every destructive step verifies first: +// - the slot is unlinked only if the marker still carries our token (an +// evictor stalled long enough for its marker to be broken as an orphan has +// lost its claim and backs off) and the slot is still the exact file +// inspected — identical dev/ino/size/mtimeNs means its content and age are +// unchanged, so the stale verdict still holds, while a slot recreated since +// inspection fails the check and its lock stands; +// - our marker is removed only if it still carries our token, so a marker +// that has passed to another claimant is left alone; +// - an orphaned marker is broken only if it is still the old file it was +// judged to be (see breakOrphanedMarker). +// +// Residual windows. POSIX has no conditional unlink, so each check-then- +// unlink pair keeps a gap of two adjacent syscalls: +// (a) Slot: between the lstat identity check and unlinkSync(slot), a live +// owner past HOOK_LOCK_STALE_MS could release and a new hook recreate the +// slot, whose fresh lock would then be deleted. The consequence is at +// most one extra concurrent augment beyond HOOK_LOCK_MAX_INFLIGHT for +// that run — the cap is a load guard, and no data or index state +// depends on it. The victim's release() sees a foreign or missing file +// and leaves it alone. +// (b) Marker: between the token re-read and unlinkSync(marker) (ours or an +// orphan's), the marker could pass to another claimant, whose claim would +// then be removed. That only re-opens the slot to one more evictor, which +// still has to pass the slot identity check before deleting anything. +// Both need a stall of seconds landing on that exact syscall pair, and the +// only thing lost is one run's cap accounting, so they are accepted rather +// than traded for heavier machinery. A crash at any point orphans at most the +// marker, which the next contender breaks after it expires. +function evictStaleSlot(slotPath, inspected) { + const marker = `${slotPath}.evicting`; + breakOrphanedMarker(marker); + const token = `${process.pid}:${crypto.randomBytes(8).toString('hex')}`; + try { + fs.writeFileSync(marker, token, { flag: 'wx' }); + } catch { + return; // Another evictor holds this slot — leave it to that evictor. + } + try { + if ( + readMarkerToken(marker) === token && + sameSlotFile(fs.lstatSync(slotPath, { bigint: true }), inspected) + ) { + fs.unlinkSync(slotPath); + } + } catch { + /* slot already gone — the retry claims it */ + } finally { + if (readMarkerToken(marker) === token) { + try { + fs.unlinkSync(marker); + } catch { + /* already gone */ + } + } + } +} + +function acquireHookSlot(gitNexusDir) { + const lockDir = path.join(gitNexusDir, HOOK_LOCK_SUBDIR); + try { + fs.mkdirSync(lockDir, { recursive: true }); + } catch { + // Cannot create lock dir (read-only fs, cross-user perm denial, out of + // inodes, etc.) — fail closed by returning null. Caller skips augment. + // Fail-open here would let N concurrent hooks all proceed unguarded and + // reintroduce the #1486 fan-out the guard exists to prevent. + return null; + } + + const myPidStr = String(process.pid); + + for (let slot = 0; slot < HOOK_LOCK_MAX_INFLIGHT; slot++) { + const slotPath = path.join(lockDir, `slot-${slot}.lock`); + for (let attempt = 0; attempt < 2; attempt++) { + try { + fs.writeFileSync(slotPath, myPidStr, { flag: 'wx' }); + let released = false; + const release = () => { + if (released) return; + released = true; + process.removeListener('exit', release); + try { + // Only unlink if we still own the slot. If we appeared stale and + // another hook took over, the file now belongs to it — leave alone. + const content = fs.readFileSync(slotPath, 'utf-8').trim(); + if (content === myPidStr) fs.unlinkSync(slotPath); + } catch { + /* already removed or unreadable */ + } + }; + process.on('exit', release); + return release; + } catch { + // Slot exists. Decide whether to take it over. + // Open once and inspect mtime + content via the same fd so there's + // no TOCTOU between the metadata check and the content read + // (codeql js/file-system-race). + let fd; + try { + fd = fs.openSync(slotPath, 'r'); + } catch { + continue; // Vanished between EEXIST and open — retry this slot. + } + let isLive = false; + let mtimeMs = Date.now(); + let inspected = null; + try { + inspected = fs.fstatSync(fd, { bigint: true }); + mtimeMs = Number(inspected.mtimeMs); + const buf = Buffer.alloc(32); + const n = fs.readSync(fd, buf, 0, 32, 0); + const ownerStr = buf.slice(0, n).toString('utf-8').trim(); + if (ownerStr === '') { + // Owner created the file but hasn't written its PID yet. The + // wx open+write window is microseconds; give it the benefit + // of the doubt and treat as live. + isLive = true; + } else { + const owner = Number.parseInt(ownerStr, 10); + if (Number.isFinite(owner) && owner > 0) { + try { + process.kill(owner, 0); + isLive = true; + } catch (e) { + // ESRCH = process gone → treat as dead. EPERM = process exists + // but owned by another user (cross-user lock dir) → still alive, + // keep the slot. Anything else: be conservative, assume alive. + if (e && e.code === 'ESRCH') { + isLive = false; + } else { + isLive = true; + } + } + } + } + } catch { + /* unreadable — treat as dead */ + } finally { + try { + fs.closeSync(fd); + } catch { + /* already closed */ + } + } + // For slots younger than HOOK_LOCK_STALE_MS, PID-liveness wins — + // a slow-but-alive hook is never wrongly evicted. For older slots, + // age is the final arbiter as a defense against PID reuse on long- + // abandoned slots. 30s >> the 7s augment timeout, so a healthy run + // never crosses this threshold. + if (isLive && Date.now() - mtimeMs > HOOK_LOCK_STALE_MS) { + isLive = false; + } + if (isLive) break; // Try the next slot. + // No stat means we cannot prove which file we judged stale; leave it + // (the retry re-inspects it) rather than risk deleting a fresh lock. + if (inspected) evictStaleSlot(slotPath, inspected); + // Loop and retry this slot. + } + } + } + + return null; +} + +module.exports = { + HOOK_LOCK_SUBDIR, + HOOK_LOCK_MAX_INFLIGHT, + HOOK_LOCK_STALE_MS, + acquireHookSlot, +}; diff --git a/gitnexus-factory-plugin/hooks/hooks.json b/gitnexus-factory-plugin/hooks/hooks.json new file mode 100644 index 000000000..778a7e747 --- /dev/null +++ b/gitnexus-factory-plugin/hooks/hooks.json @@ -0,0 +1,14 @@ +{ + "PostToolUse": [ + { + "matcher": "Grep|Glob|Execute", + "hooks": [ + { + "type": "command", + "command": "node \"${DROID_PLUGIN_ROOT}/hooks/gitnexus-hook.js\"", + "timeout": 10 + } + ] + } + ] +} diff --git a/gitnexus-factory-plugin/hooks/registry-query.cjs b/gitnexus-factory-plugin/hooks/registry-query.cjs new file mode 100644 index 000000000..52abc3474 --- /dev/null +++ b/gitnexus-factory-plugin/hooks/registry-query.cjs @@ -0,0 +1,442 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { createHash } = require('crypto'); +const { spawnSync } = require('child_process'); + +// Hooks are copied into editor-specific directories and run without the +// package's TypeScript modules. Keep their on-disk names centralized here. +const GITNEXUS_DIR = '.gitnexus'; +const INDEX_METADATA_FILE = 'gitnexus.json'; +const LEGACY_METADATA_FILE = 'meta.json'; +const LBUG_DIRECTORY = 'lbug'; +const BRANCHES_DIRECTORY = 'branches'; +const STORAGE_PATH_ENV = 'GITNEXUS_STORAGE_PATH'; +const STORAGE_ROOT_ENV = 'GITNEXUS_STORAGE_ROOT'; +const STORAGE_SLOT_HASH_LENGTH = 12; +const LOCAL_OWNED_PARENT_HOPS = 5; + +function stripWindowsLongPathPrefix(p) { + if (process.platform !== 'win32') return p; + if (/^\\\\\?\\UNC\\(?=[^\\])/i.test(p)) return `\\\\${p.slice(8)}`; + if (/^\\\\\?\\[A-Za-z]:\\/.test(p)) return p.slice(4); + return p; +} + +function canonicalize(value) { + if (typeof value !== 'string' || !value || value.includes('\0') || !path.isAbsolute(value)) + return null; + const resolved = path.resolve(value); + try { + return stripWindowsLongPathPrefix(fs.realpathSync.native(resolved)); + } catch { + return stripWindowsLongPathPrefix(resolved); + } +} + +function samePath(left, right) { + if (left == null || right == null) return false; + return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right; +} + +function isMissingFile(error) { + return error && (error.code === 'ENOENT' || error.code === 'ENOTDIR'); +} + +function readMetadataFile(storagePath, filename) { + try { + const value = JSON.parse(fs.readFileSync(path.join(storagePath, filename), 'utf-8')); + return value && typeof value === 'object' && !Array.isArray(value) + ? { state: 'valid', value } + : { state: 'invalid' }; + } catch (error) { + return isMissingFile(error) ? { state: 'absent' } : { state: 'invalid' }; + } +} + +function readIndexMetadata(storagePath) { + const primary = readMetadataFile(storagePath, INDEX_METADATA_FILE); + if (primary.state === 'valid') return primary.value; + if (primary.state !== 'absent') return null; + + const legacy = readMetadataFile(storagePath, LEGACY_METADATA_FILE); + return legacy.state === 'valid' ? legacy.value : null; +} + +function isOwnedStorage(repoPath, storagePath, repositoryLocal, metadata) { + // Repository-local storage remains usable for metadata written before + // repoPath was recorded, but an explicit repoPath must never name another + // checkout. External storage always requires the complete ownership binding. + if (repositoryLocal && (!metadata || typeof metadata.repoPath !== 'string')) { + return true; + } + if (!metadata || typeof metadata.repoPath !== 'string') return false; + + const metadataRepoPath = canonicalize(metadata.repoPath); + const expectedRepoPath = canonicalize(repoPath); + if ( + metadataRepoPath == null || + expectedRepoPath == null || + !samePath(metadataRepoPath, expectedRepoPath) + ) { + return false; + } + if (repositoryLocal) return true; + if (typeof metadata.storagePath !== 'string') return false; + + const metadataStoragePath = canonicalize(metadata.storagePath); + const expectedStoragePath = canonicalize(storagePath); + return ( + metadataStoragePath != null && + expectedStoragePath != null && + samePath(metadataStoragePath, expectedStoragePath) + ); +} + +function ancestorPaths(cwd) { + const paths = []; + let current = canonicalize(cwd); + while (current) { + paths.push(current); + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + return paths; +} + +function isInsideOrEqual(child, ancestor) { + if (child == null || ancestor == null) return false; + if (samePath(child, ancestor)) return true; + const relative = path.relative(ancestor, child); + return ( + relative !== '' && + relative !== '..' && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative) + ); +} + +function ancestorPathsThrough(cwd, stopAt) { + const paths = []; + let current = canonicalize(cwd); + const stop = canonicalize(stopAt); + while (current) { + if (stop && !isInsideOrEqual(current, stop)) break; + paths.push(current); + if (stop && samePath(current, stop)) break; + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + return paths; +} + +function currentGitBranch(cwd) { + try { + const result = spawnSync('git', ['symbolic-ref', '--quiet', '--short', 'HEAD'], { + encoding: 'utf-8', + timeout: 2000, + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }); + if (result.error || result.status !== 0) return null; + const branch = String(result.stdout || '').trim(); + return branch || null; + } catch { + return null; + } +} + +function registryPathsForCwd(cwd) { + const fallbackPaths = ancestorPaths(cwd); + if (fallbackPaths.length === 0) return { repoPaths: [], branch: null }; + try { + const result = spawnSync( + 'git', + ['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'], + { + encoding: 'utf-8', + timeout: 2000, + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }, + ); + if (result.error || result.status !== 0) return { repoPaths: fallbackPaths, branch: null }; + + const [worktreeRoot, commonDir] = String(result.stdout || '') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + if (!worktreeRoot || !path.isAbsolute(worktreeRoot)) { + return { repoPaths: fallbackPaths, branch: null }; + } + + // Keep ancestor paths of cwd that stay inside this worktree (cwd up to + // and including show-toplevel) so a --skip-git subdirectory index can + // win via longest-match. Do not walk ancestors outside the worktree — + // that would re-attribute a parent index to a nested git checkout. + const repoPaths = ancestorPathsThrough(cwd, worktreeRoot); + const worktreeCanon = canonicalize(worktreeRoot); + if (worktreeCanon && !repoPaths.some((repoPath) => samePath(repoPath, worktreeCanon))) { + repoPaths.push(worktreeCanon); + } + + // Linked worktrees share the canonical repo's git dir. Include that + // parent so the registered main checkout is still discoverable, but do + // not walk any further outside this worktree. + if (commonDir) { + const commonParent = canonicalize(path.dirname(commonDir)); + if ( + commonParent && + worktreeCanon && + !samePath(commonParent, worktreeCanon) && + !repoPaths.some((repoPath) => samePath(repoPath, commonParent)) + ) { + repoPaths.push(commonParent); + } + } + return { + repoPaths, + branch: currentGitBranch(cwd), + }; + } catch { + return { repoPaths: fallbackPaths, branch: null }; + } +} + +function branchSlug(rawRef) { + const sanitized = rawRef.replace(/^-+/, '').replace(/[^a-zA-Z0-9._-]/g, '_'); + const reserved = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\..*)?$/i; + const safe = + !sanitized || sanitized === '.' || sanitized === '..' || reserved.test(sanitized) + ? 'unknown' + : sanitized; + const hash = createHash('sha256').update(rawRef).digest('hex').slice(0, 8); + return `${safe}-${hash}`; +} + +// Mirror gitnexus/src/storage/storage-slot.ts slotNameForCanonicalPath exactly +// (sanitize + sha256 of the canonical repo path, 12-hex suffix). +function sanitizeSlotBasename(value) { + // Cap first, then walk the tail once — same order as + // gitnexus/src/storage/storage-slot.ts (avoids /[. ]+$/ ReDoS). + const sanitized = value.replace(/[\u0000-\u001f<>:"/\\|?*]/g, '-').slice(0, 80); + let end = sanitized.length; + while (end > 0) { + const code = sanitized.charCodeAt(end - 1); + if (code !== 0x20 && code !== 0x2e) break; + end--; + } + const candidate = sanitized.slice(0, end) || 'repository'; + // Windows also reserves device names with an extension (`CON.txt`); same + // platform branch as gitnexus/src/storage/storage-slot.ts. + const reserved = + process.platform === 'win32' + ? /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i + : /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i; + return reserved.test(candidate) ? `repository-${candidate}` : candidate; +} + +function storageSlotName(repoPath) { + const canonical = canonicalize(repoPath); + if (!canonical) return null; + const identity = process.platform === 'win32' ? canonical.toLowerCase() : canonical; + const basename = sanitizeSlotBasename(path.basename(canonical)); + const digest = createHash('sha256') + .update(identity) + .digest('hex') + .slice(0, STORAGE_SLOT_HASH_LENGTH); + return `${basename}-${digest}`; +} + +// Definedness matches the CLI's storage-resolver.ts: any value other than +// undefined (including '') counts as configured. +function envOverridesStorage() { + return process.env[STORAGE_PATH_ENV] !== undefined || process.env[STORAGE_ROOT_ENV] !== undefined; +} + +// A set-but-invalid override (empty, relative, or containing NUL) makes +// storage unresolvable (the CLI's storage-resolver.ts throws); never fall +// back to the registry row. A filesystem root is invalid only for +// GITNEXUS_STORAGE_PATH (validateConfiguredStoragePath rejects it); +// GITNEXUS_STORAGE_ROOT accepts a filesystem root — storagePathFromRoot +// resolves the slot directly under it. +function resolveEntryStoragePath(entry) { + const envPath = process.env[STORAGE_PATH_ENV]; + if (envPath !== undefined) { + if (!envPath || envPath.includes('\0') || !path.isAbsolute(envPath)) return null; + const resolved = path.resolve(envPath); + // validateConfiguredStoragePath rejects a filesystem root. + return path.basename(resolved) ? resolved : null; + } + + const envRoot = process.env[STORAGE_ROOT_ENV]; + if (envRoot !== undefined) { + if (!envRoot || envRoot.includes('\0') || !path.isAbsolute(envRoot)) return null; + const root = path.resolve(envRoot); + const slot = storageSlotName(entry.path); + if (!slot) return null; + const storagePath = path.join(root, slot); + return samePath(path.dirname(storagePath), root) ? storagePath : null; + } + + if (entry.storagePath !== undefined) { + if ( + typeof entry.storagePath !== 'string' || + !entry.storagePath || + entry.storagePath.includes('\0') || + !path.isAbsolute(entry.storagePath) + ) { + return null; + } + return path.resolve(entry.storagePath); + } + return path.resolve(path.join(entry.path, GITNEXUS_DIR)); +} + +// A single path segment: `..repo-` is a legal slot name, `..` is not. +function isDirectChild(parent, child) { + const rel = path.relative(parent, child); + return rel !== '' && rel !== '..' && !path.isAbsolute(rel) && !rel.includes(path.sep); +} + +// Mirror gitnexus/src/storage/shared-store.ts resolveGraphPath (#3352): a +// shared-store checkout slot may read a commit graph in the same store +// instead of owning /lbug. Any other recorded value is ignored. +function resolveGraphPath(storagePath, metadata) { + const own = path.join(storagePath, LBUG_DIRECTORY); + const storesRoot = path.resolve( + process.env.GITNEXUS_HOME || path.join(os.homedir(), '.gitnexus'), + 'stores', + ); + const slot = path.resolve(storagePath); + const checkoutsDir = path.dirname(slot); + const root = path.dirname(checkoutsDir); + if (path.basename(checkoutsDir) !== 'checkouts') return own; + if (!isDirectChild(checkoutsDir, slot) || !isDirectChild(storesRoot, root)) return own; + const recorded = metadata && metadata.graphPath; + if (typeof recorded !== 'string' || !path.isAbsolute(recorded)) return own; + const graph = path.resolve(recorded); + // Only a published `-` dir, never `.publish-*` staging. + const valid = + path.basename(graph) === LBUG_DIRECTORY && + isDirectChild(path.join(root, 'commits'), path.dirname(graph)) && + /^[0-9a-f]{7,64}-[0-9a-f]{8,64}$/.test(path.basename(path.dirname(graph))); + return valid ? graph : own; +} + +function hasLocalIndexSignal(storagePath) { + try { + return ( + fs.existsSync(path.join(storagePath, INDEX_METADATA_FILE)) || + fs.existsSync(path.join(storagePath, LBUG_DIRECTORY)) + ); + } catch { + return false; + } +} + +function findLocalOwnedRepo(cwd) { + // Environment storage overrides win; a leftover repo-local .gitnexus must + // not skip the registry scan that applies STORAGE_PATH / STORAGE_ROOT. + if (envOverridesStorage()) return null; + const { repoPaths, branch } = registryPathsForCwd(cwd); + let current = canonicalize(cwd); + for (let hops = 0; hops <= LOCAL_OWNED_PARENT_HOPS && current; hops++) { + const storagePath = path.join(current, GITNEXUS_DIR); + if (hasLocalIndexSignal(storagePath)) { + const metadata = readIndexMetadata(storagePath); + if (isOwnedStorage(current, storagePath, true, metadata)) { + const branchDir = + branch != null ? path.join(storagePath, BRANCHES_DIRECTORY, branchSlug(branch)) : null; + const indexDir = branchDir && hasLocalIndexSignal(branchDir) ? branchDir : storagePath; + return { + path: current, + storagePath, + lbugPath: path.join(indexDir, LBUG_DIRECTORY), + metadata: indexDir === storagePath ? metadata : readIndexMetadata(indexDir), + }; + } + } + const parent = path.dirname(current); + if (parent === current) break; + // Stay inside this checkout. Registered lookup already stops at + // `--show-toplevel`; walking raw parents would adopt `/outer/.gitnexus` + // from `/outer/nested-repo`. + if (repoPaths.length > 0 && !repoPaths.some((repoPath) => samePath(repoPath, parent))) { + break; + } + current = parent; + } + return null; +} + +function findRegisteredRepo(cwd) { + const { repoPaths, branch } = registryPathsForCwd(cwd); + if (repoPaths.length === 0) return null; + + const home = process.env.GITNEXUS_HOME || path.join(os.homedir(), '.gitnexus'); + let entries; + try { + entries = JSON.parse(fs.readFileSync(path.join(home, 'registry.json'), 'utf-8')); + } catch { + return null; + } + if (!Array.isArray(entries)) return null; + + let best = null; + let bestLen = -1; + for (const entry of entries) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue; + if (typeof entry.path !== 'string') continue; + if (entry.path.includes('\0') || !path.isAbsolute(entry.path)) continue; + const registeredPath = canonicalize(entry.path); + if (!registeredPath || !repoPaths.some((repoPath) => samePath(repoPath, registeredPath))) { + continue; + } + const storagePath = resolveEntryStoragePath(entry); + if (!storagePath) continue; + const repositoryLocal = samePath( + canonicalize(path.join(entry.path, GITNEXUS_DIR)), + canonicalize(storagePath), + ); + const ownershipMetadata = readIndexMetadata(storagePath); + if (!isOwnedStorage(entry.path, storagePath, repositoryLocal, ownershipMetadata)) continue; + const branchIsIndexed = + branch && + Array.isArray(entry.branches) && + entry.branches.some((summary) => summary && summary.branch === branch); + const indexDir = branchIsIndexed + ? path.join(storagePath, BRANCHES_DIRECTORY, branchSlug(branch)) + : storagePath; + if (registeredPath.length > bestLen) { + bestLen = registeredPath.length; + best = { + path: entry.path, + storagePath, + lbugPath: branchIsIndexed + ? path.join(indexDir, LBUG_DIRECTORY) + : resolveGraphPath(storagePath, ownershipMetadata), + metadata: branchIsIndexed ? readIndexMetadata(indexDir) : ownershipMetadata, + }; + } + } + return best; +} + +/** Registry row wins (including persisted external storagePath); local owned is fallback. */ +function resolveHookRepo(cwd) { + return findRegisteredRepo(cwd) || findLocalOwnedRepo(cwd); +} + +module.exports = { + findRegisteredRepo, + findLocalOwnedRepo, + resolveHookRepo, + INDEX_METADATA_FILE, + LEGACY_METADATA_FILE, + LBUG_DIRECTORY, +}; diff --git a/gitnexus-factory-plugin/hooks/win-rm-list-json.ps1 b/gitnexus-factory-plugin/hooks/win-rm-list-json.ps1 new file mode 100644 index 000000000..5c1564e30 --- /dev/null +++ b/gitnexus-factory-plugin/hooks/win-rm-list-json.ps1 @@ -0,0 +1,76 @@ +$ErrorActionPreference = 'Stop' +$target = $env:GITNEXUS_HOOK_RM_TARGET +if ([string]::IsNullOrWhiteSpace($target)) { Write-Output '[]'; exit 0 } +$target = (Resolve-Path -LiteralPath $target).ProviderPath + +if (-not ([Management.Automation.PSTypeName]'GitNexusHookRm.Native').Type) { +Add-Type @' +using System; +using System.Runtime.InteropServices; +namespace GitNexusHookRm { + public static class Native { + public const int ErrorMoreData = 234; + [StructLayout(LayoutKind.Sequential, Pack = 4)] + public struct RM_UNIQUE_PROCESS { + public int dwProcessId; + public long ProcessStartTime; + } + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public struct RM_PROCESS_INFO { + public RM_UNIQUE_PROCESS Process; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)] + public string strAppName; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)] + public string strServiceShortName; + public uint ApplicationType; + public uint AppStatus; + public uint TSSessionId; + public uint bRestartable; + } + [DllImport("rstrtmgr.dll", CharSet = CharSet.Unicode)] + public static extern int RmStartSession(out uint pSessionHandle, uint dwSessionFlags, string strSessionKey); + [DllImport("rstrtmgr.dll", CharSet = CharSet.Unicode)] + public static extern int RmRegisterResources(uint pSessionHandle, uint nFiles, string[] rgsFileNames, uint nApplications, IntPtr rgApplications, uint nServices, string[] rgsServiceNames); + [DllImport("rstrtmgr.dll")] + public static extern int RmGetList(uint dwSessionHandle, out uint pnProcInfoNeeded, ref uint pnProcInfo, [In, Out] RM_PROCESS_INFO[] rgAffectedApps, ref uint lpdwRebootReasons); + [DllImport("rstrtmgr.dll")] + public static extern int RmEndSession(uint pSessionHandle); + } +} +'@ +} + +$h = [uint32]0 +$key = [guid]::NewGuid().ToString('N') +$rmErr = [GitNexusHookRm.Native]::RmStartSession([ref]$h, 0, $key) +if ($rmErr -ne 0) { Write-Output '[]'; exit 0 } +$files = @($target) +$err = [GitNexusHookRm.Native]::RmRegisterResources($h, 1, $files, 0, [IntPtr]::Zero, 0, $null) +if ($err -ne 0) { + [void][GitNexusHookRm.Native]::RmEndSession($h) + Write-Output '[]' + exit 0 +} +$need = [uint32]0 +$n = [uint32]0 +$reboot = [uint32]0 +$err = [GitNexusHookRm.Native]::RmGetList($h, [ref]$need, [ref]$n, $null, [ref]$reboot) +if ($err -ne [GitNexusHookRm.Native]::ErrorMoreData) { + [void][GitNexusHookRm.Native]::RmEndSession($h) + Write-Output '[]' + exit 0 +} +$n = $need +$buf = New-Object GitNexusHookRm.Native+RM_PROCESS_INFO[] ([int]$n) +$err = [GitNexusHookRm.Native]::RmGetList($h, [ref]$need, [ref]$n, $buf, [ref]$reboot) +[void][GitNexusHookRm.Native]::RmEndSession($h) +if ($err -ne 0) { Write-Output '[]'; exit 0 } + +$out = @() +for ($i = 0; $i -lt [int]$n; $i++) { + $procId = $buf[$i].Process.dwProcessId + $p = Get-CimInstance -ClassName Win32_Process -Filter "ProcessId=$procId" -ErrorAction SilentlyContinue + $cmd = if ($p) { $p.CommandLine } else { '' } + $out += [PSCustomObject]@{ pid = [int]$procId; cmd = $cmd } +} +ConvertTo-Json -InputObject @($out) -Compress diff --git a/gitnexus-factory-plugin/mcp.json b/gitnexus-factory-plugin/mcp.json new file mode 100644 index 000000000..afe364b82 --- /dev/null +++ b/gitnexus-factory-plugin/mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "gitnexus": { + "command": "npx", + "args": ["-y", "gitnexus@1.6.12", "mcp"] + } + } +} diff --git a/gitnexus-shared/src/index.ts b/gitnexus-shared/src/index.ts index 7958cfa9d..b5fbe37a5 100644 --- a/gitnexus-shared/src/index.ts +++ b/gitnexus-shared/src/index.ts @@ -22,6 +22,7 @@ export { getLanguageFromFilename, getSyntaxLanguageFromFilename, isBladeTemplateFilename, + isNotebookFilename, } from './language-detection.js'; export type { MroStrategy } from './mro-strategy.js'; @@ -98,6 +99,7 @@ export type { ResolveTypeRefContext } from './scope-resolution/resolve-type-ref. // ScopeExtractor output contracts (RFC §3.2 Phase 1; Ring 2 PKG #919) export type { ParsedFile } from './scope-resolution/parsed-file.js'; +export type { CallResultAssignmentSite } from './scope-resolution/call-result-assignment-site.js'; export type { ReferenceSite, ReferenceKind, diff --git a/gitnexus-shared/src/language-detection.ts b/gitnexus-shared/src/language-detection.ts index 3fe27f7b7..583a0fd5f 100644 --- a/gitnexus-shared/src/language-detection.ts +++ b/gitnexus-shared/src/language-detection.ts @@ -29,7 +29,7 @@ const RUBY_EXTENSIONLESS_FILES = new Set([ const EXTENSION_MAP: Record = { [SupportedLanguages.JavaScript]: ['.js', '.jsx', '.mjs', '.cjs'], [SupportedLanguages.TypeScript]: ['.ts', '.tsx', '.mts', '.cts'], - [SupportedLanguages.Python]: ['.py'], + [SupportedLanguages.Python]: ['.py', '.ipynb'], [SupportedLanguages.Java]: ['.java'], [SupportedLanguages.C]: ['.c'], [SupportedLanguages.ObjectiveC]: ['.m', '.mm'], @@ -76,6 +76,10 @@ for (const [lang, exts] of Object.entries(EXTENSION_MAP) as [ export const isBladeTemplateFilename = (filePath: string): boolean => filePath.replace(/\\/g, '/').toLowerCase().endsWith('.blade.php'); +/** Jupyter notebooks: ingested as Python; on-disk bytes are JSON. */ +export const isNotebookFilename = (filePath: string): boolean => + filePath.replace(/\\/g, '/').toLowerCase().endsWith('.ipynb'); + /** * Map file extension to SupportedLanguage enum. * Returns null if the file extension is not recognized. @@ -163,6 +167,7 @@ const AUXILIARY_BASENAME_MAP: Record = { */ export const getSyntaxLanguageFromFilename = (filePath: string): string => { if (isBladeTemplateFilename(filePath)) return 'markup'; + if (isNotebookFilename(filePath)) return 'json'; const lang = getLanguageFromFilename(filePath); if (lang) return SYNTAX_MAP[lang]; diff --git a/gitnexus-shared/src/scope-resolution/call-result-assignment-site.ts b/gitnexus-shared/src/scope-resolution/call-result-assignment-site.ts new file mode 100644 index 000000000..1b9347101 --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/call-result-assignment-site.ts @@ -0,0 +1,14 @@ +import type { Range, ScopeId } from './types.js'; + +/** + * Compact extraction-time identity for `lhs = callee()`. + * + * The call-site range uses the same call-expression anchor as reference + * resolution, allowing downstream passes to join this fact to the exact + * resolved callee id without relying on a possibly polluted type binding. + */ +export interface CallResultAssignmentSite { + readonly callSite: Range; + readonly inScope: ScopeId; + readonly lhs: string; +} diff --git a/gitnexus-shared/src/scope-resolution/parsed-file.ts b/gitnexus-shared/src/scope-resolution/parsed-file.ts index db0833967..140193afc 100644 --- a/gitnexus-shared/src/scope-resolution/parsed-file.ts +++ b/gitnexus-shared/src/scope-resolution/parsed-file.ts @@ -56,6 +56,7 @@ import type { ParsedImport } from './types.js'; import type { SymbolDefinition } from './symbol-definition.js'; import type { ReferenceSite } from './reference-site.js'; import type { CallableFlowSite } from './callable-flow-site.js'; +import type { CallResultAssignmentSite } from './call-result-assignment-site.js'; export interface ParsedFile { readonly filePath: string; @@ -81,6 +82,8 @@ export interface ParsedFile { * syntax remain source-compatible; consumers normalize absence to `[]`. */ readonly callableFlowSites?: readonly CallableFlowSite[]; + /** Exact call-expression → assigned local identity for return-type replay. */ + readonly callResultAssignmentSites?: readonly CallResultAssignmentSite[]; /** * Opaque, language-private serialization of capture-time side-channel * state that a provider's `emitScopeCaptures` populates into module-level diff --git a/gitnexus-shared/src/scope-resolution/types.ts b/gitnexus-shared/src/scope-resolution/types.ts index 50a968146..c5c1b4438 100644 --- a/gitnexus-shared/src/scope-resolution/types.ts +++ b/gitnexus-shared/src/scope-resolution/types.ts @@ -379,6 +379,12 @@ type ParsedImportSyntax = * deferred — `use` does not execute * (`LanguageProvider.importsExecuteWhereWritten`). */ readonly runsOnlyWhenCalled?: boolean; + /** + * C/C++ `#include <…>` (true) versus `#include "…"`. + * Angle includes resolve only on header search paths. Quoted includes + * may still use the including file's directory and the basename index. + */ + readonly isSystem?: boolean; } /** * Runtime-computed target — the import path is not a static literal at @@ -643,6 +649,7 @@ export interface TypeRef { | 'parameter-annotation' | 'return-annotation' | 'self' + | 'decorator-unknown' | 'assignment-inferred' | 'constructor-inferred' | 'receiver-propagated'; diff --git a/gitnexus-web/.gitignore b/gitnexus-web/.gitignore index c8a733615..0fe739585 100644 --- a/gitnexus-web/.gitignore +++ b/gitnexus-web/.gitignore @@ -1,2 +1,3 @@ .vercel .env*.local +.cursor/ diff --git a/gitnexus-web/e2e/analyze-public-sse.spec.ts b/gitnexus-web/e2e/analyze-public-sse.spec.ts new file mode 100644 index 000000000..3e3a9800e --- /dev/null +++ b/gitnexus-web/e2e/analyze-public-sse.spec.ts @@ -0,0 +1,301 @@ +import { test, expect } from '@playwright/test'; +import fs from 'node:fs'; +import { + MISSING_GITHUB, + TOKEN_LEAK, + assertNoLeaks, + bindBackend, + capture, + fetchOps, + livePrereqSkipReason, + openAnalyzeForm, + postAnalyze, + startLiveBackend, + stopLiveBackend, + waitForAnalyzeSlotFree, + waitForJob, + writeTinyRepo, + type LiveBackend, +} from './helpers/public-contract'; + +/** + * Live e2e for the public analyze flow. Spawns a real `gitnexus serve` and + * drives the UI — no `page.route` mocks. + */ + +test.describe.configure({ mode: 'serial' }); + +let backend: LiveBackend | undefined; + +test.beforeAll(async () => { + test.setTimeout(300_000); + const skip = await livePrereqSkipReason(); + if (skip) { + test.skip(true, skip); + return; + } + backend = await startLiveBackend(); +}); + +test.beforeEach(async ({ page }) => { + if (!backend) return; + await bindBackend(page, backend.url); +}); + +test.afterAll(async () => { + await stopLiveBackend(backend); +}); + +function requireBackend(): LiveBackend { + if (!backend) throw new Error('live backend was not started'); + return backend; +} + +test.describe('Analyze — happy path', () => { + test('local folder path → real analyze → done by basename, no path on screen', async ({ + page, + }, testInfo) => { + test.setTimeout(180_000); + const { url, fixtures } = requireBackend(); + const repoDir = writeTinyRepo(fixtures, 'courses'); + const repoQueries: string[] = []; + page.on('request', (req) => { + const u = new URL(req.url()); + if (u.pathname === '/api/repo' || u.pathname === '/api/graph') { + const repo = u.searchParams.get('repo'); + if (repo) repoQueries.push(repo); + } + }); + + await openAnalyzeForm(page); + await capture(page, testInfo, '01-empty-form'); + + await page.getByRole('tab', { name: 'Local Folder' }).click(); + await page.getByTestId('local-path-input').fill(repoDir); + await capture(page, testInfo, '02-filled-local-path'); + await page.getByRole('button', { name: /Analyze Repository/ }).click(); + + await expect(page.locator('[data-testid="analyze-progress"]')).toBeVisible({ timeout: 20_000 }); + await capture(page, testInfo, '03-progress'); + + const done = page.locator('[data-testid="analyze-done"]'); + const retry = page.getByRole('button', { name: /Try again/ }); + await expect(done.or(retry)).toBeVisible({ timeout: 120_000 }); + if (await retry.isVisible()) { + throw new Error(`live analyze failed:\n${await page.locator('body').innerText()}`); + } + await expect(done).toBeVisible(); + await expect(done.getByText('Analysis complete')).toBeVisible(); + await expect(done.getByText('courses', { exact: true })).toBeVisible(); + await expect(done).not.toContainText(repoDir); + await expect(done).not.toContainText(fixtures); + await capture(page, testInfo, '04-done-basename'); + + const snap = JSON.stringify(await fetchOps(url)); + expect(snap).toContain('courses'); + expect(snap).not.toContain(repoDir); + expect(snap).not.toContain('"repoPath"'); + + // Reconnect resolves the SSE repoId against /api/repos and loads the exact + // registered path, never a same-named sibling. The path stays off screen. + await expect + .poll(() => repoQueries.length > 0 && repoQueries.every((q) => q === repoDir), { + timeout: 20_000, + }) + .toBe(true); + await capture(page, testInfo, '05-after-complete'); + await assertNoLeaks(page, [fixtures]); + }); +}); + +test.describe('Analyze — failure, retry, cancel', () => { + test('missing local path fails and Try again restores the form', async ({ page }, testInfo) => { + test.setTimeout(120_000); + const { fixtures } = requireBackend(); + const notARepo = `${fixtures}/not-a-repo.txt`; + fs.writeFileSync(notARepo, 'this is a file, not a repository\n'); + + await openAnalyzeForm(page); + await page.getByRole('tab', { name: 'Local Folder' }).click(); + await page.getByTestId('local-path-input').fill(notARepo); + await page.getByRole('button', { name: /Analyze Repository/ }).click(); + + await expect(page.getByRole('button', { name: /Try again/ })).toBeVisible({ timeout: 60_000 }); + await expect(page.locator('body')).not.toContainText(TOKEN_LEAK); + await expect(page.locator('body')).not.toContainText(notARepo); + await capture(page, testInfo, '07-failed'); + await assertNoLeaks(page, [fixtures, notARepo]); + + await page.getByRole('button', { name: /Try again/ }).click(); + await expect(page.getByRole('tab', { name: 'GitHub URL' })).toBeVisible(); + await expect(page.getByRole('button', { name: /Analyze Repository/ })).toBeVisible(); + await capture(page, testInfo, '08-try-again-form'); + }); + + test('cancel during analyze DELETEs the live job and returns the form', async ({ + page, + }, testInfo) => { + test.setTimeout(180_000); + const { url, fixtures } = requireBackend(); + // The previous test's failed local-path job keeps the slot until its worker exits. + await waitForAnalyzeSlotFree(url); + const repoDir = writeTinyRepo(fixtures, 'cancel-me'); + const deletes: string[] = []; + page.on('request', (req) => { + if (req.method() === 'DELETE' && req.url().includes('/api/analyze/')) { + deletes.push(req.url()); + } + }); + + await openAnalyzeForm(page); + await page.getByRole('tab', { name: 'Local Folder' }).click(); + await page.getByTestId('local-path-input').fill(repoDir); + await page.getByRole('button', { name: /Analyze Repository/ }).click(); + + const progress = page.locator('[data-testid="analyze-progress"]'); + await expect(progress).toBeVisible({ timeout: 20_000 }); + await capture(page, testInfo, '09-progress-before-cancel'); + + await page.getByRole('button', { name: /^Cancel$/ }).click(); + await expect.poll(() => deletes.length, { timeout: 15_000 }).toBeGreaterThan(0); + await expect(page.getByRole('tab', { name: 'GitHub URL' })).toBeVisible({ timeout: 15_000 }); + await expect(progress).toBeHidden(); + await capture(page, testInfo, '10-form-after-cancel'); + }); + + test('second analyze while one is running surfaces the live 409', async ({ page }, testInfo) => { + test.setTimeout(180_000); + const { url, fixtures } = requireBackend(); + const first = writeTinyRepo(fixtures, 'lock-a'); + const second = writeTinyRepo(fixtures, 'lock-b'); + await waitForAnalyzeSlotFree(url); + // A real local analyze holds the slot for the whole UI round trip; a + // missing-repo clone fails in under a second and would free it early. + const held = await postAnalyze(url, { path: first }); + expect(held.http).toBe(202); + expect(held.jobId).toBeTruthy(); + + await openAnalyzeForm(page); + await page.getByRole('tab', { name: 'Local Folder' }).click(); + await page.getByTestId('local-path-input').fill(second); + await page.getByRole('button', { name: /Analyze Repository/ }).click(); + + await expect(page.getByText(/already (active|in progress)/i)).toBeVisible({ timeout: 20_000 }); + await capture(page, testInfo, '11-lock-409'); + if (held.jobId) await waitForJob(url, held.jobId); + }); +}); + +test.describe('Analyze — other sources and token', () => { + test('optional GitHub token is posted but never painted after a failed clone', async ({ + page, + }, testInfo) => { + test.setTimeout(180_000); + await waitForAnalyzeSlotFree(requireBackend().url); + let posted: Record = {}; + page.on('request', (req) => { + if (req.method() === 'POST' && req.url().endsWith('/api/analyze')) { + posted = (req.postDataJSON() as Record) ?? {}; + } + }); + + await openAnalyzeForm(page); + await page.locator('input[type="url"]').fill(MISSING_GITHUB); + await page.locator('input[type="password"]').fill(TOKEN_LEAK); + await capture(page, testInfo, '13-token-filled'); + await page.getByRole('button', { name: /Analyze Repository/ }).click(); + + await expect(page.getByRole('button', { name: /Try again/ })).toBeVisible({ timeout: 120_000 }); + expect(posted).toMatchObject({ url: MISSING_GITHUB, token: TOKEN_LEAK }); + await assertNoLeaks(page); + await capture(page, testInfo, '14-failed-token-masked'); + }); + + test('GitLab URL is posted to the live server and fails closed without leaking the URL host path', async ({ + page, + }, testInfo) => { + test.setTimeout(180_000); + await waitForAnalyzeSlotFree(requireBackend().url); + let posted: Record = {}; + page.on('request', (req) => { + if (req.method() === 'POST' && req.url().endsWith('/api/analyze')) { + posted = (req.postDataJSON() as Record) ?? {}; + } + }); + + await openAnalyzeForm(page); + await page.getByRole('tab', { name: 'GitLab URL' }).click(); + await page.locator('input[type="url"]').fill('https://gitlab.com/gitnexus-e2e-missing/project'); + await capture(page, testInfo, '15-gitlab-filled'); + await page.getByRole('button', { name: /Analyze Repository/ }).click(); + + await expect(page.getByRole('button', { name: /Try again/ })).toBeVisible({ timeout: 120_000 }); + expect(posted).toMatchObject({ url: 'https://gitlab.com/gitnexus-e2e-missing/project' }); + const failureText = page.locator('p.text-red-400'); + await expect(failureText).toBeVisible(); + await expect(failureText).not.toContainText('gitlab.com'); + await expect(failureText).not.toContainText('gitnexus-e2e-missing'); + await capture(page, testInfo, '16-gitlab-failed'); + await assertNoLeaks(page); + }); + + test('folder upload analyzes on the live server and shows the folder name', async ({ + page, + }, testInfo) => { + test.setTimeout(180_000); + const { url, fixtures } = requireBackend(); + await waitForAnalyzeSlotFree(url); + const fixtureDir = writeTinyRepo(fixtures, 'myrepo'); + + await openAnalyzeForm(page); + await page.getByRole('tab', { name: 'Local Folder' }).click(); + await capture(page, testInfo, '19-local-folder-tab'); + await page.locator('[data-testid="folder-upload-input"]').setInputFiles(fixtureDir); + + const done = page.locator('[data-testid="analyze-done"]'); + const retry = page.getByRole('button', { name: /Try again/ }); + await expect(done.or(retry)).toBeVisible({ timeout: 120_000 }); + if (await retry.isVisible()) { + throw new Error( + `live folder-upload analyze failed:\n${await page.locator('body').innerText()}`, + ); + } + await expect(done.getByText('myrepo', { exact: true })).toBeVisible(); + await expect(done).not.toContainText(fixtureDir); + await capture(page, testInfo, '20-folder-upload-done'); + await assertNoLeaks(page, [fixtures]); + }); +}); + +test.describe('Analyze — form validation and tabs', () => { + test('invalid GitHub URL keeps Analyze disabled; tabs each have their own form', async ({ + page, + }, testInfo) => { + requireBackend(); + await openAnalyzeForm(page); + const analyzeBtn = page.getByRole('button', { name: /Analyze Repository/ }); + await expect(analyzeBtn).toBeDisabled(); + + await page.locator('input[type="url"]').fill('not-a-url'); + await expect(analyzeBtn).toBeDisabled(); + await capture(page, testInfo, '21-invalid-github'); + + await page.getByRole('tab', { name: 'GitLab URL' }).click(); + await expect(page.getByPlaceholder('https://gitlab.com/owner/repo')).toBeVisible(); + await capture(page, testInfo, '22-gitlab-tab'); + + await page.getByRole('tab', { name: 'Azure DevOps' }).click(); + await expect( + page.getByPlaceholder('http://azuredevops.example.com/Collection/Project/_git/Repo'), + ).toBeVisible(); + await capture(page, testInfo, '23-azure-tab'); + + await page.getByRole('tab', { name: 'Local Folder' }).click(); + await expect(page.locator('[data-testid="upload-folder"]')).toBeVisible(); + await capture(page, testInfo, '24-local-tab'); + + await page.getByRole('tab', { name: 'GitHub URL' }).click(); + await expect(page.locator('input[type="url"]')).toHaveValue(''); + await expect(analyzeBtn).toBeDisabled(); + }); +}); diff --git a/gitnexus-web/e2e/helpers/public-contract.ts b/gitnexus-web/e2e/helpers/public-contract.ts new file mode 100644 index 000000000..d54cfe6a1 --- /dev/null +++ b/gitnexus-web/e2e/helpers/public-contract.ts @@ -0,0 +1,396 @@ +import { expect, type Page, type TestInfo } from '@playwright/test'; +import { spawn, spawnSync, type ChildProcess } from 'node:child_process'; +import fs from 'node:fs'; +import net from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:5173'; +export const TOKEN_LEAK = 'ghs_secret_e2e_token'; +export const MISSING_GITHUB = 'https://github.com/gitnexus-e2e-missing/no-such-repo'; + +const GALLERY_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'screenshots'); +const GITNEXUS_DIR = path.resolve(process.cwd(), '..', 'gitnexus'); +const TSX_BIN = path.join(GITNEXUS_DIR, 'node_modules', '.bin', 'tsx'); +const CLI_TS = path.join(GITNEXUS_DIR, 'src', 'cli', 'index.ts'); +const CLI_DIST = path.join(GITNEXUS_DIR, 'dist', 'cli', 'index.js'); +/** Per-request bound so a stalled connection cannot outlive a helper's deadline. */ +const REQUEST_TIMEOUT_MS = 30_000; +/** POST /api/analyze allows 10/min per IP; 409 polling must not burn that budget. */ +const SLOT_POLL_MS = 2_000; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +export interface LiveBackend { + url: string; + port: number; + home: string; + fixtures: string; + child: ChildProcess; + log: string; +} + +async function freePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(0, '127.0.0.1', () => { + const addr = server.address(); + if (!addr || typeof addr === 'string') { + server.close(); + reject(new Error('could not bind an ephemeral port')); + return; + } + const { port } = addr; + server.close((err) => (err ? reject(err) : resolve(port))); + }); + server.on('error', reject); + }); +} + +function serveArgs(port: number): { cmd: string; args: string[] } { + if (fs.existsSync(TSX_BIN) && fs.existsSync(CLI_TS)) { + return { cmd: TSX_BIN, args: [CLI_TS, 'serve', '--port', String(port), '--host', '127.0.0.1'] }; + } + if (fs.existsSync(CLI_DIST)) { + return { + cmd: process.execPath, + args: [CLI_DIST, 'serve', '--port', String(port), '--host', '127.0.0.1'], + }; + } + throw new Error(`neither ${TSX_BIN} nor ${CLI_DIST} is available`); +} + +/** Spawn an isolated `gitnexus serve` on 127.0.0.1. */ +export async function startLiveBackend(): Promise { + const port = await freePort(); + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-e2e-home-')); + const fixtures = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gn-e2e-fx-'))); + const { cmd, args } = serveArgs(port); + const child = spawn(cmd, args, { + cwd: GITNEXUS_DIR, + env: { + ...process.env, + GITNEXUS_HOME: home, + // Real parse workers — same override the CLI integration suite uses on + // a loaded host. A 5s default ready budget dies when two live specs + // cold-start worker pools at once. + GITNEXUS_WORKER_READY_TIMEOUT_MS: process.env.GITNEXUS_WORKER_READY_TIMEOUT_MS ?? '60000', + // Still a real pool; size 1 matches a two-file fixture and keeps two + // parallel live servers from spawning cores-1 workers each. + GITNEXUS_WORKER_POOL_SIZE: process.env.GITNEXUS_WORKER_POOL_SIZE ?? '1', + // Serve forks analyze with min(8192, 0.75×RAM) MB. Two parallel specs + // both getting an 8GB child is what produced `Worker crashed (code null)`. + GITNEXUS_SERVER_ANALYZE_HEAP_MB: process.env.GITNEXUS_SERVER_ANALYZE_HEAP_MB ?? '512', + GITNEXUS_WORKER_HEAP_MB: process.env.GITNEXUS_WORKER_HEAP_MB ?? '256', + // Ladybug FTS CREATE_FTS_INDEX SIGSEGVs on this host (CLI exit 139). + // Graph analyze still runs for real; keyword indexes are the only skip. + GITNEXUS_SKIP_FTS: process.env.GITNEXUS_SKIP_FTS ?? '1', + }, + stdio: ['ignore', 'pipe', 'pipe'], + // Own process group so teardown also reaches the forked analyze worker. + detached: process.platform !== 'win32', + }); + const backend: LiveBackend = { + url: `http://127.0.0.1:${port}`, + port, + home, + fixtures, + child, + log: '', + }; + const captureLog = (chunk: Buffer) => { + backend.log = (backend.log + chunk.toString()).slice(-8_192); + }; + child.stdout?.on('data', captureLog); + child.stderr?.on('data', captureLog); + + let exited: number | null | undefined; + child.on('exit', (code) => { + exited = code; + }); + + const deadline = Date.now() + 45_000; + try { + for (;;) { + if (exited !== undefined) { + throw new Error(`live backend exited early (code ${exited}):\n${backend.log}`); + } + const ok = await fetch(`${backend.url}/api/health`, { + signal: AbortSignal.timeout(2_000), + }) + .then((r) => r.ok) + .catch(() => false); + if (ok) return backend; + if (Date.now() > deadline) { + throw new Error(`live backend did not become ready on ${backend.url}:\n${backend.log}`); + } + await new Promise((r) => setTimeout(r, 250)); + } + } catch (err) { + await stopLiveBackend(backend); + throw err; + } +} + +export async function stopLiveBackend(backend: LiveBackend | undefined): Promise { + if (!backend) return; + if (backend.child.exitCode === null && backend.child.signalCode === null) { + const exited = new Promise((resolve) => backend.child.once('exit', () => resolve())); + const pid = backend.child.pid; + const signal = (sig: NodeJS.Signals) => { + if (pid !== undefined && process.platform !== 'win32') { + try { + process.kill(-pid, sig); + return; + } catch { + /* group gone or not a leader: fall back to the child */ + } + } + backend.child.kill(sig); + }; + signal('SIGTERM'); + let timer: ReturnType | undefined; + try { + const exitedInTime = await Promise.race([ + exited.then(() => true), + new Promise((resolve) => { + timer = setTimeout(() => resolve(false), 5_000); + }), + ]); + if (!exitedInTime) { + signal('SIGKILL'); + await exited; + } + } finally { + if (timer !== undefined) clearTimeout(timer); + } + } + try { + fs.rmSync(backend.home, { recursive: true, force: true }); + fs.rmSync(backend.fixtures, { recursive: true, force: true }); + } catch { + /* best-effort */ + } +} + +export function writeTinyRepo(parent: string, name: string): string { + const dir = path.join(parent, name); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'README.md'), `# ${name}\n`); + fs.writeFileSync(path.join(dir, 'src', 'index.ts'), 'export const ready = true;\n'); + const git = spawnSync('git', ['-C', dir, 'init', '-q', '-b', 'main'], { stdio: 'ignore' }); + if (git.status === 0) { + spawnSync('git', ['-C', dir, 'config', 'user.email', 'e2e@gitnexus.test'], { stdio: 'ignore' }); + spawnSync('git', ['-C', dir, 'config', 'user.name', 'e2e'], { stdio: 'ignore' }); + spawnSync('git', ['-C', dir, 'add', '-A'], { stdio: 'ignore' }); + spawnSync('git', ['-C', dir, 'commit', '-qm', 'init'], { stdio: 'ignore' }); + } + return dir; +} + +export interface AnalyzePostResult { + jobId: string; + status?: string; + error?: string; + http: number; + /** From the draft-7 `RateLimit` header; Infinity when absent. */ + remaining: number; + resetMs: number; +} + +/** + * POST /api/analyze; a 429 waits out the limiter window and retries, unless + * that wait would pass the caller's `deadline`. + */ +export async function postAnalyze( + backendUrl: string, + body: Record, + deadline = Infinity, +): Promise { + for (;;) { + const res = await fetch(`${backendUrl}/api/analyze`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + const rateLimit = res.headers.get('ratelimit') ?? ''; + const resetMs = Number(/reset=(\d+)/.exec(rateLimit)?.[1] ?? 60) * 1000; + if (res.status === 429) { + await res.body?.cancel(); + if (Date.now() + resetMs > deadline) { + throw new Error('analyze rate limit outlasts the caller deadline (HTTP 429)'); + } + await sleep(resetMs + 250); + continue; + } + const json = (await res.json().catch(() => ({}))) as { + jobId?: string; + status?: string; + error?: string; + }; + const remaining = /remaining=(\d+)/.exec(rateLimit)?.[1]; + return { + jobId: json.jobId ?? '', + status: json.status, + error: json.error, + http: res.status, + remaining: remaining === undefined ? Infinity : Number(remaining), + resetMs, + }; + } +} + +/** + * Wait until the server will accept a new analyze, with at least `budget` + * POST /api/analyze calls left in the rate-limit window so the caller's own + * posts are not answered with 429. + * + * Probes with a clone that fails in-server before any worker fork: a `failed` + * probe leaves no child holding the slot. A local-path probe would fork a + * worker that outlives its own `failed` status and re-occupy the slot. + */ +export async function waitForAnalyzeSlotFree( + backendUrl: string, + timeoutMs = 90_000, + budget = 3, +): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + const probe = await postAnalyze(backendUrl, { url: MISSING_GITHUB }, deadline); + if (probe.http !== 409) { + if (probe.jobId) { + await waitForJob(backendUrl, probe.jobId, Math.max(0, deadline - Date.now())); + } + if (probe.remaining < budget) await sleep(probe.resetMs + 250); + return; + } + if (Date.now() > deadline) { + throw new Error(`analyze slot stayed busy: ${probe.error ?? 'HTTP 409'}`); + } + await sleep(SLOT_POLL_MS); + } +} + +/** Single-slot: a failed job still occupies the slot until its child exits. */ +export async function postAnalyzeWhenIdle( + backendUrl: string, + body: Record, + timeoutMs = 60_000, +): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + const result = await postAnalyze(backendUrl, body, deadline); + if (result.http !== 409) return result; + if (Date.now() > deadline) { + throw new Error(`analyze slot stayed busy: ${result.error ?? 'HTTP 409'}`); + } + await sleep(SLOT_POLL_MS); + } +} + +export async function waitForJob( + backendUrl: string, + jobId: string, + timeoutMs = 120_000, +): Promise<{ status: string; error?: string; repoName?: string }> { + const deadline = Date.now() + timeoutMs; + for (;;) { + const poll = await fetch(`${backendUrl}/api/analyze/${jobId}`, { + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + const job = (await poll.json()) as { status: string; error?: string; repoName?: string }; + if (job.status === 'complete' || job.status === 'failed') { + return job; + } + if (Date.now() > deadline) throw new Error(`job ${jobId} timed out at ${job.status}`); + await sleep(400); + } +} + +export async function fetchOps(backendUrl: string): Promise> { + const res = await fetch(`${backendUrl}/api/ops`, { + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!res.ok) throw new Error(`GET /api/ops → HTTP ${res.status}`); + return (await res.json()) as Record; +} + +/** Point the app at this spec's live server before the first navigation. */ +export async function bindBackend(page: Page, backendUrl: string): Promise { + await page.addInitScript((url) => { + window.localStorage.setItem('gitnexus-backend-url', url); + }, backendUrl); +} + +function frontendHref(base: string, pathAndQuery: string): string { + const normalized = base.endsWith('/') ? base : `${base}/`; + return new URL(pathAndQuery.replace(/^\//, ''), normalized).href; +} + +const probeFrontend = (url: string) => + fetch(url, { signal: AbortSignal.timeout(2_000) }) + .then((r) => r.ok) + .catch(() => false); + +/** Prefer an explicit FRONTEND_URL; otherwise the first listener CI or local Vite bound. */ +async function resolveFrontendUrl(): Promise { + if (process.env.FRONTEND_URL) return process.env.FRONTEND_URL; + for (const url of ['http://localhost:5173', 'http://127.0.0.1:5173']) { + if (await probeFrontend(url)) return url; + } + return FRONTEND_URL; +} + +export async function openAnalyzeForm(page: Page): Promise { + // Stay on the reachable frontend (localStorage already has the backend). + // `?server=` makes App auto-load the last graph and never show the form. + // DropZone on `/` shows onboarding (0 repos) or landing + analyze (N repos). + await page.goto(frontendHref(await resolveFrontendUrl(), '/')); + await expect(page.getByRole('tab', { name: 'GitHub URL' })).toBeVisible({ timeout: 30_000 }); +} + +export async function openOps(page: Page, backendUrl: string): Promise { + await page.goto( + frontendHref(await resolveFrontendUrl(), `/?view=ops&server=${encodeURIComponent(backendUrl)}`), + ); + await expect(page.locator('[data-testid="ops-dashboard"]')).toBeVisible({ timeout: 20_000 }); +} + +/** Empty string means go; otherwise a skip reason (or throw under E2E=1). */ +export async function livePrereqSkipReason(): Promise { + const frontendUp = + (await probeFrontend(FRONTEND_URL)) || + (await probeFrontend('http://localhost:5173')) || + (await probeFrontend('http://127.0.0.1:5173')); + const cliReady = fs.existsSync(TSX_BIN) || fs.existsSync(CLI_DIST); + if (process.env.E2E) { + if (!cliReady) throw new Error(`backend CLI missing (${CLI_TS} / ${CLI_DIST})`); + if (!frontendUp) throw new Error(`Vite dev server not available at ${FRONTEND_URL}`); + return ''; + } + if (!frontendUp) return 'Vite dev server not available'; + if (!cliReady) return 'backend CLI not available'; + return ''; +} + +export async function capture(page: Page, testInfo: TestInfo, name: string): Promise { + const out = testInfo.outputPath(`${name}.png`); + await page.screenshot({ path: out, fullPage: true }); + fs.mkdirSync(GALLERY_DIR, { recursive: true }); + const slug = testInfo.title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') + .slice(0, 72); + fs.copyFileSync(out, path.join(GALLERY_DIR, `${slug}--${name}.png`)); +} + +export async function assertNoLeaks(page: Page, extra: string[] = []): Promise { + const body = await page.locator('body').innerText(); + for (const leak of [TOKEN_LEAK, 'ghs_secret', 'x-access-token', ...extra]) { + expect(body, `page must not show ${leak}`).not.toContain(leak); + } + expect(body).not.toMatch(/[A-Za-z]:\\Users\\/); +} diff --git a/gitnexus-web/e2e/ops-dashboard.spec.ts b/gitnexus-web/e2e/ops-dashboard.spec.ts new file mode 100644 index 000000000..e5add562a --- /dev/null +++ b/gitnexus-web/e2e/ops-dashboard.spec.ts @@ -0,0 +1,146 @@ +import { test, expect } from '@playwright/test'; +import { + MISSING_GITHUB, + assertNoLeaks, + bindBackend, + capture, + fetchOps, + livePrereqSkipReason, + openOps, + postAnalyze, + postAnalyzeWhenIdle, + startLiveBackend, + stopLiveBackend, + waitForJob, + writeTinyRepo, + type LiveBackend, +} from './helpers/public-contract'; + +/** + * Live e2e for `?view=ops`. Spawns a real `gitnexus serve` and reads the + * unauthenticated ops feed the server actually emits — no `page.route` mocks. + */ + +test.describe.configure({ mode: 'serial' }); + +let backend: LiveBackend | undefined; +let completeName = ''; +let completePath = ''; + +test.beforeAll(async () => { + test.setTimeout(300_000); + const skip = await livePrereqSkipReason(); + if (skip) { + test.skip(true, skip); + return; + } + backend = await startLiveBackend(); +}); + +test.beforeEach(async ({ page }) => { + if (!backend) return; + await bindBackend(page, backend.url); +}); + +test.afterAll(async () => { + await stopLiveBackend(backend); +}); + +function requireBackend(): LiveBackend { + if (!backend) throw new Error('live backend was not started'); + return backend; +} + +test.describe('Ops dashboard — empty and connection states', () => { + test('empty server shows vacant lanes and waiting table', async ({ page }, testInfo) => { + const { url } = requireBackend(); + await openOps(page, url); + await expect(page.getByText('No jobs in this lane yet')).toHaveCount(2); + await expect( + page.getByText('Waiting for analyze / embed jobs on the connected server…'), + ).toBeVisible(); + await expect(page.getByText('0 active · 0 queued · 0 done · 0 failed')).toHaveCount(2); + await capture(page, testInfo, '03-empty'); + await assertNoLeaks(page); + }); + + test('unreachable backend shows offline and a connect error', async ({ page }, testInfo) => { + await openOps(page, 'http://127.0.0.1:5999'); + await expect(page.getByText(/offline/)).toBeVisible({ timeout: 10_000 }); + await expect(page.getByText('Backend unreachable')).toBeVisible(); + await capture(page, testInfo, '04-unreachable'); + }); + + test('Connect to a dead server updates the URL and goes offline', async ({ page }, testInfo) => { + const { url } = requireBackend(); + await openOps(page, url); + await expect(page.getByText(/live · (sse|poll)/)).toBeVisible({ timeout: 15_000 }); + await capture(page, testInfo, '06-before-reconnect'); + + const serverInput = page.locator('input[placeholder="http://localhost:4747"]'); + await serverInput.fill('http://127.0.0.1:5999'); + await page.getByRole('button', { name: 'Connect' }).click(); + + await expect(page.getByText('Backend unreachable')).toBeVisible({ timeout: 10_000 }); + await expect(page.getByText(/offline/)).toBeVisible(); + expect(decodeURIComponent(page.url())).toContain('127.0.0.1:5999'); + expect(page.url()).toContain('view=ops'); + await capture(page, testInfo, '07-after-dead-connect'); + }); +}); + +test.describe('Ops dashboard — live public jobs', () => { + test('renders a real failed + complete analyze without leaking the repo path', async ({ + page, + }, testInfo) => { + test.setTimeout(180_000); + const { url, fixtures } = requireBackend(); + completeName = 'private-repo'; + completePath = writeTinyRepo(fixtures, completeName); + + const fail = await postAnalyze(url, { url: MISSING_GITHUB }); + if (fail.jobId) await waitForJob(url, fail.jobId); + const ok = await postAnalyzeWhenIdle(url, { path: completePath }); + expect(ok.http).toBeLessThan(400); + const done = await waitForJob(url, ok.jobId); + expect(done.status, done.error).toMatch(/complete/); + + const snap = await fetchOps(url); + const raw = JSON.stringify(snap); + expect(raw).not.toContain(completePath); + expect(raw).not.toContain(fixtures); + expect(raw).not.toContain('"repoPath"'); + expect(raw).not.toContain('"repoUrl"'); + expect(raw).not.toContain('"branch"'); + + await openOps(page, url); + await expect(page.getByRole('heading', { name: 'Execution Ops' })).toBeVisible(); + await expect(page.getByText(/live · (sse|poll)/)).toBeVisible(); + await capture(page, testInfo, '01-live-jobs'); + + await expect(page.getByText('Analyze lane')).toBeVisible(); + await expect(page.getByText(/1 failed/)).toBeVisible(); + await expect(page.getByText(/1 done/)).toBeVisible(); + + const jobs = page.locator('[data-testid="ops-job"]'); + await expect(jobs).toHaveCount(2); + await expect(page.getByText(completeName).first()).toBeVisible(); + await expect(page.getByText('failed', { exact: true }).first()).toBeVisible(); + await expect(page.getByText('complete', { exact: true }).first()).toBeVisible(); + + await expect(page.getByRole('heading', { name: 'Recent activity' })).toBeVisible(); + await expect(page.locator('table tbody tr')).toHaveCount(2); + + await capture(page, testInfo, '01b-live-jobs-detail'); + await assertNoLeaks(page, [fixtures, completePath]); + }); + + test('mobile viewport still shows public rows only', async ({ page }, testInfo) => { + const { url, fixtures } = requireBackend(); + await page.setViewportSize({ width: 390, height: 844 }); + await openOps(page, url); + await expect(page.locator('[data-testid="ops-job"]').first()).toBeVisible(); + await capture(page, testInfo, '02-mobile'); + await assertNoLeaks(page, [fixtures]); + }); +}); diff --git a/gitnexus-web/e2e/repo-path-identity.spec.ts b/gitnexus-web/e2e/repo-path-identity.spec.ts index a27928cb2..d093a581f 100644 --- a/gitnexus-web/e2e/repo-path-identity.spec.ts +++ b/gitnexus-web/e2e/repo-path-identity.spec.ts @@ -3,6 +3,7 @@ import { spawn, type ChildProcess } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { postAnalyzeWhenIdle } from './helpers/public-contract'; /** * E2E tests for repo *path* identity with duplicate display names (#2419). @@ -50,9 +51,6 @@ const CLI_PATH = path.resolve(process.cwd(), '..', 'gitnexus', 'dist', 'cli', 'i const DUPE_NAME = 'pr2419-dupe'; const READY_TIMEOUT_MS = 45_000; -interface AnalyzeJobResponse { - jobId: string; -} interface AnalyzeJobStatus { status: string; error?: string; @@ -119,13 +117,13 @@ function markerFile(repoPath: string): string { } async function analyzeAndWait(repoPath: string): Promise { - const res = await fetch(`${BACKEND_URL}/api/analyze`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ path: repoPath, force: true }), - }); - if (!res.ok) throw new Error(`POST /api/analyze for ${repoPath} → HTTP ${res.status}`); - const { jobId } = (await res.json()) as AnalyzeJobResponse; + // The previous analyze's worker holds the single slot until it exits, even + // after its job reports complete — wait out that 409 (and any 429). + const res = await postAnalyzeWhenIdle(BACKEND_URL, { path: repoPath, force: true }); + if (res.http !== 202 || !res.jobId) { + throw new Error(`POST /api/analyze for ${repoPath} → HTTP ${res.http}`); + } + const { jobId } = res; const deadline = Date.now() + 120_000; for (;;) { const poll = await fetch(`${BACKEND_URL}/api/analyze/${jobId}`); diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index 4ec41926d..d904db1f6 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -29,14 +29,14 @@ "i18next": "^26.3.6", "i18next-browser-languagedetector": "^8.2.1", "langchain": "^1.5.11", - "lru-cache": "^11.5.2", - "lucide-react": "^1.31.0", + "lru-cache": "^11.5.3", + "lucide-react": "^1.46.0", "mermaid": "^11.17.2", "mnemonist": "^0.40.4", "pandemonium": "^2.4.0", - "react": "^19.2.5", - "react-dom": "^19.2.8", - "react-i18next": "^17.0.13", + "react": "^19.3.0", + "react-dom": "^19.3.0", + "react-i18next": "^17.0.14", "react-markdown": "^10.1.0", "react-syntax-highlighter": "^16.1.1", "react-zoom-pan-pinch": "^4.2.0", @@ -44,26 +44,26 @@ "sigma": "^3.0.3", "tailwindcss": "^4.3.3", "uuid": "^14.0.2", - "zod": "^4.5.4" + "zod": "^4.6.5" }, "devDependencies": { "@babel/types": "^8.0.5", - "@playwright/test": "^1.62.1", - "@testing-library/jest-dom": "^7.0.0", + "@playwright/test": "^1.63.0", + "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.3", "@testing-library/user-event": "^14.6.7", "@types/dompurify": "^3.2.0", "@types/node": "^26.5.1", - "@types/react": "^19.2.18", - "@types/react-dom": "^19.2.4", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", "@types/react-syntax-highlighter": "^15.5.13", - "@vercel/node": "^5.10.2", + "@vercel/node": "^7.0.0", "@vitejs/plugin-react": "^6.1.1", "@vitest/coverage-v8": "^4.1.11", "jsdom": "^30.0.1", "tree-sitter-wasms": "^0.1.13", "typescript": "^7.0.2", - "vite": "^8.1.5", + "vite": "^8.3.0", "vitest": "^4.1.10", "wait-on": "^9.1.0" }, @@ -494,37 +494,6 @@ "node": ">=16" } }, - "node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.0.tgz", @@ -1305,24 +1274,6 @@ "@chevrotain/types": "~11.1.2" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", - "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.3" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1362,22 +1313,22 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.139.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", - "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "version": "0.149.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz", + "integrity": "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==", "license": "MIT", "funding": { - "url": "https://github.com/sponsors/Boshen" + "url": "https://github.com/sponsors/oxc-project" } }, "node_modules/@playwright/test": { - "version": "1.62.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", - "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.62.1" + "playwright": "1.63.0" }, "bin": { "playwright": "cli.js" @@ -1386,10 +1337,26 @@ "node": ">=20" } }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.8.tgz", + "integrity": "sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", - "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.8.tgz", + "integrity": "sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==", "cpu": [ "arm64" ], @@ -1403,9 +1370,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", - "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.8.tgz", + "integrity": "sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==", "cpu": [ "arm64" ], @@ -1419,9 +1386,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", - "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.8.tgz", + "integrity": "sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==", "cpu": [ "x64" ], @@ -1435,9 +1402,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", - "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.8.tgz", + "integrity": "sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==", "cpu": [ "x64" ], @@ -1451,9 +1418,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", - "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.8.tgz", + "integrity": "sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==", "cpu": [ "arm" ], @@ -1467,9 +1434,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", - "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.8.tgz", + "integrity": "sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==", "cpu": [ "arm64" ], @@ -1483,9 +1450,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", - "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.8.tgz", + "integrity": "sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==", "cpu": [ "arm64" ], @@ -1499,9 +1466,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", - "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.8.tgz", + "integrity": "sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==", "cpu": [ "ppc64" ], @@ -1515,9 +1482,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", - "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.8.tgz", + "integrity": "sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==", "cpu": [ "s390x" ], @@ -1531,9 +1498,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", - "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.8.tgz", + "integrity": "sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==", "cpu": [ "x64" ], @@ -1547,9 +1514,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", - "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.8.tgz", + "integrity": "sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==", "cpu": [ "x64" ], @@ -1563,9 +1530,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", - "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.8.tgz", + "integrity": "sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==", "cpu": [ "arm64" ], @@ -1578,28 +1545,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", - "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", - "cpu": [ - "wasm32" - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.11.1", - "@emnapi/runtime": "1.11.1", - "@napi-rs/wasm-runtime": "^1.1.6" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", - "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.8.tgz", + "integrity": "sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==", "cpu": [ "arm64" ], @@ -1613,9 +1562,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", - "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.8.tgz", + "integrity": "sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==", "cpu": [ "x64" ], @@ -2017,9 +1966,9 @@ } }, "node_modules/@testing-library/jest-dom": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.0.tgz", - "integrity": "sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz", + "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==", "dev": true, "license": "MIT", "dependencies": { @@ -2036,7 +1985,13 @@ "yarn": ">=1" }, "peerDependencies": { - "@testing-library/dom": ">=10 <11" + "@testing-library/dom": ">=10 <11", + "vitest": ">= 0.32" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } } }, "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { @@ -2132,16 +2087,6 @@ "node": "*" } }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", - "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -2509,22 +2454,22 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.2.18", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", - "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", "license": "MIT", "dependencies": { "csstype": "^3.2.2" } }, "node_modules/@types/react-dom": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", - "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", "dev": true, "license": "MIT", "peerDependencies": { - "@types/react": "^19.2.0" + "@types/react": "^19.3.0" } }, "node_modules/@types/react-syntax-highlighter": { @@ -2557,7 +2502,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2574,7 +2518,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2591,7 +2534,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2608,7 +2550,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2625,7 +2566,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2642,7 +2582,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2659,7 +2598,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2676,7 +2614,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2693,7 +2630,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2710,7 +2646,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2727,7 +2662,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2744,7 +2678,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2761,7 +2694,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2778,7 +2710,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2795,7 +2726,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2812,7 +2742,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2829,7 +2758,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2846,7 +2774,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2863,7 +2790,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2880,7 +2806,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2907,11 +2832,12 @@ } }, "node_modules/@vercel/build-utils": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-14.2.0.tgz", - "integrity": "sha512-GwmtB31tBXQEzFw11grr8BKFCBdUORmYeooB0ZtonaCXZMZaPCHLBFTMFKsvaV6ZciQORPInRwXShbFvmnjqtg==", + "version": "14.4.0", + "resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-14.4.0.tgz", + "integrity": "sha512-X+VSuVphZO+qSOse1ttxCKtBi1WzMKpRuV84wsTZUTMAZWB6VDTQzH0YgEFrXNHr+QpgulfltETpTaSa7P941g==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "cjs-module-lexer": "1.2.3", "es-module-lexer": "1.5.0" @@ -2922,7 +2848,8 @@ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.0.tgz", "integrity": "sha512-pqrTKmwEIgafsYZAGw9kszYzmagcE/n4dbgwGWLEXg7J4QFJVQRBld8j3Q3GNez79jzxZshq0bcT962QHOghjw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@vercel/error-utils": { "version": "2.2.1", @@ -2959,9 +2886,9 @@ } }, "node_modules/@vercel/node": { - "version": "5.10.2", - "resolved": "https://registry.npmjs.org/@vercel/node/-/node-5.10.2.tgz", - "integrity": "sha512-YBXcoQVOh5O2ySXvzE+POhPEQEPMJJo4ctlMMdp5why/NIoa8m6gotv14j8Uo6D5qyZsnc+0+++JgUiV4mYB6w==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@vercel/node/-/node-7.0.0.tgz", + "integrity": "sha512-RqNQuw4Ix0nG5yiUUE8UZPEaz+f3YRKJtEzYq8qGvGElNYwokivBVPa0BcW1XWjKwGcGGSJPFz5pLnLp+XY85g==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -2969,7 +2896,6 @@ "@edge-runtime/primitives": "4.1.0", "@edge-runtime/vm": "3.2.0", "@types/node": "20.11.0", - "@vercel/build-utils": "14.2.0", "@vercel/error-utils": "2.2.1", "@vercel/nft": "1.10.0", "@vercel/static-config": "3.4.1", @@ -2987,6 +2913,9 @@ "tsx": "4.21.0", "typescript": "npm:typescript@5.9.3", "undici": "5.28.4" + }, + "peerDependencies": { + "@vercel/build-utils": "14.4.0" } }, "node_modules/@vercel/node/node_modules/@types/node": { @@ -5927,18 +5856,18 @@ } }, "node_modules/lru-cache": { - "version": "11.5.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", - "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" } }, "node_modules/lucide-react": { - "version": "1.31.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.31.0.tgz", - "integrity": "sha512-G8u2eEtoHUnUa9f8lbvqDhCiORMnYLdUEo06EEG9MQvHQrInKcX3Pa2TH39MM5qyzRcWETxB0+aOwAPI1g1kEg==", + "version": "1.46.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.46.0.tgz", + "integrity": "sha512-Bv+FZXgZPrxc/NCl1e7JJVQFLdiCxYgxNVhqoV7X0p6I8ADJo8DxBnK1auH0fZz4AmqOJ3jgneL4f1i8LJQRAA==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -7106,9 +7035,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.18", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", - "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", "funding": [ { "type": "github", @@ -7417,9 +7346,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "license": "MIT", "engines": { "node": ">=12" @@ -7440,28 +7369,25 @@ } }, "node_modules/playwright": { - "version": "1.62.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", - "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.62.1" + "playwright-core": "1.63.0" }, "bin": { "playwright": "cli.js" }, "engines": { "node": ">=20" - }, - "optionalDependencies": { - "fsevents": "2.3.2" } }, "node_modules/playwright-core": { - "version": "1.62.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", - "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -7471,21 +7397,6 @@ "node": ">=20" } }, - "node_modules/playwright/node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/points-on-curve": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", @@ -7503,9 +7414,9 @@ } }, "node_modules/postcss": { - "version": "8.5.25", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", - "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", "funding": [ { "type": "opencollective", @@ -7522,7 +7433,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.16", + "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -7622,30 +7533,30 @@ "license": "MIT" }, "node_modules/react": { - "version": "19.2.8", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", - "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.8", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", - "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", "license": "MIT", "dependencies": { - "scheduler": "^0.27.0" + "scheduler": "^0.28.0" }, "peerDependencies": { - "react": "^19.2.8" + "react": "^19.3.0" } }, "node_modules/react-i18next": { - "version": "17.0.13", - "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.13.tgz", - "integrity": "sha512-Cc1PscmblIHA1kljTqDwrcVMI21ydgmUzw0UAeQBe7pAOgfuRLfzXze4EUBQoeDiICzFIXXhHFoZxuetNg5D0Q==", + "version": "17.0.14", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.14.tgz", + "integrity": "sha512-ZpMBfJL3BiXPuYHj5QMY1GwvKJNhE1vCxOJQ8BoMBdHP5XONTfi/Qss1nuRlnFdIl72PKo2jPmw+fxuLjRFgaQ==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.29.7", @@ -7882,12 +7793,12 @@ "license": "Unlicense" }, "node_modules/rolldown": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", - "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.8.tgz", + "integrity": "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==", "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.139.0", + "@oxc-project/types": "=0.149.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -7897,21 +7808,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.1.5", - "@rolldown/binding-darwin-arm64": "1.1.5", - "@rolldown/binding-darwin-x64": "1.1.5", - "@rolldown/binding-freebsd-x64": "1.1.5", - "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", - "@rolldown/binding-linux-arm64-gnu": "1.1.5", - "@rolldown/binding-linux-arm64-musl": "1.1.5", - "@rolldown/binding-linux-ppc64-gnu": "1.1.5", - "@rolldown/binding-linux-s390x-gnu": "1.1.5", - "@rolldown/binding-linux-x64-gnu": "1.1.5", - "@rolldown/binding-linux-x64-musl": "1.1.5", - "@rolldown/binding-openharmony-arm64": "1.1.5", - "@rolldown/binding-wasm32-wasi": "1.1.5", - "@rolldown/binding-win32-arm64-msvc": "1.1.5", - "@rolldown/binding-win32-x64-msvc": "1.1.5" + "@rolldown/binding-android-arm-eabi": "1.2.8", + "@rolldown/binding-android-arm64": "1.2.8", + "@rolldown/binding-darwin-arm64": "1.2.8", + "@rolldown/binding-darwin-x64": "1.2.8", + "@rolldown/binding-freebsd-x64": "1.2.8", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.8", + "@rolldown/binding-linux-arm64-gnu": "1.2.8", + "@rolldown/binding-linux-arm64-musl": "1.2.8", + "@rolldown/binding-linux-ppc64-gnu": "1.2.8", + "@rolldown/binding-linux-s390x-gnu": "1.2.8", + "@rolldown/binding-linux-x64-gnu": "1.2.8", + "@rolldown/binding-linux-x64-musl": "1.2.8", + "@rolldown/binding-openharmony-arm64": "1.2.8", + "@rolldown/binding-win32-arm64-msvc": "1.2.8", + "@rolldown/binding-win32-x64-msvc": "1.2.8" } }, "node_modules/roughjs": { @@ -7986,9 +7897,9 @@ } }, "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", "license": "MIT" }, "node_modules/semver": { @@ -8368,7 +8279,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "devOptional": true, + "dev": true, "license": "0BSD" }, "node_modules/tsx": { @@ -8587,15 +8498,15 @@ } }, "node_modules/vite": { - "version": "8.1.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", - "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", + "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", "license": "MIT", "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.5", - "postcss": "^8.5.17", - "rolldown": "~1.1.5", + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.6", "tinyglobby": "^0.2.17" }, "bin": { @@ -8612,7 +8523,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.3.0", + "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -8663,6 +8574,255 @@ } } }, + "node_modules/vite/node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/vite/node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/vitest": { "version": "4.1.11", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", @@ -8872,9 +9032,9 @@ } }, "node_modules/zod": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.5.4.tgz", - "integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==", + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json index a10e13ffa..787e24949 100644 --- a/gitnexus-web/package.json +++ b/gitnexus-web/package.json @@ -39,14 +39,14 @@ "i18next": "^26.3.6", "i18next-browser-languagedetector": "^8.2.1", "langchain": "^1.5.11", - "lru-cache": "^11.5.2", - "lucide-react": "^1.31.0", + "lru-cache": "^11.5.3", + "lucide-react": "^1.46.0", "mermaid": "^11.17.2", "mnemonist": "^0.40.4", "pandemonium": "^2.4.0", - "react": "^19.2.5", - "react-dom": "^19.2.8", - "react-i18next": "^17.0.13", + "react": "^19.3.0", + "react-dom": "^19.3.0", + "react-i18next": "^17.0.14", "react-markdown": "^10.1.0", "react-syntax-highlighter": "^16.1.1", "react-zoom-pan-pinch": "^4.2.0", @@ -54,26 +54,26 @@ "sigma": "^3.0.3", "tailwindcss": "^4.3.3", "uuid": "^14.0.2", - "zod": "^4.5.4" + "zod": "^4.6.5" }, "devDependencies": { "@babel/types": "^8.0.5", - "@playwright/test": "^1.62.1", - "@testing-library/jest-dom": "^7.0.0", + "@playwright/test": "^1.63.0", + "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.3", "@testing-library/user-event": "^14.6.7", "@types/dompurify": "^3.2.0", "@types/node": "^26.5.1", - "@types/react": "^19.2.18", - "@types/react-dom": "^19.2.4", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", "@types/react-syntax-highlighter": "^15.5.13", - "@vercel/node": "^5.10.2", + "@vercel/node": "^7.0.0", "@vitejs/plugin-react": "^6.1.1", "@vitest/coverage-v8": "^4.1.11", "jsdom": "^30.0.1", "tree-sitter-wasms": "^0.1.13", "typescript": "^7.0.2", - "vite": "^8.1.5", + "vite": "^8.3.0", "vitest": "^4.1.10", "wait-on": "^9.1.0" }, diff --git a/gitnexus-web/playwright.config.ts b/gitnexus-web/playwright.config.ts index 291b1805f..560538b56 100644 --- a/gitnexus-web/playwright.config.ts +++ b/gitnexus-web/playwright.config.ts @@ -23,7 +23,7 @@ export default defineConfig({ timeout: 60_000, retries: process.env.CI ? 1 : 0, use: { - baseURL: 'http://localhost:5173', + baseURL: process.env.FRONTEND_URL || 'http://localhost:5173', trace: 'retain-on-failure', screenshot: 'retain-on-failure', video: 'retain-on-failure', diff --git a/gitnexus-web/src/App.tsx b/gitnexus-web/src/App.tsx index 6d847ff40..bfa35cb99 100644 --- a/gitnexus-web/src/App.tsx +++ b/gitnexus-web/src/App.tsx @@ -9,6 +9,7 @@ import { SettingsPanel } from './components/SettingsPanel'; import { StatusBar } from './components/StatusBar'; import { FileTreePanel } from './components/FileTreePanel'; import { CodeReferencesPanel } from './components/CodeReferencesPanel'; +import { ExecutionDashboard } from './components/ExecutionDashboard'; import { getActiveProviderConfig } from './core/llm/settings-service'; import { buildGraphFromConnectResult } from './lib/apply-connect-result'; import { @@ -45,6 +46,11 @@ const BOTTOM_BANNER_CLASS = export const pickRestoreRepo = (params: URLSearchParams): string | undefined => params.get('repo') ?? params.get('project') ?? undefined; +const isOpsView = (): boolean => { + if (typeof window === 'undefined') return false; + return new URLSearchParams(window.location.search).get('view') === 'ops'; +}; + const AppContent = () => { const { t } = useTranslation(['common', 'errors']); const { @@ -465,6 +471,9 @@ const AppContent = () => { }; function App() { + if (isOpsView()) { + return ; + } return ( diff --git a/gitnexus-web/src/components/AnalyzeProgress.tsx b/gitnexus-web/src/components/AnalyzeProgress.tsx index ded08d9dc..49529585d 100644 --- a/gitnexus-web/src/components/AnalyzeProgress.tsx +++ b/gitnexus-web/src/components/AnalyzeProgress.tsx @@ -29,7 +29,7 @@ export const AnalyzeProgress = ({ progress, onCancel }: AnalyzeProgressProps) => const pct = Math.max(0, Math.min(100, progress.percent)); return ( -
+
{/* Phase label + elapsed */}
{label} diff --git a/gitnexus-web/src/components/CodeReferencesPanel.tsx b/gitnexus-web/src/components/CodeReferencesPanel.tsx index 3fb6e6461..533e66634 100644 --- a/gitnexus-web/src/components/CodeReferencesPanel.tsx +++ b/gitnexus-web/src/components/CodeReferencesPanel.tsx @@ -17,6 +17,11 @@ import { useAppState } from '../hooks/useAppState'; import { type GraphNode, getSyntaxLanguageFromFilename } from 'gitnexus-shared'; import { NODE_COLORS } from '../lib/constants'; import { BackendError, readFile, type ReadFileResult } from '../services/backend-client'; +import { + selectedNodeDisplayLine, + selectedNodeFileRange, + selectedNodeLineHighlighted, +} from './code-panel-lines'; import { useTranslation } from 'react-i18next'; const getSyntaxLanguage = (filePath: string | undefined): string => { @@ -46,6 +51,41 @@ export interface CodeReferencesPanelProps { onFocusNode: (nodeId: string) => void; } +/** A fetched code excerpt for one AI citation card. Line numbers are 0-based file offsets. */ +interface CitationSnippet { + content: string; + start: number; + end: number; + /** Highlight range relative to `start`. */ + highlightStart: number; + highlightEnd: number; + totalLines: number; +} +const CITATION_CONTEXT_LINES = 5; +/** Max lines to fetch when a citation has no start/end range. */ +const RANGELESS_CITATION_LINES = 80; +/** Cap simultaneous `/api/file` reads when a reply cites many files. */ +const CITATION_SNIPPET_CONCURRENCY = 4; + +async function mapWithConcurrency( + items: T[], + concurrency: number, + worker: (item: T) => Promise, +): Promise { + if (items.length === 0) return []; + const results: R[] = new Array(items.length); + let nextIndex = 0; + const runners = Array.from({ length: Math.min(concurrency, items.length) }, async () => { + while (nextIndex < items.length) { + const currentIndex = nextIndex; + nextIndex += 1; + results[currentIndex] = await worker(items[currentIndex]); + } + }); + await Promise.all(runners); + return results; +} + export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) => { const { t } = useTranslation(['common', 'graph']); const { @@ -180,19 +220,103 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) = }; }, [codeReferenceFocus, aiReferences]); + // Per-citation snippets, fetched from the server around each reference's + // (0-based) line range. Keyed by reference id; a failed read stays absent so + // the card falls back to the "code not available" notice. + const [citationSnippets, setCitationSnippets] = useState>( + () => new Map(), + ); + // Ids already requested (loaded or failed) — a failed read is not retried. + const requestedSnippetIds = useRef>(new Set()); + + const snippetRepoKey = currentRepo || projectName || undefined; + const snippetRepoKeyRef = useRef(undefined); + // Live citation ids at apply time — in-flight batches must not resurrect + // excerpts after clearAICodeReferences() mints a fresh list. + const liveCitationIdsRef = useRef>(new Set()); + liveCitationIdsRef.current = new Set(aiReferences.map((ref) => ref.id)); + + useEffect(() => { + if (snippetRepoKeyRef.current !== snippetRepoKey) { + snippetRepoKeyRef.current = snippetRepoKey; + requestedSnippetIds.current.clear(); + setCitationSnippets(new Map()); + } + + const liveIds = liveCitationIdsRef.current; + for (const id of [...requestedSnippetIds.current]) { + if (!liveIds.has(id)) requestedSnippetIds.current.delete(id); + } + setCitationSnippets((prev) => { + if (prev.size === 0) return prev; + let removed = false; + const next = new Map(); + for (const [id, snippet] of prev) { + if (liveIds.has(id)) next.set(id, snippet); + else removed = true; + } + return removed ? next : prev; + }); + + const pending = aiReferences.filter((ref) => !requestedSnippetIds.current.has(ref.id)); + if (pending.length === 0) return; + for (const ref of pending) requestedSnippetIds.current.add(ref.id); + + mapWithConcurrency(pending, CITATION_SNIPPET_CONCURRENCY, async (ref) => { + const hasRange = typeof ref.startLine === 'number'; + // Range-less citations must not download/highlight the entire file. + const refStart = hasRange ? (ref.startLine as number) : 0; + const refEnd = hasRange + ? (ref.endLine ?? refStart) + : Math.max(0, RANGELESS_CITATION_LINES - 1); + const options = hasRange + ? selectedNodeFileRange(refStart, refEnd, CITATION_CONTEXT_LINES) + : { startLine: 0, endLine: refEnd }; + try { + const result = await readFile(ref.filePath, { ...options, repo: snippetRepoKey }); + const start = result.startLine ?? 0; + const lineCount = result.content.split('\n').length; + const snippet: CitationSnippet = { + content: result.content, + start, + end: result.endLine ?? start + lineCount - 1, + highlightStart: hasRange ? refStart - start : 0, + highlightEnd: hasRange ? refEnd - start : 0, + totalLines: result.totalLines, + }; + return [ref.id, snippet] as const; + } catch { + return null; + } + }).then((entries) => { + // Repo switch already cleared the set and started a replacement batch. + if (snippetRepoKeyRef.current !== snippetRepoKey) return; + const loaded = entries.filter((e): e is readonly [string, CitationSnippet] => e !== null); + if (loaded.length === 0) return; + setCitationSnippets((prev) => { + const next = new Map(prev); + for (const [id, snippet] of loaded) { + if (liveCitationIdsRef.current.has(id)) next.set(id, snippet); + } + return next; + }); + }); + }, [aiReferences, snippetRepoKey]); + const refsWithSnippets = useMemo(() => { return aiReferences.map((ref) => { + const snippet = citationSnippets.get(ref.id); return { ref, - content: null as string | null, - start: 0, - end: 0, - highlightStart: 0, - highlightEnd: 0, - totalLines: 0, + content: snippet?.content ?? null, + start: snippet?.start ?? 0, + end: snippet?.end ?? 0, + highlightStart: snippet?.highlightStart ?? 0, + highlightEnd: snippet?.highlightEnd ?? 0, + totalLines: snippet?.totalLines ?? 0, }; }); - }, [aiReferences]); + }, [aiReferences, citationSnippets]); const selectedFilePath = selectedNode?.properties?.filePath; const selectedIsFile = selectedNode?.label === 'File' && !!selectedFilePath; @@ -224,17 +348,13 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) = setFileResult(null); setSourceUnavailable(false); - // Determine read range: full file for File nodes, buffered for symbols + // Determine read range: full file for File nodes, buffered for symbols. + // Graph node lines are 0-based (#2377); /api/file ranges are 0-indexed. const startLine = selectedNode?.properties?.startLine as number | undefined; const endLine = selectedNode?.properties?.endLine as number | undefined; const isWholeFile = selectedIsFile || startLine === undefined; - const options = isWholeFile - ? {} - : { - startLine: Math.max(0, startLine - CONTEXT_LINES), - endLine: (endLine ?? startLine) + CONTEXT_LINES, - }; + const options = isWholeFile ? {} : selectedNodeFileRange(startLine, endLine, CONTEXT_LINES); // Prefer the repo path identity over the display name — duplicate display // names would otherwise resolve to the wrong repository's file (#2420). @@ -267,9 +387,10 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) = currentRepo, ]); - // Scroll to the selected node's startLine after content loads + // Scroll to the selected node's startLine after content loads. + // GraphNode startLine is 0-based; displayed gutters are 1-based. useEffect(() => { - if (!selectedFileContent || !selectedNode?.properties?.startLine) return; + if (!selectedFileContent || typeof selectedNode?.properties?.startLine !== 'number') return; const startLine = selectedNode.properties.startLine as number; // Double rAF: wait for SyntaxHighlighter to fully render before scrolling @@ -279,15 +400,23 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) = if (cancelled) return; const container = selectedViewerRef.current; if (!container) return; + // Index into the rendered lines: the viewer starts at `fileStartLine` + // (0-based), so the symbol's 0-based file line minus that offset. + const renderedIndex = Math.max(0, startLine - fileStartLine); const lineEl = - (container.querySelector(`[data-line-number="${startLine + 1}"]`) as HTMLElement) ?? - (container.querySelectorAll('.linenumber')[startLine] as HTMLElement); + (container.querySelector( + `[data-line-number="${selectedNodeDisplayLine(startLine)}"]`, + ) as HTMLElement) ?? + (container.querySelectorAll('.linenumber')[renderedIndex] as HTMLElement); if (lineEl) { lineEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); } else { // Fallback: estimate scroll position based on line height const lineHeight = 20.8; // 13px font * 1.6 line-height - container.scrollTop = Math.max(0, startLine * lineHeight - container.clientHeight / 3); + container.scrollTop = Math.max( + 0, + renderedIndex * lineHeight - container.clientHeight / 3, + ); } }); rafIds.push(innerRaf); @@ -297,7 +426,7 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) = cancelled = true; rafIds.forEach((id) => cancelAnimationFrame(id)); }; - }, [selectedFileContent, selectedNode?.properties?.startLine]); + }, [selectedFileContent, selectedNode?.properties?.startLine, fileStartLine]); if (isCollapsed) { return ( @@ -410,12 +539,12 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) = userSelect: 'none', }} lineProps={(lineNumber) => { + // `lineNumber` is 1-based (startingLineNumber); node lines are 0-based. const symStart = selectedNode?.properties?.startLine; const symEnd = selectedNode?.properties?.endLine ?? symStart; const isHighlighted = typeof symStart === 'number' && - lineNumber >= symStart + 1 && - lineNumber <= (symEnd ?? symStart) + 1; + selectedNodeLineHighlighted(lineNumber, symStart, symEnd); return { style: { display: 'block', diff --git a/gitnexus-web/src/components/ExecutionDashboard.tsx b/gitnexus-web/src/components/ExecutionDashboard.tsx new file mode 100644 index 000000000..51ceaf73b --- /dev/null +++ b/gitnexus-web/src/components/ExecutionDashboard.tsx @@ -0,0 +1,493 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + connectHeartbeat, + fetchOpsSnapshot, + getAuthToken, + getBackendUrl, + normalizeServerUrl, + probeBackendStatus, + setBackendUrl, + streamOpsSnapshot, + type OpsJobView, + type OpsLaneMetrics, + type OpsSnapshot, +} from '../services/backend-client'; +import { DEFAULT_BACKEND_URL } from '../config/ui-constants'; + +/** GET /api/ops is 60/min; safety REST + immediate tick + 1s would 429. */ +const OPS_POLL_INTERVAL_MS = 2_000; + +const STATUS_COLORS: Record = { + queued: 'text-text-muted', + cloning: 'text-sky-400', + analyzing: 'text-amber-400', + loading: 'text-violet-400', + complete: 'text-emerald-400', + failed: 'text-red-400', +}; + +const TONE_CLASS: Record<'default' | 'ok' | 'warn' | 'bad', string> = { + default: 'border-border-default text-text-primary', + ok: 'border-emerald-500/30 text-emerald-300', + warn: 'border-amber-500/30 text-amber-300', + bad: 'border-red-500/30 text-red-300', +}; + +const EMPTY_LANE_METRICS: OpsLaneMetrics = { + total: 0, + active: 0, + queued: 0, + complete: 0, + failed: 0, + byStatus: { + queued: 0, + cloning: 0, + analyzing: 0, + loading: 0, + complete: 0, + failed: 0, + }, + avgDurationMs: null, + maxDurationMs: null, + activeProgressSum: 0, +}; + +const formatDuration = (ms: number): string => { + const s = Math.floor(ms / 1000); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + const rem = s % 60; + if (m < 60) return `${m}m ${rem}s`; + const h = Math.floor(m / 60); + return `${h}h ${m % 60}m`; +}; + +const formatClock = (ts: number): string => + new Date(ts).toLocaleTimeString(undefined, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + +const MetricCard = ({ + label, + value, + hint, + tone = 'default', +}: { + label: string; + value: string | number; + hint?: string; + tone?: 'default' | 'ok' | 'warn' | 'bad'; +}) => { + const toneClass = TONE_CLASS[tone]; + return ( +
+
{label}
+
{value}
+ {hint ?
{hint}
: null} +
+ ); +}; + +const JobRow = ({ job }: { job: OpsJobView }) => { + const pct = Math.max(0, Math.min(100, job.progress.percent)); + return ( +
+
+
+
+ {job.repoName || job.id.slice(0, 8)} +
+
+ {job.lane} · {job.id.slice(0, 8)} + {job.branch ? ` · ${job.branch}` : ''} + {job.retryCount > 0 ? ` · retry ${job.retryCount}` : ''} +
+
+
+ + {job.status} + + + {formatDuration(job.durationMs)} + +
+
+
+
+
+
+ {job.progress.message || job.progress.phase} + {pct}% +
+ {job.error ?
{job.error}
: null} +
+ ); +}; + +const LanePanel = ({ + title, + jobs, + metrics, +}: { + title: string; + jobs: OpsJobView[]; + metrics: OpsSnapshot['analyze']['metrics']; +}) => ( +
+
+
+

{title}

+

+ {metrics.active} active · {metrics.queued} queued · {metrics.complete} done ·{' '} + {metrics.failed} failed +

+
+
+
avg {metrics.avgDurationMs != null ? formatDuration(metrics.avgDurationMs) : '—'}
+
max {metrics.maxDurationMs != null ? formatDuration(metrics.maxDurationMs) : '—'}
+
+
+
+ {jobs.length === 0 ? ( +
+ No jobs in this lane yet +
+ ) : ( + jobs.map((job) => ) + )} +
+
+); + +export const ExecutionDashboard = () => { + const [backendInput, setBackendInput] = useState(() => { + const params = new URLSearchParams(window.location.search); + return params.get('server') || getBackendUrl() || DEFAULT_BACKEND_URL; + }); + // Applied URL the connection effect binds to — distinct from the input so + // keystrokes do not restart SSE/heartbeat, and Connect always reconnects. + const [connectedServer, setConnectedServer] = useState(null); + const [connectNonce, setConnectNonce] = useState(0); + const [snapshot, setSnapshot] = useState(null); + const [live, setLive] = useState(false); + const [streamMode, setStreamMode] = useState<'sse' | 'poll' | 'offline'>('offline'); + const [error, setError] = useState(null); + const [lastTick, setLastTick] = useState(null); + const validationErrorRef = useRef(false); + + const applyBackend = useCallback((raw: string) => { + try { + const url = normalizeServerUrl(raw.trim() || DEFAULT_BACKEND_URL); + setBackendUrl(url); + setBackendInput(url); + setConnectedServer(url); + setSnapshot(null); + setLastTick(null); + setConnectNonce((n) => n + 1); + const next = new URL(window.location.href); + next.searchParams.set('view', 'ops'); + next.searchParams.set('server', url); + window.history.replaceState({}, '', next.toString()); + validationErrorRef.current = false; + setError(null); + } catch (err) { + validationErrorRef.current = true; + setLive(false); + setStreamMode('offline'); + setError(err instanceof Error ? err.message : 'Invalid backend URL'); + } + }, []); + + useEffect(() => { + // A `?server=` link must not carry the session's deploy token to another + // origin on its own: prefill it and wait for Connect when a token is held. + const linked = new URLSearchParams(window.location.search).get('server'); + if (linked && getAuthToken()) { + let foreign: boolean; + try { + foreign = normalizeServerUrl(linked) !== getBackendUrl(); + } catch { + // Invalid input: applyBackend below reports it without connecting. + foreign = false; + } + if (foreign) return; + } + applyBackend(backendInput); + // Mount-only: wire ?server= into the client once. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (!connectedServer) return; + + let cancelled = false; + let pollTimer: ReturnType | undefined; + let streamAbort: AbortController | undefined; + let stopHeartbeat: (() => void) | undefined; + + const ingest = (next: OpsSnapshot) => { + if (cancelled) return; + setSnapshot(next); + setLastTick(Date.now()); + // A failed Connect leaves this stream running; do not wipe its error. + if (!validationErrorRef.current) setError(null); + setLive(true); + }; + + const stopPolling = () => { + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = undefined; + } + }; + + const startPolling = () => { + if (cancelled) return; + stopPolling(); + setStreamMode('poll'); + let polling = false; + const tick = async () => { + if (polling || cancelled) return; + polling = true; + try { + const status = await probeBackendStatus(); + if (cancelled) return; + if (status !== 'ok') { + setLive(false); + setError(status === 'unauthorized' ? 'Backend requires auth' : 'Backend unreachable'); + return; + } + ingest(await fetchOpsSnapshot()); + } catch (err) { + if (cancelled) return; + setLive(false); + setError(err instanceof Error ? err.message : 'Failed to fetch ops snapshot'); + } finally { + polling = false; + } + }; + void tick(); + pollTimer = setInterval(() => void tick(), OPS_POLL_INTERVAL_MS); + }; + + const start = async () => { + const status = await probeBackendStatus(); + if (cancelled) return; + if (status !== 'ok') { + setLive(false); + setError(status === 'unauthorized' ? 'Backend requires auth' : 'Backend unreachable'); + startPolling(); + return; + } + + stopHeartbeat = connectHeartbeat( + () => setLive(true), + () => setLive(false), + ); + + streamAbort = streamOpsSnapshot( + (next) => { + // Safety poll may already be running after a transient REST miss. + stopPolling(); + setStreamMode('sse'); + ingest(next); + }, + () => { + // Fall back to polling if SSE cannot stay up. + streamAbort?.abort(); + streamAbort = undefined; + startPolling(); + }, + ); + + // Safety poll in case the first SSE frame is delayed. + try { + ingest(await fetchOpsSnapshot()); + if (cancelled) return; + setStreamMode((mode) => (mode === 'offline' ? 'poll' : mode)); + } catch { + // REST snapshot failed — do not abort a live SSE handshake. Polling + // covers the gap until the stream opens or its own onError fires. + startPolling(); + } + }; + + void start(); + + return () => { + cancelled = true; + stopPolling(); + streamAbort?.abort(); + stopHeartbeat?.(); + }; + }, [connectedServer, connectNonce]); + + const allJobs = useMemo(() => { + if (!snapshot) return [] as OpsJobView[]; + return [...snapshot.analyze.jobs, ...snapshot.embed.jobs].sort( + (a, b) => b.startedAt - a.startedAt, + ); + }, [snapshot]); + + return ( +
+
+
+
+
GitNexus
+

Execution Ops

+
+
+ + + {live ? 'live' : 'offline'} · {streamMode} + + {snapshot ? ( + + up {formatDuration(snapshot.uptimeMs)} · tick{' '} + {lastTick ? formatClock(lastTick) : '—'} + + ) : null} +
+
+
{ + e.preventDefault(); + applyBackend(backendInput); + }} + > + setBackendInput(e.target.value)} + className="min-w-0 flex-1 rounded-lg border border-border-default bg-surface px-3 py-2 font-mono text-sm outline-none focus:border-accent" + placeholder="http://localhost:4747" + spellCheck={false} + /> + +
+ {error ?

{error}

: null} +
+ +
+
+ 0 ? 'warn' : 'default'} + /> + + 0 ? 'bad' : 'default'} + /> + +
+ +
+ + +
+ +
+
+

Recent activity

+

Both lanes, newest first

+
+
+ + + + + + + + + + + + + + {allJobs.length === 0 ? ( + + + + ) : ( + allJobs.map((job) => ( + + + + + + + + + + )) + )} + +
LaneRepoStatusPhase%DurationStarted
+ Waiting for analyze / embed jobs on the connected server… +
+ {job.lane} + {job.repoName || '—'} + {job.status} + + {job.progress.phase} + {job.progress.percent}% + {formatDuration(job.durationMs)} + + {formatClock(job.startedAt)} +
+
+
+
+
+ ); +}; diff --git a/gitnexus-web/src/components/RepoAnalyzer.tsx b/gitnexus-web/src/components/RepoAnalyzer.tsx index 9d44cdb91..f4bf1d609 100644 --- a/gitnexus-web/src/components/RepoAnalyzer.tsx +++ b/gitnexus-web/src/components/RepoAnalyzer.tsx @@ -3,10 +3,11 @@ * * Two input modes: * - "github" → GitHub URL (https://github.com/owner/repo) - * - "local" → Select a local folder via the browser's native directory picker + * - "local" → Select a local folder via the browser's native directory picker, + * or drop one onto the upload control */ -import { useState, useRef, useEffect, useId } from 'react'; +import { useState, useRef, useEffect, useId, type DragEvent as ReactDragEvent } from 'react'; import { Github, Gitlab, @@ -22,12 +23,20 @@ import { import { startAnalyze, cancelAnalyze, + fetchRepos, streamAnalyzeProgress, uploadFolder, type JobProgress, } from '../services/backend-client'; import { AnalyzeProgress } from './AnalyzeProgress'; -import { filterRepoFiles } from '@/lib/upload-filter'; +import { filterRepoFiles, type FilterResult } from '@/lib/upload-filter'; +import { + collectDropEntries, + isFolderDropSupported, + readDroppedFolder, + DropRejection, + type DropRejectionReason, +} from '@/lib/folder-drop'; import { useTranslation } from 'react-i18next'; import { formatBackendError } from '../i18n/error-messages'; @@ -55,6 +64,23 @@ function isValidAzureUrl(value: string): boolean { return AZURE_RE.test(value.trim()); } +/** i18n key (under onboarding:repoAnalyzer.upload) for a refused folder drop. */ +function dropRejectionKey(reason: DropRejectionReason): string { + switch (reason) { + case 'unsupported': + return 'dropUnsupported'; + case 'tooManyFiles': + return 'dropTooManyFiles'; + case 'notSingleFolder': + return 'dropSingleFolder'; + } +} + +/** True for a drag that carries files or folders from the OS, not text or links. */ +function isFileDrag(e: ReactDragEvent): boolean { + return Array.from(e.dataTransfer.types).includes('Files'); +} + // ── Mode tabs ──────────────────────────────────────────────────────────────── function ModeTabs({ mode, onChange }: { mode: InputMode; onChange: (m: InputMode) => void }) { @@ -165,6 +191,7 @@ function DoneState({ repoName }: { repoName: string }) { className="flex animate-fade-in flex-col items-center gap-3 py-4" role="status" aria-live="polite" + data-testid="analyze-done" >
@@ -185,9 +212,13 @@ type InternalPhase = 'input' | 'starting' | 'analyzing' | 'done' | 'error'; export interface RepoAnalyzerProps { variant: 'onboarding' | 'sheet'; /** - * Receives the repo IDENTITY to reconnect with — the analyzed path when the - * server provides one (`repoPath` on the SSE complete event), otherwise the - * display name. Never rendered; the done screen shows the display name. + * Receives the repo identity used to reconnect. Prefers `repoPath` when an + * older server still sends it on the SSE complete event. Current servers omit + * it (an unauthenticated ops-listed job id must not leak a filesystem path) + * and send an opaque `repoId`, which is resolved to the matching + * `GET /api/repos` entry's `path`. Falls back to the display name (`repoName`, + * then the input basename, then the i18n default) when neither resolves. + * Never rendered; the done screen shows the display name. */ onComplete: (repoIdentity: string) => void; onCancel?: () => void; @@ -198,9 +229,16 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp const inputId = useId(); const [mode, setMode] = useState('github'); const [uploading, setUploading] = useState(false); - const [uploadSummary, setUploadSummary] = useState<{ count: number; dropped: number } | null>( - null, - ); + // Files found so far while walking a dropped folder; null when not reading. + const [readingCount, setReadingCount] = useState(null); + const [dragActive, setDragActive] = useState(false); + // `skippedDirs` is set only for a dropped folder: directories the walk left + // out without enumerating them (a directory count, unlike `dropped`). + const [uploadSummary, setUploadSummary] = useState<{ + count: number; + dropped: number; + skippedDirs?: number; + } | null>(null); const [githubUrl, setGithubUrl] = useState(''); const [githubToken, setGithubToken] = useState(''); const [gitlabUrl, setGitlabUrl] = useState(''); @@ -223,10 +261,19 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp // arriving after a mode switch / cancel / unmount can never drive state. const requestControllerRef = useRef(null); const completeTimerRef = useRef | null>(null); + // The completion identity may still be resolving (`/api/repos`) when the + // dwell timer fires; clearing the timer cannot cancel that continuation. + const unmountedRef = useRef(false); const folderInputRef = useRef(null); + // dragenter/dragleave fire for every child boundary crossed; count them so + // the highlight does not flicker while the cursor moves over the button. + const dragDepthRef = useRef(0); useEffect(() => { + // Reset on (re)mount: StrictMode runs cleanup then setup again. + unmountedRef.current = false; return () => { + unmountedRef.current = true; sseControllerRef.current?.abort(); requestControllerRef.current?.abort(); if (completeTimerRef.current) clearTimeout(completeTimerRef.current); @@ -277,6 +324,10 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp setValidationError(null); setUploadSummary(null); setUploading(false); + // The aborted controller also stops a folder walk that is still running. + setReadingCount(null); + setDragActive(false); + dragDepthRef.current = 0; // An aborted request no longer resolves to move `phase` off 'starting'; // reset so the new mode's form is immediately usable (also clears a stale // 'error' phase). Only reachable while showInput is true. @@ -287,14 +338,17 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp // exposes an absolute path, so the old typed-path/browse approach couldn't // work — see handleFolderUpload). A typed server path is also still accepted. + // A folder walk in progress (readingCount) blocks Analyze as well: starting a + // request would abort the walk through renewRequestController. const canSubmit = - mode === 'github' + readingCount === null && + (mode === 'github' ? isValidGithubUrl(githubUrl) && (phase === 'input' || phase === 'error') : mode === 'gitlab' ? isValidGitlabUrl(gitlabUrl) && (phase === 'input' || phase === 'error') : mode === 'azure' ? isValidAzureUrl(azureUrl) && (phase === 'input' || phase === 'error') - : localPath.trim().length > 1 && (phase === 'input' || phase === 'error'); + : localPath.trim().length > 1 && (phase === 'input' || phase === 'error')); const handleAnalyze = async () => { if (mode === 'github' && !isValidGithubUrl(githubUrl)) { @@ -367,24 +421,34 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp (p) => setProgress(p), (data) => { // Display vs identity split: the done screen renders the display name - // (never an absolute path), while onComplete receives the identity — - // the analyzed path when the server provides it, so the reconnect - // targets the exact repo even when basenames collide. Old servers omit - // repoPath and degrade to today's name behavior. + // (never an absolute path). Current servers omit repoPath on the + // unauthenticated SSE terminal frame and send an opaque repoId that + // selects the exact /api/repos entry (names are not unique). + // Older servers that still send repoPath keep collision-safe reconnect. const displayName = data.repoName ?? (fallbackNameSource ? fallbackNameSource.split(/[/\\]/).filter(Boolean).at(-1) : undefined) ?? t('onboarding:repoAnalyzer.defaultRepoName'); - const identity = data.repoPath ?? displayName; + const repoId = data.repoId; + const identity: Promise = data.repoPath + ? Promise.resolve(data.repoPath) + : repoId + ? fetchRepos().then( + (repos) => repos.find((r) => r.id === repoId)?.path ?? displayName, + () => displayName, + ) + : Promise.resolve(displayName); setCompletedRepoName(displayName); setGithubToken(''); setPhase('done'); sseControllerRef.current = null; completeTimerRef.current = setTimeout(() => { completeTimerRef.current = null; - onComplete(identity); + void identity.then((id) => { + if (!unmountedRef.current) onComplete(id); + }); }, 1200); }, (errMsg) => { @@ -398,20 +462,33 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp // Upload a browser-selected folder (webkitdirectory) and start analysis. The // upload endpoint returns a jobId, which then joins the normal SSE flow. const handleFolderUpload = async (fileList: FileList) => { - if (uploading || isLoading) return; // guard against a concurrent upload - const { files, manifest, droppedCount } = filterRepoFiles(fileList); + // guard against a concurrent upload or a folder walk still running + if (uploading || isLoading || readingCount !== null) return; + await startFolderUpload(filterRepoFiles(fileList), renewRequestController()); + }; + + // Shared tail of the picker and drop paths: `filtered` is the client-side + // filter result, `controller` owns the request, `skippedDirs` is set for a + // drop and counts the directories the walk left out before reading them + // (the picker enumerates everything and lets filterRepoFiles drop the files + // instead, so its count arrives inside `filtered.droppedCount`). + const startFolderUpload = async ( + filtered: FilterResult, + controller: AbortController, + skippedDirs?: number, + ) => { + const { files, manifest, droppedCount } = filtered; if (files.length === 0) { setValidationError(t('onboarding:repoAnalyzer.upload.empty')); return; } setValidationError(null); - setUploadSummary({ count: files.length, dropped: droppedCount }); + setUploadSummary({ count: files.length, dropped: droppedCount, skippedDirs }); setUploading(true); setPhase('starting'); // The selected folder's name (manifest entries are `/`) is a // sensible fallback if the server's complete event omits repoName. const folderName = manifest[0]?.split('/')[0] ?? null; - const controller = renewRequestController(); try { const { jobId } = await uploadFolder(files, manifest, controller.signal); if (controller.signal.aborted) { @@ -436,6 +513,76 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp } }; + // Drag and drop a folder onto the upload control. The entries have to be + // taken from dataTransfer synchronously inside the drop handler (browsers + // empty `items` once the handler yields). Only a single folder is accepted, + // mirroring the server's one-top-level-directory rule, and the walk runs + // under the same request controller as the upload so a mode switch or an + // unmount aborts both. + const isDropBlocked = () => uploading || phase === 'starting' || readingCount !== null; + + const handleDragEnter = (e: ReactDragEvent) => { + if (!isFileDrag(e)) return; + e.preventDefault(); + if (isDropBlocked()) return; + dragDepthRef.current += 1; + setDragActive(true); + }; + + const handleDragOver = (e: ReactDragEvent) => { + if (!isFileDrag(e)) return; + // Without preventDefault the drop never fires and the browser navigates + // to the dropped file instead, so it is called even while blocked. + e.preventDefault(); + e.dataTransfer.dropEffect = isDropBlocked() ? 'none' : 'copy'; + }; + + const handleDragLeave = () => { + // No type check here: some engines hand dragleave an empty `types` list, + // and a stray decrement is harmless because the depth is clamped at 0. + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); + if (dragDepthRef.current === 0) setDragActive(false); + }; + + const handleDrop = async (e: ReactDragEvent) => { + if (!isFileDrag(e)) return; + e.preventDefault(); + dragDepthRef.current = 0; + setDragActive(false); + if (isDropBlocked()) return; + const controller = renewRequestController(); + setValidationError(null); + setUploadSummary(null); + setReadingCount(0); + try { + const entries = collectDropEntries(e.dataTransfer); // sync, before any await + const { files, skipped, oversized } = await readDroppedFolder(entries, { + signal: controller.signal, + onProgress: setReadingCount, + }); + // Only the walk that still owns the request controller may clear the + // reading mutex. An aborted drop that settles after a later drop started + // must not steal the live walk's lock (readingCount === null is what + // unblocks Analyze and a second drop). + if (requestControllerRef.current === controller) setReadingCount(null); + if (controller.signal.aborted) return; + const filtered = filterRepoFiles(files); + await startFolderUpload( + { ...filtered, droppedCount: filtered.droppedCount + oversized }, + controller, + skipped, + ); + } catch (err) { + if (requestControllerRef.current === controller) setReadingCount(null); + if (controller.signal.aborted) return; + setValidationError( + err instanceof DropRejection + ? t(`onboarding:repoAnalyzer.upload.${dropRejectionKey(err.reason)}`, { max: err.max }) + : formatBackendError(err, t), + ); + } + }; + const handleCancel = async () => { sseControllerRef.current?.abort(); sseControllerRef.current = null; @@ -453,6 +600,7 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp setPhase('input'); setProgress({ phase: 'queued', percent: 0, message: t('common:analyzePhases.queued') }); setUploading(false); + setReadingCount(null); setUploadSummary(null); }; @@ -658,9 +806,19 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
)} - {/* Local folder input */} + {/* Local folder input. The whole panel is the drop target for a folder: + a drop that lands on the path input or the label is caught too, and + the browser's default (navigating to the dropped file) never fires. */} {showInput && mode === 'local' && ( -
+