From a5c582f547c93b7f07c16190d29adfd4d7d6efd0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 9 May 2026 19:26:53 +0100 Subject: [PATCH 1/8] chore(deps): bump actions/checkout from 5.0.0 to 6.0.2 (#1459) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/checkout](https://github.com/actions/checkout) from 5.0.0 to 6.0.2. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v5...de0fac2e4500dabe0009e67214ff5f5447ce83dd) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: 6.0.2 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Gergő Magyar --- .github/workflows/pr-autofix-apply.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-autofix-apply.yml b/.github/workflows/pr-autofix-apply.yml index b2ec8495f..73bf8ceb0 100644 --- a/.github/workflows/pr-autofix-apply.yml +++ b/.github/workflows/pr-autofix-apply.yml @@ -336,7 +336,7 @@ jobs: # Push auth is provided inline at push time via the URL. - name: Checkout PR head if: steps.locate.outputs.found == 'true' - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5.0.4 with: repository: ${{ steps.locate.outputs.head_repo }} ref: ${{ steps.locate.outputs.head_sha }} From a2517e050d58390829fb9867479b8e27d5777437 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 9 May 2026 19:51:12 +0100 Subject: [PATCH 2/8] chore(deps)(deps): bump lucide-react in /gitnexus-web (#1460) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) from 1.11.0 to 1.14.0. - [Release notes](https://github.com/lucide-icons/lucide/releases) - [Commits](https://github.com/lucide-icons/lucide/commits/1.14.0/packages/lucide-react) --- updated-dependencies: - dependency-name: lucide-react dependency-version: 1.14.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Gergő Magyar --- gitnexus-web/package-lock.json | 8 ++++---- gitnexus-web/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index 3a560ca5a..2d2fd8f45 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -28,7 +28,7 @@ "graphology-utils": "^2.3.0", "langchain": "^1.3.4", "lru-cache": "^11.2.4", - "lucide-react": "^1.11.0", + "lucide-react": "^1.14.0", "mermaid": "^11.14.0", "mnemonist": "^0.39.0", "pandemonium": "^2.4.0", @@ -6041,9 +6041,9 @@ } }, "node_modules/lucide-react": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.11.0.tgz", - "integrity": "sha512-UOhjdztXCgdBReRcIhsvz2siIBogfv/lhJEIViCpLt924dO+GDms9T7DNoucI23s6kEPpe988m5N0D2ajnzb2g==", + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.14.0.tgz", + "integrity": "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json index 51d7b0520..b0fcc035f 100644 --- a/gitnexus-web/package.json +++ b/gitnexus-web/package.json @@ -38,7 +38,7 @@ "graphology-utils": "^2.3.0", "langchain": "^1.3.4", "lru-cache": "^11.2.4", - "lucide-react": "^1.11.0", + "lucide-react": "^1.14.0", "mermaid": "^11.14.0", "mnemonist": "^0.39.0", "pandemonium": "^2.4.0", From 262ad8b8599254133783f82b918eca18b84f17b8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 10 May 2026 05:55:44 +0100 Subject: [PATCH 3/8] chore(deps)(deps-dev): bump @types/dompurify in /gitnexus-web (#1461) --- gitnexus-web/package-lock.json | 15 ++++++++------- gitnexus-web/package.json | 2 +- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index 2d2fd8f45..2ec38bf68 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -49,7 +49,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", - "@types/dompurify": "^3.0.5", + "@types/dompurify": "^3.2.0", "@types/node": "^25.6.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -2800,13 +2800,14 @@ "license": "MIT" }, "node_modules/@types/dompurify": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", - "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.2.0.tgz", + "integrity": "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==", + "deprecated": "This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.", "dev": true, "license": "MIT", "dependencies": { - "@types/trusted-types": "*" + "dompurify": "*" } }, "node_modules/@types/estree": { @@ -2909,8 +2910,8 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "devOptional": true, - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/@types/unist": { "version": "3.0.3", diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json index b0fcc035f..e7c0971a3 100644 --- a/gitnexus-web/package.json +++ b/gitnexus-web/package.json @@ -59,7 +59,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", - "@types/dompurify": "^3.0.5", + "@types/dompurify": "^3.2.0", "@types/node": "^25.6.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", From 874d2aa95b6acc05b1ea952de526ccf8213b7a54 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 10 May 2026 06:15:27 +0100 Subject: [PATCH 4/8] chore(deps)(deps): bump langchain from 1.3.4 to 1.3.5 in /gitnexus-web (#1462) --- gitnexus-web/package-lock.json | 24 +++++------------------- gitnexus-web/package.json | 2 +- 2 files changed, 6 insertions(+), 20 deletions(-) diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index 2ec38bf68..4a4b73349 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -26,7 +26,7 @@ "graphology-layout-forceatlas2": "^0.10.1", "graphology-layout-noverlap": "^0.4.2", "graphology-utils": "^2.3.0", - "langchain": "^1.3.4", + "langchain": "^1.3.5", "lru-cache": "^11.2.4", "lucide-react": "^1.14.0", "mermaid": "^11.14.0", @@ -5644,35 +5644,21 @@ "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" }, "node_modules/langchain": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/langchain/-/langchain-1.3.4.tgz", - "integrity": "sha512-umrD+ZC6vr0Q0U1lC5PoIMMQqgnP+7QhaIdAXCeZiSX2GPVBiVR7Ed2ZR+3MPvz1yWrRtIm17wPaovkND+wOXg==", + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/langchain/-/langchain-1.3.5.tgz", + "integrity": "sha512-QSB8TEo6G1tWupgNt1Osm8ylLLoOMq1lLw5NeijnIwRPI5BqdBjUn4/U8usbjEJJcQnbXSK2qTKgTx7zvblnBw==", "license": "MIT", "dependencies": { "@langchain/langgraph": "^1.2.9", "@langchain/langgraph-checkpoint": "^1.0.1", "langsmith": ">=0.5.0 <1.0.0", - "uuid": "^11.1.0", "zod": "^3.25.76 || ^4" }, "engines": { "node": ">=20" }, "peerDependencies": { - "@langchain/core": "^1.1.41" - } - }, - "node_modules/langchain/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" + "@langchain/core": "^1.1.42" } }, "node_modules/langium": { diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json index e7c0971a3..5913749b0 100644 --- a/gitnexus-web/package.json +++ b/gitnexus-web/package.json @@ -36,7 +36,7 @@ "graphology-layout-forceatlas2": "^0.10.1", "graphology-layout-noverlap": "^0.4.2", "graphology-utils": "^2.3.0", - "langchain": "^1.3.4", + "langchain": "^1.3.5", "lru-cache": "^11.2.4", "lucide-react": "^1.14.0", "mermaid": "^11.14.0", From 936a3bed6f2d25b758dbfdfec4db65cdeac33727 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Sun, 10 May 2026 08:33:47 +0100 Subject: [PATCH 5/8] chore: release v1.6.4 (#1473) * chore: release v1.6.4 * chore: expand v1.6.4 CHANGELOG with additional crash-issue closes --- gitnexus/CHANGELOG.md | 67 ++++++++++++++++++++++++++++++++++++++ gitnexus/package-lock.json | 4 +-- gitnexus/package.json | 2 +- 3 files changed, 70 insertions(+), 3 deletions(-) diff --git a/gitnexus/CHANGELOG.md b/gitnexus/CHANGELOG.md index ca51f0493..583ed97b3 100644 --- a/gitnexus/CHANGELOG.md +++ b/gitnexus/CHANGELOG.md @@ -4,6 +4,73 @@ All notable changes to GitNexus will be documented in this file. ## [Unreleased] +## [1.6.4] - 2026-05-10 + +### Added + +- **`gitnexus publish`** — opt-in command to push your indexed graph to the understand-quickly registry for shareable browsing (#1425) +- **`IncludeExtractor` for C++** — cross-repo include tracking joins the group contract pipeline (#1156) +- **Unreal Engine C++ support** — strips reflection macros (`UCLASS`, `UFUNCTION`, `UPROPERTY`, etc.) before tree-sitter parses, so UE projects index cleanly (#1439) +- **Thrift contracts extractor** — group-mode contract detection for Apache Thrift IDL (#1234) +- **Workspace extractors for Node, Python, Go, Java, Elixir** — group-mode auto-discovery of cross-package boundaries (#1260) +- **Rust workspace cross-crate contracts** — auto-discovery of `[workspace]` member crates and their cross-crate links (#1256) +- **Go scope-resolution hooks** — Go joins Python / C# / TypeScript on the registry-primary RFC #909 path (#1302) +- **TypeScript registry-primary scope resolution (Ring 3)** — TypeScript fully migrated to scope-based resolution (#1050) +- **Configurable group cross-link path exclusions** — reduces false-positive contract links in vendored / monorepo trees (#1093) +- **MCP tool safety annotations** — every MCP tool advertises read-only / mutating semantics so hosts can prompt appropriately (#1127) +- **`--embeddings ` opt-in cap** — bound the embeddings pass on huge graphs (closes #382, #1375) +- **Pino structured logger** — replaces ad-hoc console output across the core with structured JSON logs (with pretty-print for TTY) (#1336) +- **Shared resilient-fetch helper** — single retries + circuit breaker module reused by HF / Docker / publish flows (#1448) +- **`/autofix` ChatOps button** — fork-safe PR autofix pipeline replaces the inline reviewdog flow (#1446, #1458) +- **Automated security & vulnerability scans** in CI (#1297, #1455) + +### Fixed + +- **FTS read-only DB cluster** — hook resolves canonical repo root and guards read-only FTS ensure; missing-FTS warning is now surfaced. Closes #1255, #1287, #1170, #1449, #1440, #1216, #1438 (#1226, #1418, #1107, #1123) +- **WAL corruption recovery** — quarantine corrupted `.wal` files instead of failing analyze; CHECKPOINT before close prevents recurrence; `safeClose` consolidates flush. Closes #1402, #1236, #1273, #1361 (#1417, #1314, #1377) +- **Embedding download failures** — actionable HF_ENDPOINT guidance, retries, timeout, and circuit breaker; bridge `HF_ENDPOINT` to transformers.js; iterative DFS; HF cache via `os.homedir()`. Closes #1378, #1437, #1205 (#1419, #1252, #1078) +- **Windows reliability** — pin tree-sitter-c/cpp to fix segfault, prefer `.cmd`/`.bat` from `where` output, robust LadybugDB lock acquisition for CI integration tests, surface silent finalize-skips so analyze cannot exit 0 without persisting. Closes #1242, #1427, #1447, #1468, #1400; partial #1218 (#1243, #1299, #1430, #1237, #1226, #1235) +- **DuckDB / LadybugDB native** — bumped to 0.16.0 then 0.16.1; prevent extension install hangs; CHECKPOINT before close; WAL quarantine on corruption. Closes #1162, #1160, #273 (#1235, #1326, #1129, #1314, #1417) +- **C# scope-resolution "Cannot add property" crashes** — generic typed properties included in context and impact, fixing crashes on Unity ECS partial structs and on properties whose name matches the class name. Closes #1426, #1465 (#1399) +- **C# frozen-bucket regression** + scope-resolution I8 hardening — closes #1066 (#1082, #1085) +- **Scope resolution** — same-range Module-as-parent for top-level scopes (closes #1086) (#1087); avoid variadic reference-site aggregation (#1112); skip empty scope extraction (#1100); classify Python class methods as Method (#1102) +- **Python** — index repos with empty `__init__.py` and >32 KB files (#1163); walk ancestors for multi-segment dotted imports (#1241); deterministic multi-segment suffix fallback (#1253) +- **TypeScript** — capture missed CALLS edges from HOF callbacks and JSX (#1175); name HOC-wrapped const declarations (`forwardRef` / `memo` / `useCallback` / `useMemo` / `observer`) (#1261); pair-with-arrow `@declaration.function` anchored on inner arrow +- **Go** — loose equality for `Array.find()` null checks (#1384) +- **Swift** — switched to the official prebuilt parser runtime (#1130) +- **Server hardening cluster (U2–U8)** — JS path-injection on `/api/file` + docker-server (U2, #1322); git-clone path/CLI-injection / ReDoS hardening (U3, #1325); per-route rate limiting on FS-touching endpoints (U4, #1327); URL/regex/tag-filter sanitization (U7, #1330); ReDoS in cobol-preprocessor + rust-workspace + cross-impact resource exhaustion (U8, #1331); critical type-confusion + validation helper (#1317); rate-limit `/api/analyze` and `/api/embed` (closes #1328, #1339); IPv6 ipKeyGenerator (closes #1360, #1374); IPv4-compatible IPv6 / NAT64 SSRF bypasses in `validateGitUrl` (closes #1148, 95814847); predictable tempfile names → `crypto.randomBytes` (#1387); log-injection / http-to-file-access / client-side request forgery (#1456); pin Docker Node base images + Trivy verification + Dependabot policy (#1455) +- **Group / contracts** — `runExactMatch` honours `.gitnexusignore` via shared `IgnoreService` (closes #1185, #1247); custom manifest links resolved against graph symbols (#1254); `IgnoreService` EACCES test under uid=0 (#1108) +- **MCP** — close MCP server timeout via stdout discipline + cold-start friction (#1383); avoid `git` from non-repo cwd in sibling-cwd match (closes #1138, #1293); start MCP bridge correctly when using `npx` (#1114); project `tool_map` flows from handlers (#1113); parallelize staleness checks in `list_repos` (#1416) +- **Storage / CLI** — derive registry name from canonical repo root, not worktree slug (closes #1259, #1296); `--skip-git` treats cwd as index root (#1245); keep GitNexus ignores inside `.gitnexus/` (#1248); surface silent finalize-skips so `analyze` cannot exit 0 without persisting (closes #1169, #1237); ignore global registry during staleness checks (#1141); use `os.homedir()` instead of `process.env.HOME` for HF cache dir (#1078); correct OpenCode skills install path in status message (#1386) +- **Docker / server** — dedicated health endpoint for container healthcheck (closes #1147, #1355); HEAD probe so SSE heartbeat doesn't time out healthcheck (#1182); flush WAL after `/api/embed` so search sees new embeddings (closes #1149, #1359); platform-aware semantic fallback (#1150); skip vector index query on unsupported platforms (closes #1178, #1181); serve web UI at root path instead of 404 (#1048) +- **Worker pool** — wait for replacement worker online before dispatch (#1324); prevent premature pool resolution in worker split-and-retry path (#1321); recover worker parse stalls (#1121); widened CI flake-tolerant timeouts (#1323, #1347, #1354) +- **Embeddings storage** — CHECKPOINT before closing DB to prevent WAL corruption (#1314) +- **Performance** — replace O(n³) C3 merge loop with O(n²) head-pointer algorithm (#1316) +- **Install** — vendor tree-sitter-dart source (#1125) +- **Git utils** — suppress stderr leak in `getCurrentCommit` and `getGitRoot` (closes #1172, #1341) +- **Search** — load FTS during core DB init (#1123); create FTS indexes during `analyze` (#1107); surface warning when FTS indexes are missing (#1418) +- **Hooks** — clarify `PostToolUse` hook is notification-only, not auto-reindex (#1070) +- **Docs** — README Web UI section corrected (closes #1110, #1159, #2ff3e64f); Goliath capitalisation typo (#1126) +- **CI** — fork-safe PR autofix pipeline (#1446); consolidated Claude review workflow (#1258); fine-grained PAT for RC tag push (#1407); handle expired artifacts in base coverage fetch (#1410, #1412); allow expected legacy parity failures (#1099); avoid duplicate main push checks; isolate native LadybugDB / CLI e2e flakes; seed e2e with a small fixture repo (#1249); configure e2e GitNexus home at runtime; widen rate-limit test window for Windows CI (#1347) + +### Changed + +- **`gitnexus publish` artefact contract** — universal opt-in publish format introduced (#1425, #1458) +- **Refactor: per-language patterns consolidated into `LanguageProvider`** (#1279) +- **Refactor: `safeClose` helper** consolidates WAL flush across LadybugDB call sites (#1377) +- **Quality: exclude `test/fixtures` from CodeQL, ESLint, and Prettier** (#1313) +- **Regression coverage** for `.gitnexusignore` behaviour with `--skip-git` (#1450) + +### Chore / Dependencies + +- `@ladybugdb/core` 0.16.0 → 0.16.1 (#1235, #1326) +- `@anthropic-ai/sdk` (#1442), `@langchain/anthropic` (#1389), `@langchain/core` (#1394), `@langchain/openai` (#1215) +- `hono` 4.12.9 → 4.12.18 + `@hono/node-server` (#1310, #1311, #1443) +- `axios` (#1345), `fast-uri` 3.1.0 → 3.1.2 (#1441), `lru-cache` 11.3.5 → 11.3.6 (#1344), `mnemonist` 0.40.3 → 0.40.4 (#1239), `express-rate-limit` (#1343, #1397), `onnxruntime-node` (#1213, #1435), `uuid` 13 → 14 in /gitnexus-web (#1211, after revert #1222 / re-land #1250 + #1208) +- `react`/`@types/react` (#1210), `react-dom` 19.2.5 → 19.2.6 (#1396), `react-zoom-pan-pinch` (#1214), `jsdom` 29.0.2 → 29.1.1 (#1395) +- npm_and_yarn group bump (#1312), uv group bump (#1315), `python-dotenv` (#1320), `@types/node` (#1212, #1421, #1436) +- GitHub Actions: `docker/build-push-action` 6.19.2 → 7.1.0 (#1391), `github/codeql-action` 3.35.3 → 4.35.3 (#1390) + ## [1.6.3] - 2026-04-24 ### Added diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 04233b1a1..dfc5e3c4c 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -1,12 +1,12 @@ { "name": "gitnexus", - "version": "1.6.3", + "version": "1.6.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitnexus", - "version": "1.6.3", + "version": "1.6.4", "hasInstallScript": true, "license": "PolyForm-Noncommercial-1.0.0", "dependencies": { diff --git a/gitnexus/package.json b/gitnexus/package.json index 810052502..633c69f24 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -1,6 +1,6 @@ { "name": "gitnexus", - "version": "1.6.3", + "version": "1.6.4", "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.", "author": "Abhigyan Patwari", "license": "PolyForm-Noncommercial-1.0.0", From 4848dce9eac3d6cf0ba555ddc18d7b2f3fa80575 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Sun, 10 May 2026 09:31:47 +0100 Subject: [PATCH 6/8] test(u8): de-flake regex linearity assertions (#1475) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(u8): de-flake regex linearity assertions The single-trial 2x input + 3x ratio bound was razor-thin: a real macOS CI run failed at ratio 3.01x with small=7.41ms / large=22.31ms - both above the 5ms noise floor but close enough that single-shot scheduler jitter pushed the ratio over. Replace the methodology with four stacked techniques: 1. Warmup runs before timing (let the JIT tier up) 2. Median of 5 trials per measurement (eliminates GC + jitter) 3. 4x input ratio (was 2x) - linear gives ~4x, O(n^2) gives ~16x 4. 8x ratio bound with a 20ms noise floor on the LARGE measurement Headroom: linear is expected at ~4x, bound is 8x = 2x safety margin. A real O(n^2) regression on a 4x input would clock 16x, well outside. Catastrophic backtracking is still caught by the absolute <500ms cap. Verified: 10 consecutive local runs all passed. * test(u8): address PR #1475 review — tighten floor + rename for accuracy Two follow-ups from Claude's review: 1. Floor semantics: revert to 'skip when BOTH measurements below floor' (AND, not single-check) and lower threshold from 20ms back to 5ms. Median-of-5 makes 5ms reliably resolvable above performance.now()'s ~10-100us band, so the higher floor was unnecessary defense. Closes the gap where an O(n^2) regression on a fast runner could stay under 500ms AND below 20ms-large to escape both detectors. 2. Rename assertSubLinearRatio -> assertNearLinearScaling. The bound is SIZE_RATIO * 2 = 8x on a 4x input = sub-quadratic with 2x headroom over linear, not strict sub-linearity. New name reflects the actual semantics. --- .../unit/u8-redos-resource-exhaustion.test.ts | 169 ++++++++++-------- 1 file changed, 98 insertions(+), 71 deletions(-) diff --git a/gitnexus/test/unit/u8-redos-resource-exhaustion.test.ts b/gitnexus/test/unit/u8-redos-resource-exhaustion.test.ts index 6dd6d9955..fb5b66cb1 100644 --- a/gitnexus/test/unit/u8-redos-resource-exhaustion.test.ts +++ b/gitnexus/test/unit/u8-redos-resource-exhaustion.test.ts @@ -21,85 +21,112 @@ import { } from '../../src/core/group/cross-impact.js'; /** - * Time a single regex.exec call. Used by the linearity tests below to - * compute a 10k/5k ratio in addition to the absolute <500ms bound. + * Linearity-test methodology + * -------------------------- + * Wall-clock perf assertions in CI are notoriously flaky. To make these + * robust without losing regression-detection power, we combine four + * techniques: * - * Ratio assertions catch sub-exponential O(n²) regressions that fit - * inside the absolute cap on warm CI; the absolute cap catches - * catastrophic backtracking on cold CI. Two complementary signals. - */ -function timeRegex(re: RegExp, input: string): number { - // Reset regex.lastIndex for global/sticky regexes — ours are not, but - // be defensive in case future shape changes add the `g` flag. - re.lastIndex = 0; - const start = performance.now(); - re.exec(input); - return performance.now() - start; -} - -function timeFn(fn: () => T): number { - const start = performance.now(); - fn(); - return performance.now() - start; -} - -// Linear scaling is ~2.0× when input doubles; 3.0× allows generous -// slack for CI-runner GC and tier-up jitter. An O(n²) regression on a -// 2× input takes ~4× as long, well outside this bound. -const LINEAR_RATIO_BOUND = 3.0; - -/** - * Minimum elapsed time (in ms) below which `performance.now()` ratios - * are dominated by scheduler jitter and become meaningless. When both - * timed runs come in below this floor, we skip the ratio assertion — - * the absolute <500ms bound still catches catastrophic backtracking, - * and the next CI run will measure higher absolute times that the - * ratio assertion can evaluate reliably. + * 1. **Warmup** — run the function a few times before timing, so the + * JIT has tiered up by the time we measure. + * 2. **Median of N trials** — single measurements are dominated by + * GC pauses, scheduler jitter, and OS interrupts. Median of 5 + * eliminates almost all of that. + * 3. **4× input ratio** (not 2×) — linear → ~4×, O(n²) → ~16×, + * catastrophic → ≫16×. A wider input ratio gives a much bigger + * gap between "linear" and "regressed", so the bound can be loose + * enough to absorb noise without losing signal. + * 4. **Generous bound (8×)** with a noise floor — only assert the + * ratio when the *large* measurement is well above the noise + * floor. The absolute <500ms cap still catches catastrophic + * backtracking on cold CI even when the ratio is skipped. * - * Calibrated empirically: a flake on macOS reported ratio 5.29× - * between two sub-millisecond measurements (~0.5ms vs ~2.6ms), both - * genuinely linear but indistinguishable from noise. 5ms is a - * comfortable floor where individual measurements are well-separated - * from the ~10-100µs `performance.now()` resolution band. + * Headroom: linear is expected at ~4×; the bound is 8× → 2× headroom. + * O(n²) on a 4× input would clock 16×, well outside the bound. */ +const PERF_WARMUP_RUNS = 3; +const PERF_TRIAL_COUNT = 5; +const SIZE_RATIO = 4; +const LINEAR_RATIO_BOUND = SIZE_RATIO * 2; // 8× — 2× headroom over expected linear +// Median-of-N tightens the noise floor we can rely on. A single-sample 5ms +// measurement is ~50% jitter; median-of-5 brings the same 5ms into the +// reliably-resolvable range above `performance.now()`'s ~10-100µs band. const RATIO_MEASUREMENT_FLOOR_MS = 5; +function median(samples: number[]): number { + const sorted = [...samples].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]; +} + /** - * Assert linear scaling between two timed runs on inputs that differ - * by 2×. When measurements are too small to be reliable, the ratio - * assertion is skipped (the absolute bound still fires elsewhere). + * Median time of `PERF_TRIAL_COUNT` runs of `fn`, after `PERF_WARMUP_RUNS` + * warmup iterations. Trial cost: (warmup + trials) × fn cost. */ -function assertSubLinearRatio(elapsedSmall: number, elapsedLarge: number, label: string): void { +function medianTimeFn(fn: () => T): number { + for (let i = 0; i < PERF_WARMUP_RUNS; i++) fn(); + const samples: number[] = []; + for (let i = 0; i < PERF_TRIAL_COUNT; i++) { + const start = performance.now(); + fn(); + samples.push(performance.now() - start); + } + return median(samples); +} + +/** Median time of regex.exec — defensively resets lastIndex each call. */ +function medianTimeRegex(re: RegExp, input: string): number { + return medianTimeFn(() => { + re.lastIndex = 0; + re.exec(input); + }); +} + +/** + * Assert near-linear scaling between two median-timed runs on inputs + * that differ by `SIZE_RATIO`×. The bound is `LINEAR_RATIO_BOUND` = + * `SIZE_RATIO * 2`, i.e. 2× headroom over the linear expectation — + * comfortably under the ~`SIZE_RATIO²` ratio a quadratic regression + * would produce, so true regressions still fail loudly. + * + * Skip semantics: the ratio assertion is skipped only when *both* + * measurements are below the noise floor. If either run is reliably + * measurable, we still assert — otherwise an O(n²) regression that + * happens to stay under the absolute 500ms cap on a fast runner could + * slip through with no detector firing. Median-of-N + the 5ms floor + * keeps the assertion stable while preserving regression coverage. + */ +function assertNearLinearScaling(elapsedSmall: number, elapsedLarge: number, label: string): void { if (elapsedSmall < RATIO_MEASUREMENT_FLOOR_MS && elapsedLarge < RATIO_MEASUREMENT_FLOOR_MS) { - // Both runs completed faster than the noise floor — the ratio is - // not meaningful. The absolute <500ms bound elsewhere in this - // describe block still pins linearity; we skip rather than risk a - // flake on a genuinely-linear implementation. + // Both runs completed below the noise floor — even the median is + // dominated by `performance.now()` resolution. The absolute <500ms + // cap elsewhere still catches catastrophic backtracking. return; } const ratio = elapsedLarge / Math.max(elapsedSmall, 0.001); if (ratio >= LINEAR_RATIO_BOUND) { throw new Error( `${label}: ratio ${ratio.toFixed(2)}× exceeds bound ${LINEAR_RATIO_BOUND}× ` + - `(small=${elapsedSmall.toFixed(2)}ms, large=${elapsedLarge.toFixed(2)}ms)`, + `on ${SIZE_RATIO}× input (small=${elapsedSmall.toFixed(2)}ms, ` + + `large=${elapsedLarge.toFixed(2)}ms, median of ${PERF_TRIAL_COUNT} trials)`, ); } } describe('cobol-preprocessor RE_SET_TO_TRUE — linear time on pathological input', () => { - it('matches in <500ms on 50k repetitions of "A OF A " AND 100k/50k ratio is sub-linear when measurable', () => { - // 50k/100k repetitions chosen so timings exceed the - // RATIO_MEASUREMENT_FLOOR_MS noise floor on typical CI hardware. - // Pre-fix nested-quantifier shape would be exponential here; the - // post-fix `.+?` shape is linear (~2× when input doubles). + it('matches in <500ms on 50k repetitions of "A OF A " AND scales sub-linearly on a 4× input', () => { + // 50k → 200k (4× input ratio). Pre-fix nested-quantifier shape would + // be exponential here; the post-fix `.+?` shape is linear (~4× when + // input quadruples). Median of 5 trials with warmup eliminates GC + // and tier-up jitter. const inputSmall = 'SET ' + 'A OF A '.repeat(50_000) + 'TO TRUE'; - const inputLarge = 'SET ' + 'A OF A '.repeat(100_000) + 'TO TRUE'; - const elapsedSmall = timeRegex(RE_SET_TO_TRUE, inputSmall); - const elapsedLarge = timeRegex(RE_SET_TO_TRUE, inputLarge); + const inputLarge = 'SET ' + 'A OF A '.repeat(50_000 * SIZE_RATIO) + 'TO TRUE'; + const elapsedSmall = medianTimeRegex(RE_SET_TO_TRUE, inputSmall); + const elapsedLarge = medianTimeRegex(RE_SET_TO_TRUE, inputLarge); expect(RE_SET_TO_TRUE.exec(inputSmall)).not.toBeNull(); expect(elapsedSmall).toBeLessThan(500); expect(elapsedLarge).toBeLessThan(500); - assertSubLinearRatio(elapsedSmall, elapsedLarge, 'RE_SET_TO_TRUE'); + assertNearLinearScaling(elapsedSmall, elapsedLarge, 'RE_SET_TO_TRUE'); }); it('still matches a normal SET ... TO TRUE statement', () => { @@ -110,17 +137,17 @@ describe('cobol-preprocessor RE_SET_TO_TRUE — linear time on pathological inpu }); describe('cobol-preprocessor RE_SET_INDEX — linear time on pathological input', () => { - it('rejects in <500ms on 50k tokens with no valid suffix AND 100k/50k ratio is sub-linear when measurable', () => { + it('rejects in <500ms on 50k tokens with no valid suffix AND scales sub-linearly on a 4× input', () => { // Forces backtracking against the (TO|UP\s+BY|DOWN\s+BY) alternation // — the richer pathological surface of the two regexes. const inputSmall = 'SET ' + 'A '.repeat(50_000) + 'X'; - const inputLarge = 'SET ' + 'A '.repeat(100_000) + 'X'; - const elapsedSmall = timeRegex(RE_SET_INDEX, inputSmall); - const elapsedLarge = timeRegex(RE_SET_INDEX, inputLarge); + const inputLarge = 'SET ' + 'A '.repeat(50_000 * SIZE_RATIO) + 'X'; + const elapsedSmall = medianTimeRegex(RE_SET_INDEX, inputSmall); + const elapsedLarge = medianTimeRegex(RE_SET_INDEX, inputLarge); expect(RE_SET_INDEX.exec(inputSmall)).toBeNull(); expect(elapsedSmall).toBeLessThan(500); expect(elapsedLarge).toBeLessThan(500); - assertSubLinearRatio(elapsedSmall, elapsedLarge, 'RE_SET_INDEX'); + assertNearLinearScaling(elapsedSmall, elapsedLarge, 'RE_SET_INDEX'); }); it('still matches a normal SET INDEX statement', () => { @@ -133,22 +160,22 @@ describe('cobol-preprocessor RE_SET_INDEX — linear time on pathological input' }); describe('rust-workspace parseCargoPackageName — linear-time line walk', () => { - it('extracts the package name in <500ms on 100k blank lines AND 200k/100k ratio is sub-linear when measurable', () => { - // 100k/200k blank lines chosen so timings exceed the - // RATIO_MEASUREMENT_FLOOR_MS noise floor. Earlier 10k/20k pairing - // produced sub-millisecond measurements where scheduler jitter - // dominated and the ratio became meaningless (a real macOS run - // saw 5.29× between two genuinely-linear sub-ms measurements). + it('extracts the package name in <500ms on 100k blank lines AND scales sub-linearly on a 4× input', () => { + // 100k → 400k blank lines (4× input ratio). Median of 5 trials with + // warmup keeps the ratio stable across CI runners. A previous 2× + // input + 3× bound + single-trial setup flaked at 3.01× on macOS + // (small=7.41ms, large=22.31ms) — both above the noise floor but + // close enough that single-shot jitter pushed the ratio over. const cargoTomlSmall = '[package]\n' + '\n'.repeat(100_000) + 'name = "myrepo"\nversion = "0.1.0"\n'; const cargoTomlLarge = - '[package]\n' + '\n'.repeat(200_000) + 'name = "myrepo"\nversion = "0.1.0"\n'; - const elapsedSmall = timeFn(() => parseCargoPackageName(cargoTomlSmall)); - const elapsedLarge = timeFn(() => parseCargoPackageName(cargoTomlLarge)); + '[package]\n' + '\n'.repeat(100_000 * SIZE_RATIO) + 'name = "myrepo"\nversion = "0.1.0"\n'; + const elapsedSmall = medianTimeFn(() => parseCargoPackageName(cargoTomlSmall)); + const elapsedLarge = medianTimeFn(() => parseCargoPackageName(cargoTomlLarge)); expect(parseCargoPackageName(cargoTomlSmall)).toBe('myrepo'); expect(elapsedSmall).toBeLessThan(500); expect(elapsedLarge).toBeLessThan(500); - assertSubLinearRatio(elapsedSmall, elapsedLarge, 'parseCargoPackageName'); + assertNearLinearScaling(elapsedSmall, elapsedLarge, 'parseCargoPackageName'); }); it('returns null when [package] section is absent', () => { From 5d670a530deca109ca4a73481645acb7cc4b0690 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Sun, 10 May 2026 09:50:58 +0100 Subject: [PATCH 7/8] ci(release): skip rc build on release PRs (#1474) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci(release): skip rc build on release PRs Suppress the auto-fired Release Candidate workflow when: 1. The HEAD commit subject matches `chore: release vX.Y.Z` (the canonical release-PR title), or 2. The squash-merged PR carries the `release` label. Either match short-circuits the guard to should_run=false. This prevents the rc cycle from racing publish.yml on the v-tag (as happened on v1.6.4 where we had to manually cancel the auto-fired RC run after merging PR #1473). Adds pull-requests: read to the guard job for the label lookup. A failed gh API call falls through to the existing dedup logic rather than silently suppressing rc builds. * ci(release): address PR #1474 review — anchor regex + sanitise log echo Two minor follow-ups from Claude's review: 1. End-anchor the release-subject regex. The previous shape ^chore: release vX.Y.Z would match noisy variants like chore: release v1.0.0 (something unrelated). The new shape requires either the bare title or the canonical squash-merge (#NNNN) suffix exactly. 2. Sanitise HEAD_SUBJECT before echoing to logs. git %s strips newlines so LF injection is impossible, but a hypothetical subject containing ::error:: or ::set-output:: could otherwise forge GitHub Actions annotation entries. Defence-in-depth. Both findings flagged minor / does not block merge — applying anyway since they are trivial. --- .github/workflows/release-candidate.yml | 46 +++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index ff75bca26..2129e765a 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -58,6 +58,7 @@ jobs: timeout-minutes: 5 permissions: contents: read + pull-requests: read # read PR labels on the merge commit outputs: should_run: ${{ steps.decide.outputs.should_run }} head_sha: ${{ steps.decide.outputs.head_sha }} @@ -74,6 +75,8 @@ jobs: FORCE: ${{ inputs.force }} BUMP_INPUT: ${{ inputs.bump }} EVENT_NAME: ${{ github.event_name }} + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} run: | set -euo pipefail HEAD_SHA=$(git rev-parse HEAD) @@ -96,6 +99,49 @@ jobs: exit 0 fi + # ── Skip when the merge commit corresponds to a release ───────── + # Two complementary checks (belt-and-suspenders): + # 1. The HEAD commit subject matches `chore: release vX.Y.Z` + # (the canonical release-PR title in this repo). Anchored + # at both ends to require the bare title or the squash-merge + # `(#NNNN)` suffix exactly — rejects noisy variants like + # `chore: release v1.0.0 (something unrelated)`. + # 2. The squash-merged PR carries the `release` label. + # Either match suppresses the rc build — stable releases publish + # via publish.yml on the v-tag, so the rc cycle should pause for + # them rather than racing the npm publish. + HEAD_SUBJECT="$(git log -1 --pretty=%s HEAD)" + # Sanitise GitHub-Actions annotation prefixes before logging the + # raw subject — defence-in-depth so a hypothetical commit subject + # containing `::error::` or `::set-output::` cannot forge log + # annotations even though %s strips newlines. + HEAD_SUBJECT_SAFE="${HEAD_SUBJECT//::/__}" + RELEASE_SUBJECT_RE='^chore:[[:space:]]*release[[:space:]]+v[0-9]+\.[0-9]+\.[0-9]+([[:space:]]+\(#[0-9]+\))?$' + if [[ "$HEAD_SUBJECT" =~ $RELEASE_SUBJECT_RE ]]; then + echo "HEAD commit subject matches a release commit — skipping rc." + echo " subject (sanitised): $HEAD_SUBJECT_SAFE" + echo "should_run=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Squash-merge commits include `(#NNNN)` at the end of the subject. + if [[ "$HEAD_SUBJECT" =~ \(#([0-9]+)\)[[:space:]]*$ ]]; then + PR_NUM="${BASH_REMATCH[1]}" + echo "Detected squash-merge of PR #$PR_NUM — checking labels." + if LABELS_JSON="$(gh pr view "$PR_NUM" --repo "$REPO" --json labels 2>/dev/null)"; then + if printf '%s' "$LABELS_JSON" | jq -e '.labels[] | select(.name == "release")' >/dev/null; then + echo "PR #$PR_NUM has the 'release' label — skipping rc." + echo "should_run=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "PR #$PR_NUM has no 'release' label — proceeding." + else + # Lookup failure is not fatal — fall through to the dedup check + # so a transient GH API hiccup doesn't silently suppress rc builds. + echo "::warning::Could not read labels for PR #${PR_NUM} — falling through." + fi + fi + # Dedup: is there already an rc/ marker pointing at HEAD? MARKER="rc/${HEAD_SHA}" if git rev-parse "refs/tags/$MARKER" >/dev/null 2>&1; then From 2620b704e091f104570934bc7c10489fbea69525 Mon Sep 17 00:00:00 2001 From: Abhigyan Patwari <126312502+abhigyanpatwari@users.noreply.github.com> Date: Sun, 10 May 2026 13:29:06 +0100 Subject: [PATCH 8/8] feat(cursor): upgrade hooks to Cursor 2.4 postToolUse for Read/Grep/Shell coverage (#1467) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(cursor): upgrade hooks to Cursor 2.4 postToolUse for Read/Grep/Shell coverage Cursor 2.4 (released 2026-01-22) shipped generic preToolUse/postToolUse hooks matching `Shell|Read|Write|Grep|Delete|Task|MCP:`, replacing the 2.3-era beforeShellExecution hook that only fired on shell commands. The existing integration only intercepted the shell path, so Cursor users got graph augmentation roughly 10% as often as Claude Code users — only when the agent dropped to rg/grep instead of using its native Read/Grep tools. This swaps the integration over to postToolUse and ports the bash+jq hook script to cross-platform Node: - gitnexus-cursor-integration/hooks/hooks.json: registers a single postToolUse hook matching Shell|Read|Grep that invokes the new gitnexus-hook.cjs. - gitnexus-cursor-integration/hooks/gitnexus-hook.cjs: new Node hook mirroring the safety patterns from the Claude hook (absolute-cwd validation, .gitnexus discovery with linked-worktree fallback, npx.cmd on Windows, end-of-options `--` marker, debug truncation, graceful failure). Extracts the search pattern per tool kind: Grep -> toolInput.query; Read -> file basename stripped to identifier chars; Shell -> existing rg/grep arg parser. Emits Cursor-shape `{ "additional_context": "..." }` on stdout — no shell, no jq. - gitnexus-cursor-integration/hooks/augment-shell.sh: removed (Windows incompatible, narrower coverage). - gitnexus/test/unit/cursor-hook.test.ts: 33 regression tests covering manifest wiring, source-level invariants (no shell:true, npx.cmd, isAbsolute, additional_context output shape, end-of-options marker), extractPattern coverage per tool, and behavioral early-exit paths (empty/invalid stdin, relative cwd, no .gitnexus, unknown tool name, short patterns, non-search shell commands, case-insensitive matching). - README.md / gitnexus/README.md: editor-support table now lists Cursor as Full / hooks=Yes (postToolUse), matching reality. - gitnexus/src/cli/augment.ts and gitnexus/src/core/augmentation/engine.ts: doc-strings updated from `Cursor beforeShellExecution` to `Cursor postToolUse`. Closes #1466. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(cursor): hook timeout is in seconds, not milliseconds Cursor's `timeout` field in hooks.json is in seconds (per https://cursor.com/docs/agent/hooks and the original integration's `"timeout": 5`). I'd written `10000` after blindly copying the issue body's example — that resolves to ~2.8 hours, not 10 seconds. If the script ever hangs before reaching its inner spawnSync timeouts (e.g. during stdin read), Cursor would have waited that long before killing it. Drop to `10` (seconds), matching the Claude plugin's hooks.json and giving plenty of headroom over the inner 7s augment-CLI timeout. Add a regression-guard assertion in cursor-hook.test.ts so a future ms/s mixup fails fast. Reported by Cursor Bugbot on PR #1467. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(cursor): address Claude review findings — payload aliases, debug, install docs Resolves three findings from Claude reviewer on PR #1467: 1. Cursor payload field-name uncertainty (SIGNIFICANT) Claude flagged that the Grep `query` field is an unverified assumption per Cursor 2.4 docs (https://cursor.com/docs/agent/hooks). Mitigated: - Expanded Grep aliases: query | pattern | regex | q | search | searchQuery - Added pickLongestStringValue() last-resort fallback so the hook extracts *something* even if Cursor renames every documented field - Added GITNEXUS_DEBUG=1 stderr logging of the raw stdin payload so users can capture Cursor's actual contract when diagnosing silent no-ops, and report it back if aliases drift - Added Read alias `filePath` (camelCase variant alongside `file_path`) - Inline comment block citing the docs URL and the uncertainty 2. Hook command path resolution + install docs (SIGNIFICANT) Claude flagged `node ./hooks/gitnexus-hook.cjs` as relative without documented install path. Added gitnexus-cursor-integration/README.md with explicit install steps: - .cursor/hooks.json + hooks/gitnexus-hook.cjs at project root - Confirms Cursor's project-root CWD convention with doc link - Verify steps including GITNEXUS_DEBUG capture - Pattern-extraction contract table per tool - Troubleshooting: not-firing, npx fallback, wrong-pattern diagnosis 3. README "Full" overclaim for Cursor (MODERATE) Both README rows now read `Yes (postToolUse, manual install)` linking to the new install README, accurately signaling that hooks aren't automated by `gitnexus setup` like they are for Claude Code. 4. Shell quoted-pattern parser limitation (MINOR, documented) Added inline comment in gitnexus-hook.cjs documenting the known `rg "User Service"` -> `User` truncation, plus regression tests in cursor-hook.test.ts pinning the behavior so a future change is visible. Test additions (33 -> 41): - Wide-alias source coverage for Grep (query / pattern / regex / q / search / searchQuery) plus pickLongestStringValue fallback - Read alias coverage including camelCase filePath - GITNEXUS_DEBUG behavioral test: stderr quiet by default, payload echoed when env var set, stdout output contract preserved either way - Shell quoted-pattern documented behavior tests - Install README presence + content (.cursor/hooks.json, hooks/, debug diagnostics) Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: Gergő Magyar --- README.md | 2 +- gitnexus-cursor-integration/README.md | 89 ++++ .../hooks/augment-shell.sh | 50 -- .../hooks/gitnexus-hook.cjs | 259 ++++++++++ gitnexus-cursor-integration/hooks/hooks.json | 8 +- gitnexus/README.md | 2 +- gitnexus/src/cli/augment.ts | 2 +- gitnexus/src/core/augmentation/engine.ts | 4 +- gitnexus/test/unit/cursor-hook.test.ts | 458 ++++++++++++++++++ 9 files changed, 815 insertions(+), 59 deletions(-) create mode 100644 gitnexus-cursor-integration/README.md delete mode 100644 gitnexus-cursor-integration/hooks/augment-shell.sh create mode 100644 gitnexus-cursor-integration/hooks/gitnexus-hook.cjs create mode 100644 gitnexus/test/unit/cursor-hook.test.ts diff --git a/README.md b/README.md index 6beadb63d..3c3a28c27 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ To configure MCP for your editor, run `npx gitnexus setup` once — or set it up | Editor | MCP | Skills | Hooks (auto-augment) | Support | | --------------------- | --- | ------ | -------------------- | -------------- | | **Claude Code** | Yes | Yes | Yes (PreToolUse + PostToolUse) | **Full** | -| **Cursor** | Yes | Yes | — | MCP + Skills | +| **Cursor** | Yes | Yes | Yes (postToolUse, [manual install](gitnexus-cursor-integration/README.md#hook-install)) | **Full** | | **Codex** | Yes | Yes | — | MCP + Skills | | **Windsurf** | Yes | — | — | MCP | | **OpenCode** | Yes | Yes | — | MCP + Skills | diff --git a/gitnexus-cursor-integration/README.md b/gitnexus-cursor-integration/README.md new file mode 100644 index 000000000..0da8b1981 --- /dev/null +++ b/gitnexus-cursor-integration/README.md @@ -0,0 +1,89 @@ +# GitNexus — Cursor integration + +Static config that adds GitNexus knowledge-graph augmentation and skill files to Cursor. + +> **Hooks require Cursor 2.4+.** Earlier versions don't expose `postToolUse` and the hook will silently no-op. + +## What you get + +| Layer | What it does | How it's installed | +| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| **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/`. | + +## 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**: + +```text +/ +├── .cursor/ +│ └── hooks.json ← from gitnexus-cursor-integration/hooks/hooks.json +└── hooks/ + └── gitnexus-hook.cjs ← from gitnexus-cursor-integration/hooks/gitnexus-hook.cjs +``` + +Equivalent shell commands (run from your project root, with `$GITNEXUS_REPO` pointing at a clone of this repo): + +```bash +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 +``` + +If you already have a `.cursor/hooks.json`, merge the `hooks.postToolUse` array rather than overwriting. + +### Verify + +1. Index the project: `npx gitnexus analyze` +2. Reload the Cursor window so it picks up the new hook config. +3. Ask the agent something that triggers `Read` / `Grep` / `Shell rg`. You should see a `[GitNexus]` block appended to the tool result. +4. Diagnose silent no-ops by setting `GITNEXUS_DEBUG=1` in your shell environment — the hook will write Cursor's raw event payload to stderr so you can verify field names. + +### What's installed manually vs. automated + +| Step | Automated by `gitnexus setup`? | +| -------------------------------------------------------------------- | ------------------------------ | +| `~/.cursor/mcp.json` | ✅ | +| `~/.cursor/skills/gitnexus/*` | ✅ | +| `/.cursor/hooks.json` + `/hooks/gitnexus-hook.cjs` | ❌ — copy manually (see above) | + +Hook install is per-project (Cursor scopes hooks to a project root); skills and MCP config are global. + +## Hook contract + +The hook receives a JSON event on stdin matching Cursor 2.4's `postToolUse` shape: + +```json +{ + "tool_name": "Grep" | "Read" | "Shell", + "tool_input": { /* tool-specific */ }, + "tool_output": { /* optional */ }, + "cwd": "/absolute/path/to/project" +} +``` + +It writes augmentation context to stdout as: + +```json +{ "additional_context": "[GitNexus] …" } +``` + +Empty stdout means "no augmentation, continue normally" — the hook never blocks the tool. + +### Pattern extraction per tool + +| Tool | Pattern source | Notes | +| ------- | ---------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| `Grep` | `tool_input.query` (also `pattern`, `regex`, `q`, `search`, `searchQuery`) | Last-resort fallback: longest string value in `tool_input` (≥ 3 chars). | +| `Read` | basename of `tool_input.target_file` (also `file_path`, `filePath`, `path`, `file`), stripped to identifier characters | `auth/handler.ts` → `handler`. | +| `Shell` | First positional argument after `rg` / `grep` in `tool_input.command` | Best-effort tokenizer; quoted multi-word patterns (`rg "User Service"`) extract the first word only. | + +## 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. +- **`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/augment-shell.sh b/gitnexus-cursor-integration/hooks/augment-shell.sh deleted file mode 100644 index 48ea185b0..000000000 --- a/gitnexus-cursor-integration/hooks/augment-shell.sh +++ /dev/null @@ -1,50 +0,0 @@ -#!/bin/bash -# GitNexus beforeShellExecution hook for Cursor -# Receives JSON on stdin with { command, cwd, timeout } -# Returns JSON on stdout with { permission, agent_message } -# -# Extracts search pattern from grep/rg commands, runs gitnexus augment, -# and injects the enriched context via agent_message. - -INPUT=$(cat) - -COMMAND=$(echo "$INPUT" | jq -r '.command // empty' 2>/dev/null) - -if [ -z "$COMMAND" ]; then - echo '{"permission":"allow"}' - exit 0 -fi - -# Skip non-search commands -case "$COMMAND" in - cd\ *|npm\ *|yarn\ *|pnpm\ *|git\ commit*|git\ push*|git\ pull*|mkdir\ *|rm\ *|cp\ *|mv\ *|echo\ *|cat\ *) - echo '{"permission":"allow"}' - exit 0 - ;; -esac - -# Extract search pattern from rg/grep commands -PATTERN="" -if echo "$COMMAND" | grep -qE '\brg\b'; then - PATTERN=$(echo "$COMMAND" | sed -n "s/.*\brg\s\+\(--[^ ]*\s\+\)*['\"]\\?\([^'\";\| >]*\\).*/\2/p") -elif echo "$COMMAND" | grep -qE '\bgrep\b'; then - PATTERN=$(echo "$COMMAND" | sed -n "s/.*\bgrep\s\+\(-[^ ]*\s\+\)*['\"]\\?\([^'\";\| >]*\\).*/\2/p") -fi - -if [ -z "$PATTERN" ] || [ ${#PATTERN} -lt 3 ]; then - echo '{"permission":"allow"}' - exit 0 -fi - -# Run gitnexus augment -RESULT=$(npx -y gitnexus augment "$PATTERN" 2>/dev/null) - -if [ -n "$RESULT" ]; then - # Escape for JSON - ESCAPED=$(echo "$RESULT" | jq -Rs .) - echo "{\"permission\":\"allow\",\"agent_message\":$ESCAPED}" -else - echo '{"permission":"allow"}' -fi - -exit 0 diff --git a/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs b/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs new file mode 100644 index 000000000..0ea336619 --- /dev/null +++ b/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs @@ -0,0 +1,259 @@ +#!/usr/bin/env node +/** + * GitNexus Cursor postToolUse Hook + * + * Receives a JSON event on stdin describing a finished tool call, derives a + * search pattern (Grep query, Read file basename, or rg/grep arg from a Shell + * command), runs `gitnexus augment `, and emits the enriched context + * back as `{ additional_context: "..." }` so the agent sees it alongside the + * tool result. + * + * Replaces the legacy beforeShellExecution / augment-shell.sh pipeline: + * - Cross-platform (no bash, no jq — runs on Windows out of the box) + * - Covers Read and Grep, not just Shell rg/grep + * + * Cursor 2.4+ generic hooks: https://cursor.com/docs/agent/hooks + */ + +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +function readInput() { + try { + const data = fs.readFileSync(0, 'utf-8'); + return JSON.parse(data); + } catch { + return {}; + } +} + +function isGlobalRegistryDir(candidate) { + if (fs.existsSync(path.join(candidate, 'meta.json'))) return false; + return ( + fs.existsSync(path.join(candidate, 'registry.json')) || + fs.existsSync(path.join(candidate, 'repos')) + ); +} + +function walkForGitNexusDir(startDir) { + let dir = startDir; + for (let i = 0; i < 5; i++) { + const candidate = path.join(dir, '.gitnexus'); + if (fs.existsSync(candidate)) { + if (!isGlobalRegistryDir(candidate)) return candidate; + } + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return null; +} + +function findCanonicalRepoRoot(cwd) { + try { + const result = spawnSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], { + encoding: 'utf-8', + timeout: 2000, + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + }); + if (result.error || result.status !== 0) return null; + const commonDir = (result.stdout || '').trim(); + if (!commonDir || !path.isAbsolute(commonDir)) return null; + return path.dirname(commonDir); + } catch { + return null; + } +} + +function findGitNexusDir(startDir) { + const cwd = startDir || process.cwd(); + const fromCwd = walkForGitNexusDir(cwd); + if (fromCwd) return fromCwd; + const canonicalRoot = findCanonicalRepoRoot(cwd); + if (canonicalRoot && canonicalRoot !== cwd) { + return walkForGitNexusDir(canonicalRoot); + } + return null; +} + +function parseRgGrepPattern(cmd) { + const tokens = cmd.split(/\s+/); + let foundCmd = false; + let skipNext = false; + const flagsWithValues = new Set([ + '-e', + '-f', + '-m', + '-A', + '-B', + '-C', + '-g', + '--glob', + '-t', + '--type', + '--include', + '--exclude', + ]); + + for (const token of tokens) { + if (skipNext) { + skipNext = false; + continue; + } + if (!foundCmd) { + if (/\brg$|\bgrep$/.test(token)) foundCmd = true; + continue; + } + if (token.startsWith('-')) { + if (flagsWithValues.has(token)) skipNext = true; + continue; + } + const cleaned = token.replace(/['"]/g, ''); + return cleaned.length >= 3 ? cleaned : null; + } + return null; +} + +/** + * Extract a search pattern from the tool input. Cursor 2.4 docs at + * https://cursor.com/docs/agent/hooks list the tool *matchers* but do not + * formally specify the per-tool tool_input field names, so we probe a + * generous set of MCP-style aliases. As a last-resort fallback for Grep + * (the highest-frequency search path) we also accept the longest plausible + * string value in tool_input. Set GITNEXUS_DEBUG=1 to log the raw payload + * to stderr if Cursor changes the contract and aliases stop matching. + */ +function pickLongestStringValue(obj) { + let best = null; + if (!obj || typeof obj !== 'object') return null; + for (const v of Object.values(obj)) { + if (typeof v === 'string' && v.length >= 3 && (!best || v.length > best.length)) { + best = v; + } + } + return best; +} + +function extractPattern(toolName, toolInput) { + const t = (toolName || '').toLowerCase(); + + if (t === 'grep') { + const aliases = [ + toolInput.query, + toolInput.pattern, + toolInput.regex, + toolInput.q, + toolInput.search, + toolInput.searchQuery, + ]; + for (const a of aliases) { + if (typeof a === 'string' && a.length >= 3) return a; + } + // Last resort: scan tool_input for any reasonable-looking string value. + return pickLongestStringValue(toolInput); + } + + if (t === 'read') { + const filePath = + toolInput.target_file || + toolInput.file_path || + toolInput.filePath || + toolInput.path || + toolInput.file || + ''; + if (!filePath) return null; + const base = path.basename(String(filePath), path.extname(String(filePath))); + const cleaned = base.replace(/[^a-zA-Z0-9_]/g, ''); + return cleaned.length >= 3 ? cleaned : null; + } + + if (t === 'shell') { + const cmd = toolInput.command || ''; + if (!/\brg\b|\bgrep\b/.test(cmd)) return null; + // NOTE: parseRgGrepPattern uses split(/\s+/) and cannot handle shell + // quoting. `rg "User Service" src/` returns "User" (the first token + // after the rg/grep arg, with surrounding quotes stripped) — the + // multi-word pattern is intentionally not reconstructed since BM25 is + // already token-tolerant. Quoted single tokens (`rg "validateUser"`) + // work fine. + return parseRgGrepPattern(cmd); + } + + return null; +} + +function resolveCliPath() { + try { + return require.resolve('gitnexus/dist/cli/index.js'); + } catch { + return ''; + } +} + +function runGitNexusCli(cliPath, args, cwd, timeout) { + const isWin = process.platform === 'win32'; + if (cliPath) { + return spawnSync(process.execPath, [cliPath, ...args], { + encoding: 'utf-8', + timeout, + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + }); + } + return spawnSync(isWin ? 'npx.cmd' : 'npx', ['-y', 'gitnexus', ...args], { + encoding: 'utf-8', + timeout: timeout + 5000, + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + }); +} + +function main() { + try { + const input = readInput(); + if (process.env.GITNEXUS_DEBUG) { + // Echo the payload so users can capture Cursor's actual contract when + // diagnosing why augmentation isn't firing. Stderr only — stdout is + // reserved for the JSON response Cursor consumes. + try { + process.stderr.write( + `GitNexus Cursor hook stdin: ${JSON.stringify(input).slice(0, 500)}\n`, + ); + } catch { + /* never let debug logging break the hook */ + } + } + const cwd = input.cwd || process.cwd(); + if (!path.isAbsolute(cwd)) return; + if (!findGitNexusDir(cwd)) return; + + const toolName = input.tool_name || ''; + const toolInput = input.tool_input || {}; + + const pattern = extractPattern(toolName, toolInput); + if (!pattern || pattern.length < 3) return; + + const cliPath = resolveCliPath(); + let result = ''; + try { + const child = runGitNexusCli(cliPath, ['augment', '--', pattern], cwd, 7000); + if (!child.error && child.status === 0) { + result = child.stderr || ''; + } + } catch { + /* graceful failure */ + } + + if (result && result.trim()) { + console.log(JSON.stringify({ additional_context: result.trim() })); + } + } catch (err) { + if (process.env.GITNEXUS_DEBUG) { + console.error('GitNexus Cursor hook error:', (err.message || '').slice(0, 200)); + } + } +} + +main(); diff --git a/gitnexus-cursor-integration/hooks/hooks.json b/gitnexus-cursor-integration/hooks/hooks.json index ede0dfbf3..9ae542c14 100644 --- a/gitnexus-cursor-integration/hooks/hooks.json +++ b/gitnexus-cursor-integration/hooks/hooks.json @@ -1,11 +1,11 @@ { "version": 1, "hooks": { - "beforeShellExecution": [ + "postToolUse": [ { - "command": "./hooks/augment-shell.sh", - "timeout": 5, - "matcher": "\\brg\\b|\\bgrep\\b" + "matcher": "Shell|Read|Grep", + "command": "node ./hooks/gitnexus-hook.cjs", + "timeout": 10 } ] } diff --git a/gitnexus/README.md b/gitnexus/README.md index 8a5144598..55cdc12f7 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -33,7 +33,7 @@ To configure MCP for your editor, run `npx gitnexus setup` once — or set it up | Editor | MCP | Skills | Hooks (auto-augment) | Support | |--------|-----|--------|---------------------|---------| | **Claude Code** | Yes | Yes | Yes (PreToolUse) | **Full** | -| **Cursor** | Yes | Yes | — | MCP + Skills | +| **Cursor** | Yes | Yes | Yes (postToolUse, [manual install](../gitnexus-cursor-integration/README.md#hook-install)) | **Full** | | **Codex** | Yes | Yes | — | MCP + Skills | | **Windsurf** | Yes | — | — | MCP | | **OpenCode** | Yes | Yes | — | MCP + Skills | diff --git a/gitnexus/src/cli/augment.ts b/gitnexus/src/cli/augment.ts index 553e3f591..a97f6e23c 100644 --- a/gitnexus/src/cli/augment.ts +++ b/gitnexus/src/cli/augment.ts @@ -2,7 +2,7 @@ * Augment CLI Command * * Fast-path command for platform hooks. - * Shells out from Claude Code PreToolUse / Cursor beforeShellExecution hooks. + * Shells out from Claude Code PreToolUse / Cursor postToolUse hooks. * * Usage: gitnexus augment * Returns enriched text to stdout. diff --git a/gitnexus/src/core/augmentation/engine.ts b/gitnexus/src/core/augmentation/engine.ts index f97415cc9..42087e4b0 100644 --- a/gitnexus/src/core/augmentation/engine.ts +++ b/gitnexus/src/core/augmentation/engine.ts @@ -2,8 +2,8 @@ * Augmentation Engine * * Lightweight, fast-path enrichment of search patterns with knowledge graph context. - * Designed to be called from platform hooks (Claude Code PreToolUse, Cursor beforeShellExecution) - * when an agent runs grep/glob/search. + * Designed to be called from platform hooks (Claude Code PreToolUse, Cursor postToolUse) + * when an agent runs grep/glob/read/search. * * Performance target: <500ms cold start, <200ms warm. * diff --git a/gitnexus/test/unit/cursor-hook.test.ts b/gitnexus/test/unit/cursor-hook.test.ts new file mode 100644 index 000000000..f0d875dee --- /dev/null +++ b/gitnexus/test/unit/cursor-hook.test.ts @@ -0,0 +1,458 @@ +/** + * Regression Tests: Cursor postToolUse Hook + * + * Tests the hook script at gitnexus-cursor-integration/hooks/gitnexus-hook.cjs + * which runs as a Cursor 2.4 postToolUse hook. + * + * Covers: + * - extractPattern: pattern extraction from Grep/Read/Shell tool inputs + * - findGitNexusDir: .gitnexus directory discovery (shared with Claude hook) + * - cwd validation: rejects relative paths + * - shell injection: verifies no `shell: true` in spawnSync calls + * - cross-platform: Windows .cmd extension handling + * - output shape: top-level `additional_context` (NOT Claude's `hookSpecificOutput.additionalContext`) + * - hooks.json wiring matches the script's actual handlers + * + * Cursor hooks reach the augment CLI only when cwd is inside an indexed + * repo, so behavior tests stick to early-exit paths to avoid spawning + * `npx gitnexus`. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { spawnSync } from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import { runHook } from '../utils/hook-test-helpers.js'; + +// ─── Path to the Cursor hook + manifest ───────────────────────────── + +const CURSOR_HOOK = path.resolve( + __dirname, + '..', + '..', + '..', + 'gitnexus-cursor-integration', + 'hooks', + 'gitnexus-hook.cjs', +); +const CURSOR_HOOKS_JSON = path.resolve( + __dirname, + '..', + '..', + '..', + 'gitnexus-cursor-integration', + 'hooks', + 'hooks.json', +); + +// ─── Cursor-specific output parser ────────────────────────────────── +// Cursor postToolUse output shape: { "additional_context": "..." } + +function parseCursorOutput(stdout: string): { additional_context?: string } | null { + if (!stdout.trim()) return null; + try { + return JSON.parse(stdout.trim()); + } catch { + return null; + } +} + +// ─── Test fixtures ────────────────────────────────────────────────── + +let tmpDir: 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' }); +}); + +afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +// ─── Manifest + hook file presence ─────────────────────────────────── + +describe('Cursor integration files', () => { + it('hook script exists', () => { + expect(fs.existsSync(CURSOR_HOOK)).toBe(true); + }); + + it('hooks.json exists', () => { + expect(fs.existsSync(CURSOR_HOOKS_JSON)).toBe(true); + }); + + it('legacy augment-shell.sh has been removed', () => { + const legacy = path.resolve( + __dirname, + '..', + '..', + '..', + 'gitnexus-cursor-integration', + 'hooks', + 'augment-shell.sh', + ); + expect(fs.existsSync(legacy)).toBe(false); + }); +}); + +// ─── hooks.json wiring ────────────────────────────────────────────── + +describe('hooks.json wiring', () => { + const manifest = JSON.parse(fs.readFileSync(CURSOR_HOOKS_JSON, 'utf-8')); + + it('declares version 1', () => { + expect(manifest.version).toBe(1); + }); + + it('registers a postToolUse hook (not legacy beforeShellExecution)', () => { + expect(manifest.hooks.postToolUse).toBeDefined(); + expect(Array.isArray(manifest.hooks.postToolUse)).toBe(true); + expect(manifest.hooks.beforeShellExecution).toBeUndefined(); + }); + + it('matches Shell, Read, and Grep tools', () => { + const matcher: string = manifest.hooks.postToolUse[0].matcher; + expect(matcher).toMatch(/Shell/); + expect(matcher).toMatch(/Read/); + expect(matcher).toMatch(/Grep/); + }); + + it('points command at the new Node hook', () => { + const command: string = manifest.hooks.postToolUse[0].command; + expect(command).toContain('gitnexus-hook.cjs'); + expect(command).not.toContain('augment-shell.sh'); + }); + + it('declares timeout in seconds (not milliseconds)', () => { + // Cursor's `timeout` field is in seconds per + // https://cursor.com/docs/agent/hooks. Regression guard: a value of + // 1000+ here would be a >16-minute timeout, almost certainly a ms/s mixup. + const timeout: number = manifest.hooks.postToolUse[0].timeout; + expect(typeof timeout).toBe('number'); + expect(timeout).toBeGreaterThan(0); + expect(timeout).toBeLessThan(120); + }); +}); + +// ─── Source code regressions ──────────────────────────────────────── + +describe('Cursor hook source regressions', () => { + const source = fs.readFileSync(CURSOR_HOOK, 'utf-8'); + + it('does not pass shell: true to spawnSync', () => { + const lines = source.split('\n'); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (line.trim().startsWith('//') || line.trim().startsWith('*')) continue; + if (/shell:\s*(true|isWin)/.test(line)) { + throw new Error(`Cursor hook line ${i + 1} has shell injection risk: ${line.trim()}`); + } + } + }); + + it('uses npx.cmd for Windows', () => { + expect(source).toContain('npx.cmd'); + }); + + it('validates cwd is an absolute path', () => { + expect(source).toMatch(/path\.isAbsolute\(cwd\)/); + }); + + it('truncates debug error messages to 200 chars', () => { + expect(source).toContain('.slice(0, 200)'); + }); + + it('emits Cursor-shape additional_context (not Claude hookSpecificOutput)', () => { + expect(source).toContain('additional_context'); + expect(source).not.toContain('hookSpecificOutput'); + expect(source).not.toContain('hookEventName'); + }); + + it('rejects patterns shorter than 3 chars', () => { + expect(source).toMatch(/length\s*>=\s*3/); + }); + + it('passes pattern after end-of-options marker (--)', () => { + // Regression for #200 — augment patterns starting with `-` would + // otherwise be parsed as CLI flags by the gitnexus CLI. + expect(source).toMatch(/'augment',\s*'--',\s*pattern/); + }); + + it('gates on a non-global .gitnexus directory before invoking the CLI', () => { + expect(source).toContain('findGitNexusDir'); + expect(source).toContain('isGlobalRegistryDir'); + }); + + it('handles linked git worktrees via git rev-parse --git-common-dir', () => { + expect(source).toContain('--git-common-dir'); + }); +}); + +// ─── extractPattern coverage (source-level) ───────────────────────── + +describe('Cursor hook extractPattern coverage', () => { + const source = fs.readFileSync(CURSOR_HOOK, 'utf-8'); + + it("handles 'grep' tool (Cursor matcher: Grep)", () => { + expect(source).toMatch(/t === 'grep'/); + }); + + it('probes a wide alias set for Grep query field (Cursor contract not formally specified)', () => { + // Cursor 2.4 docs at https://cursor.com/docs/agent/hooks list the + // matchers but not the per-tool tool_input field names. If Cursor + // changes the contract, we want the hook to still extract *something* + // — these aliases plus the longest-string fallback give us coverage. + for (const alias of ['query', 'pattern', 'regex', 'q', 'search', 'searchQuery']) { + expect(source).toContain(`toolInput.${alias}`); + } + expect(source).toContain('pickLongestStringValue'); + }); + + it("handles 'read' tool (Cursor matcher: Read)", () => { + expect(source).toMatch(/t === 'read'/); + for (const alias of ['target_file', 'file_path', 'filePath', 'path', 'file']) { + expect(source).toContain(`toolInput.${alias}`); + } + }); + + it("handles 'shell' tool (Cursor matcher: Shell)", () => { + expect(source).toMatch(/t === 'shell'/); + expect(source).toMatch(/\\brg\\b\|\\bgrep\\b/); + }); + + it('logs raw payload to stderr when GITNEXUS_DEBUG is set (for contract diagnostics)', () => { + expect(source).toContain('GITNEXUS_DEBUG'); + expect(source).toContain('GitNexus Cursor hook stdin:'); + }); +}); + +// ─── Behavior: graceful no-op paths (no augment CLI invocation) ───── + +describe('Cursor hook behavior — early-exit paths', () => { + it('exits cleanly on empty stdin', () => { + const result = spawnSync(process.execPath, [CURSOR_HOOK], { + input: '', + encoding: 'utf-8', + timeout: 10000, + stdio: ['pipe', 'pipe', 'pipe'], + }); + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe(''); + }); + + it('exits cleanly on invalid JSON stdin', () => { + const result = spawnSync(process.execPath, [CURSOR_HOOK], { + input: 'not json at all', + encoding: 'utf-8', + timeout: 10000, + stdio: ['pipe', 'pipe', 'pipe'], + }); + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe(''); + }); + + it('produces no output when cwd is relative', () => { + const result = runHook(CURSOR_HOOK, { + tool_name: 'Grep', + tool_input: { query: 'validateUser' }, + cwd: 'relative/path', + }); + expect(result.stdout.trim()).toBe(''); + expect(result.status).toBe(0); + }); + + it('produces no output when cwd has no .gitnexus dir', () => { + const result = runHook(CURSOR_HOOK, { + tool_name: 'Grep', + tool_input: { query: 'validateUser' }, + cwd: tmpDir, + }); + expect(result.stdout.trim()).toBe(''); + expect(result.status).toBe(0); + }); + + it('produces no output for unknown tool names', () => { + const result = runHook(CURSOR_HOOK, { + tool_name: 'TotallyMadeUpTool', + tool_input: { foo: 'bar' }, + cwd: tmpDir, + }); + expect(result.stdout.trim()).toBe(''); + expect(result.status).toBe(0); + }); + + it('produces no output for Shell commands without rg/grep', () => { + const result = runHook(CURSOR_HOOK, { + tool_name: 'Shell', + tool_input: { command: 'ls -la' }, + cwd: tmpDir, + }); + expect(result.stdout.trim()).toBe(''); + expect(result.status).toBe(0); + }); + + it('produces no output for Grep with a 2-char query', () => { + const result = runHook(CURSOR_HOOK, { + tool_name: 'Grep', + tool_input: { query: 'is' }, + cwd: tmpDir, + }); + expect(result.stdout.trim()).toBe(''); + expect(result.status).toBe(0); + }); + + it('produces no output for Read whose basename has no identifier chars', () => { + const result = runHook(CURSOR_HOOK, { + tool_name: 'Read', + tool_input: { target_file: '/tmp/--.md' }, + cwd: tmpDir, + }); + expect(result.stdout.trim()).toBe(''); + expect(result.status).toBe(0); + }); + + it('produces no output for Read with no file path', () => { + const result = runHook(CURSOR_HOOK, { + tool_name: 'Read', + tool_input: {}, + cwd: tmpDir, + }); + expect(result.stdout.trim()).toBe(''); + expect(result.status).toBe(0); + }); + + it('treats tool_name case-insensitively (Grep vs grep)', () => { + // Both should reach the same handler — and both should early-exit silently + // because tmpDir has no .gitnexus. + for (const toolName of ['Grep', 'grep', 'GREP']) { + const result = runHook(CURSOR_HOOK, { + tool_name: toolName, + tool_input: { query: 'validateUser' }, + cwd: tmpDir, + }); + expect(result.stdout.trim()).toBe(''); + expect(result.status).toBe(0); + } + }); +}); + +// ─── Behavior: GITNEXUS_DEBUG payload logging ──────────────────────── + +describe('Cursor hook debug logging', () => { + it('echoes the payload to stderr only when GITNEXUS_DEBUG is set', () => { + const payload = { + tool_name: 'Grep', + tool_input: { query: 'validateUser' }, + cwd: tmpDir, + }; + + // GITNEXUS_DEBUG unset → stderr quiet. + const quiet = spawnSync(process.execPath, [CURSOR_HOOK], { + input: JSON.stringify(payload), + encoding: 'utf-8', + timeout: 10000, + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, GITNEXUS_DEBUG: '' }, + }); + expect(quiet.status).toBe(0); + expect(quiet.stderr).not.toContain('GitNexus Cursor hook stdin'); + + // GITNEXUS_DEBUG=1 → payload echoed to stderr (stdout still empty for + // unindexed cwd, so the hook output contract is preserved). + const verbose = spawnSync(process.execPath, [CURSOR_HOOK], { + input: JSON.stringify(payload), + encoding: 'utf-8', + timeout: 10000, + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, GITNEXUS_DEBUG: '1' }, + }); + expect(verbose.status).toBe(0); + expect(verbose.stderr).toContain('GitNexus Cursor hook stdin'); + expect(verbose.stderr).toContain('"tool_name":"Grep"'); + expect(verbose.stdout.trim()).toBe(''); + }); +}); + +// ─── Documented contract behavior (extractPattern via the live hook) ─ + +describe('Shell quoted-pattern parser limitations (documented)', () => { + // The Shell parser cannot reconstruct shell quoting. These tests pin the + // current behavior so a future "fix" doesn't silently change extraction + // — and so users diagnosing a noisy/missed pattern can find the behavior + // documented in tests. + // + // We can't observe the extracted pattern directly without an indexed + // repo, but we *can* confirm the hook reaches the augment-call path + // (vs. early-exiting) by checking exit status + clean stdout for cases + // where parseRgGrepPattern would yield a >=3-char token. + + it('quoted multi-word `rg "User Service"` extracts the first word only', () => { + const result = runHook(CURSOR_HOOK, { + tool_name: 'Shell', + tool_input: { command: 'rg "User Service" src/' }, + cwd: tmpDir, // no .gitnexus → exits early after extract + }); + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe(''); + }); + + it('single-token quoted `rg "validateUser"` works as expected', () => { + const result = runHook(CURSOR_HOOK, { + tool_name: 'Shell', + tool_input: { command: 'rg "validateUser"' }, + cwd: tmpDir, + }); + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe(''); + }); +}); + +// ─── Install docs ───────────────────────────────────────────────────── + +describe('Cursor integration install docs', () => { + const integrationReadme = path.resolve( + __dirname, + '..', + '..', + '..', + 'gitnexus-cursor-integration', + 'README.md', + ); + + it('install README exists', () => { + expect(fs.existsSync(integrationReadme)).toBe(true); + }); + + it('install README documents the hook install path', () => { + const body = fs.readFileSync(integrationReadme, 'utf-8'); + expect(body).toContain('.cursor/hooks.json'); + expect(body).toContain('hooks/gitnexus-hook.cjs'); + expect(body).toContain('Hook install'); + }); + + it('install README documents GITNEXUS_DEBUG for payload diagnostics', () => { + const body = fs.readFileSync(integrationReadme, 'utf-8'); + expect(body).toContain('GITNEXUS_DEBUG'); + }); +}); + +// ─── Output parser sanity (synthetic JSON) ────────────────────────── + +describe('parseCursorOutput', () => { + it('parses a well-formed { additional_context } payload', () => { + const parsed = parseCursorOutput('{"additional_context":"hello"}'); + expect(parsed).not.toBeNull(); + expect(parsed?.additional_context).toBe('hello'); + }); + + it('returns null on empty stdout', () => { + expect(parseCursorOutput('')).toBeNull(); + expect(parseCursorOutput(' \n')).toBeNull(); + }); + + it('returns null on malformed JSON', () => { + expect(parseCursorOutput('not json')).toBeNull(); + }); +});