diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 93dcf9ce3..0cd526768 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -135,7 +135,7 @@ jobs: uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Install Cosign - uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1 + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 - name: Log in to GitHub Container Registry if: ${{ github.event_name != 'pull_request' && !inputs.dry_run }} diff --git a/Dockerfile.cli b/Dockerfile.cli index 60b56451f..3275c8f7e 100644 --- a/Dockerfile.cli +++ b/Dockerfile.cli @@ -40,8 +40,8 @@ RUN npm prune --omit=dev --prefix gitnexus # node:22-bookworm-slim FROM node:22-bookworm-slim@sha256:9f6d5975c7dca860947d3915877f85607946403fc55349f39b4bc3688448bb6e AS runtime -# curl for the healthcheck; git so `gitnexus` can clone repos at runtime. -RUN apt-get update && apt-get install -y --no-install-recommends curl git && rm -rf /var/lib/apt/lists/* \ +# curl for the healthcheck; git for cloning; ca-certificates for TLS verification. +RUN apt-get update && apt-get install -y --no-install-recommends curl git ca-certificates && rm -rf /var/lib/apt/lists/* \ && rm -rf /usr/local/lib/node_modules/npm \ && rm -rf /usr/local/lib/node_modules/corepack \ && rm -f /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack @@ -58,6 +58,15 @@ COPY --from=builder --chown=node:node /app/gitnexus/package.json ./gitnexus/pack COPY --from=builder --chown=node:node /app/gitnexus/scripts/install-duckdb-extension.mjs ./gitnexus/scripts/install-duckdb-extension.mjs COPY --from=builder --chown=node:node /app/gitnexus/vendor ./gitnexus/vendor +# Expose the `gitnexus` binary on PATH so the documented Docker workflow +# (`docker compose exec gitnexus-server gitnexus index /workspace/`) +# works without users having to invoke `node /app/gitnexus/dist/cli/index.js`. +# `npm prune --omit=dev` in the builder stage strips `node_modules/.bin/` +# entries, so the `gitnexus` bin declared in package.json (`dist/cli/index.js`, +# which already carries `#!/usr/bin/env node` and 755 perms) is otherwise +# unreachable from $PATH. +RUN ln -s /app/gitnexus/dist/cli/index.js /usr/local/bin/gitnexus + USER node # The web UI defaults to http://localhost:4747 - keep that contract. diff --git a/README.md b/README.md index 3c3a28c27..e08c0eb7d 100644 --- a/README.md +++ b/README.md @@ -722,6 +722,12 @@ gitnexus wiki --base-url https://api.anthropic.com/v1 # Force full regeneration gitnexus wiki --force + + +# Increase the timeout or retries for large codebase or slow LLM providers +gitnexus wiki --timeout # Per-attempt LLM request timeout in seconds (default: 60) +gitnexus wiki --retries # Max LLM retry attempts per request (default: 3) + ``` 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. diff --git a/eval/uv.lock b/eval/uv.lock index fcd666e75..04b89f336 100644 --- a/eval/uv.lock +++ b/eval/uv.lock @@ -2278,11 +2278,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.6.3" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] [[package]] diff --git a/gitnexus-claude-plugin/hooks/gitnexus-hook.js b/gitnexus-claude-plugin/hooks/gitnexus-hook.js index 7d8fbfda4..245d34043 100644 --- a/gitnexus-claude-plugin/hooks/gitnexus-hook.js +++ b/gitnexus-claude-plugin/hooks/gitnexus-hook.js @@ -14,6 +14,7 @@ const fs = require('fs'); const path = require('path'); const { spawnSync } = require('child_process'); +const { acquireHookSlot } = require('./hook-lock.js'); /** * Read JSON input from stdin synchronously. @@ -217,7 +218,8 @@ function sendHookResponse(hookEventName, message) { function handlePreToolUse(input) { const cwd = input.cwd || process.cwd(); if (!path.isAbsolute(cwd)) return; - if (!findGitNexusDir(cwd)) return; + const gitNexusDir = findGitNexusDir(cwd); + if (!gitNexusDir) return; const toolName = input.tool_name || ''; const toolInput = input.tool_input || {}; @@ -227,6 +229,9 @@ function handlePreToolUse(input) { const pattern = extractPattern(toolName, toolInput); if (!pattern || pattern.length < 3) return; + const release = acquireHookSlot(gitNexusDir); + if (!release) return; + let result = ''; try { const child = runGitNexusCli(['augment', '--', pattern], cwd, 7000); @@ -235,6 +240,8 @@ function handlePreToolUse(input) { } } catch { /* graceful failure */ + } finally { + release(); } if (result && result.trim()) { diff --git a/gitnexus-claude-plugin/hooks/hook-lock.js b/gitnexus-claude-plugin/hooks/hook-lock.js new file mode 100644 index 000000000..759856384 --- /dev/null +++ b/gitnexus-claude-plugin/hooks/hook-lock.js @@ -0,0 +1,119 @@ +const fs = require('fs'); +const path = require('path'); + +const HOOK_LOCK_SUBDIR = '.hook-locks'; +const HOOK_LOCK_MAX_INFLIGHT = 3; +const HOOK_LOCK_STALE_MS = 30000; + +function acquireHookSlot(gitNexusDir) { + const lockDir = path.join(gitNexusDir, HOOK_LOCK_SUBDIR); + try { + fs.mkdirSync(lockDir, { recursive: true }); + } catch { + // Cannot create lock dir (read-only fs, cross-user perm denial, out of + // inodes, etc.) — fail closed by returning null. Caller skips augment. + // Fail-open here would let N concurrent hooks all proceed unguarded and + // reintroduce the #1486 fan-out the guard exists to prevent. + return null; + } + + const myPidStr = String(process.pid); + + for (let slot = 0; slot < HOOK_LOCK_MAX_INFLIGHT; slot++) { + const slotPath = path.join(lockDir, `slot-${slot}.lock`); + for (let attempt = 0; attempt < 2; attempt++) { + try { + fs.writeFileSync(slotPath, myPidStr, { flag: 'wx' }); + let released = false; + const release = () => { + if (released) return; + released = true; + try { + // Only unlink if we still own the slot. If we appeared stale and + // another hook took over, the file now belongs to it — leave alone. + const content = fs.readFileSync(slotPath, 'utf-8').trim(); + if (content === myPidStr) fs.unlinkSync(slotPath); + } catch { + /* already removed or unreadable */ + } + }; + process.on('exit', release); + return release; + } catch { + // Slot exists. Decide whether to take it over. + // Open once and inspect mtime + content via the same fd so there's + // no TOCTOU between the metadata check and the content read + // (codeql js/file-system-race). + let fd; + try { + fd = fs.openSync(slotPath, 'r'); + } catch { + continue; // Vanished between EEXIST and open — retry this slot. + } + let isLive = false; + let mtimeMs = Date.now(); + try { + mtimeMs = fs.fstatSync(fd).mtimeMs; + const buf = Buffer.alloc(32); + const n = fs.readSync(fd, buf, 0, 32, 0); + const ownerStr = buf.slice(0, n).toString('utf-8').trim(); + if (ownerStr === '') { + // Owner created the file but hasn't written its PID yet. The + // wx open+write window is microseconds; give it the benefit + // of the doubt and treat as live. + isLive = true; + } else { + const owner = Number.parseInt(ownerStr, 10); + if (Number.isFinite(owner) && owner > 0) { + try { + process.kill(owner, 0); + isLive = true; + } catch (e) { + // ESRCH = process gone → treat as dead. EPERM = process exists + // but owned by another user (cross-user lock dir) → still alive, + // keep the slot. Anything else: be conservative, assume alive. + if (e && e.code === 'ESRCH') { + isLive = false; + } else { + isLive = true; + } + } + } + } + } catch { + /* unreadable — treat as dead */ + } finally { + try { + fs.closeSync(fd); + } catch { + /* already closed */ + } + } + // For slots younger than HOOK_LOCK_STALE_MS, PID-liveness wins — + // a slow-but-alive hook is never wrongly evicted. For older slots, + // age is the final arbiter as a defense against PID reuse on long- + // abandoned slots. 30s >> the 7s augment timeout, so a healthy run + // never crosses this threshold. + if (isLive && Date.now() - mtimeMs > HOOK_LOCK_STALE_MS) { + isLive = false; + } + if (isLive) break; // Try the next slot. + try { + fs.unlinkSync(slotPath); + } catch { + /* another hook beat us to it — retry will hit EEXIST */ + } + // Loop and retry this slot. + } + } + } + + return null; +} + +module.exports = { + HOOK_LOCK_SUBDIR, + HOOK_LOCK_MAX_INFLIGHT, + HOOK_LOCK_STALE_MS, + acquireHookSlot, +}; diff --git a/gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md b/gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md index 1c38face4..11945b8cc 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md +++ b/gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md @@ -62,6 +62,8 @@ Generates repository documentation from the knowledge graph using an LLM. Requir | `--api-key ` | LLM API key | | `--concurrency ` | Parallel LLM calls (default: 3) | | `--gist` | Publish wiki as a public GitHub Gist | +| `--timeout ` | Per-attempt LLM request timeout in seconds (default: 60) | +| `--retries ` | Max LLM retry attempts per request (default: 3) | ### list — Show all indexed repos diff --git a/gitnexus-cursor-integration/README.md b/gitnexus-cursor-integration/README.md index 0da8b1981..6545bacec 100644 --- a/gitnexus-cursor-integration/README.md +++ b/gitnexus-cursor-integration/README.md @@ -10,20 +10,21 @@ Static config that adds GitNexus knowledge-graph augmentation and skill files to | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | | **MCP** | `gitnexus` MCP server with 16 tools (`query`, `context`, `impact`, `detect_changes`, `rename`, …) | `npx gitnexus setup` writes `~/.cursor/mcp.json` automatically. | | **Skills** | `/gitnexus-exploring`, `/gitnexus-debugging`, `/gitnexus-impact-analysis`, `/gitnexus-refactoring`, `/gitnexus-pr-review` markdown skills | `npx gitnexus setup` copies them to `~/.cursor/skills/gitnexus/`. | -| **Hooks** _(this README)_ | `postToolUse` hook that enriches `Shell` / `Read` / `Grep` tool calls with graph context — same augmentation Claude Code gets | **Manual** — copy the two files described below into your project's `.cursor/`. | +| **Hooks** _(this README)_ | `postToolUse` hook that enriches `Shell` / `Read` / `Grep` tool calls with graph context — same augmentation Claude Code gets | **Manual** — copy the files described below into your project's `.cursor/`. | ## Hook install Cursor 2.4+ reads `.cursor/hooks.json` from the project root and runs hook commands with the project root as the working directory ([docs](https://cursor.com/docs/agent/hooks)). -From this repo's `gitnexus-cursor-integration/hooks/`, copy the two files into your **project root**: +From this repo's `gitnexus-cursor-integration/hooks/`, copy the files below into your **project root**: ```text / ├── .cursor/ │ └── hooks.json ← from gitnexus-cursor-integration/hooks/hooks.json └── hooks/ - └── gitnexus-hook.cjs ← from gitnexus-cursor-integration/hooks/gitnexus-hook.cjs + ├── gitnexus-hook.cjs ← from gitnexus-cursor-integration/hooks/gitnexus-hook.cjs + └── hook-lock.cjs ← from gitnexus-cursor-integration/hooks/hook-lock.cjs ``` Equivalent shell commands (run from your project root, with `$GITNEXUS_REPO` pointing at a clone of this repo): @@ -32,6 +33,7 @@ Equivalent shell commands (run from your project root, with `$GITNEXUS_REPO` poi mkdir -p .cursor hooks cp "$GITNEXUS_REPO/gitnexus-cursor-integration/hooks/hooks.json" .cursor/hooks.json cp "$GITNEXUS_REPO/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs" hooks/gitnexus-hook.cjs +cp "$GITNEXUS_REPO/gitnexus-cursor-integration/hooks/hook-lock.cjs" hooks/hook-lock.cjs ``` If you already have a `.cursor/hooks.json`, merge the `hooks.postToolUse` array rather than overwriting. @@ -49,7 +51,7 @@ If you already have a `.cursor/hooks.json`, merge the `hooks.postToolUse` array | -------------------------------------------------------------------- | ------------------------------ | | `~/.cursor/mcp.json` | ✅ | | `~/.cursor/skills/gitnexus/*` | ✅ | -| `/.cursor/hooks.json` + `/hooks/gitnexus-hook.cjs` | ❌ — copy manually (see above) | +| `/.cursor/hooks.json` + `/hooks/gitnexus-hook.cjs` + `/hooks/hook-lock.cjs` | ❌ — copy manually (see above) | Hook install is per-project (Cursor scopes hooks to a project root); skills and MCP config are global. @@ -84,6 +86,6 @@ Empty stdout means "no augmentation, continue normally" — the hook never block ## Troubleshooting -- **Nothing happens** — Confirm Cursor is on 2.4+ and the project root has both `.cursor/hooks.json` and the script at `hooks/gitnexus-hook.cjs`. Then `npx gitnexus list` to confirm the project is indexed. +- **Nothing happens** — Confirm Cursor is on 2.4+ and the project root has `.cursor/hooks.json` plus both hook files at `hooks/gitnexus-hook.cjs` and `hooks/hook-lock.cjs`. Then `npx gitnexus list` to confirm the project is indexed. - **`gitnexus` not found** — The hook prefers a locally-resolvable `gitnexus/dist/cli/index.js` and falls back to `npx -y gitnexus`. Install globally with `npm i -g gitnexus` to skip the npx cold-start latency. - **Wrong pattern extracted** — Set `GITNEXUS_DEBUG=1` and run a tool call. The raw stdin payload is logged to stderr; use it to confirm Cursor's actual `tool_input` field names against the table above. If they differ, file an issue with the captured payload. diff --git a/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs b/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs index 0ea336619..74c5587b3 100644 --- a/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs +++ b/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs @@ -18,6 +18,7 @@ const fs = require('fs'); const path = require('path'); const { spawnSync } = require('child_process'); +const { acquireHookSlot } = require('./hook-lock.cjs'); function readInput() { try { @@ -227,7 +228,8 @@ function main() { } const cwd = input.cwd || process.cwd(); if (!path.isAbsolute(cwd)) return; - if (!findGitNexusDir(cwd)) return; + const gitNexusDir = findGitNexusDir(cwd); + if (!gitNexusDir) return; const toolName = input.tool_name || ''; const toolInput = input.tool_input || {}; @@ -235,6 +237,9 @@ function main() { const pattern = extractPattern(toolName, toolInput); if (!pattern || pattern.length < 3) return; + const release = acquireHookSlot(gitNexusDir); + if (!release) return; + const cliPath = resolveCliPath(); let result = ''; try { @@ -244,6 +249,8 @@ function main() { } } catch { /* graceful failure */ + } finally { + release(); } if (result && result.trim()) { diff --git a/gitnexus-cursor-integration/hooks/hook-lock.cjs b/gitnexus-cursor-integration/hooks/hook-lock.cjs new file mode 100644 index 000000000..759856384 --- /dev/null +++ b/gitnexus-cursor-integration/hooks/hook-lock.cjs @@ -0,0 +1,119 @@ +const fs = require('fs'); +const path = require('path'); + +const HOOK_LOCK_SUBDIR = '.hook-locks'; +const HOOK_LOCK_MAX_INFLIGHT = 3; +const HOOK_LOCK_STALE_MS = 30000; + +function acquireHookSlot(gitNexusDir) { + const lockDir = path.join(gitNexusDir, HOOK_LOCK_SUBDIR); + try { + fs.mkdirSync(lockDir, { recursive: true }); + } catch { + // Cannot create lock dir (read-only fs, cross-user perm denial, out of + // inodes, etc.) — fail closed by returning null. Caller skips augment. + // Fail-open here would let N concurrent hooks all proceed unguarded and + // reintroduce the #1486 fan-out the guard exists to prevent. + return null; + } + + const myPidStr = String(process.pid); + + for (let slot = 0; slot < HOOK_LOCK_MAX_INFLIGHT; slot++) { + const slotPath = path.join(lockDir, `slot-${slot}.lock`); + for (let attempt = 0; attempt < 2; attempt++) { + try { + fs.writeFileSync(slotPath, myPidStr, { flag: 'wx' }); + let released = false; + const release = () => { + if (released) return; + released = true; + try { + // Only unlink if we still own the slot. If we appeared stale and + // another hook took over, the file now belongs to it — leave alone. + const content = fs.readFileSync(slotPath, 'utf-8').trim(); + if (content === myPidStr) fs.unlinkSync(slotPath); + } catch { + /* already removed or unreadable */ + } + }; + process.on('exit', release); + return release; + } catch { + // Slot exists. Decide whether to take it over. + // Open once and inspect mtime + content via the same fd so there's + // no TOCTOU between the metadata check and the content read + // (codeql js/file-system-race). + let fd; + try { + fd = fs.openSync(slotPath, 'r'); + } catch { + continue; // Vanished between EEXIST and open — retry this slot. + } + let isLive = false; + let mtimeMs = Date.now(); + try { + mtimeMs = fs.fstatSync(fd).mtimeMs; + const buf = Buffer.alloc(32); + const n = fs.readSync(fd, buf, 0, 32, 0); + const ownerStr = buf.slice(0, n).toString('utf-8').trim(); + if (ownerStr === '') { + // Owner created the file but hasn't written its PID yet. The + // wx open+write window is microseconds; give it the benefit + // of the doubt and treat as live. + isLive = true; + } else { + const owner = Number.parseInt(ownerStr, 10); + if (Number.isFinite(owner) && owner > 0) { + try { + process.kill(owner, 0); + isLive = true; + } catch (e) { + // ESRCH = process gone → treat as dead. EPERM = process exists + // but owned by another user (cross-user lock dir) → still alive, + // keep the slot. Anything else: be conservative, assume alive. + if (e && e.code === 'ESRCH') { + isLive = false; + } else { + isLive = true; + } + } + } + } + } catch { + /* unreadable — treat as dead */ + } finally { + try { + fs.closeSync(fd); + } catch { + /* already closed */ + } + } + // For slots younger than HOOK_LOCK_STALE_MS, PID-liveness wins — + // a slow-but-alive hook is never wrongly evicted. For older slots, + // age is the final arbiter as a defense against PID reuse on long- + // abandoned slots. 30s >> the 7s augment timeout, so a healthy run + // never crosses this threshold. + if (isLive && Date.now() - mtimeMs > HOOK_LOCK_STALE_MS) { + isLive = false; + } + if (isLive) break; // Try the next slot. + try { + fs.unlinkSync(slotPath); + } catch { + /* another hook beat us to it — retry will hit EEXIST */ + } + // Loop and retry this slot. + } + } + } + + return null; +} + +module.exports = { + HOOK_LOCK_SUBDIR, + HOOK_LOCK_MAX_INFLIGHT, + HOOK_LOCK_STALE_MS, + acquireHookSlot, +}; diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index efde27611..51974dfc5 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -29,7 +29,7 @@ "langchain": "^1.3.5", "lru-cache": "^11.2.4", "lucide-react": "^1.14.0", - "mermaid": "^11.14.0", + "mermaid": "^11.15.0", "mnemonist": "^0.39.0", "pandemonium": "^2.4.0", "react": "^19.2.5", @@ -60,7 +60,7 @@ "jsdom": "^29.1.1", "tree-sitter-wasms": "^0.1.13", "typescript": "^5.4.5", - "vite": "^8.0.10", + "vite": "^8.0.11", "vitest": "^4.1.5", "wait-on": "^9.0.5" }, @@ -528,41 +528,10 @@ "integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==", "license": "MIT" }, - "node_modules/@chevrotain/cst-dts-gen": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-12.0.0.tgz", - "integrity": "sha512-fSL4KXjTl7cDgf0B5Rip9Q05BOrYvkJV/RrBTE/bKDN096E4hN/ySpcBK5B24T76dlQ2i32Zc3PAE27jFnFrKg==", - "license": "Apache-2.0", - "dependencies": { - "@chevrotain/gast": "12.0.0", - "@chevrotain/types": "12.0.0" - } - }, - "node_modules/@chevrotain/gast": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-12.0.0.tgz", - "integrity": "sha512-1ne/m3XsIT8aEdrvT33so0GUC+wkctpUPK6zU9IlOyJLUbR0rg4G7ZiApiJbggpgPir9ERy3FRjT6T7lpgetnQ==", - "license": "Apache-2.0", - "dependencies": { - "@chevrotain/types": "12.0.0" - } - }, - "node_modules/@chevrotain/regexp-to-ast": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-12.0.0.tgz", - "integrity": "sha512-p+EW9MaJwgaHguhoqwOtx/FwuGr+DnNn857sXWOi/mClXIkPGl3rn7hGNWvo31HA3vyeQxjqe+H36yZJwYU8cA==", - "license": "Apache-2.0" - }, "node_modules/@chevrotain/types": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-12.0.0.tgz", - "integrity": "sha512-S+04vjFQKeuYw0/eW3U52LkAHQsB1ASxsPGsLPUyQgrZ2iNNibQrsidruDzjEX2JYfespXMG0eZmXlhA6z7nWA==", - "license": "Apache-2.0" - }, - "node_modules/@chevrotain/utils": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-12.0.0.tgz", - "integrity": "sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA==", + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz", + "integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==", "license": "Apache-2.0" }, "node_modules/@cspotcode/source-map-support": { @@ -1679,12 +1648,12 @@ } }, "node_modules/@mermaid-js/parser": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.0.tgz", - "integrity": "sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.1.tgz", + "integrity": "sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==", "license": "MIT", "dependencies": { - "langium": "^4.0.0" + "@chevrotain/types": "~11.1.1" } }, "node_modules/@napi-rs/wasm-runtime": { @@ -1744,9 +1713,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.127.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", - "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "version": "0.128.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.128.0.tgz", + "integrity": "sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" @@ -1769,9 +1738,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.18.tgz", + "integrity": "sha512-lIDyUAfD7U3+BWKzdxMbJcsYHuqXqmGz40aeRqvuAm3y5TkJSYTBW2RDrn65DJFPQqVjUAUqq5uz8urzQ8aBdQ==", "cpu": [ "arm64" ], @@ -1785,9 +1754,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.18.tgz", + "integrity": "sha512-apJq2ktnGp27nSInMR5Vcj8kY6xJzDAvfdIFlpDcAK/w4cDO58qVoi1YQsES/SKiFNge/6e4CUzgjfHduYqWpQ==", "cpu": [ "arm64" ], @@ -1801,9 +1770,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", - "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.18.tgz", + "integrity": "sha512-5Ofot8xbs+pxRHJqm9/9N/4sTQOvdrwEsmPE9pdLEEoAbdZtG6F2LMDfO1sp6ZAtXJuJV/21ew2srq3W8NXB5g==", "cpu": [ "x64" ], @@ -1817,9 +1786,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", - "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.18.tgz", + "integrity": "sha512-7h8eeOTT1eyqJyx64BFCnWZpNm486hGWt2sqeLLgDxA0xI1oGZ9H7gK1S85uNGmBhkdPwa/6reTxfFFKvIsebw==", "cpu": [ "x64" ], @@ -1833,9 +1802,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", - "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.18.tgz", + "integrity": "sha512-eRcm/HVt9U/JFu5RKAEKwGQYtDCKWLiaH6wOnsSEp6NMBb/3Os8LgHZlNyzMpFVNmiiMFlfb2zEnebfzJrHFmg==", "cpu": [ "arm" ], @@ -1849,9 +1818,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.18.tgz", + "integrity": "sha512-SOrT/cT4ukTmgnrEz/Hg3m7LBnuCLW9psDeMKrimRWY4I8DmnO7Lco8W2vtqPmMkbVu8iJ+g4GFLVLLOVjJ9DQ==", "cpu": [ "arm64" ], @@ -1865,9 +1834,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", - "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.18.tgz", + "integrity": "sha512-QWjdxN1HJCpBTAcZ5N5F7wju3gVPzRzSpmGzx7na0c/1qpN9CFil+xt+l9lV/1M6/gqHSNXCiqPfwhVJPeLnug==", "cpu": [ "arm64" ], @@ -1881,9 +1850,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.18.tgz", + "integrity": "sha512-ugCOyj7a4d9h3q9B+wXmf6g3a68UsjGh6dob5DHevHGMwDUbhsYNbSPxJsENcIttJZ9jv7qGM2UesLw5jqIhdg==", "cpu": [ "ppc64" ], @@ -1897,9 +1866,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.18.tgz", + "integrity": "sha512-kKWRhbsotpXkGbcd5dllUWg5gEXcDAa8u5YnP9AV5DYNbvJHGzzuwv7dpmhc8NqKMJldl0a+x76IHbspEpEmdA==", "cpu": [ "s390x" ], @@ -1913,9 +1882,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.18.tgz", + "integrity": "sha512-uCo8ElcCIAMyYAZyuIZ81oFkhTSIllNvUCHCAlbhlN4ji3uC28h7IIdlXyIvGO7HsuqnV9p3rD/bpH7XhIyhRw==", "cpu": [ "x64" ], @@ -1929,9 +1898,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", - "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.18.tgz", + "integrity": "sha512-XNOQZtuE6yUIvx4rwGemwh8kpL1xvU41FXy/s9K7T/3JVcqGzo3NfKM2HrbrGgfPYGFW42f07Wk++aOC6B9NWA==", "cpu": [ "x64" ], @@ -1945,9 +1914,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.18.tgz", + "integrity": "sha512-tSn/kzrfa7tNOXr7sEacDBN4YsIqTyLqh45IO0nHDwtpKIDNDJr+VFojt+4klSpChxB29JLyduSsE0MKEwa65A==", "cpu": [ "arm64" ], @@ -1961,9 +1930,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", - "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.18.tgz", + "integrity": "sha512-+J9YGmc+czgqlhYmwun3S3O0FIZhsH8ep2456xwjAdIOmuJxM7xz4P4PtrxU+Bz17a/5bqPA8o3HAAoX0teUdg==", "cpu": [ "wasm32" ], @@ -1979,9 +1948,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", - "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.18.tgz", + "integrity": "sha512-zsu47DgU0FQzSwi6sU9dZoEdUv7pc1AptSEz/Z8HBg54sV0Pbs3N0+CrIbTsgiu6EyoaNN9CHboqbLaz9lhOyQ==", "cpu": [ "arm64" ], @@ -1995,9 +1964,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", - "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.18.tgz", + "integrity": "sha512-7H+3yqGgmnlDTRRhw/xpYY9J1kf4GC681nVc4GqKhExZTDrVVrV2tsOR9kso0fvgBdcTCcQShx4SLLoHgaLwhg==", "cpu": [ "x64" ], @@ -3643,34 +3612,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/chevrotain": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-12.0.0.tgz", - "integrity": "sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==", - "license": "Apache-2.0", - "dependencies": { - "@chevrotain/cst-dts-gen": "12.0.0", - "@chevrotain/gast": "12.0.0", - "@chevrotain/regexp-to-ast": "12.0.0", - "@chevrotain/types": "12.0.0", - "@chevrotain/utils": "12.0.0" - }, - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/chevrotain-allstar": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.4.1.tgz", - "integrity": "sha512-PvVJm3oGqrveUVW2Vt/eZGeiAIsJszYweUcYwcskg9e+IubNYKKD+rHHem7A6XVO22eDAL+inxNIGAzZ/VIWlA==", - "license": "MIT", - "dependencies": { - "lodash-es": "^4.17.21" - }, - "peerDependencies": { - "chevrotain": "^12.0.0" - } - }, "node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -4628,6 +4569,16 @@ "node": ">= 0.4" } }, + "node_modules/es-toolkit": { + "version": "1.46.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz", + "integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.0.tgz", @@ -5661,24 +5612,6 @@ "@langchain/core": "^1.1.42" } }, - "node_modules/langium": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/langium/-/langium-4.2.2.tgz", - "integrity": "sha512-JUshTRAfHI4/MF9dH2WupvjSXyn8JBuUEWazB8ZVJUtXutT0doDlAv1XKbZ1Pb5sMexa8FF4CFBc0iiul7gbUQ==", - "license": "MIT", - "dependencies": { - "@chevrotain/regexp-to-ast": "~12.0.0", - "chevrotain": "~12.0.0", - "chevrotain-allstar": "~0.4.1", - "vscode-languageserver": "~9.0.1", - "vscode-languageserver-textdocument": "~1.0.11", - "vscode-uri": "~3.1.0" - }, - "engines": { - "node": ">=20.10.0", - "npm": ">=10.2.3" - } - }, "node_modules/langsmith": { "version": "0.5.23", "resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.5.23.tgz", @@ -6422,14 +6355,14 @@ } }, "node_modules/mermaid": { - "version": "11.14.0", - "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.14.0.tgz", - "integrity": "sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g==", + "version": "11.15.0", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.15.0.tgz", + "integrity": "sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw==", "license": "MIT", "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.2", - "@mermaid-js/parser": "^1.1.0", + "@mermaid-js/parser": "^1.1.1", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.33.1", @@ -6440,27 +6373,14 @@ "dagre-d3-es": "7.0.14", "dayjs": "^1.11.19", "dompurify": "^3.3.1", + "es-toolkit": "^1.45.1", "katex": "^0.16.25", "khroma": "^2.1.0", - "lodash-es": "^4.17.23", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", - "uuid": "^11.1.0" - } - }, - "node_modules/mermaid/node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/esm/bin/uuid" + "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" } }, "node_modules/micromark": { @@ -7216,9 +7136,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "funding": [ { "type": "github", @@ -7595,9 +7515,9 @@ } }, "node_modules/postcss": { - "version": "8.5.10", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", - "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", "funding": [ { "type": "opencollective", @@ -7947,13 +7867,13 @@ "license": "Unlicense" }, "node_modules/rolldown": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", - "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.18.tgz", + "integrity": "sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg==", "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.127.0", - "@rolldown/pluginutils": "1.0.0-rc.17" + "@oxc-project/types": "=0.128.0", + "@rolldown/pluginutils": "1.0.0-rc.18" }, "bin": { "rolldown": "bin/cli.mjs" @@ -7962,27 +7882,27 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.17", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", - "@rolldown/binding-darwin-x64": "1.0.0-rc.17", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" + "@rolldown/binding-android-arm64": "1.0.0-rc.18", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.18", + "@rolldown/binding-darwin-x64": "1.0.0-rc.18", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.18", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.18", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.18", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.18", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.18", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.18", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.18", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.18", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.18", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.18", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.18", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.18" } }, "node_modules/rolldown/node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", - "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.18.tgz", + "integrity": "sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw==", "license": "MIT" }, "node_modules/roughjs": { @@ -8702,15 +8622,15 @@ } }, "node_modules/vite": { - "version": "8.0.10", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", - "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", + "version": "8.0.11", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.11.tgz", + "integrity": "sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow==", "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", - "postcss": "^8.5.10", - "rolldown": "1.0.0-rc.17", + "postcss": "^8.5.14", + "rolldown": "1.0.0-rc.18", "tinyglobby": "^0.2.16" }, "bin": { @@ -8727,7 +8647,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.0", + "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -8875,55 +8795,6 @@ "dev": true, "license": "MIT" }, - "node_modules/vscode-jsonrpc": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", - "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/vscode-languageserver": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz", - "integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==", - "license": "MIT", - "dependencies": { - "vscode-languageserver-protocol": "3.17.5" - }, - "bin": { - "installServerIntoExtension": "bin/installServerIntoExtension" - } - }, - "node_modules/vscode-languageserver-protocol": { - "version": "3.17.5", - "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", - "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", - "license": "MIT", - "dependencies": { - "vscode-jsonrpc": "8.2.0", - "vscode-languageserver-types": "3.17.5" - } - }, - "node_modules/vscode-languageserver-textdocument": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", - "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", - "license": "MIT" - }, - "node_modules/vscode-languageserver-types": { - "version": "3.17.5", - "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", - "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", - "license": "MIT" - }, - "node_modules/vscode-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", - "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", - "license": "MIT" - }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json index b68b7540d..9e1b466e3 100644 --- a/gitnexus-web/package.json +++ b/gitnexus-web/package.json @@ -39,7 +39,7 @@ "langchain": "^1.3.5", "lru-cache": "^11.2.4", "lucide-react": "^1.14.0", - "mermaid": "^11.14.0", + "mermaid": "^11.15.0", "mnemonist": "^0.39.0", "pandemonium": "^2.4.0", "react": "^19.2.5", @@ -70,7 +70,7 @@ "jsdom": "^29.1.1", "tree-sitter-wasms": "^0.1.13", "typescript": "^5.4.5", - "vite": "^8.0.10", + "vite": "^8.0.11", "vitest": "^4.1.5", "wait-on": "^9.0.5" } diff --git a/gitnexus/hooks/claude/gitnexus-hook.cjs b/gitnexus/hooks/claude/gitnexus-hook.cjs index 7bfa150cd..9541fcb50 100755 --- a/gitnexus/hooks/claude/gitnexus-hook.cjs +++ b/gitnexus/hooks/claude/gitnexus-hook.cjs @@ -14,6 +14,7 @@ const fs = require('fs'); const path = require('path'); const { spawnSync } = require('child_process'); +const { acquireHookSlot } = require('./hook-lock.cjs'); /** * Read JSON input from stdin synchronously. @@ -207,7 +208,8 @@ function runGitNexusCli(cliPath, args, cwd, timeout) { function handlePreToolUse(input) { const cwd = input.cwd || process.cwd(); if (!path.isAbsolute(cwd)) return; - if (!findGitNexusDir(cwd)) return; + const gitNexusDir = findGitNexusDir(cwd); + if (!gitNexusDir) return; const toolName = input.tool_name || ''; const toolInput = input.tool_input || {}; @@ -217,6 +219,9 @@ function handlePreToolUse(input) { const pattern = extractPattern(toolName, toolInput); if (!pattern || pattern.length < 3) return; + const release = acquireHookSlot(gitNexusDir); + if (!release) return; + const cliPath = resolveCliPath(); let result = ''; try { @@ -226,6 +231,8 @@ function handlePreToolUse(input) { } } catch { /* graceful failure */ + } finally { + release(); } if (result && result.trim()) { diff --git a/gitnexus/hooks/claude/hook-lock.cjs b/gitnexus/hooks/claude/hook-lock.cjs new file mode 100644 index 000000000..759856384 --- /dev/null +++ b/gitnexus/hooks/claude/hook-lock.cjs @@ -0,0 +1,119 @@ +const fs = require('fs'); +const path = require('path'); + +const HOOK_LOCK_SUBDIR = '.hook-locks'; +const HOOK_LOCK_MAX_INFLIGHT = 3; +const HOOK_LOCK_STALE_MS = 30000; + +function acquireHookSlot(gitNexusDir) { + const lockDir = path.join(gitNexusDir, HOOK_LOCK_SUBDIR); + try { + fs.mkdirSync(lockDir, { recursive: true }); + } catch { + // Cannot create lock dir (read-only fs, cross-user perm denial, out of + // inodes, etc.) — fail closed by returning null. Caller skips augment. + // Fail-open here would let N concurrent hooks all proceed unguarded and + // reintroduce the #1486 fan-out the guard exists to prevent. + return null; + } + + const myPidStr = String(process.pid); + + for (let slot = 0; slot < HOOK_LOCK_MAX_INFLIGHT; slot++) { + const slotPath = path.join(lockDir, `slot-${slot}.lock`); + for (let attempt = 0; attempt < 2; attempt++) { + try { + fs.writeFileSync(slotPath, myPidStr, { flag: 'wx' }); + let released = false; + const release = () => { + if (released) return; + released = true; + try { + // Only unlink if we still own the slot. If we appeared stale and + // another hook took over, the file now belongs to it — leave alone. + const content = fs.readFileSync(slotPath, 'utf-8').trim(); + if (content === myPidStr) fs.unlinkSync(slotPath); + } catch { + /* already removed or unreadable */ + } + }; + process.on('exit', release); + return release; + } catch { + // Slot exists. Decide whether to take it over. + // Open once and inspect mtime + content via the same fd so there's + // no TOCTOU between the metadata check and the content read + // (codeql js/file-system-race). + let fd; + try { + fd = fs.openSync(slotPath, 'r'); + } catch { + continue; // Vanished between EEXIST and open — retry this slot. + } + let isLive = false; + let mtimeMs = Date.now(); + try { + mtimeMs = fs.fstatSync(fd).mtimeMs; + const buf = Buffer.alloc(32); + const n = fs.readSync(fd, buf, 0, 32, 0); + const ownerStr = buf.slice(0, n).toString('utf-8').trim(); + if (ownerStr === '') { + // Owner created the file but hasn't written its PID yet. The + // wx open+write window is microseconds; give it the benefit + // of the doubt and treat as live. + isLive = true; + } else { + const owner = Number.parseInt(ownerStr, 10); + if (Number.isFinite(owner) && owner > 0) { + try { + process.kill(owner, 0); + isLive = true; + } catch (e) { + // ESRCH = process gone → treat as dead. EPERM = process exists + // but owned by another user (cross-user lock dir) → still alive, + // keep the slot. Anything else: be conservative, assume alive. + if (e && e.code === 'ESRCH') { + isLive = false; + } else { + isLive = true; + } + } + } + } + } catch { + /* unreadable — treat as dead */ + } finally { + try { + fs.closeSync(fd); + } catch { + /* already closed */ + } + } + // For slots younger than HOOK_LOCK_STALE_MS, PID-liveness wins — + // a slow-but-alive hook is never wrongly evicted. For older slots, + // age is the final arbiter as a defense against PID reuse on long- + // abandoned slots. 30s >> the 7s augment timeout, so a healthy run + // never crosses this threshold. + if (isLive && Date.now() - mtimeMs > HOOK_LOCK_STALE_MS) { + isLive = false; + } + if (isLive) break; // Try the next slot. + try { + fs.unlinkSync(slotPath); + } catch { + /* another hook beat us to it — retry will hit EEXIST */ + } + // Loop and retry this slot. + } + } + } + + return null; +} + +module.exports = { + HOOK_LOCK_SUBDIR, + HOOK_LOCK_MAX_INFLIGHT, + HOOK_LOCK_STALE_MS, + acquireHookSlot, +}; diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 13b225aed..15831e54d 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -1600,9 +1600,9 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/codegen": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", - "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/eventemitter": { @@ -1628,9 +1628,9 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/inquire": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", - "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.1.tgz", + "integrity": "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/path": { @@ -4543,22 +4543,22 @@ "license": "MIT" }, "node_modules/protobufjs": { - "version": "7.5.5", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.5.tgz", - "integrity": "sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==", + "version": "7.5.8", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.8.tgz", + "integrity": "sha512-dvpCIeLPbXZS/Ete7yLaO7RenOdken2NHKykBXbsaGxZT0UTltcarBciw+A78SRQs9iMAAVpsYA+l8b1hTePIA==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", + "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", + "@protobufjs/inquire": "^1.1.1", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.0.0" }, diff --git a/gitnexus/scripts/build.js b/gitnexus/scripts/build.js index 84f43fc6b..ec7f67cf4 100644 --- a/gitnexus/scripts/build.js +++ b/gitnexus/scripts/build.js @@ -21,11 +21,15 @@ const SHARED_DEST = path.join(DIST, '_shared'); // ── 1. Build gitnexus-shared ─────────────────────────────────────── console.log('[build] compiling gitnexus-shared…'); -execSync('npx tsc', { cwd: SHARED_ROOT, stdio: 'inherit', timeout: 120_000 }); +const tscCmd = + process.platform === 'win32' + ? path.join('node_modules', '.bin', 'tsc.cmd') + : path.join('node_modules', '.bin', 'tsc'); +execSync(tscCmd, { cwd: SHARED_ROOT, stdio: 'inherit', timeout: 120_000 }); // ── 2. Build gitnexus ────────────────────────────────────────────── console.log('[build] compiling gitnexus…'); -execSync('npx tsc', { cwd: ROOT, stdio: 'inherit', timeout: 120_000 }); +execSync(tscCmd, { cwd: ROOT, stdio: 'inherit', timeout: 120_000 }); // ── 3. Copy shared dist ──────────────────────────────────────────── console.log('[build] copying shared module into dist/_shared…'); diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index d38675f03..4b009e4aa 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -161,6 +161,8 @@ program ) .option('--no-reasoning-model', 'Disable reasoning model mode (overrides saved config)') .option('--concurrency ', 'Parallel LLM calls (default: 3)', '3') + .option('--timeout ', 'Per-attempt LLM request timeout in seconds (default: 60)') + .option('--retries ', 'Max LLM retry attempts per request (default: 3)') .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') diff --git a/gitnexus/src/cli/setup.ts b/gitnexus/src/cli/setup.ts index af3c4737a..8f52e0f2d 100644 --- a/gitnexus/src/cli/setup.ts +++ b/gitnexus/src/cli/setup.ts @@ -364,6 +364,15 @@ async function installClaudeCodeHooks(result: SetupResult): Promise { // Script not found in source — skip } + try { + await fs.copyFile( + path.join(pluginHooksPath, 'hook-lock.cjs'), + path.join(destHooksDir, 'hook-lock.cjs'), + ); + } catch { + // Helper not found in source — skip + } + const hookPath = path.join(destHooksDir, 'gitnexus-hook.cjs').replace(/\\/g, '/'); // Escape backslashes FIRST, then quotes (CodeQL js/incomplete-sanitization). // The previous shape `replace(/"/g, '\\"')` alone would let `path\with"quote` diff --git a/gitnexus/src/cli/wiki.ts b/gitnexus/src/cli/wiki.ts index 38a0f82a6..8d9da9572 100644 --- a/gitnexus/src/cli/wiki.ts +++ b/gitnexus/src/cli/wiki.ts @@ -33,6 +33,8 @@ export interface WikiCommandOptions { provider?: LLMProvider; verbose?: boolean; review?: boolean; + timeout?: string; + retries?: string; } /** @@ -347,6 +349,16 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio } } + // ── Apply per-run overrides not saved to config ──────────────────── + if (options?.timeout) { + const secs = parseInt(options.timeout, 10); + if (!isNaN(secs) && secs > 0) llmConfig.requestTimeoutMs = secs * 1000; + } + if (options?.retries) { + const n = parseInt(options.retries, 10); + if (!isNaN(n) && n > 0) llmConfig.maxAttempts = n; + } + // ── Setup progress bar with elapsed timer ────────────────────────── const bar = new cliProgress.SingleBar( { diff --git a/gitnexus/src/core/search/hybrid-search.ts b/gitnexus/src/core/search/hybrid-search.ts index b76a9f5e9..a2521dd83 100644 --- a/gitnexus/src/core/search/hybrid-search.ts +++ b/gitnexus/src/core/search/hybrid-search.ts @@ -50,9 +50,15 @@ export const mergeWithRRF = ( ): HybridSearchResult[] => { const merged = new Map(); + // Guard against undefined/null inputs (#1489) — when FTS is unavailable + // in the MCP process, bm25Results can arrive as undefined and the + // for-loop would throw "bm25Results is not iterable". + const safeBm25 = bm25Results ?? []; + const safeSemantic = semanticResults ?? []; + // Process BM25 results - for (let i = 0; i < bm25Results.length; i++) { - const r = bm25Results[i]; + for (let i = 0; i < safeBm25.length; i++) { + const r = safeBm25[i]; const rrfScore = 1 / (RRF_K + i + 1); // i+1 because rank starts at 1 merged.set(r.filePath, { @@ -65,8 +71,8 @@ export const mergeWithRRF = ( } // Process semantic results and merge - for (let i = 0; i < semanticResults.length; i++) { - const r = semanticResults[i]; + for (let i = 0; i < safeSemantic.length; i++) { + const r = safeSemantic[i]; const rrfScore = 1 / (RRF_K + i + 1); const existing = merged.get(r.filePath); @@ -149,6 +155,9 @@ export const formatHybridResults = (results: HybridSearchResult[]): string => { * Execute BM25 + semantic search and merge with RRF. * Uses LadybugDB FTS for always-fresh BM25 results (no cached data). * The semanticSearch function is injected to keep this module environment-agnostic. + * + * When FTS is unavailable (e.g. read-only MCP connection, missing indexes), + * falls back to semantic-only results instead of crashing (#1489). */ export const hybridSearch = async ( query: string, @@ -160,8 +169,16 @@ export const hybridSearch = async ( k?: number, ) => Promise, ): Promise => { - // Use LadybugDB FTS for always-fresh BM25 results - const { results: bm25Results } = await searchFTSFromLbug(query, limit); + // Use LadybugDB FTS for always-fresh BM25 results. + // If FTS fails (e.g. extension not loaded in MCP process), fall back to + // semantic-only search instead of crashing with "bm25Results is not iterable". + let bm25Results: BM25SearchResult[] = []; + try { + const ftsResponse = await searchFTSFromLbug(query, limit); + bm25Results = ftsResponse?.results ?? []; + } catch { + // FTS unavailable — continue with semantic-only search + } const semanticResults = await semanticSearch(executeQuery, query, limit); return mergeWithRRF(bm25Results, semanticResults, limit); }; diff --git a/gitnexus/src/core/wiki/generator.ts b/gitnexus/src/core/wiki/generator.ts index 9f65d26e8..dc9c2e74e 100644 --- a/gitnexus/src/core/wiki/generator.ts +++ b/gitnexus/src/core/wiki/generator.ts @@ -28,6 +28,7 @@ import { type FileWithExports, } from './graph-queries.js'; import { generateHTMLViewer } from './html-viewer.js'; +import { sanitizeMermaidMarkdown } from './mermaid-sanitizer.js'; import { callLLM, @@ -591,7 +592,7 @@ export class WikiGenerator { const response = await this.invokeLLM(prompt, MODULE_SYSTEM_PROMPT, this.streamOpts(node.name)); // Write page with front matter - const pageContent = `# ${node.name}\n\n${response.content}`; + const pageContent = sanitizeMermaidMarkdown(`# ${node.name}\n\n${response.content}`); await fs.writeFile(path.join(this.wikiDir, `${node.slug}.md`), pageContent, 'utf-8'); } @@ -631,7 +632,7 @@ export class WikiGenerator { const response = await this.invokeLLM(prompt, PARENT_SYSTEM_PROMPT, this.streamOpts(node.name)); - const pageContent = `# ${node.name}\n\n${response.content}`; + const pageContent = sanitizeMermaidMarkdown(`# ${node.name}\n\n${response.content}`); await fs.writeFile(path.join(this.wikiDir, `${node.slug}.md`), pageContent, 'utf-8'); } @@ -681,7 +682,9 @@ export class WikiGenerator { this.streamOpts('Generating overview', 88), ); - const pageContent = `# ${path.basename(this.repoPath)} — Wiki\n\n${response.content}`; + const pageContent = sanitizeMermaidMarkdown( + `# ${path.basename(this.repoPath)} — Wiki\n\n${response.content}`, + ); await fs.writeFile(path.join(this.wikiDir, 'overview.md'), pageContent, 'utf-8'); } diff --git a/gitnexus/src/core/wiki/html-viewer.ts b/gitnexus/src/core/wiki/html-viewer.ts index f961d36ae..c0fd7323a 100644 --- a/gitnexus/src/core/wiki/html-viewer.ts +++ b/gitnexus/src/core/wiki/html-viewer.ts @@ -7,6 +7,7 @@ import fs from 'fs/promises'; import path from 'path'; +import { sanitizeMermaidMarkdown } from './mermaid-sanitizer.js'; interface ModuleTreeNode { name: string; @@ -42,7 +43,7 @@ export async function generateHTMLViewer(wikiDir: string, projectName: string): const dirEntries = await fs.readdir(wikiDir); for (const f of dirEntries.filter((f) => f.endsWith('.md'))) { const content = await fs.readFile(path.join(wikiDir, f), 'utf-8'); - pages[f.replace(/\.md$/, '')] = content; + pages[f.replace(/\.md$/, '')] = sanitizeMermaidMarkdown(content); } const html = buildHTML(projectName, moduleTree, pages, meta); diff --git a/gitnexus/src/core/wiki/llm-client.ts b/gitnexus/src/core/wiki/llm-client.ts index 37fe7a9f2..40ef831bf 100644 --- a/gitnexus/src/core/wiki/llm-client.ts +++ b/gitnexus/src/core/wiki/llm-client.ts @@ -23,6 +23,10 @@ export interface LLMConfig { apiVersion?: string; /** When true, strips sampling params and uses max_completion_tokens instead of max_tokens */ isReasoningModel?: boolean; + /** Per-attempt fetch timeout in ms (default: 60_000). */ + requestTimeoutMs?: number; + /** Max fetch attempts before giving up (default: 3). */ + maxAttempts?: number; } export interface LLMResponse { @@ -237,12 +241,12 @@ export async function callLLM( // indefinitely on a frozen TCP connection — the per-call // signal is the only timeout `resilientFetch` honors; // `capDelayMs` only bounds the *backoff* between attempts. - // 60s matches typical LLM completion budgets. - signal: AbortSignal.timeout(60_000), + // Default 60s; raise via --timeout for slow models or large pages. + signal: AbortSignal.timeout(config.requestTimeoutMs ?? 60_000), }, { breakerKey: `wiki-llm-${new URL(url).host}`, - retry: { maxAttempts: 3, baseDelayMs: 2_000, capDelayMs: 30_000 }, + retry: { maxAttempts: config.maxAttempts ?? 3, baseDelayMs: 2_000, capDelayMs: 30_000 }, }, ); } catch (err) { diff --git a/gitnexus/src/core/wiki/mermaid-sanitizer.ts b/gitnexus/src/core/wiki/mermaid-sanitizer.ts new file mode 100644 index 000000000..c8d443c78 --- /dev/null +++ b/gitnexus/src/core/wiki/mermaid-sanitizer.ts @@ -0,0 +1,119 @@ +const MERMAID_FENCE_RE = /```mermaid\s*\n([\s\S]*?)```/g; +const NODE_LABEL_RE = + /(\[[^\]\n]*(?:\\n)[^\]\n]*\]|\{[^}\n]*(?:\\n)[^}\n]*\}|\([^)\n]*(?:\\n)[^)\n]*\))/g; +const EDGE_LABEL_RE = /\|([^|\n]+)\|/g; +const UNSAFE_EDGE_LABEL_RE = /[()[\]{}<>]/; +const UNSAFE_NODE_ID_RE = /[^A-Za-z0-9_-]/; +const NODE_ID_RE = /^[A-Za-z0-9_.:/()-]+$/; + +const LINE_PREFIX_RE = /^(\s*(?:(?:[-A-Za-z0-9_]+)\s*:\s*)?)(.*)$/; +const EDGE_RE = + /(\s*(?:[ox])?(?:--+|==+|\.\.+)(?:[>|ox])?\|[^|\n]*\|(?:[>|ox])?|\s*(?:[ox])?(?:--+|==+|\.\.+)(?:[>|ox])?|\s*<--+>?\s*)/g; + +export function sanitizeMermaidMarkdown(markdown: string): string { + return markdown.replace(MERMAID_FENCE_RE, (_match, diagram: string) => { + return '```mermaid\n' + sanitizeMermaidDiagram(diagram) + '```'; + }); +} + +export function sanitizeMermaidDiagram(diagram: string): string { + const aliases = new Map(); + let nextAlias = 1; + + const aliasFor = (id: string): string => { + const existing = aliases.get(id); + if (existing) return existing; + + const base = id.replace(/[^A-Za-z0-9_-]/g, '_').replace(/^_+|_+$/g, '') || 'node'; + let alias = base; + while ([...aliases.values()].includes(alias)) { + nextAlias += 1; + alias = `${base}_${nextAlias}`; + } + aliases.set(id, alias); + return alias; + }; + + return diagram + .split('\n') + .map((line) => sanitizeMermaidLine(line, aliasFor)) + .join('\n'); +} + +function sanitizeMermaidLine(line: string, aliasFor: (id: string) => string): string { + let sanitized = replaceLiteralLineBreaksInLabels(line); + sanitized = quoteUnsafeEdgeLabels(sanitized); + + const prefixMatch = sanitized.match(LINE_PREFIX_RE); + if (!prefixMatch) return sanitized; + + const prefix = prefixMatch[1]; + const body = prefixMatch[2]; + if (isDirectiveLine(body)) return sanitized; + + const parts = body.split(EDGE_RE); + if (parts.length === 1) return sanitized; + + for (let i = 0; i < parts.length; i += 2) { + parts[i] = sanitizeNodeReference(parts[i], aliasFor); + } + + return prefix + parts.join(''); +} + +function replaceLiteralLineBreaksInLabels(line: string): string { + return line.replace(NODE_LABEL_RE, (label) => label.replace(/\\n/g, '
')); +} + +function quoteUnsafeEdgeLabels(line: string): string { + return line.replace(EDGE_LABEL_RE, (match, label: string) => { + const trimmed = label.trim(); + if (!UNSAFE_EDGE_LABEL_RE.test(trimmed)) return match; + if ( + (trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + return match; + } + return `|"${escapeMermaidLabel(trimmed)}"|`; + }); +} + +function sanitizeNodeReference(segment: string, aliasFor: (id: string) => string): string { + const match = segment.match(/^(\s*)([A-Za-z0-9_.:/()-]+)(.*?)(\s*)$/); + if (!match) return segment; + + const [, leading, id, suffix, trailing] = match; + if (!NODE_ID_RE.test(id) || !UNSAFE_NODE_ID_RE.test(id)) return segment; + const hasInlineLabel = + suffix.trim().startsWith('[') || suffix.trim().startsWith('(') || suffix.trim().startsWith('{'); + + if (hasInlineLabel) return `${leading}${aliasFor(id)}${suffix}${trailing}`; + + return `${leading}${aliasFor(id)}["${escapeMermaidLabel(id)}"]${suffix}${trailing}`; +} + +function escapeMermaidLabel(label: string): string { + return label.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +} + +function isDirectiveLine(line: string): boolean { + const trimmed = line.trim(); + return ( + trimmed === '' || + trimmed.startsWith('%%') || + trimmed.startsWith('graph ') || + trimmed.startsWith('flowchart ') || + trimmed.startsWith('sequenceDiagram') || + trimmed.startsWith('classDiagram') || + trimmed.startsWith('stateDiagram') || + trimmed.startsWith('erDiagram') || + trimmed.startsWith('journey') || + trimmed.startsWith('gantt') || + trimmed.startsWith('pie ') || + trimmed.startsWith('mindmap') || + trimmed.startsWith('timeline') || + trimmed.startsWith('subgraph ') || + trimmed === 'end' + ); +} diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 167c1db81..922a69f85 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -755,8 +755,10 @@ export class LocalBackend { timer.time('vector', this.semanticSearch(repo, searchQuery, searchLimit)), ]); - const bm25Results = bm25SearchResult.results; - const ftsUsed = bm25SearchResult.ftsUsed; + // Guard against undefined results (#1489) — when FTS is entirely + // unavailable the search helper may return an unexpected shape. + const bm25Results = bm25SearchResult?.results ?? []; + const ftsUsed = bm25SearchResult?.ftsUsed ?? false; // Merge via reciprocal rank fusion timer.start('merge'); @@ -774,8 +776,9 @@ export class LocalBackend { } } - for (let i = 0; i < semanticResults.length; i++) { - const result = semanticResults[i]; + const safeSemanticResults = semanticResults ?? []; + for (let i = 0; i < safeSemanticResults.length; i++) { + const result = safeSemanticResults[i]; const key = result.nodeId || result.filePath; const rrfScore = 1 / (60 + i); const existing = scoreMap.get(key); @@ -992,7 +995,17 @@ export class LocalBackend { query: string, limit: number, ): Promise<{ results: any[]; ftsUsed: boolean }> { - const { searchFTSFromLbug } = await import('../../core/search/bm25-index.js'); + let searchFTSFromLbug; + try { + ({ searchFTSFromLbug } = await import('../../core/search/bm25-index.js')); + } catch (err: any) { + // Module import can fail in sandboxed MCP contexts (#1489) + logger.warn( + { err: err?.message }, + 'GitNexus: bm25-index.js import failed — falling back to semantic-only', + ); + return { results: [], ftsUsed: false }; + } let ftsResponse; try { ftsResponse = await searchFTSFromLbug(query, limit, repo.id); @@ -1004,8 +1017,10 @@ export class LocalBackend { return { results: [], ftsUsed: false }; } - const bm25Results = ftsResponse.results; - const ftsUsed = ftsResponse.ftsAvailable; + // Guard against unexpected response shape (#1489) — ftsResponse.results + // could be undefined when the FTS extension is unavailable in the MCP process. + const bm25Results = ftsResponse?.results ?? []; + const ftsUsed = ftsResponse?.ftsAvailable ?? false; const results: any[] = []; diff --git a/gitnexus/test/integration/cli-e2e.test.ts b/gitnexus/test/integration/cli-e2e.test.ts index 8037511bf..99e52c330 100644 --- a/gitnexus/test/integration/cli-e2e.test.ts +++ b/gitnexus/test/integration/cli-e2e.test.ts @@ -36,6 +36,7 @@ const FIXTURE_SRC = path.resolve(testDir, '..', 'fixtures', 'mini-repo'); // still works), `afterAll` rms the parent tmpdir. let MINI_REPO: string; let tmpParent: string; +let suiteGitnexusHome: string; // Absolute file:// URL to tsx loader — needed when spawning CLI with cwd // outside the project tree (bare 'tsx' specifier won't resolve there). @@ -49,6 +50,7 @@ beforeAll(() => { // Copy the fixture into an isolated tmpdir named `mini-repo` so that the // `--repo mini-repo` CLI arg (which matches by basename) still works. tmpParent = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-cli-e2e-')); + suiteGitnexusHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-cli-e2e-home-')); MINI_REPO = path.join(tmpParent, 'mini-repo'); fs.cpSync(FIXTURE_SRC, MINI_REPO, { recursive: true }); @@ -75,21 +77,30 @@ afterAll(() => { if (tmpParent) { fs.rmSync(tmpParent, { recursive: true, force: true }); } + if (suiteGitnexusHome) { + fs.rmSync(suiteGitnexusHome, { recursive: true, force: true }); + } }); +function cliEnv(extraEnv: Record = {}) { + return { + ...process.env, + GITNEXUS_HOME: suiteGitnexusHome, + // Pre-set --max-old-space-size so analyzeCommand's ensureHeap() sees it + // and skips the re-exec. The re-exec drops the tsx loader (--import tsx + // is not in process.argv), causing ERR_UNKNOWN_FILE_EXTENSION on .ts files. + NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(), + ...extraEnv, + }; +} + function runCli(command: string, cwd: string, timeoutMs = 15000) { return spawnSync(process.execPath, ['--import', tsxImportUrl, cliEntry, command], { cwd, encoding: 'utf8', timeout: timeoutMs, stdio: ['pipe', 'pipe', 'pipe'], - env: { - ...process.env, - // Pre-set --max-old-space-size so analyzeCommand's ensureHeap() sees it - // and skips the re-exec. The re-exec drops the tsx loader (--import tsx - // is not in process.argv), causing ERR_UNKNOWN_FILE_EXTENSION on .ts files. - NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(), - }, + env: cliEnv(), }); } @@ -103,10 +114,7 @@ function runCliRaw(extraArgs: string[], cwd: string, timeoutMs = 15000) { encoding: 'utf8', timeout: timeoutMs, stdio: ['pipe', 'pipe', 'pipe'], - env: { - ...process.env, - NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(), - }, + env: cliEnv(), }); } @@ -126,11 +134,7 @@ function runCliWithEnv( encoding: 'utf8', timeout: timeoutMs, stdio: ['pipe', 'pipe', 'pipe'], - env: { - ...process.env, - NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(), - ...extraEnv, - }, + env: cliEnv(extraEnv), }); } @@ -919,10 +923,7 @@ describe('CLI end-to-end', () => { encoding: 'utf8', timeout: timeoutMs, stdio: ['pipe', 'pipe', 'pipe'], - env: { - ...process.env, - NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(), - }, + env: cliEnv(), }); } @@ -1042,10 +1043,7 @@ describe('CLI end-to-end', () => { encoding: 'utf8', timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'], - env: { - ...process.env, - NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(), - }, + env: cliEnv(), }, ); if (result.status === null) return; @@ -1159,10 +1157,7 @@ describe('CLI end-to-end', () => { { cwd: MINI_REPO, stdio: ['ignore', 'pipe', 'pipe'], - env: { - ...process.env, - NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(), - }, + env: cliEnv(), }, ); @@ -1212,10 +1207,7 @@ describe('CLI end-to-end', () => { { cwd: MINI_REPO, stdio: ['ignore', 'pipe', 'pipe'], - env: { - ...process.env, - NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(), - }, + env: cliEnv(), }, ); diff --git a/gitnexus/test/unit/calltool-dispatch.test.ts b/gitnexus/test/unit/calltool-dispatch.test.ts index 45e1b71d2..8a9a1a629 100644 --- a/gitnexus/test/unit/calltool-dispatch.test.ts +++ b/gitnexus/test/unit/calltool-dispatch.test.ts @@ -216,6 +216,19 @@ describe('LocalBackend.callTool', () => { expect(result).not.toHaveProperty('warning'); }); + it('does not crash when searchFTSFromLbug throws (#1489)', async () => { + const { searchFTSFromLbug } = await import('../../src/core/search/bm25-index.js'); + vi.mocked(searchFTSFromLbug).mockRejectedValueOnce(new Error('bm25Results is not iterable')); + (executeParameterized as any).mockResolvedValue([]); + + const result = await backend.callTool('query', { query: 'auth' }); + + // Should still return a valid result shape (semantic-only fallback) + expect(result).toHaveProperty('processes'); + expect(result).toHaveProperty('definitions'); + expect(result).not.toHaveProperty('error'); + }); + it('skips vector index query when VECTOR is unsupported by the platform', async () => { const cap = _captureLogger(); platformMocks.isVectorExtensionSupportedByPlatform.mockReturnValue(false); diff --git a/gitnexus/test/unit/cursor-hook.test.ts b/gitnexus/test/unit/cursor-hook.test.ts index f0d875dee..e64979895 100644 --- a/gitnexus/test/unit/cursor-hook.test.ts +++ b/gitnexus/test/unit/cursor-hook.test.ts @@ -35,6 +35,15 @@ const CURSOR_HOOK = path.resolve( 'hooks', 'gitnexus-hook.cjs', ); +const CURSOR_HOOK_LOCK = path.resolve( + __dirname, + '..', + '..', + '..', + 'gitnexus-cursor-integration', + 'hooks', + 'hook-lock.cjs', +); const CURSOR_HOOKS_JSON = path.resolve( __dirname, '..', @@ -60,16 +69,35 @@ function parseCursorOutput(stdout: string): { additional_context?: string } | nu // ─── Test fixtures ────────────────────────────────────────────────── let tmpDir: string; +// Separate fixture for the concurrency guard tests: this one has a real +// `.gitnexus/` so the hook reaches acquireHookSlot. The base tmpDir above +// deliberately has no .gitnexus so unrelated early-exit tests stay cheap. +let guardTmpDir: string; +let guardGitNexusDir: string; beforeAll(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-cursor-hook-test-')); spawnSync('git', ['init'], { cwd: tmpDir, stdio: 'pipe' }); spawnSync('git', ['config', 'user.email', 'test@test.com'], { cwd: tmpDir, stdio: 'pipe' }); spawnSync('git', ['config', 'user.name', 'Test'], { cwd: tmpDir, stdio: 'pipe' }); + + guardTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-cursor-hook-guard-')); + guardGitNexusDir = path.join(guardTmpDir, '.gitnexus'); + fs.mkdirSync(guardGitNexusDir, { recursive: true }); + spawnSync('git', ['init'], { cwd: guardTmpDir, stdio: 'pipe' }); + spawnSync('git', ['config', 'user.email', 'test@test.com'], { + cwd: guardTmpDir, + stdio: 'pipe', + }); + spawnSync('git', ['config', 'user.name', 'Test'], { cwd: guardTmpDir, stdio: 'pipe' }); + fs.writeFileSync(path.join(guardTmpDir, 'dummy.txt'), 'hello'); + spawnSync('git', ['add', '.'], { cwd: guardTmpDir, stdio: 'pipe' }); + spawnSync('git', ['commit', '-m', 'init'], { cwd: guardTmpDir, stdio: 'pipe' }); }); afterAll(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); + fs.rmSync(guardTmpDir, { recursive: true, force: true }); }); // ─── Manifest + hook file presence ─────────────────────────────────── @@ -375,6 +403,155 @@ describe('Cursor hook debug logging', () => { }); }); +// ─── Source code regression: concurrency guard (#1486) ───────────── + +describe('Cursor hook concurrency guard', () => { + const source = fs.readFileSync(CURSOR_HOOK, 'utf-8'); + const lockSource = fs.readFileSync(CURSOR_HOOK_LOCK, 'utf-8'); + + it('loads acquireHookSlot helper module', () => { + expect(source).toContain('acquireHookSlot'); + expect(source).toContain('hook-lock.cjs'); + }); + + it('helper defines acquireHookSlot with MAX_INFLIGHT constant', () => { + expect(lockSource).toContain('function acquireHookSlot'); + expect(lockSource).toContain('HOOK_LOCK_MAX_INFLIGHT'); + }); + + it('calls acquireHookSlot in main() and releases via finally', () => { + // The Cursor hook uses a flat main() dispatcher rather than a separate + // handlePreToolUse — assert the guard call + finally release wiring is + // present so a future refactor cannot accidentally skip it. + expect(source).toContain('acquireHookSlot('); + expect(source).toMatch(/finally\s*\{[^}]*release\(\)/s); + }); + + it('uses atomic fixed-name slot files (hard cap, not soft TOCTOU cap)', () => { + expect(lockSource).toMatch(/slot-\$\{slot\}\.lock|`slot-/); + const slotFn = lockSource.slice( + lockSource.indexOf('function acquireHookSlot'), + lockSource.indexOf('function', lockSource.indexOf('function acquireHookSlot') + 1), + ); + expect(slotFn).not.toContain('readdirSync'); + }); + + it('fails closed when lock dir cannot be created', () => { + // Regression: see hooks.test.ts. The mkdirSync catch must return null + // (skip augment) rather than `() => {}` (proceed unguarded), so that + // a read-only or cross-user `.gitnexus/` cannot reintroduce #1486. + const slotFn = lockSource.slice( + lockSource.indexOf('function acquireHookSlot'), + lockSource.indexOf('function', lockSource.indexOf('function acquireHookSlot') + 1), + ); + const mkdirCatch = slotFn.slice( + slotFn.indexOf('fs.mkdirSync(lockDir'), + slotFn.indexOf('const myPidStr'), + ); + expect(mkdirCatch).toContain('return null'); + expect(mkdirCatch).not.toMatch(/return\s*\(\s*\)\s*=>\s*\{\s*\}/); + }); + + // Note: the 10-concurrent-spawner burst test that validates `wx` + // (O_CREAT|O_EXCL) under simultaneous contention lives in + // hooks.test.ts. The Cursor hook uses byte-for-byte the same + // acquireHookSlot, so duplicating the burst test here would only test + // the OS primitive, not Cursor-specific wiring. The source-level checks + // above guarantee the Cursor hook keeps calling that same algorithm. +}); + +// ─── Integration: concurrency guard skips when slots are full ────── + +describe('Cursor hook concurrency guard (integration)', () => { + it('exits silently when all MAX_INFLIGHT slots hold live pids', async () => { + const { spawn } = await import('child_process'); + const lockDir = path.join(guardGitNexusDir, '.hook-locks'); + fs.mkdirSync(lockDir, { recursive: true }); + + const sleepers = [0, 1, 2].map(() => + spawn(process.execPath, ['-e', 'setTimeout(()=>{},60000)'], { + stdio: 'ignore', + detached: false, + }), + ); + const writtenLocks: string[] = []; + try { + for (let i = 0; i < sleepers.length; i++) { + const p = path.join(lockDir, `slot-${i}.lock`); + fs.writeFileSync(p, String(sleepers[i].pid)); + writtenLocks.push(p); + } + + const result = runHook(CURSOR_HOOK, { + tool_name: 'Grep', + tool_input: { query: 'validateUser' }, + cwd: guardTmpDir, + }); + + expect(result.stdout.trim()).toBe(''); + for (let i = 0; i < sleepers.length; i++) { + const p = path.join(lockDir, `slot-${i}.lock`); + expect(fs.existsSync(p)).toBe(true); + expect(fs.readFileSync(p, 'utf-8').trim()).toBe(String(sleepers[i].pid)); + } + } finally { + for (const child of sleepers) { + try { + child.kill(); + } catch { + /* ignore */ + } + } + for (const p of writtenLocks) { + try { + fs.unlinkSync(p); + } catch { + /* ignore */ + } + } + try { + fs.rmdirSync(lockDir); + } catch { + /* ignore */ + } + } + }); + + it('reclaims a slot held by a dead pid', () => { + const lockDir = path.join(guardGitNexusDir, '.hook-locks'); + fs.mkdirSync(lockDir, { recursive: true }); + const deadPid = 2_147_483_640; + const stalePath = path.join(lockDir, 'slot-0.lock'); + try { + fs.writeFileSync(stalePath, String(deadPid)); + expect(fs.readFileSync(stalePath, 'utf-8').trim()).toBe(String(deadPid)); + + runHook(CURSOR_HOOK, { + tool_name: 'Grep', + tool_input: { query: 'validateUser' }, + cwd: guardTmpDir, + }); + + // The hook reclaimed and then released slot-0 — either gone (released) + // or no longer owned by the dead pid. + if (fs.existsSync(stalePath)) { + expect(fs.readFileSync(stalePath, 'utf-8').trim()).not.toBe(String(deadPid)); + } + } finally { + try { + fs.unlinkSync(stalePath); + } catch { + /* already pruned */ + } + try { + fs.rmdirSync(lockDir); + } catch { + /* ignore */ + } + } + }); +}); + // ─── Documented contract behavior (extractPattern via the live hook) ─ describe('Shell quoted-pattern parser limitations (documented)', () => { @@ -429,6 +606,7 @@ describe('Cursor integration install docs', () => { const body = fs.readFileSync(integrationReadme, 'utf-8'); expect(body).toContain('.cursor/hooks.json'); expect(body).toContain('hooks/gitnexus-hook.cjs'); + expect(body).toContain('hooks/hook-lock.cjs'); expect(body).toContain('Hook install'); }); diff --git a/gitnexus/test/unit/hooks.test.ts b/gitnexus/test/unit/hooks.test.ts index da19da002..346ee1ed6 100644 --- a/gitnexus/test/unit/hooks.test.ts +++ b/gitnexus/test/unit/hooks.test.ts @@ -26,6 +26,7 @@ import { runHook, parseHookOutput } from '../utils/hook-test-helpers.js'; // ─── Paths to both hook variants ──────────────────────────────────── const CJS_HOOK = path.resolve(__dirname, '..', '..', 'hooks', 'claude', 'gitnexus-hook.cjs'); +const CJS_HOOK_LOCK = path.resolve(__dirname, '..', '..', 'hooks', 'claude', 'hook-lock.cjs'); const PLUGIN_HOOK = path.resolve( __dirname, '..', @@ -35,6 +36,15 @@ const PLUGIN_HOOK = path.resolve( 'hooks', 'gitnexus-hook.js', ); +const PLUGIN_HOOK_LOCK = path.resolve( + __dirname, + '..', + '..', + '..', + 'gitnexus-claude-plugin', + 'hooks', + 'hook-lock.js', +); // ─── Test fixtures: temporary .gitnexus directory ─────────────────── @@ -294,6 +304,296 @@ describe('Git mutation regex', () => { } }); +// ─── Source code regression: PreToolUse concurrency guard (#1486) ── + +describe('PreToolUse concurrency guard', () => { + for (const [label, hookPath, lockPath] of [ + ['CJS', CJS_HOOK, CJS_HOOK_LOCK], + ['Plugin', PLUGIN_HOOK, PLUGIN_HOOK_LOCK], + ] as const) { + it(`${label} hook loads acquireHookSlot helper`, () => { + const source = fs.readFileSync(hookPath, 'utf-8'); + expect(source).toContain('acquireHookSlot'); + expect(source).toContain('hook-lock'); + }); + + it(`${label} helper defines acquireHookSlot`, () => { + const source = fs.readFileSync(lockPath, 'utf-8'); + expect(source).toContain('function acquireHookSlot'); + expect(source).toContain('HOOK_LOCK_MAX_INFLIGHT'); + }); + + it(`${label} hook calls acquireHookSlot in handlePreToolUse`, () => { + const source = fs.readFileSync(hookPath, 'utf-8'); + const preBody = source.slice( + source.indexOf('function handlePreToolUse'), + source.indexOf('function handlePostToolUse'), + ); + expect(preBody).toContain('acquireHookSlot('); + expect(preBody).toMatch(/release\(\)/); + }); + + it(`${label} hook uses atomic fixed-name slot files (hard cap)`, () => { + // Regression for the TOCTOU soft-cap: an earlier revision counted + // entries then wrote a per-pid lock, which let simultaneous bursts + // exceed MAX_INFLIGHT. The hard-cap version writes to fixed-name + // slot-N.lock paths so O_CREAT|O_EXCL is atomic across processes. + const source = fs.readFileSync(lockPath, 'utf-8'); + expect(source).toMatch(/slot-\$\{slot\}\.lock|`slot-/); + // And no longer reads the lock dir to count active hooks. + const slotFn = source.slice( + source.indexOf('function acquireHookSlot'), + source.indexOf('function', source.indexOf('function acquireHookSlot') + 1), + ); + expect(slotFn).not.toContain('readdirSync'); + }); + + it(`${label} hook fails closed when lock dir cannot be created`, () => { + // Regression: an earlier revision returned `() => {}` (truthy no-op) on + // mkdirSync failure, which left callers — `if (!release) return;` — to + // proceed unguarded and reintroduce the #1486 fan-out on read-only or + // cross-user `.gitnexus/` setups. The guard must fail closed (null). + const source = fs.readFileSync(lockPath, 'utf-8'); + const slotFn = source.slice( + source.indexOf('function acquireHookSlot'), + source.indexOf('function', source.indexOf('function acquireHookSlot') + 1), + ); + const mkdirCatch = slotFn.slice( + slotFn.indexOf('fs.mkdirSync(lockDir'), + slotFn.indexOf('const myPidStr'), + ); + expect(mkdirCatch).toContain('return null'); + expect(mkdirCatch).not.toMatch(/return\s*\(\s*\)\s*=>\s*\{\s*\}/); + }); + } +}); + +// ─── Integration: concurrency guard skips when slots are full ────── + +describe('PreToolUse concurrency guard (integration)', () => { + for (const [label, hookPath] of [ + ['CJS', CJS_HOOK], + ['Plugin', PLUGIN_HOOK], + ] as const) { + it(`${label}: hook exits silently when all MAX_INFLIGHT slots hold live pids`, async () => { + const { spawn } = await import('child_process'); + const lockDir = path.join(gitNexusDir, '.hook-locks'); + fs.mkdirSync(lockDir, { recursive: true }); + + // Spawn 3 long-sleeping node child processes to use as live PIDs. + const sleepers = [0, 1, 2].map(() => + spawn(process.execPath, ['-e', 'setTimeout(()=>{},60000)'], { + stdio: 'ignore', + detached: false, + }), + ); + const writtenLocks: string[] = []; + try { + for (let i = 0; i < sleepers.length; i++) { + // Slot files are named slot-N.lock; content is the owning PID. + const p = path.join(lockDir, `slot-${i}.lock`); + fs.writeFileSync(p, String(sleepers[i].pid)); + writtenLocks.push(p); + } + + const result = runHook(hookPath, { + hook_event_name: 'PreToolUse', + tool_name: 'Grep', + tool_input: { pattern: 'validateUser' }, + cwd: tmpDir, + }); + + expect(result.stdout.trim()).toBe(''); + // Sentinel slot files survive; the hook bailed before claiming any of them. + for (let i = 0; i < sleepers.length; i++) { + const p = path.join(lockDir, `slot-${i}.lock`); + expect(fs.existsSync(p)).toBe(true); + // Owner unchanged. + expect(fs.readFileSync(p, 'utf-8').trim()).toBe(String(sleepers[i].pid)); + } + } finally { + for (const child of sleepers) { + try { + child.kill(); + } catch { + /* ignore */ + } + } + for (const p of writtenLocks) { + try { + fs.unlinkSync(p); + } catch { + /* ignore */ + } + } + try { + fs.rmdirSync(lockDir); + } catch { + /* ignore */ + } + } + }); + + it(`${label}: hook reclaims a slot held by a dead pid`, () => { + const lockDir = path.join(gitNexusDir, '.hook-locks'); + fs.mkdirSync(lockDir, { recursive: true }); + // PID 1 exists on every POSIX system (init); on Windows process.kill(1,0) + // throws. Use a definitely-dead PID instead: a very large number unlikely + // to be assigned. + const deadPid = 2_147_483_640; + const stalePath = path.join(lockDir, 'slot-0.lock'); + try { + fs.writeFileSync(stalePath, String(deadPid)); + expect(fs.readFileSync(stalePath, 'utf-8').trim()).toBe(String(deadPid)); + + runHook(hookPath, { + hook_event_name: 'PreToolUse', + tool_name: 'Grep', + tool_input: { pattern: 'validateUser' }, + cwd: tmpDir, + }); + + // The hook reclaimed and then released slot-0 — either the file is + // gone (released) or its content is something other than the dead PID. + if (fs.existsSync(stalePath)) { + expect(fs.readFileSync(stalePath, 'utf-8').trim()).not.toBe(String(deadPid)); + } + } finally { + try { + fs.unlinkSync(stalePath); + } catch { + /* already pruned */ + } + try { + fs.rmdirSync(lockDir); + } catch { + /* ignore */ + } + } + }); + + it(`${label}: hook does not exceed MAX_INFLIGHT under simultaneous bursts (hard cap)`, async () => { + // Spawn many hook processes concurrently and assert that at most + // MAX_INFLIGHT (3) slot files end up populated by live pids. The + // O_CREAT|O_EXCL slot scheme makes this a hard cap, not the soft cap + // that the count-then-claim approach gives. + const { spawn } = await import('child_process'); + const lockDir = path.join(gitNexusDir, '.hook-locks'); + // Clean any leftover slot files. + try { + for (const f of fs.readdirSync(lockDir)) fs.unlinkSync(path.join(lockDir, f)); + } catch { + /* dir may not exist yet */ + } + fs.mkdirSync(lockDir, { recursive: true }); + + // We use child workers that just claim a slot via the same algorithm + // and then sleep, so we can observe the on-disk state under contention + // without spawning the real gitnexus augment CLI. + const claimerScript = ` + const fs = require('fs'); const path = require('path'); + const lockDir = ${JSON.stringify(lockDir)}; + const MAX = 3; + const STALE = 30000; + const myPid = String(process.pid); + function tryAcquire() { + for (let slot = 0; slot < MAX; slot++) { + const p = path.join(lockDir, 'slot-' + slot + '.lock'); + for (let a = 0; a < 2; a++) { + try { fs.writeFileSync(p, myPid, { flag: 'wx' }); return p; } + catch { + let stat; try { stat = fs.statSync(p); } catch { continue; } + let live = false; + try { + const s = fs.readFileSync(p, 'utf-8').trim(); + if (s === '') live = true; + else { const o = Number.parseInt(s, 10); + if (Number.isFinite(o) && o > 0) { try { process.kill(o, 0); live = true; } catch {} } + } + } catch {} + if (live && Date.now() - stat.mtimeMs > STALE) live = false; + if (live) break; + try { fs.unlinkSync(p); } catch {} + } + } + } + return null; + } + const claimed = tryAcquire(); + if (claimed) { + process.stdout.write('CLAIMED:' + claimed + '\\n'); + setTimeout(() => {}, 5000); + } else { + process.stdout.write('SKIPPED\\n'); + } + `; + + const N = 10; + const claimers = Array.from({ length: N }, () => + spawn(process.execPath, ['-e', claimerScript], { + stdio: ['ignore', 'pipe', 'ignore'], + detached: false, + }), + ); + try { + // Wait until every claimer has printed its decision. + const decisions = await Promise.all( + claimers.map( + (c) => + new Promise((resolve) => { + let buf = ''; + c.stdout!.on('data', (d) => { + buf += d.toString(); + if (buf.includes('\n')) resolve(buf.split('\n')[0]); + }); + c.on('exit', () => resolve(buf.split('\n')[0] || 'EXIT')); + }), + ), + ); + const claimedCount = decisions.filter((d) => d.startsWith('CLAIMED:')).length; + const skippedCount = decisions.filter((d) => d === 'SKIPPED').length; + + // HARD CAP: never more than 3 winners, regardless of how many bursts. + expect(claimedCount).toBeLessThanOrEqual(3); + // And the remainder must have all explicitly skipped. + expect(claimedCount + skippedCount).toBe(N); + + // On-disk state matches. + const liveSlots = fs + .readdirSync(lockDir) + .filter((f) => /^slot-\d+\.lock$/.test(f)) + .filter((f) => { + try { + const o = Number.parseInt(fs.readFileSync(path.join(lockDir, f), 'utf-8').trim(), 10); + return Number.isFinite(o) && o > 0; + } catch { + return false; + } + }); + expect(liveSlots.length).toBeLessThanOrEqual(3); + } finally { + for (const c of claimers) { + try { + c.kill(); + } catch { + /* ignore */ + } + } + try { + for (const f of fs.readdirSync(lockDir)) fs.unlinkSync(path.join(lockDir, f)); + } catch { + /* ignore */ + } + try { + fs.rmdirSync(lockDir); + } catch { + /* ignore */ + } + } + }); + } +}); + // ─── Integration: PostToolUse staleness detection ─────────────────── describe('PostToolUse staleness detection (integration)', () => { diff --git a/gitnexus/test/unit/hybrid-search.test.ts b/gitnexus/test/unit/hybrid-search.test.ts index ee2a4499b..813f78aa7 100644 --- a/gitnexus/test/unit/hybrid-search.test.ts +++ b/gitnexus/test/unit/hybrid-search.test.ts @@ -1,5 +1,5 @@ /** - * P1 Unit Tests: Hybrid Search (mergeWithRRF) + * P1 Unit Tests: Hybrid Search (mergeWithRRF + hybridSearch) * * Tests: mergeWithRRF from hybrid-search.ts * - BM25-only merge @@ -7,12 +7,20 @@ * - Combined ranking * - Limit parameter * - Empty inputs + * - Undefined/null inputs (#1489) + * + * Tests: hybridSearch fallback when FTS unavailable (#1489) */ -import { describe, it, expect } from 'vitest'; -import { mergeWithRRF } from '../../src/core/search/hybrid-search.js'; +import { describe, it, expect, vi } from 'vitest'; +import { mergeWithRRF, hybridSearch } from '../../src/core/search/hybrid-search.js'; import type { BM25SearchResult } from '../../src/core/search/bm25-index.js'; import type { SemanticSearchResult } from '../../src/core/embeddings/types.js'; +vi.mock('../../src/core/search/bm25-index.js', async (importOriginal) => { + const actual = (await importOriginal()) as any; + return { ...actual, searchFTSFromLbug: vi.fn() }; +}); + let bm25Rank = 0; function makeBM25(filePath: string, score: number): BM25SearchResult { return { filePath, score, rank: ++bm25Rank }; @@ -123,4 +131,72 @@ describe('mergeWithRRF', () => { expect(result[0].bm25Score).toBe(15); expect(result[0].semanticScore).toBeCloseTo(0.7); // 1 - distance }); + + // Regression: #1489 — bm25Results is not iterable when FTS unavailable + it('does not crash when bm25Results is undefined (#1489)', () => { + const semantic: SemanticSearchResult[] = [makeSemantic('src/a.ts', 0.1)]; + // Force undefined to simulate the crash path where FTS returns unexpected shape + const result = mergeWithRRF(undefined as any, semantic); + expect(result).toHaveLength(1); + expect(result[0].filePath).toBe('src/a.ts'); + expect(result[0].sources).toEqual(['semantic']); + }); + + it('does not crash when semanticResults is undefined (#1489)', () => { + const bm25: BM25SearchResult[] = [makeBM25('src/a.ts', 10)]; + const result = mergeWithRRF(bm25, undefined as any); + expect(result).toHaveLength(1); + expect(result[0].filePath).toBe('src/a.ts'); + expect(result[0].sources).toEqual(['bm25']); + }); + + it('does not crash when both inputs are undefined (#1489)', () => { + const result = mergeWithRRF(undefined as any, undefined as any); + expect(result).toHaveLength(0); + }); +}); + +// Regression: #1489 — hybridSearch must not crash when FTS is unavailable +describe('hybridSearch — FTS failure fallback (#1489)', () => { + it('falls back to semantic-only when searchFTSFromLbug throws', async () => { + const { searchFTSFromLbug } = await import('../../src/core/search/bm25-index.js'); + vi.mocked(searchFTSFromLbug).mockRejectedValueOnce(new Error('bm25Results is not iterable')); + + const mockExecuteQuery = vi.fn().mockResolvedValue([]); + const mockSemanticSearch = vi + .fn() + .mockResolvedValue([makeSemantic('src/semantic-hit.ts', 0.15)]); + + const results = await hybridSearch('test query', 10, mockExecuteQuery, mockSemanticSearch); + expect(results).toHaveLength(1); + expect(results[0].filePath).toBe('src/semantic-hit.ts'); + expect(results[0].sources).toEqual(['semantic']); + }); + + it('returns empty when both FTS and semantic return nothing', async () => { + const { searchFTSFromLbug } = await import('../../src/core/search/bm25-index.js'); + vi.mocked(searchFTSFromLbug).mockRejectedValueOnce(new Error('FTS unavailable')); + + const mockExecuteQuery = vi.fn().mockResolvedValue([]); + const mockSemanticSearch = vi.fn().mockResolvedValue([]); + + const results = await hybridSearch('test query', 10, mockExecuteQuery, mockSemanticSearch); + expect(results).toHaveLength(0); + }); + + it('works normally when FTS succeeds', async () => { + const { searchFTSFromLbug } = await import('../../src/core/search/bm25-index.js'); + vi.mocked(searchFTSFromLbug).mockResolvedValueOnce({ + results: [{ filePath: 'src/fts-hit.ts', score: 5, rank: 1 }], + ftsAvailable: true, + }); + + const mockExecuteQuery = vi.fn().mockResolvedValue([]); + const mockSemanticSearch = vi.fn().mockResolvedValue([]); + + const results = await hybridSearch('test query', 10, mockExecuteQuery, mockSemanticSearch); + expect(results).toHaveLength(1); + expect(results[0].filePath).toBe('src/fts-hit.ts'); + expect(results[0].sources).toEqual(['bm25']); + }); }); diff --git a/gitnexus/test/unit/wiki-mermaid-sanitizer.test.ts b/gitnexus/test/unit/wiki-mermaid-sanitizer.test.ts new file mode 100644 index 000000000..e8c74c8cb --- /dev/null +++ b/gitnexus/test/unit/wiki-mermaid-sanitizer.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; + +import { + sanitizeMermaidDiagram, + sanitizeMermaidMarkdown, +} from '../../src/core/wiki/mermaid-sanitizer.js'; + +describe('sanitizeMermaidMarkdown', () => { + it('replaces literal newline escapes inside rectangle and diamond labels', () => { + const markdown = [ + '```mermaid', + 'flowchart TD', + ' A[HTTP request\\nwith ID param] --> B{Preceding tei:zone\\nwith @start=#pid?}', + '```', + ].join('\n'); + + const sanitized = sanitizeMermaidMarkdown(markdown); + + expect(sanitized).toContain('A[HTTP request
with ID param]'); + expect(sanitized).toContain('B{Preceding tei:zone
with @start=#pid?}'); + expect(sanitized).not.toContain('\\n'); + }); + + it('quotes unsafe edge labels without changing safe labels', () => { + const diagram = [ + 'graph LR', + ' Script -->|doc()| eXist[(eXist-db XML)]', + ' Client -->|HTTP params| Script', + ].join('\n'); + + const sanitized = sanitizeMermaidDiagram(diagram); + + expect(sanitized).toContain('Script -->|"doc()"| eXist[(eXist-db XML)]'); + expect(sanitized).toContain('Client -->|HTTP params| Script'); + }); + + it('escapes backslashes and quotes in quoted edge labels', () => { + const diagram = ['graph LR', ' Script -->|doc("C:\\\\tmp")| Target'].join('\n'); + + const sanitized = sanitizeMermaidDiagram(diagram); + + expect(sanitized).toContain('Script -->|"doc(\\"C:\\\\\\\\tmp\\")"| Target'); + }); + + it('aliases bare node IDs that contain dots and keeps display labels', () => { + const diagram = [ + 'graph LR', + ' Client -->|xmlurl + xslurl| xslt-conversion.xq', + ' xslt-conversion.xq -->|stream-transform| lbpwebjs-main.xsl', + ' lbpwebjs-main.xsl -->|fetches| TEI-XML[(TEI XML in eXist)]', + ].join('\n'); + + const sanitized = sanitizeMermaidDiagram(diagram); + + expect(sanitized).toContain( + 'Client -->|xmlurl + xslurl| xslt-conversion_xq["xslt-conversion.xq"]', + ); + expect(sanitized).toContain( + 'xslt-conversion_xq["xslt-conversion.xq"] -->|stream-transform| lbpwebjs-main_xsl["lbpwebjs-main.xsl"]', + ); + expect(sanitized).toContain( + 'lbpwebjs-main_xsl["lbpwebjs-main.xsl"] -->|fetches| TEI-XML[(TEI XML in eXist)]', + ); + }); + + it('aliases unsafe node IDs while preserving existing inline labels', () => { + const diagram = [ + 'graph LR', + ' file.name.ts[(eXist-db XML)] --> target.node["Target node"]', + ].join('\n'); + + const sanitized = sanitizeMermaidDiagram(diagram); + + expect(sanitized).toContain('file_name_ts[(eXist-db XML)] --> target_node["Target node"]'); + }); + + it('only rewrites fenced Mermaid blocks in markdown', () => { + const markdown = [ + 'Regular text with doc() and file.name.ts.', + '', + '```ts', + 'const label = "A\\nB";', + '```', + '', + '```mermaid', + 'flowchart LR', + ' A -->|doc()| file.name.ts', + '```', + ].join('\n'); + + const sanitized = sanitizeMermaidMarkdown(markdown); + + expect(sanitized).toContain('Regular text with doc() and file.name.ts.'); + expect(sanitized).toContain('const label = "A\\nB";'); + expect(sanitized).toContain('A -->|"doc()"| file_name_ts["file.name.ts"]'); + }); +});