mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-21 00:21:30 +00:00
Merge branch 'main' of https://github.com/prajapatisparsh/GitNexus into feat/Desktop-app
This commit is contained in:
commit
2a00970565
17 changed files with 1050 additions and 167 deletions
2
.github/workflows/pr-autofix-apply.yml
vendored
2
.github/workflows/pr-autofix-apply.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
46
.github/workflows/release-candidate.yml
vendored
46
.github/workflows/release-candidate.yml
vendored
|
|
@ -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/<HEAD_SHA> marker pointing at HEAD?
|
||||
MARKER="rc/${HEAD_SHA}"
|
||||
if git rev-parse "refs/tags/$MARKER" >/dev/null 2>&1; then
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
89
gitnexus-cursor-integration/README.md
Normal file
89
gitnexus-cursor-integration/README.md
Normal file
|
|
@ -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
|
||||
<your-project>/
|
||||
├── .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/*` | ✅ |
|
||||
| `<project>/.cursor/hooks.json` + `<project>/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.
|
||||
|
|
@ -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
|
||||
259
gitnexus-cursor-integration/hooks/gitnexus-hook.cjs
Normal file
259
gitnexus-cursor-integration/hooks/gitnexus-hook.cjs
Normal file
|
|
@ -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 <pattern>`, 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();
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
47
gitnexus-web/package-lock.json
generated
47
gitnexus-web/package-lock.json
generated
|
|
@ -26,9 +26,9 @@
|
|||
"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.11.0",
|
||||
"lucide-react": "^1.14.0",
|
||||
"mermaid": "^11.14.0",
|
||||
"mnemonist": "^0.39.0",
|
||||
"pandemonium": "^2.4.0",
|
||||
|
|
@ -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",
|
||||
|
|
@ -5643,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": {
|
||||
|
|
@ -6041,9 +6028,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"
|
||||
|
|
|
|||
|
|
@ -36,9 +36,9 @@
|
|||
"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.11.0",
|
||||
"lucide-react": "^1.14.0",
|
||||
"mermaid": "^11.14.0",
|
||||
"mnemonist": "^0.39.0",
|
||||
"pandemonium": "^2.4.0",
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 <limit>` 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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
4
gitnexus/package-lock.json
generated
4
gitnexus/package-lock.json
generated
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 <pattern>
|
||||
* Returns enriched text to stdout.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
458
gitnexus/test/unit/cursor-hook.test.ts
Normal file
458
gitnexus/test/unit/cursor-hook.test.ts
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<T>(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<T>(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', () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue