Merge branch 'main' into fix/cursor-npx-fallback-host-budget

This commit is contained in:
Gergő Magyar 2026-09-29 08:29:12 +01:00 • committed by GitHub
commit 2596d89564
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
430 changed files with 49046 additions and 2667 deletions

View file

@ -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."
}
]
}

View file

@ -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

View file

@ -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

View file

@ -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"

View file

@ -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

1
.gitignore vendored
View file

@ -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

28
.vercelignore Normal file
View file

@ -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

View file

@ -1,7 +1,7 @@
<!-- version: 1.15.0 -->
<!-- Last updated: 2026-09-07 -->
<!-- version: 1.17.0 -->
<!-- Last updated: 2026-09-24 -->
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 (`<GITNEXUS_HOME>/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 `<repo>/.gitnexus/`. `GITNEXUS_STORAGE_PATH` selects one complete external index directory and wins over `GITNEXUS_STORAGE_ROOT`, which creates an isolated `<repo-basename>-<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 `<repo>/.gitnexus/`. `GITNEXUS_STORAGE_PATH` selects one complete external index directory and wins over `GITNEXUS_STORAGE_ROOT`, which creates an isolated `<repo-basename>-<12-hex>/` slot per repository. Linked worktrees share one store under `<GITNEXUS_HOME>/stores/<key>/` (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`.

View file

@ -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/<key>/ # Shared sibling index store (see below)
├── caches/ # parse cache + durable ParsedFile store
├── commits/<commit>-<featureKey>/ # one immutable graph per commit + settings
└── checkouts/<slot>/ # one checkout's metadata, membership, and
# private graph when it has local edits
```
The flat `<repo>/.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/<key>/` 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

View file

@ -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

View file

@ -1,4 +1,4 @@
# GitNexus (Akon Labs)
# GitNexus
<div align="center">
@ -34,7 +34,6 @@
<p>
💬 <a href="https://discord.gg/MgJrmsqr62">Discord</a> ·
🌐 <a href="https://gitnexus.vercel.app">Web UI</a> ·
🏢 <a href="https://akonlabs.com">Enterprise (SaaS & self-hosted)</a>
</p>
</div>
@ -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.
<a id="fn-antigravity-hooks"></a>
@ -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@<marketplace>`, 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 <n> # 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 <n> # Process-detection process cap (replaces dynamic max(20, round(symbols/10)))
gitnexus analyze --max-entry-point-candidates <n> # 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.
</details>
<details>
<summary><strong>Environment variables</strong></summary>
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 <kb>`. | 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 <n>` / `.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 <n>`. 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 <n>`. 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 <n>`. 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 <seconds>` × 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 (`<repo>/.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 `<repo-basename>-<canonical-path-hash>/` 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 <bytes>`. `-1` keeps LadybugDB's stock threshold (~16 MiB). Larger thresholds reduce checkpoint frequency but increase the WAL size at rotation time — choose a smaller value on disk-constrained environments. | You need a larger or smaller WAL auto-checkpoint threshold for your analyze workload. |
@ -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 `<repo-basename>-<canonical-path-hash>/` 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/<repo>/` 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 <name-or-path>` 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.
<details>
<summary><strong>Architecture diagram</strong></summary>

View file

@ -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.

14
eval/uv.lock generated
View file

@ -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" },

View file

@ -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 <abs path to .../node_modules/gitnexus/dist/cli/index.js> 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/<pid>/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/<pid>/fd.
if (code === 'ENOTDIR') {
// The fd path is not a directory at all, so this is structurally not a
// real /proc/<pid>/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,
};

View file

@ -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
// `<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.
}
}

View file

@ -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-<hash>` 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 <slot>/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 `<commit>-<featureKey>` 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,
};
}

View file

@ -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/`. |

View file

@ -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
// `<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.
}
}

View file

@ -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-<hash>` 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 <slot>/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 `<commit>-<featureKey>` 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,
};
}

View file

@ -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"]
}

View file

@ -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 <path>`, 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 };

View file

@ -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/<pid>/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/<pid>/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/<pid>/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 <budget> 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 <file>.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/<pid>/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 <abs path to .../node_modules/gitnexus/dist/cli/index.js> 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/<pid>/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/<pid>/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/<pid>/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/<pid>/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,
};

View file

@ -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
// `<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,
};

View file

@ -0,0 +1,14 @@
{
"PostToolUse": [
{
"matcher": "Grep|Glob|Execute",
"hooks": [
{
"type": "command",
"command": "node \"${DROID_PLUGIN_ROOT}/hooks/gitnexus-hook.js\"",
"timeout": 10
}
]
}
]
}

View file

@ -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-<hash>` 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 <slot>/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 `<commit>-<featureKey>` 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,
};

View file

@ -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

View file

@ -0,0 +1,8 @@
{
"mcpServers": {
"gitnexus": {
"command": "npx",
"args": ["-y", "gitnexus@1.6.12", "mcp"]
}
}
}

View file

@ -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,

View file

@ -29,7 +29,7 @@ const RUBY_EXTENSIONLESS_FILES = new Set([
const EXTENSION_MAP: Record<SupportedLanguages, readonly string[]> = {
[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<string, string> = {
*/
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];

View file

@ -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;
}

View file

@ -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

View file

@ -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';

View file

@ -1,2 +1,3 @@
.vercel
.env*.local
.cursor/

View file

@ -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<string, unknown> = {};
page.on('request', (req) => {
if (req.method() === 'POST' && req.url().endsWith('/api/analyze')) {
posted = (req.postDataJSON() as Record<string, unknown>) ?? {};
}
});
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<string, unknown> = {};
page.on('request', (req) => {
if (req.method() === 'POST' && req.url().endsWith('/api/analyze')) {
posted = (req.postDataJSON() as Record<string, unknown>) ?? {};
}
});
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();
});
});

View file

@ -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<number> {
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<LiveBackend> {
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<void> {
if (!backend) return;
if (backend.child.exitCode === null && backend.child.signalCode === null) {
const exited = new Promise<void>((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<typeof setTimeout> | undefined;
try {
const exitedInTime = await Promise.race([
exited.then(() => true),
new Promise<boolean>((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<string, unknown>,
deadline = Infinity,
): Promise<AnalyzePostResult> {
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<void> {
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<string, unknown>,
timeoutMs = 60_000,
): Promise<AnalyzePostResult> {
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<Record<string, unknown>> {
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<string, unknown>;
}
/** Point the app at this spec's live server before the first navigation. */
export async function bindBackend(page: Page, backendUrl: string): Promise<void> {
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<string> {
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<void> {
// 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<void> {
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<string> {
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<void> {
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<void> {
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\\/);
}

View file

@ -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]);
});
});

View file

@ -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<void> {
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}`);

File diff suppressed because it is too large Load diff

View file

@ -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"
},

View file

@ -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',

View file

@ -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 <ExecutionDashboard />;
}
return (
<AppStateProvider>
<AppContent />

View file

@ -29,7 +29,7 @@ export const AnalyzeProgress = ({ progress, onCancel }: AnalyzeProgressProps) =>
const pct = Math.max(0, Math.min(100, progress.percent));
return (
<div className="space-y-4">
<div className="space-y-4" data-testid="analyze-progress">
{/* Phase label + elapsed */}
<div className="flex items-center justify-between text-sm">
<span className="font-medium text-text-secondary">{label}</span>

View file

@ -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<T, R>(
items: T[],
concurrency: number,
worker: (item: T) => Promise<R>,
): Promise<R[]> {
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<Map<string, CitationSnippet>>(
() => new Map(),
);
// Ids already requested (loaded or failed) — a failed read is not retried.
const requestedSnippetIds = useRef<Set<string>>(new Set());
const snippetRepoKey = currentRepo || projectName || undefined;
const snippetRepoKeyRef = useRef<string | undefined>(undefined);
// Live citation ids at apply time — in-flight batches must not resurrect
// excerpts after clearAICodeReferences() mints a fresh list.
const liveCitationIdsRef = useRef<Set<string>>(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<string, CitationSnippet>();
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',

View file

@ -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<OpsJobView['status'], string> = {
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 (
<div className={`rounded-xl border bg-surface/80 px-4 py-3 ${toneClass}`}>
<div className="text-[11px] tracking-wide text-text-muted uppercase">{label}</div>
<div className="mt-1 font-mono text-2xl font-semibold tabular-nums">{value}</div>
{hint ? <div className="mt-1 text-xs text-text-secondary">{hint}</div> : null}
</div>
);
};
const JobRow = ({ job }: { job: OpsJobView }) => {
const pct = Math.max(0, Math.min(100, job.progress.percent));
return (
<div
className="rounded-lg border border-border-subtle bg-elevated/60 px-3 py-2.5"
data-testid="ops-job"
data-job-id={job.id}
>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-sm font-medium text-text-primary">
{job.repoName || job.id.slice(0, 8)}
</div>
<div className="mt-0.5 font-mono text-[11px] text-text-muted">
{job.lane} · {job.id.slice(0, 8)}
{job.branch ? ` · ${job.branch}` : ''}
{job.retryCount > 0 ? ` · retry ${job.retryCount}` : ''}
</div>
</div>
<div className="flex items-center gap-3 text-right">
<span
className={`font-mono text-xs font-semibold uppercase ${STATUS_COLORS[job.status]}`}
>
{job.status}
</span>
<span className="font-mono text-xs text-text-muted">
{formatDuration(job.durationMs)}
</span>
</div>
</div>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-void">
<div
className="h-full rounded-full bg-accent transition-all duration-500"
style={{ width: `${pct}%` }}
/>
</div>
<div className="mt-1.5 flex justify-between gap-2 text-[11px] text-text-secondary">
<span className="truncate">{job.progress.message || job.progress.phase}</span>
<span className="shrink-0 font-mono">{pct}%</span>
</div>
{job.error ? <div className="mt-1 truncate text-[11px] text-red-400">{job.error}</div> : null}
</div>
);
};
const LanePanel = ({
title,
jobs,
metrics,
}: {
title: string;
jobs: OpsJobView[];
metrics: OpsSnapshot['analyze']['metrics'];
}) => (
<section className="flex min-h-0 flex-1 flex-col rounded-2xl border border-border-default bg-deep/80">
<header className="flex items-center justify-between border-b border-border-subtle px-4 py-3">
<div>
<h2 className="text-sm font-semibold text-text-primary">{title}</h2>
<p className="text-xs text-text-muted">
{metrics.active} active · {metrics.queued} queued · {metrics.complete} done ·{' '}
{metrics.failed} failed
</p>
</div>
<div className="text-right font-mono text-[11px] text-text-muted">
<div>avg {metrics.avgDurationMs != null ? formatDuration(metrics.avgDurationMs) : '—'}</div>
<div>max {metrics.maxDurationMs != null ? formatDuration(metrics.maxDurationMs) : '—'}</div>
</div>
</header>
<div className="flex-1 space-y-2 overflow-y-auto p-3">
{jobs.length === 0 ? (
<div className="rounded-lg border border-dashed border-border-subtle px-3 py-8 text-center text-sm text-text-muted">
No jobs in this lane yet
</div>
) : (
jobs.map((job) => <JobRow key={`${job.lane}-${job.id}`} job={job} />)
)}
</div>
</section>
);
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<string | null>(null);
const [connectNonce, setConnectNonce] = useState(0);
const [snapshot, setSnapshot] = useState<OpsSnapshot | null>(null);
const [live, setLive] = useState(false);
const [streamMode, setStreamMode] = useState<'sse' | 'poll' | 'offline'>('offline');
const [error, setError] = useState<string | null>(null);
const [lastTick, setLastTick] = useState<number | null>(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<typeof setInterval> | 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 (
<div
className="flex min-h-screen flex-col bg-void text-text-primary"
data-testid="ops-dashboard"
>
<header className="border-b border-border-subtle bg-deep/90 px-4 py-3 backdrop-blur">
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-3">
<div>
<div className="text-xs tracking-[0.2em] text-accent uppercase">GitNexus</div>
<h1 className="text-lg font-semibold">Execution Ops</h1>
</div>
<div className="flex flex-wrap items-center gap-2">
<span
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium ${
live
? 'border-emerald-500/40 bg-emerald-500/10 text-emerald-300'
: 'border-red-500/40 bg-red-500/10 text-red-300'
}`}
>
<span
className={`h-1.5 w-1.5 rounded-full ${live ? 'bg-emerald-400' : 'bg-red-400'}`}
/>
{live ? 'live' : 'offline'} · {streamMode}
</span>
{snapshot ? (
<span className="font-mono text-[11px] text-text-muted">
up {formatDuration(snapshot.uptimeMs)} · tick{' '}
{lastTick ? formatClock(lastTick) : '—'}
</span>
) : null}
</div>
</div>
<form
className="mx-auto mt-3 flex max-w-7xl gap-2"
onSubmit={(e) => {
e.preventDefault();
applyBackend(backendInput);
}}
>
<input
value={backendInput}
onChange={(e) => 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}
/>
<button
type="submit"
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent-dim"
>
Connect
</button>
</form>
{error ? <p className="mx-auto mt-2 max-w-7xl text-sm text-red-400">{error}</p> : null}
</header>
<main className="mx-auto flex w-full max-w-7xl flex-1 flex-col gap-4 p-4">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard
label="Active jobs"
value={snapshot?.totals.active ?? 0}
tone={snapshot && snapshot.totals.active > 0 ? 'warn' : 'default'}
/>
<MetricCard label="Completed" value={snapshot?.totals.complete ?? 0} tone="ok" />
<MetricCard
label="Failed"
value={snapshot?.totals.failed ?? 0}
tone={snapshot && snapshot.totals.failed > 0 ? 'bad' : 'default'}
/>
<MetricCard
label="Server"
value={snapshot?.server.version ?? '—'}
hint={
snapshot
? `${snapshot.server.launchContext} · ${snapshot.server.nodeVersion}`
: undefined
}
/>
</div>
<div className="grid min-h-[28rem] flex-1 gap-4 lg:grid-cols-2">
<LanePanel
title="Analyze lane"
jobs={snapshot?.analyze.jobs ?? []}
metrics={snapshot?.analyze.metrics ?? EMPTY_LANE_METRICS}
/>
<LanePanel
title="Embed lane"
jobs={snapshot?.embed.jobs ?? []}
metrics={snapshot?.embed.metrics ?? EMPTY_LANE_METRICS}
/>
</div>
<section className="rounded-2xl border border-border-default bg-deep/80">
<header className="border-b border-border-subtle px-4 py-3">
<h2 className="text-sm font-semibold">Recent activity</h2>
<p className="text-xs text-text-muted">Both lanes, newest first</p>
</header>
<div className="overflow-x-auto">
<table className="w-full min-w-[40rem] text-left text-sm">
<thead className="text-[11px] tracking-wide text-text-muted uppercase">
<tr className="border-b border-border-subtle">
<th className="px-4 py-2 font-medium">Lane</th>
<th className="px-4 py-2 font-medium">Repo</th>
<th className="px-4 py-2 font-medium">Status</th>
<th className="px-4 py-2 font-medium">Phase</th>
<th className="px-4 py-2 font-medium">%</th>
<th className="px-4 py-2 font-medium">Duration</th>
<th className="px-4 py-2 font-medium">Started</th>
</tr>
</thead>
<tbody>
{allJobs.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-text-muted">
Waiting for analyze / embed jobs on the connected server…
</td>
</tr>
) : (
allJobs.map((job) => (
<tr key={`${job.lane}-${job.id}`} className="border-b border-border-subtle/60">
<td className="px-4 py-2 font-mono text-xs text-text-secondary">
{job.lane}
</td>
<td className="max-w-[14rem] truncate px-4 py-2">{job.repoName || '—'}</td>
<td
className={`px-4 py-2 font-mono text-xs uppercase ${STATUS_COLORS[job.status]}`}
>
{job.status}
</td>
<td className="max-w-[16rem] truncate px-4 py-2 text-text-secondary">
{job.progress.phase}
</td>
<td className="px-4 py-2 font-mono text-xs">{job.progress.percent}%</td>
<td className="px-4 py-2 font-mono text-xs">
{formatDuration(job.durationMs)}
</td>
<td className="px-4 py-2 font-mono text-xs text-text-muted">
{formatClock(job.startedAt)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</section>
</main>
</div>
);
};

View file

@ -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"
>
<div className="flex h-12 w-12 items-center justify-center rounded-xl border border-emerald-500/30 bg-emerald-500/15 shadow-[0_0_20px_rgba(16,185,129,0.15)]">
<Check className="h-6 w-6 text-emerald-400" />
@ -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<InputMode>('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<number | null>(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<AbortController | null>(null);
const completeTimerRef = useRef<ReturnType<typeof setTimeout> | 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<HTMLInputElement>(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<string> = 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 `<folder>/<rest>`) 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<HTMLDivElement>) => {
if (!isFileDrag(e)) return;
e.preventDefault();
if (isDropBlocked()) return;
dragDepthRef.current += 1;
setDragActive(true);
};
const handleDragOver = (e: ReactDragEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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
</div>
)}
{/* 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' && (
<div className="space-y-2">
<div
className="space-y-2"
data-testid="folder-drop-zone"
data-drag-active={dragActive || undefined}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<label
htmlFor={`${inputId}-local`}
className="block text-xs font-medium tracking-wider text-text-secondary uppercase"
@ -693,6 +851,7 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
}}
disabled={isLoading}
placeholder={isWindows ? 'C:\\Users\\you\\project' : '/home/you/project'}
data-testid="local-path-input"
autoComplete="off"
spellCheck={false}
className="flex-1 border-none bg-transparent font-mono text-sm text-text-primary outline-none placeholder:text-text-muted disabled:opacity-50"
@ -702,7 +861,9 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
)}
</div>
{/* Upload a folder from your computer — no server path or mount needed.
The browser can't expose an absolute path, so we upload the files. */}
The browser can't expose an absolute path, so we upload the files.
Dropping a folder onto this panel walks it with webkitGetAsEntry and
feeds the same upload path. */}
<input
ref={folderInputRef}
type="file"
@ -722,12 +883,35 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
type="button"
data-testid="upload-folder"
onClick={() => folderInputRef.current?.click()}
disabled={isLoading}
className="flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg border border-border-subtle bg-elevated px-3 py-2 text-xs font-medium text-text-secondary transition-all duration-150 hover:bg-hover hover:text-text-primary disabled:opacity-50"
disabled={isLoading || readingCount !== null}
className={`flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg border bg-elevated px-3 py-2 text-xs font-medium transition-all duration-150 hover:bg-hover hover:text-text-primary disabled:opacity-50 ${
dragActive
? 'border-accent/50 text-text-primary shadow-[0_0_0_3px_rgba(124,58,237,0.08)]'
: 'border-border-subtle text-text-secondary'
}`}
>
<FolderOpen className="h-3.5 w-3.5" />
{t('onboarding:repoAnalyzer.upload.button')}
</button>
{isFolderDropSupported() && !isDropBlocked() && (
<p className="text-center text-[11px] text-text-muted" data-testid="drop-hint">
{t(
dragActive
? 'onboarding:repoAnalyzer.upload.dropActive'
: 'onboarding:repoAnalyzer.upload.dropHint',
)}
</p>
)}
{readingCount !== null && (
<div role="status" aria-busy="true" data-testid="drop-reading" className="space-y-1">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-elevated">
<div className="h-full w-1/3 animate-pulse rounded-full bg-accent" />
</div>
<p className="text-xs text-text-muted">
{t('onboarding:repoAnalyzer.upload.reading', { fileCount: readingCount })}
</p>
</div>
)}
{uploading && (
<div role="status" aria-busy="true" data-testid="upload-progress" className="space-y-1">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-elevated">
@ -740,10 +924,16 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
)}
{uploadSummary && !uploading && phase !== 'error' && (
<p className="text-xs text-text-muted" data-testid="upload-summary">
{t('onboarding:repoAnalyzer.upload.selected', {
fileCount: uploadSummary.count,
dropped: uploadSummary.dropped,
})}
{uploadSummary.skippedDirs === undefined
? t('onboarding:repoAnalyzer.upload.selected', {
fileCount: uploadSummary.count,
dropped: uploadSummary.dropped,
})
: t('onboarding:repoAnalyzer.upload.selectedDrop', {
fileCount: uploadSummary.count,
dropped: uploadSummary.dropped,
skippedDirs: uploadSummary.skippedDirs,
})}
</p>
)}
</div>

View file

@ -25,6 +25,7 @@ export const RightPanel = () => {
graph,
graphMode,
addCodeReference,
resolveFilePath,
// LLM / chat state
chatMessages,
isChatLoading,
@ -46,10 +47,6 @@ export const RightPanel = () => {
isChatLoading,
);
const resolveFilePathForUI = useCallback((_requestedPath: string): string | null => {
return null;
}, []);
const findFileNodeIdForUI = useCallback(
(filePath: string): string | undefined => {
if (!graph) return undefined;
@ -81,7 +78,11 @@ export const RightPanel = () => {
endLine1 = parseInt(lineMatch[3] || lineMatch[2], 10);
}
const resolvedPath = resolveFilePathForUI(rawPath);
// Malformed citations like "[[ :10]]" leave an empty path; refuse rather
// than letting the suffix matcher return the first indexed file.
if (!rawPath) return;
const resolvedPath = resolveFilePath(rawPath);
if (!resolvedPath) return;
const nodeId = findFileNodeIdForUI(resolvedPath);
@ -100,7 +101,7 @@ export const RightPanel = () => {
source: 'ai',
});
},
[addCodeReference, findFileNodeIdForUI, resolveFilePathForUI],
[addCodeReference, findFileNodeIdForUI, resolveFilePath],
);
// Handler for node grounding: [[Class:View]], [[Function:trigger]], etc.
@ -134,12 +135,14 @@ export const RightPanel = () => {
// 2. Add to Code Panel (if node has file/line info)
if (node.properties.filePath) {
const resolvedPath = resolveFilePathForUI(node.properties.filePath);
const resolvedPath = resolveFilePath(node.properties.filePath);
if (resolvedPath) {
addCodeReference({
filePath: resolvedPath,
startLine: node.properties.startLine ? node.properties.startLine - 1 : undefined,
endLine: node.properties.endLine ? node.properties.endLine - 1 : undefined,
startLine:
typeof node.properties.startLine === 'number' ? node.properties.startLine : undefined,
endLine:
typeof node.properties.endLine === 'number' ? node.properties.endLine : undefined,
nodeId: node.id,
label: node.label,
name: node.properties.name,
@ -148,7 +151,7 @@ export const RightPanel = () => {
}
}
},
[graph, resolveFilePathForUI, addCodeReference],
[graph, resolveFilePath, addCodeReference],
);
const handleLinkClick = useCallback(

View file

@ -0,0 +1,30 @@
/**
* GraphNode startLine/endLine are 0-based (#2377 / line-base.ts).
* `/api/file` ranges are also 0-indexed. Convert to 1-based only for display.
*/
export function selectedNodeFileRange(
startLine: number,
endLine: number | undefined,
contextLines: number,
): { startLine: number; endLine: number } {
return {
startLine: Math.max(0, startLine - contextLines),
endLine: (endLine ?? startLine) + contextLines,
};
}
/** 1-based gutter / `data-line-number` for a 0-based GraphNode line. */
export function selectedNodeDisplayLine(storedStartLine: number): number {
return storedStartLine + 1;
}
export function selectedNodeLineHighlighted(
displayedLineNumber: number,
storedStart: number,
storedEnd: number | undefined,
): boolean {
const displayStart = selectedNodeDisplayLine(storedStart);
const displayEnd = selectedNodeDisplayLine(storedEnd ?? storedStart);
return displayedLineNumber >= displayStart && displayedLineNumber <= displayEnd;
}

View file

@ -932,18 +932,48 @@ MATCH (n:Function {id: emb.nodeId}) RETURN n`,
return `⚠️ AMBIGUOUS TARGET: Multiple files named "${target}" found:\n\n${allPaths.map((p: string, i: number) => `${i + 1}. ${p}`).join('\n')}\n\nPlease specify which file you mean by using a more specific path, e.g.:\n- impact("${allPaths[0].split('/').slice(-3).join('/')}")\n- impact("${allPaths[1]?.split('/').slice(-3).join('/') || allPaths[0]}")`;
}
// If target contains a path, try to find matching file
// If target contains a path, pick the File node for that path. The
// lookup above matched on `filePath CONTAINS target`, so every symbol
// DEFINED in the file (functions, classes, ...) is also in the result
// set and shares the same filePath — a plain "first row" pick could
// silently analyze one arbitrary symbol while reporting a file impact.
let targetNode = targetResults[0];
if (target.includes('/') && targetResults.length > 1) {
const exactMatch = targetResults.find((r: any) => {
const path = Array.isArray(r) ? r[2] : r.filePath;
return path && path.toLowerCase().includes(target.toLowerCase());
});
if (exactMatch) {
targetNode = exactMatch;
const rowType = (r: any) => (Array.isArray(r) ? r[1] : r.nodeType);
const rowPath = (r: any): string | undefined => (Array.isArray(r) ? r[2] : r.filePath);
const targetLower = target.toLowerCase();
const fileRows = targetResults.filter((r: any) => rowType(r) === 'File');
// Exact path first; suffix match only when unique among File rows.
const exactFile = fileRows.find((r: any) => rowPath(r)?.toLowerCase() === targetLower);
const suffixFiles = exactFile
? []
: fileRows.filter((r: any) => rowPath(r)?.toLowerCase()?.endsWith(`/${targetLower}`));
const fileMatch = exactFile ?? (suffixFiles.length === 1 ? suffixFiles[0] : undefined);
if (suffixFiles.length > 1) {
const paths = suffixFiles.map((r: any) => rowPath(r)).filter(Boolean) as string[];
return `⚠️ AMBIGUOUS TARGET: Multiple files match "${target}":\n\n${paths.map((p, i) => `${i + 1}. ${p}`).join('\n')}\n\nPlease use a more specific path.`;
}
// LIMIT 10 is a cap. A single suffix-matching File in that page can
// still hide another File past the limit — only exact path is safe.
if (!exactFile && fileMatch && targetResults.length >= 10) {
const distinctPaths = [...new Set<string>(allPaths)];
return `⚠️ AMBIGUOUS TARGET: Could not uniquely match "${target}". Found:\n\n${distinctPaths.map((p: string, i: number) => `${i + 1}. ${p}`).join('\n')}\n\nPlease use a more specific path.`;
}
if (fileMatch) {
targetNode = fileMatch;
} else {
// Still ambiguous even with path
return `⚠️ AMBIGUOUS TARGET: Could not uniquely match "${target}". Found:\n\n${allPaths.map((p: string, i: number) => `${i + 1}. ${p}`).join('\n')}\n\nPlease use a more specific path.`;
const distinctPaths = [...new Set<string>(allPaths)];
const uniquePath = distinctPaths.length === 1 ? distinctPaths[0] : undefined;
const uniqueLower = uniquePath?.toLowerCase();
const uniqueIsBounded =
uniqueLower === targetLower || uniqueLower?.endsWith(`/${targetLower}`) === true;
// LIMIT 10 is a cap, not a complete result set. One CONTAINS hit
// that is only a filename substring (src/mylib/foo.ts vs lib/foo.ts)
// must not be rebound as a unique File.
if (!uniqueIsBounded || targetResults.length >= 10 || !uniquePath) {
return `⚠️ AMBIGUOUS TARGET: Could not uniquely match "${target}". Found:\n\n${distinctPaths.map((p: string, i: number) => `${i + 1}. ${p}`).join('\n')}\n\nPlease use a more specific path.`;
}
targetNode = { id: `file:${uniquePath}`, nodeType: 'File', filePath: uniquePath };
}
}

View file

@ -45,7 +45,7 @@ import {
} from '../services/backend-client';
import { ERROR_RESET_DELAY_MS } from '../config/ui-constants';
import i18n from '../i18n';
import { normalizePath } from '../lib/path-resolution';
import { normalizePath, resolveUniqueIndexedPath } from '../lib/path-resolution';
import { FILE_REF_REGEX, NODE_REF_REGEX } from '../lib/grounding-patterns';
import { GraphStateProvider, useGraphState, type GraphMode } from './app-state/graph';
@ -69,6 +69,12 @@ export type ViewMode = 'onboarding' | 'loading' | 'exploring';
export type RightPanelTab = 'code' | 'chat';
export type EmbeddingStatus = 'idle' | 'loading' | 'embedding' | 'indexing' | 'ready' | 'error';
/**
* POST /api/embed 409 "Another job is already active for this repository"
* is the shared analyze/embed lock, not proof this repo is embedding.
*/
export const embeddingStatusForStartFailure = (_error: unknown): EmbeddingStatus => 'error';
export interface QueryResult {
rows: Record<string, any>[];
nodeIds: string[];
@ -231,6 +237,8 @@ interface AppState {
isCodePanelOpen: boolean;
setCodePanelOpen: (open: boolean) => void;
addCodeReference: (ref: Omit<CodeReference, 'id'>) => void;
/** Resolve a (possibly partial) file path cited by the agent to a real graph file path. */
resolveFilePath: (requestedPath: string) => string | null;
removeCodeReference: (id: string) => void;
clearAICodeReferences: () => void;
clearCodeReferences: () => void;
@ -418,16 +426,8 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
}, [graph]);
const resolveFilePath = useCallback(
(requestedPath: string): string | null => {
const normalized = normalizePath(requestedPath);
// Exact match
if (filePathIndex.has(normalized)) return filePathIndex.get(normalized)!;
// Suffix match (partial paths like "src/utils.ts")
for (const [key, value] of filePathIndex) {
if (key.endsWith(normalized)) return value;
}
return null;
},
(requestedPath: string): string | null =>
resolveUniqueIndexedPath(filePathIndex, requestedPath),
[filePathIndex],
);
@ -558,13 +558,11 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
},
);
});
} catch (error: any) {
if (error?.message?.includes('already in progress')) {
// Dedup — embeddings already running, just wait
setEmbeddingStatus('embedding');
return;
}
setEmbeddingStatus('error');
} catch (error: unknown) {
// Shared acquireRepoLock 409 is used for both analyze-held and embed-held
// locks. Never treat it as in-progress embedding — that hid an analyze
// occupant as a successful embed start.
setEmbeddingStatus(embeddingStatusForStartFailure(error));
throw error;
}
}, []);
@ -630,6 +628,14 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
useEffect(() => {
graphModeRef.current = graphMode;
}, [graphMode]);
// Same trick for the display name: initializeAgent has empty deps, so a plain
// `projectName` read would be trapped at the initial '' for callers that pass
// no override (settings-saved re-init, lazy init from sendChatMessage) and
// the system prompt would label the codebase the literal 'project'.
const projectNameRef = useRef(projectName);
useEffect(() => {
projectNameRef.current = projectName;
}, [projectName]);
const initializeAgent = useCallback(
async (
@ -649,7 +655,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
setAgentError(null);
try {
const effectiveProjectName = overrideProjectName || projectName || 'project';
const effectiveProjectName = overrideProjectName || projectNameRef.current || 'project';
// Sync repoRef so all agent backend calls target the correct repo.
// initializeAgent can be called from App.tsx (handleServerConnect) which
@ -911,10 +917,14 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
addCodeReference({
filePath: resolvedPath,
startLine: node.properties.startLine
? node.properties.startLine - 1
: undefined,
endLine: node.properties.endLine ? node.properties.endLine - 1 : undefined,
startLine:
typeof node.properties.startLine === 'number'
? node.properties.startLine
: undefined,
endLine:
typeof node.properties.endLine === 'number'
? node.properties.endLine
: undefined,
nodeId: node.id,
label: node.label,
name: node.properties.name,
@ -1126,6 +1136,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
clearAIToolHighlights,
graph,
embeddingStatus,
llmSettings.activeProvider,
],
);
@ -1588,6 +1599,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
isCodePanelOpen,
setCodePanelOpen,
addCodeReference,
resolveFilePath,
removeCodeReference,
clearAICodeReferences,
clearCodeReferences,

View file

@ -1458,7 +1458,11 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => {
layoutTimeoutRef.current = setTimeout(() => {
if (layoutRef.current) {
// stop() only flips the supervisor's running flag; kill() terminates
// the Web Worker and unbinds its graph listeners. Nulling the ref
// without kill() leaked one worker per completed layout.
layoutRef.current.stop();
layoutRef.current.kill();
layoutRef.current = null;
// Light noverlap cleanup

View file

@ -0,0 +1,283 @@
import { describe, expect, it, vi } from 'vitest';
import {
collectDropEntries,
readDroppedFolder,
DropRejection,
MAX_DROP_FILES,
MAX_PATH_SEGMENTS,
} from './folder-drop';
import { filterRepoFiles, MAX_FILE_BYTES } from './upload-filter';
// jsdom has no File and Directory Entries API, so the fixtures are plain
// objects with the members the walk uses: isFile/isDirectory/name,
// createReader().readEntries(ok, err) and file(ok, err).
function fileEntry(name: string, size = 1): FileSystemFileEntry {
return {
isFile: true,
isDirectory: false,
name,
fullPath: `/browser-root/${name}`,
file: (ok: (f: File) => void) => ok(new File([new Uint8Array(size)], name)),
} as unknown as FileSystemFileEntry;
}
type DirFixture = FileSystemDirectoryEntry & { reads: number; readerCreated: number };
/** Directory whose reader hands `children` out in `batchSize` chunks, then `[]`. */
function dirEntry(name: string, children: FileSystemEntry[], batchSize = 100): DirFixture {
const dir = {
isFile: false,
isDirectory: true,
name,
fullPath: `/browser-root/${name}`,
reads: 0,
readerCreated: 0,
createReader: () => {
dir.readerCreated++;
let i = 0;
return {
readEntries: (ok: (entries: FileSystemEntry[]) => void) => {
dir.reads++;
const batch = children.slice(i, i + batchSize);
i += batch.length;
ok(batch);
},
};
},
};
return dir as unknown as DirFixture;
}
/** Directory whose reader fails, like a subtree the browser may not read. */
function unreadableDir(name: string): FileSystemDirectoryEntry {
return {
isFile: false,
isDirectory: true,
name,
fullPath: `/browser-root/${name}`,
createReader: () => ({
readEntries: (_ok: unknown, err: (e: Error) => void) => err(new Error('EACCES')),
}),
} as unknown as FileSystemDirectoryEntry;
}
function dataTransfer(entries: (FileSystemEntry | null)[], supported = true): DataTransfer {
const items = entries.map((e) => (supported ? { webkitGetAsEntry: () => e } : {}));
return { items, types: ['Files'], files: [] } as unknown as DataTransfer;
}
const paths = (files: File[]) => files.map((f) => f.webkitRelativePath);
describe('collectDropEntries', () => {
it('returns the entries synchronously and skips null (non-file) items', () => {
const dir = dirEntry('repo', []);
const entries = collectDropEntries(dataTransfer([dir, null]));
expect(entries).toEqual([dir]);
});
it('returns nothing for an empty transfer', () => {
expect(collectDropEntries({ items: [] } as unknown as DataTransfer)).toEqual([]);
});
it('rejects a browser without webkitGetAsEntry', () => {
let caught: unknown;
try {
collectDropEntries(dataTransfer([dirEntry('repo', [])], false));
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(DropRejection);
expect((caught as DropRejection).reason).toBe('unsupported');
});
});
describe('readDroppedFolder', () => {
it('builds <folder>/<rest> paths with forward slashes from entry names', async () => {
const root = dirEntry('repo', [
fileEntry('README.md'),
dirEntry('src', [fileEntry('a.ts'), dirEntry('deep', [fileEntry('b.ts')])]),
]);
const { files, skipped } = await readDroppedFolder([root]);
expect(paths(files)).toEqual(['repo/README.md', 'repo/src/a.ts', 'repo/src/deep/b.ts']);
expect(files.every((f) => f instanceof File)).toBe(true);
expect(skipped).toBe(0);
});
it('loops readEntries until an empty batch', async () => {
const children = Array.from({ length: 250 }, (_, i) => fileEntry(`f${i}.ts`));
const root = dirEntry('repo', children, 100);
const { files } = await readDroppedFolder([root]);
expect(files).toHaveLength(250);
// two full 100-entry batches, one 50-entry batch, then the empty read
expect(root.reads).toBe(4);
});
it('prunes excluded directories before reading them and counts them', async () => {
const nodeModules = dirEntry('node_modules', [fileEntry('x.js')]);
const git = dirEntry('.git', [fileEntry('HEAD')]);
const root = dirEntry('repo', [nodeModules, git, dirEntry('src', [fileEntry('a.ts')])]);
const { files, skipped } = await readDroppedFolder([root]);
expect(paths(files)).toEqual(['repo/src/a.ts']);
expect(skipped).toBe(2);
expect(nodeModules.readerCreated).toBe(0);
expect(git.readerCreated).toBe(0);
});
it('rejects a loose file', async () => {
await expect(readDroppedFolder([fileEntry('a.ts')])).rejects.toMatchObject({
name: 'DropRejection',
reason: 'notSingleFolder',
});
});
it('rejects several roots', async () => {
await expect(
readDroppedFolder([dirEntry('a', [fileEntry('x')]), dirEntry('b', [fileEntry('y')])]),
).rejects.toMatchObject({ reason: 'notSingleFolder' });
});
it('rejects an empty drop', async () => {
await expect(readDroppedFolder([])).rejects.toMatchObject({ reason: 'notSingleFolder' });
});
it('stops past MAX_DROP_FILES', async () => {
const children = Array.from({ length: MAX_DROP_FILES + 1 }, (_, i) => fileEntry(`f${i}`));
await expect(readDroppedFolder([dirEntry('repo', children)])).rejects.toMatchObject({
reason: 'tooManyFiles',
max: MAX_DROP_FILES,
});
});
it('does not count oversized files toward MAX_DROP_FILES', async () => {
const oversized = fileEntry('big.bin', MAX_FILE_BYTES + 1);
const keepers = Array.from({ length: MAX_DROP_FILES }, (_, i) => fileEntry(`f${i}`));
const { files, oversized: oversizedCount } = await readDroppedFolder([
dirEntry('repo', [oversized, ...keepers]),
]);
expect(files).toHaveLength(MAX_DROP_FILES);
expect(paths(files)).not.toContain('repo/big.bin');
expect(oversizedCount).toBe(1);
});
it('skips oversized files before they reach filterRepoFiles', async () => {
const { files, skipped, oversized } = await readDroppedFolder([
dirEntry('repo', [fileEntry('a.ts'), fileEntry('big.bin', MAX_FILE_BYTES + 1)]),
]);
expect(paths(files)).toEqual(['repo/a.ts']);
expect(skipped).toBe(0);
expect(oversized).toBe(1);
});
it('aborts mid-walk and does not read later siblings', async () => {
const controller = new AbortController();
const later = dirEntry('later', [fileEntry('z.ts')]);
const root = dirEntry('repo', [
dirEntry(
'first',
Array.from({ length: 60 }, (_, i) => fileEntry(`f${i}`)),
),
later,
]);
const promise = readDroppedFolder([root], {
signal: controller.signal,
// progress is reported every 50 files; abort on the first report
onProgress: () => controller.abort(),
});
await expect(promise).rejects.toMatchObject({ name: 'AbortError' });
expect(later.readerCreated).toBe(0);
});
it('rejects when the abort lands during the last file read', async () => {
const controller = new AbortController();
const last = {
isFile: true,
isDirectory: false,
name: 'last.ts',
fullPath: '/browser-root/last.ts',
file: (ok: (f: File) => void) => {
controller.abort();
ok(new File(['x'], 'last.ts'));
},
} as unknown as FileSystemFileEntry;
const onProgress = vi.fn();
await expect(
readDroppedFolder([dirEntry('repo', [fileEntry('a.ts'), last])], {
signal: controller.signal,
onProgress,
}),
).rejects.toMatchObject({ name: 'AbortError' });
expect(onProgress).not.toHaveBeenCalled();
});
it('skips an unreadable subtree and keeps the rest', async () => {
const root = dirEntry('repo', [unreadableDir('locked'), dirEntry('src', [fileEntry('a.ts')])]);
const { files, skipped } = await readDroppedFolder([root]);
expect(paths(files)).toEqual(['repo/src/a.ts']);
expect(skipped).toBe(1);
});
it('skips a file whose file() call fails and keeps the rest', async () => {
const broken = {
isFile: true,
isDirectory: false,
name: 'dangling-symlink',
fullPath: '/browser-root/dangling-symlink',
file: (_ok: unknown, err: (e: Error) => void) =>
err(new DOMException('not found', 'NotFoundError')),
} as unknown as FileSystemFileEntry;
const root = dirEntry('repo', [fileEntry('a.ts'), broken, fileEntry('b.ts')]);
const { files, skipped } = await readDroppedFolder([root]);
expect(paths(files)).toEqual(['repo/a.ts', 'repo/b.ts']);
expect(skipped).toBe(1);
});
it('answers a folder named like build output without walking it', async () => {
const root = dirEntry('dist', [fileEntry('bundle.js')]);
const { files, skipped } = await readDroppedFolder([root]);
expect(files).toEqual([]);
expect(skipped).toBe(1);
expect(root.readerCreated).toBe(0);
});
it('skips files that would exceed the server path depth', async () => {
// A file directly inside a directory at depth d has d + 1 segments.
const build = (depth: number, leaf: string): FileSystemEntry => {
let entry: FileSystemEntry = fileEntry(leaf);
for (let i = depth; i >= 2; i--) entry = dirEntry(`d${i}`, [entry]);
return entry;
};
// root (1) + 62 dirs = 63 segments, leaf makes 64: allowed
const okChain = build(63, 'ok.ts');
// root (1) + 63 dirs = 64 segments, leaf would make 65: skipped
const tooDeep = build(64, 'deep.ts');
const root = dirEntry('repo', [okChain, tooDeep]);
const { files, skipped } = await readDroppedFolder([root]);
expect(skipped).toBe(1);
const rels = paths(files);
expect(rels).toHaveLength(1);
const [only] = rels;
expect(only?.endsWith('/ok.ts')).toBe(true);
expect(only?.split('/')).toHaveLength(MAX_PATH_SEGMENTS);
});
it('reports progress every 50 files and once at the end', async () => {
const onProgress = vi.fn();
const children = Array.from({ length: 120 }, (_, i) => fileEntry(`f${i}`));
await readDroppedFolder([dirEntry('repo', children)], { onProgress });
expect(onProgress.mock.calls.map((c) => c[0])).toEqual([50, 100, 120]);
});
it('produces files that filterRepoFiles turns into an aligned manifest', async () => {
const root = dirEntry('repo', [
fileEntry('a.ts'),
fileEntry('big.bin', MAX_FILE_BYTES + 1),
dirEntry('src', [fileEntry('b.ts')]),
]);
const { files } = await readDroppedFolder([root]);
const result = filterRepoFiles(files);
expect(result.manifest).toEqual(['repo/a.ts', 'repo/src/b.ts']);
expect(result.droppedCount).toBe(0);
result.files.forEach((f, i) => expect(f.webkitRelativePath).toBe(result.manifest[i]));
});
});

View file

@ -0,0 +1,214 @@
/**
* Read a folder that was dragged and dropped onto the analyzer.
*
* The `webkitdirectory` picker hands us a `FileList` whose entries carry
* `webkitRelativePath` (`<folder>/<rest>`). A drop hands us
* `DataTransferItem`s instead, and the `File`s behind them report an empty
* `webkitRelativePath`, so the tree has to be walked with the File and
* Directory Entries API (`webkitGetAsEntry`). This module does that walk and
* returns real `File` objects shaped exactly like the picker's, so the result
* goes through the unchanged `filterRepoFiles -> uploadFolder` path and the
* server sees the same manifest either way.
*
* Two platform constraints shape the API:
* - `dataTransfer.items` is readable only synchronously inside the drop
* handler, so `collectDropEntries` is sync and must run before the first
* `await`; `readDroppedFolder` does the async walk afterwards.
* - `FileSystemDirectoryReader.readEntries` returns batches (Chromium caps a
* batch at 100 entries) and signals the end with an empty array, so it is
* called in a loop.
*
* Directories on the shared exclusion list are pruned before they are read:
* `node_modules` is never enumerated, which is the point of dropping over
* picking on a large checkout (the picker enumerates everything first and
* only then filters).
*/
import { EXCLUDED_DIRS, MAX_FILE_BYTES } from './upload-filter';
/** Matches the server's `maxFiles` (DEFAULT_INGEST_LIMITS in upload-ingest.ts). */
export const MAX_DROP_FILES = 20000;
/**
* Matches the server's MAX_PATH_DEPTH: a manifest entry may have at most 64
* `/`-separated segments including the folder name and the file name.
*/
export const MAX_PATH_SEGMENTS = 64;
/** How many files are collected between two `onProgress` calls. */
const PROGRESS_EVERY = 50;
export type DropRejectionReason = 'unsupported' | 'notSingleFolder' | 'tooManyFiles';
/** Thrown when a drop cannot be turned into a single-folder upload. */
export class DropRejection extends Error {
constructor(
readonly reason: DropRejectionReason,
readonly max?: number,
) {
super(`Folder drop rejected: ${reason}`);
this.name = 'DropRejection';
}
}
export interface ReadDroppedFolderOptions {
signal?: AbortSignal;
/** Called with the running number of files collected so far. */
onProgress?: (filesFound: number) => void;
}
export interface DroppedFolder {
/** Every file under the dropped folder, `webkitRelativePath` set to `<folder>/<rest>`. */
files: File[];
/**
* Directories left out of `files`: pruned because their name is on the
* exclusion list, deeper than the server accepts, or unreadable. Their
* contents were never enumerated, so this is a directory count, not a file
* count. Unreadable single files are counted here as well.
*/
skipped: number;
/**
* Readable files omitted because they exceed MAX_FILE_BYTES. They do not
* count toward MAX_DROP_FILES; the drop summary adds this to droppedCount
* so it matches the picker (filterRepoFiles) skip count.
*/
oversized: number;
}
/**
* True when the browser can hand out directory entries for a drop. A missing
* `DataTransferItem` global (test environments) counts as supported; only an
* engine that has the class without `webkitGetAsEntry` cannot read folders.
*/
export function isFolderDropSupported(): boolean {
return (
typeof DataTransferItem === 'undefined' || 'webkitGetAsEntry' in DataTransferItem.prototype
);
}
/**
* Take the `FileSystemEntry` of every dropped item. Synchronous on purpose:
* call it first thing in the drop handler, before any `await`. Throws
* `DropRejection('unsupported')` when the browser has no `webkitGetAsEntry`;
* the picker button keeps working there.
*/
export function collectDropEntries(dataTransfer: DataTransfer): FileSystemEntry[] {
const items = dataTransfer.items;
if (!items || items.length === 0) return [];
const entries: FileSystemEntry[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (typeof item.webkitGetAsEntry !== 'function') throw new DropRejection('unsupported');
const entry = item.webkitGetAsEntry();
// Non-file items (dragged text, URLs) yield null and are simply ignored.
if (entry) entries.push(entry);
}
return entries;
}
/**
* Walk exactly one dropped directory into `File`s. Rejects with
* `DropRejection('notSingleFolder')` for loose files, no folder, or several
* roots (the server accepts one top-level folder per upload), with
* `DropRejection('tooManyFiles')` past MAX_DROP_FILES, and with the signal's
* reason (an `AbortError`) when aborted mid-walk.
*/
export async function readDroppedFolder(
entries: FileSystemEntry[],
{ signal, onProgress }: ReadDroppedFolderOptions = {},
): Promise<DroppedFolder> {
if (entries.length !== 1 || !entries[0].isDirectory) {
throw new DropRejection('notSingleFolder');
}
const root = entries[0] as FileSystemDirectoryEntry;
const files: File[] = [];
let skipped = 0;
let oversized = 0;
// A folder that is itself named like build output (`dist`, `out`, ...) would
// lose every file to filterRepoFiles anyway (the root is a path segment
// too); answer without walking it.
if (EXCLUDED_DIRS.has(root.name)) return { files, skipped: 1, oversized: 0 };
const throwIfAborted = () => {
if (signal?.aborted) throw signal.reason ?? new DOMException('Aborted', 'AbortError');
};
const report = (count: number) => {
if (!signal?.aborted) onProgress?.(count);
};
// Paths are joined from entry names, never taken from `entry.fullPath`: the
// result is `<root>/<rest>` with forward slashes in every browser and never
// carries a leading slash, which the server rejects.
const walk = async (dir: FileSystemDirectoryEntry, prefix: string, segments: number) => {
// Files inside this directory would have `segments + 1` path segments.
if (segments + 1 > MAX_PATH_SEGMENTS) {
skipped++;
return;
}
const reader = dir.createReader();
for (;;) {
throwIfAborted();
let batch: FileSystemEntry[];
try {
batch = await readBatch(reader);
} catch {
// An unreadable subtree (permissions) is skipped, like the picker
// silently omits files it cannot read; the rest of the drop proceeds.
skipped++;
return;
}
if (batch.length === 0) return;
for (const child of batch) {
throwIfAborted();
const rel = `${prefix}/${child.name}`;
if (child.isDirectory) {
if (EXCLUDED_DIRS.has(child.name)) {
skipped++;
continue;
}
await walk(child as FileSystemDirectoryEntry, rel, segments + 1);
} else if (child.isFile) {
let file: File;
try {
file = await getFile(child as FileSystemFileEntry);
} catch {
// Deleted or renamed mid-walk, a dangling symlink, a locked file:
// skip it the same way an unreadable directory is skipped.
skipped++;
continue;
}
throwIfAborted();
// Do not push oversized files: they used to count toward
// MAX_DROP_FILES, so a tree the picker accepts (20k keepers +
// oversized siblings) was rejected as tooManyFiles. Count them so
// the drop summary's droppedCount still matches the picker.
if (file.size > MAX_FILE_BYTES) {
oversized++;
continue;
}
// A dropped File reports '' here while the picker reports
// `<folder>/<rest>`. An own property shadows the prototype getter, so
// filterRepoFiles (and therefore the server) sees one shape.
Object.defineProperty(file, 'webkitRelativePath', { value: rel, configurable: true });
files.push(file);
if (files.length > MAX_DROP_FILES) {
throw new DropRejection('tooManyFiles', MAX_DROP_FILES);
}
if (files.length % PROGRESS_EVERY === 0) report(files.length);
}
}
}
};
await walk(root, root.name, 1);
throwIfAborted();
report(files.length);
return { files, skipped, oversized };
}
const readBatch = (reader: FileSystemDirectoryReader) =>
new Promise<FileSystemEntry[]>((resolve, reject) => reader.readEntries(resolve, reject));
const getFile = (entry: FileSystemFileEntry) =>
new Promise<File>((resolve, reject) => entry.file(resolve, reject));

View file

@ -3,6 +3,31 @@ export const normalizePath = (p: string): string => {
return p.replace(/\\/g, '/').replace(/^\.?\//, '');
};
/**
* Resolve a citation against a normalized→original graph path index.
* Exact match wins. A suffix match is accepted only when it is unique among
* keys equal to the request or ending in `/${normalized}` — `index.ts` must
* not resolve `src/myindex.ts`, nor silently pick the first filePathIndex entry.
*/
export const resolveUniqueIndexedPath = (
filePathIndex: ReadonlyMap<string, string>,
requestedPath: string,
): string | null => {
const normalized = normalizePath(requestedPath);
if (!normalized) return null;
const exact = filePathIndex.get(normalized);
if (exact !== undefined) return exact;
const boundedSuffix = `/${normalized}`;
let unique: string | undefined;
for (const [key, value] of filePathIndex) {
if (!key.endsWith(boundedSuffix)) continue;
if (unique !== undefined) return null;
unique = value;
}
return unique ?? null;
};
/**
* Resolve a user-supplied path (which may be partial) to an exact file path in the repo.
* Follows the same heuristics previously embedded in useAppState:

View file

@ -72,7 +72,14 @@
"button": "Upload a folder",
"uploading": "Uploading…",
"selected": "{{fileCount}} files ready ({{dropped}} skipped: .git, node_modules, build output)",
"empty": "No analyzable files found in that folder."
"selectedDrop": "{{fileCount, number}} files ready ({{skippedDirs, number}} folders and {{dropped, number}} files skipped: .git, node_modules, build output, files over 25 MB)",
"empty": "No analyzable files found in that folder.",
"dropHint": "or drop a folder here",
"dropActive": "Release to upload this folder",
"reading": "Reading folder… {{fileCount, number}} files found",
"dropSingleFolder": "Drop a single folder, not files or several folders.",
"dropTooManyFiles": "That folder holds more than {{max, number}} files. Drop a subfolder instead.",
"dropUnsupported": "This browser cannot read a dropped folder. Use the “Upload a folder” button instead."
}
}
}

View file

@ -72,7 +72,14 @@
"button": "上传文件夹",
"uploading": "上传中…",
"selected": "已准备 {{fileCount}} 个文件(已跳过 {{dropped}} 个:.git、node_modules、构建产物)",
"empty": "该文件夹中未找到可分析的文件。"
"selectedDrop": "已准备 {{fileCount, number}} 个文件(已跳过 {{skippedDirs, number}} 个文件夹和 {{dropped, number}} 个文件:.git、node_modules、构建产物、超过 25 MB 的文件)",
"empty": "该文件夹中未找到可分析的文件。",
"dropHint": "或将文件夹拖放到此处",
"dropActive": "松开以上传此文件夹",
"reading": "正在读取文件夹… 已找到 {{fileCount, number}} 个文件",
"dropSingleFolder": "请拖放单个文件夹,而不是文件或多个文件夹。",
"dropTooManyFiles": "该文件夹的文件超过 {{max, number}} 个,请改为拖放子文件夹。",
"dropUnsupported": "此浏览器无法读取拖放的文件夹,请改用“上传文件夹”按钮。"
}
}
}

View file

@ -18,6 +18,8 @@ import { decideSkipGraph } from '../lib/graph-load-decision';
// ── Types ──────────────────────────────────────────────────────────────────
export interface BackendRepo {
/** Opaque per-server-process handle; matches `repoId` on analyze completion. */
id?: string;
name: string;
path: string;
repoPath?: string; // git HEAD returns "repoPath"; older versions return "path"
@ -110,6 +112,52 @@ export interface JobStatus {
completedAt?: number;
}
/** Snapshot from GET /api/ops — execution metrics for the ops dashboard. */
export interface OpsLaneMetrics {
total: number;
active: number;
queued: number;
complete: number;
failed: number;
byStatus: Record<JobStatus['status'], number>;
avgDurationMs: number | null;
maxDurationMs: number | null;
activeProgressSum: number;
}
export interface OpsJobView extends JobStatus {
lane: 'analyze' | 'embed';
branch?: string;
retryCount: number;
durationMs: number;
partial?: {
kind: 'embedding-partial';
pendingNodeCount: number;
nodesProcessed: number;
};
}
export interface OpsSnapshot {
generatedAt: number;
uptimeMs: number;
health: 'ok';
server: {
version: string;
launchContext: string;
nodeVersion: string;
latestVersion?: string;
updateAvailable?: boolean;
};
analyze: { jobs: OpsJobView[]; metrics: OpsLaneMetrics };
embed: { jobs: OpsJobView[]; metrics: OpsLaneMetrics };
totals: {
jobs: number;
active: number;
failed: number;
complete: number;
};
}
export class BackendError extends Error {
constructor(
message: string,
@ -190,6 +238,18 @@ export interface SSEOptions {
* the edge's token gate resolves itself once a token is entered.
*/
retryOnHttpError?: boolean;
/**
* When true (default), a successful HTTP open resets the retry counter so a
* long-lived stream can reconnect forever after transient drops. Set false
* for finite budgets (ops → poll fallback): otherwise a 200 that then closes
* would reset the counter on every reconnect and never reach `onError`.
*/
resetRetriesOnOpen?: boolean;
/**
* Abort the handshake if response headers do not arrive in this window.
* Fires `onError` so callers (ops → poll) are not stuck on a pending fetch.
*/
connectTimeoutMs?: number;
}
/**
@ -210,6 +270,7 @@ export function streamSSE<T = unknown>(
const maxRetries = options.maxRetries ?? 3;
const baseDelayMs = options.baseDelayMs ?? 1_000;
const capDelayMs = options.capDelayMs ?? Infinity;
const resetRetriesOnOpen = options.resetRetriesOnOpen ?? true;
let lastEventId = '';
@ -225,13 +286,26 @@ export function streamSSE<T = unknown>(
if (controller.signal.aborted) return;
(async () => {
let handshakeTimer: ReturnType<typeof setTimeout> | undefined;
try {
const headers = withAuthHeader(new Headers());
if (lastEventId) {
headers.set('Last-Event-ID', lastEventId);
}
if (options.connectTimeoutMs && options.connectTimeoutMs > 0) {
handshakeTimer = setTimeout(() => {
if (controller.signal.aborted) return;
handlers.onError?.('SSE handshake timed out');
controller.abort();
}, options.connectTimeoutMs);
}
const response = await fetch(url, { signal: controller.signal, headers });
if (handshakeTimer) {
clearTimeout(handshakeTimer);
handshakeTimer = undefined;
}
if (!response.ok) {
if (options.retryOnHttpError && scheduleRetry(retryCount)) return;
handlers.onError?.(`Server returned ${response.status}`);
@ -244,8 +318,10 @@ export function streamSSE<T = unknown>(
return;
}
// Reset retry count on successful connection
retryCount = 0;
// Long-lived streams reset; finite budgets (ops poll fallback) must not.
if (resetRetriesOnOpen) {
retryCount = 0;
}
handlers.onOpen?.();
const decoder = new TextDecoder();
@ -292,12 +368,20 @@ export function streamSSE<T = unknown>(
}
}
// Stream ended without terminal event — try to reconnect
scheduleRetry(retryCount);
// Stream ended without terminal event — try to reconnect; when the
// retry budget is spent, surface the same onError path the catch arm
// already uses so callers (e.g. ops dashboard → poll fallback) can run.
// scheduleRetry also returns false when aborted — mirror the catch arm
// and do not invoke onError after the caller cancelled the stream.
if (!controller.signal.aborted && !scheduleRetry(retryCount)) {
handlers.onError?.('Stream ended');
}
} catch (err: unknown) {
if (handshakeTimer) clearTimeout(handshakeTimer);
if (err instanceof DOMException && err.name === 'AbortError') return;
// Network error — attempt reconnect with backoff
if (!scheduleRetry(retryCount)) {
// Network error — attempt reconnect with backoff. Skip onError when the
// caller already aborted (scheduleRetry returns false for abort too).
if (!controller.signal.aborted && !scheduleRetry(retryCount)) {
handlers.onError?.(err instanceof Error ? err.message : 'Stream error');
}
}
@ -342,10 +426,27 @@ export const setBackendUrl = (url: string): void => {
export const getBackendUrl = (): string => _backendUrl;
/**
* Strip `user[:password]@` userinfo from an http(s) URL so credentials never
* land in `?server=`, history, or `_backendUrl` display/storage paths.
*/
function stripBackendUrlCredentials(url: string): string {
try {
const parsed = new URL(url);
if (!parsed.username && !parsed.password) return url;
parsed.username = '';
parsed.password = '';
// URL() may add a trailing slash for bare origins; keep normalize's contract.
return parsed.toString().replace(/\/+$/, '');
} catch {
return url.replace(/^(https?:\/\/)[^/]*@/i, '$1');
}
}
/**
* Normalize a user-entered server URL into a base URL suitable for setBackendUrl().
* Adds protocol if missing, strips trailing slashes, and strips a trailing /api suffix
* (since all API methods append their own /api/... paths to _backendUrl).
* Adds protocol if missing, strips trailing slashes / userinfo, and strips a
* trailing /api suffix (since all API methods append their own /api/... paths).
*/
export function normalizeServerUrl(input: string): string {
let url = input.trim().replace(/\/+$/, '');
@ -361,7 +462,7 @@ export function normalizeServerUrl(input: string): string {
// Strip /api suffix if present — _backendUrl stores the base, not the /api path
url = url.replace(/\/api$/, '');
return url;
return stripBackendUrlCredentials(url);
}
// ── Access token ───────────────────────────────────────────────────────────
@ -1070,6 +1171,40 @@ export const getAnalyzeStatus = async (jobId: string): Promise<JobStatus> => {
return response.json() as Promise<JobStatus>;
};
/** Fetch the ops / execution metrics snapshot. */
export const fetchOpsSnapshot = async (): Promise<OpsSnapshot> => {
const response = await fetchWithTimeout(`${_backendUrl}/api/ops`, {}, 5_000);
await assertOk(response);
return response.json() as Promise<OpsSnapshot>;
};
/**
* Stream ops snapshots via SSE (≈1 Hz). Falls back callers should use
* `fetchOpsSnapshot` polling when the stream cannot be established.
*/
export const streamOpsSnapshot = (
onSnapshot: (snapshot: OpsSnapshot) => void,
onError: (error: string) => void,
): AbortController => {
return streamSSE<OpsSnapshot>(
`${_backendUrl}/api/ops/stream`,
{
onMessage: onSnapshot,
onError,
},
// Finite retries so onError can fire and the dashboard falls back to poll.
// Do not reset the budget on a successful open — short-lived 200s must count.
{
maxRetries: 3,
baseDelayMs: 1_000,
capDelayMs: 5_000,
retryOnHttpError: true,
resetRetriesOnOpen: false,
connectTimeoutMs: 5_000,
},
);
};
/** Cancel a running analysis job. */
export const cancelAnalyze = async (jobId: string): Promise<void> => {
const response = await fetchWithTimeout(
@ -1083,7 +1218,7 @@ export const cancelAnalyze = async (jobId: string): Promise<void> => {
export const streamAnalyzeProgress = (
jobId: string,
onProgress: (progress: JobProgress) => void,
onComplete: (data: { repoName?: string; repoPath?: string }) => void,
onComplete: (data: { repoName?: string; repoPath?: string; repoId?: string }) => void,
onError: (error: string) => void,
): AbortController => {
return streamSSE<JobProgress>(

View file

@ -235,5 +235,34 @@ describe('backend-client access token', () => {
// Budget spent → the caller finally hears about it.
expect(onError).toHaveBeenCalledWith('Server returned 401');
});
it('fires onError when the handshake exceeds connectTimeoutMs', async () => {
vi.useFakeTimers();
const fetchMock = vi.fn(() => new Promise<Response>(() => {}));
vi.stubGlobal('fetch', fetchMock);
const onError = vi.fn();
streamSSE(`${BASE}/api/ops/stream`, { onError }, { maxRetries: 0, connectTimeoutMs: 50 });
await vi.advanceTimersByTimeAsync(50);
expect(onError).toHaveBeenCalledWith('SSE handshake timed out');
vi.useRealTimers();
});
it('exhausts a finite budget across successful-then-closed streams when resetRetriesOnOpen is false', async () => {
// Ops dashboard needs this: otherwise every short 200 resets the counter
// and onError (poll fallback) never fires.
const fetchMock = vi.fn(async () => sseResponse(['data: {"ok":true}\n\n']));
vi.stubGlobal('fetch', fetchMock);
const onError = vi.fn();
streamSSE(
`${BASE}/api/ops/stream`,
{ onError },
{ baseDelayMs: 0, maxRetries: 2, resetRetriesOnOpen: false },
);
await vi.waitFor(() => expect(onError).toHaveBeenCalledWith('Stream ended'));
// Initial + 2 retries = 3 opens before the budget is spent.
expect(fetchMock).toHaveBeenCalledTimes(3);
});
});
});

View file

@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import {
selectedNodeDisplayLine,
selectedNodeFileRange,
selectedNodeLineHighlighted,
} from '../../src/components/code-panel-lines';
describe('selected-node GraphNode line math (0-based storage)', () => {
it('requests /api/file starting at storedStart - context (not storedStart - 1 - context)', () => {
// GraphNode startLine 99 is editor line 100. CONTEXT_LINES = 50.
expect(selectedNodeFileRange(99, 99, 50)).toEqual({ startLine: 49, endLine: 149 });
});
it('highlights the 1-based display line for a 0-based node span', () => {
expect(selectedNodeLineHighlighted(100, 99, 99)).toBe(true);
expect(selectedNodeLineHighlighted(99, 99, 99)).toBe(false);
expect(selectedNodeDisplayLine(99)).toBe(100);
});
it('still highlights a first-line symbol stored as startLine 0', () => {
expect(selectedNodeLineHighlighted(1, 0, 0)).toBe(true);
expect(selectedNodeDisplayLine(0)).toBe(1);
expect(selectedNodeFileRange(0, 0, 50)).toEqual({ startLine: 0, endLine: 50 });
});
});

View file

@ -56,9 +56,16 @@ vi.mock('react-i18next', () => ({
}),
}));
const citationReads = () =>
vi.mocked(readFile).mock.calls.filter(([, opts]) => opts && 'startLine' in (opts as object));
describe('CodeReferencesPanel repo identity (#2420)', () => {
beforeEach(() => {
vi.clearAllMocks();
appState.codeReferences = [];
appState.selectedNode = fileNode;
appState.projectName = 'reels';
appState.currentRepo = undefined;
vi.mocked(readFile).mockResolvedValue({ content: 'const a = 1;', totalLines: 1 });
});
@ -91,4 +98,142 @@ describe('CodeReferencesPanel repo identity (#2420)', () => {
expect(screen.getByText('graph:codePanel.sourceUnavailable')).toBeInTheDocument();
});
});
it('does not free replacement-batch citation ids when a cancelled repo-switch batch settles', async () => {
appState.codeReferences = [
{
id: 'cite-1',
filePath: 'src/foo.ts',
startLine: 0,
endLine: 0,
source: 'ai',
},
];
appState.currentRepo = '/ws/a/reels';
let releaseFirst!: (value: { content: string; startLine: number; totalLines: number }) => void;
const firstCitation = new Promise<{ content: string; startLine: number; totalLines: number }>(
(resolve) => {
releaseFirst = resolve;
},
);
vi.mocked(readFile).mockImplementation((_path, opts) => {
if (opts && 'startLine' in opts) return firstCitation;
return Promise.resolve({ content: 'const a = 1;', totalLines: 1 });
});
const { rerender } = render(<CodeReferencesPanel onFocusNode={vi.fn()} />);
await waitFor(() => expect(citationReads()).toHaveLength(1));
vi.mocked(readFile).mockImplementation((_path, opts) => {
if (opts && 'startLine' in opts) {
return Promise.resolve({ content: 'const a = 1;', startLine: 0, totalLines: 1 });
}
return Promise.resolve({ content: 'const a = 1;', totalLines: 1 });
});
appState.currentRepo = '/ws/b/reels';
rerender(<CodeReferencesPanel onFocusNode={vi.fn()} />);
await waitFor(() => expect(citationReads()).toHaveLength(2));
expect(citationReads()[1]?.[1]).toEqual(expect.objectContaining({ repo: '/ws/b/reels' }));
releaseFirst({ content: 'stale', startLine: 0, totalLines: 1 });
await new Promise((r) => setTimeout(r, 30));
expect(citationReads()).toHaveLength(2);
});
it('does not re-issue in-flight citation reads when a new reference is appended', async () => {
appState.codeReferences = [
{
id: 'cite-1',
filePath: 'src/foo.ts',
startLine: 0,
endLine: 0,
source: 'ai',
},
];
appState.currentRepo = '/ws/a/reels';
let releaseFirst!: (value: { content: string; startLine: number; totalLines: number }) => void;
const firstCitation = new Promise<{ content: string; startLine: number; totalLines: number }>(
(resolve) => {
releaseFirst = resolve;
},
);
vi.mocked(readFile).mockImplementation((filePath, opts) => {
if (opts && 'startLine' in opts) {
if (filePath === 'src/foo.ts') return firstCitation;
return Promise.resolve({ content: 'const b = 2;', startLine: 0, totalLines: 1 });
}
return Promise.resolve({ content: 'const a = 1;', totalLines: 1 });
});
const { rerender } = render(<CodeReferencesPanel onFocusNode={vi.fn()} />);
await waitFor(() => expect(citationReads()).toHaveLength(1));
appState.codeReferences = [
...appState.codeReferences,
{
id: 'cite-2',
filePath: 'src/bar.ts',
startLine: 0,
endLine: 0,
source: 'ai',
},
];
rerender(<CodeReferencesPanel onFocusNode={vi.fn()} />);
await waitFor(() => expect(citationReads()).toHaveLength(2));
expect(citationReads()[1]?.[0]).toBe('src/bar.ts');
releaseFirst({ content: 'first', startLine: 0, totalLines: 1 });
await new Promise((r) => setTimeout(r, 30));
expect(citationReads()).toHaveLength(2);
});
it('prunes cached citation snippets when AI references are cleared', async () => {
appState.codeReferences = [
{
id: 'cite-1',
filePath: 'src/foo.ts',
startLine: 0,
endLine: 0,
source: 'ai',
},
];
appState.currentRepo = '/ws/a/reels';
vi.mocked(readFile).mockImplementation((_path, opts) => {
if (opts && 'startLine' in opts) {
return Promise.resolve({ content: 'FIRST_SNIPPET', startLine: 0, totalLines: 1 });
}
return Promise.resolve({ content: 'const a = 1;', totalLines: 1 });
});
const { rerender } = render(<CodeReferencesPanel onFocusNode={vi.fn()} />);
await waitFor(() => expect(screen.getByText('FIRST_SNIPPET')).toBeInTheDocument());
appState.codeReferences = [];
rerender(<CodeReferencesPanel onFocusNode={vi.fn()} />);
vi.mocked(readFile).mockImplementation((_path, opts) => {
if (opts && 'startLine' in opts) {
return Promise.resolve({ content: 'SECOND_SNIPPET', startLine: 0, totalLines: 1 });
}
return Promise.resolve({ content: 'const a = 1;', totalLines: 1 });
});
appState.codeReferences = [
{
id: 'cite-1',
filePath: 'src/foo.ts',
startLine: 0,
endLine: 0,
source: 'ai',
},
];
rerender(<CodeReferencesPanel onFocusNode={vi.fn()} />);
await waitFor(() => expect(screen.getByText('SECOND_SNIPPET')).toBeInTheDocument());
expect(screen.queryByText('FIRST_SNIPPET')).not.toBeInTheDocument();
expect(citationReads().length).toBeGreaterThanOrEqual(2);
});
});

View file

@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { embeddingStatusForStartFailure } from '../../src/hooks/useAppState';
import { BackendError } from '../../src/services/backend-client';
describe('embeddingStatusForStartFailure', () => {
it('maps the shared analyze/embed lock 409 to error, not embedding', () => {
const error = new BackendError(
'Another job is already active for this repository',
409,
'client',
);
expect(embeddingStatusForStartFailure(error)).toBe('error');
expect(embeddingStatusForStartFailure(error)).not.toBe('embedding');
});
it('maps other start failures to error', () => {
expect(embeddingStatusForStartFailure(new Error('boom'))).toBe('error');
});
});

View file

@ -0,0 +1,183 @@
import { fireEvent, render, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ExecutionDashboard } from '../../src/components/ExecutionDashboard';
import {
connectHeartbeat,
fetchOpsSnapshot,
getAuthToken,
normalizeServerUrl,
probeBackendStatus,
setBackendUrl,
streamOpsSnapshot,
type OpsSnapshot,
} from '../../src/services/backend-client';
vi.mock('../../src/services/backend-client', () => ({
connectHeartbeat: vi.fn(() => () => {}),
fetchOpsSnapshot: vi.fn(),
getAuthToken: vi.fn(() => ''),
getBackendUrl: vi.fn(() => 'http://127.0.0.1:4747'),
normalizeServerUrl: vi.fn((url: string) => url),
probeBackendStatus: vi.fn(),
setBackendUrl: vi.fn(),
streamOpsSnapshot: vi.fn(),
}));
const emptyMetrics = {
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 emptySnap = (): OpsSnapshot => ({
generatedAt: 1,
uptimeMs: 1,
health: 'ok',
server: { version: '1', launchContext: 'local', nodeVersion: 'v22' },
analyze: { jobs: [], metrics: emptyMetrics },
embed: { jobs: [], metrics: emptyMetrics },
totals: { jobs: 0, active: 0, failed: 0, complete: 0 },
});
describe('ExecutionDashboard safety poll', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(probeBackendStatus).mockResolvedValue('ok');
vi.mocked(connectHeartbeat).mockReturnValue(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it('stops the safety poll once the SSE stream delivers a frame', async () => {
let onFrame: ((snapshot: OpsSnapshot) => void) | undefined;
vi.mocked(streamOpsSnapshot).mockImplementation((onSnapshot) => {
onFrame = onSnapshot;
return { abort: vi.fn() } as unknown as AbortController;
});
vi.mocked(fetchOpsSnapshot).mockRejectedValue(new Error('rest down'));
const setIntervalSpy = vi.spyOn(window, 'setInterval');
const clearIntervalSpy = vi.spyOn(window, 'clearInterval');
const { getByText } = render(<ExecutionDashboard />);
await waitFor(() => expect(setIntervalSpy).toHaveBeenCalled());
const timerId = setIntervalSpy.mock.results[0]?.value;
expect(onFrame).toBeTypeOf('function');
onFrame!(emptySnap());
await waitFor(() => expect(clearIntervalSpy).toHaveBeenCalledWith(timerId));
expect(getByText(/· sse/)).toBeInTheDocument();
});
it('polls /api/ops at 2s so the fallback stays under the 60/min route limit', async () => {
vi.mocked(streamOpsSnapshot).mockImplementation(
() => ({ abort: vi.fn() }) as unknown as AbortController,
);
vi.mocked(fetchOpsSnapshot).mockRejectedValue(new Error('rest down'));
const setIntervalSpy = vi.spyOn(window, 'setInterval');
render(<ExecutionDashboard />);
await waitFor(() => expect(setIntervalSpy).toHaveBeenCalled());
expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 2_000);
});
it('clears the prior snapshot when connecting to an unreachable server', async () => {
const first = emptySnap();
first.server = { ...first.server, version: 'old-server' };
vi.mocked(streamOpsSnapshot).mockImplementation((onSnapshot) => {
onSnapshot(first);
return { abort: vi.fn() } as unknown as AbortController;
});
vi.mocked(fetchOpsSnapshot).mockResolvedValue(first);
const { getByText, getByPlaceholderText, queryByText } = render(<ExecutionDashboard />);
await waitFor(() => expect(getByText('old-server')).toBeInTheDocument());
vi.mocked(probeBackendStatus).mockResolvedValue('unreachable');
vi.mocked(fetchOpsSnapshot).mockRejectedValue(new Error('down'));
vi.mocked(streamOpsSnapshot).mockImplementation(
() => ({ abort: vi.fn() }) as unknown as AbortController,
);
const input = getByPlaceholderText('http://localhost:4747');
fireEvent.change(input, { target: { value: 'http://127.0.0.1:9999' } });
fireEvent.submit(input.closest('form')!);
await waitFor(() => {
expect(queryByText('old-server')).not.toBeInTheDocument();
});
});
it('keeps an invalid-server error when the prior stream delivers a snapshot', async () => {
let onFrame: ((snapshot: OpsSnapshot) => void) | undefined;
vi.mocked(streamOpsSnapshot).mockImplementation((onSnapshot) => {
onFrame = onSnapshot;
return { abort: vi.fn() } as unknown as AbortController;
});
vi.mocked(fetchOpsSnapshot).mockResolvedValue(emptySnap());
vi.mocked(normalizeServerUrl).mockImplementation((url: string) => {
if (url.includes('bad')) throw new Error('Invalid backend URL');
return url;
});
const { getByText, getByPlaceholderText } = render(<ExecutionDashboard />);
await waitFor(() => expect(onFrame).toBeTypeOf('function'));
const input = getByPlaceholderText('http://localhost:4747');
fireEvent.change(input, { target: { value: 'http://bad' } });
fireEvent.submit(input.closest('form')!);
await waitFor(() => expect(getByText('Invalid backend URL')).toBeInTheDocument());
onFrame!(emptySnap());
await waitFor(() => expect(getByText('Invalid backend URL')).toBeInTheDocument());
});
});
describe('ExecutionDashboard ?server= link', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(probeBackendStatus).mockResolvedValue('ok');
vi.mocked(fetchOpsSnapshot).mockResolvedValue(emptySnap());
vi.mocked(streamOpsSnapshot).mockReturnValue({ abort: vi.fn() } as unknown as AbortController);
window.history.replaceState({}, '', '/?view=ops&server=https://other.example');
});
afterEach(() => {
window.history.replaceState({}, '', '/');
vi.mocked(getAuthToken).mockReturnValue('');
});
it('does not auto-connect a foreign server while a deploy token is held', async () => {
vi.mocked(getAuthToken).mockReturnValue('deploy-token');
const { getByDisplayValue } = render(<ExecutionDashboard />);
expect(getByDisplayValue('https://other.example')).toBeInTheDocument();
expect(setBackendUrl).not.toHaveBeenCalled();
expect(streamOpsSnapshot).not.toHaveBeenCalled();
});
it('auto-connects the linked server when no token is held', async () => {
render(<ExecutionDashboard />);
await waitFor(() => expect(setBackendUrl).toHaveBeenCalledWith('https://other.example'));
});
});

View file

@ -206,4 +206,50 @@ describe('Graph-RAG impact risk contract', () => {
expect(output).toContain('enrichment-truncated');
expect(output).not.toContain('enrichment-budget-exhausted');
});
it('does not treat a filename substring as a unique File suffix', async () => {
const executeQuery = vi.fn(async (query: string) => {
if (query.includes('filePath CONTAINS')) {
return [
{ id: 'file-mylib', nodeType: 'File', filePath: 'src/mylib/foo.ts' },
{ id: 'fn-mylib', nodeType: 'Function', filePath: 'src/mylib/foo.ts' },
];
}
return [];
});
const output = await impactTool({ ...noOpBackend, executeQuery }).invoke({
target: 'lib/foo.ts',
direction: 'upstream',
maxDepth: 1,
});
expect(output).toContain('AMBIGUOUS TARGET');
expect(output).toContain('lib/foo.ts');
});
it('does not accept a unique File suffix from a truncated CONTAINS page', async () => {
const executeQuery = vi.fn(async (query: string) => {
if (query.includes('filePath CONTAINS')) {
return [
...Array.from({ length: 9 }, (_, index) => ({
id: `sym-${index}`,
nodeType: 'Function',
filePath: `src/other-${index}.ts`,
})),
{ id: 'file-visible', nodeType: 'File', filePath: 'apps/web/lib/foo.ts' },
];
}
return [];
});
const output = await impactTool({ ...noOpBackend, executeQuery }).invoke({
target: 'lib/foo.ts',
direction: 'upstream',
maxDepth: 1,
});
expect(output).toContain('AMBIGUOUS TARGET');
expect(output).toContain('Could not uniquely match');
});
});

View file

@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest';
import { normalizePath, resolveFilePath } from '../../src/lib/path-resolution';
import {
normalizePath,
resolveFilePath,
resolveUniqueIndexedPath,
} from '../../src/lib/path-resolution';
describe('path-resolution utilities', () => {
const contents = new Map<string, string>([
@ -31,3 +35,42 @@ describe('path-resolution utilities', () => {
expect(resolveFilePath(contents, '')).toBeNull();
});
});
describe('resolveUniqueIndexedPath', () => {
const index = new Map<string, string>([
['src/components/Header.tsx', 'src/components/Header.tsx'],
['src/index.ts', 'src/index.ts'],
['lib/index.ts', 'lib/index.ts'],
['packages/core/utils.ts', 'packages/core/utils.ts'],
]);
it('prefers an exact indexed path', () => {
expect(resolveUniqueIndexedPath(index, 'src/index.ts')).toBe('src/index.ts');
});
it('resolves a unique suffix', () => {
expect(resolveUniqueIndexedPath(index, 'core/utils.ts')).toBe('packages/core/utils.ts');
});
it('resolves a unique filename only at a path-component boundary', () => {
expect(resolveUniqueIndexedPath(index, 'Header.tsx')).toBe('src/components/Header.tsx');
});
it('does not treat a filename substring as a unique suffix', () => {
const substringIndex = new Map<string, string>([['src/myindex.ts', 'src/myindex.ts']]);
expect(resolveUniqueIndexedPath(substringIndex, 'index.ts')).toBeNull();
});
it('returns null when more than one file shares the suffix', () => {
expect(resolveUniqueIndexedPath(index, 'index.ts')).toBeNull();
});
it('returns null for an empty request instead of matching every key', () => {
expect(resolveUniqueIndexedPath(index, '')).toBeNull();
expect(resolveUniqueIndexedPath(index, './')).toBeNull();
});
it('returns null when nothing matches', () => {
expect(resolveUniqueIndexedPath(index, 'missing.ts')).toBeNull();
});
});

View file

@ -4,8 +4,9 @@
* The SSE complete event may carry `repoPath` (the analyzed path). RepoAnalyzer
* must pass that IDENTITY to onComplete — so the post-analyze reconnect targets
* the exact repo even when basenames collide — while the done screen keeps
* rendering the display NAME and never shows an absolute path. Old servers
* omit repoPath; the name fallback must be preserved.
* rendering the display NAME and never shows an absolute path. Current servers
* omit repoPath and send an opaque repoId, resolved against /api/repos; with
* neither, the name fallback must be preserved.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen } from '@testing-library/react';
@ -13,6 +14,7 @@ import { RepoAnalyzer } from '../../src/components/RepoAnalyzer';
import { i18nReady } from '../../src/i18n';
import {
cancelAnalyze,
fetchRepos,
streamAnalyzeProgress,
uploadFolder,
} from '../../src/services/backend-client';
@ -31,13 +33,14 @@ vi.mock('../../src/services/backend-client', () => ({
},
startAnalyze: vi.fn(),
cancelAnalyze: vi.fn(),
fetchRepos: vi.fn(),
streamAnalyzeProgress: vi.fn(),
uploadFolder: vi.fn(),
}));
const JOB = { jobId: 'job-1', status: 'queued' };
type CompleteData = { repoName?: string; repoPath?: string };
type CompleteData = { repoName?: string; repoPath?: string; repoId?: string };
beforeEach(async () => {
await i18nReady;
@ -64,7 +67,7 @@ async function startTrackedJob() {
vi.useFakeTimers();
const onDone = vi.fn<(repoIdentity: string) => void>();
render(<RepoAnalyzer variant="onboarding" onComplete={onDone} />);
const { unmount } = render(<RepoAnalyzer variant="onboarding" onComplete={onDone} />);
fireEvent.click(screen.getByRole('tab', { name: 'Local Folder' }));
fireEvent.change(screen.getByTestId('folder-upload-input'), {
target: { files: [new File(['x'], 'a.ts')] },
@ -73,7 +76,7 @@ async function startTrackedJob() {
await act(async () => {});
expect(streamAnalyzeProgress).toHaveBeenCalledTimes(1);
return { onDone, complete: (data: CompleteData) => sseComplete?.(data) };
return { onDone, unmount, complete: (data: CompleteData) => sseComplete?.(data) };
}
describe('analyze completion identity', () => {
@ -90,7 +93,7 @@ describe('analyze completion identity', () => {
// onComplete fires after the ~1200ms done-screen dwell, with the identity.
expect(onDone).not.toHaveBeenCalled();
act(() => {
await act(async () => {
vi.advanceTimersByTime(1200);
});
expect(onDone).toHaveBeenCalledTimes(1);
@ -105,10 +108,64 @@ describe('analyze completion identity', () => {
});
expect(screen.getByText('reels')).toBeInTheDocument();
act(() => {
await act(async () => {
vi.advanceTimersByTime(1200);
});
expect(onDone).toHaveBeenCalledTimes(1);
expect(onDone).toHaveBeenCalledWith('reels');
});
it('resolves repoId to the exact /api/repos entry when names collide', async () => {
vi.mocked(fetchRepos).mockResolvedValue([
{ id: 'id-a', name: 'reels', path: '/ws/a/reels', indexedAt: '' },
{ id: 'id-b', name: 'reels', path: '/ws/b/reels', indexedAt: '' },
]);
const { onDone, complete } = await startTrackedJob();
act(() => {
complete({ repoName: 'reels', repoId: 'id-b' });
});
expect(screen.getByText('reels')).toBeInTheDocument();
expect(screen.queryByText('/ws/b/reels')).toBeNull();
await act(async () => {
vi.advanceTimersByTime(1200);
});
expect(onDone).toHaveBeenCalledWith('/ws/b/reels');
});
it('falls back to the display name when repoId matches no entry', async () => {
vi.mocked(fetchRepos).mockResolvedValue([]);
const { onDone, complete } = await startTrackedJob();
act(() => {
complete({ repoName: 'reels', repoId: 'gone' });
});
await act(async () => {
vi.advanceTimersByTime(1200);
});
expect(onDone).toHaveBeenCalledWith('reels');
});
it('does not call onComplete when unmounted while repoId is still resolving', async () => {
let resolveRepos: ((repos: never[]) => void) | undefined;
vi.mocked(fetchRepos).mockReturnValue(
new Promise((resolve) => {
resolveRepos = resolve;
}),
);
const { onDone, complete, unmount } = await startTrackedJob();
act(() => {
complete({ repoName: 'reels', repoId: 'id-b' });
});
await act(async () => {
vi.advanceTimersByTime(1200);
});
unmount();
await act(async () => {
resolveRepos?.([]);
});
expect(onDone).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,334 @@
/**
* Folder drag-and-drop on RepoAnalyzer's Local Folder tab.
*
* A dropped folder is walked client-side (src/lib/folder-drop.ts) and then
* takes the exact path the "Upload a folder" picker takes: filterRepoFiles ->
* uploadFolder -> streamAnalyzeProgress. These tests drive the drop with
* fixture FileSystemEntry objects (jsdom has no File and Directory Entries
* API) and assert the upload the server would receive.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { RepoAnalyzer } from '../../src/components/RepoAnalyzer';
import { i18nReady } from '../../src/i18n';
import {
cancelAnalyze,
streamAnalyzeProgress,
uploadFolder,
} from '../../src/services/backend-client';
vi.mock('../../src/services/backend-client', () => ({
BackendError: class BackendError extends Error {
constructor(
message: string,
public readonly status: number,
public readonly code: string,
public readonly retryAfterMs?: number,
) {
super(message);
this.name = 'BackendError';
}
},
startAnalyze: vi.fn(),
cancelAnalyze: vi.fn(),
streamAnalyzeProgress: vi.fn(),
uploadFolder: vi.fn(),
}));
const JOB = { jobId: 'job-1', status: 'queued' };
// ── Fixtures ─────────────────────────────────────────────────────────────────
function fileEntry(name: string, size = 1): FileSystemFileEntry {
return {
isFile: true,
isDirectory: false,
name,
fullPath: `/browser-root/${name}`,
file: (ok: (f: File) => void) => ok(new File([new Uint8Array(size)], name)),
} as unknown as FileSystemFileEntry;
}
/** A file whose `file()` callback is released manually, to hold the walk open. */
function deferredFileEntry(name: string) {
let release: (() => void) | undefined;
const entry = {
isFile: true,
isDirectory: false,
name,
fullPath: `/browser-root/${name}`,
file: (ok: (f: File) => void) => {
release = () => ok(new File(['x'], name));
},
} as unknown as FileSystemFileEntry;
return { entry, release: () => release?.() };
}
function dirEntry(name: string, children: FileSystemEntry[]): FileSystemDirectoryEntry {
return {
isFile: false,
isDirectory: true,
name,
fullPath: `/browser-root/${name}`,
createReader: () => {
let done = false;
return {
readEntries: (ok: (entries: FileSystemEntry[]) => void) => {
if (done) return ok([]);
done = true;
ok(children);
},
};
},
} as unknown as FileSystemDirectoryEntry;
}
function dataTransfer(entries: (FileSystemEntry | null)[], supported = true) {
const items = entries.map((e) => (supported ? { webkitGetAsEntry: () => e } : {}));
return { items, types: ['Files'], files: [], dropEffect: 'none' };
}
const filesDrag = { dataTransfer: { types: ['Files'], items: [], dropEffect: 'none' } };
// ── Setup ────────────────────────────────────────────────────────────────────
beforeEach(async () => {
await i18nReady;
vi.clearAllMocks();
vi.mocked(cancelAnalyze).mockResolvedValue(undefined as never);
vi.mocked(uploadFolder).mockResolvedValue(JOB);
vi.mocked(streamAnalyzeProgress).mockImplementation(() => new AbortController());
});
function renderLocalTab() {
const onComplete = vi.fn();
render(<RepoAnalyzer variant="onboarding" onComplete={onComplete} />);
fireEvent.click(screen.getByRole('tab', { name: 'Local Folder' }));
return { zone: screen.getByTestId('folder-drop-zone'), onComplete };
}
const REPO = () =>
dirEntry('repo', [
fileEntry('a.ts'),
dirEntry('src', [fileEntry('b.ts')]),
dirEntry('node_modules', [fileEntry('dep.js')]),
dirEntry('.git', [fileEntry('HEAD')]),
]);
// ── Tests ────────────────────────────────────────────────────────────────────
describe('folder drop on the Local Folder tab', () => {
it('uploads a dropped folder through the existing upload path', async () => {
const { zone } = renderLocalTab();
fireEvent.drop(zone, { dataTransfer: dataTransfer([REPO()]) });
await act(async () => {});
expect(uploadFolder).toHaveBeenCalledTimes(1);
const [files = [], manifest = [], signal] = vi.mocked(uploadFolder).mock.calls[0] ?? [];
expect(manifest).toEqual(['repo/a.ts', 'repo/src/b.ts']);
expect(files.map((f) => f.webkitRelativePath)).toEqual(manifest);
expect(signal).toBeInstanceOf(AbortSignal);
expect(streamAnalyzeProgress).toHaveBeenCalledWith(
'job-1',
expect.any(Function),
expect.any(Function),
expect.any(Function),
);
});
it('shows the reading state while the folder is walked', async () => {
const { zone } = renderLocalTab();
const held = deferredFileEntry('slow.ts');
const repo = dirEntry('repo', [fileEntry('a.ts'), held.entry]);
fireEvent.drop(zone, { dataTransfer: dataTransfer([repo]) });
await act(async () => {});
expect(screen.getByTestId('drop-reading')).toBeInTheDocument();
expect(screen.getByTestId('upload-folder')).toBeDisabled();
expect(uploadFolder).not.toHaveBeenCalled();
await act(async () => {
held.release();
});
expect(screen.queryByTestId('drop-reading')).toBeNull();
expect(uploadFolder).toHaveBeenCalledTimes(1);
});
it('refuses a loose file', async () => {
const { zone } = renderLocalTab();
fireEvent.drop(zone, { dataTransfer: dataTransfer([fileEntry('a.ts')]) });
await act(async () => {});
expect(uploadFolder).not.toHaveBeenCalled();
expect(
screen.getByText('Drop a single folder, not files or several folders.'),
).toBeInTheDocument();
});
it('refuses several folders at once', async () => {
const { zone } = renderLocalTab();
fireEvent.drop(zone, {
dataTransfer: dataTransfer([
dirEntry('a', [fileEntry('x')]),
dirEntry('b', [fileEntry('y')]),
]),
});
await act(async () => {});
expect(uploadFolder).not.toHaveBeenCalled();
expect(
screen.getByText('Drop a single folder, not files or several folders.'),
).toBeInTheDocument();
});
it('explains when the browser cannot read dropped folders', async () => {
const { zone } = renderLocalTab();
fireEvent.drop(zone, { dataTransfer: dataTransfer([REPO()], false) });
await act(async () => {});
expect(uploadFolder).not.toHaveBeenCalled();
expect(
screen.getByText(
'This browser cannot read a dropped folder. Use the “Upload a folder” button instead.',
),
).toBeInTheDocument();
expect(screen.getByTestId('upload-folder')).toBeEnabled();
});
it('shows the existing empty-folder message for a folder without analyzable files', async () => {
const { zone } = renderLocalTab();
fireEvent.drop(zone, {
dataTransfer: dataTransfer([dirEntry('repo', [dirEntry('node_modules', [fileEntry('x')])])]),
});
await act(async () => {});
expect(uploadFolder).not.toHaveBeenCalled();
expect(screen.getByText('No analyzable files found in that folder.')).toBeInTheDocument();
});
it('aborts the walk on a mode switch and never uploads', async () => {
const { zone } = renderLocalTab();
const held = deferredFileEntry('slow.ts');
const repo = dirEntry('repo', [fileEntry('a.ts'), held.entry]);
fireEvent.drop(zone, { dataTransfer: dataTransfer([repo]) });
await act(async () => {});
expect(screen.getByTestId('drop-reading')).toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: /GitHub/ }));
await act(async () => {
held.release();
});
expect(uploadFolder).not.toHaveBeenCalled();
expect(screen.queryByTestId('drop-reading')).toBeNull();
expect(screen.queryByText(/single folder|cannot read/)).toBeNull();
});
it('keeps the highlight while the cursor crosses the button, clears it on leave', () => {
const { zone } = renderLocalTab();
const button = screen.getByTestId('upload-folder');
fireEvent.dragEnter(zone, filesDrag);
expect(zone).toHaveAttribute('data-drag-active', 'true');
expect(screen.getByTestId('drop-hint')).toHaveTextContent('Release to upload this folder');
fireEvent.dragEnter(button, filesDrag);
fireEvent.dragLeave(button, filesDrag);
expect(zone).toHaveAttribute('data-drag-active', 'true');
fireEvent.dragLeave(zone, filesDrag);
expect(zone).not.toHaveAttribute('data-drag-active');
expect(screen.getByTestId('drop-hint')).toHaveTextContent('or drop a folder here');
});
it('catches a drop that lands on the path input inside the panel', async () => {
renderLocalTab();
fireEvent.drop(screen.getByPlaceholderText(/project/), {
dataTransfer: dataTransfer([REPO()]),
});
await act(async () => {});
expect(uploadFolder).toHaveBeenCalledTimes(1);
});
it('disables Analyze while a dropped folder is being read', async () => {
const { zone } = renderLocalTab();
fireEvent.change(screen.getByPlaceholderText(/project/), { target: { value: '/srv/repo' } });
expect(screen.getByRole('button', { name: /Analyze Repository/ })).toBeEnabled();
const held = deferredFileEntry('slow.ts');
fireEvent.drop(zone, { dataTransfer: dataTransfer([dirEntry('repo', [held.entry])]) });
await act(async () => {});
expect(screen.getByRole('button', { name: /Analyze Repository/ })).toBeDisabled();
await act(async () => {
held.release();
});
expect(uploadFolder).toHaveBeenCalledTimes(1);
});
it('ignores drags that carry no files', () => {
const { zone } = renderLocalTab();
fireEvent.dragEnter(zone, { dataTransfer: { types: ['text/plain'], items: [] } });
expect(zone).not.toHaveAttribute('data-drag-active');
fireEvent.drop(zone, { dataTransfer: { types: ['text/plain'], items: [] } });
expect(uploadFolder).not.toHaveBeenCalled();
});
it("does not let an aborted drop clear a later drop's reading state", async () => {
const { zone } = renderLocalTab();
const heldA = deferredFileEntry('slow-a.ts');
fireEvent.drop(zone, { dataTransfer: dataTransfer([dirEntry('repo-a', [heldA.entry])]) });
await act(async () => {});
expect(screen.getByTestId('drop-reading')).toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: /GitHub/ }));
fireEvent.click(screen.getByRole('tab', { name: 'Local Folder' }));
const zoneB = screen.getByTestId('folder-drop-zone');
const heldB = deferredFileEntry('slow-b.ts');
fireEvent.drop(zoneB, { dataTransfer: dataTransfer([dirEntry('repo-b', [heldB.entry])]) });
await act(async () => {});
expect(screen.getByTestId('drop-reading')).toBeInTheDocument();
await act(async () => {
heldA.release();
});
expect(screen.getByTestId('drop-reading')).toBeInTheDocument();
expect(screen.getByTestId('upload-folder')).toBeDisabled();
expect(uploadFolder).not.toHaveBeenCalled();
await act(async () => {
heldB.release();
});
expect(screen.queryByTestId('drop-reading')).toBeNull();
expect(uploadFolder).toHaveBeenCalledTimes(1);
const [, manifest] = vi.mocked(uploadFolder).mock.calls[0] ?? [];
expect(manifest).toEqual(['repo-b/slow-b.ts']);
});
it('ignores a drop while an upload is already in flight', async () => {
const { zone } = renderLocalTab();
vi.mocked(uploadFolder).mockImplementation(() => new Promise(() => {}));
fireEvent.drop(zone, { dataTransfer: dataTransfer([REPO()]) });
await act(async () => {});
expect(uploadFolder).toHaveBeenCalledTimes(1);
fireEvent.drop(zone, { dataTransfer: dataTransfer([REPO()]) });
await act(async () => {});
expect(uploadFolder).toHaveBeenCalledTimes(1);
});
});

View file

@ -58,6 +58,13 @@ describe('normalizeServerUrl', () => {
it('preserves existing https://', () => {
expect(normalizeServerUrl('https://gitnexus.example.com')).toBe('https://gitnexus.example.com');
});
it('strips userinfo so credentials never reach ?server= or _backendUrl', () => {
expect(normalizeServerUrl('https://user:secret@gitnexus.example.com:8443')).toBe(
'https://gitnexus.example.com:8443',
);
expect(normalizeServerUrl('http://token@localhost:4747/api')).toBe('http://localhost:4747');
});
});
afterEach(() => {

View file

@ -1,3 +1,20 @@
{
"installCommand": "npm ci --include=dev && cd ../gitnexus-shared && node ../gitnexus-web/node_modules/typescript/lib/tsc.js"
"installCommand": "npm ci --include=dev && cd ../gitnexus-shared && node ../gitnexus-web/node_modules/typescript/lib/tsc.js",
"rewrites": [
{
"source": "/((?!assets/|api/).*)",
"destination": "/index.html"
}
],
"headers": [
{
"source": "/assets/(.*)",
"headers": [
{
"key": "Cache-Control",
"value": "public, max-age=31536000, immutable"
}
]
}
]
}

View file

@ -7,6 +7,7 @@ All notable changes to GitNexus will be documented in this file.
### Changed
- **MCP `query` / `context` / `impact` / `cypher` always attach a ref-carrying `staleness` field** — object results include it even when `status` is `current`. Absence is no longer the freshness signal: read `staleness.status` (`behind`/`diverged` vs `current`/`unknown`) and `branch`/`lastCommit` for which index answered. `list_repos` and the HTTP repo routes are unchanged (still omit `staleness` when current; the ref is top-level) (#3291, #3293)
- **MCP `query` keeps one `process_symbols` row per `(id, process_id)`** — a symbol in more than one execution flow stays on each process card, and `symbol_count` is the number of those emitted rows after `max_symbols`. A `repo` of `@<group>` returns `{ group, query, results, per_repo }` and does not include `process_symbols`; `results[].symbol_count` is that member's post-slice count, and a `service` prefix counts only attaches under the prefix. Query `@<group>/<memberPath>` for that member's attach rows (#3351)
## [1.6.12] - 2026-09-12

View file

@ -2,7 +2,7 @@
**Graph-powered code intelligence for AI agents.** Index any codebase into a knowledge graph, then query it via MCP or CLI.
Works with **Cursor**, **Claude Code**, **Antigravity** (Google), **Codex**, **Windsurf**, **Cline**, **OpenCode**, **CodeBuddy** (Tencent), **Qoder** (Alibaba), and any MCP-compatible tool.
Works with **Cursor**, **Claude Code**, **Antigravity** (Google), **Codex**, **Factory** (Droid), **Windsurf**, **Cline**, **OpenCode**, **CodeBuddy** (Tencent), **Qoder** (Alibaba), and any MCP-compatible tool.
[![npm version](https://img.shields.io/npm/v/gitnexus.svg)](https://www.npmjs.com/package/gitnexus)
[![License: PolyForm Noncommercial](https://img.shields.io/badge/License-PolyForm%20Noncommercial-blue.svg)](https://polyformproject.org/licenses/noncommercial/1.0.0/)
@ -44,12 +44,13 @@ To configure MCP for your editor, run `npx gitnexus setup` once — or set it up
| **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/)) | **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 automatically enrich grep/glob/bash calls with knowledge 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.
### Community Integrations
@ -88,6 +89,33 @@ 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) (full support — MCP + skills + hooks)
`gitnexus setup -c droid` writes the MCP server to `~/.factory/mcp.json` and installs skills to
`~/.factory/skills/`. To configure MCP by hand instead, add 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"]
}
}
}
```
For the PostToolUse search-augment hook, install the bundled
[`gitnexus-factory-plugin/`](../gitnexus-factory-plugin/) with `droid plugin install gitnexus@<marketplace>`
from a marketplace that includes this repo, or point Droid at it via `extraKnownMarketplaces` in
`.factory/settings.json`.
> **Factory notes:** Droid reads [AGENTS.md natively](https://docs.factory.ai/), which already carries the
> GitNexus context block, so no SessionStart hook is registered. Pick **one** install route
> (`gitnexus setup -c droid` **or** the plugin) — the plugin ships its own MCP entry, so installing both
> can register the server twice.
### Cursor / Windsurf
Add to `~/.cursor/mcp.json` (global — works for all projects):
@ -182,29 +210,31 @@ Note that the bundled Graphology path is no longer the slow option it once was:
## MCP Tools
Your AI agent gets **17 tools** (15 per-repo + 2 group) automatically:
Your AI agent gets **19 tools** (17 per-repo + 2 group) automatically:
| Tool | What It Does |
| ---------------- | ---------------------------------------------------------------------- |
| `list_repos` | Discover all indexed repositories (paginated — `limit`/`offset`) |
| `query` | Process-grouped hybrid search (BM25 + semantic + RRF) |
| `context` | 360-degree symbol view — categorized refs, process participation |
| `impact` | Blast radius analysis with depth grouping and confidence |
| `trace` | Shortest directed path between two symbols (call + class-member edges) |
| `detect_changes` | Git-diff impact — maps changed lines to affected processes |
| `check` | Read-only structural checks against the indexed graph |
| `rename` | Multi-file coordinated rename with graph + text search |
| `cypher` | Raw Cypher graph queries |
| `route_map` | API route map — which components fetch which endpoints, and handlers |
| `tool_map` | MCP/RPC tool definitions — where they're defined and handled |
| `shape_check` | Validate API response shapes against consumers' property accesses |
| `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) |
| `group_list` | List configured repository groups |
| `group_sync` | Rebuild a group's Contract Registry and cross-repo links |
| Tool | What It Does |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `list_repos` | Discover all indexed repositories (paginated — `limit`/`offset`) |
| `query` | Process-grouped hybrid search (BM25 + semantic + RRF); optional `chain_depth` expands each result's call chain |
| `context` | 360-degree symbol view — categorized refs, process participation, HTTP routes, `is_entry_point` flag; optional `chain_depth` call-chain expansion |
| `impact` | Blast radius analysis with depth grouping and confidence |
| `trace` | Shortest directed path between two symbols (call + class-member edges) |
| `detect_changes` | Git-diff impact — maps changed lines to affected processes |
| `check` | Read-only structural checks against the indexed graph |
| `rename` | Multi-file coordinated rename with graph + text search |
| `cypher` | Raw Cypher graph queries |
| `route_map` | API route map — which components fetch which endpoints, and handlers |
| `tool_map` | MCP/RPC tool definitions — where they're defined and handled |
| `shape_check` | Validate API response shapes against consumers' property accesses |
| `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; optional `caseSensitive` / `literal`) |
| `group_list` | List configured repository groups |
| `group_sync` | Rebuild a group's Contract Registry and cross-repo links |
> Read-only tools can omit `repo` when 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—and for mutating tools with multiple indexed repos and no MCP default—specify it explicitly: `query({search_query: "auth", repo: "my-app"})`. Per-repo tools also take an optional `branch` for indexes pinned with `gitnexus analyze --branch`; omitting it queries the workspace index, which follows your checked-out working tree. `explain` and `pdg_query` need an index built with `gitnexus analyze --pdg`.
> Read-only tools can omit `repo` when 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—and for mutating tools with multiple indexed repos and no MCP default—specify it explicitly: `query({search_query: "auth", repo: "my-app"})`. 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. `explain` and `pdg_query` need an index built with `gitnexus analyze --pdg`.
## MCP Resources
@ -245,10 +275,13 @@ gitnexus analyze --skip-agents-md # Preserve custom AGENTS.md/CLAUDE.md gitnexu
gitnexus analyze --skip-skills # Skip installing standard .claude/skills/gitnexus-* skill files
gitnexus analyze --skip-git # Index folders that are not Git repositories
gitnexus analyze --workers <n> # Parse worker pool size (>=1; default: cores-1, capped at 16)
gitnexus analyze --max-processes <n> # Process-detection process cap (replaces dynamic max(20, round(symbols/10)))
gitnexus analyze --max-entry-point-candidates <n> # 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 --verbose # Log skipped files when parsers are unavailable
gitnexus analyze --max-file-size 1024 # Skip files larger than N KB (default: 512, cap: 32768)
gitnexus analyze --worker-timeout 60 # Increase worker idle timeout for slow parses
gitnexus analyze --memory-budget 3000 # Main-thread V8 heap in MB (>= 200); overrides the auto-sizer and any --max-old-space-size pin
gitnexus analyze --wal-checkpoint-threshold 67108864 # 64 MiB. Control LadybugDB WAL auto-checkpoint threshold (default: 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB)
gitnexus auto-sync [init|start|restart|stop|status|reset] # Scheduled remote clone/pull + analyze from GITNEXUS_HOME/watch_config.yml
gitnexus mcp # Start MCP server (stdio) — serves all indexed repos
@ -298,7 +331,8 @@ installation. Run a one-shot `gitnexus analyze` when those generated files need
updating. Stop watch mode with Ctrl+C.
Watch mode accepts `--debounce`, `--workers`, `--worker-timeout`,
`--max-file-size`, `--branch`, `--pdg`, `--name`, `--allow-duplicate-name`, and
`--max-file-size`, `--max-processes`, `--max-process-branching`,
`--max-process-trace-depth`, `--max-entry-point-candidates`, `--branch`, `--pdg`, `--name`, `--allow-duplicate-name`, and
`--verbose`. Explicit one-shot options such as `--force`, `--repair-fts`,
embedding flags, `--skills`, `--default-branch`, `--skip-agents-md`,
`--skip-skills`, `--no-stats`, `--self-commit`, `--index-only`, and `--skip-git`
@ -322,6 +356,7 @@ max_concurrency: 1
repo_git_timeout: 10s
analyze_timeout: 5m
analyze_failure_threshold: 3
# allowed_hosts: [gitlab.mycompany.com]
projects:
- local_path: /abs/path/to/repos
branches: [master, main]
@ -336,7 +371,7 @@ projects:
- git@gitee.com:owner/repo.git
```
`sync_interval_minutes` must be an integer of at least `5`. `local_path` must be an absolute path without traversal; each remote is cloned below it as `host/namespace/repo`, preventing same-basename repositories from colliding. `remote_urls` must use SSH SCP form for github.com, gitlab.com, or gitee.com. `repo_git_timeout` applies to each repo clone/pull and defaults to `10s`; a bare number such as `10` is interpreted as seconds, while `10000ms`, `10s`, and `1m` keep their explicit units. It must not exceed one hour or `sync_interval_minutes`, whichever is smaller — so a bare `600000` is rejected, because it means 600000 seconds rather than milliseconds. `analyze_timeout` applies to each isolated analysis worker and defaults to half of `sync_interval_minutes`, but it is independent of polling and may be longer, up to Node's timer limit (`2147483647ms`). A `5` minute poll with `analyze_timeout: 30m` is valid. A tick that arrives while the previous loop is active never overlaps it: ticks coalesce into one immediate follow-up run, which pulls and analyzes 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 already in native work exits after it returns to a JS-visible safe point. While waiting, auto-sync reports `cancelling` or `stopping` and keeps its ownership files so another auto-sync cannot take over. The parent waits up to 5 seconds for the worker to exit; after that it stops waiting, releases its ownership files, and leaves the worker to finish and exit on its own rather than killing it mid-write. `auto-sync stop` uses this same control path on macOS and Windows.
`sync_interval_minutes` must be an integer of at least `5`. `local_path` must be an absolute path without traversal; each remote is cloned below it as `host/namespace/repo`, preventing same-basename repositories from colliding. `remote_urls` may use SSH SCP form (`git@host:owner/repo.git`) or HTTPS (`https://host/owner/repo.git`). Hosts are limited to github.com, gitlab.com, and gitee.com unless listed in top-level `allowed_hosts` (exact DNS names, no wildcards). The published CLI image includes `openssh-client` so SSH remotes can clone; mount keys and `known_hosts` yourself. An invalid `watch_config.yml` skips auto-sync immediately with the validation error. `repo_git_timeout` applies to each repo clone/pull and defaults to `10s`; a bare number such as `10` is interpreted as seconds, while `10000ms`, `10s`, and `1m` keep their explicit units. It must not exceed one hour or `sync_interval_minutes`, whichever is smaller — so a bare `600000` is rejected, because it means 600000 seconds rather than milliseconds. `analyze_timeout` applies to each isolated analysis worker and defaults to half of `sync_interval_minutes`, but it is independent of polling and may be longer, up to Node's timer limit (`2147483647ms`). A `5` minute poll with `analyze_timeout: 30m` is valid. Auto-sync analysis honors the cloned repo's `.gitnexusrc` embeddings settings; the CLI image still needs `GITNEXUS_EMBEDDING_URL` or a bind-mounted embedding stack because npm is stripped. A tick that arrives while the previous loop is active never overlaps it: ticks coalesce into one immediate follow-up run, which pulls and analyzes 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 already in native work exits after it returns to a JS-visible safe point. While waiting, auto-sync reports `cancelling` or `stopping` and keeps its ownership files so another auto-sync cannot take over. The parent waits up to 5 seconds for the worker to exit; after that it stops waiting, releases its ownership files, and leaves the worker to finish and exit on its own rather than killing it mid-write. `auto-sync stop` uses this same control path on macOS and Windows.
`pdg` is configured per project. `pdg: true` builds and maintains the full CFG, control-dependence, reaching-definition, and taint layers on both initial and incremental analyses. Auto-sync requests staged atomic incremental publication where the analyzer supports it: the old graph remains available to readers until the replacement succeeds, and analysis errors are recorded while the old graph remains intact. Unsupported paths retain the analyzer's existing in-place behavior. Untouched configs that omit `pdg` preserve the existing index 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 `pdg: false` disables PDG and emits a warning before a successful rebuild removes those layers. `overwrite_local_changes` defaults to `false`; a dirty local clone is skipped with an error log, while `true` allows branch fallback to replace local changes and additionally discards untracked files and directories in the clone after checkout — ignored paths, including GitNexus's own `.gitnexus/` storage, are preserved. `max_concurrency` defaults to `1` and is capped at runtime by `floor(availableMemoryGB / 2)` with a minimum of `1`; the effective value is printed at the start of each loop. Each analysis worker's heap cap is the machine-wide cap divided by the number of repositories analyzed in parallel, so concurrent workers share one memory budget instead of each claiming the whole machine. `analyze_failure_threshold` defaults to `3`, must be at least `2`, and pauses repeated failures only for the same repo branch, commit, and requested PDG mode; a new commit, a PDG mode change, or `gitnexus auto-sync reset` clears the block and allows analysis again. Repositories are registered and added to groups by their full remote identity (`host/namespace/repo`), so repositories with the same basename remain distinct. Use `branches` to try branches in order; legacy `branch` remains supported, but the two fields cannot be set together. If all branches are unavailable or time out, watch logs an error, records the repo status, and skips that repo for the loop. Leave `group_name` empty or omit it to skip group add/sync for that project; otherwise create the group first with `gitnexus group create <name>`. `$GITNEXUS_HOME/watch/project_commit_info.txt` is for inspection only; GitNexus stores machine state separately in `$GITNEXUS_HOME/watch/auto-sync-state.json`.
@ -723,6 +758,11 @@ If analyze says the repository doesn't fit, do what the message says:
- **The machine is the ceiling**: shrink the scope (exclude generated or
vendored directories, below) or use a machine with more RAM.
To set the main-thread heap yourself on a memory-constrained host, pass
`--memory-budget <mb>`: analyze re-runs with exactly that V8 heap, overriding
the auto-sizer and any `--max-old-space-size` pin. It sizes the main thread
only; parse workers keep their own caps.
Escape hatches (`GITNEXUS_MEMORY=off` to decline the autopilot,
`GITNEXUS_WORKER_HEAP_MB` to size workers yourself) are listed in the
environment-variable table below —
@ -764,6 +804,22 @@ npx gitnexus analyze
Values above **32768 KB (32 MB)** are clamped to the tree-sitter parser ceiling; invalid values fall back to the 512 KB default with a one-time warning. When an override is active, `analyze` prints the effective threshold in its startup banner (e.g. `GITNEXUS_MAX_FILE_SIZE: effective threshold 2048KB (default 512KB)`).
### Process detection reports missing flows
On a large repository, `analyze` may warn that `[processes] … whole flows are MISSING`. That means ranked entry points or completed flows were sampled away by the analyze-time detection budget — not that the code path is absent, and not the query-time `IMPACT_MAX_CHUNKS` cap.
Defaults stay in place when nothing is set: dynamic `maxProcesses = max(20, round(non-File symbols / 10))`, branching `4`, trace depth `10`, entry-point candidate pool `200`. Raise a knob only when the warning names it:
```bash
# Usual first move when entryPointCandidatesDropped is the loud counter
npx gitnexus analyze --max-entry-point-candidates 400
# When ranked entry points were never traced, or flows were dropped at maxProcesses
npx gitnexus analyze --max-processes 80
```
Equivalent `.gitnexusrc` keys: `maxProcesses`, `maxProcessBranching`, `maxProcessTraceDepth`, `maxEntryPointCandidates`. Equivalent env vars: `GITNEXUS_MAX_PROCESSES`, `GITNEXUS_MAX_PROCESS_BRANCHING`, `GITNEXUS_MAX_PROCESS_TRACE_DEPTH`, `GITNEXUS_MAX_ENTRY_POINT_CANDIDATES`. Precedence is CLI > `.gitnexusrc` > env > default. `0` is invalid, not unlimited. Changing these knobs re-runs process detection on the next `analyze` without `--force`. Raising them increases CPU and memory; this is not a heap-OOM fix.
### Analyze reports a worker timeout
Worker parse timeouts are recoverable. GitNexus retries stalled worker jobs with backoff, splits large jobs to isolate slow files, and quarantines a file that repeatedly crashes its worker (respawning the slot so the pool keeps going). If a large repository needs more time per worker job, use either:
@ -792,6 +848,7 @@ Four env vars expose the pool's resilience layers (respawn budget, cumulative-ti
| `GITNEXUS_WORKER_READY_TIMEOUT_MS` | `5000` | Startup budget for a parse worker to load its grammar bindings and report `{type:'ready'}`. Slots that miss it are treated as startup crashes. Raise it on a slow or heavily loaded host where a full pool cold-starting concurrently needs more than 5s. |
| `GITNEXUS_MEMORY` | `off` | unset (autopilot on) | `off` declines GitNexus's memory autopilot: analyze will neither re-run itself with a RAM-aware heap cap nor abort the parse before V8 enters its ineffective-mark-compact death spiral. Use it when you want to drive memory manually; to simply pin a heap size, pass Node's own `--max-old-space-size`, which is already honoured as your decision. |
| `GITNEXUS_WORKER_HEAP_MB` | `clamp(512, RAM/2/poolSize, 4096)` | Per-worker V8 old-generation heap cap (#2649). Bounds pool RSS on large repos; a worker exceeding it dies with a real heap error handled by quarantine/respawn. |
| `GITNEXUS_HEAP_LIMIT_SOURCE` | unset | Set by analyze itself (`budget` or `auto`) to record where the heap limit came from, so out-of-memory advice points at `--memory-budget` rather than a `--max-old-space-size` pin. | Never — internal; set `--memory-budget` instead. |
| `GITNEXUS_SERVER_ANALYZE_HEAP_MB` | `min(8192, auto cap)` | Heap for the web/MCP server's forked analyze worker (#2649). Defaults to the historical 8192 MB bounded by the machine/container's RAM-aware auto cap; set an absolute MB value to override. |
| `GITNEXUS_CPP_CAPTURE_BUDGET_MS` | `20000` | Per-file wall-clock budget for C++ capture extraction; on breach the file keeps partial captures with a warning (#2432). `0` expires immediately. |

View file

@ -298,42 +298,42 @@
"imports": 3200,
"resolved": 1153,
"distinct_outcomes": 2987,
"fingerprint": "318084f48ffa4eeae4a5b7fc25916d4ad78673d92dba62b7e462d1bb87ca553a"
"fingerprint": "eb36bd1ccab5c4405cc79d48732b7aa272a90351db60664137663dde7b2db73c"
},
"large": {
"files": 1600,
"imports": 12800,
"resolved": 4681,
"distinct_outcomes": 11875,
"fingerprint": "5151cd2498bd4b7698dc9309e2539977d306f9ba82a388c630c89b51fc4a3187"
"fingerprint": "08c288bdeaa6f4518e7f5e32e3a8ac6265a5147ec366d37789db6cb573ed2bbe"
},
"deep": {
"files": 400,
"imports": 3200,
"resolved": 1153,
"distinct_outcomes": 2987,
"fingerprint": "79776ec1c22afa619fd31aeb05dcac567723b436d0461a5782693b9e929f6f74"
"fingerprint": "909c8444e0004692be0d0a8179f56173e87d790ad7aeea38bb54c647ce0d638f"
},
"collide": {
"files": 400,
"imports": 3200,
"resolved": 1153,
"distinct_outcomes": 2999,
"fingerprint": "1b145a4c3b41ffc4efa26f74449c3d44646d6d59728163ac896fc0ee25c6d608"
"fingerprint": "4625f0310442e60b4291fd9f5c9b90333f6e9175ab5b4c7c7eb2ecbd5def500d"
},
"collide_large": {
"files": 1600,
"imports": 12800,
"resolved": 4681,
"distinct_outcomes": 11948,
"fingerprint": "b7e5303220b8fa64e85c7e17622961018316a10c5ef921a02584864309748b52"
"fingerprint": "9e7aa3a503352b1f437a70e51ccfe020dff65da319c6082fb25b1ae4417c3531"
},
"fingerprint": "5151cd2498bd4b7698dc9309e2539977d306f9ba82a388c630c89b51fc4a3187",
"fingerprint": "08c288bdeaa6f4518e7f5e32e3a8ac6265a5147ec366d37789db6cb573ed2bbe",
"heap": {
"files_small": 8000,
"files_large": 32000,
"path_segments": 11,
"probe": "package:ext0/src/thing.dart"
"probe": "./src/thing.dart"
},
"_measured": {
"collide_ms": 1.511,
@ -659,6 +659,11 @@
"path_segments": 13,
"probe": "ExternalPkg0"
},
"context": {
"target": "App",
"with_context": "Sources/App/Lib.swift,Sources/Models/User.swift",
"without_context": "Sources/App/Lib.swift"
},
"_measured": {
"collide_ms": 0.819,
"collide_scaling_ratio": 3.454,
@ -976,6 +981,11 @@
"depth_ratio": 1.938,
"scaling_ratio": 1.033,
"small_ms": 1.649
},
"context": {
"target": "stdio.h",
"with_context": "<null>",
"without_context": "src/stdio.h"
}
},
"cpp": {
@ -1027,6 +1037,11 @@
"depth_ratio": 2.064,
"scaling_ratio": 1.167,
"small_ms": 1.626
},
"context": {
"target": "cstdio.h",
"with_context": "<null>",
"without_context": "src/cstdio.h"
}
},
"objc": {

View file

@ -105,12 +105,14 @@
* path components, so the `deep` arm's uniform prefix reaches the
* config (see `tsBaseUrlFor`) and its cost is the same keyed lookup the
* other arms pay.
* - c, cpp: `resolveCppImportTarget` delegates to `resolveCImportTarget`, so
* the two share a resolver and differ in extension set and in which adapter
* builds the augmented set. Cost is a basename bucket walk with a
* depth-then-lexicographic tie-break, so the collide arm (a `mod{n}` header
* in every service's `include/`) is where it grows: 2.54 / 2.64 against
* 1.06 on file count.
* - c, cpp: quoted includes walk a basename bucket (depth, then lexicographic).
* The collide arm — a `mod{n}` header in every service's `include/` — is
* where that walk grows: 2.54 / 2.64 against 1.06 on file count. Each
* language keeps its own bucket memo. Angle includes join the target onto
* header search paths and never take that walk. The timing corpus passes
* a raw header set and no `isSystem` flag, so it stays on the quoted path.
* `--check` also resolves a quoted hit, an angle hit on a declared include
* root, and an angle miss against a same-named `src/` decoy.
* - zig: `resolveZigImportInternal` is rust's shape — an `@import("…zig")`
* path is walked component by component from the importer's directory and
* probed with two `allFiles.has(...)` calls (as written, then `+ '.zig'`),
@ -208,12 +210,14 @@
* workload — identical file, import and resolved counts — laid out the way
* these languages are actually written: `svcN/internal/`, `SrcN/Models/`, a
* `mod0.dart`/`mod0.rb` in every package. Measured on that shape the per-import
* cost is NOT corpus-size-independent for the four resolvers that scan a
* cost is NOT corpus-size-independent for the three resolvers that scan a
* bucket:
*
* - go, csharp and java walk `PackageDirIndex.dirsByLastSegment[seg]`, which
* now holds every directory;
* - dart walks its basename bucket, which now holds every same-named file;
* - dart formerly walked its basename bucket. Since #2963 its package-URI
* arms use exact declared-package paths; the separate heap probe and
* scan-count regressions still exercise its relative-path suffix index;
* - ruby, kotlin, php and cobol answer from keyed maps and are collision-
* IMMUNE, so their collide budgets are the linear ones — that immunity is
* the assertion, and for cobol the arm is also the only one that reaches
@ -616,18 +620,19 @@ const HEAP_BUDGETED = [
// were about.
/**
* The arms handed the fifth `context` argument — `{ parsedFiles, parsedImport }`
* — because their registered hook DECLARES it. Four of seventeen arms, and the
* inventory arm at the foot of this file reconciles that claim against
* `SCOPE_RESOLVERS` in both directions rather than trusting this line.
* Arms whose registered hook declares the fifth `context` argument
* (`{ parsedFiles, parsedImport }`). The inventory at the foot of this file
* reconciles this list against `SCOPE_RESOLVERS` in both directions, so
* membership is that check rather than a count written here.
*
* These are also the only arms for which `newPass` builds a `ParsedFile[]` at
* all. Building one for the other thirteen would cost their timed loop an
* O(files) allocation per pass that no resolver of theirs can even observe —
* their hooks declare three or four parameters — so their numbers stay exactly
* where they were.
* `newPass` builds a `ParsedFile[]` only for the members that are not header
* languages. C and C++ are listed because they read `parsedImport.isSystem`,
* but `newPass` returns on `HEADER_EXTENSION` before this list, so a timed
* pass does not allocate that array for them. Arms whose hooks declare fewer
* parameters cannot observe a context and are not listed, so their timed
* numbers stay on the three-argument shape.
*/
const CONTEXT_LANGS = ['php', 'java', 'kotlin', 'python'];
const CONTEXT_LANGS = ['php', 'java', 'kotlin', 'python', 'swift', 'c', 'cpp'];
/**
* Needs `node --expose-gc` to force collection for a clean delta; without it
@ -849,7 +854,8 @@ function uniqueDir(lang, d, i) {
// and breaks its same-workload invariant. C# therefore exercises the removed
// rule through progressive stripping rather than through its primary query.
if (lang === 'csharp') return d % 7 === 0 ? `src/Ns${d}/Sub/Ns${d}` : `src/Ns${d}`;
if (lang === 'dart') return d % 3 === 0 ? `lib/feature${d}` : `pkg/feature${d}`;
// Keep every synthetic local import valid under app's pubspec lib root.
if (lang === 'dart') return `lib/feature${d}`;
if (lang === 'kotlin') {
return d % 7 === 0
? `mod${d}/src/main/kotlin/com/example/pkg${d}/inner/pkg${d}`
@ -1500,11 +1506,10 @@ function collideTarget(lang, { local, r, d, j, dirs }) {
}
if (lang === 'dart') {
return local
? `package:app/pkg${j % dirs}/lib/src/mod${Math.floor(j / dirs)}.dart`
? `package:pkg${j % dirs}/src/mod${Math.floor(j / dirs)}.dart`
: (r >>> 3) % 3 === 0
? ['dart:core', 'dart:async', 'dart:io'][(r >>> 4) % 3]
: // A repeated basename under a directory nothing carries: both
// candidates walk the whole basename bucket and miss.
: // Foreign package names must miss despite repeated local basenames.
`package:ext${(r >>> 4) % 97}/other/mod${(r >>> 4) % 8}.dart`;
}
if (lang === 'kotlin') {
@ -1730,8 +1735,11 @@ function buildRepo(lang, fileCount, pad = 0, shape = 'unique') {
* `csharp_csproj` is the precedent and stays where it is: a per-language
* CONTEXT over a corpus aliased to another language's, rather than a new axis.
*
* `parsedFiles` is the third pass-stable object, present for `CONTEXT_LANGS`
* and undefined for everyone else. It is built BEFORE the path set and the path
* `parsedFiles` is the third pass-stable object. `newPass` builds it for the
* `CONTEXT_LANGS` members that are not header languages, and leaves it
* undefined otherwise — C and C++ are in that list but return on
* `HEADER_EXTENSION` first, so a timed pass does not allocate the array.
* Where it is built, it is built BEFORE the path set and the path
* set is derived FROM it, which is not a stylistic choice: `run.ts` does
* `new Set(parsedFiles.map((f) => f.filePath))`, so two independently built
* lists would be a shape the pipeline cannot produce. Fresh per pass for
@ -1740,6 +1748,16 @@ function buildRepo(lang, fileCount, pad = 0, shape = 'unique') {
* hide their build from rep 2 onward and `fastest()` reports the minimum.
*/
function newPass(lang, files, pad = 0) {
if (lang === 'dart') {
// Synthetic pubspec declarations: app at the root, one package per
// collision directory. Package imports no longer suffix-match (#2963).
const packages = new Map([['app', joinBase(tsBaseUrlFor(pad), 'lib')]]);
for (const file of files) {
const match = /(?:^|\/)(pkg\d+)\/lib\//.exec(file);
if (match) packages.set(match[1], joinBase(tsBaseUrlFor(pad), `${match[1]}/lib`));
}
return { allFilePaths: new Set(files), config: { packages } };
}
if (HEADER_EXTENSION[lang] !== undefined) {
const sources = [];
const headers = [];
@ -1803,7 +1821,7 @@ function resolveAll(lang, files, imports, pad = 0) {
function resolveOne(lang, from, target, pass) {
const allFilePaths = pass.allFilePaths;
if (lang === 'go') return resolveGoImportTarget(target, from, allFilePaths, GO_MODULE);
if (lang === 'dart') return resolveDartImportTarget(target, from, allFilePaths);
if (lang === 'dart') return resolveDartImportTarget(target, from, allFilePaths, pass.config);
if (lang === 'ruby') return resolveRubyImportTarget(target, from, allFilePaths);
if (lang === 'kotlin') {
const parsedImport = {
@ -1869,7 +1887,7 @@ function resolveOne(lang, from, target, pass) {
if (lang === 'swift') {
return resolveSwiftImportTarget(
{ kind: 'namespace', localName: 'X', importedName: 'X', targetRaw: target },
{ fromFile: from, allFilePaths },
{ fromFile: from, allFilePaths, parsedFiles: pass.parsedFiles },
);
}
if (lang === 'rust') return resolveRustImportTarget(target, from, allFilePaths, undefined);
@ -1918,10 +1936,22 @@ function resolveOne(lang, from, target, pass) {
return typescriptScopeResolver.resolveImportTarget(target, from, allFilePaths, pass.config);
}
if (lang === 'c') {
return cScopeResolver.resolveImportTarget(target, from, allFilePaths, pass.config);
return cScopeResolver.resolveImportTarget(
target,
from,
allFilePaths,
pass.config,
pass.includeContext,
);
}
if (lang === 'cpp') {
return cppScopeResolver.resolveImportTarget(target, from, allFilePaths, pass.config);
return cppScopeResolver.resolveImportTarget(
target,
from,
allFilePaths,
pass.config,
pass.includeContext,
);
}
if (lang === 'objc') {
return objectiveCScopeResolver.resolveImportTarget(target, from, allFilePaths, pass.config);
@ -2131,6 +2161,10 @@ const HEAP_RETAINED = [];
* #2903 made lazy. It is the witness that the read pattern IS the
* footprint: same corpus and same `getWorkspaceFileIndex` as `csharp`,
* three times the retained bytes.
*
* `dart` is the exception (`./src/thing.dart` below). `uniqueTarget` never
* emits a relative path, and no timing arm exercises the relative suffix
* fallback, so that probe does.
*/
const HEAP_PROBE_TARGET = {
csharp: 'Ghost0.Deep.Missing',
@ -2149,13 +2183,15 @@ const HEAP_PROBE_TARGET = {
objc: 'vendor0/missing.m',
// The entries below cover the BOUNDED tier — see `HEAP_BOUNDED`, which
// derives to cobol, swift and rust; the rest were promoted. Same rule as the
// budgeted ones above: a spelling `uniqueTarget` already mints for that language, and
// budgeted ones above, except `dart`: a spelling `uniqueTarget` already mints, and
// one that MISSES, so the reading is the index and the cascade runs to the
// end. Chosen from the miss family that reaches furthest into each cascade:
// - `go` names a missing package inside GO_MODULE, which reaches the
// package-directory lookup and forces `PackageDirIndex`;
// - `dart` is an external package, so BOTH candidate paths miss and both
// walk the basename bucket to completion;
// - `dart` is a relative miss (`./src/thing.dart`), not a `uniqueTarget`
// spelling. No timing arm exercises the relative suffix fallback; this
// probe does, so the basename index stays in the heap reading. Package
// imports use exact membership and allocate no file index;
// - `kotlin` misses after building its declared-package/module-binding index;
// - `cobol` misses in both tier maps, `swift` in `byModule`, and `rust`
// probes candidate paths and builds nothing — that last is the reading
@ -2165,7 +2201,7 @@ const HEAP_PROBE_TARGET = {
// bound compares like with like. `vue`'s is bare rather than `@/…`
// because the alias branch rewrites to `src/` and would resolve.
go: 'example.com/mod/repo0/pkg/util',
dart: 'package:ext0/src/thing.dart',
dart: './src/thing.dart',
kotlin: 'com.ghost0.deep.Missing',
cobol: 'VENDOR0',
swift: 'ExternalPkg0',
@ -2326,6 +2362,50 @@ const CONTEXT_PROBE = {
probeFile('app/main.py', [['Function', 'app.main.run']]),
],
},
/**
* `import App` where App `@_exported import`s Models. With parsedFiles the
* re-export closure unions Models' files; without it the adapter returns
* only App's own file. Two distinct non-null answers, so a dropped
* `parsedFiles` cannot look like a miss.
*/
swift: {
from: 'Sources/Client/Main.swift',
target: 'App',
parsedFiles: [
{
...probeFile('Sources/App/Lib.swift', [['Class', 'App.Lib']]),
parsedImports: [
{ kind: 'reexport', localName: 'Models', importedName: 'Models', targetRaw: 'Models' },
],
},
probeFile('Sources/Models/User.swift', [['Class', 'Models.User']]),
probeFile('Sources/Client/Main.swift', [['Class', 'Client.Main']]),
],
},
/**
* `#include <stdio.h>` with a repo file `src/stdio.h`. With the fifth
* argument the include is an angle include and the decoy is not on a
* search path, so the answer is null. Without it the call is the quoted
* basename walk and the decoy wins. Same shape for C++ `cstdio.h`.
*/
c: {
from: 'src/main.c',
target: 'stdio.h',
parsedFiles: [
probeFile('src/main.c', []),
probeFile('src/stdio.h', []),
probeFile('include/util.h', []),
],
},
cpp: {
from: 'src/main.cpp',
target: 'cstdio.h',
parsedFiles: [
probeFile('src/main.cpp', []),
probeFile('src/cstdio.h', []),
probeFile('include/util.hpp', []),
],
},
};
/** Resolve the probe twice through `resolveOne` — once with the pass's parsed
@ -2337,9 +2417,14 @@ function measureContext(lang) {
const config = lang === 'php' ? phpComposerConfigFor(0) : undefined;
const answer = (files) => {
restoreBenchmarkSideChannels(lang, files ?? []);
return renderResolved(
resolveOne(lang, from, target, { allFilePaths, config, parsedFiles: files }),
);
const pass = { allFilePaths, config, parsedFiles: files };
if ((lang === 'c' || lang === 'cpp') && files !== undefined) {
pass.includeContext = {
parsedFiles: files,
parsedImport: { kind: 'wildcard', targetRaw: target, isSystem: true },
};
}
return renderResolved(resolveOne(lang, from, target, pass));
};
return {
target,
@ -2348,6 +2433,85 @@ function measureContext(lang) {
};
}
/**
* Quote vs angle for C and C++, on top of the scaling arms. The timing corpus
* stays quoted (no `isSystem`) so its fingerprints do not move. This is the
* arm that fails if angle includes go back to a repo-wide name hunt, or if a
* raw header set stops resolving a quoted include.
*/
function checkCIncludeForms(failures) {
const arms = [
{
lang: 'c',
resolver: cScopeResolver,
from: 'src/main.c',
files: ['src/main.c', 'src/stdio.h', 'include/util.h'],
quoted: 'util.h',
quotedHit: 'include/util.h',
angleHit: 'util.h',
angleHitFile: 'include/util.h',
angleMiss: 'stdio.h',
},
{
lang: 'cpp',
resolver: cppScopeResolver,
from: 'src/main.cpp',
files: ['src/main.cpp', 'src/cstdio.h', 'include/util.hpp'],
quoted: 'util.hpp',
quotedHit: 'include/util.hpp',
angleHit: 'util.hpp',
angleHitFile: 'include/util.hpp',
angleMiss: 'cstdio.h',
},
];
for (const arm of arms) {
const allFilePaths = new Set(arm.files);
const config = {
headers: new Set(arm.files.filter((file) => file !== arm.from)),
headerSearchPaths: ['include'],
userHeaderSearchPaths: [],
};
const resolve = (target, isSystem) =>
arm.resolver.resolveImportTarget(target, arm.from, allFilePaths, config, {
parsedFiles: [],
parsedImport: { kind: 'wildcard', targetRaw: target, isSystem },
});
const quotedResult = resolve(arm.quoted, false);
const angledResult = resolve(arm.angleHit, true);
const missedResult = resolve(arm.angleMiss, true);
if (quotedResult !== arm.quotedHit) {
failures.push(
`${arm.lang}: quoted "${arm.quoted}" resolved to ${JSON.stringify(quotedResult)}, ` +
`expected ${arm.quotedHit}`,
);
}
if (angledResult !== arm.angleHitFile) {
failures.push(
`${arm.lang}: angle <${arm.angleHit}> resolved to ${JSON.stringify(angledResult)}, ` +
`expected ${arm.angleHitFile} on the declared include root`,
);
}
if (missedResult !== null) {
failures.push(
`${arm.lang}: angle <${arm.angleMiss}> resolved to ${JSON.stringify(missedResult)}; ` +
`a same-named file outside the include root must miss`,
);
}
const raw = arm.resolver.resolveImportTarget(
arm.quoted,
arm.from,
new Set([arm.from]),
new Set(arm.files.filter((file) => file !== arm.from)),
);
if (raw !== arm.quotedHit) {
failures.push(
`${arm.lang}: a raw header set resolved quoted "${arm.quoted}" to ${JSON.stringify(raw)}, ` +
`expected ${arm.quotedHit}`,
);
}
}
}
function fingerprint(outcomes) {
return crypto
.createHash('sha256')
@ -2518,6 +2682,7 @@ if (!CHECK) {
const baseline = JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf-8'));
const failures = [];
checkCIncludeForms(failures);
/**
* PRESENCE, for one budget, in the one place that spells the reason.
@ -3064,10 +3229,12 @@ expectNoOrphanKeys(
// The SAME reconciliation for `CONTEXT_LANGS`, against the registry rather than
// against a claim in a comment. `run.ts` passes the fifth argument to every
// provider; which ones can OBSERVE it is decided by how many parameters each
// hook declares, and that is a number the registry can be asked for. Today
// exactly four answer 5 (php, java, kotlin, python) and the other thirteen answer 3 or 4 —
// which is why thirteen arms can ignore this whole question and their numbers
// did not move when it was fixed.
// hook declares, and that is a number the registry can be asked for. php,
// java, kotlin, python, and swift read the parsed workspace; c and cpp read
// `parsedImport.isSystem` and nothing else on that object. Their timed passes
// still return before any parsed-file build (see `newPass`), so the fifth
// argument shows up here as the include-form probe, not as a cost on the
// scaling arms.
//
// `Function.length` stops at the first defaulted or rest parameter, so a hook
// written as `(a, b, c, d, context = {})` would read 4 and slip past this arm.

View file

@ -43,30 +43,56 @@ export function runFingerprintParityCheck(report, leftArm, rightArm) {
);
}
export function runBaselineCheck(report, baselinePath) {
const baseline = JSON.parse(fs.readFileSync(baselinePath, 'utf-8'));
function isFiniteNonNegativeNumber(value) {
return typeof value === 'number' && Number.isFinite(value) && value >= 0;
}
function missingBudgetError(field) {
return `no numeric ${field} in baselines.json — a missing budget is a DELETED GATE, not a passing arm`;
}
/** Pure --check errors. Missing/non-numeric budgets must fail closed. */
export function collectBaselineErrors(report, baseline) {
const errors = [];
if (report.fingerprint !== baseline.fingerprint) {
errors.push(`fingerprint drift: ${report.fingerprint} != ${baseline.fingerprint}`);
}
if (report.scaling_ratio > baseline.scaling_budget) {
if (!isFiniteNonNegativeNumber(baseline.scaling_budget)) {
errors.push(missingBudgetError('scaling_budget'));
} else if (report.scaling_ratio > baseline.scaling_budget) {
errors.push(`scaling_ratio ${report.scaling_ratio} > ${baseline.scaling_budget}`);
}
if (
baseline.absolute_ms_budget !== undefined &&
report.absolute_ms > baseline.absolute_ms_budget
) {
errors.push(`absolute_ms ${report.absolute_ms} > ${baseline.absolute_ms_budget}`);
if (Object.hasOwn(report, 'absolute_ms')) {
if (!isFiniteNonNegativeNumber(baseline.absolute_ms_budget)) {
errors.push(missingBudgetError('absolute_ms_budget'));
} else if (report.absolute_ms > baseline.absolute_ms_budget) {
errors.push(`absolute_ms ${report.absolute_ms} > ${baseline.absolute_ms_budget}`);
}
}
if (
baseline.widening_overhead_budget !== undefined &&
report.widening_overhead > baseline.widening_overhead_budget
) {
errors.push(
`widening_overhead ${report.widening_overhead} > ${baseline.widening_overhead_budget}`,
);
if (Object.hasOwn(report, 'widening_overhead')) {
if (!isFiniteNonNegativeNumber(baseline.widening_overhead_budget)) {
errors.push(missingBudgetError('widening_overhead_budget'));
} else if (report.widening_overhead > baseline.widening_overhead_budget) {
errors.push(
`widening_overhead ${report.widening_overhead} > ${baseline.widening_overhead_budget}`,
);
}
}
failIfNeeded(report, errors);
if (Object.hasOwn(report, 'chain_scaling_ratio')) {
if (!isFiniteNonNegativeNumber(baseline.chain_scaling_budget)) {
errors.push(missingBudgetError('chain_scaling_budget'));
} else if (report.chain_scaling_ratio > baseline.chain_scaling_budget) {
errors.push(
`chain_scaling_ratio ${report.chain_scaling_ratio} > ${baseline.chain_scaling_budget}`,
);
}
}
return errors;
}
export function runBaselineCheck(report, baselinePath) {
const baseline = JSON.parse(fs.readFileSync(baselinePath, 'utf-8'));
failIfNeeded(report, collectBaselineErrors(report, baseline));
console.log(JSON.stringify({ ok: true, report }, null, 2));
}

View file

@ -8,8 +8,8 @@
"list_repos": 200,
"_shape_note": "THE FLOOR. Without these three, every ratio below is a ceiling over nothing. listTools_vs_listRepos_ratio only asserts something while the corpus still pays N parallel rev-list processes. Shrink it to three happy-path rows and both arms are cheap; the ratio still passes, asserting a property the corpus no longer has.",
"tools_listed": 17,
"_tools_note": "Exact GITNEXUS_TOOLS roster size returned by client.listTools(). A schema path that throws, filters, or returns [] still looks fast on the ratio arm.",
"tools_listed": 19,
"_tools_note": "Exact GITNEXUS_TOOLS roster size returned by client.listTools(). 19 after read_file and grep were added as the MCP twins of GET /api/file and GET /api/grep (was 17). GITNEXUS_TOOLS.length and listTools must move together; a schema path that throws, filters, or returns [] still looks fast on the ratio arm.",
"schema_read_only_requires_repo": true,
"schema_mutating_requires_repo": true,

View file

@ -1 +1 @@
7412edd9db56b4626c77fc09363e0d49a2f95763453756dceb82db6606280c28
dde4450f8bb763575b0464255f063fd0ded24980ecf43d7d2a7d4dc4880f2665

View file

@ -1,5 +1,37 @@
# Receiver-resolution baseline
## Python mixin dispatch (#3390)
The added mixin fixture deliberately calls an absent `missing_target` on a known
in-program receiver. The resolver now records that unresolved call rather than
silently treating missing edges as complete coverage. The focused Python resolver
test asserts this outcome. CI run 36319863343 at `4034cee` measured one additional
Python call drop (113 to 114; all-kind total 159 to 160), classified as in-program
with no receiver-shape annotation. No shape-arm result or performance threshold
changed. The renamed bound-receiver correction preserves the existing method's
effective arity. The measurements below supersede this earlier snapshot.
The final #3390 head did not retain that snapshot: its full corpus measured 125
call drops, including 12 in the new mixin fixture. At #3393 head, C3 resolves
`order_hook` to `OrderX.order_hook`, removing that fixture's one ambiguous drop.
The full corpus now measures 124 call drops (16 Python, 54 in-program), with 11
from the mixin fixture. The ten fixture outcomes missing from the old baseline
are three `helper()` calls with valid targets and an unproven variadic sibling,
field shadowing, three incompatible argument shapes, private-name lookup, an
abstract declaration, and duplicate definitions. The integration test pins
their exact call sites; none of these ten is the C3 `order_hook` call. These
counts track conservative unresolved coverage, including partial fan-out, not
only calls with no emitted edge.
The Python capture fingerprint is also intentionally regenerated: ordinary call
captures now include statically known argument counts, the corpus includes eight
new mixin fixture files, and bound method parameter counts exclude receivers by
class/decorator context rather than spelling. Unknown splat cardinalities remain
unknown. The final deterministic corpus contains 213 entries and 3,463 capture
groups. The golden test pins each fixture; only the mixin and renamed target
digests changed in the bound-receiver delta, with their group counts unchanged.
Scaling limits and all non-Python capture baselines remain unchanged.
> **`baseline.json` is the source of truth for every number.** It is what
> `measure.mjs --check` enforces byte-exactly. This file is a lab notebook:
> each section records what was measured AT THAT UNIT and why it changed the

View file

@ -199,21 +199,21 @@
}
},
"countArm": {
"callDrops": 113,
"totalDropsAllKinds": 159,
"callDrops": 124,
"totalDropsAllKinds": 170,
"bySiteKind": {
"call": 113,
"call": 124,
"read": 27,
"write": 19
},
"callDropsByExtension": {
".java": 49,
".py": 16,
".zig": 11,
".cs": 8,
".ts": 7,
".cpp": 7,
".tsx": 6,
".py": 5,
".go": 5,
".php": 4,
".kt": 4,
@ -226,12 +226,13 @@
"chain-field": 60,
"chain-call": 27,
"no-chain": 23,
"<<unclassified>>": 11,
"chain-mixed": 2,
"chain-unwrap": 1
},
"callDropsByOrigin": {
"in-program": 54,
"external": 44,
"in-program": 43,
"unknown": 26
}
}

View file

@ -106,7 +106,7 @@
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 3745662053c76b6ae0a84a29aad319626ed5ccb88f7b9376c2680d3dc6502e28 -> b213a872342da2d866b04681dede988770e4d3dfdc0d6e9f62212ec5b59cdc2c."
},
"ruby": {
"fingerprint": "1c8c9c4b54036fa24c2a81e39ea530e938645c856d369075e5f437da78218c57",
"fingerprint": "45e65d9fa8a9e5e905ddb596b179b77c86ed13ab5139c6b17ccaaf7315dfe46a",
"scaling_budget": 1.5,
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior cff273ae6cb7232c977d9241581834a2a2fa8bcf6369f7bd8f2471cd4419a6ef -> bf50ec6a53c8c91680dc6feac63a8956e78b1059249232dc25a0cfed25f31236; scaling 1.103 < 1.5.",
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Ruby Method/Proc callable flow facts with invocation/constructor-result suppression. Prior b5ea93bb3d0469c3821a8c70f5d5991c6f326e41097c119ad691154301dcc753 -> cff273ae6cb7232c977d9241581834a2a2fa8bcf6369f7bd8f2471cd4419a6ef; scaling 1.086 < 1.5.",
@ -114,10 +114,13 @@
"_note": "F62: + scope_resolution class/module declaration captures \u2014 fixture count 78\u219281, fingerprint drift expected. #1975: + ruby-tail-collision fixture (Foo::Bar vs Baz::Bar stay distinct nodes) \u2014 pure fixture-corpus drift, scope-extractor captures unchanged; 81\u219282. #1991: + ruby-nested-mixin-tail-collision fixture (85\u219286). Recomputed on the #942 merge (fixture-comment rewording shifts capture byte-positions, capture LOGIC unchanged): bf6b13a -> b5ea93bb.",
"_rebaselined_2522_review_fixes": "PR #2522 review fixes: bare identifiers are calls, not callable references (bareNamesAreCalls). Prior bf50ec6a53c8c91680dc6feac63a8956e78b1059249232dc25a0cfed25f31236 -> 070e4e11502442998ddf4048c2981cf1b2b735a87362ff854c5d14d71f98f4e2; scaling ratio re-verified within budget.",
"_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior fea3edf82f521995147874b7f6c5f9e2eb88efdebf6365668f3260e913f0b558 -> fc81941b0a921074fa80dc448284de9a23bd07358ddc84d4894797cc08c3fe83.",
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior fc81941b0a921074fa80dc448284de9a23bd07358ddc84d4894797cc08c3fe83 -> 1c8c9c4b54036fa24c2a81e39ea530e938645c856d369075e5f437da78218c57."
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior fc81941b0a921074fa80dc448284de9a23bd07358ddc84d4894797cc08c3fe83 -> 1c8c9c4b54036fa24c2a81e39ea530e938645c856d369075e5f437da78218c57.",
"_rebaselined_3354_callable_alternatives": "#3354 callable alternatives: a callable chosen by a value-selecting source now flows every branch it can yield (`a ?? b`, `a || b`, `a or b`, `c ? a : b`, statement `if`, elvis), and an operator branch (`x == f || g`) stays opaque instead of seeding a qualified name. Verified by running the BASE (merge-base 233ca2849) and HEAD emitters over the SAME HEAD fixture corpus: every added or removed match is an `@callable-flow.*` match on one of those sources, and the pre-existing corpus is byte-identical (all other languages: zero delta). The rest of the drift is corpus growth from this PR's regression fixture, which this bench globs. Corpus growth: ruby-callable-alternatives/app.rb (+1 file, +58 groups). Emitter delta: +5 / -0: seeds for single-statement `if` / `elsif` branches (run_then, run_else, run_sweep, run_a, run_b); the multi-statement branch contributes nothing. capture_groups_fp 1358 -> 1416, fixture_count 91 -> 92; synthetic counts unchanged; scaling 1.04 < 1.5. Prior 1c8c9c4b54036fa24c2a81e39ea530e938645c856d369075e5f437da78218c57 -> 45e65d9fa8a9e5e905ddb596b179b77c86ed13ab5139c6b17ccaaf7315dfe46a."
},
"swift": {
"fingerprint": "adef9284feaecd39cb490aebce83876e15b9150c7a04b00a396feb78b7e1e0a9",
"fingerprint": "d56406c2645637042899cfcc8dc73f603d77caef0a9a2bac179ffbec848258a3",
"_rebaselined_3355_xcode_and_import_fixtures": "#3355 follow-up: new swift-xcode-targets fixture (four sources) and two sources added to swift-nested-packages (a Docs/Net folder decoy and an `import Net` caller). The capture query is unchanged; this is fixture-corpus growth only. capture_groups_fp 1393 -> 1436 and fixture_count 77 -> 83; synthetic scale counts remain 5012/16012. Prior aea33bf12f57561be7e3125929fb98cec7aed5a681e25aad435c7bb558747853 -> d56406c2645637042899cfcc8dc73f603d77caef0a9a2bac179ffbec848258a3; measured scaling 1.036 < 1.5.",
"_rebaselined_3355_nested_packages": "#3355: new swift-nested-packages fixture (three nested Package.swift manifests, six sources) for nested-package module grouping. The capture query is unchanged; this is fixture-corpus growth only. capture_groups_fp 1333 -> 1393 and fixture_count 68 -> 77; synthetic scale counts remain 5012/16012. Prior c9fc553662f0db18027fba882e3ff730744f6153cc46eca7b00667fabd6a0df8 -> aea33bf12f57561be7e3125929fb98cec7aed5a681e25aad435c7bb558747853; measured scaling 1.023 < 1.5.",
"scaling_budget": 1.5,
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 5f923c6604d825d12b249f31c155b0f4d13a8379d532e5dde64a0f9b15cf4725 -> 7687ee2466e16020a12440a03fbda53e63aa05f94b4481f6133c09867a0d560d; scaling 1.042 < 1.5.",
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Swift function-value callable flow facts with invocation-result suppression. Prior 180ac68e780bdf6f9089d53f51cbb9a66aed3e7774631cc3fcbaae5020213998 -> 5f923c6604d825d12b249f31c155b0f4d13a8379d532e5dde64a0f9b15cf4725; scaling 1.043 < 1.5.",
@ -125,18 +128,27 @@
"_rebaselined_2522_review_fixes": "PR #2522 review fixes: assignment target:/result: fields join the shared fallback. Prior 7687ee2466e16020a12440a03fbda53e63aa05f94b4481f6133c09867a0d560d -> 115c5da807e36bb12fdeba28e44f2b6484ef322ff26c19fa0f191febaf774248; scaling ratio re-verified within budget.",
"_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 115c5da807e36bb12fdeba28e44f2b6484ef322ff26c19fa0f191febaf774248 -> a6fca5f052ae5ec635b56051e28a168c864a988b2221a3279ddd69807378ba0b.",
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior a6fca5f052ae5ec635b56051e28a168c864a988b2221a3279ddd69807378ba0b -> 2f04ae960123cf50138a49fabdc5a146c2963170cecf5755c552b23c9055a9e7.",
"_rebaselined_inferred_field_receiver_2807": "#2807: optional property annotations (`var a: Outer?`) now emit a type binding. The prior pattern required the `user_type` to be a DIRECT child of the annotation, so an `optional_type` wrapper meant an optional field was never typed at all and its receiver could not resolve. ADDS @type-binding.annotation captures on the optional form only; no capture is removed. Prior 2f04ae960123cf50138a49fabdc5a146c2963170cecf5755c552b23c9055a9e7 -> adef9284feaecd39cb490aebce83876e15b9150c7a04b00a396feb78b7e1e0a9; scaling 1.023 < 1.5."
"_rebaselined_inferred_field_receiver_2807": "#2807: optional property annotations (`var a: Outer?`) now emit a type binding. The prior pattern required the `user_type` to be a DIRECT child of the annotation, so an `optional_type` wrapper meant an optional field was never typed at all and its receiver could not resolve. ADDS @type-binding.annotation captures on the optional form only; no capture is removed. Prior 2f04ae960123cf50138a49fabdc5a146c2963170cecf5755c552b23c9055a9e7 -> adef9284feaecd39cb490aebce83876e15b9150c7a04b00a396feb78b7e1e0a9; scaling 1.023 < 1.5.",
"_rebaselined_nested_constructor_3262": "#3262: the existing nested-constructor fixture now uses the qualified legal form `extension Outer.Container`, covering full extension-owner recovery. Fixture-only capture drift; no capture logic changed. Prior adef9284feaecd39cb490aebce83876e15b9150c7a04b00a396feb78b7e1e0a9 -> 3ac80b64776969effb75c8d32aa0bc38fc6a41c6c115a33829c502559fa404f9; scaling 1.025 < 1.5.",
"_rebaselined_3309_call_result_assignment": "#3309: the Swift protocol-extension regression fixture adds one file with an untyped local initialized from a helper call, plus a compound-field receiver call. The capture query is unchanged; this is fixture-corpus growth only. capture_groups_fp 1235 -> 1272 and fixture_count 62 -> 64; synthetic scale counts remain 5012/16012. Prior adef9284feaecd39cb490aebce83876e15b9150c7a04b00a396feb78b7e1e0a9 -> 4d32535d454cd79086f0fcaec3b75de855c1314e73630321460e569f19aa484f; measured scaling 1.020 < 1.5.",
"_rebaselined_3309_exact_return_replay": "#3309 follow-up: Swift callable declarations now emit exact return-type captures, and untyped local initializers emit call-result assignment captures through direct, try, await, and try-await forms. This is intentional capture-set growth covered by exact-callable collision and wrapper regressions. Prior 4d32535d454cd79086f0fcaec3b75de855c1314e73630321460e569f19aa484f -> 724553c91e8ebcf872d7302b14105d59476c230f9b623acdacc0b58e39912329; hosted benchmark scaling remained within the existing 1.5 budget.",
"_rebaselined_3308_merge_main": "Merge origin/main into #3308: combine #3262 fixture-owner recovery with #3309 protocol-extension capture growth. Prior 3ac80b64776969effb75c8d32aa0bc38fc6a41c6c115a33829c502559fa404f9 + 724553c91e8ebcf872d7302b14105d59476c230f9b623acdacc0b58e39912329 -> b0c89f8de1a4ce30409182e79c1c14cbc49a509d18d853c0d7c5f22f1f66be06; capture_groups_fp 1297, fixture_count 67.",
"_rebaselined_3308_public_extension": "#3308 review: the nested-constructor fixture now uses `public extension Outer.Container` so owner recovery is gated on modifier-prefixed headers. Fixture-only digest drift. Prior b0c89f8de1a4ce30409182e79c1c14cbc49a509d18d853c0d7c5f22f1f66be06 -> a6d61e3749c9c02b9a54611e42d03db02f8fabfded3d7ce53a1b7229d3d38d59; capture_groups_fp 1297, fixture_count 67.",
"_rebaselined_3308_public_enclosing_types": "#3308 review: `Outer` / `Container` / `Entry` in the nested-constructor fixture are now `public` so the `public extension` is valid Swift. Fixture-only digest drift (Types.swift capture text). Prior a6d61e3749c9c02b9a54611e42d03db02f8fabfded3d7ce53a1b7229d3d38d59 -> decf74c01af0c7f403e203b19c2bd92dbf95b4562f6da2b0ac5117ef872204da; capture_groups_fp 1297.",
"_rebaselined_3354_callable_alternatives": "#3354 callable alternatives: a callable chosen by a value-selecting source now flows every branch it can yield (`a ?? b`, `a || b`, `a or b`, `c ? a : b`, statement `if`, elvis), and an operator branch (`x == f || g`) stays opaque instead of seeding a qualified name. Verified by running the BASE (merge-base 233ca2849) and HEAD emitters over the SAME HEAD fixture corpus: every added or removed match is an `@callable-flow.*` match on one of those sources, and the pre-existing corpus is byte-identical (all other languages: zero delta). The rest of the drift is corpus growth from this PR's regression fixture, which this bench globs. Corpus growth: swift-callable-alternatives/App.swift (+1 file, +36 groups). Emitter delta: +6 / -3: `??` and ternary seeds and copies now name each operand; the 3 removed matches are the whole-expression seeds and copy that carried a `target-qualified-name` / `source-qualified-name` of the compound source. capture_groups_fp 1297 -> 1333, fixture_count 67 -> 68; synthetic counts unchanged; scaling 1.057 < 1.5. Prior decf74c01af0c7f403e203b19c2bd92dbf95b4562f6da2b0ac5117ef872204da -> c9fc553662f0db18027fba882e3ff730744f6153cc46eca7b00667fabd6a0df8."
},
"dart": {
"fingerprint": "3a8ddabbeb1cba47a4757451d4f79d726ca230fd15e860772b11526fbb1c6687",
"fingerprint": "f4edcfedb6e97def5ffbb9d3227fce2549e5c6c6cc876a05bc3b68a46ed30c24",
"scaling_budget": 1.5,
"_rebaselined_2963_package_imports": "#3369 package-import fixtures: dart-package-imports adds six .dart files. CORPUS GROWTH ONLY, NOT A CAPTURE CHANGE: parking that directory restores 795290a9524ee0dc38af635536e9744c401ec13828ec77822d39e59e1cd6e649 byte-for-byte. The six files contribute 33 capture groups (862 -> 895) and fixture_count 37 -> 43. Synthetic scale counts stay 5011/16011. Local scaling 1.091 < 1.5. Prior 795290a9524ee0dc38af635536e9744c401ec13828ec77822d39e59e1cd6e649 -> f4edcfedb6e97def5ffbb9d3227fce2549e5c6c6cc876a05bc3b68a46ed30c24.",
"_rebaselined_generic_instantiation_2912": "#2912: the Dart heritage marker carries a fourth field \u2014 the type arguments the clause was written with (`implements Validator<String>`) \u2014 so interface dispatch can prune implementors of a mismatched instantiation. Additive marker text on existing heritage matches rather than a new match, so this is digest drift only; a marker from a pre-#2912 cache simply has no fourth field and reads as unknown. Prior ba93c90dcd341259e8e088816bc8c76ad27882419f665e35c056dc22fa54cf73 -> 3a8ddabbeb1cba47a4757451d4f79d726ca230fd15e860772b11526fbb1c6687; scaling 1.027 < 1.5.",
"_rebaselined_2538": "#2538: Dart extension type headers are preprocessed into normal extension declarations before scope capture, so extension type symbols and their methods are now emitted. Intentional Dart-only capture fingerprint drift; CI measured scaling 1.042 < 1.5.",
"_rebaselined_2538_implements": "#2538 tri-review follow-up: Dart extension type implements clauses now emit heritage markers and fixture coverage asserts IMPLEMENTS edges, including multi-arg generic interfaces. Prior committed baseline 66a46d5ff09f3d11b2771db0f48596fe7057e95c5bc8f56241fdb911137298c3 -> ba93c90dcd341259e8e088816bc8c76ad27882419f665e35c056dc22fa54cf73; scaling 0.945 < 1.5.",
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 29ce2bfe70b246b1c9d5e99c0ec11e850c22e9672737592207242b7f4cc824b8 -> 66a46d5ff09f3d11b2771db0f48596fe7057e95c5bc8f56241fdb911137298c3; scaling 1.054 < 1.5.",
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Dart function-tearoff and selector callable flow facts, split signature/body lexical ownership, and invocation-result suppression. Prior 94bf2c26e1ba96f4211634aa572c0a989b503e717e75dfc5df04f66c417de80f -> 29ce2bfe70b246b1c9d5e99c0ec11e850c22e9672737592207242b7f4cc824b8; scaling 0.906 < 1.5.",
"_added": "#939: dart added to the scope-capture bench with the registry-primary migration. Heritage-bearing scale source (Entity extends Base implements Marker) gates the @reference.inherits synth + the postfix-chain reference walk at scale. emitDartScopeCaptures threads tree-sitter captured nodes (no findNodeAtRange root-walk), so it is linear (~1.0).",
"_rebaselined": "#1919 review CF3 fix: extended kotlin-local-property-owner (init/accessor destructuring) + new dart-accessor-owner fixture (getter/setter ownership). Fingerprint-only corpus drift; scaling ~1.0."
"_rebaselined": "#1919 review CF3 fix: extended kotlin-local-property-owner (init/accessor destructuring) + new dart-accessor-owner fixture (getter/setter ownership). Fingerprint-only corpus drift; scaling ~1.0.",
"_rebaselined_3354_callable_alternatives": "#3354 callable alternatives: a callable chosen by a value-selecting source now flows every branch it can yield (`a ?? b`, `a || b`, `a or b`, `c ? a : b`, statement `if`, elvis), and an operator branch (`x == f || g`) stays opaque instead of seeding a qualified name. Verified by running the BASE (merge-base 233ca2849) and HEAD emitters over the SAME HEAD fixture corpus: every added or removed match is an `@callable-flow.*` match on one of those sources, and the pre-existing corpus is byte-identical (all other languages: zero delta). The rest of the drift is corpus growth from this PR's regression fixture, which this bench globs. Corpus growth: dart-callable-alternatives/app.dart (+1 file, +33 groups). Emitter delta: +6 / -3: `??` and `?:` seeds and copies now name each operand; the 3 removed matches are the whole-expression seeds and copy that carried a qualified name of the compound source. capture_groups_fp 829 -> 862, fixture_count 36 -> 37; synthetic counts unchanged; scaling 1.033 < 1.5. Prior 3a8ddabbeb1cba47a4757451d4f79d726ca230fd15e860772b11526fbb1c6687 -> 795290a9524ee0dc38af635536e9744c401ec13828ec77822d39e59e1cd6e649."
},
"java": {
"fingerprint": "e22a753b9f59331898a3a27c43072b588c33639937b1f618f6c70ce04f072a9f",
@ -174,7 +186,7 @@
"capture_groups_fp": 680
},
"typescript": {
"fingerprint": "60e75bbe846f7200005f12c4d95c32e82d9d3feae9f2fd594e484670762b82b0",
"fingerprint": "77c9b4ea654123a64972db8348190ec250b669472b150db65ea8c7f20b355467",
"_rebaselined_3190": "Capture matches now retain explicit ESM export/private evidence, including synthesized default HOCs; CommonJS surfaces remain undecided. Capture group counts unchanged. Scaling budget unchanged.",
"scaling_budget": 1.5,
"_rebaselined_2934_import_type_only": "#2934: `import-decomposer.ts` attaches a presence-only `@import.type-only` synthetic capture to specifiers `tsc` erases, so `check --cycles` can stop counting type-only edges as initialization cycles. DIGEST DRIFT ONLY, NOT A CAPTURE-SET CHANGE \u2014 the tag is added to import matches that already existed, never a new match, the same shape as the #2747 receiver-chain rebaseline. Every count is unchanged: capture_groups_fp 2414, fixture_count 155, capture_groups_small/large 4503/14403 (those measure the SYNTHETIC scaling source, which has no imports at all). The fingerprint moves because `canonicalizeMatch` in measure.mjs hashes every TAG on every match, synthetics included, so one extra presence-only tag on an existing match rewrites that match's canonical string. Attribution is exact, not inferred: neutralizing ONLY the `m['@import.type-only'] = \u2026` assignment in import-decomposer.ts and re-running returns the fingerprint to c2fbf8a89e5686dd\u2026 byte-for-byte, so nothing else in the TypeScript capture stream moved. All 14 other languages report ok. Scaling 0.997 < 1.5. NOTE ON THE CONTROL: javascript did not move (2026993b\u2026, 43 fixtures), but it is a WEAK control here \u2014 `import type` is TypeScript-only syntax, so a JS corpus cannot express the construct and could not have drifted either way. It evidences no collateral damage, not the correctness of the TS change; the exact-attribution check above is what does that. Prior c2fbf8a89e5686dd1ff3659b20d41d8b05ebcc9790356e3653ee0c8ca5d365c8 -> f719163eb03a447c9e40ca316a905dd76cee82192a75a403df478ebbdc13e98f.",
@ -198,7 +210,8 @@
"_rebaselined_type_parameter_shadowing_w2_8": "W2-8: `@declaration.type-parameters` is now captured on generic FUNCTIONS, generator functions and type ALIASES, not only on class/interface declarations. NO NEW CAPTURE NAME \u2014 verified by diffing the capture-name sets against the wave-1 branch, which returns empty; the tag already existed and simply fires on more declarations. That is the whole delta: capture_groups_fp 2338 -> 2371 (+33 occurrences of an existing tag) and fixture_count 151 -> 152 (one new fixture, typescript-type-parameters). capture_groups_small/large unchanged at 4503/14403, since those measure the synthetic scaling source this does not touch. Scaling 1.06 < 1.5. JavaScript is untouched \u2014 it has no type parameters \u2014 and its fingerprint does not move, which is the check that this is the TS declaration rules and not something broader. Prior f66a3e6f1e096431e7046505129a627deaa00ca0de5bc846b080591b397248f7 -> 62c7f1bfbe568eed927fb78f00061ed5e49d12511fd8260648b876df386f3b4c.",
"_rebaselined_2899_review_type_parameter_scope_fixtures": "PR #2899 review follow-up: FIXTURE-CORPUS GROWTH ONLY \u2014 no query rule changed and no capture name was added or removed. `typescript/query.ts` is byte-identical to the previous baseline; the type-parameter shadowing defect was fixed on the RESOLUTION side (`walkers.ts` gains a `declarationOpenedScope` gate so a declaration's `typeParameters` bind only inside the scope that declaration opened, and the `USES` guard moved from `graph-bridge/references-to-edges.ts` to `resolve-references.ts` where the spelled `site.name` is in hand). The fingerprint moves because measure.mjs fingerprints the whole `lang-resolution/typescript-*` fixture corpus and the regression tests add three files to `typescript-type-parameters/src/` (values.ts, aliased.ts, namespaced.ts) plus two scope-less generic aliases in shapes.ts. Per-file accounting sums exactly to the delta: shapes.ts 33->35 (+2), values.ts +11, aliased.ts +10, namespaced.ts +20 = +43. capture_groups_fp 2371 -> 2414; fixture_count 152 -> 155. capture_groups_small/large unchanged at 4503/14403 (they measure the SYNTHETIC scaling source, untouched). JAVASCRIPT IS THE CONTROL AND DID NOT MOVE (fingerprint 2026993b..., 43 fixtures) \u2014 which is the check that this is corpus growth and not a capture regression; all 14 other languages report `ok`. Scaling 0.976 < 1.5. Prior 62c7f1bfbe568eed927fb78f00061ed5e49d12511fd8260648b876df386f3b4c -> c2fbf8a89e5686dd1ff3659b20d41d8b05ebcc9790356e3653ee0c8ca5d365c8.",
"_rebaselined_2953_workspace_fixture": "#2953 adds test/fixtures/lang-resolution/typescript-pnpm-workspace-imports, a pnpm monorepo of 12 .ts files, and the TypeScript capture corpus is collected from test/fixtures. CORPUS GROWTH ONLY, NOT A CAPTURE CHANGE: fixture_count 155 -> 167 and capture_groups_fp 2414 -> 2465 are the 12 new files' own matches; capture_groups_small/large are unchanged at 4503/14403 because those measure the SYNTHETIC scaling source, which the fixture corpus does not feed. Attribution is exact rather than inferred: moving that one fixture directory aside and re-running returns typescript to f719163eb03a447c9e40ca316a905dd76cee82192a75a403df478ebbdc13e98f byte-for-byte with fixture_count back at 155, and [scope-capture --check] PASSES for all 15 languages - so nothing in the TypeScript capture stream moved. #2953 changes import RESOLUTION, which runs after capture and feeds no capture tag. Prior f719163eb03a447c9e40ca316a905dd76cee82192a75a403df478ebbdc13e98f -> 05d1dadd6c9ef35c74079fa50f341b1b36e4fb02c9a89dd1b59f32b7cfd5e633.",
"_rebaselined_1432_member_call_callee_name": "#1432 (Zig): the shared callable-flow reader no longer names a callee by simple name for a MEMBER call (`@callable-flow.direct-callee-name` requires a direct designator: `f(x)`, `ns.f(x)`), and a member call is a field-stored-callable invoke only when a MEMBER store (`o.f = handler`) or a declared callable-typed field is visible - a same-named plain binding no longer gates it. CAPTURE-EMISSION CHANGE, not fixture growth (fixture_count unchanged). Drift: `await svc.verify<GuestPayload>(token, ...)` (typescript-generic-calls/src/guest.ts, member call) and `initializer()(() => {...})` (typescript-hof-callbacks/src/store.ts, call-of-call) lose `direct-callee-name`. `await verifyToken<AdminPayload>(token, ...)` (admin.ts/auth.ts) KEEPS `direct-callee-name|verifyToken`: tree-sitter-typescript parses `await f<T>(x)` as call_expression(function: await_expression(f), type_arguments, ...), and wrappedExpression now unwraps `await_expression` so the direct designator survives as it does for the un-awaited spelling. capture_groups_fp 2465 (unchanged). Prior 05d1dadd6c9ef35c74079fa50f341b1b36e4fb02c9a89dd1b59f32b7cfd5e633 -> fed04ed1d5db112387781e405da208ae6b3ab803889773b0455be96f01b893ff."
"_rebaselined_1432_member_call_callee_name": "#1432 (Zig): the shared callable-flow reader no longer names a callee by simple name for a MEMBER call (`@callable-flow.direct-callee-name` requires a direct designator: `f(x)`, `ns.f(x)`), and a member call is a field-stored-callable invoke only when a MEMBER store (`o.f = handler`) or a declared callable-typed field is visible - a same-named plain binding no longer gates it. CAPTURE-EMISSION CHANGE, not fixture growth (fixture_count unchanged). Drift: `await svc.verify<GuestPayload>(token, ...)` (typescript-generic-calls/src/guest.ts, member call) and `initializer()(() => {...})` (typescript-hof-callbacks/src/store.ts, call-of-call) lose `direct-callee-name`. `await verifyToken<AdminPayload>(token, ...)` (admin.ts/auth.ts) KEEPS `direct-callee-name|verifyToken`: tree-sitter-typescript parses `await f<T>(x)` as call_expression(function: await_expression(f), type_arguments, ...), and wrappedExpression now unwraps `await_expression` so the direct designator survives as it does for the un-awaited spelling. capture_groups_fp 2465 (unchanged). Prior 05d1dadd6c9ef35c74079fa50f341b1b36e4fb02c9a89dd1b59f32b7cfd5e633 -> fed04ed1d5db112387781e405da208ae6b3ab803889773b0455be96f01b893ff.",
"_rebaselined_3354_callable_alternatives": "#3354 callable alternatives: a callable chosen by a value-selecting source now flows every branch it can yield (`a ?? b`, `a || b`, `a or b`, `c ? a : b`, statement `if`, elvis), and an operator branch (`x == f || g`) stays opaque instead of seeding a qualified name. Verified by running the BASE (merge-base 233ca2849) and HEAD emitters over the SAME HEAD fixture corpus: every added or removed match is an `@callable-flow.*` match on one of those sources, and the pre-existing corpus is byte-identical (all other languages: zero delta). The rest of the drift is corpus growth from this PR's regression fixture, which this bench globs. Corpus growth: typescript-callable-alternatives/{index,compare,worker,sweep/index}.ts (+4 files, +288 groups). Emitter delta: +18 / -14: `??` / `||` / `&&` / ternary seeds and copies now name each operand (`&&` its right operand only); the 14 removed matches are the whole-expression seeds and copies carrying a qualified name of the compound source, including the comparison branches in compare.ts that now stay opaque. capture_groups_fp 2465 -> 2753, fixture_count 167 -> 171; synthetic counts unchanged; scaling 0.963 < 1.5. Prior 60e75bbe846f7200005f12c4d95c32e82d9d3feae9f2fd594e484670762b82b0 -> 77c9b4ea654123a64972db8348190ec250b669472b150db65ea8c7f20b355467."
},
"javascript": {
"fingerprint": "b916c7072b30d09b4949803810604830ea1aca1cfdda0d9d9312f7cb22f7a10a",
@ -217,7 +230,7 @@
"_rebaselined_blind_spots_2856": "#2856 blind-spots series: the JS/TS SCOPE queries gained capture rules, so fingerprint drift is expected and additive. Verified before re-baselining by diffing the capture-name sets in both scope queries against origin/main: TypeScript gained exactly @reference.read.identifier (A2 bare-identifier reads in value positions) and @reference.type (R2-2 type references, so a declared contract stops reporting incoming:{}); JavaScript gained exactly @reference.read.identifier, @reference.read.destructured (R2-1c) and @reference.write.property-key (R2-1b record-construction writes). NOTHING was removed on either side \u2014 the delta is a pure superset, which is the check that no existing capture moved. capture_groups_small/large are unchanged (4503/14403) because those measure the SYNTHETIC scaling source, which this branch does not touch; only the fixture-corpus count moves. capture_groups_fp 2097 -> 2338 and fixture_count 146 -> 151 from 21 new lang-resolution fixtures. Scaling stayed linear and inside budget: typescript 1.116 < 1.5, javascript 1.010 < 1.5. Prior typescript ed92588e0fc7b28b3a0174339ac378b4dd85965fe007db1208dea97a65ce0571 -> f66a3e6f1e096431e7046505129a627deaa00ca0de5bc846b080591b397248f7; prior javascript 806f70ad3cce5fc849f6d06a08ace8a95f92a1ea84a2418fddabb1eef5846594 -> 2026993b81b873839dd2ef8797d9c14d9c48516b2b57b05ac17d8d43f2f4eba3."
},
"kotlin": {
"fingerprint": "a9d3f0db7547ff47856159debf15d2a6f427efca97a6af27b4004174ed432132",
"fingerprint": "f3fefe058484823761b52df5715f41d65fa4ff0223843bcc86893d7b0a6b3de2",
"scaling_budget": 1.5,
"_rebaselined_interface_abstract_2885": "#2885: Kotlin interface property accessors stay in the capture set (groups still 5753/18403 and capture_groups_fp 2563) but Method isAbstract is now true for body-less interface properties, which changes accessor-plan identity in the fixture digest. Prior 82ae5e1f750580383344d4c84c400a290474528cd502be4af8cd56705819a683 -> aeafc7a87402c933786ef582b7c98683b1822b78fa909e605cb97552867fa0d5; CI scaling 0.838 < 1.5.",
"_rebaselined_jvm_property_accessors_2885": "#2885: Kotlin val/var properties now emit JVM getter/setter scope and declaration captures, including data-class constructor properties and custom accessors. Synthetic scaling counts move 4753/15203 -> 5753/18403; fixture-corpus groups move 2367 -> 2563. Accessor declaration sidecars use the canonical @declaration.qualified_name key, preserve same-name owner identity, follow JvmAbi is-prefix naming, and suppress @JvmName-renamed accessors until their custom names are modeled. Prior f98e7e936afbce0e99588285cfc603bf945fd58c5de45271860509a5d90eb832 -> 82ae5e1f750580383344d4c84c400a290474528cd502be4af8cd56705819a683; scaling 0.869 < 1.5.",
@ -239,6 +252,12 @@
"fixture_count": 141,
"_rebaselined_1432_member_call_callee_name": "#1432 (Zig): the shared callable-flow reader no longer names a callee by simple name for a MEMBER call (`@callable-flow.direct-callee-name` requires a direct designator: `f(x)`, `ns.f(x)`), and a member call is a field-stored-callable invoke only when a MEMBER store (`o.f = handler`) or a declared callable-typed field is visible - a same-named plain binding no longer gates it. CAPTURE-EMISSION CHANGE, not fixture growth (fixture_count unchanged). Only drift: `users.map { it.name }.forEach { name -> println(name) }` (kotlin-lambda-scopes/App.kt) loses `direct-callee-name|forEach` (member call). capture_groups_fp 2334 (unchanged). Prior a184f8ff0ae40d246db855b63f7ff26bda3afac03e5f4c76e4593c7e2cefce54 -> 5a181af0dbc9451937da0964c40d3f3f9820914ca429d873bb5c812b5e2b9284.",
"_rebaselined_1432_rebase_onto_2960": "#1432 rebase onto main @ aac7515d: the kotlin fingerprint is a COMBINATION of two independent changes, so neither side of the merge conflict was correct on its own and resolving it by picking a side would have committed a fingerprint no run can reproduce. main's #2960 added four declared-package fixture files (fixture_count 137 -> 141, capture_groups_fp 2334 -> 2367); this branch's `_rebaselined_1432_member_call_callee_name` drops `direct-callee-name|forEach` from one member call. Recomputed under both: capture_groups_fp 2367 and fixture_count 141 match main's committed counts EXACTLY (this branch's change is emission-only and moves no count), capture_groups_small/large stay 4753/15203 (the SYNTHETIC scaling source, which neither change touches), scaling 1.003 < 1.5, and the other 14 languages report ok against their committed baselines in the same run - which is the check that the rebase replayed nothing else into the capture stream. Attribution is exact rather than inferred: moving test/fixtures/lang-resolution/kotlin-import-package-evidence aside and re-running returns kotlin to 5a181af0dbc9451937da0964c40d3f3f9820914ca429d873bb5c812b5e2b9284 byte-for-byte with fixture_count back at 137 and capture_groups_fp back at 2334 - this branch's pre-rebase value - so the whole delta is #2960's corpus growth layered on top, with nothing else moving. Prior (this branch, pre-rebase) 5a181af0dbc9451937da0964c40d3f3f9820914ca429d873bb5c812b5e2b9284 and (main) f98e7e936afbce0e99588285cfc603bf945fd58c5de45271860509a5d90eb832 -> 973d702510002dda76166e017c5eca90cae37a139f5a512877b7a1b04ad19dc5.",
"_rebaselined_1432_merge_main_2885": "#1432 merge of main @ 212e007a: the kotlin fingerprint is again a COMBINATION of two independent changes \u2014 main's #2885 JVM property accessors / interface-abstract (4753/15203 -> 5753/18403, capture_groups_fp 2367 -> 2563) and this branch's `_rebaselined_1432_member_call_callee_name` (drops `direct-callee-name|forEach` from one member call). Neither side's value reproduces under the merged tree. Recomputed under both: counts 5753/18403/2563 and fixture_count 141 match main's committed counts EXACTLY (this branch's change is emission-only), scaling 1.061 < 1.5, and csharp / cpp / typescript measure byte-for-byte at this branch's committed values (main did not touch them since the merge-base) while the other 11 languages report ok \u2014 the check that the merge replayed nothing else into the capture stream. Prior (main) aeafc7a87402c933786ef582b7c98683b1822b78fa909e605cb97552867fa0d5 and (this branch) 973d702510002dda76166e017c5eca90cae37a139f5a512877b7a1b04ad19dc5 -> a9d3f0db7547ff47856159debf15d2a6f427efca97a6af27b4004174ed432132."
"_rebaselined_1432_merge_main_2885": "#1432 merge of main @ 212e007a: the kotlin fingerprint is again a COMBINATION of two independent changes \u2014 main's #2885 JVM property accessors / interface-abstract (4753/15203 -> 5753/18403, capture_groups_fp 2367 -> 2563) and this branch's `_rebaselined_1432_member_call_callee_name` (drops `direct-callee-name|forEach` from one member call). Neither side's value reproduces under the merged tree. Recomputed under both: counts 5753/18403/2563 and fixture_count 141 match main's committed counts EXACTLY (this branch's change is emission-only), scaling 1.061 < 1.5, and csharp / cpp / typescript measure byte-for-byte at this branch's committed values (main did not touch them since the merge-base) while the other 11 languages report ok \u2014 the check that the merge replayed nothing else into the capture stream. Prior (main) aeafc7a87402c933786ef582b7c98683b1822b78fa909e605cb97552867fa0d5 and (this branch) 973d702510002dda76166e017c5eca90cae37a139f5a512877b7a1b04ad19dc5 -> a9d3f0db7547ff47856159debf15d2a6f427efca97a6af27b4004174ed432132.",
"_rebaselined_3354_callable_alternatives": "#3354 callable alternatives: a callable chosen by a value-selecting source now flows every branch it can yield (`a ?? b`, `a || b`, `a or b`, `c ? a : b`, statement `if`, elvis), and an operator branch (`x == f || g`) stays opaque instead of seeding a qualified name. Verified by running the BASE (merge-base 233ca2849) and HEAD emitters over the SAME HEAD fixture corpus: every added or removed match is an `@callable-flow.*` match on one of those sources, and the pre-existing corpus is byte-identical (all other languages: zero delta). The rest of the drift is corpus growth from this PR's regression fixture, which this bench globs. Corpus growth: kotlin-callable-alternatives/App.kt (+1 file, +71 groups). Emitter delta: +8 / -2: elvis and `if` expression (bare and braced) seeds and copies now name each operand; the 2 removed matches are the whole-expression seed and copy carrying a qualified name of the compound source; the multi-statement `if` stays opaque. capture_groups_fp 2563 -> 2634, fixture_count 141 -> 142; synthetic counts unchanged; scaling 1.17 < 1.5. Prior a9d3f0db7547ff47856159debf15d2a6f427efca97a6af27b4004174ed432132 -> f3fefe058484823761b52df5715f41d65fa4ff0223843bcc86893d7b0a6b3de2."
},
"typescript-deep-chain": {
"fingerprint": "c4dd97ad7be50a554930237f79035993ffa67957de6dc8c1d6606518071204b2",
"scaling_budget": 1.5,
"_added": "#3354 review: one TS `handler || w === \"k0\" || \u2026` chain, operand count = entity count (250 -> 800). Guards callable-flow value-alternative expansion (valueAlternatives + per-leaf visibility walks) against per-leaf cost that grows with chain depth: before the fix this measured 7.5-8.0 (super-linear; 8000 operands overflowed the stack), after it 1.11-1.22. Synthetic-only fingerprint (no fixture corpus); identical before and after the fix."
}
}

View file

@ -309,6 +309,21 @@ const LANGS = [
` getId(): number { return this.id; }\n` +
` setName(v: string): void { this.name = v; }\n}\n\n`,
},
{
// One `a || b || …` chain whose operand count is the entity count (#3354
// review): each operand is one more nesting level, so this guards the
// callable-flow value-alternative expansion against per-leaf work that
// grows with chain depth (ratio ~8 before the fix, and 8000 operands
// overflowed the stack). No fixture corpus — the fingerprint is the synthetic
// source alone; `typescript` above already covers the TS fixtures.
name: 'typescript-deep-chain',
emit: emitTsScopeCaptures,
exts: ['.ts'],
file: 'bench-chain.ts',
header: 'function handler() {}\n\nexport function isKw(w: string) {\n const f = handler',
unit: (n) => ` || w === "k${n}"`,
footer: ';\n return f;\n}\n',
},
{
name: 'javascript',
emit: emitJsScopeCaptures,
@ -371,7 +386,9 @@ function measureLang(lang) {
// Correctness fingerprint over the fixture corpus + a fixed 20-entity source.
const perFixture = [];
let groups = 0;
for (const { key, absPath } of collectFixtures(lang.fixturePrefix, lang.exts)) {
const fixtures =
lang.fixturePrefix === undefined ? [] : collectFixtures(lang.fixturePrefix, lang.exts);
for (const { key, absPath } of fixtures) {
const matches = lang.emit(fs.readFileSync(absPath, 'utf8'), absPath);
groups += matches.length;
perFixture.push(`${key}\t${matches.length}\t${digestCaptures(matches)}`);

View file

@ -0,0 +1,52 @@
{
"_what": "Baselines for bench/swift-package-imports/measure.mjs --check. Guards Swift Package.swift declaration resolve after #2964 / #2931: declared-target hits, SDK and undeclared-folder misses, empty declaredTargets fail-closed, @_exported union, #2931 nested-prefix membership, first-wins grouping, and https:// factory survival. Same approach as bench/parse-dispatch-rounds/baselines.json — exact floors and a fingerprint first; the only timing arms are ratios.",
"_triage": "READ THIS BEFORE RE-RUNNING. targets, files, imports, declared_resolved, sdk_external, undeclared_external, empty_declared_external, reexport_extra, nested_repeat_resolved, first_wins, parse_targets, parse_binary_skipped, url_comment_targets, parse_complete and layout_fingerprint are DETERMINISTIC: a re-run never changes them, and none may be re-baselined to make CI green. resolve_scaling_ratio, strategy_scaling_ratio and parse_scaling_ratio are the only timing arms; runner contention dominates them, so re-run on an idle machine before investigating and read the reported `reps` first. If exactly one arm fails and it is a timing arm, suspect the machine.",
"targets": 16,
"files": 260,
"imports": 12288,
"_shape_note": "THE FLOOR. Without these three, every arm below is a ceiling over nothing. declared_resolved only asserts something while the corpus still walks many files and imports. Shrink it to one happy-path module and declared_resolved still reads 32 and still passes, asserting a property the corpus no longer has. Files-per-target is 16 and does not scale — 4x grows target count so a hit returns a constant-size file list. Scaling files-per-target instead makes the return-list copy look quadratic even when the index is linear (the same trap bench/import-target documents for Swift collide_scaling).",
"declared_resolved": 32,
"sdk_external": 32,
"undeclared_external": 16,
"empty_declared_external": 1,
"reexport_extra": 1,
"nested_repeat_resolved": 1,
"first_wins": 1,
"_membership_note": "Exact resolve/grouping counts on the unique query set (one File0 per target). declared_resolved=32 is two declared spellings (Mod{t+1} and Mod{t+1}.Model) across 16 targets. sdk_external=32 is Foundation+UIKit. undeclared_external=16 is the in-repo CoreUI folder that Package.swift does not declare — the hole bench/import-target's Swift arm cannot see. empty_declared_external=1 pins an empty declaredTargets map. reexport_extra=1 pins import Mod0 including Mod1 after @_exported. nested_repeat_resolved=1 pins vendor/Sources/Mod0/Sources/Mod0/Nested.swift (#2931). first_wins=1 pins a two-prefix file into Mod0 only — under #3355 root-anchored grouping because it sits under Sources/Mod0, not because Mod0 is listed first.",
"parse_targets": 3,
"parse_binary_skipped": 1,
"url_comment_targets": 1,
"parse_complete": 1,
"_parse_note": "Exact parse floors on a fixed manifest, not the scaling corpus. parse_targets=3 is the importable source targets Models+App+AppTests. parse_binary_skipped=1 means Lib/CFoo never entered the map and Gen is a non-importable plugin module at Plugins/Gen (#3355: plugins compile as their own module, so grouping needs them; before #3355 Gen was skipped too). url_comment_targets=1 pins https:// on the same line as a later .target. parse_complete=1 pins both manifests readable.",
"layout_fingerprint": "2b220baa1df63778489949f9596879ba1c89a49e62c57bd9d2336fd8d74c17bd",
"_layout_fingerprint_note": "sha256 over sorted from|target->files rows on the unique query set. A change here is a BEHAVIOUR change — the resolver returned a different target set. Explain it, never re-baseline it alone.",
"resolve_scaling_budget": 1.9,
"strategy_scaling_budget": 1.6,
"parse_scaling_budget": 1.9,
"_resolve_scaling_note": "(t_4n / t_n) / 4 for resolveSwiftImportTarget over the declared corpus; ~1.0 is linear. A RATIO rather than a millisecond ceiling, deliberately: wall-clock is runner-speed-dependent, and this repo has already been bitten by a fixed ms budget. A per-import allFilePaths scan scores ~4. Budget is 1.9, i.e. 1.51x the measured maximum 1.261 — this file's siblings use ~1.5x on ratios. min-of-15 estimator.",
"_strategy_scaling_note": "(t_4n / t_n) / 4 for swiftPackageStrategy (the import-config WeakMap index). Measured maximum 1.030; budget 1.6 is 1.55x, matching parse-dispatch-rounds' pack_scaling_budget.",
"_parse_scaling_note": "(t_4n / t_n) / 4 for parseSwiftPackageManifest over 256 vs 1024 factories. Measured maximum 1.276; budget 1.9 is 1.49x. The small cell is still sub-millisecond, so this budget is looser than strategy on purpose.",
"_measured": {
"resolve_scaling_ratio": 1.261,
"resolve_scaling_ratio_samples": [1.261, 1.201, 1.182, 1.241, 1.212],
"strategy_scaling_ratio": 1.03,
"strategy_scaling_ratio_samples": [1.019, 0.994, 0.97, 1.015, 1.03],
"parse_scaling_ratio": 1.276,
"parse_scaling_ratio_samples": [1.25, 1.276, 1.215, 1.224, 1.227],
"resolve_ms": 3.5,
"resolve_ms_4x": 17.16,
"strategy_ms": 0.67,
"strategy_ms_4x": 2.65,
"parse_ms": 0.41,
"parse_ms_4x": 2.01,
"reps": 15
},
"_measured_note": "Maxima (and sample lists) over 5 consecutive local runs using the min-of-15 estimator from parse-dispatch-rounds. Milliseconds are diagnostic context only — nothing gates on them."
}

View file

@ -0,0 +1,482 @@
/**
* Build-free bench for Swift Package.swift declaration resolve (#2964 / #2931).
*
* WHY THIS EXISTS. `bench/import-target`'s Swift arm never threads a
* `resolutionConfig`. It times the no-manifest directory-segment index
* (`getSwiftModuleIndex`) and cannot see a revert that starts scanning
* `allFilePaths` per `import`, drops declaration-only resolve, treats
* `https://` as a comment, or matches a target dir inside another path
* segment (#2931). Graph output on a tiny fixture is identical either way.
* This file is the same shape as `bench/parse-dispatch-rounds`: exact floors
* first, ratio timing only, never a millisecond ceiling.
*
* ARMS:
*
* - `targets` / `files` / `imports` — EXACT, and they are the FLOOR.
* `declared_resolved` only asserts something while the corpus still has
* many files and imports to walk. Shrink it to one happy-path module and
* the resolve arm still passes, gating a property the corpus no longer has.
*
* - `declared_resolved` / `sdk_external` / `undeclared_external` /
* `empty_declared_external` / `reexport_extra` / `nested_repeat_resolved` /
* `first_wins` — EXACT. Declaration map hits stay hits. Foundation / UIKit
* and an in-repo `CoreUI` folder that is NOT in Package.swift stay
* external. An empty `declaredTargets` map fails every name closed. Import
* of a module that `@_exported import`s another unions that module's files.
* A `#2931` nested `Sources/Mod0` path still belongs to Mod0 for import
* resolve. Grouping anchors target paths at the repo root (#3355), so a
* two-prefix file joins the target it sits under, not a later match.
*
* - `parse_targets` / `parse_binary_skipped` / `url_comment_targets` /
* `parse_complete` — EXACT. Source factories are kept; binary and
* system-library factories are not modules; a plugin is a non-importable
* module under `Plugins/` (#3355); `https://` on the same line does not hide
* a later `.target`.
*
* - `layout_fingerprint` — EXACT. sha256 over sorted `from|target->files`
* rows on the unique query set. Catches a target-set change that leaves
* the counts intact.
*
* - `resolve_scaling_ratio` / `strategy_scaling_ratio` / `parse_scaling_ratio`
* — the only timing arms, RATIOS not millisecond ceilings.
* `(t_4n / t_n) / 4` divides the machine out; ~1.0 is linear.
* Files-per-target is FIXED while target count (and therefore files and
* imports) grow 4x, so a hit returns a constant-size file list. A correct
* once-per-pass index is then linear in imports; a per-import scan of
* allFilePaths scores ~4. Parse scales factory count 4x on a fixed-shape
* manifest.
*
* Usage:
* node --import tsx bench/swift-package-imports/measure.mjs
* node --import tsx bench/swift-package-imports/measure.mjs --check
*/
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { performance } from 'node:perf_hooks';
import { parseSwiftPackageManifest } from '../../src/core/ingestion/language-config.ts';
import { swiftPackageStrategy } from '../../src/core/ingestion/import-resolvers/configs/swift.ts';
import { resolveSwiftImportTarget } from '../../src/core/ingestion/languages/swift/import-target.ts';
import { groupSwiftFilesByModule } from '../../src/core/ingestion/languages/swift/target-grouping.ts';
const baselines = JSON.parse(readFileSync(new URL('./baselines.json', import.meta.url), 'utf8'));
const REPS = 15;
const TARGETS_SMALL = 16;
const FILES_PER_TARGET = 16;
const QUERY_REPEATS = 8;
const PARSE_TARGETS = 256;
const SCALE = 4;
function queryKinds(targetCount) {
return [
(t) => ({ raw: `Mod${(t + 1) % targetCount}`, expect: 'declared' }),
() => ({ raw: 'Foundation', expect: 'sdk' }),
() => ({ raw: 'CoreUI', expect: 'undeclared' }),
(t) => ({ raw: `Mod${(t + 1) % targetCount}.Model`, expect: 'declared' }),
() => ({ raw: 'UIKit', expect: 'sdk' }),
() => ({ raw: 'Ghost', expect: 'miss' }),
];
}
function parsedImport(targetRaw) {
return { kind: 'namespace', localName: targetRaw, importedName: targetRaw, targetRaw };
}
function stubFile(filePath, parsedImports = []) {
return {
filePath,
moduleScope: `module:${filePath}`,
scopes: [],
parsedImports,
localDefs: [],
referenceSites: [],
};
}
function targetMap(targetCount) {
const targets = new Map();
for (let t = 0; t < targetCount; t++) targets.set(`Mod${t}`, `Sources/Mod${t}`);
return targets;
}
function declaredConfig(targets) {
return { origin: 'package.swift', targets, declaredTargets: targets };
}
function buildCorpus(targetCount) {
const targets = targetMap(targetCount);
const files = [];
const parsedFiles = [];
for (let t = 0; t < targetCount; t++) {
for (let i = 0; i < FILES_PER_TARGET; i++) {
const filePath = `Sources/Mod${t}/File${i}.swift`;
files.push(filePath);
const imports =
t === 0 && i === 0
? [{ kind: 'reexport', localName: 'Mod1', importedName: 'Mod1', targetRaw: 'Mod1' }]
: [];
parsedFiles.push(stubFile(filePath, imports));
}
}
const foundation = 'Sources/Foundation/Thing.swift';
const coreUi = 'Sources/CoreUI/View.swift';
const nested = 'vendor/Sources/Mod0/Sources/Mod0/Nested.swift';
const clash = 'Sources/Mod0/Vendor/Sources/Mod1/Clash.swift';
for (const extra of [foundation, coreUi, nested, clash]) {
files.push(extra);
parsedFiles.push(stubFile(extra));
}
const kinds = queryKinds(targetCount);
const queries = [];
for (let t = 0; t < targetCount; t++) {
for (let i = 0; i < FILES_PER_TARGET; i++) {
const from = `Sources/Mod${t}/File${i}.swift`;
for (let r = 0; r < QUERY_REPEATS; r++) {
for (const kind of kinds) {
const { raw, expect } = kind(t);
queries.push({ from, raw, expect, unique: r === 0 && i === 0 });
}
}
}
}
return {
files,
parsedFiles,
queries,
targets,
config: declaredConfig(targets),
extras: { foundation, coreUi, nested, clash },
targetCount,
};
}
function outcomeKey(from, raw, result) {
if (result == null) return `${from}|${raw}-><null>`;
const list = typeof result === 'string' ? [result] : [...result];
return `${from}|${raw}->${list.sort().join(',')}`;
}
function resultFiles(result) {
if (result == null) return [];
return typeof result === 'string' ? [result] : [...result];
}
function resolveOne(query, allFilePaths, config, parsedFiles) {
return resolveSwiftImportTarget(parsedImport(query.raw), {
fromFile: query.from,
allFilePaths,
resolutionConfig: config,
parsedFiles,
});
}
function strategyCtx(files, config) {
return {
allFilePaths: new Set(files),
allFileList: files,
normalizedFileList: files.map((p) => p.replace(/\\/g, '/')),
index: {},
resolveCache: new Map(),
configs: {
tsconfigPaths: null,
goModule: null,
composerConfig: null,
swiftPackageConfig: config,
csharpConfigs: [],
},
};
}
function resolvePass(corpus) {
const allFilePaths = new Set(corpus.files);
let resolved = 0;
for (const query of corpus.queries) {
if (resolveOne(query, allFilePaths, corpus.config, corpus.parsedFiles) != null) resolved++;
}
return resolved;
}
function strategyPass(corpus) {
const ctx = strategyCtx(corpus.files, corpus.config);
let resolved = 0;
for (const query of corpus.queries) {
if (swiftPackageStrategy(query.raw, query.from, ctx) != null) resolved++;
}
return resolved;
}
function manifestFor(targetCount) {
const rows = [];
for (let i = 0; i < targetCount; i++) {
rows.push(
` .target(name: "Mod${i}", dependencies: [.product(name: "X", package: "https://example.com/x")]),`,
);
if (i % 8 === 0) {
rows.push(
` .binaryTarget(name: "Bin${i}", url: "https://example.com/b${i}.xcframework"),`,
);
}
}
return `let package = Package(\n name: "Demo",\n targets: [\n${rows.join('\n')}\n ]\n)\n`;
}
function fastest(fn, reps) {
fn();
let best = Infinity;
for (let r = 0; r < reps; r++) {
const t0 = performance.now();
fn();
best = Math.min(best, performance.now() - t0);
}
return best;
}
function correctness(corpus) {
const allFilePaths = new Set(corpus.files);
const records = [];
let declaredResolved = 0;
let sdkExternal = 0;
let undeclaredExternal = 0;
const unique = corpus.queries.filter((q) => q.unique);
for (const query of unique) {
const result = resolveOne(query, allFilePaths, corpus.config, corpus.parsedFiles);
records.push(outcomeKey(query.from, query.raw, result));
if (query.expect === 'declared' && result != null) declaredResolved++;
if (query.expect === 'sdk' && result == null) sdkExternal++;
if (query.expect === 'undeclared' && result == null) undeclaredExternal++;
}
const fromOther = 'Sources/Mod2/File0.swift';
const reexport = resultFiles(
resolveOne({ from: fromOther, raw: 'Mod0' }, allFilePaths, corpus.config, corpus.parsedFiles),
);
const reexportExtra =
reexport.includes('Sources/Mod0/File0.swift') && reexport.includes('Sources/Mod1/File0.swift')
? 1
: 0;
const nestedRepeatResolved = reexport.includes(corpus.extras.nested) ? 1 : 0;
const empty = resolveOne(
{ from: fromOther, raw: 'Mod0' },
allFilePaths,
{ origin: 'package.swift', targets: corpus.targets, declaredTargets: new Map() },
corpus.parsedFiles,
);
const emptyDeclaredExternal = empty == null ? 1 : 0;
// Anchored at the repo root: Clash.swift is under Sources/Mod0; the
// Sources/Mod1 further down its path is a vendored copy, not Mod1 (#3355).
const groups = groupSwiftFilesByModule(corpus.files, (p) => p, { targets: corpus.targets });
const firstWins =
groups.get('Mod0')?.includes(corpus.extras.clash) === true &&
groups.get('Mod1')?.includes(corpus.extras.clash) !== true
? 1
: 0;
const parseFixed = parseSwiftPackageManifest(`
let package = Package(
name: "Demo",
dependencies: [.package(url: "https://example.com/foo.git", from: "1.0.0")],
targets: [
.target(name: "Models"),
.target(name: "App"),
.binaryTarget(name: "Lib", url: "https://example.com/Lib.xcframework", checksum: "abc"),
.testTarget(name: "AppTests"),
.plugin(name: "Gen"),
.systemLibrary(name: "CFoo"),
]
)
`);
const urlComment = parseSwiftPackageManifest(
'let package = Package(name: "Demo", dependencies: [.package(url: "https://example.com/foo.git", from: "1.0.0")], targets: [.target(name: "T")])',
);
return {
declaredResolved,
sdkExternal,
undeclaredExternal,
emptyDeclaredExternal,
reexportExtra,
nestedRepeatResolved,
firstWins,
// Importable source targets. Plugins are modules (grouped) but never
// `import`-able (#3355), so they are counted by the arm below instead.
parseTargets: parseFixed.targets.size - parseFixed.plugins.size,
parseBinarySkipped:
!parseFixed.targets.has('Lib') &&
!parseFixed.targets.has('CFoo') &&
parseFixed.targets.get('Gen') === 'Plugins/Gen' &&
parseFixed.plugins.has('Gen')
? 1
: 0,
urlCommentTargets: urlComment.targets.get('T') === 'Sources/T' ? 1 : 0,
parseComplete: parseFixed.complete && urlComment.complete ? 1 : 0,
fingerprint: createHash('sha256').update(records.sort().join('\n')).digest('hex'),
};
}
const small = buildCorpus(TARGETS_SMALL);
const large = buildCorpus(TARGETS_SMALL * SCALE);
const shape = correctness(small);
const smallResolveMs = fastest(() => resolvePass(small), REPS);
const largeResolveMs = fastest(() => resolvePass(large), REPS);
const resolveScaling = largeResolveMs / smallResolveMs / SCALE;
const smallStrategyMs = fastest(() => strategyPass(small), REPS);
const largeStrategyMs = fastest(() => strategyPass(large), REPS);
const strategyScaling = largeStrategyMs / smallStrategyMs / SCALE;
const parseSmallSrc = manifestFor(PARSE_TARGETS);
const parseLargeSrc = manifestFor(PARSE_TARGETS * SCALE);
const parseSmall = parseSwiftPackageManifest(parseSmallSrc);
const parseLarge = parseSwiftPackageManifest(parseLargeSrc);
const smallParseMs = fastest(() => parseSwiftPackageManifest(parseSmallSrc), REPS);
const largeParseMs = fastest(() => parseSwiftPackageManifest(parseLargeSrc), REPS);
const parseScaling = largeParseMs / smallParseMs / SCALE;
const files = small.files.length;
const imports = small.queries.length;
console.log(`targets : ${small.targetCount} (expect ${baselines.targets})`);
console.log(`files : ${files} (expect ${baselines.files})`);
console.log(`imports : ${imports} (expect ${baselines.imports})`);
console.log(
`declared_resolved : ${shape.declaredResolved} (expect ${baselines.declared_resolved})`,
);
console.log(`sdk_external : ${shape.sdkExternal} (expect ${baselines.sdk_external})`);
console.log(
`undeclared_external : ${shape.undeclaredExternal} (expect ${baselines.undeclared_external})`,
);
console.log(
`empty_declared_external: ${shape.emptyDeclaredExternal} (expect ${baselines.empty_declared_external})`,
);
console.log(
`reexport_extra : ${shape.reexportExtra} (expect ${baselines.reexport_extra})`,
);
console.log(
`nested_repeat_resolved : ${shape.nestedRepeatResolved} (expect ${baselines.nested_repeat_resolved})`,
);
console.log(`first_wins : ${shape.firstWins} (expect ${baselines.first_wins})`);
console.log(`parse_targets : ${shape.parseTargets} (expect ${baselines.parse_targets})`);
console.log(
`parse_binary_skipped : ${shape.parseBinarySkipped} (expect ${baselines.parse_binary_skipped})`,
);
console.log(
`url_comment_targets : ${shape.urlCommentTargets} (expect ${baselines.url_comment_targets})`,
);
console.log(
`parse_complete : ${shape.parseComplete} (expect ${baselines.parse_complete})`,
);
console.log(`layout_fingerprint : ${shape.fingerprint}`);
console.log(
`resolve_scaling_ratio : ${resolveScaling.toFixed(3)} (budget <= ${baselines.resolve_scaling_budget}; ~1.0 is linear)`,
);
console.log(
`strategy_scaling_ratio : ${strategyScaling.toFixed(3)} (budget <= ${baselines.strategy_scaling_budget}; ~1.0 is linear)`,
);
console.log(
`parse_scaling_ratio : ${parseScaling.toFixed(3)} (budget <= ${baselines.parse_scaling_budget}; ~1.0 is linear)`,
);
console.log(
`reps : ${REPS} resolve ${smallResolveMs.toFixed(2)}ms / 4x ${largeResolveMs.toFixed(2)}ms strategy ${smallStrategyMs.toFixed(2)}ms / 4x ${largeStrategyMs.toFixed(2)}ms parse ${smallParseMs.toFixed(2)}ms / 4x ${largeParseMs.toFixed(2)}ms`,
);
console.log(
`parse_scale_shape : ${parseSmall.targets.size} -> ${parseLarge.targets.size} targets (expect ${PARSE_TARGETS} -> ${PARSE_TARGETS * SCALE})`,
);
if (process.argv.includes('--check')) {
let failed = false;
if (shape.fingerprint !== baselines.layout_fingerprint) {
failed = true;
console.error(
`\nFAIL layout_fingerprint: ${shape.fingerprint}\n` +
` expected ${baselines.layout_fingerprint}\n` +
` Unique-query target set moved. Explain it; do not re-baseline alone.`,
);
}
const exact = [
['targets', small.targetCount],
['files', files],
['imports', imports],
['declared_resolved', shape.declaredResolved],
['sdk_external', shape.sdkExternal],
['undeclared_external', shape.undeclaredExternal],
['empty_declared_external', shape.emptyDeclaredExternal],
['reexport_extra', shape.reexportExtra],
['nested_repeat_resolved', shape.nestedRepeatResolved],
['first_wins', shape.firstWins],
['parse_targets', shape.parseTargets],
['parse_binary_skipped', shape.parseBinarySkipped],
['url_comment_targets', shape.urlCommentTargets],
['parse_complete', shape.parseComplete],
];
for (const [name, value] of exact) {
if (value !== baselines[name]) {
failed = true;
console.error(`\nFAIL ${name}: ${value}, expected exactly ${baselines[name]}.`);
}
}
if (
small.targetCount !== baselines.targets ||
files !== baselines.files ||
imports !== baselines.imports
) {
failed = true;
console.error(
`\nFAIL shape: the corpus must stay large enough that declared_resolved still measures a walk.`,
);
}
if (
parseSmall.targets.size !== PARSE_TARGETS ||
parseLarge.targets.size !== PARSE_TARGETS * SCALE
) {
failed = true;
console.error(
`\nFAIL parse scale shape: ${parseSmall.targets.size} -> ${parseLarge.targets.size}, ` +
`expected ${PARSE_TARGETS} -> ${PARSE_TARGETS * SCALE}.`,
);
}
if (resolveScaling > baselines.resolve_scaling_budget) {
failed = true;
console.error(
`\nFAIL resolve_scaling_ratio: ${resolveScaling.toFixed(3)} exceeds ` +
`${baselines.resolve_scaling_budget} (~1.0 is linear).\n` +
` resolveSwiftImportTarget grew superlinearly in file+import count — a\n` +
` per-import scan of allFilePaths scores ~4 here. Re-run on an idle\n` +
` machine before investigating, and check \`reps\` first.`,
);
}
if (strategyScaling > baselines.strategy_scaling_budget) {
failed = true;
console.error(
`\nFAIL strategy_scaling_ratio: ${strategyScaling.toFixed(3)} exceeds ` +
`${baselines.strategy_scaling_budget} (~1.0 is linear).\n` +
` swiftPackageStrategy grew superlinearly — usually the WeakMap target\n` +
` index falling back to O(imports × files). Re-run idle; check \`reps\`.`,
);
}
if (parseScaling > baselines.parse_scaling_budget) {
failed = true;
console.error(
`\nFAIL parse_scaling_ratio: ${parseScaling.toFixed(3)} exceeds ` +
`${baselines.parse_scaling_budget} (~1.0 is linear).\n` +
` parseSwiftPackageManifest grew superlinearly in factory count.\n` +
` Re-run on an idle machine before investigating, and check \`reps\` first.`,
);
}
if (failed) process.exit(1);
console.log('\nOK — within budget.');
}

View file

@ -0,0 +1,12 @@
{
"_comment": "Baselines for bench/trpc-route-extractor/measure.mjs --check (#3339). fingerprint is sha256 over 800 identifier-mounted route identities (method, path, handler name, line) from one appRouter composing 800 same-file subrouters. inline is the shape-ballasted control; chain is the C# concentrated-namespace analogue (depth-N identifier mounts).",
"fingerprint": "72f7286ba5b8cf37c68f0707ee96ae91025e3f102a09a544d02ec015ca19f5fa",
"scaling_budget": 1.8,
"_scaling_note": "(t_large/t_small)/(800/250) on the sibling-mount arm. Measured 1.55–1.62 after memoizing mount paths (char-scan + GC band); a per-procedure remount walk is quadratic (~3+).",
"chain_scaling_budget": 2.8,
"_chain_scaling_note": "Same ratio on the depth-N chain. Output path text is Θ(n²), so the procedure-count ratio sits near 1.7–2.0 with a memoized table (CI band); the pre-memo walk measured 11.7 (O(n³) ancestor-array copies).",
"widening_overhead_budget": 3.5,
"_widening_overhead_note": "mount_large_ms / inline_large_ms. Inline carries one ballast const per procedure so file length is comparable. Measured about 2.4; budget guards a compose-step blow-up without treating scan-of-more-tokens as a failure.",
"absolute_ms_budget": 160,
"_absolute_ms_note": "Sibling-mount compose for 800 routers. Measured about 41 ms; 4× ceiling matches the route-constant benches' CI headroom."
}

View file

@ -0,0 +1,128 @@
/**
* Build-free throughput + identity bench for tRPC identifier-mounted subrouters.
*
* Arms:
* - inline: `pN: publicProcedure.query(...)` inside one appRouter (control)
* - mount: same-file `const rNRouter = t.router({ list }); appRouter = { rN: rNRouter }`
* (the #3339 composition path)
* - chain: depth-N identifier mounts with a procedure at every level (the
* C# "concentrated namespace" analog — a remount walk is O(depth ×
* procedures); a once-built path table stays linear)
*
* Usage:
* node --import tsx bench/trpc-route-extractor/measure.mjs
* node --import tsx bench/trpc-route-extractor/measure.mjs --check
*/
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { extractTrpcRoutes } from '../../src/core/ingestion/route-extractors/trpc.ts';
import { minSample } from '../lib/identity-guard.mjs';
import { fingerprintIds, runBaselineCheck, runCountCheck } from '../lib/route-constant-guard.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const BASELINE_PATH = path.resolve(__dirname, 'baselines.json');
const FILE = 'src/server/trpc/routers/app.ts';
const SMALL = 250;
const LARGE = 800;
const REPS = 15;
const WARMUP = 5;
function header() {
return [
"import { initTRPC } from '@trpc/server';",
'const t = initTRPC.create();',
'const publicProcedure = t.procedure;',
'',
].join('\n');
}
function generateInline(procedureCount) {
// Shape-equivalent ballast so widening_overhead compares compose cost, not
// file length: each mount arm procedure is a `const rNRouter = t.router`.
const pads = Array.from({ length: procedureCount }, (_, i) => `const _pad${i} = ${i};`).join(
'\n',
);
const keys = Array.from(
{ length: procedureCount },
(_, i) => ` p${i}: publicProcedure.query(() => null),`,
).join('\n');
return `${header()}${pads}\nexport const appRouter = t.router({\n${keys}\n});\n`;
}
function generateMount(routerCount) {
const routers = Array.from(
{ length: routerCount },
(_, i) => `const r${i}Router = t.router({\n list: publicProcedure.query(() => null),\n});\n`,
).join('');
const mounts = Array.from({ length: routerCount }, (_, i) => ` r${i}: r${i}Router,`).join('\n');
return `${header()}${routers}export const appRouter = t.router({\n${mounts}\n});\n`;
}
function generateChain(depth) {
const parts = [header()];
parts.push('const r0Router = t.router({\n list: publicProcedure.query(() => null),\n});\n');
for (let i = 1; i < depth; i++) {
parts.push(
`const r${i}Router = t.router({\n n${i - 1}: r${i - 1}Router,\n list: publicProcedure.query(() => null),\n});\n`,
);
}
parts.push(`export const appRouter = t.router({\n n${depth - 1}: r${depth - 1}Router,\n});\n`);
return parts.join('');
}
const GENERATORS = {
inline: generateInline,
mount: generateMount,
chain: generateChain,
};
function measure(mode, procedureCount) {
const source = GENERATORS[mode](procedureCount);
const { last, ms } = minSample(
() => {
const routes = extractTrpcRoutes(FILE, source);
return routes.map((r) => `${r.httpMethod} ${r.routePath} ${r.methodName} ${r.lineNumber}`);
},
WARMUP,
REPS,
);
return {
procedures: procedureCount,
ms,
routes: last.length,
fingerprint: fingerprintIds(last),
};
}
function scalingRatio(large, small) {
return Number((large.ms / small.ms / (LARGE / SMALL)).toFixed(3));
}
const report = {
inline_small: measure('inline', SMALL),
inline_large: measure('inline', LARGE),
mount_small: measure('mount', SMALL),
mount_large: measure('mount', LARGE),
chain_small: measure('chain', SMALL),
chain_large: measure('chain', LARGE),
};
report.scaling_ratio = scalingRatio(report.mount_large, report.mount_small);
report.chain_scaling_ratio = scalingRatio(report.chain_large, report.chain_small);
report.widening_overhead = Number(
(report.mount_large.ms / Math.max(report.inline_large.ms, 0.001)).toFixed(3),
);
report.absolute_ms = report.mount_large.ms;
report.fingerprint = report.mount_large.fingerprint;
runCountCheck(report, 'routes', {
inline_large: LARGE,
mount_large: LARGE,
chain_large: LARGE,
});
if (!process.argv.includes('--check')) {
console.log(JSON.stringify(report, null, 2));
process.exit(0);
}
runBaselineCheck(report, BASELINE_PATH);

View file

@ -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 <abs path to .../node_modules/gitnexus/dist/cli/index.js> 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/<pid>/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/<pid>/fd.
if (code === 'ENOTDIR') {
// The fd path is not a directory at all, so this is structurally not a
// real /proc/<pid>/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,
};

View file

@ -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
// `<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.
}
}

View file

@ -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-<hash>` 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 <slot>/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 `<commit>-<featureKey>` 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,
};
}

View file

@ -83,13 +83,13 @@
}
},
"node_modules/@babel/code-frame": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-8.0.0.tgz",
"integrity": "sha512-dYYg153EyN2Ekbqw2zAsbd6/JR+9N2SEoC7YV2GyyqMM7x9bLDTjBD6XBhSMLH0wtIVyJj03jWNriQhaN+eoCw==",
"version": "8.0.6",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-8.0.6.tgz",
"integrity": "sha512-uMHhvmDmwFiLuiozaoRYTZHqMFuu3fem/X9JY78lohAuLoh8LSGGC8JqUzo2cVuBsBxkjMZNk56CE7ock8Xxkw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^8.0.0",
"@babel/helper-validator-identifier": "^8.0.6",
"js-tokens": "^10.0.0"
},
"engines": {
@ -97,14 +97,14 @@
}
},
"node_modules/@babel/generator": {
"version": "8.0.5",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.5.tgz",
"integrity": "sha512-f/TuhuMAxJqhwxEGNsJrswuG9VHmh0oNFoQoo6TbpgtFAz9wYZXcTAcWZMHfp7ljesr0RG04bp3Aos9GI59L7w==",
"version": "8.0.6",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.6.tgz",
"integrity": "sha512-/pcanjdRsonCexCuxgGD8fKnBLLzk2i9uM4kmcmGyyev2R5YDs+rafMYZ89RvaGY1mW8T7C5au0TyYaEeHBGng==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^8.0.5",
"@babel/types": "^8.0.5",
"@babel/parser": "^8.0.6",
"@babel/types": "^8.0.6",
"@jridgewell/gen-mapping": "0.4.0-beta.0",
"@jridgewell/trace-mapping": "^0.3.31",
"@types/jsesc": "^2.5.0",
@ -115,9 +115,9 @@
}
},
"node_modules/@babel/helper-globals": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-8.0.0.tgz",
"integrity": "sha512-lLozHOM6sWWlxNo8CYqHy4MBZeTvHXNgVPBfPOGsjPKUzHC2Az9QwB6gxdQmpwHl6GlQtbGgS+lj5887guDiLw==",
"version": "8.0.6",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-8.0.6.tgz",
"integrity": "sha512-OdnA/N52LhzFpeUuVz5YGuSWjc7TPHiFmVYZbPlSgExocO2GwbxVpSni9MVs5wwW+wZ4kPuwBEBN9pIPDYz1mA==",
"dev": true,
"license": "MIT",
"engines": {
@ -125,9 +125,9 @@
}
},
"node_modules/@babel/helper-string-parser": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz",
"integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==",
"version": "8.0.6",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.6.tgz",
"integrity": "sha512-+6HYEBnsDRZHJtUTAEfMQd0zMDqdFHS7C+5vAsEZqBUr3vEMWOWlHJ0Jz1gS8jJIlCvLsuA6ivmeoqzH936YcA==",
"dev": true,
"license": "MIT",
"engines": {
@ -135,9 +135,9 @@
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "8.0.4",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz",
"integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==",
"version": "8.0.6",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.6.tgz",
"integrity": "sha512-HYx0YmuchRxRSpnjvcNffnPS8DCauKNmnumIuHG3jnQAVewRA7kfJGwF13ayEpSZs7s4R/oBH4eppuXcAQd5JQ==",
"dev": true,
"license": "MIT",
"engines": {
@ -145,13 +145,13 @@
}
},
"node_modules/@babel/parser": {
"version": "8.0.5",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.5.tgz",
"integrity": "sha512-51RXvQNFakaS0bTpYiGkxNbUVwkPO4kONv6EVLorZABxsx+KZ6Z7uSYvi/wmKS/+X+rfj9RvOw0/ZNh+cmI0Rw==",
"version": "8.0.6",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.6.tgz",
"integrity": "sha512-LpGDIYJAzc3Y3PT9x+FxBrGYu2PlWxLmyN7SwPTzaX0LSwEmFJBsRnPBqmaXF2r1v+Zhz6lNiEJ+7yKtM5+Ugg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^8.0.5"
"@babel/types": "^8.0.6"
},
"bin": {
"parser": "bin/babel-parser.js"
@ -176,18 +176,18 @@
}
},
"node_modules/@babel/traverse": {
"version": "8.0.5",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-8.0.5.tgz",
"integrity": "sha512-XFfnuvapSc/vJOcUO7kwORSvpBIvraofKEZ2dhT0PjiF21BRCD7YbAFC8UEeDJNeLoQz82/gVqzgX5hCzkCbdg==",
"version": "8.0.6",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-8.0.6.tgz",
"integrity": "sha512-ilYV+qLStGzSKLipJlETdvdtpK3rAuwfCmCoGKv15MU4EoeIuLkespSNoNLFGOPSciEHx9TLWtN5mv0fz5w5mQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^8.0.0",
"@babel/generator": "^8.0.5",
"@babel/helper-globals": "^8.0.0",
"@babel/parser": "^8.0.5",
"@babel/code-frame": "^8.0.6",
"@babel/generator": "^8.0.6",
"@babel/helper-globals": "^8.0.6",
"@babel/parser": "^8.0.6",
"@babel/template": "^8.0.0",
"@babel/types": "^8.0.5",
"@babel/types": "^8.0.6",
"obug": "^2.1.1"
},
"engines": {
@ -195,14 +195,14 @@
}
},
"node_modules/@babel/types": {
"version": "8.0.5",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.5.tgz",
"integrity": "sha512-eVdMqi3ej5aHhyQ2Si6yD2cAWeV8FJK9UrhK5aL0Sd8hu5GhT+YswhVNbVheOGVYMg8kuGuMaUpkB3stjj4z8A==",
"version": "8.0.6",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.6.tgz",
"integrity": "sha512-c+xgSWboV2pdwXP67BUWDVRHi2BO5Q3KwxyFVs7taVTY+fsK47/L51ZXQoV9rozHJDNHvJ4v4WEsI/IlxV2l2A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^8.0.0",
"@babel/helper-validator-identifier": "^8.0.4"
"@babel/helper-string-parser": "^8.0.6",
"@babel/helper-validator-identifier": "^8.0.6"
},
"engines": {
"node": "^22.18.0 || >=24.11.0"
@ -1277,9 +1277,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "26.5.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.5.1.tgz",
"integrity": "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g==",
"version": "26.6.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.2.tgz",
"integrity": "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -3109,9 +3109,9 @@
}
},
"node_modules/ignore": {
"version": "7.0.9",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.9.tgz",
"integrity": "sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw==",
"version": "7.0.10",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.10.tgz",
"integrity": "sha512-HpbUakT7xp5miBUywCHf36ZEuAJNklBJDDsGpUIjMzOSmM8ELSfA9Sa/QDPeNeqeoN31u+UTCkL4klCOVvRm4Q==",
"license": "MIT",
"engines": {
"node": ">= 4"
@ -3785,9 +3785,9 @@
}
},
"node_modules/mnemonist": {
"version": "0.40.4",
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.4.tgz",
"integrity": "sha512-ZAv+KNavneRVzu4tUeOgzkScI3W5BGwZ3rkxIpKtzzVgfTtWQFN1CgX0U72cyvyh3iTuHL3SiSmrQxTlryEIcw==",
"version": "0.40.5",
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.5.tgz",
"integrity": "sha512-egXDkYJsKZCizjk8FydZ3g8TOigBwt+dIaehgKcQSAE3DCkCTWG4N94trvwR1ZYeAHo1exxE+ZhZOUuj01HdNw==",
"license": "MIT",
"dependencies": {
"obliterator": "^2.0.4"
@ -3922,9 +3922,9 @@
}
},
"node_modules/onnxruntime-common": {
"version": "1.29.0",
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.29.0.tgz",
"integrity": "sha512-/F63/e2VJoaVXGGNu6S5QH7jivBThGO95OzAVXXQ8hTta/b1QxI8udHa6cI3+3mAb5WWIIaMMwfZw01oivjJ1g==",
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.30.0.tgz",
"integrity": "sha512-7fdVWjAID1dVhH/G8qK3APARunV4VkBFoCQAP7qp4Wkab0mrorvmc+sqiT+mKXOzDqdjN5j+/Z9nb4gzNPWcyA==",
"license": "MIT"
},
"node_modules/pandemonium": {
@ -4149,9 +4149,9 @@
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
"integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
@ -4159,6 +4159,10 @@
},
"engines": {
"node": ">= 0.10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/pump": {
@ -4961,9 +4965,9 @@
"license": "0BSD"
},
"node_modules/tsx": {
"version": "4.23.13",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz",
"integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==",
"version": "4.23.15",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz",
"integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==",
"dev": true,
"license": "MIT",
"dependencies": {

View file

@ -108,6 +108,7 @@ const PLATFORM_LOGIC = [
'test/unit/hooks.test.ts',
'test/unit/hook-db-lock-probe.test.ts',
'test/unit/cursor-hook.test.ts',
'test/unit/factory-plugin.test.ts',
'test/unit/sidecar-recovery.test.ts',
'test/unit/pool-wal-recovery.test.ts',
'test/unit/lbug-adapter-wal-schema.test.ts',
@ -165,6 +166,7 @@ const LBUG_NATIVE = [
'test/integration/lbug-open-retry.test.ts',
'test/integration/lbug-close-handle-release.test.ts',
'test/integration/lbug-orphan-sidecar-recovery.test.ts',
'test/integration/lbug-interrupted-checkpoint-recovery.test.ts',
'test/integration/lbug-readonly-init.test.ts',
'test/integration/lbug-non-ascii-path.test.ts',
// Cross-repo trace e2e: builds two real lbug indexes + a real bridge and
@ -339,6 +341,12 @@ const FILESYSTEM = [
// 4893-file pass — 2.3 s on a slow virtualised filesystem, 0.34 s on a local
// disk — against a 30 s testTimeout.
'test/unit/source-control-bytes.test.ts',
// Auto-sync reads a cloned `.gitnexusrc` through the symlink/hard-link guard,
// and clone recovery uses real `git` plus temp dirs. Ubuntu coverage alone
// would never create the Windows file symlink (`type: 'file'`) or run the
// git-config failure path on windows-latest / macos-latest.
'test/unit/gitnexus-rc-embeddings.test.ts',
'test/unit/git-clone.test.ts',
];
const ALL_CROSS_PLATFORM = [

View file

@ -13,6 +13,9 @@
* - gitnexus-claude-plugin/.codex-plugin/plugin.json (top-level version)
* - .agents/plugins/marketplace.json (plugins[gitnexus])
* - gitnexus-claude-plugin/skills/<skill>/mcp.json (gitnexus@<version> launch arg, x10)
* - gitnexus-factory-plugin/.factory-plugin/plugin.json (top-level version)
* - gitnexus-factory-plugin/mcp.json (gitnexus@<version> launch arg)
* - .factory-plugin/marketplace.json (plugins[gitnexus])
*
* Modes:
* node scripts/sync-plugin-manifests.mjs rewrite stale surfaces
@ -54,6 +57,16 @@ const MANIFEST_SURFACES = [
file: `gitnexus-claude-plugin/skills/${name}/mcp.json`,
kind: 'mcp',
})),
// The Factory plugin's manifest is also its pin source at runtime: the
// PostToolUse hook reads this version to build its `npx -y gitnexus@<version>`
// fallback, so stamping it here keeps the hook and the MCP entry on the same
// released CLI.
{ file: 'gitnexus-factory-plugin/.factory-plugin/plugin.json', kind: 'plugin' },
{ file: 'gitnexus-factory-plugin/mcp.json', kind: 'mcp' },
// Droid reads .factory-plugin/marketplace.json before .claude-plugin's, so
// this entry is what makes `droid plugin install` deliver the Factory plugin
// (Execute matcher, pinned mcp.json) instead of the translated Claude one.
{ file: '.factory-plugin/marketplace.json', kind: 'marketplace' },
];
const PLUGIN_NAME = 'gitnexus';

View file

@ -6,6 +6,8 @@
* CLAUDE.md is for Claude Code which only reads that file.
*/
import { GITNEXUS_DIR } from '../storage/storage-constants.js';
import { storeRootOfCheckoutSlot } from '../storage/shared-store.js';
import fs from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
@ -619,7 +621,13 @@ export async function generateAIContextFiles(
// CLI and hooks already share; failure to copy is non-fatal (docs carry a
// bootstrap fallback). `runnerPath` is project-relative with POSIX separators
// so the emitted command is identical across platforms.
const runnerPath = path.relative(repoPath, path.join(storagePath, 'run.cjs')).replace(/\\/g, '/');
// A shared-store slot (#3352) lives under the GitNexus home, so its path
// would be machine- and worktree-specific in committed docs; the runner goes
// next to the checkout's store pointer instead.
const runnerDir = storeRootOfCheckoutSlot(storagePath)
? path.join(repoPath, GITNEXUS_DIR)
: storagePath;
const runnerPath = path.relative(repoPath, path.join(runnerDir, 'run.cjs')).replace(/\\/g, '/');
try {
const runnerSrc = path.join(
__dirname,
@ -629,8 +637,8 @@ export async function generateAIContextFiles(
'claude',
'resolve-analyze-cmd.cjs',
);
await fs.mkdir(storagePath, { recursive: true });
await fs.copyFile(runnerSrc, path.join(storagePath, 'run.cjs'));
await fs.mkdir(runnerDir, { recursive: true });
await fs.copyFile(runnerSrc, path.join(runnerDir, 'run.cjs'));
} catch (err) {
logger.warn(`Could not write GitNexus runner to ${runnerPath}: ${String(err)}`);
}

View file

@ -33,8 +33,10 @@ import path from 'node:path';
import { readRepoControlFile } from '../config/repo-control-file.js';
import {
InvalidBranchError,
sanitizeDetectedBranch,
validateBranchName as validateBranchNameCore,
} from '../core/git-ref.js';
export { sanitizeDetectedBranch };
import type { AnalyzeOptions } from './analyze-options.js';
export const GITNEXUS_RC_FILENAME = '.gitnexusrc';
@ -100,6 +102,10 @@ const KEY_SPECS: Record<string, KeySpec> = {
workerTimeout: { target: 'workerTimeout', kind: 'numeric-string' },
walCheckpointThreshold: { target: 'walCheckpointThreshold', kind: 'numeric-string' },
workers: { target: 'workers', kind: 'numeric-string' },
maxProcesses: { target: 'maxProcesses', kind: 'numeric-string' },
maxProcessBranching: { target: 'maxProcessBranching', kind: 'numeric-string' },
maxProcessTraceDepth: { target: 'maxProcessTraceDepth', kind: 'numeric-string' },
maxEntryPointCandidates: { target: 'maxEntryPointCandidates', kind: 'numeric-string' },
embeddingThreads: { target: 'embeddingThreads', kind: 'numeric-string' },
embeddingBatchSize: { target: 'embeddingBatchSize', kind: 'numeric-string' },
embeddingSubBatchSize: { target: 'embeddingSubBatchSize', kind: 'numeric-string' },
@ -172,20 +178,6 @@ export function validateBranchName(value: string, source: string): string {
}
}
/**
* Best-effort validation for an auto-detected branch (from git). Never throws —
* returns `undefined` for anything unusable so the resolver falls back to the
* next precedence tier.
*/
export function sanitizeDetectedBranch(value: string | null | undefined): string | undefined {
if (!value) return undefined;
try {
return validateBranchName(value, 'detected branch');
} catch {
return undefined;
}
}
const normalizeValue = (kind: ValueKind, value: unknown, key: string): unknown => {
const source = `${GITNEXUS_RC_FILENAME} "${key}"`;
switch (kind) {

View file

@ -103,6 +103,10 @@ export interface AnalyzeOptions {
* `allowDuplicateName` option end-to-end.
*/
allowDuplicateName?: boolean;
/** `--share-with <repo>`: join that checkout's shared store (#3352). */
shareWith?: string;
/** `--no-share` sets this to false: leave the shared store (#3352). */
share?: boolean;
/**
* Override the walker's large-file skip threshold (#991). Value in KB;
* clamped downstream to the tree-sitter 32 MB ceiling. Sets
@ -113,8 +117,24 @@ export interface AnalyzeOptions {
workerTimeout?: string;
/** Control LadybugDB WAL auto-checkpoint threshold during analyze. */
walCheckpointThreshold?: string;
/**
* `--memory-budget <mb>` (#3137): the main-thread V8 heap limit in MB.
* `ensureHeap` applies it through the existing heap respawn, replacing the
* RAM-aware auto cap and any `--max-old-space-size` pin, so the #2649
* guards read it as the live limit. Parse workers keep their own heap caps.
* Integer, minimum 200; CLI-only (not a `.gitnexusrc` key).
*/
memoryBudget?: string;
/** Parse worker pool size (>=1); 0 is rejected (no sequential mode). */
workers?: string;
/** Process-detection process cap. Positive integer string; `0` is invalid. */
maxProcesses?: string;
/** Process-detection per-node branching cap. Positive integer string. */
maxProcessBranching?: string;
/** Process-detection DFS depth cap. Positive integer string. */
maxProcessTraceDepth?: string;
/** Ranked entry-point candidate pool. Positive integer string. */
maxEntryPointCandidates?: string;
embeddingThreads?: string;
embeddingBatchSize?: string;
embeddingSubBatchSize?: string;

View file

@ -22,6 +22,11 @@ import {
import type { AnalyzeOptions } from './analyze-options.js';
import { ensureHeap } from './analyze.js';
import { cliError, cliInfo, cliWarn } from './cli-message.js';
import { parseIntegerOption } from './int-option.js';
import {
formatInvalidProcessDetectionOverride,
parseProcessDetectionBudgetStrings,
} from '../core/ingestion/process-detection-budget.js';
import {
WATCH_FULL_REFRESH_PATH,
WatchRefreshQueue,
@ -87,9 +92,7 @@ function positiveInteger(
maximum?: number,
): number | undefined {
if (value === undefined) return undefined;
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < 1)
throw new Error(`${flag} must be a positive integer`);
const parsed = parseIntegerOption(value, flag, { minimum: 1 });
if (maximum !== undefined && parsed > maximum) {
throw new Error(`${flag} must not exceed ${maximum}`);
}
@ -164,6 +167,17 @@ export async function resolveWatchOptions(
const workerPoolSize = positiveInteger(merged.workers, '--workers');
const workerTimeoutSeconds = positiveInteger(merged.workerTimeout, 'workerTimeout');
const maxFileSize = positiveInteger(merged.maxFileSize, 'maxFileSize', MAX_FILE_SIZE_KB);
const processDetection = parseProcessDetectionBudgetStrings(
{
maxProcesses: merged.maxProcesses,
maxProcessBranching: merged.maxProcessBranching,
maxProcessTraceDepth: merged.maxProcessTraceDepth,
maxEntryPointCandidates: merged.maxEntryPointCandidates,
},
(flag, raw) => {
cliWarn(formatInvalidProcessDetectionOverride(flag, raw));
},
);
setEnvironment(
'GITNEXUS_MAX_FILE_SIZE',
@ -183,6 +197,10 @@ export async function resolveWatchOptions(
registryName: merged.name,
allowDuplicateName: merged.allowDuplicateName,
workerPoolSize,
maxProcesses: processDetection.maxProcesses,
maxProcessBranching: processDetection.maxProcessBranching,
maxProcessTraceDepth: processDetection.maxProcessTraceDepth,
maxEntryPointCandidates: processDetection.maxEntryPointCandidates,
fetchWrappers: merged.fetchWrappers,
skipAgentsMd: true,
skipSkills: true,
@ -406,7 +424,11 @@ export async function watchCommandWithRunnerIdentity(
inputPath?: string,
cliOptions: WatchCliOptions = {},
): Promise<void> {
if (await ensureHeap({ cleanForwardedTermination: true })) return;
if (
await ensureHeap({ cleanForwardedTermination: true, memoryBudget: cliOptions.memoryBudget })
) {
return;
}
const requestedRepoPath = inputPath ? path.resolve(inputPath) : getGitRoot(process.cwd());
if (requestedRepoPath === null || !hasGitDir(requestedRepoPath)) {

View file

@ -54,11 +54,23 @@ import type { AnalyzeOptions } from './analyze-options.js';
import { runFullAnalysis } from '../core/run-analyze.js';
import { getRuntimeFingerprint } from '../core/platform/capabilities.js';
import { getMaxFileSizeBannerMessage } from '../core/ingestion/utils/max-file-size.js';
import {
formatInvalidProcessDetectionOverride,
formatProcessDetectionBudgetBanner,
parseProcessDetectionBudgetStrings,
resolveProcessDetectionBudget,
} from '../core/ingestion/process-detection-budget.js';
import { warnMissingOptionalGrammars, getOptionalGrammarExtensions } from './optional-grammars.js';
import { glob } from 'glob';
import fs from 'fs/promises';
import { cliError, cliWarn } from './cli-message.js';
import { heapCapMbFor, memoryAutopilotDisabled } from '../core/ingestion/utils/effective-ram.js';
import { IntegerOptionError, parseIntegerOption, parseMemoryBudgetMb } from './int-option.js';
import {
HEAP_LIMIT_SOURCE_ENV,
heapCapMbFor,
memoryAutopilotDisabled,
type HeapLimitSource,
} from '../core/ingestion/utils/effective-ram.js';
import { EMBEDDING_DIMS_ERROR, normalizeEmbeddingDims } from './embedding-dims.js';
import { formatElapsed } from './format-elapsed.js';
import { isHfDownloadFailure } from '../core/embeddings/hf-env.js';
@ -537,27 +549,23 @@ const RECOMMENDED_WAL_CHECKPOINT_THRESHOLD = 64 * 1024 * 1024;
* later-flag-wins semantics when NODE_OPTIONS repeats a flag.
*/
export function parseMaxOldSpaceMb(nodeOptions: string): number | null {
// V8 accepts `-` and `_` interchangeably in flag names, and Node accepts a
// space-separated value in NODE_OPTIONS — honor every spelling of the pin
// instead of silently overriding it (#2649 review).
const matches = [...nodeOptions.matchAll(/--max[-_]old[-_]space[-_]size(?:=|\s+)(\d+)/g)];
return parseV8SizeFlagMb(nodeOptions, 'max-old-space-size');
}
/**
* Last value (MB) of a V8 `--<name>` size flag in a flag string. V8 accepts
* `-` and `_` interchangeably in flag names, and Node accepts a
* space-separated value — honor every spelling instead of silently
* overriding it (#2649 review).
*/
function parseV8SizeFlagMb(flags: string, name: string): number | null {
const stem = name.split('-').join('[-_]');
const matches = [...flags.matchAll(new RegExp(`--${stem}(?:=|\\s+)(\\d+)`, 'g'))];
if (matches.length === 0) return null;
const mb = Number(matches[matches.length - 1][1]);
return Number.isFinite(mb) && mb > 0 ? mb : null;
}
/** Re-exec the process with the RAM-aware auto heap cap + larger semi-space/stack
* if we're currently below that.
*
* Heap-source precedence (#2649):
* - an explicit per-invocation `--max-old-space-size` (execArgv) always wins;
* - `GITNEXUS_MEMORY=off` declines the memory autopilot entirely;
* - an ambient NODE_OPTIONS heap >= the auto cap is honored as-is;
* - an ambient NODE_OPTIONS heap BELOW the auto cap is treated as an
* inherited environment default (devcontainers/CI export one for other
* tooling), not a deliberate per-run choice: warn and respawn with the
* auto cap. Pre-#2649 this returned early and large repos then OOM'd on
* whatever heap the environment happened to specify. */
export function forwardedSignalExitCode(signal: NodeJS.Signals, cleanTermination: boolean): number {
if (cleanTermination) return 0;
if (signal === 'SIGINT') return 130;
@ -565,17 +573,165 @@ export function forwardedSignalExitCode(signal: NodeJS.Signals, cleanTermination
return 1;
}
/** Old-space and semi-space flags (MB) that give a child a V8 heap of exactly the budget. */
export interface BudgetHeapSizing {
oldSpaceMb: number;
semiSpaceMb: number;
}
/**
* Size the respawn flags so the child's `heap_size_limit` equals the budget
* (#3137). V8's limit is old space plus three semi-spaces, and V8 rounds the
* semi-space up to a power of two (measured on Node 22.18: semi 10 → 16), so
* the semi-space is budget/25 rounded up the same way, capped at the usual
* 128MB, and the old space takes the rest: 2000 → 1616 + 3 × 128, 200 → 176 + 3 × 8.
*/
export function budgetHeapSizing(budgetMb: number): BudgetHeapSizing {
const scaled = Math.max(1, Math.floor(budgetMb / 25));
const semiSpaceMb = Math.min(SEMI_SPACE_MB, 2 ** Math.ceil(Math.log2(scaled)));
return { oldSpaceMb: budgetMb - 3 * semiSpaceMb, semiSpaceMb };
}
export interface BudgetHeapInput {
budgetMb: number;
execArgv: readonly string[];
nodeOptions: string;
/** The RAM-aware auto cap the budget replaces. */
autoCapMb: number;
/** `GITNEXUS_MEMORY=off`: names Node's default limit as the one replaced. */
autopilotDisabled: boolean;
/** Inherited `GITNEXUS_HEAP_LIMIT_SOURCE`; `budget` marks a budget-respawned child. */
inheritedSource: string | undefined;
}
export interface BudgetHeapDecision {
respawn: boolean;
sizing: BudgetHeapSizing;
/** At most one warning line for the operator; absent in a budget-respawned child. */
warning?: string;
}
/**
* The `--memory-budget` heap decision (#3137), kept pure so every branch is
* testable without a process. The effective requested old space is the last
* `--max-old-space-size` in execArgv, else in NODE_OPTIONS (every spelling
* `parseMaxOldSpaceMb` accepts). Only the budget-respawned child keeps
* running; anything else respawns once at the budget. The budget's own flags
* follow the user's in the child, so the child always resolves to "at the
* budget".
*/
export function resolveBudgetHeap(input: BudgetHeapInput): BudgetHeapDecision {
const { budgetMb, autoCapMb } = input;
const sizing = budgetHeapSizing(budgetMb);
const execFlags = input.execArgv.join(' ');
const pinnedMb = parseMaxOldSpaceMb(execFlags) ?? parseMaxOldSpaceMb(input.nodeOptions);
const semiSpaceMb =
parseV8SizeFlagMb(execFlags, 'max-semi-space-size') ??
parseV8SizeFlagMb(input.nodeOptions, 'max-semi-space-size');
// Only a budget-respawned child carries both the budget's old space and its
// semi-space, so only it is exactly at the budget. A pin that merely equals
// the budget leaves V8's young generation on top (a 2000MB pin is a 2048MB
// heap), so that process respawns once like any other. The respawning
// parent already logged; the child stays quiet. The env marker alone is not
// proof — it is inherited — so both budget flags must be present too.
if (
input.inheritedSource === 'budget' &&
pinnedMb === sizing.oldSpaceMb &&
semiSpaceMb === sizing.semiSpaceMb
) {
return { respawn: false, sizing };
}
const swapWarning =
budgetMb > autoCapMb
? ` --memory-budget ${budgetMb}MB is above the ${autoCapMb}MB this machine's RAM supports — analyze may swap-thrash.\n`
: '';
const replaced =
pinnedMb !== null
? `the ${pinnedMb}MB --max-old-space-size heap limit`
: input.autopilotDisabled
? `Node's default heap limit`
: `the auto-sized ${autoCapMb}MB heap cap`;
return {
respawn: true,
sizing,
warning:
` --memory-budget replaces ${replaced}: re-running analyze with a ${budgetMb}MB heap.\n` +
swapWarning,
};
}
/** Re-exec the process with the RAM-aware auto heap cap + larger semi-space/stack
* if we're currently below that, or at the `--memory-budget` heap (#3137).
*
* Heap-source precedence (#2649, #3137):
* - an explicit `--memory-budget` wins over everything, including
* `GITNEXUS_MEMORY=off` and any `--max-old-space-size` pin;
* - an explicit per-invocation `--max-old-space-size` (execArgv) wins next;
* - `GITNEXUS_MEMORY=off` declines the memory autopilot entirely;
* - an ambient NODE_OPTIONS heap >= the auto cap is honored as-is;
* - an ambient NODE_OPTIONS heap BELOW the auto cap is treated as an
* inherited environment default (devcontainers/CI export one for other
* tooling), not a deliberate per-run choice: warn and respawn with the
* auto cap. Pre-#2649 this returned early and large repos then OOM'd on
* whatever heap the environment happened to specify.
*
* Paths managed by the auto-sizer or `--memory-budget` record the source in
* `GITNEXUS_HEAP_LIMIT_SOURCE` (this process when kept, the child env when
* respawned) so the parse phase's remedy text advises by source. Explicit
* heap pins and `GITNEXUS_MEMORY=off` intentionally leave it unset. */
export async function ensureHeap(
options: { cleanForwardedTermination?: boolean } = {},
options: { cleanForwardedTermination?: boolean; memoryBudget?: string } = {},
): Promise<boolean> {
const nodeOpts = process.env.NODE_OPTIONS || '';
// The budget is checked BEFORE the GITNEXUS_MEMORY=off return: an explicit
// flag is a deliberate per-run choice, which the opt-out does not cover.
if (options.memoryBudget !== undefined) {
let budgetMb: number;
try {
budgetMb = parseMemoryBudgetMb(options.memoryBudget);
} catch (error) {
// The CLI's preAction hook rejects bad values first; this covers
// programmatic callers.
if (!(error instanceof IntegerOptionError)) throw error;
cliError(` ${error.message}\n`);
process.exitCode = 1;
return true;
}
const decision = resolveBudgetHeap({
budgetMb,
execArgv: process.execArgv,
nodeOptions: nodeOpts,
autoCapMb: RESPAWN_HEAP_MB,
autopilotDisabled: memoryAutopilotDisabled(),
inheritedSource: process.env[HEAP_LIMIT_SOURCE_ENV],
});
if (decision.warning) cliWarn(decision.warning);
if (!decision.respawn) {
process.env[HEAP_LIMIT_SOURCE_ENV] = 'budget';
return false;
}
const { oldSpaceMb, semiSpaceMb } = decision.sizing;
return respawnWithHeap(
`--max-old-space-size=${oldSpaceMb}`,
`--max-semi-space-size=${semiSpaceMb}`,
'budget',
` Analysis likely ran out of memory (heap limit set to ${budgetMb}MB by --memory-budget).\n` +
` This repository's working set exceeds the budget. Raise it, or omit --memory-budget\n` +
` to use the auto-sized cap (a budget above physical RAM causes swap-thrash — use with care):\n` +
` gitnexus analyze --memory-budget <MB> [your-args]\n` +
` If this persists, it may be a native crash unrelated to heap size.\n`,
nodeOpts,
options,
);
}
// Explicit opt-out disables auto-sizing ENTIRELY — both the ambient-pin
// override and the default v8-limit respawn — and is honored SILENTLY:
// the operator already made the call, and stderr-sensitive consumers
// (test harnesses, scripts, supervisors that track a single PID) rely on
// a quiet, single-process run.
if (memoryAutopilotDisabled()) return false;
const nodeOpts = process.env.NODE_OPTIONS || '';
if (process.execArgv.some((a) => a.startsWith('--max-old-space-size'))) return false;
if (parseMaxOldSpaceMb(process.execArgv.join(' ')) !== null) return false;
const ambientHeapMb = parseMaxOldSpaceMb(nodeOpts);
if (ambientHeapMb !== null) {
@ -586,12 +742,39 @@ export async function ensureHeap(
);
} else {
const v8Heap = v8.getHeapStatistics().heap_size_limit;
if (v8Heap >= HEAP_MB * 1024 * 1024 * 0.9) return false;
if (v8Heap >= HEAP_MB * 1024 * 1024 * 0.9) {
process.env[HEAP_LIMIT_SOURCE_ENV] = 'auto';
return false;
}
}
return respawnWithHeap(
HEAP_FLAG,
SEMI_FLAG,
'auto',
` Analysis likely ran out of memory (heap cap auto-sized to ${RESPAWN_HEAP_MB}MB ≈ 0.75x RAM).\n` +
` This repository's working set exceeds available RAM. Use a machine with more RAM,\n` +
` or override the cap (a cap above physical RAM causes swap-thrash — use with care):\n` +
` NODE_OPTIONS="--max-old-space-size=<MB>" gitnexus analyze [your-args]\n` +
` (Windows: set NODE_OPTIONS=--max-old-space-size=<MB> && gitnexus analyze [your-args])\n` +
` If this persists, it may be a native crash unrelated to heap size.\n`,
nodeOpts,
options,
);
}
/** Run the analyze child with the given heap flags and map its exit onto this process. */
async function respawnWithHeap(
heapFlag: string,
semiFlag: string,
source: HeapLimitSource,
oomGuidance: string,
nodeOpts: string,
options: { cleanForwardedTermination?: boolean },
): Promise<boolean> {
// --stack-size is a V8 flag not allowed in NODE_OPTIONS on Node 24+, so pass it
// only as a direct CLI argument. --max-semi-space-size IS allowed in NODE_OPTIONS.
const cliFlags = [HEAP_FLAG, SEMI_FLAG];
const cliFlags = [heapFlag, semiFlag];
if (!nodeOpts.includes('--stack-size')) cliFlags.push(STACK_FLAG);
// Preserve the parent's node flags (execArgv) — dropping them breaks any
@ -605,7 +788,8 @@ export async function ensureHeap(
const childArgs = [...preservedExecArgv, ...cliFlags, ...process.argv.slice(1)];
const childEnv = {
...process.env,
NODE_OPTIONS: `${nodeOpts} ${HEAP_FLAG} ${SEMI_FLAG}`.trim(),
NODE_OPTIONS: `${nodeOpts} ${heapFlag} ${semiFlag}`.trim(),
[HEAP_LIMIT_SOURCE_ENV]: source,
};
if (shouldBridgeRespawnProgressTty()) childEnv[RESPAWN_PROGRESS_ENV] = '1';
const childExit = await runRespawnedAnalyze(childArgs, childEnv);
@ -618,15 +802,7 @@ export async function ensureHeap(
}
if (childExit.status !== 0 || childExit.signal) {
if (childProcessLikelyOom(childExit)) {
cliError(
` Analysis likely ran out of memory (heap cap auto-sized to ${RESPAWN_HEAP_MB}MB ≈ 0.75x RAM).\n` +
` This repository's working set exceeds available RAM. Use a machine with more RAM,\n` +
` or override the cap (a cap above physical RAM causes swap-thrash — use with care):\n` +
` NODE_OPTIONS="--max-old-space-size=<MB>" gitnexus analyze [your-args]\n` +
` (Windows: set NODE_OPTIONS=--max-old-space-size=<MB> && gitnexus analyze [your-args])\n` +
` If this persists, it may be a native crash unrelated to heap size.\n`,
{ recoveryHint: 'heap-oom-respawn' },
);
cliError(oomGuidance, { recoveryHint: 'heap-oom-respawn' });
} else if (childProcessLikelyNativeAbort(childExit)) {
cliError(
` Analysis aborted in a native worker or native binding path.\n` +
@ -653,6 +829,7 @@ export async function ensureHeap(
* for it in the first place.
*/
const ANALYZE_CLI_ENV_KEYS = [
HEAP_LIMIT_SOURCE_ENV,
'GITNEXUS_VERBOSE',
'GITNEXUS_PROFILE_DEFERRED',
'GITNEXUS_PROFILE_DEFERRED_SLOW_MS',
@ -722,7 +899,10 @@ export const analyzeCommand = async (
options?: AnalyzeOptions,
runnerIdentityAtBootstrap?: AnalyzerRunnerIdentity,
) => {
if (await ensureHeap()) return;
// Snapshot before ensureHeap: it records GITNEXUS_HEAP_LIMIT_SOURCE on a
// kept process, which must not leak into a later programmatic call.
const envSnap = snapshotAnalyzeEnv();
if (await ensureHeap({ memoryBudget: options?.memoryBudget })) return;
forceHeapOOMForTestIfEnabled();
// Install fatal handlers immediately after re-exec resolution so any
@ -742,7 +922,6 @@ export const analyzeCommand = async (
// exiting, restoration is moot. For early-return paths (validation
// errors) and the alreadyUpToDate fast path the finally restores the
// pre-call values.
const envSnap = snapshotAnalyzeEnv();
try {
await analyzeCommandImpl(inputPath, options, runnerIdentityAtBootstrap);
} finally {
@ -934,8 +1113,10 @@ const analyzeCommandImpl = async (
// previous call leaked.
let workerPoolSize: number | undefined;
if (options.workers !== undefined) {
const parsedWorkers = Number(options.workers);
if (!Number.isInteger(parsedWorkers) || parsedWorkers < 1) {
try {
workerPoolSize = parseIntegerOption(options.workers, '--workers', { minimum: 1 });
} catch (error) {
if (!(error instanceof IntegerOptionError)) throw error;
cliError(
' --workers must be a positive integer (>= 1). ' +
'GitNexus parses through a worker pool only — there is no sequential ' +
@ -944,17 +1125,32 @@ const analyzeCommandImpl = async (
process.exitCode = 1;
return;
}
workerPoolSize = parsedWorkers;
}
const processDetectionFromFlags = parseProcessDetectionBudgetStrings(
{
maxProcesses: options.maxProcesses,
maxProcessBranching: options.maxProcessBranching,
maxProcessTraceDepth: options.maxProcessTraceDepth,
maxEntryPointCandidates: options.maxEntryPointCandidates,
},
(flag, raw) => {
cliWarn(` ${formatInvalidProcessDetectionOverride(flag, raw)}\n`);
},
);
// Parse `--embeddings [limit]`: `true` → default cap, string → numeric cap
// (0 disables the cap entirely). Validated up here so failures match the
// sibling-validation pattern (exit before bar.start() — otherwise
// process.exit() leaves the progress bar's hidden cursor uncleared).
let embeddingsNodeLimit: number | undefined;
if (typeof options.embeddings === 'string') {
const parsed = Number(options.embeddings);
if (!Number.isInteger(parsed) || parsed < 0) {
try {
embeddingsNodeLimit = parseIntegerOption(options.embeddings, '--embeddings', {
minimum: 0,
});
} catch (error) {
if (!(error instanceof IntegerOptionError)) throw error;
cliError(
` --embeddings expects a non-negative integer (got "${options.embeddings}"). ` +
`Pass 0 to disable the safety cap, or omit the value to keep the default.\n`,
@ -962,7 +1158,6 @@ const analyzeCommandImpl = async (
process.exitCode = 1;
return;
}
embeddingsNodeLimit = parsed;
}
const embeddingsEnabled = !!options.embeddings;
@ -972,13 +1167,14 @@ const analyzeCommandImpl = async (
value: string | undefined,
): boolean => {
if (value === undefined) return true;
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) {
cliError(` ${optionName} must be a positive integer.\n`);
try {
process.env[envName] = String(parseIntegerOption(value, optionName, { minimum: 1 }));
} catch (error) {
if (!(error instanceof IntegerOptionError)) throw error;
cliError(` ${error.message}.\n`);
process.exitCode = 1;
return false;
}
process.env[envName] = String(parsed);
return true;
};
@ -1189,7 +1385,7 @@ const analyzeCommandImpl = async (
// injection, including community skill writes that `--skills` would normally
// produce. Surface the override explicitly so users don't wonder why a
// pipeline re-index ran but no skill files appeared. The pipeline still
// re-runs (see `force: options.force || options.skills` below); the warning
// re-runs (`skills` is passed to runFullAnalysis below); the warning
// is purely about the dropped post-index write step.
if (options.indexOnly && options.skills) {
console.log(
@ -1236,6 +1432,12 @@ const analyzeCommandImpl = async (
if (maxFileSizeBanner) {
console.log(`${maxFileSizeBanner}\n`);
}
const processDetectionBanner = formatProcessDetectionBudgetBanner(
resolveProcessDetectionBudget(processDetectionFromFlags),
);
if (processDetectionBanner) {
console.log(`${processDetectionBanner}\n`);
}
// ── CLI progress bar setup ─────────────────────────────────────────
const barOptions: cliProgress.Options & { terminal?: CliProgressTerminal } = {
@ -1337,10 +1539,12 @@ const analyzeCommandImpl = async (
const skipAgentsMd = skipAll || options.skipAgentsMd;
const skipSkills = skipAll || options.skipSkills;
const runOptions = {
// Pipeline re-index — OR'd with --skills because skill generation needs
// a fresh pipelineResult, and with --no-parse-cache because bypassing
// parser output is meaningful only when the pipeline runs.
force: options.force || options.skills || options.parseCache === false,
// The user's own --force only. --skills (skill generation needs a fresh
// pipelineResult) and --no-parse-cache (bypassing parser output means
// anything only when the pipeline runs) each force the rebuild inside
// runFullAnalysis under their own named reason (#3137).
force: options.force,
skills: options.skills,
useParseCache: options.parseCache !== false,
repairFts: options.repairFts,
skipFts: options.skipFts,
@ -1372,10 +1576,16 @@ const analyzeCommandImpl = async (
// be able to accept the duplicate name without also paying the
// cost of a full pipeline re-index. See #829 review round 2.
allowDuplicateName: options.allowDuplicateName,
shareWith: options.shareWith,
noShare: options.share === false,
// Worker pool size threaded from --workers, replacing the previous
// GITNEXUS_WORKER_POOL_SIZE env mutation. `undefined` defers to the
// env / auto-formula fallback inside the pipeline.
workerPoolSize,
maxProcesses: processDetectionFromFlags.maxProcesses,
maxProcessBranching: processDetectionFromFlags.maxProcessBranching,
maxProcessTraceDepth: processDetectionFromFlags.maxProcessTraceDepth,
maxEntryPointCandidates: processDetectionFromFlags.maxEntryPointCandidates,
// Extra fetch-wrapper names from `.gitnexusrc` (#1589/#1852 residual);
// forwarded to the routes phase consumer scan.
fetchWrappers: options.fetchWrappers,

View file

@ -114,6 +114,9 @@ function defaultSyncConfig(localPath: string): string {
'repo_git_timeout: 10s',
'analyze_timeout: 5m',
'analyze_failure_threshold: 3',
'# Extra SSH/HTTPS hosts beyond github.com, gitlab.com, and gitee.com.',
'# Exact DNS names only; wildcards are rejected.',
'# allowed_hosts: [gitlab.mycompany.com]',
'projects:',
` - local_path: ${localPath}`,
' branches: [master, main]',
@ -124,6 +127,8 @@ function defaultSyncConfig(localPath: string): string {
' overwrite_local_changes: false',
' remote_urls:',
' - git@github.com:owner/repo.git',
' # HTTPS remotes are also allowed. Other hosts need top-level allowed_hosts.',
' # - https://github.com/owner/public-repo.git',
'',
].join('\n');
}

View file

@ -9,62 +9,271 @@ import fs from 'fs/promises';
import path from 'path';
import { logger } from '../core/logger.js';
import {
findRegistryEntryByRepoPath,
findRepo,
unregisterRepo,
listRegisteredRepos,
getStoragePaths,
removeBranchIndex,
type RegistryEntry,
} from '../storage/repo-manager.js';
import { requireDeletableStoragePath, StorageDeletionError } from '../storage/storage-resolver.js';
import { formatSlotSize, formatStaleSlotLine } from './stale-branch-format.js';
import { listLocalHeads } from '../storage/git.js';
import {
isContainedBranchDir,
isDeleteCandidate,
listStaleBranchSlots,
removeBranchSlot,
staleListingBlock,
type StaleBranchSlot,
} from '../storage/stale-branch-slots.js';
import {
cleanParkedLbugSidecars,
inspectLbugSidecars,
listParkedLbugSidecars,
} from '../core/lbug/sidecar-recovery.js';
import { t } from './i18n/index.js';
import { getGlobalDir } from '../storage/global-dir.js';
import { STORES_DIR } from '../storage/shared-store.js';
import {
findLegacyLocalIndex,
reclaimAfterSlotRemoval,
reclaimSharedStore,
removeLegacyLocalIndex,
removeCheckoutStorage,
type ReclaimResult,
} from '../storage/shared-store-lifecycle.js';
type OwnedCwdStorage = {
repo: NonNullable<Awaited<ReturnType<typeof findRepo>>>;
entry: RegistryEntry | undefined;
storagePath: string;
};
const resolveOwnedCwdStorage = async (refusePrefix: string): Promise<OwnedCwdStorage | null> => {
const repo = await findRepo(process.cwd());
if (!repo) {
console.log(t('clean.notFoundHere'));
return null;
}
try {
const [entries, storagePath] = await Promise.all([
listRegisteredRepos(),
requireDeletableStoragePath({
path: repo.repoPath,
storagePath: repo.storagePath,
}),
]);
return {
repo,
entry: findRegistryEntryByRepoPath(entries, repo.repoPath),
storagePath,
};
} catch (err) {
if (err instanceof StorageDeletionError) {
logger.error(`${refusePrefix}${err.message}`);
return null;
}
throw err;
}
};
const printStaleSlotLines = (message: string, slots: readonly StaleBranchSlot[]): void => {
console.log(message);
for (const slot of slots) {
console.log(` - ${formatStaleSlotLine(slot)}`);
}
};
const cleanStaleBranchSlots = async (force: boolean): Promise<void> => {
const owned = await resolveOwnedCwdStorage('Refusing to clean leftover branch indexes: ');
if (!owned) return;
const { repo, entry, storagePath } = owned;
const slots = await listStaleBranchSlots({
repoPath: repo.repoPath,
storagePath,
branches: entry?.branches,
includeSize: !force,
});
const listingBlock = staleListingBlock(slots);
if (listingBlock === 'heads-unavailable') {
printStaleSlotLines(
t('clean.stale.headsUnavailable'),
slots.filter((row) => row.reason === 'heads-unavailable'),
);
return;
}
if (listingBlock === 'listing-failed') {
console.log(t('clean.stale.listingFailed'));
return;
}
const candidates = slots.filter(isDeleteCandidate);
const probeFailed = slots.filter((slot) => slot.reason === 'probe-failed');
if (candidates.length === 0) {
if (probeFailed.length > 0) {
printStaleSlotLines(t('clean.stale.probeFailed'), probeFailed);
return;
}
console.log(t('clean.stale.none'));
return;
}
if (!force) {
printStaleSlotLines(t('clean.stale.preview', { count: candidates.length }), candidates);
if (probeFailed.length > 0) {
printStaleSlotLines(t('clean.stale.probeFailed'), probeFailed);
}
console.log(`\n${t('common.runForceConfirm')}`);
return;
}
let deletedAny = false;
for (const slot of candidates) {
const heads = listLocalHeads(repo.repoPath);
if (heads === null) {
console.log(
deletedAny ? t('clean.stale.remainingSkipped') : t('clean.stale.headsUnavailable'),
);
return;
}
if (heads.includes(slot.branch)) {
console.log(t('clean.stale.skippedLive', { branch: slot.branch }));
continue;
}
const result = await removeBranchSlot({
repoPath: repo.repoPath,
storagePath,
branch: slot.branch,
dir: slot.dir,
});
if (!result.ok) {
console.log(t('clean.stale.failed', { branch: slot.branch }));
logger.error({ err: result.error }, 'Failed to delete leftover branch index:');
continue;
}
deletedAny = true;
console.log(t('clean.stale.deleted', { branch: slot.branch }));
}
if (probeFailed.length > 0) {
printStaleSlotLines(t('clean.stale.probeFailed'), probeFailed);
}
};
const reportReclaim = (result: ReclaimResult | null): void => {
if (!result) return;
if (result.removed.length > 0) {
console.log(t('clean.shared.reclaimed', { count: result.removed.length }));
}
if (result.kept.length > 0) console.log(t('clean.shared.kept', { count: result.kept.length }));
};
/** `clean --gc`: collect every shared store under GITNEXUS_HOME (#3352). */
const collectSharedStores = async (force: boolean): Promise<void> => {
const storesDir = path.join(getGlobalDir(), STORES_DIR);
// Only a missing stores root means "nothing to collect"; an unreadable one
// must fail loudly rather than report success.
const names = await fs.readdir(storesDir).catch((err: NodeJS.ErrnoException) => {
if (err.code === 'ENOENT') return [] as string[];
throw err;
});
// Stray files (`.DS_Store`) are not stores, and a symlink is not followed:
// reclaim deletes under the root it is given. A store another collector
// removed meanwhile is simply gone. The lstat skips a stray link; it is not
// a race guard, since stores/ belongs to the user running clean and anyone
// able to swap an entry there can already delete the store itself.
const roots: string[] = [];
for (const name of names) {
const root = path.join(storesDir, name);
const stat = await fs.lstat(root).catch((err: NodeJS.ErrnoException) => {
if (err.code === 'ENOENT') return null;
throw err;
});
if (stat?.isDirectory()) roots.push(root);
}
if (roots.length === 0) {
console.log(t('clean.gc.none'));
return;
}
for (const root of roots) {
// Without --force this is a preview: same selection, nothing deleted.
const result = await reclaimSharedStore(root, { gc: true, dryRun: !force });
console.log(
t(force ? 'clean.gc.store' : 'clean.gc.preview', {
path: root,
members: result.droppedMembers.length,
graphs: result.removed.length,
}),
);
if (result.keptMembers.length > 0) {
console.log(t('clean.gc.keptMembers', { count: result.keptMembers.length }));
}
if (result.kept.length > 0) console.log(t('clean.shared.kept', { count: result.kept.length }));
if (result.storeRemoved) console.log(t('clean.shared.storeRemoved', { path: root }));
}
if (!force) console.log(`\n${t('common.runForceConfirm')}`);
};
export const cleanCommand = async (options?: {
force?: boolean;
all?: boolean;
lbugSidecars?: boolean;
stale?: boolean;
branch?: string;
gc?: boolean;
localIndex?: boolean;
}) => {
// --branch <name>: remove a single non-primary branch's index (#2106 R7).
// Resolve against the RECORDED branches[] summary (never by slugging the
// user's raw input, which can disagree with the index-time-sanitized label).
if (options?.branch) {
const cwd = process.cwd();
const repo = await findRepo(cwd);
if (options?.gc) {
await collectSharedStores(options.force === true);
return;
}
// --local-index: delete a pre-adoption index left in <checkout>/.gitnexus
// after the checkout moved into a shared store (#3352). Keeps the pointer.
if (options?.localIndex) {
const repo = await findRepo(process.cwd());
if (!repo) {
console.log(t('clean.notFoundHere'));
return;
}
const entries = await listRegisteredRepos();
const entry = entries.find((e) => path.resolve(e.path) === path.resolve(repo.repoPath));
const legacy = await findLegacyLocalIndex(repo.repoPath, repo.storagePath);
if (!legacy) {
console.log(t('clean.localIndex.none'));
return;
}
if (!options.force) {
console.log(
t('clean.localIndex.preview', { path: legacy.dir, size: formatSlotSize(legacy.bytes) }),
);
console.log(`\n${t('common.runForceConfirm')}`);
return;
}
await removeLegacyLocalIndex(repo.repoPath, repo.storagePath);
console.log(
t('clean.localIndex.deleted', { path: legacy.dir, size: formatSlotSize(legacy.bytes) }),
);
return;
}
// --stale: reclaim leftover per-branch slots whose recorded branch is not
// a live local head (#3331). Exclusive arm before --branch.
if (options?.stale) {
await cleanStaleBranchSlots(options.force === true);
return;
}
// --branch <name>: remove a single non-primary branch's index (#2106 R7).
// Resolve against the RECORDED branches[] summary (never by slugging the
// user's raw input, which can disagree with the index-time-sanitized label).
if (options?.branch) {
const owned = await resolveOwnedCwdStorage('Refusing to clean branch index: ');
if (!owned) return;
const { repo, entry, storagePath } = owned;
const summary = entry?.branches?.find((b) => b.branch === options.branch);
if (!summary) {
console.log(t('clean.branchNotIndexed', { branch: options.branch }));
return;
}
let storagePath: string;
try {
storagePath = await requireDeletableStoragePath({
path: repo.repoPath,
storagePath: repo.storagePath,
});
} catch (err) {
if (err instanceof StorageDeletionError) {
logger.error(`Refusing to clean branch index: ${err.message}`);
return;
}
throw err;
}
const { lbugPath } = getStoragePaths(repo.repoPath, summary.branch, storagePath);
const branchDir = path.dirname(lbugPath);
// Safety guard: the target MUST live under the validated
// storage slot's `branches/` directory before any destructive fs.rm.
const branchesRoot = path.join(storagePath, 'branches') + path.sep;
if (!branchDir.startsWith(branchesRoot)) {
if (!isContainedBranchDir(storagePath, branchDir)) {
logger.error(
`Refusing to clean branch index outside the validated storage slot: ${branchDir}`,
);
@ -75,13 +284,17 @@ export const cleanCommand = async (options?: {
console.log(`\n${t('common.runForceConfirm')}`);
return;
}
try {
await fs.rm(branchDir, { recursive: true, force: true });
await removeBranchIndex(repo.repoPath, summary.branch);
console.log(t('clean.deletedBranch', { branch: summary.branch }));
} catch (err) {
logger.error({ err }, 'Failed to delete branch index:');
const result = await removeBranchSlot({
repoPath: repo.repoPath,
storagePath,
branch: summary.branch,
dir: branchDir,
});
if (!result.ok) {
logger.error({ err: result.error }, 'Failed to delete branch index:');
return;
}
console.log(t('clean.deletedBranch', { branch: summary.branch }));
return;
}
@ -177,9 +390,12 @@ export const cleanCommand = async (options?: {
for (const entry of entries) {
try {
const storagePath = await requireDeletableStoragePath(entry);
await fs.rm(storagePath, { recursive: true, force: true });
await unregisterRepo(entry.path);
// A shared slot is unregistered before it is deleted: its lock file
// lives inside it, so it must go last. A failed delete throws with the
// `clean --gc --force` recovery (shared-store-clean.test.ts).
await removeCheckoutStorage(storagePath, () => unregisterRepo(entry.path), entry.path);
console.log(t('clean.deletedRepo', { name: entry.name, storagePath }));
reportReclaim(await reclaimAfterSlotRemoval(storagePath));
} catch (err) {
if (err instanceof StorageDeletionError) {
logger.error(`Refusing to clean ${entry.name}: ${err.message}`);
@ -223,9 +439,9 @@ export const cleanCommand = async (options?: {
}
try {
await fs.rm(storagePath, { recursive: true, force: true });
await unregisterRepo(repo.repoPath);
await removeCheckoutStorage(storagePath, () => unregisterRepo(repo.repoPath), repo.repoPath);
console.log(t('common.deleted', { target: storagePath }));
reportReclaim(await reclaimAfterSlotRemoval(storagePath));
} catch (err) {
logger.error({ err }, 'Failed to delete:');
}

View file

@ -1,4 +1,5 @@
import { getRuntimeCapabilities, getRuntimeFingerprint } from '../core/platform/capabilities.js';
import { findLegacyLocalIndex } from '../storage/shared-store-lifecycle.js';
import { resolveEmbeddingConfig } from '../core/embeddings/config.js';
import { isHttpMode } from '../core/embeddings/http-client.js';
import {
@ -34,6 +35,18 @@ import { updateEligibleInstallSync } from '../core/install-context.js';
import { readValidatedUpdateCacheSync, type ValidatedUpdateCache } from '../core/update-cache.js';
import { t } from './i18n/index.js';
import { cachedUpdateNoticeLine } from './update-notice.js';
import { formatSlotSize, staleReasonLabel } from './stale-branch-format.js';
import {
findRegistryEntryByRepoPath,
findRepo,
listRegisteredRepos,
} from '../storage/repo-manager.js';
import {
isDeleteCandidate,
listStaleBranchSlots,
staleListingBlock,
type StaleBranchSlot,
} from '../storage/stale-branch-slots.js';
function isCombiningMark(codePoint: number): boolean {
return (
@ -191,6 +204,35 @@ export function nativeStatusLine(check: NativeCheckResult): string {
return ` ${padDisplayEnd('native', 10)}${nativeStatusText(check)}`;
}
/**
* Cwd leftover-slot lines (#3331). Pure: no deletes and no registry scan.
* When heads cannot be listed, do not title rows as orphaned or name
* `clean --stale` (#3337): that command refuses to delete in the same state.
*/
export function leftoverBranchSlotDoctorLines(slots: StaleBranchSlot[]): string[] {
if (slots.length === 0) return [];
const listingBlock = staleListingBlock(slots);
if (listingBlock === 'heads-unavailable') {
return [t('clean.stale.headsUnavailable')];
}
if (listingBlock === 'listing-failed') {
return [t('clean.stale.listingFailed')];
}
const lines = [t('doctor.orphanedBranches')];
let total = 0;
for (const slot of slots) {
total += slot.sizeBytes;
lines.push(
` ${slot.branch} ${staleReasonLabel(slot.reason)} ${formatSlotSize(slot.sizeBytes)}`,
);
}
lines.push(` ${t('doctor.orphanedBranches.total', { size: formatSlotSize(total) })}`);
if (slots.some(isDeleteCandidate)) {
lines.push(` ${t('doctor.orphanedBranches.reclaim')}`);
}
return lines;
}
function nativeStatusText(check: NativeCheckResult): string {
if (check.ok) return '✓ lbugjs.node loaded';
switch (check.kind) {
@ -359,4 +401,28 @@ export const doctorCommand = async () => {
console.log(` ${padDisplayEnd('', 12)}${cudaRedirect.detail}`);
}
}
// Cwd leftover-slot report only; never delete.
const cwdRepo = await findRepo(process.cwd());
if (!cwdRepo) return;
const entries = await listRegisteredRepos();
const entry = findRegistryEntryByRepoPath(entries, cwdRepo.repoPath);
const slots = await listStaleBranchSlots({
repoPath: cwdRepo.repoPath,
storagePath: cwdRepo.storagePath,
branches: entry?.branches,
});
const leftoverLines = leftoverBranchSlotDoctorLines(slots);
// A pre-adoption index left in <repo>/.gitnexus after this checkout moved
// into a shared store (#3352).
const legacy = await findLegacyLocalIndex(cwdRepo.repoPath, cwdRepo.storagePath);
if (legacy) {
leftoverLines.push(
t('status.legacyLocalIndex', { path: legacy.dir, size: formatSlotSize(legacy.bytes) }),
);
}
if (leftoverLines.length === 0) return;
console.log('');
for (const line of leftoverLines) {
console.log(line);
}
};

View file

@ -26,7 +26,8 @@ export type EditorId =
| 'opencode'
| 'codebuddy'
| 'qoder'
| 'codex';
| 'codex'
| 'droid';
/** An editor whose MCP config is a JSONC document (server keyed by name). */
export interface McpJsoncTarget {
@ -151,6 +152,15 @@ export function getEditorTargets(home: string = os.homedir()): EditorTargets {
file: path.join(home, '.qoder.json'),
keyPath: ['mcpServers', 'gitnexus'],
},
{
id: 'droid',
label: 'Factory Droid',
// Factory's user-scope MCP config (https://docs.factory.ai/cli/configuration/mcp).
// Same `mcpServers` object shape as Cursor/Claude; user config takes
// precedence over project-level .factory/mcp.json.
file: path.join(home, '.factory', 'mcp.json'),
keyPath: ['mcpServers', 'gitnexus'],
},
];
const codex: CodexMcpTarget = {
@ -175,6 +185,9 @@ export function getEditorTargets(home: string = os.homedir()): EditorTargets {
{ id: 'qoder', label: 'Qoder', dir: path.join(home, '.qoder', 'skills') },
// Codex reads skills from ~/.agents/skills (not ~/.codex).
{ id: 'codex', label: 'Codex', dir: path.join(home, '.agents', 'skills') },
// Factory Droid reads user-scope skills from ~/.factory/skills/{name}/SKILL.md
// (https://docs.factory.ai/cli/configuration/skills).
{ id: 'droid', label: 'Factory Droid', dir: path.join(home, '.factory', 'skills') },
];
const hooks: HookTarget[] = [

View file

@ -1,4 +1,6 @@
import { lstat } from 'node:fs/promises';
import { ensurePrivateSharedGraph } from '../core/shared-store-analyze.js';
import { LBUG_DIRECTORY } from '../storage/storage-constants.js';
import path from 'node:path';
import { cliInfo } from './cli-message.js';
import { getGitRoot } from '../storage/git.js';
@ -43,14 +45,20 @@ export const embeddingsSyncCommand = async (inputPath?: string): Promise<void> =
const repoPath = inputPath ? path.resolve(inputPath) : getGitRoot(process.cwd());
if (!repoPath) throw new Error('Not inside a git repository. Pass a repository path.');
const { lbugPath, metaPath } = getStoragePaths(repoPath);
const { metaPath } = getStoragePaths(repoPath);
const metaDir = path.dirname(metaPath);
// Writes go to the slot's own graph. A shared-store checkout that reads an
// immutable commit graph (#3352) takes a private copy first.
const lbugPath = path.join(metaDir, LBUG_DIRECTORY);
const lock = await acquireIndexLock(metaDir);
try {
requireExclusiveIndexLock(
lock,
`Cannot acquire the index lock at ${metaDir}; refusing an unlocked embeddings sync.`,
);
if (!(await ensurePrivateSharedGraph(metaDir, (m) => console.log(` ${m}`)))) {
throw new Error('The shared graph this checkout reads is gone. Run gitnexus analyze first.');
}
const meta = await loadMeta(metaDir);
if (!meta)
throw new Error(`No GitNexus index found for ${repoPath}. Run gitnexus analyze first.`);

View file

@ -67,11 +67,18 @@ const OPTION_DESCRIPTION_KEYS = {
'analyze|--skip-git': 'help.option.skipGit',
'analyze|--name <alias>': 'help.option.analyze.name',
'analyze|--allow-duplicate-name': 'help.option.analyze.allowDuplicateName',
'analyze|--share-with <repo>': 'help.option.analyze.shareWith',
'analyze|--no-share': 'help.option.analyze.noShare',
'analyze|-v, --verbose': 'help.option.verbose',
'analyze|--max-file-size <kb>': 'help.option.analyze.maxFileSize',
'analyze|--worker-timeout <seconds>': 'help.option.analyze.workerTimeout',
'analyze|--wal-checkpoint-threshold <bytes>': 'help.option.analyze.walCheckpointThreshold',
'analyze|--memory-budget <mb>': 'help.option.analyze.memoryBudget',
'analyze|--workers <n>': 'help.option.analyze.workers',
'analyze|--max-processes <n>': 'help.option.analyze.maxProcesses',
'analyze|--max-process-branching <n>': 'help.option.analyze.maxProcessBranching',
'analyze|--max-process-trace-depth <n>': 'help.option.analyze.maxProcessTraceDepth',
'analyze|--max-entry-point-candidates <n>': 'help.option.analyze.maxEntryPointCandidates',
'analyze|--embedding-threads <n>': 'help.option.analyze.embeddingThreads',
'analyze|--embedding-batch-size <n>': 'help.option.analyze.embeddingBatchSize',
'analyze|--embedding-sub-batch-size <n>': 'help.option.analyze.embeddingSubBatchSize',
@ -92,6 +99,9 @@ const OPTION_DESCRIPTION_KEYS = {
'clean|--all': 'help.option.clean.all',
'clean|--branch <name>': 'help.option.clean.branch',
'clean|--lbug-sidecars': 'help.option.clean.lbugSidecars',
'clean|--stale': 'help.option.clean.stale',
'clean|--gc': 'help.option.clean.gc',
'clean|--local-index': 'help.option.clean.localIndex',
'remove|-f, --force': 'help.option.force.confirmation',
'wiki|-f, --force': 'help.option.wiki.force',
'wiki|--provider <provider>': 'help.option.wiki.provider',

View file

@ -28,6 +28,15 @@ export const en = {
'list.clusters': 'Clusters',
'list.processes': 'Processes',
'list.unknown': 'unknown',
'status.sharedStoreShared': 'Shared index: store {{key}}, shared graph for commit {{commit}}',
'status.sharedStorePrivate':
'Shared index: store {{key}}, private graph (local changes or a pinned branch index)',
'status.sharedStoreCloneCow':
' Copied copy-on-write: unchanged pages are shared with the commit graph on disk',
'status.sharedStoreCloneCopy':
' Full copy: this filesystem cannot clone copy-on-write (APFS, btrfs and XFS can)',
'status.legacyLocalIndex':
'Leftover local index: {{path}} ({{size}}); remove it with `gitnexus clean --local-index --force`',
'status.notGitRepo': 'Not a git repository.',
'status.staleKuzu': 'Repository has a stale KuzuDB index from a previous version.',
'status.rebuildLadybug': 'Run: gitnexus analyze (rebuilds the index with LadybugDB)',
@ -59,8 +68,47 @@ export const en = {
'clean.deleteAll': 'This will delete GitNexus indexes for {{count}} repo(s):',
'clean.deletedRepo': 'Deleted: {{name}} ({{storagePath}})',
'clean.notFoundHere': 'No indexed repository found in this directory.',
'clean.shared.reclaimed':
'Shared store: removed {{count}} commit graph(s) no checkout references.',
'clean.shared.kept':
'Shared store: kept {{count}} unreferenced commit graph(s) that could not be removed (in use or not writable); run `gitnexus clean --gc` later.',
'clean.shared.storeRemoved': 'Shared store: removed {{path}} (no checkouts remain).',
'clean.gc.none': 'No shared stores to collect.',
'clean.gc.keptMembers':
'Shared store: kept {{count}} checkout(s) it could not delete; run `gitnexus clean --gc --force` later.',
'clean.gc.store':
'Shared store {{path}}: dropped {{members}} checkout(s), removed {{graphs}} commit graph(s).',
'clean.gc.preview':
'Shared store {{path}}: would drop {{members}} checkout(s) and remove {{graphs}} commit graph(s).',
'clean.localIndex.none': 'No leftover local index in this checkout.',
'clean.localIndex.preview':
'This will delete the leftover local index at {{path}} ({{size}}). The shared index is not affected.',
'clean.localIndex.deleted': 'Deleted the leftover local index at {{path}} ({{size}}).',
'clean.deleteCurrent': 'This will delete the GitNexus index for: {{repoName}}',
'clean.branchNotIndexed': 'No indexed branch named "{{branch}}" for this repository.',
'clean.branchNotIndexed':
'No indexed branch named "{{branch}}" for this repository. Use `gitnexus clean --stale` to reclaim leftover branch indexes, or `gitnexus list` to see recorded names.',
'clean.stale.none': 'No leftover branch indexes to reclaim.',
'clean.stale.preview': 'This will delete {{count}} leftover branch index(es):',
'clean.stale.item': '{{branch}} {{reason}} {{path}} {{size}}',
'clean.stale.registryOnlyPath': '(registry only)',
'clean.stale.headsUnavailable':
'Could not list local heads; leftover branch indexes were not deleted. Re-run `gitnexus clean --stale` when git is available.',
'clean.stale.remainingSkipped':
'Could not list local heads; remaining leftover branch indexes were skipped. Re-run `gitnexus clean --stale` when git is available.',
'clean.stale.listingFailed':
'Could not read leftover branch index directories; leftover indexes were not deleted. Check permissions on the branches/ directory and re-run `gitnexus clean --stale`.',
'clean.stale.probeFailed':
'Could not inspect leftover branch index path(s); those slots were not deleted.',
'clean.stale.deleted': 'Deleted leftover branch index: {{branch}}',
'clean.stale.failed': 'Could not delete leftover branch index "{{branch}}".',
'clean.stale.skippedLive':
'Skipped leftover branch index "{{branch}}" — it is a local head again.',
'clean.stale.reason.refMissing': 'not a local head',
'clean.stale.reason.diskOnly': 'leftover directory (no registry row)',
'clean.stale.reason.registryOnly': 'registry row (directory gone)',
'clean.stale.reason.headsUnavailable': 'could not list local heads',
'clean.stale.reason.probeFailed': 'could not inspect slot path',
'clean.stale.reason.listingFailed': 'could not list leftover directories',
'clean.deleteBranch': 'This will delete the branch index "{{branch}}" at: {{path}}',
'clean.deletedBranch': 'Deleted branch index: {{branch}}',
'clean.lbugSidecars.state': 'LadybugDB sidecar state: {{state}}',
@ -116,6 +164,9 @@ export const en = {
'doctor.runtime': 'Runtime',
'doctor.capabilities': 'Capabilities',
'doctor.embeddings': 'Embeddings',
'doctor.orphanedBranches': 'Orphaned branch indexes',
'doctor.orphanedBranches.total': 'Total: {{size}}',
'doctor.orphanedBranches.reclaim': 'Reclaim with: gitnexus clean --stale',
'doctor.labels.os': 'OS:',
'doctor.labels.node': 'Node:',
'doctor.labels.gitnexus': 'GitNexus:',
@ -154,13 +205,13 @@ export const en = {
'help.option.help': 'display help for command',
'help.option.version': 'output the version number',
'help.command.setup.description':
'One-time setup: configure MCP for Cursor, Claude Code, Antigravity, OpenCode, CodeBuddy, Qoder, Codex',
'One-time setup: configure MCP for Cursor, Claude Code, Antigravity, OpenCode, CodeBuddy, Qoder, Codex, Factory Droid',
'help.command.uninstall.description':
'Reverse `setup`: remove GitNexus MCP entries, skills, and hooks from all detected editors',
'help.command.autoSync.description':
'Control scheduled repository clone/pull and analysis from GITNEXUS_HOME/watch_config.yml',
'help.autoSync.details':
'\nActions: init, start (default), restart, stop, status, reset\nConfiguration: GITNEXUS_HOME/watch_config.yml\nRuntime files: GITNEXUS_HOME/watch/watch.pid, watch.mutex, watch.owner.json, watch.status.json, auto-sync-state.json\nRecovery: mutexes with verified dead owners are reclaimed automatically; invalid or legacy mutexes fail closed and require manual removal after confirming no watch process is running.\nWrites: GITNEXUS_HOME/watch/project_commit_info.txt\nRemote URLs: only SSH URLs on github.com, gitlab.com, and gitee.com are allowed.\nRuns once immediately, then repeats on sync_interval_minutes.',
'\nActions: init, start (default), restart, stop, status, reset\nConfiguration: GITNEXUS_HOME/watch_config.yml\nRuntime files: GITNEXUS_HOME/watch/watch.pid, watch.mutex, watch.owner.json, watch.status.json, auto-sync-state.json\nRecovery: mutexes with verified dead owners are reclaimed automatically; invalid or legacy mutexes fail closed and require manual removal after confirming no watch process is running.\nWrites: GITNEXUS_HOME/watch/project_commit_info.txt\nRemote URLs: SSH or HTTPS URLs on github.com, gitlab.com, and gitee.com are allowed. Other hosts need a top-level allowed_hosts list of exact DNS names. Invalid watch_config.yml skips auto-sync immediately.\nRuns once immediately, then repeats on sync_interval_minutes.',
'help.command.watch.description':
'Ambiguous: use `analyze --watch` for local files, or `auto-sync` for scheduled remotes',
'help.watch.details':
@ -245,6 +296,10 @@ export const en = {
'Register this repo under a custom name in ~/.gitnexus/registry.json (disambiguates repos whose paths share a basename, e.g. two different .../app folders)',
'help.option.analyze.allowDuplicateName':
'Register this repo even if another path already uses the same --name alias. Leaves `-r <name>` ambiguous for the two paths; use -r <path> to disambiguate.',
'help.option.analyze.shareWith':
'Join the shared index store of a registered checkout of the same repository (name or path); the remote URL must match. Clones join a sibling clone’s store automatically; this names one explicitly and clears a --no-share opt-out.',
'help.option.analyze.noShare':
'Clones only: leave the shared index store, index into <repo>/.gitnexus again, and stop joining sibling clones automatically until --share-with (linked worktrees always share; set GITNEXUS_SHARED_STORE=off instead)',
'help.option.verbose': 'Enable verbose output',
'help.option.analyze.maxFileSize':
'Skip files larger than this (KB). Default: 512. Hard cap: 32768 (tree-sitter limit).',
@ -252,8 +307,18 @@ export const en = {
'Worker sub-batch idle timeout before retry/fallback. Default: 30.',
'help.option.analyze.walCheckpointThreshold':
'LadybugDB WAL auto-checkpoint threshold in bytes during analyze (integer >= -1; default: 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB).',
'help.option.analyze.memoryBudget':
'Main-thread V8 heap size in MB for analyze (integer >= 200). Re-runs analyze with exactly this heap, overriding the RAM/cgroup auto-sizer and any --max-old-space-size pin; parse workers keep their own heap caps.',
'help.option.analyze.workers':
'Parse worker pool size (>=1). Default: cores-1 capped at 16, auto-sized to the repo.',
'help.option.analyze.maxProcesses':
'Process-detection process cap (positive integer). Replaces the dynamic max(20, round(symbols/10)) formula. Default: dynamic.',
'help.option.analyze.maxProcessBranching':
'Process-detection per-node branching cap (positive integer). Default: 4.',
'help.option.analyze.maxProcessTraceDepth':
'Process-detection DFS depth cap (positive integer). Default: 10.',
'help.option.analyze.maxEntryPointCandidates':
'Ranked entry-point candidate pool (positive integer). Default: 200. Raise when the warning names this knob; doubling is the usual first raise.',
'help.option.analyze.embeddingThreads': 'Limit local ONNX embedding CPU threads',
'help.option.analyze.embeddingBatchSize': 'Number of nodes per embedding batch',
'help.option.analyze.embeddingSubBatchSize': 'Number of chunks per embedding model call',
@ -275,6 +340,11 @@ export const en = {
'help.option.clean.branch': 'Delete only the named branch index (not the workspace index)',
'help.option.clean.lbugSidecars':
'Clean parked LadybugDB recovery sidecars (missing-shadow WAL quarantines and dirty-recovery parks)',
'help.option.clean.stale': 'Reclaim leftover branch indexes that are not a live local head',
'help.option.clean.gc':
'Drop shared-store checkouts no registry entry uses and delete commit graphs nothing references',
'help.option.clean.localIndex':
'Delete the index left in <repo>/.gitnexus after this checkout moved into a shared store',
'help.option.wiki.force': 'Force full regeneration even if up to date',
'help.option.wiki.provider':
'LLM provider: minimax, openai, openrouter, azure, custom, cursor, claude, codex, opencode, or grok (default: minimax)',
@ -358,5 +428,5 @@ export const en = {
'help.identityCache.environment':
'\nAnalyzer identity cache:\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir\n Operator-trusted persistent cache for warm cross-process status. The directory must pre-exist, be outside the GitNexus package/build roots, and contain no symlink or junction components. Defaults remain fail-closed on platforms without POSIX ownership APIs.',
'help.analyze.environment':
'\nEnvironment variables:\n GITNEXUS_NO_GITIGNORE=1 Skip .gitignore parsing (still reads .gitnexusignore)\n GITNEXUS_MAX_FILE_SIZE=N Override large-file skip threshold (KB). Default 512, max 32768.\n GITNEXUS_STORAGE_PATH=/absolute/index Complete external index directory. Preserves the existing configuration semantics and overrides GITNEXUS_STORAGE_ROOT when both are set.\n GITNEXUS_STORAGE_ROOT=/absolute/root External index root; each repository uses an isolated <repo-basename>-<canonical-path-hash>/ slot.\n GITNEXUS_CONTENT_RETENTION=full Source-text retention profile: full, symbol, or none. Default full.\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir Operator-trusted persistent analyzer identity cache; must pre-exist, be outside package/build roots, and contain no symlink/junction components.\n GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker idle timeout in milliseconds. Default 30000.\n GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL auto-checkpoint threshold in bytes (default 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB).\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker job byte budget. Default 8388608.\n GITNEXUS_WORKER_POOL_SIZE=N Parse worker count override. Default cores-1 capped at 16.\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N Concurrent in-flight parse chunks. Default 2.\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N Max replacement spawns per slot before drop. Default 3.\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N Total retry wall-time per job. Default 5x sub-batch timeout.\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N Per-slot deaths to trip circuit breaker. Default max(3, poolSize).\n GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS=N Max wait at pool shutdown for a retired worker still inside native code (terminated at its next safe point instead of aborting the process). Default 30000.\n GITNEXUS_CPP_CAPTURE_BUDGET_MS=N Per-file wall-clock budget for C++ capture extraction; on breach the file keeps partial captures with a warning. Default 20000.\n GITNEXUS_EMBEDDING_THREADS=N Limit local ONNX CPU threads for --embeddings.\n GITNEXUS_EMBEDDING_RETRY_TIMEOUTS=1 Retry per-attempt HTTP embedding timeouts through GITNEXUS_EMBEDDING_MAX_ATTEMPTS (default off; timeouts stay terminal).\n GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N Max embedding chunks for exact-scan fallback. Default 10000.\n GITNEXUS_VECTOR_MAX_DISTANCE=N Max accepted semantic/vector cosine distance (0 < N <= 2; higher values clamp to 2). Default 0.6 for MCP, 0.5 elsewhere.\n\nFlags override the corresponding env vars when both are provided.\n\nTip: `.gitnexusignore` supports `.gitignore`-style negation. Add e.g.\n `!__tests__/` to index a directory that is auto-filtered by default (#771).',
'\nEnvironment variables:\n GITNEXUS_NO_GITIGNORE=1 Skip .gitignore parsing (still reads .gitnexusignore)\n GITNEXUS_MAX_FILE_SIZE=N Override large-file skip threshold (KB). Default 512, max 32768.\n GITNEXUS_STORAGE_PATH=/absolute/index Complete external index directory. Preserves the existing configuration semantics and overrides GITNEXUS_STORAGE_ROOT when both are set.\n GITNEXUS_STORAGE_ROOT=/absolute/root External index root; each repository uses an isolated <repo-basename>-<canonical-path-hash>/ slot.\n GITNEXUS_CONTENT_RETENTION=full Source-text retention profile: full, symbol, or none. Default full.\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir Operator-trusted persistent analyzer identity cache; must pre-exist, be outside package/build roots, and contain no symlink/junction components.\n GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker idle timeout in milliseconds. Default 30000.\n GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL auto-checkpoint threshold in bytes (default 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB).\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker job byte budget. Default 8388608.\n GITNEXUS_WORKER_POOL_SIZE=N Parse worker count override. Default cores-1 capped at 16.\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N Concurrent in-flight parse chunks. Default 2.\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N Max replacement spawns per slot before drop. Default 3.\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N Total retry wall-time per job. Default 5x sub-batch timeout.\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N Per-slot deaths to trip circuit breaker. Default max(3, poolSize).\n GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS=N Max wait at pool shutdown for a retired worker still inside native code (terminated at its next safe point instead of aborting the process). Default 30000.\n GITNEXUS_CPP_CAPTURE_BUDGET_MS=N Per-file wall-clock budget for C++ capture extraction; on breach the file keeps partial captures with a warning. Default 20000.\n GITNEXUS_EMBEDDING_THREADS=N Limit local ONNX CPU threads for --embeddings.\n GITNEXUS_EMBEDDING_RETRY_TIMEOUTS=1 Retry per-attempt HTTP embedding timeouts through GITNEXUS_EMBEDDING_MAX_ATTEMPTS (default off; timeouts stay terminal).\n GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N Max embedding chunks for exact-scan fallback. Default 10000.\n GITNEXUS_VECTOR_MAX_DISTANCE=N Max accepted semantic/vector cosine distance (0 < N <= 2; higher values clamp to 2). Default 0.6 for MCP, 0.5 elsewhere.\n GITNEXUS_MAX_PROCESSES=N Process-detection process cap (positive integer). Replaces the dynamic max(20, round(symbols/10)) formula. Distinct from query-time IMPACT_MAX_CHUNKS.\n GITNEXUS_MAX_PROCESS_BRANCHING=N Process-detection per-node branching cap. Default 4.\n GITNEXUS_MAX_PROCESS_TRACE_DEPTH=N Process-detection DFS depth cap. Default 10.\n GITNEXUS_MAX_ENTRY_POINT_CANDIDATES=N Ranked entry-point candidate pool. Default 200. Raise when the warning names this knob; doubling is the usual first raise.\n\nCLI flags take precedence over `.gitnexusrc`, which takes precedence over env vars, which take precedence over built-in defaults.\n\nTip: `.gitnexusignore` supports `.gitignore`-style negation. Add e.g.\n `!__tests__/` to index a directory that is auto-filtered by default (#771).',
} as const;

Some files were not shown because too many files have changed in this diff Show more