mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-21 00:21:30 +00:00
Merge remote-tracking branch 'upstream/main'
This commit is contained in:
commit
7ba5483f61
29 changed files with 1213 additions and 205 deletions
10
.github/workflows/ci-tests.yml
vendored
10
.github/workflows/ci-tests.yml
vendored
|
|
@ -184,8 +184,10 @@ jobs:
|
|||
# spawns and Windows is ~5x slower than macOS at those, so the unsharded
|
||||
# run crept past the 15-min watchdog in run-cross-platform.ts. vitest
|
||||
# shards by file COUNT, not runtime, so the heaviest spawn suites can
|
||||
# cluster on one shard; 3 shards keep even the busiest Windows shard
|
||||
# comfortably under the watchdog (macOS had margin either way).
|
||||
# cluster on one shard. The busiest Windows shard has grown to the old
|
||||
# 15-minute watchdog (14m57s on the v1.6.10-rc.19 green run, one
|
||||
# observed timeout since — #2449), so the job env below raises the
|
||||
# per-shard watchdog to 20 minutes, still bounded by timeout-minutes.
|
||||
# Shard indices come from the shard-plan job (single source of truth):
|
||||
# its TOTAL drives this list and the /N in the job name + --shard arg.
|
||||
shard: ${{ fromJSON(needs.shard-plan.outputs.shards) }}
|
||||
|
|
@ -204,6 +206,10 @@ jobs:
|
|||
env:
|
||||
GITNEXUS_REQUIRE_FTS: '1'
|
||||
GITNEXUS_E2E_CLI: dist
|
||||
# #2449: hosted Windows runners intermittently push the busiest shard past
|
||||
# the default 15-minute watchdog. 20 minutes restores real headroom while
|
||||
# the 25-minute job timeout above still bounds a genuine hang.
|
||||
GITNEXUS_CROSS_PLATFORM_TIMEOUT_MINUTES: '20'
|
||||
steps:
|
||||
# persist-credentials: false — runs tests only, never pushes (zizmor
|
||||
# credential-persistence / artipacked audit).
|
||||
|
|
|
|||
21
.github/workflows/publish.yml
vendored
21
.github/workflows/publish.yml
vendored
|
|
@ -423,6 +423,10 @@ jobs:
|
|||
echo "::error::Tag version (v$TAG_VERSION) does not match package.json version ($PKG_VERSION)"
|
||||
exit 1
|
||||
fi
|
||||
# Stable releases carry their version bump on main via the release
|
||||
# PR, so the manifest surfaces must already be in sync — refuse to
|
||||
# publish a stable whose manifests drifted (#2445).
|
||||
node scripts/sync-plugin-manifests.mjs --check
|
||||
echo "Version verified: $PKG_VERSION"
|
||||
|
||||
# ── RC-only: compute the next rc version against the live registry ──
|
||||
|
|
@ -584,6 +588,17 @@ jobs:
|
|||
npm version "${{ steps.rc-version.outputs.rc_version }}" \
|
||||
--no-git-tag-version --allow-same-version
|
||||
|
||||
# ── Verify the plugin manifest surfaces synced (#2445) ───────────────
|
||||
# The npm `version` lifecycle script in gitnexus/package.json syncs all
|
||||
# four manifest surfaces whenever `npm version` runs (the step above,
|
||||
# and a maintainer's laptop alike). This step only verifies fail-closed
|
||||
# so a future removal of that wiring cannot ship a drifted RC again.
|
||||
- name: Verify plugin manifests (rc)
|
||||
if: needs.route.outputs.mode == 'rc'
|
||||
shell: bash
|
||||
working-directory: gitnexus
|
||||
run: node scripts/sync-plugin-manifests.mjs --check
|
||||
|
||||
- name: Build gitnexus
|
||||
run: npm run build
|
||||
working-directory: gitnexus
|
||||
|
|
@ -669,6 +684,12 @@ jobs:
|
|||
# pristine, but the v-tag's tree matches the published package
|
||||
# exactly (release-integrity).
|
||||
git add package.json package-lock.json 2>/dev/null || git add package.json
|
||||
# The synced manifest surfaces (#2445) belong in the same detached
|
||||
# release commit so the tag's tree passes its own version contract.
|
||||
git add ../gitnexus-claude-plugin/.claude-plugin/plugin.json \
|
||||
../.claude-plugin/marketplace.json \
|
||||
../gitnexus-claude-plugin/.codex-plugin/plugin.json \
|
||||
../.agents/plugins/marketplace.json
|
||||
git commit -m "release: ${VTAG}" --allow-empty
|
||||
RELEASE_SHA="$(git rev-parse HEAD)"
|
||||
echo "Detached release commit: $RELEASE_SHA"
|
||||
|
|
|
|||
186
README.md
186
README.md
|
|
@ -82,15 +82,15 @@ That's it. `analyze` indexes the codebase, installs agent skills, registers Clau
|
|||
|
||||
## Two Ways to Use GitNexus
|
||||
|
||||
| | **CLI + MCP** (recommended) | **Web UI** |
|
||||
| ----------- | ---------------------------------------------------------------------- | --------------------------------------------------------------------- |
|
||||
| **What** | Index repos locally, connect AI agents via MCP | Visual graph explorer + AI chat in browser |
|
||||
| **For** | Daily development with Cursor, Claude Code, Antigravity, Codex, Windsurf, OpenCode | Quick exploration, demos, one-off analysis |
|
||||
| **Scale** | Full repos, any size | Limited by browser memory (~5k files), or unlimited via backend mode |
|
||||
| **Install** | `npm install -g gitnexus` | No install — [gitnexus.vercel.app](https://gitnexus.vercel.app) |
|
||||
| **Storage** | LadybugDB native (fast, persistent) | LadybugDB WASM (in-memory, per session) |
|
||||
| **Parsing** | Tree-sitter native bindings | Tree-sitter WASM |
|
||||
| **Privacy** | Everything local, no network | Everything in-browser, no server |
|
||||
| | **CLI + MCP** (recommended) | **Web UI** |
|
||||
| ----------- | ---------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
|
||||
| **What** | Index repos locally, connect AI agents via MCP | Visual graph explorer + AI chat in browser |
|
||||
| **For** | Daily development with Cursor, Claude Code, Antigravity, Codex, Windsurf, OpenCode | Quick exploration, demos, one-off analysis |
|
||||
| **Scale** | Full repos, any size | Limited by browser memory (~5k files), or unlimited via backend mode |
|
||||
| **Install** | `npm install -g gitnexus` | No install — [gitnexus.vercel.app](https://gitnexus.vercel.app) |
|
||||
| **Storage** | LadybugDB native (fast, persistent) | LadybugDB WASM (in-memory, per session) |
|
||||
| **Parsing** | Tree-sitter native bindings | Tree-sitter WASM |
|
||||
| **Privacy** | Everything local, no network | Everything in-browser, no server |
|
||||
|
||||
> **Bridge mode:** `gitnexus serve` connects the two — the web UI auto-detects the local server and can browse all your CLI-indexed repos without re-uploading or re-indexing.
|
||||
|
||||
|
|
@ -137,49 +137,49 @@ flowchart TB
|
|||
|
||||
### 17 MCP tools (15 per-repo + 2 group)
|
||||
|
||||
| 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) |
|
||||
| `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 |
|
||||
|
||||
> Per-repo tools take an optional `repo` parameter (omit it when only one repo is indexed) and 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`.
|
||||
|
||||
### Resources for instant context
|
||||
|
||||
| Resource | Purpose |
|
||||
| ---------------------------------------- | ---------------------------------------------------- |
|
||||
| `gitnexus://repos` | List all indexed repositories (read this first) |
|
||||
| `gitnexus://setup` | Setup and usage guidance for agents |
|
||||
| `gitnexus://repo/{name}/context` | Codebase stats, staleness check, and available tools |
|
||||
| `gitnexus://repo/{name}/clusters` | All functional clusters with cohesion scores |
|
||||
| `gitnexus://repo/{name}/cluster/{name}` | Cluster members and details |
|
||||
| `gitnexus://repo/{name}/processes` | All execution flows |
|
||||
| `gitnexus://repo/{name}/process/{name}` | Full process trace with steps |
|
||||
| `gitnexus://repo/{name}/schema` | Graph schema for Cypher queries |
|
||||
| `gitnexus://group/{name}/contracts` | A group's extracted contracts and cross-links |
|
||||
| `gitnexus://group/{name}/status` | Staleness of repos in a group |
|
||||
| Resource | Purpose |
|
||||
| --------------------------------------- | ---------------------------------------------------- |
|
||||
| `gitnexus://repos` | List all indexed repositories (read this first) |
|
||||
| `gitnexus://setup` | Setup and usage guidance for agents |
|
||||
| `gitnexus://repo/{name}/context` | Codebase stats, staleness check, and available tools |
|
||||
| `gitnexus://repo/{name}/clusters` | All functional clusters with cohesion scores |
|
||||
| `gitnexus://repo/{name}/cluster/{name}` | Cluster members and details |
|
||||
| `gitnexus://repo/{name}/processes` | All execution flows |
|
||||
| `gitnexus://repo/{name}/process/{name}` | Full process trace with steps |
|
||||
| `gitnexus://repo/{name}/schema` | Graph schema for Cypher queries |
|
||||
| `gitnexus://group/{name}/contracts` | A group's extracted contracts and cross-links |
|
||||
| `gitnexus://group/{name}/status` | Staleness of repos in a group |
|
||||
|
||||
### 2 MCP prompts for guided workflows
|
||||
|
||||
| Prompt | What It Does |
|
||||
| --------------- | -------------------------------------------------------------------------- |
|
||||
| `detect_impact` | Pre-commit change analysis — scope, affected processes, risk level |
|
||||
| `generate_map` | Architecture documentation from the knowledge graph with mermaid diagrams |
|
||||
| Prompt | What It Does |
|
||||
| --------------- | ------------------------------------------------------------------------- |
|
||||
| `detect_impact` | Pre-commit change analysis — scope, affected processes, risk level |
|
||||
| `generate_map` | Architecture documentation from the knowledge graph with mermaid diagrams |
|
||||
|
||||
### 6 agent skills installed to `.claude/skills/` automatically
|
||||
|
||||
|
|
@ -196,20 +196,21 @@ flowchart TB
|
|||
|
||||
`gitnexus setup` auto-detects your editors and writes the correct global MCP config. Run it once. To configure only selected integrations, pass `--coding-agent`/`-c` with a comma-separated list, e.g. `gitnexus setup -c cursor,codex`.
|
||||
|
||||
| Editor | MCP | Skills | Hooks (auto-augment) | Support |
|
||||
| ------------------------ | --- | ------ | ---------------------------------------------------------------------------------------- | ------------ |
|
||||
| **Claude Code** | Yes | Yes | Yes (PreToolUse + PostToolUse) | **Full** |
|
||||
| **Cursor** | Yes | Yes | Yes (postToolUse, [manual install](gitnexus-cursor-integration/README.md#hook-install)) | **Full** |
|
||||
| Editor | MCP | Skills | Hooks (auto-augment) | Support |
|
||||
| ------------------------ | --- | ------ | ----------------------------------------------------------------------------------------------------------------- | ------------ |
|
||||
| **Claude Code** | Yes | Yes | Yes (PreToolUse + PostToolUse) | **Full** |
|
||||
| **Cursor** | Yes | Yes | Yes (postToolUse, [manual install](gitnexus-cursor-integration/README.md#hook-install)) | **Full** |
|
||||
| **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** |
|
||||
| **OpenCode** | Yes | Yes | — | MCP + Skills |
|
||||
| **CodeBuddy** (Tencent) | Yes | Yes | — | MCP + Skills |
|
||||
| **Qoder** (Alibaba) | Yes | Yes | — | MCP + Skills |
|
||||
| **Windsurf** | Yes | — | — | MCP |
|
||||
| **Codex** | Yes | Yes | Yes (PreToolUse + PostToolUse, [Codex hooks](https://developers.openai.com/codex/hooks)) | **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.
|
||||
|
||||
<a id="fn-antigravity-hooks"></a>
|
||||
|
||||
> ¹ **Antigravity hooks** follow the [Gemini CLI hooks reference](https://geminicli.com/docs/hooks/reference/) (Antigravity 2.0 is the documented successor to Gemini CLI). Augmentation runs in `AfterTool` because `BeforeTool` has no context-injection channel in the Gemini contract — the agent sees graph context appended to the tool result via `hookSpecificOutput.additionalContext`. Stale-index hints land in the same channel after a successful `git commit/merge/rebase/cherry-pick/pull`. The schema may evolve if Antigravity-specific hook docs diverge from Gemini CLI's; the implementation will track those changes.
|
||||
|
||||
<details>
|
||||
|
|
@ -446,7 +447,7 @@ Commit a `.gitnexusrc` JSON file at the repo root to preconfigure recurring `ana
|
|||
"skipContextFiles": true, // alias of skipAgentsMd: keep your own AGENTS.md/CLAUDE.md
|
||||
"skipSkills": true, // don't install standard .claude/skills/gitnexus-* skills
|
||||
"embeddings": true, // generate embeddings by default
|
||||
"workerTimeout": 60
|
||||
"workerTimeout": 60,
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -470,32 +471,32 @@ Notes:
|
|||
|
||||
Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max-file-size`, `--verbose`). Use the env-var form when you'd otherwise repeat the same flag every run, or when invoking GitNexus from a long-running host (MCP server, eval-server, CI shell) that already manages its own environment. CLI flags take precedence over env vars; env vars take precedence over built-in defaults.
|
||||
|
||||
| Variable | Default | Effect | Tune when… |
|
||||
| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GITNEXUS_WORKER_POOL_SIZE` | `cores - 1`, capped at 16 | Parse worker pool size (must be ≥ 1). Equivalent to `--workers <n>`. The worker pool is the sole parse path — there is no sequential parser, so `0` is rejected with an actionable error (the pool self-heals via quarantine + respawn). | Constrained containers (cgroup CPU limits) or CI runners with explicit quotas. To narrow down a worker crash set `1` for a single-worker pool — not `0`. |
|
||||
| `GITNEXUS_PARSE_CHUNK_CONCURRENCY` | `2` | Number of chunks whose file contents may be read into memory in parallel while the pool dispatches the current chunk. Worker dispatch itself stays serial. | Repos large enough to chunk (multi-MB total source) where disk I/O is a measurable fraction of analyze wall-clock. |
|
||||
| `GITNEXUS_VERBOSE` | unset | When `1`, enables verbose ingestion logs (skipped-file warnings, per-chunk throughput, parse-cache stats). Equivalent to `--verbose`. | Debugging an analyze that "completed" but seems to have missed files; tuning `--workers` / chunk concurrency against observable throughput. |
|
||||
| `GITNEXUS_AUTH_TOKEN` | unset | Bearer token required when `eval-server` binds beyond loopback. May also be read from `.env.local` or `.env`; shell values take precedence. | Exposing the evaluation HTTP tools to a container, VM, or LAN. |
|
||||
| `GITNEXUS_PROFILE_DEFERRED` | unset | When `1`, emits `[deferred-profile]` timing/progress logs for the post-chunk deferred resolution band (imports → heritage → buildHeritageMap → legacy call resolution). Implied by `GITNEXUS_VERBOSE`. | Diagnosing analyze stalls in "Resolving calls (all chunks)" on large Java/Kotlin repos (issue #1741) without the full verbose ingestion noise. |
|
||||
| `GITNEXUS_PROFILE_DEFERRED_SLOW_MS` | `3000` (verbose) / `5000` | Per-file threshold in ms above which `processCallsFromExtracted` emits a `slow file …` log line. Parsed via `Number()`: accepts integers (`5000`), scientific notation (`2.5e3`), decimals (`.5`), and hex (`0x10`). Non-finite or non-positive values fall back to the default. | Hunting a few outlier files dominating the deferred call-resolution stage; lower to surface more, raise to focus only on the worst. |
|
||||
| `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_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_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_WAL_CHECKPOINT_THRESHOLD` | `67108864` (64 MiB) | LadybugDB WAL auto-checkpoint threshold in bytes. Equivalent to `--wal-checkpoint-threshold <bytes>`. `-1` keeps LadybugDB's stock threshold (~16 MiB). Larger thresholds reduce checkpoint frequency but increase the WAL size at rotation time — choose a smaller value on disk-constrained environments. | You need a larger or smaller WAL auto-checkpoint threshold for your analyze workload. |
|
||||
| `GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES` | `8388608` (8 MB) | Per-job byte budget the pool will send to a worker in one `postMessage`. | Very large individual files; mostly diagnostic — bumping past 8 MB risks structured-clone memory pressure. |
|
||||
| `GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT` | `3` | Max replacement spawns per worker slot before the slot is dropped from the active rotation. Bounds respawn loops on a chronically-crashing slot. | Hosts where a flaky worker should retry more (raise) or fail-fast (lower) before the slot is dropped. |
|
||||
| `GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS` | `5 × subBatchTimeoutMs` | Total retry wall-time budget per job before quarantining. Combined with `timeoutBackoffFactor`, prevents exponentially-growing retries from stalling for hours. | Slow files that legitimately need long total retry windows; lower to fail-fast on stalls. |
|
||||
| `GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD`| `max(3, poolSize)` | Per-slot consecutive deaths before the pool's circuit breaker trips. After tripping, every subsequent dispatch rejects until a fresh pool is created. | Hosts where a SIGSEGV-prone native grammar should trip the breaker sooner; CI runners that should fail loudly. |
|
||||
| `GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS` | `30000` | Max wait at pool shutdown for a retired worker still inside native code. The worker is terminated at its next JS-safe point instead of mid-native-call (which aborts the whole process with `Napi::Error`, #2432); on expiry it is left running, unref'd, and terminated when it surfaces. | Shutdown latency matters more than draining a wedged worker (lower), or a legitimately-slow native grammar needs longer to surface (raise). |
|
||||
| `GITNEXUS_CPP_CAPTURE_BUDGET_MS` | `20000` | Per-file wall-clock budget for C++ capture extraction. On breach the file keeps the captures accumulated so far and logs a warning — the worker returns to JS instead of stalling in native-heavy loops (#2432). `0` expires immediately. | Pathological generated C++ that still exceeds the budget after the indexed lookups; raise for completeness, lower to fail-fast. |
|
||||
| `GITNEXUS_CHUNK_BYTE_BUDGET` | `2097152` (2 MB) | Chunk boundary used for cache-key composition and dispatch. Smaller = finer-grained cache hits but more dispatch overhead. | Tuning incremental-analyze cache behavior on monorepos. |
|
||||
| `GITNEXUS_NO_GITIGNORE` | unset | When set, skips `.gitignore` parsing. `.gitnexusignore` is still honored. | Indexing a repo whose `.gitignore` excludes files you actually want indexed (e.g., generated code committed for cross-repo lookup). |
|
||||
| `GITNEXUS_SKIP_OPTIONAL_GRAMMARS` | unset | When `=1` strictly, skips the vendored grammar materialize for `tree-sitter-dart`, `tree-sitter-proto`, `tree-sitter-swift`, and `tree-sitter-kotlin` at install time (and the Dart/Proto source builds). Those four won't be parsed; the install still succeeds. | Installing on a host without a C++ toolchain or where the vendored prebuilds don't match; willing to skip Dart/Proto/Swift/Kotlin parsing. |
|
||||
| `GITNEXUS_MCP_READ_ONLY` | unset | Set to `1` to expose only proven single-repository read tools and resources; `0` disables the policy and any other value fails startup. | The MCP server runs in an environment where graph mutation, raw Cypher, and cross-repository group routing must be unavailable. |
|
||||
| `GITNEXUS_MCP_ALLOWED_REPOS` | unset | Comma-separated allowlist of canonical indexed repository names or absolute paths. Invalid, ambiguous, or blank entries fail startup. | One MCP process must expose only a bounded subset of the repositories in the global registry. |
|
||||
| `GITNEXUS_MCP_DEFAULT_REPO` | unset | Canonical indexed repository name or absolute path used when a tool or resource omits its repository. Must belong to the allowlist when one is set. | Several repositories are available but unqualified MCP calls should resolve deterministically. |
|
||||
| `GITNEXUS_MCP_DEFAULT_MAX_TOKENS` | unset | Default positive-integer response budget for MCP `query`, `context`, and `impact`, estimated at four UTF-8 bytes per token. Explicit `maxTokens` wins. | Long MCP responses consume too much model context and callers cannot reliably add a per-request budget. |
|
||||
| Variable | Default | Effect | Tune when… |
|
||||
| ----------------------------------------------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GITNEXUS_WORKER_POOL_SIZE` | `cores - 1`, capped at 16 | Parse worker pool size (must be ≥ 1). Equivalent to `--workers <n>`. The worker pool is the sole parse path — there is no sequential parser, so `0` is rejected with an actionable error (the pool self-heals via quarantine + respawn). | Constrained containers (cgroup CPU limits) or CI runners with explicit quotas. To narrow down a worker crash set `1` for a single-worker pool — not `0`. |
|
||||
| `GITNEXUS_PARSE_CHUNK_CONCURRENCY` | `2` | Number of chunks whose file contents may be read into memory in parallel while the pool dispatches the current chunk. Worker dispatch itself stays serial. | Repos large enough to chunk (multi-MB total source) where disk I/O is a measurable fraction of analyze wall-clock. |
|
||||
| `GITNEXUS_VERBOSE` | unset | When `1`, enables verbose ingestion logs (skipped-file warnings, per-chunk throughput, parse-cache stats). Equivalent to `--verbose`. | Debugging an analyze that "completed" but seems to have missed files; tuning `--workers` / chunk concurrency against observable throughput. |
|
||||
| `GITNEXUS_AUTH_TOKEN` | unset | Bearer token required when `eval-server` binds beyond loopback. May also be read from `.env.local` or `.env`; shell values take precedence. | Exposing the evaluation HTTP tools to a container, VM, or LAN. |
|
||||
| `GITNEXUS_PROFILE_DEFERRED` | unset | When `1`, emits `[deferred-profile]` timing/progress logs for the post-chunk deferred resolution band (imports → heritage → buildHeritageMap → legacy call resolution). Implied by `GITNEXUS_VERBOSE`. | Diagnosing analyze stalls in "Resolving calls (all chunks)" on large Java/Kotlin repos (issue #1741) without the full verbose ingestion noise. |
|
||||
| `GITNEXUS_PROFILE_DEFERRED_SLOW_MS` | `3000` (verbose) / `5000` | Per-file threshold in ms above which `processCallsFromExtracted` emits a `slow file …` log line. Parsed via `Number()`: accepts integers (`5000`), scientific notation (`2.5e3`), decimals (`.5`), and hex (`0x10`). Non-finite or non-positive values fall back to the default. | Hunting a few outlier files dominating the deferred call-resolution stage; lower to surface more, raise to focus only on the worst. |
|
||||
| `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_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_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_WAL_CHECKPOINT_THRESHOLD` | `67108864` (64 MiB) | LadybugDB WAL auto-checkpoint threshold in bytes. Equivalent to `--wal-checkpoint-threshold <bytes>`. `-1` keeps LadybugDB's stock threshold (~16 MiB). Larger thresholds reduce checkpoint frequency but increase the WAL size at rotation time — choose a smaller value on disk-constrained environments. | You need a larger or smaller WAL auto-checkpoint threshold for your analyze workload. |
|
||||
| `GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES` | `8388608` (8 MB) | Per-job byte budget the pool will send to a worker in one `postMessage`. | Very large individual files; mostly diagnostic — bumping past 8 MB risks structured-clone memory pressure. |
|
||||
| `GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT` | `3` | Max replacement spawns per worker slot before the slot is dropped from the active rotation. Bounds respawn loops on a chronically-crashing slot. | Hosts where a flaky worker should retry more (raise) or fail-fast (lower) before the slot is dropped. |
|
||||
| `GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS` | `5 × subBatchTimeoutMs` | Total retry wall-time budget per job before quarantining. Combined with `timeoutBackoffFactor`, prevents exponentially-growing retries from stalling for hours. | Slow files that legitimately need long total retry windows; lower to fail-fast on stalls. |
|
||||
| `GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD` | `max(3, poolSize)` | Per-slot consecutive deaths before the pool's circuit breaker trips. After tripping, every subsequent dispatch rejects until a fresh pool is created. | Hosts where a SIGSEGV-prone native grammar should trip the breaker sooner; CI runners that should fail loudly. |
|
||||
| `GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS` | `30000` | Max wait at pool shutdown for a retired worker still inside native code. The worker is terminated at its next JS-safe point instead of mid-native-call (which aborts the whole process with `Napi::Error`, #2432); on expiry it is left running, unref'd, and terminated when it surfaces. | Shutdown latency matters more than draining a wedged worker (lower), or a legitimately-slow native grammar needs longer to surface (raise). |
|
||||
| `GITNEXUS_CPP_CAPTURE_BUDGET_MS` | `20000` | Per-file wall-clock budget for C++ capture extraction. On breach the file keeps the captures accumulated so far and logs a warning — the worker returns to JS instead of stalling in native-heavy loops (#2432). `0` expires immediately. | Pathological generated C++ that still exceeds the budget after the indexed lookups; raise for completeness, lower to fail-fast. |
|
||||
| `GITNEXUS_CHUNK_BYTE_BUDGET` | `2097152` (2 MB) | Chunk boundary used for cache-key composition and dispatch. Smaller = finer-grained cache hits but more dispatch overhead. | Tuning incremental-analyze cache behavior on monorepos. |
|
||||
| `GITNEXUS_NO_GITIGNORE` | unset | When set, skips `.gitignore` parsing. `.gitnexusignore` is still honored. | Indexing a repo whose `.gitignore` excludes files you actually want indexed (e.g., generated code committed for cross-repo lookup). |
|
||||
| `GITNEXUS_SKIP_OPTIONAL_GRAMMARS` | unset | When `=1` strictly, skips the vendored grammar materialize for `tree-sitter-dart`, `tree-sitter-proto`, `tree-sitter-swift`, and `tree-sitter-kotlin` at install time (and the Dart/Proto source builds). Those four won't be parsed; the install still succeeds. | Installing on a host without a C++ toolchain or where the vendored prebuilds don't match; willing to skip Dart/Proto/Swift/Kotlin parsing. |
|
||||
| `GITNEXUS_MCP_READ_ONLY` | unset | Set to `1` to expose only proven single-repository read tools and resources; `0` disables the policy and any other value fails startup. | The MCP server runs in an environment where graph mutation, raw Cypher, and cross-repository group routing must be unavailable. |
|
||||
| `GITNEXUS_MCP_ALLOWED_REPOS` | unset | Comma-separated allowlist of canonical indexed repository names or absolute paths. Invalid, ambiguous, or blank entries fail startup. | One MCP process must expose only a bounded subset of the repositories in the global registry. |
|
||||
| `GITNEXUS_MCP_DEFAULT_REPO` | unset | Canonical indexed repository name or absolute path used when a tool or resource omits its repository. Must belong to the allowlist when one is set. | Several repositories are available but unqualified MCP calls should resolve deterministically. |
|
||||
| `GITNEXUS_MCP_DEFAULT_MAX_TOKENS` | unset | Default positive-integer response budget for MCP `query`, `context`, and `impact`, estimated at four UTF-8 bytes per token. Explicit `maxTokens` wins. | Long MCP responses consume too much model context and callers cannot reliably add a per-request budget. |
|
||||
|
||||
</details>
|
||||
|
||||
|
|
@ -739,10 +740,17 @@ gitnexus wiki --force
|
|||
gitnexus wiki --timeout <seconds> # LLM request timeout in seconds (default: disabled)
|
||||
gitnexus wiki --retries <n> # Max LLM retry attempts per request (default: 3)
|
||||
|
||||
# Allow a specific LAN/self-hosted HTTP LLM host (HTTPS is preferred for remote endpoints)
|
||||
gitnexus wiki --base-url http://llama-box.local:8080/v1 --allow-insecure-connection llama-box.local
|
||||
# Or set a comma-separated host allowlist:
|
||||
GITNEXUS_ALLOW_INSECURE_CONNECTION=llama-box.local,192.168.1.23
|
||||
|
||||
# Change the output language
|
||||
gitnexus wiki --lang <lang> # e.g. english, chinese, spanish, japanese
|
||||
```
|
||||
|
||||
For safety, `http://` LLM base URLs are allowed by default only for loopback hosts (`localhost`, `127.0.0.1`, `::1`). `--allow-insecure-connection` and `GITNEXUS_ALLOW_INSECURE_CONNECTION` accept exact hostnames or IP addresses only; do not include schemes, ports, paths, credentials, or wildcards.
|
||||
|
||||
The wiki generator reads the indexed graph structure, groups files into modules via LLM, generates per-module documentation pages, and creates an overview page — all with cross-references to the knowledge graph.
|
||||
|
||||
## Web UI (browser-based)
|
||||
|
|
@ -781,10 +789,10 @@ This starts the server on `http://localhost:4747` and the web UI on `http://loca
|
|||
|
||||
The official setup ships **two signed images**, published identically to **GitHub Container Registry** (GHCR) and **Docker Hub** — same build, same digest, same Cosign signature:
|
||||
|
||||
| Purpose | GHCR (default in `docker-compose.yaml`) | Docker Hub mirror |
|
||||
| ----------------------------------------------------------------------- | ---------------------------------------------- | ------------------------------- |
|
||||
| CLI / `gitnexus serve` backend (HTTP API on port `4747`, MCP, indexer) | `ghcr.io/abhigyanpatwari/gitnexus:latest` | `akonlabs/gitnexus:latest` |
|
||||
| Static web UI (port `4173`) | `ghcr.io/abhigyanpatwari/gitnexus-web:latest` | `akonlabs/gitnexus-web:latest` |
|
||||
| Purpose | GHCR (default in `docker-compose.yaml`) | Docker Hub mirror |
|
||||
| ---------------------------------------------------------------------- | --------------------------------------------- | ------------------------------ |
|
||||
| CLI / `gitnexus serve` backend (HTTP API on port `4747`, MCP, indexer) | `ghcr.io/abhigyanpatwari/gitnexus:latest` | `akonlabs/gitnexus:latest` |
|
||||
| Static web UI (port `4173`) | `ghcr.io/abhigyanpatwari/gitnexus-web:latest` | `akonlabs/gitnexus-web:latest` |
|
||||
|
||||
A named volume (`gitnexus-data`) persists the global registry, indexes, and cloned repos at `/data/gitnexus` inside the server container. To make repos on your host machine indexable, set `WORKSPACE_DIR` before bringing the stack up:
|
||||
|
||||
|
|
@ -914,11 +922,11 @@ Enterprise includes:
|
|||
|
||||
Built by the community — not officially maintained, but worth checking out.
|
||||
|
||||
| Project | Author | Description |
|
||||
| ------------------------------------------------------------------------------ | ------------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| [pi-gitnexus](https://github.com/tintinweb/pi-gitnexus) | [@tintinweb](https://github.com/tintinweb) | GitNexus plugin for [pi](https://pi.dev) — `pi install npm:pi-gitnexus` |
|
||||
| [gitnexus-stable-ops](https://github.com/ShunsukeHayashi/gitnexus-stable-ops) | [@ShunsukeHayashi](https://github.com/ShunsukeHayashi) | Stable ops & deployment workflows (Miyabi ecosystem) |
|
||||
| [KiloCode MCP workflow](Documentation/kilo-code-mcp.md) | [@oktanishq](https://github.com/oktanishq) | Guide to connect GitNexus MCP to Kilo Code and verify tools. |
|
||||
| Project | Author | Description |
|
||||
| ----------------------------------------------------------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------- |
|
||||
| [pi-gitnexus](https://github.com/tintinweb/pi-gitnexus) | [@tintinweb](https://github.com/tintinweb) | GitNexus plugin for [pi](https://pi.dev) — `pi install npm:pi-gitnexus` |
|
||||
| [gitnexus-stable-ops](https://github.com/ShunsukeHayashi/gitnexus-stable-ops) | [@ShunsukeHayashi](https://github.com/ShunsukeHayashi) | Stable ops & deployment workflows (Miyabi ecosystem) |
|
||||
| [KiloCode MCP workflow](Documentation/kilo-code-mcp.md) | [@oktanishq](https://github.com/oktanishq) | Guide to connect GitNexus MCP to Kilo Code and verify tools. |
|
||||
|
||||
> Have a project built on GitNexus? Open a PR to add it here!
|
||||
|
||||
|
|
|
|||
|
|
@ -249,6 +249,8 @@ gitnexus clean # Delete index for current repo
|
|||
gitnexus clean --all --force # Delete all indexes
|
||||
gitnexus wiki [path] # Generate LLM-powered docs from knowledge graph
|
||||
gitnexus wiki --model <model> # Wiki with custom LLM model (default: minimax/minimax-m2.5)
|
||||
gitnexus wiki --base-url http://llama-box.local:8080/v1 --allow-insecure-connection llama-box.local
|
||||
# Allow an exact LAN/self-hosted HTTP LLM host; env: GITNEXUS_ALLOW_INSECURE_CONNECTION
|
||||
gitnexus doctor # Show runtime platform capabilities and embedding configuration
|
||||
|
||||
# Direct graph queries — the same tools the MCP server exposes, no MCP daemon needed
|
||||
|
|
@ -461,14 +463,14 @@ GitNexus uses optional DuckDB extensions for BM25 and vector search. The `gitnex
|
|||
|
||||
Configure the behavior with these environment variables:
|
||||
|
||||
| Variable | Values | Default | Effect |
|
||||
| -------------------------------------------- | ---------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GITNEXUS_LBUG_EXTENSION_INSTALL` | `auto`, `load-only`, `never` | `auto` | `auto` runs one bounded install if LOAD fails — a plain `INSTALL`, escalating to `FORCE INSTALL` only when the LOAD error shows the present extension file is broken. `load-only` only uses already-installed extensions (recommended for offline / firewalled environments). `never` skips optional extensions entirely. |
|
||||
| `GITNEXUS_LBUG_EXTENSION_INSTALL_TIMEOUT_MS` | positive integer | `15000` | Wall-clock budget for the out-of-process extension-install child before it is killed. |
|
||||
| `GITNEXUS_FTS_STEMMER` | supported LadybugDB 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` when that better matches repository comments and identifiers. Re-run `gitnexus analyze --repair-fts` after changing it. |
|
||||
| `GITNEXUS_FTS_CJK_SEGMENTATION` | `none`, `bigram` | `none` | `bigram` inserts overlapping character-bigram boundaries into Chinese/Japanese Han-ideograph spans in `content`/`description` before FTS indexing, so LadybugDB's space-only tokenizer can see sub-phrase word boundaries. Scoped to CJK Unified Ideographs only — Japanese Hiragana/Katakana and Korean Hangul are not currently segmented. Unlike `GITNEXUS_FTS_STEMMER`, this rewrites stored text — enabling it on an already-indexed repo requires a full `gitnexus analyze --force`; neither `--repair-fts` nor a plain incremental `analyze` applies it to previously-indexed files. Set the same value wherever `analyze` and search-serving processes (CLI query, MCP server, web server) run. |
|
||||
| `GITNEXUS_COMMUNITY_ENGINE` | `graphology`, `icebug`, `auto` | `graphology` | Community-detection engine used during analyze. `graphology` uses the bundled default path. `icebug` and `auto` currently behave identically: both try the experimental Icebug CSR path and fall back to Graphology if the optional native module is unavailable or incompatible. |
|
||||
| `GITNEXUS_WAL_CHECKPOINT_THRESHOLD` | integer `>= -1` | `67108864` (64 MiB) | LadybugDB WAL auto-checkpoint threshold during analyze (bytes). Auto-checkpoint remains enabled; `-1` keeps Ladybug's stock ~16 MiB. Larger thresholds reduce checkpoint frequency but increase the WAL size at rotation time — choose a smaller value on disk-constrained environments. |
|
||||
| Variable | Values | Default | Effect |
|
||||
| -------------------------------------------- | ------------------------------ | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GITNEXUS_LBUG_EXTENSION_INSTALL` | `auto`, `load-only`, `never` | `auto` | `auto` runs one bounded install if LOAD fails — a plain `INSTALL`, escalating to `FORCE INSTALL` only when the LOAD error shows the present extension file is broken. `load-only` only uses already-installed extensions (recommended for offline / firewalled environments). `never` skips optional extensions entirely. |
|
||||
| `GITNEXUS_LBUG_EXTENSION_INSTALL_TIMEOUT_MS` | positive integer | `15000` | Wall-clock budget for the out-of-process extension-install child before it is killed. |
|
||||
| `GITNEXUS_FTS_STEMMER` | supported LadybugDB 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` when that better matches repository comments and identifiers. Re-run `gitnexus analyze --repair-fts` after changing it. |
|
||||
| `GITNEXUS_FTS_CJK_SEGMENTATION` | `none`, `bigram` | `none` | `bigram` inserts overlapping character-bigram boundaries into Chinese/Japanese Han-ideograph spans in `content`/`description` before FTS indexing, so LadybugDB's space-only tokenizer can see sub-phrase word boundaries. Scoped to CJK Unified Ideographs only — Japanese Hiragana/Katakana and Korean Hangul are not currently segmented. Unlike `GITNEXUS_FTS_STEMMER`, this rewrites stored text — enabling it on an already-indexed repo requires a full `gitnexus analyze --force`; neither `--repair-fts` nor a plain incremental `analyze` applies it to previously-indexed files. Set the same value wherever `analyze` and search-serving processes (CLI query, MCP server, web server) run. |
|
||||
| `GITNEXUS_COMMUNITY_ENGINE` | `graphology`, `icebug`, `auto` | `graphology` | Community-detection engine used during analyze. `graphology` uses the bundled default path. `icebug` and `auto` currently behave identically: both try the experimental Icebug CSR path and fall back to Graphology if the optional native module is unavailable or incompatible. |
|
||||
| `GITNEXUS_WAL_CHECKPOINT_THRESHOLD` | integer `>= -1` | `67108864` (64 MiB) | LadybugDB WAL auto-checkpoint threshold during analyze (bytes). Auto-checkpoint remains enabled; `-1` keeps Ladybug's stock ~16 MiB. Larger thresholds reduce checkpoint frequency but increase the WAL size at rotation time — choose a smaller value on disk-constrained environments. |
|
||||
|
||||
```bash
|
||||
# Offline/airgapped: never reach the network for extensions
|
||||
|
|
@ -533,13 +535,13 @@ For repositories with very large source files, `GITNEXUS_WORKER_SUB_BATCH_MAX_BY
|
|||
|
||||
Three env vars expose the pool's resilience layers (respawn budget, cumulative-timeout cap, circuit breaker). Defaults are tuned for typical repos; bump them when an analyze legitimately needs more retries, or lower them to fail-fast on a known-bad shape.
|
||||
|
||||
| Variable | Default | Effect |
|
||||
| ----------------------------------------------- | ----------------------- | --------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT` | `3` | Max replacement spawns per slot before the slot is dropped from the active rotation. |
|
||||
| `GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS` | `5 × subBatchTimeoutMs` | Total retry wall-time budget per job before quarantining. Bounds exponentially-growing retry waits. |
|
||||
| `GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD` | `max(3, poolSize)` | Per-slot consecutive deaths before the pool's circuit breaker trips. After tripping, dispatches require a fresh pool. |
|
||||
| Variable | Default | Effect |
|
||||
| ----------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT` | `3` | Max replacement spawns per slot before the slot is dropped from the active rotation. |
|
||||
| `GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS` | `5 × subBatchTimeoutMs` | Total retry wall-time budget per job before quarantining. Bounds exponentially-growing retry waits. |
|
||||
| `GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD` | `max(3, poolSize)` | Per-slot consecutive deaths before the pool's circuit breaker trips. After tripping, dispatches require a fresh pool. |
|
||||
| `GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS` | `30000` | Max wait at pool shutdown for a retired worker still inside native code — terminated at its next JS-safe point instead of mid-native-call, which would abort the process (`Napi::Error`, #2432). |
|
||||
| `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. |
|
||||
| `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. |
|
||||
|
||||
### Graph cleanup tuning
|
||||
|
||||
|
|
|
|||
249
gitnexus/package-lock.json
generated
249
gitnexus/package-lock.json
generated
|
|
@ -51,8 +51,8 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@babel/generator": "^7.29.7",
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/traverse": "^7.29.7",
|
||||
"@babel/parser": "^8.0.0",
|
||||
"@babel/traverse": "^8.0.0",
|
||||
"@babel/types": "^7.29.7",
|
||||
"@types/busboy": "^1.5.4",
|
||||
"@types/cli-progress": "^3.11.6",
|
||||
|
|
@ -83,26 +83,28 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-8.0.0.tgz",
|
||||
"integrity": "sha512-dYYg153EyN2Ekbqw2zAsbd6/JR+9N2SEoC7YV2GyyqMM7x9bLDTjBD6XBhSMLH0wtIVyJj03jWNriQhaN+eoCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-validator-identifier": "^7.29.7",
|
||||
"js-tokens": "^4.0.0",
|
||||
"picocolors": "^1.1.1"
|
||||
"@babel/helper-validator-identifier": "^8.0.0",
|
||||
"js-tokens": "^10.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
"node": "^22.18.0 || >=24.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame/node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"node_modules/@babel/code-frame/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==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^22.18.0 || >=24.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/generator": {
|
||||
"version": "7.29.7",
|
||||
|
|
@ -121,14 +123,30 @@
|
|||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-globals": {
|
||||
"node_modules/@babel/generator/node_modules/@babel/parser": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
|
||||
"integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
|
||||
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.29.7"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
"node": "^22.18.0 || >=24.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
|
|
@ -152,53 +170,173 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
|
||||
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.0.tgz",
|
||||
"integrity": "sha512-aLxAE+imI9bCcyaPrUDjBv3uSkWieifjLe0kuFOZF0zli0L6GCsTmsePnTr55adbIAgYz2zhN1vnFimCBUYcRQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.29.7"
|
||||
"@babel/types": "^8.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
"node": "^22.18.0 || >=24.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser/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==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^22.18.0 || >=24.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser/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==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^22.18.0 || >=24.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser/node_modules/@babel/types": {
|
||||
"version": "8.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.4.tgz",
|
||||
"integrity": "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^8.0.0",
|
||||
"@babel/helper-validator-identifier": "^8.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.18.0 || >=24.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
|
||||
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-8.0.0.tgz",
|
||||
"integrity": "sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/types": "^7.29.7"
|
||||
"@babel/code-frame": "^8.0.0",
|
||||
"@babel/parser": "^8.0.0",
|
||||
"@babel/types": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
"node": "^22.18.0 || >=24.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template/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==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^22.18.0 || >=24.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template/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==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^22.18.0 || >=24.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template/node_modules/@babel/types": {
|
||||
"version": "8.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.4.tgz",
|
||||
"integrity": "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^8.0.0",
|
||||
"@babel/helper-validator-identifier": "^8.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.18.0 || >=24.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/traverse": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
|
||||
"integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-8.0.0.tgz",
|
||||
"integrity": "sha512-bxTj/W2VclGE6CctlfQOpxg8MPDzXArRqkOBePw8EHfebcjF7fETWSS3BriEECo+UiU/Yblq+xUtSImFu7cTbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/generator": "^7.29.7",
|
||||
"@babel/helper-globals": "^7.29.7",
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/template": "^7.29.7",
|
||||
"@babel/types": "^7.29.7",
|
||||
"debug": "^4.3.1"
|
||||
"@babel/code-frame": "^8.0.0",
|
||||
"@babel/generator": "^8.0.0",
|
||||
"@babel/helper-globals": "^8.0.0",
|
||||
"@babel/parser": "^8.0.0",
|
||||
"@babel/template": "^8.0.0",
|
||||
"@babel/types": "^8.0.0",
|
||||
"obug": "^2.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
"node": "^22.18.0 || >=24.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/traverse/node_modules/@babel/generator": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0.tgz",
|
||||
"integrity": "sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^8.0.0",
|
||||
"@babel/types": "^8.0.0",
|
||||
"@jridgewell/gen-mapping": "^0.3.12",
|
||||
"@jridgewell/trace-mapping": "^0.3.28",
|
||||
"@types/jsesc": "^2.5.0",
|
||||
"jsesc": "^3.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.18.0 || >=24.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/traverse/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==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^22.18.0 || >=24.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/traverse/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==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^22.18.0 || >=24.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/traverse/node_modules/@babel/types": {
|
||||
"version": "8.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.4.tgz",
|
||||
"integrity": "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^8.0.0",
|
||||
"@babel/helper-validator-identifier": "^8.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.18.0 || >=24.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
|
|
@ -1945,6 +2083,13 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/jsesc": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz",
|
||||
"integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.9.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.5.tgz",
|
||||
|
|
@ -3978,6 +4123,22 @@
|
|||
"source-map-js": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/magicast/node_modules/@babel/parser": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
|
||||
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.29.7"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/make-dir": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
|
||||
|
|
@ -5374,9 +5535,9 @@
|
|||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.23.0",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.0.tgz",
|
||||
"integrity": "sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==",
|
||||
"version": "4.23.1",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz",
|
||||
"integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -52,7 +52,8 @@
|
|||
"postinstall": "node scripts/build-tree-sitter-grammars.cjs",
|
||||
"assert-publish-coverage": "node scripts/assert-publish-grammar-coverage.cjs",
|
||||
"prepare": "node scripts/build.js",
|
||||
"prepack": "node scripts/assert-publish-grammar-coverage.cjs && node scripts/build.js"
|
||||
"prepack": "node scripts/assert-publish-grammar-coverage.cjs && node scripts/build.js",
|
||||
"version": "node scripts/sync-plugin-manifests.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ladybugdb/core": "^0.18.0",
|
||||
|
|
@ -97,8 +98,8 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@babel/generator": "^7.29.7",
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/traverse": "^7.29.7",
|
||||
"@babel/parser": "^8.0.0",
|
||||
"@babel/traverse": "^8.0.0",
|
||||
"@babel/types": "^7.29.7",
|
||||
"@types/busboy": "^1.5.4",
|
||||
"@types/cli-progress": "^3.11.6",
|
||||
|
|
|
|||
|
|
@ -48,10 +48,11 @@ try {
|
|||
|
||||
// Per-shard watchdog, default 15 min. Sharding splits the file list by COUNT, not
|
||||
// runtime, so the heaviest spawn suites can cluster on one shard — what this
|
||||
// bounds is the *busiest* shard, not an even 1/n of wall-clock. With 3 shards
|
||||
// even that shard clears the watchdog, where the whole unsharded Windows run
|
||||
// used to trip it. Allow CI/manual runs to add headroom without editing the
|
||||
// script again.
|
||||
// bounds is the *busiest* shard, not an even 1/n of wall-clock. The busiest
|
||||
// Windows shard has grown to the default (14m57s on the v1.6.10-rc.19 green
|
||||
// run, one observed timeout since — #2449), so CI raises the budget to 20
|
||||
// minutes via GITNEXUS_CROSS_PLATFORM_TIMEOUT_MINUTES; the default stays 15
|
||||
// for local runs.
|
||||
const DEFAULT_TIMEOUT_MIN = 15;
|
||||
const timeoutMinutes = Number.parseInt(
|
||||
process.env.GITNEXUS_CROSS_PLATFORM_TIMEOUT_MINUTES ?? String(DEFAULT_TIMEOUT_MIN),
|
||||
|
|
@ -67,6 +68,7 @@ console.log(
|
|||
`${shardArg ? ` (${shardArg.replace('--shard=', 'shard ')})` : ''}...\n`,
|
||||
);
|
||||
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
execFileSync('npx', ['vitest', 'run', ...ALL_CROSS_PLATFORM, ...(shardArg ? [shardArg] : [])], {
|
||||
cwd: ROOT,
|
||||
|
|
@ -76,9 +78,22 @@ try {
|
|||
});
|
||||
} catch (err) {
|
||||
// execFileSync sets `killed`/`signal` when the watchdog above kills vitest.
|
||||
const e = err as { killed?: boolean; signal?: NodeJS.Signals | null };
|
||||
const e = err as {
|
||||
killed?: boolean;
|
||||
signal?: NodeJS.Signals | null;
|
||||
status?: number | null;
|
||||
code?: string;
|
||||
};
|
||||
if (e.killed || e.signal) {
|
||||
console.error(`vitest timed out after ${Math.round(timeoutMs / 60_000)} minutes`);
|
||||
}
|
||||
// #2449: Windows shards have died with a bare `status: null`, empty stderr
|
||||
// and nothing to triage from. Always leave the child's exit facts behind.
|
||||
const elapsedSec = Math.round((Date.now() - startedAt) / 1000);
|
||||
console.error(
|
||||
`vitest exited abnormally: status=${e.status ?? 'null'} signal=${e.signal ?? 'none'} ` +
|
||||
`killed=${e.killed === true} spawnCode=${e.code ?? 'none'} elapsed=${elapsedSec}s ` +
|
||||
`budget=${Math.round(timeoutMs / 60_000)}min`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
|
|
|||
141
gitnexus/scripts/sync-plugin-manifests.mjs
Normal file
141
gitnexus/scripts/sync-plugin-manifests.mjs
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Fail-closed version sync for the plugin manifest surfaces (#2445).
|
||||
*
|
||||
* `publish.yml` bumps only `gitnexus/package.json` when it cuts an RC, so
|
||||
* every RC tag through v1.6.10-rc.28 shipped manifests frozen at the last
|
||||
* stable version and failed its own unit suite (the cli-commands version
|
||||
* contract). This script pins all four manifest surfaces to the package
|
||||
* version:
|
||||
*
|
||||
* - gitnexus-claude-plugin/.claude-plugin/plugin.json (top-level version)
|
||||
* - .claude-plugin/marketplace.json (plugins[gitnexus])
|
||||
* - gitnexus-claude-plugin/.codex-plugin/plugin.json (top-level version)
|
||||
* - .agents/plugins/marketplace.json (plugins[gitnexus])
|
||||
*
|
||||
* Modes:
|
||||
* node scripts/sync-plugin-manifests.mjs rewrite stale surfaces
|
||||
* node scripts/sync-plugin-manifests.mjs --check verify only, exit 1 on drift
|
||||
*
|
||||
* Fail-closed: a missing file, unparseable JSON, an absent version field, or
|
||||
* anything other than exactly one `gitnexus` marketplace entry aborts with a
|
||||
* non-zero exit rather than letting a release ship a partial sync.
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const MANIFEST_SURFACES = [
|
||||
{ file: 'gitnexus-claude-plugin/.claude-plugin/plugin.json', kind: 'plugin' },
|
||||
{ file: '.claude-plugin/marketplace.json', kind: 'marketplace' },
|
||||
{ file: 'gitnexus-claude-plugin/.codex-plugin/plugin.json', kind: 'plugin' },
|
||||
{ file: '.agents/plugins/marketplace.json', kind: 'marketplace' },
|
||||
];
|
||||
|
||||
const PLUGIN_NAME = 'gitnexus';
|
||||
|
||||
function readJson(filePath) {
|
||||
let raw;
|
||||
try {
|
||||
raw = readFileSync(filePath, 'utf8');
|
||||
} catch (err) {
|
||||
throw new Error(`Cannot read manifest surface ${filePath}: ${err.message}`);
|
||||
}
|
||||
try {
|
||||
return { raw, parsed: JSON.parse(raw) };
|
||||
} catch (err) {
|
||||
throw new Error(`Manifest surface ${filePath} is not valid JSON: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function versionTarget(manifest, kind, filePath) {
|
||||
if (kind === 'plugin') {
|
||||
if (typeof manifest.version !== 'string' || manifest.version.length === 0) {
|
||||
throw new Error(`Manifest surface ${filePath} has no version field to sync`);
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
const entries = (Array.isArray(manifest.plugins) ? manifest.plugins : []).filter(
|
||||
(plugin) => plugin?.name === PLUGIN_NAME,
|
||||
);
|
||||
if (entries.length !== 1) {
|
||||
throw new Error(
|
||||
`Manifest surface ${filePath} must contain exactly one "${PLUGIN_NAME}" plugin entry, found ${entries.length}`,
|
||||
);
|
||||
}
|
||||
if (typeof entries[0].version !== 'string' || entries[0].version.length === 0) {
|
||||
throw new Error(`Manifest surface ${filePath} has no version field to sync`);
|
||||
}
|
||||
return entries[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync (or with `check: true`, only inspect) every manifest surface under
|
||||
* `rootDir`. Returns `{ version, synced, stale }` where `stale` lists the
|
||||
* surfaces that did not match the package version when the run started.
|
||||
*/
|
||||
export function syncPluginManifests(rootDir, { check = false } = {}) {
|
||||
const pkgPath = path.join(rootDir, 'gitnexus', 'package.json');
|
||||
const version = readJson(pkgPath).parsed.version;
|
||||
if (typeof version !== 'string' || version.length === 0) {
|
||||
throw new Error(`No version found in ${pkgPath}`);
|
||||
}
|
||||
|
||||
const synced = [];
|
||||
const stale = [];
|
||||
for (const { file, kind } of MANIFEST_SURFACES) {
|
||||
const manifestPath = path.join(rootDir, file);
|
||||
const { raw, parsed } = readJson(manifestPath);
|
||||
const target = versionTarget(parsed, kind, manifestPath);
|
||||
if (target.version === version) continue;
|
||||
|
||||
stale.push({ file, from: target.version });
|
||||
if (check) continue;
|
||||
|
||||
// Textual surgery instead of re-serializing: JSON.stringify would refold
|
||||
// arrays and fight prettier, turning a one-line version bump into
|
||||
// formatting churn inside the release commit. The needle is built from
|
||||
// the parsed current version, and anything other than exactly one
|
||||
// occurrence aborts rather than guessing.
|
||||
const needle = `"version": "${target.version}"`;
|
||||
const occurrences = raw.split(needle).length - 1;
|
||||
if (occurrences !== 1) {
|
||||
throw new Error(
|
||||
`Manifest surface ${manifestPath} has ${occurrences} occurrences of ${needle}; ` +
|
||||
'expected exactly one, refusing to sync',
|
||||
);
|
||||
}
|
||||
writeFileSync(manifestPath, raw.replace(needle, `"version": "${version}"`));
|
||||
synced.push(file);
|
||||
}
|
||||
|
||||
return { version, synced, stale };
|
||||
}
|
||||
|
||||
const invokedDirectly =
|
||||
process.argv[1] !== undefined && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||
|
||||
if (invokedDirectly) {
|
||||
const check = process.argv.includes('--check');
|
||||
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
const result = syncPluginManifests(rootDir, { check });
|
||||
|
||||
if (check && result.stale.length > 0) {
|
||||
for (const { file, from } of result.stale) {
|
||||
console.error(
|
||||
`::error::${file} is at ${from} but gitnexus/package.json is at ${result.version}. ` +
|
||||
'Run `node gitnexus/scripts/sync-plugin-manifests.mjs` and commit the result.',
|
||||
);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
for (const file of result.synced) {
|
||||
console.log(`synced ${file} -> ${result.version}`);
|
||||
}
|
||||
console.log(
|
||||
result.stale.length === 0 && result.synced.length === 0
|
||||
? `all plugin manifests already at ${result.version}`
|
||||
: `plugin manifests now at ${result.version}`,
|
||||
);
|
||||
}
|
||||
|
|
@ -96,6 +96,7 @@ const OPTION_DESCRIPTION_KEYS = {
|
|||
'wiki|--concurrency <n>': 'help.option.wiki.concurrency',
|
||||
'wiki|--timeout <seconds>': 'help.option.wiki.timeout',
|
||||
'wiki|--retries <n>': 'help.option.wiki.retries',
|
||||
'wiki|--allow-insecure-connection <host>': 'help.option.wiki.allowInsecureConnection',
|
||||
'wiki|--gist': 'help.option.wiki.gist',
|
||||
'wiki|-v, --verbose': 'help.option.verbose',
|
||||
'wiki|--review': 'help.option.wiki.review',
|
||||
|
|
|
|||
|
|
@ -235,6 +235,8 @@ export const en = {
|
|||
'help.option.wiki.concurrency': 'Parallel LLM calls (default: 3)',
|
||||
'help.option.wiki.timeout': 'LLM request timeout in seconds (default: disabled)',
|
||||
'help.option.wiki.retries': 'Max LLM retry attempts per request (default: 3)',
|
||||
'help.option.wiki.allowInsecureConnection':
|
||||
'Allow exact host(s) for http:// LLM base URLs (comma-separated; HTTPS is preferred)',
|
||||
'help.option.wiki.gist': 'Publish wiki as a public GitHub Gist after generation',
|
||||
'help.option.wiki.review':
|
||||
'Stop after grouping to review module structure before generating pages',
|
||||
|
|
|
|||
|
|
@ -222,6 +222,8 @@ export const zhCN = {
|
|||
'help.option.wiki.concurrency': '并行 LLM 调用数(默认:3)',
|
||||
'help.option.wiki.timeout': 'LLM 请求超时时间(秒,默认:禁用)',
|
||||
'help.option.wiki.retries': '每个请求的最大 LLM 重试次数(默认:3)',
|
||||
'help.option.wiki.allowInsecureConnection':
|
||||
'允许 http:// LLM base URL 使用的精确主机(逗号分隔;推荐使用 HTTPS)',
|
||||
'help.option.wiki.gist': '生成后发布 Wiki 为公开 GitHub Gist',
|
||||
'help.option.wiki.review': '分组后停止,以便在生成页面前审查模块结构',
|
||||
'help.option.wiki.lang': '生成文档的输出语言(如 english、chinese、spanish、japanese)',
|
||||
|
|
|
|||
|
|
@ -304,6 +304,10 @@ program
|
|||
.option('--concurrency <n>', 'Parallel LLM calls (default: 3)', '3')
|
||||
.option('--timeout <seconds>', 'LLM request timeout in seconds (default: disabled)')
|
||||
.option('--retries <n>', 'Max LLM retry attempts per request (default: 3)')
|
||||
.option(
|
||||
'--allow-insecure-connection <host>',
|
||||
'Allow exact host(s) for http:// LLM base URLs (comma-separated; HTTPS is preferred)',
|
||||
)
|
||||
.option('--gist', 'Publish wiki as a public GitHub Gist after generation')
|
||||
.option('-v, --verbose', 'Enable verbose output (show LLM commands and responses)')
|
||||
.option('--review', 'Stop after grouping to review module structure before generating pages')
|
||||
|
|
|
|||
|
|
@ -17,7 +17,11 @@ import {
|
|||
saveCLIConfig,
|
||||
} from '../storage/repo-manager.js';
|
||||
import { WikiGenerator, type WikiOptions } from '../core/wiki/generator.js';
|
||||
import { resolveLLMConfig, type LLMProvider } from '../core/wiki/llm-client.js';
|
||||
import {
|
||||
parseLLMAllowedInsecureHttpHosts,
|
||||
resolveLLMConfig,
|
||||
type LLMProvider,
|
||||
} from '../core/wiki/llm-client.js';
|
||||
import { detectCursorCLI } from '../core/wiki/cursor-client.js';
|
||||
import { detectLocalCLI } from '../core/wiki/local-cli-client.js';
|
||||
import { logger } from '../core/logger.js';
|
||||
|
|
@ -37,6 +41,7 @@ export interface WikiCommandOptions {
|
|||
timeout?: string;
|
||||
retries?: string;
|
||||
lang?: string;
|
||||
allowInsecureConnection?: string;
|
||||
}
|
||||
|
||||
function parsePositiveIntegerOption(
|
||||
|
|
@ -185,9 +190,14 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions)
|
|||
|
||||
let timeoutSeconds: number | undefined;
|
||||
let retries: number | undefined;
|
||||
let allowedInsecureHttpHosts: string[] | undefined;
|
||||
try {
|
||||
timeoutSeconds = parsePositiveIntegerOption(options?.timeout, '--timeout', 1000);
|
||||
retries = parsePositiveIntegerOption(options?.retries, '--retries');
|
||||
allowedInsecureHttpHosts =
|
||||
options?.allowInsecureConnection === undefined
|
||||
? undefined
|
||||
: parseLLMAllowedInsecureHttpHosts(options.allowInsecureConnection);
|
||||
} catch (error) {
|
||||
console.log(` Error: ${(error as Error).message}\n`);
|
||||
process.exitCode = 1;
|
||||
|
|
@ -245,6 +255,7 @@ const wikiCommandImpl = async (inputPath?: string, options?: WikiCommandOptions)
|
|||
provider: options?.provider,
|
||||
apiVersion: options?.apiVersion,
|
||||
isReasoningModel: options?.reasoningModel,
|
||||
allowedInsecureHttpHosts,
|
||||
});
|
||||
|
||||
// Run interactive setup if no saved config and no CLI flags provided
|
||||
|
|
|
|||
|
|
@ -243,7 +243,6 @@ export const batchInsertEmbeddings = async (
|
|||
contentHash?: string;
|
||||
}>,
|
||||
): Promise<void> => {
|
||||
const cypher = `CREATE (e:${EMBEDDING_TABLE_NAME} {id: $id, nodeId: $nodeId, chunkIndex: $chunkIndex, startLine: $startLine, endLine: $endLine, embedding: $embedding, contentHash: $contentHash})`;
|
||||
const paramsList = updates.map((u) => ({
|
||||
id: `${u.nodeId}:${u.chunkIndex}`,
|
||||
nodeId: u.nodeId,
|
||||
|
|
@ -253,6 +252,13 @@ export const batchInsertEmbeddings = async (
|
|||
embedding: u.embedding,
|
||||
contentHash: u.contentHash ?? STALE_HASH_SENTINEL,
|
||||
}));
|
||||
if (paramsList.length === 0) return;
|
||||
|
||||
await executeWithReusedStatement(
|
||||
`MATCH (e:${EMBEDDING_TABLE_NAME} {id: $id}) DELETE e`,
|
||||
paramsList.map(({ id }) => ({ id })),
|
||||
);
|
||||
const cypher = `CREATE (e:${EMBEDDING_TABLE_NAME} {id: $id, nodeId: $nodeId, chunkIndex: $chunkIndex, startLine: $startLine, endLine: $endLine, embedding: $embedding, contentHash: $contentHash})`;
|
||||
await executeWithReusedStatement(cypher, paramsList);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -38,10 +38,14 @@ export const TS_JS_TAINT_MODEL: SourceSinkSanitizerSpec = {
|
|||
},
|
||||
],
|
||||
sinks: [
|
||||
// Command execution — the command string is argument 0.
|
||||
// Command execution — shell strings are arg 0; argv-form APIs also treat
|
||||
// the argv array at arg 1 as command/option injection surface.
|
||||
{ name: 'exec', kind: 'command-injection', args: [0], module: 'child_process' },
|
||||
{ name: 'execSync', kind: 'command-injection', args: [0], module: 'child_process' },
|
||||
{ name: 'spawn', kind: 'command-injection', args: [0], module: 'child_process' },
|
||||
{ name: 'spawn', kind: 'command-injection', args: [0, 1], module: 'child_process' },
|
||||
{ name: 'spawnSync', kind: 'command-injection', args: [0, 1], module: 'child_process' },
|
||||
{ name: 'execFile', kind: 'command-injection', args: [0, 1], module: 'child_process' },
|
||||
{ name: 'execFileSync', kind: 'command-injection', args: [0, 1], module: 'child_process' },
|
||||
// Code evaluation. `eval` takes code at 0; `new Function(...)` treats
|
||||
// EVERY argument as source text (params + body), so `args` is omitted
|
||||
// (= all positions) rather than pinned to 0.
|
||||
|
|
@ -56,9 +60,37 @@ export const TS_JS_TAINT_MODEL: SourceSinkSanitizerSpec = {
|
|||
// (mysql2/pg/knex handles go by many names; receiver-conventional).
|
||||
{ name: 'query', kind: 'sql-injection', args: [0], anyReceiver: true },
|
||||
{ name: 'execute', kind: 'sql-injection', args: [0], anyReceiver: true },
|
||||
// Modern DB libraries expose shorter method names with high collision
|
||||
// rates (`map.get`, `task.run`, ...), so keep these receiver-conventional.
|
||||
{
|
||||
name: 'run',
|
||||
kind: 'sql-injection',
|
||||
args: [0],
|
||||
receivers: ['db', 'database', 'conn', 'client', 'pool', 'stmt', 'statement', 'prepared'],
|
||||
},
|
||||
{
|
||||
name: 'all',
|
||||
kind: 'sql-injection',
|
||||
args: [0],
|
||||
receivers: ['db', 'database', 'conn', 'client', 'pool', 'stmt', 'statement', 'prepared'],
|
||||
},
|
||||
{
|
||||
name: 'get',
|
||||
kind: 'sql-injection',
|
||||
args: [0],
|
||||
receivers: ['db', 'database', 'conn', 'client', 'pool', 'stmt', 'statement', 'prepared'],
|
||||
},
|
||||
{
|
||||
name: 'values',
|
||||
kind: 'sql-injection',
|
||||
args: [0],
|
||||
receivers: ['db', 'database', 'conn', 'client', 'pool'],
|
||||
},
|
||||
{ name: 'raw', kind: 'sql-injection', args: [0], receivers: ['db', 'knex', 'sequelize'] },
|
||||
// Reflected XSS — Express response writes, conventional receiver `res`.
|
||||
{ name: 'send', kind: 'xss', args: [0], receivers: ['res'] },
|
||||
{ name: 'write', kind: 'xss', args: [0], receivers: ['res'] },
|
||||
{ name: 'render', kind: 'xss', args: [0, 1], receivers: ['res'] },
|
||||
],
|
||||
sanitizers: [
|
||||
// URL-encoding: neutralizes markup injection AND path separators
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ export interface LLMConfig {
|
|||
requestTimeoutMs?: number;
|
||||
/** Max fetch attempts before giving up (default: 3). */
|
||||
maxAttempts?: number;
|
||||
/** Exact hostnames allowed for explicit http:// LLM endpoints. */
|
||||
allowedInsecureHttpHosts?: readonly string[];
|
||||
}
|
||||
|
||||
export interface LLMResponse {
|
||||
|
|
@ -94,6 +96,9 @@ export async function resolveLLMConfig(overrides?: Partial<LLMConfig>): Promise<
|
|||
apiVersion:
|
||||
overrides?.apiVersion || process.env.GITNEXUS_AZURE_API_VERSION || savedConfig.apiVersion,
|
||||
isReasoningModel: overrides?.isReasoningModel ?? savedConfig.isReasoningModel,
|
||||
allowedInsecureHttpHosts:
|
||||
overrides?.allowedInsecureHttpHosts ??
|
||||
parseLLMAllowedInsecureHttpHosts(process.env[LLM_ALLOW_INSECURE_CONNECTION_ENV]),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -117,6 +122,38 @@ function isTimeoutLikeError(err: unknown): boolean {
|
|||
return /time(d)?\s*out|timeout/i.test(err.message);
|
||||
}
|
||||
|
||||
export const LLM_ALLOW_INSECURE_CONNECTION_ENV = 'GITNEXUS_ALLOW_INSECURE_CONNECTION';
|
||||
|
||||
function normalizeAllowedInsecureHttpHost(host: string): string {
|
||||
const trimmed = host.trim().toLowerCase();
|
||||
const fail = () => {
|
||||
throw new Error(
|
||||
`--allow-insecure-connection / ${LLM_ALLOW_INSECURE_CONNECTION_ENV} entries must be exact hostnames or IP addresses`,
|
||||
);
|
||||
};
|
||||
if (!trimmed || /[/@?#]/.test(trimmed)) fail();
|
||||
|
||||
if (trimmed.startsWith('[')) {
|
||||
if (!trimmed.endsWith(']')) fail();
|
||||
const normalized = trimmed.slice(1, -1);
|
||||
if (!normalized || /[\[\]]/.test(normalized)) fail();
|
||||
return normalized;
|
||||
}
|
||||
|
||||
if (/[\[\]]/.test(trimmed)) fail();
|
||||
if ((trimmed.match(/:/g)?.length ?? 0) === 1) {
|
||||
// URL.hostname never includes the port, so accepting "host:port" would
|
||||
// create a confusing no-op allowlist entry.
|
||||
fail();
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function parseLLMAllowedInsecureHttpHosts(value: string | undefined): string[] {
|
||||
if (value === undefined || value.trim() === '') return [];
|
||||
return [...new Set(value.split(',').map(normalizeAllowedInsecureHttpHost))];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a base URL supplied for LLM API calls is a safe HTTP/HTTPS
|
||||
* endpoint (CWE-918 / CodeQL js/http-to-file-access).
|
||||
|
|
@ -124,15 +161,22 @@ function isTimeoutLikeError(err: unknown): boolean {
|
|||
* Allowed:
|
||||
* - https:// with any hostname (public LLM APIs, Azure, OpenRouter, …)
|
||||
* - http:// restricted to localhost / 127.0.0.1 (local servers: Ollama, LiteLLM, …)
|
||||
* - http:// to exact hosts explicitly allowlisted for LAN/self-hosted LLMs
|
||||
*
|
||||
* Rejected:
|
||||
* - file://, data:, javascript:, and any other non-HTTP scheme
|
||||
* - http:// aimed at non-loopback hosts (avoids SSRF against internal networks)
|
||||
* - http:// aimed at non-loopback hosts unless explicitly allowlisted
|
||||
* (avoids SSRF against internal networks by default)
|
||||
*
|
||||
* Throws with a descriptive message on validation failure so callers surface a
|
||||
* clear error rather than an opaque network error.
|
||||
*/
|
||||
export function validateLLMBaseUrl(baseUrl: string): void {
|
||||
export function validateLLMBaseUrl(
|
||||
baseUrl: string,
|
||||
allowedInsecureHttpHosts: readonly string[] = parseLLMAllowedInsecureHttpHosts(
|
||||
process.env[LLM_ALLOW_INSECURE_CONNECTION_ENV],
|
||||
),
|
||||
): void {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(baseUrl);
|
||||
|
|
@ -150,10 +194,12 @@ export function validateLLMBaseUrl(baseUrl: string): void {
|
|||
// Node's URL parser preserves IPv6 brackets in hostname (e.g. "[::1]"),
|
||||
// so strip them before comparing to bare address literals.
|
||||
const host = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
||||
if (host !== 'localhost' && host !== '127.0.0.1' && host !== '::1') {
|
||||
const allowedHosts = new Set(allowedInsecureHttpHosts.map(normalizeAllowedInsecureHttpHost));
|
||||
if (host !== 'localhost' && host !== '127.0.0.1' && host !== '::1' && !allowedHosts.has(host)) {
|
||||
// Use parsed.origin (scheme+host+port, no credentials) instead of the full URL.
|
||||
throw new Error(
|
||||
`Insecure http:// LLM base URLs are only allowed for localhost/127.0.0.1. ` +
|
||||
`Insecure http:// LLM base URLs are only allowed for localhost/127.0.0.1 ` +
|
||||
`or hosts listed by --allow-insecure-connection / ${LLM_ALLOW_INSECURE_CONNECTION_ENV}. ` +
|
||||
`Use https:// for remote endpoints (got ${parsed.origin})`,
|
||||
);
|
||||
}
|
||||
|
|
@ -212,7 +258,7 @@ export async function callLLM(
|
|||
options?: CallLLMOptions,
|
||||
): Promise<LLMResponse> {
|
||||
// Validate base URL before any fetch (CodeQL js/http-to-file-access)
|
||||
validateLLMBaseUrl(config.baseUrl);
|
||||
validateLLMBaseUrl(config.baseUrl, config.allowedInsecureHttpHosts);
|
||||
|
||||
const messages: Array<{ role: string; content: string }> = [];
|
||||
if (systemPrompt) {
|
||||
|
|
|
|||
|
|
@ -28,12 +28,6 @@ export interface ToolDefinition {
|
|||
}
|
||||
>;
|
||||
required: string[];
|
||||
/**
|
||||
* JSON-Schema `anyOf` for cross-property constraints `required` cannot express
|
||||
* — e.g. "at least one of route/file". Forwarded verbatim to clients by the
|
||||
* server's ListTools handler, so MCP clients see the constraint.
|
||||
*/
|
||||
anyOf?: Array<{ required: string[] }>;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -796,9 +790,6 @@ Response shape is keyed on how many routes match, not on the data: exactly one m
|
|||
repo: { type: 'string', description: 'Repository name or path.' },
|
||||
},
|
||||
required: [],
|
||||
// Exactly one lookup key is needed, but either works (route wins if both
|
||||
// are passed) — so the structural constraint is "at least one of route/file".
|
||||
anyOf: [{ required: ['route'] }, { required: ['file'] }],
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
47
gitnexus/src/types/huggingface-transformers.d.ts
vendored
Normal file
47
gitnexus/src/types/huggingface-transformers.d.ts
vendored
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
// This ambient shim intentionally shadows the optional package's bundled types
|
||||
// in dependency-pruned CI. Mirror any newly used transformer API surface here.
|
||||
declare module '@huggingface/transformers' {
|
||||
export interface ProgressInfo {
|
||||
status?: string;
|
||||
file?: string;
|
||||
progress?: number;
|
||||
loaded?: number;
|
||||
total?: number;
|
||||
}
|
||||
|
||||
export interface FeatureExtractionResult {
|
||||
data: ArrayLike<number>;
|
||||
}
|
||||
|
||||
export interface FeatureExtractionOptions {
|
||||
pooling?: string;
|
||||
normalize?: boolean;
|
||||
}
|
||||
|
||||
export interface FeatureExtractionPipeline {
|
||||
(
|
||||
input: string | string[],
|
||||
options?: FeatureExtractionOptions,
|
||||
): Promise<FeatureExtractionResult>;
|
||||
dispose?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface PipelineOptions {
|
||||
device?: string;
|
||||
dtype?: string;
|
||||
progress_callback?: (progress: ProgressInfo) => void;
|
||||
session_options?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function pipeline(
|
||||
task: 'feature-extraction',
|
||||
model: string,
|
||||
options?: PipelineOptions,
|
||||
): Promise<FeatureExtractionPipeline>;
|
||||
|
||||
export const env: {
|
||||
allowLocalModels: boolean;
|
||||
cacheDir: string;
|
||||
remoteHost: string;
|
||||
};
|
||||
}
|
||||
|
|
@ -92,10 +92,14 @@ withTestLbugDB('delete-nodes-for-files', (handle) => {
|
|||
// is plain schema (no VECTOR extension involved).
|
||||
const SURVIVOR_PATH = filePath(FILE_COUNT - 1);
|
||||
const survivorEmbeddingNodeId = `Function:${SURVIVOR_PATH}:fn${FILE_COUNT - 1}:1`;
|
||||
const survivorFileEmbeddingNodeId = `File:${SURVIVOR_PATH}`;
|
||||
const seededEmbeddingNodeIds = [
|
||||
`Function:${filePath(1)}:fn1:1`, // deleted, plain path
|
||||
`File:${filePath(1)}`, // deleted fallback File embedding, plain path
|
||||
`Function:${QUOTED_PATH}:fn0:1`, // deleted, quoted path
|
||||
`File:${QUOTED_PATH}`, // deleted fallback File embedding, quoted path
|
||||
survivorEmbeddingNodeId, // survives the delete
|
||||
survivorFileEmbeddingNodeId, // fallback File embedding also survives
|
||||
];
|
||||
await batchInsertEmbeddings(
|
||||
executeWithReusedStatement,
|
||||
|
|
@ -154,15 +158,17 @@ withTestLbugDB('delete-nodes-for-files', (handle) => {
|
|||
const embRows = (await executeQuery(
|
||||
`MATCH (e:${EMBEDDING_TABLE_NAME}) RETURN e.nodeId AS nodeId`,
|
||||
)) as Array<{ nodeId: string }>;
|
||||
expect(embRows.map((r) => String(r.nodeId))).toEqual([survivorEmbeddingNodeId]);
|
||||
expect(embRows.map((r) => String(r.nodeId)).sort()).toEqual(
|
||||
[survivorEmbeddingNodeId, survivorFileEmbeddingNodeId].sort(),
|
||||
);
|
||||
|
||||
// Zero-match batch (all paths already gone) is a clean no-op.
|
||||
await expect(deleteNodesForFiles([QUOTED_PATH, filePath(1)])).resolves.toBeUndefined();
|
||||
// …and it left the surviving embedding row alone.
|
||||
expect(await count(`MATCH (e:${EMBEDDING_TABLE_NAME}) RETURN count(e) AS c`)).toBe(1);
|
||||
expect(await count(`MATCH (e:${EMBEDDING_TABLE_NAME}) RETURN count(e) AS c`)).toBe(2);
|
||||
}, 120_000);
|
||||
|
||||
it('a file whose only nodes carry non-embeddable labels deletes cleanly and leaves other files’ embedding rows intact (FIX 4)', async () => {
|
||||
it('a File node without an embedding deletes cleanly and leaves other files’ embedding rows intact (FIX 4)', async () => {
|
||||
const { deleteNodesForFiles, executeQuery } =
|
||||
await import('../../src/core/lbug/lbug-adapter.js');
|
||||
const count = async (cypher: string): Promise<number> => {
|
||||
|
|
@ -170,9 +176,9 @@ withTestLbugDB('delete-nodes-for-files', (handle) => {
|
|||
return Number(rows[0]?.c ?? 0);
|
||||
};
|
||||
|
||||
// File is NOT an embeddable label, so the label-scoped embedding join
|
||||
// (FIX 4) never binds it — the delete must still remove the node rows
|
||||
// without erroring, and embedding rows owned by OTHER files stay put.
|
||||
// File can own fallback embeddings, but this fixture deliberately has
|
||||
// none. The delete must still remove the node row without erroring, and
|
||||
// embedding rows owned by OTHER files stay put.
|
||||
const ASSET_PATH = 'src/assets-only.txt';
|
||||
await executeQuery(
|
||||
`CREATE (:File {id: 'File:${ASSET_PATH}', name: 'assets-only.txt', filePath: '${ASSET_PATH}'})`,
|
||||
|
|
@ -190,6 +196,39 @@ withTestLbugDB('delete-nodes-for-files', (handle) => {
|
|||
embeddingsBefore,
|
||||
);
|
||||
}, 120_000);
|
||||
|
||||
it('deleteNodesForFile removes a fallback embedding owned by the File node', async () => {
|
||||
const { deleteNodesForFile, executeQuery, executeWithReusedStatement } =
|
||||
await import('../../src/core/lbug/lbug-adapter.js');
|
||||
const { batchInsertEmbeddings } =
|
||||
await import('../../src/core/embeddings/embedding-pipeline.js');
|
||||
const count = async (cypher: string): Promise<number> => {
|
||||
const rows = (await executeQuery(cypher)) as Array<{ c: number | bigint }>;
|
||||
return Number(rows[0]?.c ?? 0);
|
||||
};
|
||||
|
||||
const filePath = 'docs/singular.md';
|
||||
const nodeId = `File:${filePath}`;
|
||||
await executeQuery(
|
||||
`CREATE (:File {id: '${nodeId}', name: 'singular.md', filePath: '${filePath}'})`,
|
||||
);
|
||||
await batchInsertEmbeddings(executeWithReusedStatement, [
|
||||
{
|
||||
nodeId,
|
||||
chunkIndex: 0,
|
||||
startLine: 1,
|
||||
endLine: 1,
|
||||
embedding: new Array(EMBEDDING_DIMS).fill(0),
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(deleteNodesForFile(filePath)).resolves.toEqual({ deletedNodes: 1 });
|
||||
expect(
|
||||
await count(
|
||||
`MATCH (e:${EMBEDDING_TABLE_NAME}) WHERE e.nodeId = '${nodeId}' RETURN count(e) AS c`,
|
||||
),
|
||||
).toBe(0);
|
||||
}, 120_000);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import { LocalBackend } from '../../src/mcp/local/local-backend.js';
|
|||
import { listRegisteredRepos, loadMeta } from '../../src/storage/repo-manager.js';
|
||||
import { withTestLbugDB } from '../helpers/test-indexed-db.js';
|
||||
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
|
||||
import { decodeTaintPath } from '../../src/core/ingestion/taint/path-codec.js';
|
||||
|
||||
vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/storage/repo-manager.js')>();
|
||||
|
|
@ -42,6 +43,82 @@ vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => {
|
|||
|
||||
const FIXTURE = path.join(__dirname, 'cfg', 'fixtures', 'pdg-repo');
|
||||
|
||||
describe('TS/JS taint model sink disambiguation — real pipeline', () => {
|
||||
it('emits findings only for the intended imported/receiver-conventional sinks', async () => {
|
||||
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-taint-disambig-'));
|
||||
try {
|
||||
fs.mkdirSync(path.join(repoDir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(repoDir, 'src', 'app.ts'),
|
||||
`import { execFile as childExecFile, spawnSync } from 'node:child_process';
|
||||
|
||||
function execFile(cmd: string, args: string[]) {
|
||||
return { cmd, args };
|
||||
}
|
||||
|
||||
export function handle(req: any, db: any, map: Map<string, string>, task: any, res: any, out: any) {
|
||||
const value = req.body;
|
||||
childExecFile('git', [value]);
|
||||
spawnSync(value, []);
|
||||
execFile(value, [value]);
|
||||
db.run(value);
|
||||
map.get(value);
|
||||
task.run(value);
|
||||
res.render(value, {});
|
||||
out.render(value, {});
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const result = await runPipelineFromRepo(repoDir, () => {}, { pdg: true });
|
||||
const findings = [...result.graph.iterRelationships()]
|
||||
.filter((rel) => rel.type === 'TAINTED')
|
||||
.map((rel) => {
|
||||
const sink = result.graph.getNode(rel.targetId);
|
||||
const decoded = decodeTaintPath(rel.reason);
|
||||
if (!decoded.ok) {
|
||||
throw new Error(`invalid TAINTED reason for ${rel.id}: ${decoded.error}`);
|
||||
}
|
||||
return {
|
||||
kind: decoded.kind,
|
||||
sinkLine: decoded.hops.at(-1)?.line,
|
||||
sinkText: String(sink?.properties.text ?? ''),
|
||||
};
|
||||
});
|
||||
|
||||
expect(findings.map((f) => f.kind).sort()).toEqual([
|
||||
'command-injection',
|
||||
'command-injection',
|
||||
'sql-injection',
|
||||
'xss',
|
||||
]);
|
||||
const findingSites = findings
|
||||
.map((f) => `${f.kind}@${f.sinkLine}`)
|
||||
.sort((a, b) => Number(a.split('@')[1]) - Number(b.split('@')[1]));
|
||||
expect(findingSites).toEqual([
|
||||
'command-injection@9',
|
||||
'command-injection@10',
|
||||
'sql-injection@12',
|
||||
'xss@15',
|
||||
]);
|
||||
expect(findings.map((f) => f.sinkLine).sort((a, b) => Number(a) - Number(b))).not.toContain(
|
||||
11,
|
||||
);
|
||||
expect(findings.map((f) => f.sinkLine).sort((a, b) => Number(a) - Number(b))).not.toContain(
|
||||
13,
|
||||
);
|
||||
expect(findings.map((f) => f.sinkLine).sort((a, b) => Number(a) - Number(b))).not.toContain(
|
||||
14,
|
||||
);
|
||||
expect(findings.map((f) => f.sinkLine).sort((a, b) => Number(a) - Number(b))).not.toContain(
|
||||
16,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(repoDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Block 1: a --pdg index with real taint findings ─────────────────
|
||||
|
||||
withTestLbugDB(
|
||||
|
|
|
|||
|
|
@ -428,6 +428,38 @@ describe('runEmbeddingPipeline incremental filter', () => {
|
|||
expect(insertParams[0].contentHash).toMatch(/^[0-9a-f]{40}$/);
|
||||
});
|
||||
|
||||
it('deletes exact embedding row ids before inserting a batch (#2452)', async () => {
|
||||
mockEmbedderSetup();
|
||||
|
||||
const node = makeNode({
|
||||
id: 'Function:retry:src/retry.ts',
|
||||
name: 'retry',
|
||||
filePath: 'src/retry.ts',
|
||||
});
|
||||
const executeQuery = mockExecuteQuery([node]);
|
||||
const executeWithReusedStatement = mockExecuteWithReusedStatement();
|
||||
|
||||
const { runEmbeddingPipeline } =
|
||||
await import('../../src/core/embeddings/embedding-pipeline.js');
|
||||
|
||||
await runEmbeddingPipeline(
|
||||
executeQuery,
|
||||
executeWithReusedStatement,
|
||||
onProgress,
|
||||
{},
|
||||
undefined,
|
||||
new Map(),
|
||||
);
|
||||
|
||||
const rowDeleteIndex = stmtCalls.findIndex(
|
||||
(c) => c.cypher.includes('{id: $id}') && c.cypher.includes('DELETE'),
|
||||
);
|
||||
const createIndex = stmtCalls.findIndex((c) => c.cypher.includes('CREATE'));
|
||||
expect(rowDeleteIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(createIndex).toBeGreaterThan(rowDeleteIndex);
|
||||
expect(stmtCalls[rowDeleteIndex].params).toContainEqual({ id: `${node.id}:0` });
|
||||
});
|
||||
|
||||
it('maps positional query rows with description/isExported columns correctly', async () => {
|
||||
const embedBatchSpy = vi
|
||||
.fn()
|
||||
|
|
@ -536,7 +568,7 @@ describe('runEmbeddingPipeline incremental filter', () => {
|
|||
);
|
||||
|
||||
// Should have a DELETE call for the stale node
|
||||
const deleteCalls = stmtCalls.filter((c) => c.cypher.includes('DELETE'));
|
||||
const deleteCalls = stmtCalls.filter((c) => c.cypher.includes('{nodeId: $nodeId}'));
|
||||
expect(deleteCalls.length).toBeGreaterThanOrEqual(1);
|
||||
expect(deleteCalls[0].params.some((p: any) => p.nodeId === node.id)).toBe(true);
|
||||
|
||||
|
|
@ -568,7 +600,7 @@ describe('runEmbeddingPipeline incremental filter', () => {
|
|||
);
|
||||
|
||||
// Should have a DELETE call (stale)
|
||||
const deleteCalls = stmtCalls.filter((c) => c.cypher.includes('DELETE'));
|
||||
const deleteCalls = stmtCalls.filter((c) => c.cypher.includes('{nodeId: $nodeId}'));
|
||||
expect(deleteCalls.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Should also have a CREATE (re-embed)
|
||||
|
|
@ -604,7 +636,7 @@ describe('runEmbeddingPipeline incremental filter', () => {
|
|||
|
||||
// U6 / KTD7: per-batch interleaving means TWO separate DELETE calls (one per
|
||||
// batch), not one up-front bulk delete of both stale rows.
|
||||
const deleteCalls = stmtCalls.filter((c) => c.cypher.includes('DELETE'));
|
||||
const deleteCalls = stmtCalls.filter((c) => c.cypher.includes('{nodeId: $nodeId}'));
|
||||
expect(deleteCalls.length).toBe(2);
|
||||
|
||||
// Ordering proof: batch 1's INSERT lands BEFORE batch 2's DELETE. An up-front
|
||||
|
|
@ -614,7 +646,7 @@ describe('runEmbeddingPipeline incremental filter', () => {
|
|||
(c) => c.cypher.includes('CREATE') && c.params.some((p) => p.nodeId === n1.id),
|
||||
);
|
||||
const deleteN2 = stmtCalls.findIndex(
|
||||
(c) => c.cypher.includes('DELETE') && c.params.some((p) => p.nodeId === n2.id),
|
||||
(c) => c.cypher.includes('{nodeId: $nodeId}') && c.params.some((p) => p.nodeId === n2.id),
|
||||
);
|
||||
expect(insertN1).toBeGreaterThanOrEqual(0);
|
||||
expect(deleteN2).toBeGreaterThanOrEqual(0);
|
||||
|
|
@ -750,7 +782,7 @@ describe('runEmbeddingPipeline incremental filter', () => {
|
|||
const executeQuery = mockExecuteQuery([first, second, third]);
|
||||
const executeWithReusedStatement = mockExecuteWithReusedStatement();
|
||||
const windows: string[][] = [];
|
||||
const mutationCountsAtWindowStart: number[] = [];
|
||||
const createCountsAtWindowStart: number[] = [];
|
||||
const checkpoints: number[] = [];
|
||||
const { runEmbeddingPipeline } =
|
||||
await import('../../src/core/embeddings/embedding-pipeline.js');
|
||||
|
|
@ -766,7 +798,9 @@ describe('runEmbeddingPipeline incremental filter', () => {
|
|||
checkpointEveryNodes: 2,
|
||||
onCheckpointWindowStart: async ({ nodeIds }) => {
|
||||
windows.push(nodeIds);
|
||||
mutationCountsAtWindowStart.push(stmtCalls.length);
|
||||
createCountsAtWindowStart.push(
|
||||
stmtCalls.filter((call) => call.cypher.includes('CREATE')).length,
|
||||
);
|
||||
},
|
||||
onCheckpoint: async ({ nodesProcessed }) => {
|
||||
checkpoints.push(nodesProcessed);
|
||||
|
|
@ -775,7 +809,7 @@ describe('runEmbeddingPipeline incremental filter', () => {
|
|||
);
|
||||
|
||||
expect(windows).toEqual([[first.id, second.id], [third.id]]);
|
||||
expect(mutationCountsAtWindowStart).toEqual([0, 2]);
|
||||
expect(createCountsAtWindowStart).toEqual([0, 2]);
|
||||
expect(checkpoints).toEqual([2, 3]);
|
||||
});
|
||||
|
||||
|
|
@ -833,7 +867,7 @@ describe('runEmbeddingPipeline incremental filter', () => {
|
|||
);
|
||||
|
||||
const deletedIds = stmtCalls
|
||||
.filter((c) => c.cypher.includes('DELETE'))
|
||||
.filter((c) => c.cypher.includes('{nodeId: $nodeId}'))
|
||||
.flatMap((c) => c.params.map((p) => p.nodeId));
|
||||
expect(deletedIds).toContain(stale.id);
|
||||
expect(deletedIds).not.toContain(brandNew.id);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,14 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createLazyAction } from '../../src/cli/lazy-action.js';
|
||||
|
||||
const { checkLbugNativeMock } = vi.hoisted(() => ({
|
||||
checkLbugNativeMock: vi.fn(() => ({ ok: true })),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/core/lbug/native-check.js', () => ({
|
||||
checkLbugNative: checkLbugNativeMock,
|
||||
}));
|
||||
|
||||
describe('createLazyAction', () => {
|
||||
it('does not import target module until invoked', async () => {
|
||||
const loader = vi.fn(async () => ({
|
||||
|
|
@ -19,3 +27,34 @@ describe('createLazyAction', () => {
|
|||
await expect(action()).rejects.toThrow('notAFunction');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createLbugLazyAction', () => {
|
||||
it('fails before importing the target module when LadybugDB native cannot load', async () => {
|
||||
checkLbugNativeMock.mockReturnValueOnce({
|
||||
ok: false,
|
||||
message:
|
||||
'LadybugDB native binary (lbugjs.node) exists but failed to load:\n' + ' dlopen failed',
|
||||
});
|
||||
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||
process.exitCode = undefined;
|
||||
const loader = vi.fn(async () => ({
|
||||
run: vi.fn(async () => 'ok'),
|
||||
}));
|
||||
|
||||
try {
|
||||
const { createLbugLazyAction } = await import('../../src/cli/lazy-action.js');
|
||||
const action = createLbugLazyAction(loader, 'run');
|
||||
|
||||
await expect(action('arg-1')).resolves.toBeUndefined();
|
||||
|
||||
expect(loader).not.toHaveBeenCalled();
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(stderrSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('LadybugDB native binary (lbugjs.node) exists but failed to load:'),
|
||||
);
|
||||
} finally {
|
||||
stderrSpy.mockRestore();
|
||||
process.exitCode = undefined;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -803,8 +803,14 @@ describe('runFullAnalysis wipe-and-restore vector-index stamp (tri-review 466951
|
|||
|
||||
// The recreation seam fired exactly once…
|
||||
expect(buildVectorIndex).toHaveBeenCalledTimes(1);
|
||||
// …the restore actually submitted the cached row (one 200-row batch)…
|
||||
expect(executeWithReusedStatement).toHaveBeenCalledTimes(1);
|
||||
// …the restore first clears the exact target id, then submits the
|
||||
// cached row (one 200-row batch)…
|
||||
expect(executeWithReusedStatement).toHaveBeenCalledTimes(2);
|
||||
const [deleteCall, restoreCall] = executeWithReusedStatement.mock.calls;
|
||||
expect(deleteCall[0]).toContain('DELETE e');
|
||||
expect(deleteCall[1]).toEqual([{ id: `${RESTORED_NODE_ID}:0` }]);
|
||||
expect(restoreCall[0]).toContain('CREATE (e:CodeEmbedding');
|
||||
expect(restoreCall[1]).toHaveLength(1);
|
||||
// …and the persisted stamp reflects the DB's ACTUAL state, not the
|
||||
// platform capability fallback.
|
||||
const meta = JSON.parse(await fs.readFile(`${storagePath}/meta.json`, 'utf-8')) as RepoMeta;
|
||||
|
|
|
|||
155
gitnexus/test/unit/sync-plugin-manifests.test.ts
Normal file
155
gitnexus/test/unit/sync-plugin-manifests.test.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { syncPluginManifests } from '../../scripts/sync-plugin-manifests.mjs';
|
||||
|
||||
const SURFACES = [
|
||||
'gitnexus-claude-plugin/.claude-plugin/plugin.json',
|
||||
'.claude-plugin/marketplace.json',
|
||||
'gitnexus-claude-plugin/.codex-plugin/plugin.json',
|
||||
'.agents/plugins/marketplace.json',
|
||||
] as const;
|
||||
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function writeJson(root: string, file: string, value: unknown): void {
|
||||
const filePath = path.join(root, file);
|
||||
mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function makeRoot(packageVersion: string, manifestVersion: string): string {
|
||||
const root = mkdtempSync(path.join(os.tmpdir(), 'gitnexus-manifest-sync-'));
|
||||
tempRoots.push(root);
|
||||
writeJson(root, 'gitnexus/package.json', { name: 'gitnexus', version: packageVersion });
|
||||
writeJson(root, SURFACES[0], { name: 'gitnexus', version: manifestVersion });
|
||||
writeJson(root, SURFACES[1], {
|
||||
name: 'gitnexus-marketplace',
|
||||
plugins: [{ name: 'gitnexus', version: manifestVersion, source: './gitnexus-claude-plugin' }],
|
||||
});
|
||||
writeJson(root, SURFACES[2], { name: 'gitnexus', version: manifestVersion });
|
||||
writeJson(root, SURFACES[3], {
|
||||
name: 'gitnexus-marketplace',
|
||||
plugins: [{ name: 'gitnexus', version: manifestVersion, category: 'Developer Tools' }],
|
||||
});
|
||||
return root;
|
||||
}
|
||||
|
||||
function readVersions(root: string): string[] {
|
||||
return SURFACES.map((file) => {
|
||||
const manifest = JSON.parse(readFileSync(path.join(root, file), 'utf8')) as {
|
||||
version?: string;
|
||||
plugins?: Array<{ name: string; version: string }>;
|
||||
};
|
||||
return (
|
||||
manifest.version ??
|
||||
manifest.plugins?.find((plugin) => plugin.name === 'gitnexus')?.version ??
|
||||
''
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
describe('syncPluginManifests (#2445)', () => {
|
||||
it('rewrites all four surfaces to the package version and reports them', () => {
|
||||
const root = makeRoot('1.6.10-rc.29', '1.6.9');
|
||||
|
||||
const result = syncPluginManifests(root);
|
||||
|
||||
expect(result.version).toBe('1.6.10-rc.29');
|
||||
expect(result.synced).toHaveLength(4);
|
||||
expect(result.stale.map(({ from }) => from)).toEqual(['1.6.9', '1.6.9', '1.6.9', '1.6.9']);
|
||||
expect(readVersions(root)).toEqual(Array(4).fill('1.6.10-rc.29'));
|
||||
});
|
||||
|
||||
it('is idempotent once everything matches', () => {
|
||||
const root = makeRoot('1.6.10-rc.29', '1.6.9');
|
||||
syncPluginManifests(root);
|
||||
|
||||
const second = syncPluginManifests(root);
|
||||
|
||||
expect(second.synced).toHaveLength(0);
|
||||
expect(second.stale).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('check mode reports drift without writing anything', () => {
|
||||
const root = makeRoot('1.6.10-rc.29', '1.6.9');
|
||||
|
||||
const result = syncPluginManifests(root, { check: true });
|
||||
|
||||
expect(result.stale).toHaveLength(4);
|
||||
expect(result.synced).toHaveLength(0);
|
||||
expect(readVersions(root)).toEqual(Array(4).fill('1.6.9'));
|
||||
});
|
||||
|
||||
it('changes only the version text and preserves the surrounding formatting', () => {
|
||||
const root = makeRoot('1.6.10-rc.29', '1.6.9');
|
||||
const inlineFormatted = `{
|
||||
"name": "gitnexus",
|
||||
"version": "1.6.9",
|
||||
"keywords": ["code-intelligence", "knowledge-graph", "mcp"]
|
||||
}
|
||||
`;
|
||||
writeFileSync(path.join(root, SURFACES[0]), inlineFormatted);
|
||||
|
||||
syncPluginManifests(root);
|
||||
|
||||
expect(readFileSync(path.join(root, SURFACES[0]), 'utf8')).toBe(
|
||||
inlineFormatted.replace('"version": "1.6.9"', '"version": "1.6.10-rc.29"'),
|
||||
);
|
||||
});
|
||||
|
||||
it('fails closed when the current version text is ambiguous in the file', () => {
|
||||
const root = makeRoot('1.6.10-rc.29', '1.6.9');
|
||||
writeFileSync(
|
||||
path.join(root, SURFACES[0]),
|
||||
`{
|
||||
"name": "gitnexus",
|
||||
"version": "1.6.9",
|
||||
"previous": { "version": "1.6.9" }
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
expect(() => syncPluginManifests(root)).toThrow(/expected exactly one/);
|
||||
});
|
||||
|
||||
it('fails closed when a marketplace has no gitnexus entry', () => {
|
||||
const root = makeRoot('1.6.10-rc.29', '1.6.9');
|
||||
writeJson(root, SURFACES[1], { name: 'gitnexus-marketplace', plugins: [] });
|
||||
|
||||
expect(() => syncPluginManifests(root)).toThrow(/exactly one "gitnexus" plugin entry/);
|
||||
});
|
||||
|
||||
it('fails closed when a surface file is missing', () => {
|
||||
const root = makeRoot('1.6.10-rc.29', '1.6.9');
|
||||
rmSync(path.join(root, SURFACES[2]));
|
||||
|
||||
expect(() => syncPluginManifests(root)).toThrow(/Cannot read manifest surface/);
|
||||
});
|
||||
|
||||
it('fails closed on unparseable JSON', () => {
|
||||
const root = makeRoot('1.6.10-rc.29', '1.6.9');
|
||||
writeFileSync(path.join(root, SURFACES[0]), '{ not json');
|
||||
|
||||
expect(() => syncPluginManifests(root)).toThrow(/not valid JSON/);
|
||||
});
|
||||
|
||||
it('matches the real repository layout and passes the check on a synced tree', () => {
|
||||
const repoRoot = path.resolve(__dirname, '..', '..', '..');
|
||||
|
||||
const result = syncPluginManifests(repoRoot, { check: true });
|
||||
|
||||
expect(result.stale).toEqual([]);
|
||||
});
|
||||
|
||||
it('is wired into the npm version lifecycle so every bump syncs the manifests', async () => {
|
||||
const pkg = await import('../../package.json', { with: { type: 'json' } });
|
||||
|
||||
expect(pkg.default.scripts.version).toBe('node scripts/sync-plugin-manifests.mjs');
|
||||
});
|
||||
});
|
||||
|
|
@ -99,6 +99,22 @@ function f(c) { execSync(c); }`);
|
|||
expect(allSinks(m).map((s) => s.entry.name)).toEqual(['execSync']);
|
||||
});
|
||||
|
||||
it('argv-form child_process sinks match command injection on arg 0', () => {
|
||||
const m = matchesOf(`import { execFile, execFileSync, spawnSync } from 'node:child_process';
|
||||
function f(cmd, arg) {
|
||||
execFile(cmd, [arg]);
|
||||
execFileSync(cmd, [arg]);
|
||||
spawnSync(cmd, [arg]);
|
||||
}`);
|
||||
expect(allSinks(m).map((s) => s.entry.name)).toEqual(['execFile', 'execFileSync', 'spawnSync']);
|
||||
expect(allSinks(m).map((s) => [...s.argPositions])).toEqual([
|
||||
[0, 1],
|
||||
[0, 1],
|
||||
[0, 1],
|
||||
]);
|
||||
expect(allSinks(m).every((s) => s.entry.kind === 'command-injection')).toBe(true);
|
||||
});
|
||||
|
||||
it('an in-FUNCTION local `exec` shadows the import — no match', () => {
|
||||
const m = matchesOf(`import { exec } from 'child_process';
|
||||
function f(c) { function exec(x) { return x; } exec(c); }`);
|
||||
|
|
@ -225,10 +241,35 @@ describe('receiver-conventional sinks', () => {
|
|||
expect(allSinks(m).every((s) => s.entry.kind === 'xss')).toBe(true);
|
||||
});
|
||||
|
||||
it('res.render matches template and data args; out.render does not', () => {
|
||||
const m = matchesOf(`function f(res, out, template, data) {
|
||||
res.render(template, data);
|
||||
out.render(template, data);
|
||||
}`);
|
||||
const sinks = allSinks(m);
|
||||
expect(sinks.map((s) => s.entry.name)).toEqual(['render']);
|
||||
expect(sinks.map((s) => [...s.argPositions])).toEqual([[0, 1]]);
|
||||
expect(sinks[0].entry.kind).toBe('xss');
|
||||
});
|
||||
|
||||
it('.query/.execute match sql-injection on ANY receiver', () => {
|
||||
const m = matchesOf(`function f(db, pool, x) { db.query(x); pool.execute(x); }`);
|
||||
expect(allSinks(m).map((s) => s.entry.kind)).toEqual(['sql-injection', 'sql-injection']);
|
||||
});
|
||||
|
||||
it('modern DB method sinks match only conventional DB receivers', () => {
|
||||
const m = matchesOf(`function f(db, stmt, knex, map, task, x) {
|
||||
db.run(x);
|
||||
db.all(x);
|
||||
stmt.get(x);
|
||||
knex.raw(x);
|
||||
db.values(x);
|
||||
map.get(x);
|
||||
task.run(x);
|
||||
}`);
|
||||
expect(allSinks(m).map((s) => s.entry.name)).toEqual(['run', 'all', 'get', 'raw', 'values']);
|
||||
expect(allSinks(m).every((s) => s.entry.kind === 'sql-injection')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizers — import-aware only, kind-scoped', () => {
|
||||
|
|
|
|||
|
|
@ -539,6 +539,54 @@ describe('multi-source identity — distinct sources do not merge at one def', (
|
|||
// ── kind-set exclusion model (real built-in model) ──────────────────────────
|
||||
|
||||
describe('kind-set exclusions — sanitizers neutralize their kinds only', () => {
|
||||
it('built-in model catches argv-form child_process command sinks', () => {
|
||||
const r = analyze(
|
||||
`import { execFileSync } from 'node:child_process';
|
||||
function f(req) {
|
||||
const tool = req.body;
|
||||
const arg = req.query;
|
||||
execFileSync(tool, ['--version']);
|
||||
execFileSync('git', [arg]);
|
||||
}`,
|
||||
{ spec: TS_JS_TAINT_MODEL },
|
||||
);
|
||||
expect(r.findings.map((finding) => finding.sinkKind)).toEqual([
|
||||
'command-injection',
|
||||
'command-injection',
|
||||
]);
|
||||
});
|
||||
|
||||
it('built-in model catches conventional modern DB receiver methods', () => {
|
||||
const r = analyze(
|
||||
`function f(req, db, stmt, knex) {
|
||||
const name = req.body;
|
||||
db.run(name);
|
||||
db.all(name);
|
||||
stmt.get(name);
|
||||
knex.raw(name);
|
||||
}`,
|
||||
{ spec: TS_JS_TAINT_MODEL },
|
||||
);
|
||||
expect(r.findings.map((finding) => finding.sinkKind)).toEqual([
|
||||
'sql-injection',
|
||||
'sql-injection',
|
||||
'sql-injection',
|
||||
'sql-injection',
|
||||
]);
|
||||
});
|
||||
|
||||
it('built-in model catches Express render template and data sinks', () => {
|
||||
const r = analyze(
|
||||
`function f(req, res) {
|
||||
const template = req.body;
|
||||
const viewData = req.query;
|
||||
res.render(template, viewData);
|
||||
}`,
|
||||
{ spec: TS_JS_TAINT_MODEL },
|
||||
);
|
||||
expect(r.findings.map((finding) => finding.sinkKind)).toEqual(['xss', 'xss']);
|
||||
});
|
||||
|
||||
it('escape(req.body) → res.send(b) suppressed (xss neutralized) BUT db.query(b) fires (sql not)', () => {
|
||||
const r = analyze(
|
||||
`import { escape } from 'validator';
|
||||
|
|
|
|||
|
|
@ -134,14 +134,15 @@ describe('GITNEXUS_TOOLS', () => {
|
|||
expect(contextTool.inputSchema.properties.file).toMatchObject({ type: 'string' });
|
||||
});
|
||||
|
||||
it('api_impact tool expresses the route-or-file requirement via anyOf (#2308)', () => {
|
||||
it('api_impact tool avoids top-level schema combinators for Bedrock compatibility (#2487)', () => {
|
||||
const apiImpactTool = GITNEXUS_TOOLS.find((t) => t.name === 'api_impact')!;
|
||||
expect(apiImpactTool.inputSchema.anyOf).toEqual([
|
||||
{ required: ['route'] },
|
||||
{ required: ['file'] },
|
||||
]);
|
||||
// route/file stay optional in `required` (anyOf carries the cross-field rule)
|
||||
expect(apiImpactTool.inputSchema).not.toHaveProperty('anyOf');
|
||||
expect(apiImpactTool.inputSchema).not.toHaveProperty('oneOf');
|
||||
expect(apiImpactTool.inputSchema).not.toHaveProperty('allOf');
|
||||
// route/file stay optional in the transport schema; callTool keeps the
|
||||
// runtime guard so providers that reject top-level combinators can load it.
|
||||
expect(apiImpactTool.inputSchema.required).toEqual([]);
|
||||
expect(apiImpactTool.description).toContain('Requires at least "route" or "file"');
|
||||
});
|
||||
|
||||
it('impact tool requires direction and advertises target, name, or symbol without combinators', () => {
|
||||
|
|
|
|||
|
|
@ -579,6 +579,17 @@ describe('wikiCommand --timeout mapping', () => {
|
|||
|
||||
async function loadWikiCommandHarness() {
|
||||
let capturedConfig: Record<string, unknown> | undefined;
|
||||
const resolveLLMConfig = vi.fn().mockImplementation((overrides = {}) =>
|
||||
Promise.resolve({
|
||||
apiKey: 'sk-test',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
model: 'gpt-4o',
|
||||
maxTokens: 16_384,
|
||||
temperature: 0,
|
||||
provider: 'openai',
|
||||
...overrides,
|
||||
}),
|
||||
);
|
||||
const generatorCtor = vi
|
||||
.fn()
|
||||
.mockImplementation(function (_repoPath, _storagePath, _lbugPath, config) {
|
||||
|
|
@ -609,14 +620,7 @@ describe('wikiCommand --timeout mapping', () => {
|
|||
const actual = await importOriginal<typeof import('../../src/core/wiki/llm-client.js')>();
|
||||
return {
|
||||
...actual,
|
||||
resolveLLMConfig: vi.fn().mockResolvedValue({
|
||||
apiKey: 'sk-test',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
model: 'gpt-4o',
|
||||
maxTokens: 16_384,
|
||||
temperature: 0,
|
||||
provider: 'openai',
|
||||
}),
|
||||
resolveLLMConfig,
|
||||
};
|
||||
});
|
||||
vi.doMock('../../src/core/wiki/generator.js', () => ({
|
||||
|
|
@ -642,6 +646,7 @@ describe('wikiCommand --timeout mapping', () => {
|
|||
generatorCtor,
|
||||
consoleSpy,
|
||||
getCapturedConfig: () => capturedConfig,
|
||||
resolveLLMConfig,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -671,6 +676,25 @@ describe('wikiCommand --timeout mapping', () => {
|
|||
expect(harness.generatorCtor).toHaveBeenCalledTimes(1);
|
||||
expect(harness.getCapturedConfig()?.maxAttempts).toBe(5);
|
||||
});
|
||||
|
||||
it('maps --allow-insecure-connection to allowedInsecureHttpHosts', async () => {
|
||||
const harness = await loadWikiCommandHarness();
|
||||
|
||||
await harness.wikiCommand('/tmp/repo', {
|
||||
allowInsecureConnection: 'llama-box.local,192.168.1.23,llama-box.local',
|
||||
});
|
||||
|
||||
expect(harness.resolveLLMConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
allowedInsecureHttpHosts: ['llama-box.local', '192.168.1.23'],
|
||||
}),
|
||||
);
|
||||
expect(harness.generatorCtor).toHaveBeenCalledTimes(1);
|
||||
expect(harness.getCapturedConfig()?.allowedInsecureHttpHosts).toEqual([
|
||||
'llama-box.local',
|
||||
'192.168.1.23',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('wikiCommand timeout messaging', () => {
|
||||
|
|
|
|||
|
|
@ -2,9 +2,12 @@ import { describe, it, expect, vi, afterEach } from 'vitest';
|
|||
|
||||
// Import the function we'll add in the next step
|
||||
import {
|
||||
LLM_ALLOW_INSECURE_CONNECTION_ENV,
|
||||
isAzureProvider,
|
||||
isReasoningModel,
|
||||
buildRequestUrl,
|
||||
parseLLMAllowedInsecureHttpHosts,
|
||||
resolveLLMConfig,
|
||||
validateLLMBaseUrl,
|
||||
} from '../../src/core/wiki/llm-client.js';
|
||||
|
||||
|
|
@ -470,6 +473,10 @@ describe('readSSEStream — content_filter handling', () => {
|
|||
});
|
||||
|
||||
describe('validateLLMBaseUrl', () => {
|
||||
afterEach(() => {
|
||||
delete process.env[LLM_ALLOW_INSECURE_CONNECTION_ENV];
|
||||
});
|
||||
|
||||
it('allows https:// for any public host', () => {
|
||||
expect(() => validateLLMBaseUrl('https://api.openai.com/v1')).not.toThrow();
|
||||
expect(() => validateLLMBaseUrl('https://openrouter.ai/api/v1')).not.toThrow();
|
||||
|
|
@ -498,6 +505,46 @@ describe('validateLLMBaseUrl', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('allows explicit http:// hosts only when exactly allowlisted', () => {
|
||||
expect(() =>
|
||||
validateLLMBaseUrl('http://llama-box.local:8080/v1', ['llama-box.local']),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
validateLLMBaseUrl('http://LLAMA-BOX.local:8080/v1', [' llama-box.LOCAL ']),
|
||||
).not.toThrow();
|
||||
expect(() => validateLLMBaseUrl('http://llama-box.local.evil/v1', ['llama-box.local'])).toThrow(
|
||||
'Insecure http://',
|
||||
);
|
||||
expect(() => validateLLMBaseUrl('http://192.168.1.23:8080/v1', ['192.168.1.23'])).not.toThrow();
|
||||
});
|
||||
|
||||
it('parses and validates comma-separated insecure HTTP host allowlists', () => {
|
||||
expect(
|
||||
parseLLMAllowedInsecureHttpHosts(' llama-box.local,192.168.1.23,llama-box.local '),
|
||||
).toEqual(['llama-box.local', '192.168.1.23']);
|
||||
expect(parseLLMAllowedInsecureHttpHosts('[fe80::1]')).toEqual(['fe80::1']);
|
||||
expect(() => parseLLMAllowedInsecureHttpHosts('http://llama-box.local')).toThrow(
|
||||
'exact hostnames or IP addresses',
|
||||
);
|
||||
expect(() => parseLLMAllowedInsecureHttpHosts('llama-box.local/path')).toThrow(
|
||||
'exact hostnames or IP addresses',
|
||||
);
|
||||
expect(() => parseLLMAllowedInsecureHttpHosts('llama-box.local:8080')).toThrow(
|
||||
'exact hostnames or IP addresses',
|
||||
);
|
||||
expect(() => parseLLMAllowedInsecureHttpHosts('[fe80::1]:8080')).toThrow(
|
||||
'exact hostnames or IP addresses',
|
||||
);
|
||||
});
|
||||
|
||||
it('resolveLLMConfig reads insecure HTTP hosts from env when no override is passed', async () => {
|
||||
process.env[LLM_ALLOW_INSECURE_CONNECTION_ENV] = 'llama-box.local,192.168.1.23';
|
||||
|
||||
const config = await resolveLLMConfig();
|
||||
|
||||
expect(config.allowedInsecureHttpHosts).toEqual(['llama-box.local', '192.168.1.23']);
|
||||
});
|
||||
|
||||
it('rejects http:// hostname-spoofing attempts', () => {
|
||||
// Full-hostname comparison prevents prefix/suffix attacks
|
||||
expect(() => validateLLMBaseUrl('http://localhost.evil.com/v1')).toThrow('Insecure http://');
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue