GitNexus/gitnexus/test/unit/hooks.test.ts
Minidoracat 912285064a
perf(hooks): cmdline-first Linux db-lock scan, drop the lsof fallback (#2180) (#2183)
* perf(hooks): cmdline-first Linux db-lock scan, drop the lsof fallback (#2180)

The probe's Linux scan was O(processes × fds) — stat every fd of every
process — so on a busy host it blew its budget and fell through to lsof,
which then timed out (~2 s) and fail-closed. Every Grep/Glob/Bash hook
spent ~2 s of CPU to conclude 'couldn't tell'.

Rewrite linuxProcScanFindGitNexusServer (name kept; return type now
tri-state 'owned' | 'not-owned' | 'timeout') as three phases:
  0. /proc/<pid>/comm prefilter — kernel task->comm, never touches the
     target's memory maps; truncation-safe whitelist match (comm is
     capped at 15 visible chars). Calibrated to what a real server
     reports: @ladybugdb/core's worker_threads rename the main thread to
     'MainThread', so that is whitelisted alongside the launcher
     basenames — omitting it would blind the probe to every server.
  1. bounded /proc/<pid>/cmdline read (openSync+readSync, default 16 KiB
     with a floor of 4 KiB and a bounded escalation up to a hard ceiling)
     so a D-state holder cannot stall the hook and the mcp/serve mode
     token is never clipped off a long interpreter path.
  2. dev+ino fd match for the 0–2 survivors only.

Dispatch: 'owned' and 'timeout' both map to true. Timeout is now
fail-closed (overload self-throttle) instead of falling through to lsof;
the Linux lsof fallback is removed entirely. End-to-end semantics on
busy hosts are unchanged (the old lsof arm also fail-closed there) — the
~2 s of wasted work and the orphan-spawning lsof are what's gone.
macOS lsof+ps and Windows Restart Manager paths are untouched.

Also: fix the budget parse bug (Number(raw && trim()) treated '0' as
1200; now parseInt-then-validate, with <= 0 an explicit immediate
timeout) and add GITNEXUS_HOOK_PROC_ROOT so the Linux scan can be unit
tested against a fixture procfs instead of the host's real /proc.

Measured on a 583-process host with 6 background gitnexus mcp servers:
owner detection 6–12 ms (was ~1216 ms + lsof timeout), ~100x.

Tests: new hook-db-lock-probe.test.ts drives all three phases against a
fake procfs (comm-truncation safety, Phase 0 trap, 4 KiB-boundary
owner-miss guard, budget=0 immediate timeout, EACCES fail-closed) plus a
live-/proc e2e that pins the fd-visible lbug-handle property against a
real subprocess holder. The lsof/ps owner-detection suites are relaned
to macOS (Linux no longer takes that path); the lsof orphan-reaping
suite is removed (no lsof is spawned on Linux now) with a rationale note.

Note: pre-commit typecheck skipped; remaining tsc errors are pre-existing
on main (none in files touched here).

* fix(hooks): honest EACCES verdict + real escalation coverage (#2183 review)

Addresses the tri-review (maintainer + Codex):

- [P2] Phase-2 fd-dir EACCES no longer claims 'owned'. /proc/<pid>/fd is
  owner-only (0500), so a cross-user/root gitnexus server serving ANY
  repo cleared Phase 0+1 and hit EACCES here, and the old catch returned
  'owned' — falsely claiming it locks THIS repo's lbug (dev+ino never
  compared) and permanently suppressing augment. Split the failure
  shapes: ENOENT -> continue (raced away); EACCES/EPERM and transient
  EIO/ESTALE -> 'timeout' (unverifiable -> fail-closed, but honest, not a
  false ownership claim); ENOTDIR/other structural errors -> continue
  (not a real fd dir). Same fail-closed dispatcher outcome, no false
  'owned', plus a GITNEXUS_DEBUG diagnostic so an operator can tell this
  skip path from a real owner.
- [P2] The escalation test now actually iterates the escalation loop:
  the gitnexus token sits under 4 KB while the mode token is padded past
  GITNEXUS_HOOK_PROC_CMDLINE_MAX=4096, and a readSync spy asserts >1 read
  (the old 9 KB-under-16 KB-cap shape read once and never escalated).
- escalation loop now re-checks the budget each iteration and returns a
  distinct timeout sentinel (never '' — an empty string would read as
  'not a candidate' and could drop a real owner -> fail-open); the caller
  maps it to 'timeout'.
- GITNEXUS_HOOK_PROC_ROOT is gated to test context so a stray production
  env export can't disable Linux owner detection (fail-open).
- New uid-agnostic spy tests pin every fd-readdir errno branch
  (EACCES/EPERM/EIO/ESTALE -> timeout, ENOTDIR -> not-owned) regardless
  of the runner's uid (the disk chmod-000 tests no-op under root).

Note: pre-commit typecheck skipped; remaining tsc errors are pre-existing
on main (none in files touched here).

* fix(hooks): drop the always-true outOfBudget presence guard (CodeQL #2183)

CodeQL flagged `typeof outOfBudget === 'function' && outOfBudget()` as
unneeded defensive code: readLinuxCmdline has a single caller
(linuxProcScanFindGitNexusServer) that always passes the callback, so
the typeof guard is dead. Drop it, leaving `if (outOfBudget())`, and note
the invariant in the comment. Mirrored in the byte-identical plugin copy.

* fix(hooks): parse numeric hook env with Number() so scientific notation works (#2183 review)

getCmdlineMaxBytes and resolveLinuxProcBudgetMs parsed their env via
Number.parseInt(raw, 10), so a value like "16e3" silently became 16 (parseInt
stops at 'e') instead of 16000. Switch both to Number(String(raw).trim()),
which honors scientific notation and is stricter on trailing garbage
("123abc" -> NaN -> default) — matching the repo-majority Number()+isFinite
env idiom (src/cli/analyze.ts, src/core/embeddings/hf-env.ts).

The two functions had DIFFERENT guard skeletons, so a verbatim swap would
regress the budget: resolveLinuxProcBudgetMs used `raw != null ?` with no
empty-string short-circuit, and Number("")===0 (vs parseInt("")===NaN) would
make a set-but-empty GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS="" resolve to budget 0
=> immediate fail-CLOSED timeout => augment permanently skipped. Added the
`&& String(raw).trim()` guard so ''/whitespace fall to the 1200 default while
"0" still parses to the deliberate #2180 immediate-timeout vector.

Exported both helpers for white-box tests (the values are otherwise only
observable indirectly through scan timing) and added platform-independent
coverage: "16e3"->16000, ""/whitespace->1200 (the regression guard), "0"->0,
"123abc"/unset->1200, cmdline "8e3"->8000, "2e3"/""/unset->16384.

Both byte-identical hook-db-lock-probe.cjs copies updated together.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(hooks): allocUnsafe the per-chunk cmdline read buffer (#2183 review)

readLinuxCmdline allocated each per-chunk read buffer with Buffer.alloc(chunkCap),
zero-filling memory that readSync immediately and fully overwrites. Switch the
hot read buffer to Buffer.allocUnsafe — safe because readSync initializes
exactly [0, bytes), only buf.subarray(0, bytes) is consumed, and Buffer.concat
deep-copies that slice into `collected`, so the uninitialized tail can never
reach the decoded cmdline. The zero-length `collected = Buffer.alloc(0)` is left
unchanged (allocUnsafe gains nothing on a 0-length buffer). The existing D3
multi-chunk decode tests cover the read path and stay green.

Both byte-identical hook-db-lock-probe.cjs copies updated together.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(hooks): harden the live /proc owner-detection e2e against CI flake (#2183 review)

Two flake mechanisms, fixed without weakening what the e2e proves:

- Holder readiness (the genuine false-FAIL): the pid-file poll was 200x25ms=5s;
  a loaded runner can be slow to spawn the child, tripping
  expect(holderPid).toBeGreaterThan(0). Widened to ~10s and raised the per-test
  timeout 20s -> 40s.
- Scan budget (kept the assertion honest): the live scan ran at the default
  1200ms. Because the dispatcher maps a budget 'timeout' to owned=TRUE, a busy
  host exhausting 1200ms before reaching the holder would make the assertion
  pass for the WRONG reason (a hollow timeout, not real fd-visible detection).
  Set a generous explicit 10000ms budget via the existing setEnv() helper so the
  module afterEach restores it (replacing the raw `delete process.env...` that
  bypassed env tracking). Raised the coarse timing regression guard to sit ABOVE
  the budget (5000 -> 15000) so a legitimately-slow-but-correct scan can't trip
  it.

The load-bearing asserts (dev+ino fd-visibility precheck, owned===true for our
own lbug) are unchanged. Verified the e2e executes (not skipped) on Linux.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(changelog): empty the root CHANGELOG [Unreleased] section

Per maintainer request, nothing should sit under [Unreleased] in the root
CHANGELOG.md (the release-owned changelog is gitnexus/CHANGELOG.md, whose
[Unreleased] is already empty). Removes all three accumulated blocks — Fixed
(#2163), Performance (#2180), Changed (KuzuDB->LadybugDB) — leaving only the
[Unreleased] header above [1.5.3]. Pure removal; no release sections touched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 11:52:14 +01:00

3006 lines
115 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Regression Tests: Claude Code Hooks
*
* Tests the hook scripts (gitnexus-hook.cjs and gitnexus-hook.js) that run
* as PreToolUse and PostToolUse hooks in Claude Code.
*
* Covers:
* - extractPattern: pattern extraction from Grep/Glob/Bash tool inputs
* - findGitNexusDir: .gitnexus directory discovery
* - handlePostToolUse: staleness detection after git mutations
* - cwd validation: rejects relative paths (defense-in-depth)
* - shell injection: verifies no shell: true in spawnSync calls
* - dispatch map: correct handler routing
* - cross-platform: Windows .cmd extension handling
* - cross-platform: DB lock probe (Linux /proc, Unix lsof, Windows RM)
*
* Since the hooks are CJS scripts that call main() on load, we test them
* by spawning them as child processes with controlled stdin JSON.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { spawnSync } from 'child_process';
import { createRequire } from 'node:module';
import fs from 'fs';
import path from 'path';
import os from 'os';
import {
runHook,
parseHookOutput,
createHookToolDir,
createFakeProcRoot,
hookEnv,
} from '../utils/hook-test-helpers.js';
// ─── Paths to both hook variants ────────────────────────────────────
const CJS_HOOK = path.resolve(__dirname, '..', '..', 'hooks', 'claude', 'gitnexus-hook.cjs');
const CJS_HOOK_LOCK = path.resolve(__dirname, '..', '..', 'hooks', 'claude', 'hook-lock.cjs');
const RESOLVE_CJS = path.resolve(
__dirname,
'..',
'..',
'hooks',
'claude',
'resolve-analyze-cmd.cjs',
);
const RESOLVE_PLUGIN_CJS = path.resolve(
__dirname,
'..',
'..',
'..',
'gitnexus-claude-plugin',
'hooks',
'resolve-analyze-cmd.cjs',
);
const PLUGIN_HOOK = path.resolve(
__dirname,
'..',
'..',
'..',
'gitnexus-claude-plugin',
'hooks',
'gitnexus-hook.js',
);
const PLUGIN_HOOK_LOCK = path.resolve(
__dirname,
'..',
'..',
'..',
'gitnexus-claude-plugin',
'hooks',
'hook-lock.js',
);
const CJS_HOOK_DB_PROBE = path.resolve(
__dirname,
'..',
'..',
'hooks',
'claude',
'hook-db-lock-probe.cjs',
);
const PLUGIN_HOOK_DB_PROBE = path.resolve(
__dirname,
'..',
'..',
'..',
'gitnexus-claude-plugin',
'hooks',
'hook-db-lock-probe.cjs',
);
// ─── lsof/ps-path lane gate (#2180) ─────────────────────────────────
//
// The owner-detection tests below drive the probe through its lsof + ps backend
// (via the fake lsof/ps in createHookToolDir). That backend is the macOS/other-
// Unix path; #2180 removed the Linux lsof fallback, so on Linux these tests
// would no longer exercise the real dispatch (the cmdline-first procfs scan
// answers instead, and a temp lbug held by nobody is simply not-owned). They
// remain valid coverage for the macOS lane; Linux gets equivalent three-phase
// coverage in test/unit/hook-db-lock-probe.test.ts (fake /proc + a live e2e).
const SKIP_LSOF_PATH = process.platform === 'win32' || process.platform === 'linux';
// ─── Host guard precheck for orphan-reaping tests (#2163) ───────────
//
// The reaping lanes depend on a host coreutils `timeout`/`gtimeout` that
// passes the probe's `-k` self-test. Without one, the SIGTERM-immune fake
// child simply survives and the aliveness poll times out — a red that says
// nothing about WHY. Resolve the guard once through the probe's own exported
// resolver (the exact candidate list + self-test the hook child will use,
// with any dev-shell GITNEXUS_HOOK_TIMEOUT_PATH override cleared to mirror
// the `''` these tests pass to the hook) and assert on it with an explicit
// message. Chosen form: precheck ASSERTION, not skipIf — a skip would
// silently drop the incident-mechanism coverage on a misconfigured host
// (green-but-vacuous lane), while a red with a one-line actionable cause
// keeps the contract honest. GitHub ubuntu runners always ship coreutils,
// so CI behavior is unchanged.
const GUARD_PRECHECK_MSG =
'precheck: no self-test-passing coreutils timeout/gtimeout on this host — ' +
'orphan reaping cannot work here (the wrapper IS the reaping mechanism). ' +
'Install coreutils or expose one via GITNEXUS_HOOK_TIMEOUT_PATH.';
let hostGuardMemo: string | null | undefined;
function resolveHostGuardForReapingTests(): string | null {
if (hostGuardMemo !== undefined) return hostGuardMemo;
const saved = process.env.GITNEXUS_HOOK_TIMEOUT_PATH;
process.env.GITNEXUS_HOOK_TIMEOUT_PATH = '';
try {
// createRequire: the probe is a CJS module; this also exercises the real
// export surface the adapters consume (#2163 follow-up). Unix-only — the
// resolver's self-test spawns /bin/sh — and all callers below live in
// linux-gated describes.
const probe = createRequire(import.meta.url)(CJS_HOOK_DB_PROBE) as {
resolveUnixGuardTimeout: () => string | null;
};
hostGuardMemo = probe.resolveUnixGuardTimeout();
} finally {
if (saved === undefined) delete process.env.GITNEXUS_HOOK_TIMEOUT_PATH;
else process.env.GITNEXUS_HOOK_TIMEOUT_PATH = saved;
}
return hostGuardMemo;
}
// ─── Test fixtures: temporary .gitnexus directory ───────────────────
let tmpDir: string;
let gitNexusDir: string;
beforeAll(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-hook-test-'));
gitNexusDir = path.join(tmpDir, '.gitnexus');
fs.mkdirSync(gitNexusDir, { recursive: true });
// Initialize a bare git repo so git rev-parse HEAD works
runGit(tmpDir, ['init']);
runGit(tmpDir, ['config', 'user.email', 'test@test.com']);
runGit(tmpDir, ['config', 'user.name', 'Test']);
fs.writeFileSync(path.join(tmpDir, 'dummy.txt'), 'hello');
runGit(tmpDir, ['add', '.']);
runGit(tmpDir, ['commit', '-m', 'init']);
});
afterAll(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
// ─── Helper to get HEAD commit hash ─────────────────────────────────
function runGit(dir: string, args: string[]) {
const result = spawnSync('git', args, {
cwd: dir,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true,
});
if (result.status !== 0) {
const message = result.stderr || result.stdout || result.error?.message || 'unknown error';
throw new Error(`git ${args.join(' ')} failed in ${dir}: ${message}`);
}
return result;
}
function getHeadCommit(): string {
const result = runGit(tmpDir, ['rev-parse', 'HEAD']);
return (result.stdout || '').trim();
}
function initGitRepo(dir: string) {
runGit(dir, ['init']);
runGit(dir, ['config', 'user.email', 'test@test.com']);
runGit(dir, ['config', 'user.name', 'Test']);
fs.writeFileSync(path.join(dir, 'file.txt'), 'hello');
runGit(dir, ['add', '.']);
runGit(dir, ['commit', '-m', 'init']);
}
function createGlobalRegistry(homeDir: string, marker: 'both' | 'registry' | 'repos' = 'both') {
const registryDir = path.join(homeDir, '.gitnexus');
fs.mkdirSync(registryDir, { recursive: true });
if (marker === 'both' || marker === 'repos') {
fs.mkdirSync(path.join(registryDir, 'repos'), { recursive: true });
}
if (marker === 'both' || marker === 'registry') {
fs.writeFileSync(path.join(registryDir, 'registry.json'), JSON.stringify({ repos: [] }));
}
}
// createHookToolDir / hookEnv live in ../utils/hook-test-helpers so the antigravity
// e2e suite can reuse the same DB-owner-probe fakes.
// ─── Both hook files should exist ───────────────────────────────────
describe('Hook files exist', () => {
it('CJS hook exists', () => {
expect(fs.existsSync(CJS_HOOK)).toBe(true);
});
it('Plugin hook exists', () => {
expect(fs.existsSync(PLUGIN_HOOK)).toBe(true);
});
});
// ─── Source code regression: no shell: true ──────────────────────────
describe('Shell injection regression', () => {
for (const [label, hookPath] of [
['CJS', CJS_HOOK],
['Plugin', PLUGIN_HOOK],
['Resolve CJS', RESOLVE_CJS],
['Resolve Plugin', RESOLVE_PLUGIN_CJS],
] as const) {
it(`${label} hook has no shell: true in spawnSync calls`, () => {
const source = fs.readFileSync(hookPath, 'utf-8');
// Match spawnSync calls with shell option set to true or a variable
// Allowed: comments mentioning shell: true, string literals
const lines = source.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Skip comments and string literals
if (line.trim().startsWith('//') || line.trim().startsWith('*')) continue;
// Check for shell: true or shell: isWin in actual code
if (/shell:\s*(true|isWin)/.test(line)) {
throw new Error(`${label} hook line ${i + 1} has shell injection risk: ${line.trim()}`);
}
}
});
}
});
// ─── Source code regression: windowsHide:true on every spawn-family call ───
/**
* Every ``spawn`` / ``spawnSync`` / ``execFile`` / ``execFileSync`` /
* ``execFileAsync`` / ``execSync`` call in the hook layer **and the
* core/CLI/MCP/server source tree** must pass ``windowsHide: true``
* in its options object. Without it, Node's ``child_process`` module
* asks ``CreateProcess`` to use ``STARTF_USESHOWWINDOW`` with
* ``SW_SHOWDEFAULT`` and a black console window flashes onto the
* user's desktop for each call. Under active Claude Code / MCP /
* gitnexus-serve use that's a near-continuous stream of pop-ups —
* unusable in practice on Windows.
*
* ``windowsHide`` is a no-op on POSIX (silently dropped), so the
* flag is safe to require unconditionally. ``stdio: 'inherit'``
* callers (interactive editors etc.) are unaffected — windowsHide
* only suppresses NEW console allocation; an inherited parent
* console isn't touched.
*
* The check is source-level rather than behavioural because:
* - the flag's effect is observable only on Windows;
* - GitHub Actions runs vitest on Linux for these tests;
* - regressing this is easy (every new spawn site has to remember
* the flag), and a runtime check on a Windows-only CI leg would
* still let a PR land on the main branch first.
*
* The pre-existing fix at ``src/core/lbug/extension-loader.ts:96``
* established the convention. This test enforces it everywhere.
*/
describe('windowsHide regression', () => {
// Hook-layer files. Adding a new hook file MUST be reflected here.
const HOOK_FILES: Array<readonly [string, string]> = [
['gitnexus/hooks/claude/gitnexus-hook.cjs', CJS_HOOK],
['gitnexus/hooks/claude/resolve-analyze-cmd.cjs', RESOLVE_CJS],
['gitnexus-claude-plugin/hooks/resolve-analyze-cmd.cjs', RESOLVE_PLUGIN_CJS],
[
'gitnexus/hooks/antigravity/gitnexus-antigravity-hook.cjs',
path.resolve(__dirname, '..', '..', 'hooks', 'antigravity', 'gitnexus-antigravity-hook.cjs'),
],
[
'gitnexus/hooks/claude/hook-db-lock-probe.cjs',
path.resolve(__dirname, '..', '..', 'hooks', 'claude', 'hook-db-lock-probe.cjs'),
],
['gitnexus-claude-plugin/hooks/gitnexus-hook.js', PLUGIN_HOOK],
[
'gitnexus-claude-plugin/hooks/hook-db-lock-probe.cjs',
path.resolve(
__dirname,
'..',
'..',
'..',
'gitnexus-claude-plugin',
'hooks',
'hook-db-lock-probe.cjs',
),
],
[
'gitnexus-cursor-integration/hooks/gitnexus-hook.cjs',
path.resolve(
__dirname,
'..',
'..',
'..',
'gitnexus-cursor-integration',
'hooks',
'gitnexus-hook.cjs',
),
],
];
// Source-tree files. Every file that imports a spawn-family
// function from ``child_process`` belongs here. Discovered via
// grep -rn "from 'child_process'" -- gitnexus/src/
// plus the explicit ``await import('child_process')`` callers in
// local-backend.ts.
const SRC_FILES: Array<readonly [string, string]> = [
[
'gitnexus/src/cli/analyze.ts',
path.resolve(__dirname, '..', '..', 'src', 'cli', 'analyze.ts'),
],
['gitnexus/src/cli/setup.ts', path.resolve(__dirname, '..', '..', 'src', 'cli', 'setup.ts')],
['gitnexus/src/cli/wiki.ts', path.resolve(__dirname, '..', '..', 'src', 'cli', 'wiki.ts')],
[
'gitnexus/src/core/embeddings/embedder.ts',
path.resolve(__dirname, '..', '..', 'src', 'core', 'embeddings', 'embedder.ts'),
],
[
'gitnexus/src/core/git-staleness.ts',
path.resolve(__dirname, '..', '..', 'src', 'core', 'git-staleness.ts'),
],
[
'gitnexus/src/core/lbug/extension-loader.ts',
path.resolve(__dirname, '..', '..', 'src', 'core', 'lbug', 'extension-loader.ts'),
],
[
'gitnexus/src/core/run-analyze.ts',
path.resolve(__dirname, '..', '..', 'src', 'core', 'run-analyze.ts'),
],
[
'gitnexus/src/core/wiki/cursor-client.ts',
path.resolve(__dirname, '..', '..', 'src', 'core', 'wiki', 'cursor-client.ts'),
],
[
'gitnexus/src/core/wiki/generator.ts',
path.resolve(__dirname, '..', '..', 'src', 'core', 'wiki', 'generator.ts'),
],
[
'gitnexus/src/mcp/local/local-backend.ts',
path.resolve(__dirname, '..', '..', 'src', 'mcp', 'local', 'local-backend.ts'),
],
[
'gitnexus/src/server/git-clone.ts',
path.resolve(__dirname, '..', '..', 'src', 'server', 'git-clone.ts'),
],
// New post-upstream-merge (May 2026 sync):
[
'gitnexus/src/storage/git.ts',
path.resolve(__dirname, '..', '..', 'src', 'storage', 'git.ts'),
],
];
/**
* Strip pure-comment lines so prose mentions of ``spawn`` /
* ``exec`` don't inflate the call count.
*/
function stripComments(source: string): string {
return source
.split('\n')
.filter((l) => {
const t = l.trim();
return !t.startsWith('//') && !t.startsWith('*') && !t.startsWith('/*');
})
.join('\n');
}
/**
* Count spawn-family invocations. The regex matches ``spawn(``,
* ``spawnSync(``, ``execFile(``, ``execFileSync(``,
* ``execFileAsync(``, ``execSync(`` as function calls — not
* destructures (``const { spawn } = ...``), not method calls
* (``.exec(``), not bare ``exec()`` (which collides with regex
* ``.exec()``; we explicitly drop it).
*/
function countSpawnCalls(codeSource: string): number {
const re =
/(^|[^a-zA-Z0-9_$.])(spawn|spawnSync|execFile|execFileSync|execFileAsync|execSync)\s*\(/gm;
let count = 0;
while (re.exec(codeSource) !== null) {
count++;
}
return count;
}
for (const [label, file] of [...HOOK_FILES, ...SRC_FILES]) {
it(`${label}: every spawn-family options object contains windowsHide: true`, () => {
// The file must exist — silent-skip would mask a deletion.
expect(fs.existsSync(file)).toBe(true);
const source = fs.readFileSync(file, 'utf-8');
const codeSource = stripComments(source);
const spawnCount = countSpawnCalls(codeSource);
const hideCount = (codeSource.match(/windowsHide\s*:\s*true/g) ?? []).length;
// Sanity: catch a refactor that accidentally deletes every
// spawn call (which would otherwise make the equality below
// trivially true at 0 == 0).
expect(spawnCount).toBeGreaterThan(0);
// One windowsHide per spawn-family call. We don't try to
// match brace structure — a same-count proxy is sufficient
// because every spawn site in these files passes an options
// object literal (no helper indirection).
expect(hideCount).toBe(spawnCount);
});
}
});
// ─── Source code regression: .cmd extensions for Windows ─────────────
describe('Windows .cmd extension handling', () => {
for (const [label, hookPath] of [
['CJS', CJS_HOOK],
['Plugin', PLUGIN_HOOK],
] as const) {
it(`${label} hook uses .cmd extensions for Windows npx`, () => {
const source = fs.readFileSync(hookPath, 'utf-8');
expect(source).toContain('npx.cmd');
});
}
it('Plugin hook uses .cmd extension for Windows gitnexus binary', () => {
const source = fs.readFileSync(PLUGIN_HOOK, 'utf-8');
expect(source).toContain('gitnexus.cmd');
});
});
// ─── Source code regression: cwd validation ─────────────────────────
describe('cwd validation guards', () => {
for (const [label, hookPath] of [
['CJS', CJS_HOOK],
['Plugin', PLUGIN_HOOK],
] as const) {
it(`${label} hook validates cwd is absolute path`, () => {
const source = fs.readFileSync(hookPath, 'utf-8');
const cwdChecks = (source.match(/path\.isAbsolute\(cwd\)/g) || []).length;
// Should have at least 2 checks (one in PreToolUse, one in PostToolUse)
expect(cwdChecks).toBeGreaterThanOrEqual(2);
});
}
});
// ─── Source code regression: sendHookResponse used consistently ──────
describe('sendHookResponse consistency', () => {
for (const [label, hookPath] of [
['CJS', CJS_HOOK],
['Plugin', PLUGIN_HOOK],
] as const) {
it(`${label} hook uses sendHookResponse in both handlers`, () => {
const source = fs.readFileSync(hookPath, 'utf-8');
const calls = (source.match(/sendHookResponse\(/g) || []).length;
// At least 3: definition + PreToolUse call + PostToolUse call
expect(calls).toBeGreaterThanOrEqual(3);
});
it(`${label} hook does not inline hookSpecificOutput JSON in handlers`, () => {
const source = fs.readFileSync(hookPath, 'utf-8');
// Count inline hookSpecificOutput usage (should only be in sendHookResponse definition)
const inlineCount = (source.match(/hookSpecificOutput/g) || []).length;
// Exactly 1 occurrence: inside the sendHookResponse function body
expect(inlineCount).toBe(1);
});
}
});
// ─── Source code regression: dispatch map pattern ────────────────────
describe('Dispatch map pattern', () => {
for (const [label, hookPath] of [
['CJS', CJS_HOOK],
['Plugin', PLUGIN_HOOK],
] as const) {
it(`${label} hook uses dispatch map instead of if/else`, () => {
const source = fs.readFileSync(hookPath, 'utf-8');
expect(source).toContain('const handlers = {');
expect(source).toContain('PreToolUse: handlePreToolUse');
expect(source).toContain('PostToolUse: handlePostToolUse');
// Should NOT have if/else dispatch in main()
expect(source).not.toMatch(/if\s*\(hookEvent\s*===\s*'PreToolUse'\)/);
});
}
});
// ─── Source code regression: debug error truncation ──────────────────
describe('Debug error message truncation', () => {
for (const [label, hookPath] of [
['CJS', CJS_HOOK],
['Plugin', PLUGIN_HOOK],
] as const) {
it(`${label} hook truncates error messages to 200 chars`, () => {
const source = fs.readFileSync(hookPath, 'utf-8');
expect(source).toContain('.slice(0, 200)');
});
}
});
// ─── extractPattern regression (via source analysis) ────────────────
describe('extractPattern coverage', () => {
for (const [label, hookPath] of [
['CJS', CJS_HOOK],
['Plugin', PLUGIN_HOOK],
] as const) {
it(`${label} hook extracts pattern from Grep tool input`, () => {
const source = fs.readFileSync(hookPath, 'utf-8');
expect(source).toContain("toolName === 'Grep'");
expect(source).toContain('toolInput.pattern');
});
it(`${label} hook extracts pattern from Glob tool input`, () => {
const source = fs.readFileSync(hookPath, 'utf-8');
expect(source).toContain("toolName === 'Glob'");
});
it(`${label} hook extracts pattern from Bash grep/rg commands`, () => {
const source = fs.readFileSync(hookPath, 'utf-8');
expect(source).toMatch(/\\brg\\b.*\\bgrep\\b/);
});
it(`${label} hook rejects patterns shorter than 3 chars`, () => {
const source = fs.readFileSync(hookPath, 'utf-8');
expect(source).toContain('cleaned.length >= 3');
});
}
});
// ─── PostToolUse: git mutation regex coverage ───────────────────────
describe('Git mutation regex', () => {
const GIT_REGEX = /\\bgit\\s\+\(commit\|merge\|rebase\|cherry-pick\|pull\)/;
for (const [label, hookPath] of [
['CJS', CJS_HOOK],
['Plugin', PLUGIN_HOOK],
] as const) {
it(`${label} hook detects git commit`, () => {
const source = fs.readFileSync(hookPath, 'utf-8');
expect(source).toContain('commit');
});
it(`${label} hook detects git merge`, () => {
const source = fs.readFileSync(hookPath, 'utf-8');
expect(source).toContain('merge');
});
it(`${label} hook detects git rebase`, () => {
const source = fs.readFileSync(hookPath, 'utf-8');
expect(source).toContain('rebase');
});
it(`${label} hook detects git cherry-pick`, () => {
const source = fs.readFileSync(hookPath, 'utf-8');
expect(source).toContain('cherry-pick');
});
it(`${label} hook detects git pull`, () => {
const source = fs.readFileSync(hookPath, 'utf-8');
// 'pull' in the regex alternation
expect(source).toMatch(/commit\|merge\|rebase\|cherry-pick\|pull/);
});
}
});
// ─── Source code regression: PreToolUse concurrency guard (#1486) ──
describe('PreToolUse concurrency guard', () => {
for (const [label, hookPath, lockPath] of [
['CJS', CJS_HOOK, CJS_HOOK_LOCK],
['Plugin', PLUGIN_HOOK, PLUGIN_HOOK_LOCK],
] as const) {
it(`${label} hook loads acquireHookSlot helper`, () => {
const source = fs.readFileSync(hookPath, 'utf-8');
expect(source).toContain('acquireHookSlot');
expect(source).toContain('hook-lock');
});
it(`${label} helper defines acquireHookSlot`, () => {
const source = fs.readFileSync(lockPath, 'utf-8');
expect(source).toContain('function acquireHookSlot');
expect(source).toContain('HOOK_LOCK_MAX_INFLIGHT');
});
it(`${label} hook calls acquireHookSlot in handlePreToolUse`, () => {
const source = fs.readFileSync(hookPath, 'utf-8');
const preBody = source.slice(
source.indexOf('function handlePreToolUse'),
source.indexOf('function handlePostToolUse'),
);
expect(preBody).toContain('acquireHookSlot(');
expect(preBody).toMatch(/release\(\)/);
});
it(`${label} hook uses atomic fixed-name slot files (hard cap)`, () => {
// Regression for the TOCTOU soft-cap: an earlier revision counted
// entries then wrote a per-pid lock, which let simultaneous bursts
// exceed MAX_INFLIGHT. The hard-cap version writes to fixed-name
// slot-N.lock paths so O_CREAT|O_EXCL is atomic across processes.
const source = fs.readFileSync(lockPath, 'utf-8');
expect(source).toMatch(/slot-\$\{slot\}\.lock|`slot-/);
// And no longer reads the lock dir to count active hooks.
const slotFn = source.slice(
source.indexOf('function acquireHookSlot'),
source.indexOf('function', source.indexOf('function acquireHookSlot') + 1),
);
expect(slotFn).not.toContain('readdirSync');
});
it(`${label} hook fails closed when lock dir cannot be created`, () => {
// Regression: an earlier revision returned `() => {}` (truthy no-op) on
// mkdirSync failure, which left callers — `if (!release) return;` — to
// proceed unguarded and reintroduce the #1486 fan-out on read-only or
// cross-user `.gitnexus/` setups. The guard must fail closed (null).
const source = fs.readFileSync(lockPath, 'utf-8');
const slotFn = source.slice(
source.indexOf('function acquireHookSlot'),
source.indexOf('function', source.indexOf('function acquireHookSlot') + 1),
);
const mkdirCatch = slotFn.slice(
slotFn.indexOf('fs.mkdirSync(lockDir'),
slotFn.indexOf('const myPidStr'),
);
expect(mkdirCatch).toContain('return null');
expect(mkdirCatch).not.toMatch(/return\s*\(\s*\)\s*=>\s*\{\s*\}/);
});
}
});
// ─── Integration: concurrency guard skips when slots are full ──────
describe('PreToolUse concurrency guard (integration)', () => {
for (const [label, hookPath] of [
['CJS', CJS_HOOK],
['Plugin', PLUGIN_HOOK],
] as const) {
it(`${label}: hook exits silently when all MAX_INFLIGHT slots hold live pids`, async () => {
const { spawn } = await import('child_process');
const lockDir = path.join(gitNexusDir, '.hook-locks');
fs.mkdirSync(lockDir, { recursive: true });
// Spawn 3 long-sleeping node child processes to use as live PIDs.
const sleepers = [0, 1, 2].map(() =>
spawn(process.execPath, ['-e', 'setTimeout(()=>{},60000)'], {
stdio: 'ignore',
detached: false,
}),
);
const writtenLocks: string[] = [];
try {
for (let i = 0; i < sleepers.length; i++) {
// Slot files are named slot-N.lock; content is the owning PID.
const p = path.join(lockDir, `slot-${i}.lock`);
fs.writeFileSync(p, String(sleepers[i].pid));
writtenLocks.push(p);
}
const result = runHook(hookPath, {
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'validateUser' },
cwd: tmpDir,
});
expect(result.stdout.trim()).toBe('');
// Sentinel slot files survive; the hook bailed before claiming any of them.
for (let i = 0; i < sleepers.length; i++) {
const p = path.join(lockDir, `slot-${i}.lock`);
expect(fs.existsSync(p)).toBe(true);
// Owner unchanged.
expect(fs.readFileSync(p, 'utf-8').trim()).toBe(String(sleepers[i].pid));
}
} finally {
for (const child of sleepers) {
try {
child.kill();
} catch {
/* ignore */
}
}
for (const p of writtenLocks) {
try {
fs.unlinkSync(p);
} catch {
/* ignore */
}
}
try {
fs.rmdirSync(lockDir);
} catch {
/* ignore */
}
}
});
it(`${label}: hook reclaims a slot held by a dead pid`, () => {
const lockDir = path.join(gitNexusDir, '.hook-locks');
fs.mkdirSync(lockDir, { recursive: true });
// PID 1 exists on every POSIX system (init); on Windows process.kill(1,0)
// throws. Use a definitely-dead PID instead: a very large number unlikely
// to be assigned.
const deadPid = 2_147_483_640;
const stalePath = path.join(lockDir, 'slot-0.lock');
try {
fs.writeFileSync(stalePath, String(deadPid));
expect(fs.readFileSync(stalePath, 'utf-8').trim()).toBe(String(deadPid));
runHook(hookPath, {
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'validateUser' },
cwd: tmpDir,
});
// The hook reclaimed and then released slot-0 — either the file is
// gone (released) or its content is something other than the dead PID.
if (fs.existsSync(stalePath)) {
expect(fs.readFileSync(stalePath, 'utf-8').trim()).not.toBe(String(deadPid));
}
} finally {
try {
fs.unlinkSync(stalePath);
} catch {
/* already pruned */
}
try {
fs.rmdirSync(lockDir);
} catch {
/* ignore */
}
}
});
it(`${label}: hook does not exceed MAX_INFLIGHT under simultaneous bursts (hard cap)`, async () => {
// Spawn many hook processes concurrently and assert that at most
// MAX_INFLIGHT (3) slot files end up populated by live pids. The
// O_CREAT|O_EXCL slot scheme makes this a hard cap, not the soft cap
// that the count-then-claim approach gives.
const { spawn } = await import('child_process');
const lockDir = path.join(gitNexusDir, '.hook-locks');
// Clean any leftover slot files.
try {
for (const f of fs.readdirSync(lockDir)) fs.unlinkSync(path.join(lockDir, f));
} catch {
/* dir may not exist yet */
}
fs.mkdirSync(lockDir, { recursive: true });
// We use child workers that just claim a slot via the same algorithm
// and then sleep, so we can observe the on-disk state under contention
// without spawning the real gitnexus augment CLI.
const claimerScript = `
const fs = require('fs'); const path = require('path');
const lockDir = ${JSON.stringify(lockDir)};
const MAX = 3;
const STALE = 30000;
const myPid = String(process.pid);
function tryAcquire() {
for (let slot = 0; slot < MAX; slot++) {
const p = path.join(lockDir, 'slot-' + slot + '.lock');
for (let a = 0; a < 2; a++) {
try { fs.writeFileSync(p, myPid, { flag: 'wx' }); return p; }
catch {
let stat; try { stat = fs.statSync(p); } catch { continue; }
let live = false;
try {
const s = fs.readFileSync(p, 'utf-8').trim();
if (s === '') live = true;
else { const o = Number.parseInt(s, 10);
if (Number.isFinite(o) && o > 0) { try { process.kill(o, 0); live = true; } catch {} }
}
} catch {}
if (live && Date.now() - stat.mtimeMs > STALE) live = false;
if (live) break;
try { fs.unlinkSync(p); } catch {}
}
}
}
return null;
}
const claimed = tryAcquire();
if (claimed) {
process.stdout.write('CLAIMED:' + claimed + '\\n');
setTimeout(() => {}, 5000);
} else {
process.stdout.write('SKIPPED\\n');
}
`;
const N = 10;
const claimers = Array.from({ length: N }, () =>
spawn(process.execPath, ['-e', claimerScript], {
stdio: ['ignore', 'pipe', 'ignore'],
detached: false,
}),
);
try {
// Wait until every claimer has printed its decision.
const decisions = await Promise.all(
claimers.map(
(c) =>
new Promise<string>((resolve) => {
let buf = '';
c.stdout!.on('data', (d) => {
buf += d.toString();
if (buf.includes('\n')) resolve(buf.split('\n')[0]);
});
c.on('exit', () => resolve(buf.split('\n')[0] || 'EXIT'));
}),
),
);
const claimedCount = decisions.filter((d) => d.startsWith('CLAIMED:')).length;
const skippedCount = decisions.filter((d) => d === 'SKIPPED').length;
// HARD CAP: never more than 3 winners, regardless of how many bursts.
expect(claimedCount).toBeLessThanOrEqual(3);
// And the remainder must have all explicitly skipped.
expect(claimedCount + skippedCount).toBe(N);
// On-disk state matches.
const liveSlots = fs
.readdirSync(lockDir)
.filter((f) => /^slot-\d+\.lock$/.test(f))
.filter((f) => {
try {
const o = Number.parseInt(fs.readFileSync(path.join(lockDir, f), 'utf-8').trim(), 10);
return Number.isFinite(o) && o > 0;
} catch {
return false;
}
});
expect(liveSlots.length).toBeLessThanOrEqual(3);
} finally {
for (const c of claimers) {
try {
c.kill();
} catch {
/* ignore */
}
}
try {
for (const f of fs.readdirSync(lockDir)) fs.unlinkSync(path.join(lockDir, f));
} catch {
/* ignore */
}
try {
fs.rmdirSync(lockDir);
} catch {
/* ignore */
}
}
});
}
});
// ─── Source: cross-platform DB lock probe module (#1493) ─────────────
describe('Cross-platform DB lock probe (source)', () => {
for (const [label, hookPath, probePath] of [
['CJS', CJS_HOOK, CJS_HOOK_DB_PROBE],
['Plugin', PLUGIN_HOOK, PLUGIN_HOOK_DB_PROBE],
] as const) {
it(`${label} probe file exists`, () => {
expect(fs.existsSync(probePath)).toBe(true);
});
it(`${label} hook requires hook-db-lock-probe.cjs`, () => {
const source = fs.readFileSync(hookPath, 'utf-8');
expect(source).toContain("require('./hook-db-lock-probe.cjs')");
});
it(`${label} probe covers Linux /proc, Unix lsof, and Windows Restart Manager`, () => {
const p = fs.readFileSync(probePath, 'utf-8');
expect(p).toContain('win-rm-list-json.ps1');
expect(p).toContain('/proc/');
expect(p).toContain('linuxProcScanFindGitNexusServer');
expect(p).toContain('unixLsofPsFindGitNexusServer');
expect(p).toContain('hasGitNexusServerOwnerWindows');
expect(p).toContain('GITNEXUS_HOOK_LSOF_PATH');
expect(p).toContain('GITNEXUS_HOOK_POWERSHELL_PATH');
expect(p).toContain('GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS');
// #2163: lsof/ps orphan containment via a self-tested coreutils
// timeout/gtimeout wrapper.
expect(p).toContain('GITNEXUS_HOOK_TIMEOUT_PATH');
expect(p).toContain('resolveUnixGuardTimeout');
});
}
// T5 (#2163): the two probe copies were only kept in sync by convention
// (setup.ts copies the canonical gitnexus/hooks/claude/ file; the plugin
// ships its own). Enforce byte-parity in CI, mirroring the
// resolve-analyze-cmd.cjs parity test. `.gitattributes` pins `eol=lf`
// repo-wide, so the byte comparison is safe on the Windows lane too.
it('keeps the two hook-db-lock-probe.cjs copies byte-identical', () => {
expect(fs.readFileSync(CJS_HOOK_DB_PROBE, 'utf-8')).toBe(
fs.readFileSync(PLUGIN_HOOK_DB_PROBE, 'utf-8'),
);
});
});
// ─── Source: hook slot must gate the DB-owner probe (#2163) ──────────
describe('Hook slot gates the DB-owner probe (source order, #2163)', () => {
const ANTIGRAVITY_HOOK = path.resolve(
__dirname,
'..',
'..',
'hooks',
'antigravity',
'gitnexus-antigravity-hook.cjs',
);
// T1: pin cheap guards → acquireHookSlot → probe. The probe spawns lsof/ps,
// so it must sit BEHIND the per-repo slot cap; and the acquire must stay
// AFTER the cheap gating (extractPattern), or every tool call churns slot
// files. The antigravity adapter splits the cheap gating (extractPattern in
// buildAfterToolContext) from probe+augment (runAugment), so its slice
// spans both functions to express the same call-order contract.
for (const [label, hookPath, sliceStart, sliceEnd] of [
['CJS', CJS_HOOK, 'function handlePreToolUse', 'function handlePostToolUse'],
['Plugin', PLUGIN_HOOK, 'function handlePreToolUse', 'function handlePostToolUse'],
[
'Antigravity',
ANTIGRAVITY_HOOK,
'function buildAfterToolContext',
'function buildStaleIndexHint',
],
] as const) {
it(`${label}: extractPattern → acquireHookSlot → hasGitNexusServerOwner`, () => {
const source = fs.readFileSync(hookPath, 'utf-8');
const start = source.indexOf(sliceStart);
const end = source.indexOf(sliceEnd);
expect(start).toBeGreaterThanOrEqual(0);
expect(end).toBeGreaterThan(start);
const slice = source.slice(start, end);
const patternIdx = slice.indexOf('extractPattern(');
const acquireIdx = slice.indexOf('acquireHookSlot(');
const probeIdx = slice.indexOf('hasGitNexusServerOwner(');
expect(patternIdx).toBeGreaterThanOrEqual(0);
expect(acquireIdx).toBeGreaterThan(patternIdx);
expect(probeIdx).toBeGreaterThan(acquireIdx);
});
}
});
// ─── Behavior: slot-gated probe + wrapper-reaped orphans (#2163) ─────
describe.skipIf(process.platform === 'win32')(
'DB-owner probe is gated behind the hook slot (behavior, #2163)',
() => {
for (const [label, hookPath] of [
['CJS', CJS_HOOK],
['Plugin', PLUGIN_HOOK],
] as const) {
it(`${label}: when all slots are full, the lsof probe never runs`, async () => {
const { spawn } = await import('child_process');
const lockDir = path.join(gitNexusDir, '.hook-locks');
fs.mkdirSync(lockDir, { recursive: true });
// REQUIRED: the probe's first guard is
// `if (!fs.existsSync(dbPath)) return false;` — without a real lbug
// file the probe never reaches lsof even before the fix and this
// test would pass vacuously.
const lbugPath = path.join(gitNexusDir, 'lbug');
fs.writeFileSync(lbugPath, '');
const lsofMarkerPath = path.join(os.tmpdir(), `gn-hook-slotgate-${process.pid}-${label}`);
fs.rmSync(lsofMarkerPath, { force: true });
const binDir = createHookToolDir({
lsofMarkerPath,
lsofOutput: '12345\n',
psOutput: 'node /tmp/node_modules/.bin/gitnexus mcp\n',
});
// Fill all 3 slots with live sleeper PIDs (same pattern as the
// concurrency-guard integration tests above).
const sleepers = [0, 1, 2].map(() =>
spawn(process.execPath, ['-e', 'setTimeout(()=>{},60000)'], {
stdio: 'ignore',
detached: false,
}),
);
const writtenLocks: string[] = [];
try {
for (let i = 0; i < sleepers.length; i++) {
const p = path.join(lockDir, `slot-${i}.lock`);
fs.writeFileSync(p, String(sleepers[i].pid));
writtenLocks.push(p);
}
const result = runHook(
hookPath,
{
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'validateUser' },
cwd: tmpDir,
},
undefined,
{
env: {
...hookEnv(binDir),
// The slot gate rejects this invocation before the probe runs
// at all, so this budget never actually bounds a scan — it is
// set low only to keep the test fast in the (asserted-absent)
// case the gate ever regressed and let the probe through.
GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS: '1',
},
},
);
expect(result.stdout.trim()).toBe('');
// Core assertion: the probe (and therefore its lsof child) never
// ran — the slot gate now sits in front of it. Before the fix the
// probe ran un-gated and the marker existed.
expect(fs.existsSync(lsofMarkerPath)).toBe(false);
} finally {
for (const child of sleepers) {
try {
child.kill();
} catch {
/* ignore */
}
}
for (const p of writtenLocks) {
try {
fs.unlinkSync(p);
} catch {
/* ignore */
}
}
try {
fs.rmdirSync(lockDir);
} catch {
/* ignore */
}
fs.rmSync(lbugPath, { force: true });
fs.rmSync(lsofMarkerPath, { force: true });
fs.rmSync(binDir, { recursive: true, force: true });
}
});
}
// F5-3 (#2165 review): behavior-level slot-gate coverage for the
// ANTIGRAVITY adapter (the loop above only covers CJS/Plugin; the
// antigravity copy was pinned at source level only). The source adapter
// requires sibling helpers that live in hooks/claude/ — it is designed to
// be installed by copy (see the antigravity e2e suite) — so stage adapter
// + helpers into a temp dir and spawn that copy directly.
it('Antigravity: when all slots are full, the lsof probe never runs', async () => {
const { spawn } = await import('child_process');
const stageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-antigravity-stage-'));
const antigravitySrc = path.resolve(
__dirname,
'..',
'..',
'hooks',
'antigravity',
'gitnexus-antigravity-hook.cjs',
);
const claudeHooksDir = path.resolve(__dirname, '..', '..', 'hooks', 'claude');
const stagedHook = path.join(stageDir, 'gitnexus-antigravity-hook.cjs');
fs.copyFileSync(antigravitySrc, stagedHook);
for (const helper of [
'hook-lock.cjs',
'hook-db-lock-probe.cjs',
'resolve-analyze-cmd.cjs',
'win-rm-list-json.ps1',
]) {
fs.copyFileSync(path.join(claudeHooksDir, helper), path.join(stageDir, helper));
}
const lockDir = path.join(gitNexusDir, '.hook-locks');
fs.mkdirSync(lockDir, { recursive: true });
// REQUIRED: without a real lbug file the probe never reaches lsof even
// before the fix and this test would pass vacuously.
const lbugPath = path.join(gitNexusDir, 'lbug');
fs.writeFileSync(lbugPath, '');
const lsofMarkerPath = path.join(os.tmpdir(), `gn-hook-slotgate-${process.pid}-antigravity`);
fs.rmSync(lsofMarkerPath, { force: true });
const binDir = createHookToolDir({
lsofMarkerPath,
lsofOutput: '12345\n',
psOutput: 'node /tmp/node_modules/.bin/gitnexus mcp\n',
});
const sleepers = [0, 1, 2].map(() =>
spawn(process.execPath, ['-e', 'setTimeout(()=>{},60000)'], {
stdio: 'ignore',
detached: false,
}),
);
const writtenLocks: string[] = [];
try {
for (let i = 0; i < sleepers.length; i++) {
const p = path.join(lockDir, `slot-${i}.lock`);
fs.writeFileSync(p, String(sleepers[i].pid));
writtenLocks.push(p);
}
const result = runHook(
stagedHook,
{
hook_event_name: 'AfterTool',
tool_name: 'search_file_content',
tool_input: { pattern: 'validateUser' },
tool_response: { llmContent: '...' },
cwd: tmpDir,
},
undefined,
{
env: {
...hookEnv(binDir),
// '1', NOT '0' — see the CJS/Plugin slot-gate test above.
GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS: '1',
},
},
);
// Guards against a vacuous pass: if the staged copy crashes (e.g. a
// future sibling require missing from the staging list), stdout is
// empty and the marker absent for the wrong reason.
expect(result.status).toBe(0);
expect(result.stdout.trim()).toBe('');
// Core assertion: the probe (and therefore its lsof child) never ran —
// runAugment bailed at the slot gate before hasGitNexusServerOwner.
expect(fs.existsSync(lsofMarkerPath)).toBe(false);
} finally {
for (const child of sleepers) {
try {
child.kill();
} catch {
/* ignore */
}
}
for (const p of writtenLocks) {
try {
fs.unlinkSync(p);
} catch {
/* ignore */
}
}
try {
fs.rmdirSync(lockDir);
} catch {
/* ignore */
}
fs.rmSync(lbugPath, { force: true });
fs.rmSync(lsofMarkerPath, { force: true });
fs.rmSync(binDir, { recursive: true, force: true });
fs.rmSync(stageDir, { recursive: true, force: true });
}
});
},
);
// ─── #2180: the probe no longer spawns lsof on Linux ───────────────
//
// The 'Orphaned lsof is reaped by the timeout wrapper (#2163)' suite that
// lived here (T3 + the env-guard-points-at-a-directory fall-through test)
// drove the Linux probe to spawn a SIGTERM-immune fake lsof and asserted the
// coreutils `timeout -k 1` wrapper reaped it after the hook was SIGKILLed.
// #2180 replaced the O(procs×fds) scan + lsof fallback with a pure cmdline-
// first procfs scan and DELETED the Linux lsof leg entirely, so the probe can
// no longer create an lsof orphan on Linux by construction — there is nothing
// left for those tests to exercise. The wrapper-reaping mechanism they pinned
// is still covered where it still applies: the augment CLI child (the
// direct-exec and npx-grandchild reaping suites below) and the macOS/other-
// Unix lsof+ps path (the `Ladybug DB owner guard` suite, now relaned off
// Linux). The env-guard fall-through self-test behaviour is still pinned by
// the bad-wrapper / dir-guard guard-resolution tests in that relaned suite.
// ─── Behavior: SIGKILLed hook cannot strand the augment CLI (#2163 f-up) ──
describe.skipIf(process.platform !== 'linux')(
'Orphaned augment CLI is reaped by the timeout wrapper (#2163 follow-up)',
() => {
// Same incident mechanism as the lsof reaping suite above, one layer up:
// the augment CLI is the longest-lived hook child (7s local / 12s npx
// inner budgets), so a hook SIGKILLed mid-augment used to strand it with
// nothing left to signal it. These tests pin GITNEXUS_HOOK_CLI_PATH, so
// they exercise the DIRECT-EXEC branch only (the CLI is the guard's
// direct child): the fake CLI here is SIGTERM-immune and sleeps 30s;
// with the wrap in place the guard SIGTERMs it at 8s (= ceil(7000/1000)
// +1) and the `-k` escalation SIGKILLs it 1s later, so it must be gone
// well inside the 12s poll window. (The npx branch reaps differently —
// `-s KILL` group-kills at budget because the CLI is a grandchild there;
// see the staged npx suite below.) With runGitNexusCli's wrap reverted,
// nothing can reap it and the poll times out → red. The antigravity
// adapter shares the identical runGitNexusCli shape and is pinned at
// source level (see 'Augment CLI guard wrap (source)').
for (const [label, hookPath] of [
['CJS', CJS_HOOK],
['Plugin', PLUGIN_HOOK],
] as const) {
it(`${label}: SIGKILLed hook leaves no immortal augment CLI child`, async () => {
// Guard-availability precheck — see resolveHostGuardForReapingTests.
expect(resolveHostGuardForReapingTests(), GUARD_PRECHECK_MSG).not.toBeNull();
const { spawn } = await import('child_process');
// REQUIRED: a real lbug file means the probe runs. #2180 removed the
// Linux lsof fallback, so we route the probe at an EMPTY fake /proc
// (no gitnexus server holding the fd → not-owned) so the augment runs
// through the same probe-then-spawn flow as production. (Pre-#2180 this
// used GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS:'1' to fall through to a fake
// lsof; that path no longer exists on Linux — a '1' budget now fails
// CLOSED and would skip the augment entirely.)
const lbugPath = path.join(gitNexusDir, 'lbug');
fs.writeFileSync(lbugPath, '');
const emptyProcRoot = createFakeProcRoot([]);
const pidFile = path.join(os.tmpdir(), `gn-hook-clipid-${process.pid}-${label}`);
fs.rmSync(pidFile, { force: true });
const binDir = createHookToolDir({
gitnexusPidFile: pidFile,
gitnexusSleepMs: 30000,
gitnexusIgnoreSigterm: true,
lsofOutput: '',
psOutput: '',
});
let cliPid = 0;
let hookChild: ReturnType<typeof spawn> | null = null;
const isFakeCliAlive = () => {
try {
process.kill(cliPid, 0);
} catch {
return false; // ESRCH — reaped
}
// PID-reuse guard: only count it alive while the cmdline still
// points at our fake CLI.
try {
return fs.readFileSync(`/proc/${cliPid}/cmdline`, 'utf-8').includes(binDir);
} catch {
return false;
}
};
try {
hookChild = spawn(process.execPath, [hookPath], {
stdio: ['pipe', 'ignore', 'ignore'],
env: {
...hookEnv(binDir),
// #2180: empty fake /proc → scan completes as not-owned → augment
// runs (the path under test). Generous budget so the scan never
// times out and fails closed.
GITNEXUS_HOOK_PROC_ROOT: emptyProcRoot,
GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS: '5000',
// Hermeticity: a dev-shell GITNEXUS_HOOK_TIMEOUT_PATH=disabled
// would unwrap the CLI and fake-red this test. Empty string
// falls through to the built-in candidates (the path under test).
GITNEXUS_HOOK_TIMEOUT_PATH: '',
},
});
hookChild.stdin!.end(
JSON.stringify({
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'validateUser' },
cwd: tmpDir,
}),
);
// The fake CLI writes its PID as its FIRST statement; poll tightly.
const spawnDeadline = Date.now() + 8000;
while (Date.now() < spawnDeadline) {
try {
const raw = fs.readFileSync(pidFile, 'utf-8').trim();
if (raw) {
cliPid = Number.parseInt(raw, 10);
break;
}
} catch {
/* not written yet */
}
await new Promise((r) => setTimeout(r, 10));
}
expect(cliPid).toBeGreaterThan(0);
// Kill the hook while its augment CLI child is alive.
hookChild.kill('SIGKILL');
// Wrapper budget for the 7000ms call site is 8s, plus 1s `-k`
// grace — poll past that with margin, far short of the 30s sleep.
const reapDeadline = Date.now() + 12000;
let alive = isFakeCliAlive();
while (alive && Date.now() < reapDeadline) {
await new Promise((r) => setTimeout(r, 100));
alive = isFakeCliAlive();
}
expect(alive).toBe(false);
} finally {
// PID-reuse guard (#2169 review): re-run the detection loop's
// /proc/<pid>/cmdline identity check before the cleanup SIGKILL,
// so a PID already reaped and recycled by the OS is never
// signalled.
if (cliPid > 0 && isFakeCliAlive()) {
try {
process.kill(cliPid, 'SIGKILL');
} catch {
/* already gone */
}
}
try {
hookChild?.kill('SIGKILL');
} catch {
/* ignore */
}
// The hook claims a slot before probing; it died holding it.
const lockDir = path.join(gitNexusDir, '.hook-locks');
try {
for (const f of fs.readdirSync(lockDir)) fs.unlinkSync(path.join(lockDir, f));
} catch {
/* ignore */
}
try {
fs.rmdirSync(lockDir);
} catch {
/* ignore */
}
fs.rmSync(lbugPath, { force: true });
fs.rmSync(pidFile, { force: true });
fs.rmSync(binDir, { recursive: true, force: true });
fs.rmSync(emptyProcRoot, { recursive: true, force: true });
}
}, 30000);
}
},
);
// ─── Behavior: npx branch — SIGKILLed hook cannot strand the CLI grandchild ──
describe.skipIf(process.platform !== 'linux')(
'Orphaned npx-branch CLI grandchild is reaped by the -s KILL wrapper (#2163 follow-up)',
() => {
// The npx branch has a DEEPER topology than the direct-exec suite above:
// guard → npx → CLI, so the CLI is the guard's GRANDCHILD. Under the
// TERM-first `-k 1` guard the budget's group SIGTERM kills the obedient
// npx parent; `timeout` reaps its direct child and exits IMMEDIATELY, so
// its `-k` SIGKILL never fires — and a SIGTERM-immune CLI grandchild
// survives unbounded (reproduced on coreutils 9.x). The `-s KILL`
// wrapped arm instead SIGKILLs the whole process group at budget
// (13s = ceil((7000+5000)/1000)+1 here), which nothing can ignore.
// Reverting the npx arm to plain `-k 1` TERM-first makes this test red.
//
// Topology notes: the hook is STAGED into a bare temp dir together with
// its sibling helpers (the install-shaped copy, like the antigravity e2e
// suite uses), so resolveCliPath() finds no local dist/ and no
// resolvable gitnexus package; with GITNEXUS_HOOK_CLI_PATH cleared the
// npx fallback branch is the one that runs. A fake `npx` injected on
// PATH then spawns the SIGTERM-immune fake CLI as its own child and
// waits on it, mirroring the real npx process tree.
it('CJS (staged): SIGKILLed hook leaves no immortal CLI grandchild behind npx', async () => {
// Guard-availability precheck — see resolveHostGuardForReapingTests.
expect(resolveHostGuardForReapingTests(), GUARD_PRECHECK_MSG).not.toBeNull();
const { spawn } = await import('child_process');
// REQUIRED: a real lbug file means the probe runs. #2180 removed the
// Linux lsof fallback, so we route the probe at an EMPTY fake /proc
// (not-owned) so the augment runs through the same probe-then-spawn flow
// as production. (Pre-#2180 this used BUDGET_MS:'1' to fall through to a
// fake lsof; that path no longer exists on Linux.)
const lbugPath = path.join(gitNexusDir, 'lbug');
fs.writeFileSync(lbugPath, '');
const emptyProcRoot = createFakeProcRoot([]);
const pidFile = path.join(os.tmpdir(), `gn-hook-npxclipid-${process.pid}`);
fs.rmSync(pidFile, { force: true });
// Route self-proof (#2169 review): written by the fake npx as its first
// statement, so the test fails loudly if a future resolveCliPath /
// hookEnv change silently re-routes the augment to the direct arm.
const npxMarkerPath = path.join(os.tmpdir(), `gn-hook-npxmarker-${process.pid}`);
fs.rmSync(npxMarkerPath, { force: true });
const binDir = createHookToolDir({
gitnexusPidFile: pidFile,
gitnexusSleepMs: 30000,
gitnexusIgnoreSigterm: true,
lsofOutput: '',
psOutput: '',
});
// Fake npx: spawns the fake CLI as the guard's grandchild and waits on
// it like real npx; npx itself stays SIGTERM-obedient (Node default).
fs.writeFileSync(
path.join(binDir, 'npx'),
`#!/usr/bin/env node\n` +
`require('fs').writeFileSync(${JSON.stringify(npxMarkerPath)}, String(process.pid));\n` +
`const { spawn } = require('child_process');\n` +
`const child = spawn(process.execPath, [${JSON.stringify(
path.join(binDir, 'gitnexus-cli.js'),
)}], { stdio: 'ignore' });\n` +
`child.on('exit', (code) => process.exit(code === null ? 1 : code));\n`,
{ mode: 0o755 },
);
// Stage the hook + its sibling helpers into a bare dir with no dist/
// and no reachable node_modules/gitnexus, so resolveCliPath() → ''.
const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-staged-hook-'));
const hookSrcDir = path.dirname(CJS_HOOK);
for (const f of [
'gitnexus-hook.cjs',
'hook-lock.cjs',
'hook-db-lock-probe.cjs',
'resolve-analyze-cmd.cjs',
]) {
fs.copyFileSync(path.join(hookSrcDir, f), path.join(stagedDir, f));
}
const stagedHook = path.join(stagedDir, 'gitnexus-hook.cjs');
let cliPid = 0;
let hookChild: ReturnType<typeof spawn> | null = null;
const isFakeCliAlive = () => {
try {
process.kill(cliPid, 0);
} catch {
return false; // ESRCH — reaped
}
// PID-reuse guard: only count it alive while the cmdline still
// points at our fake CLI.
try {
return fs.readFileSync(`/proc/${cliPid}/cmdline`, 'utf-8').includes(binDir);
} catch {
return false;
}
};
try {
hookChild = spawn(process.execPath, [stagedHook], {
stdio: ['pipe', 'ignore', 'ignore'],
env: {
...hookEnv(binDir),
// Force the npx fallback: no CLI-path override (empty string
// fails resolveCliPath's trim check), and nothing for the staged
// copy's require.resolve to find via NODE_PATH.
GITNEXUS_HOOK_CLI_PATH: '',
NODE_PATH: '',
// #2180: empty fake /proc → not-owned → augment runs. Generous
// budget so the scan completes rather than failing closed.
GITNEXUS_HOOK_PROC_ROOT: emptyProcRoot,
GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS: '5000',
// Hermeticity: fall through to the built-in guard candidates.
GITNEXUS_HOOK_TIMEOUT_PATH: '',
},
});
hookChild.stdin!.end(
JSON.stringify({
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'validateUser' },
cwd: tmpDir,
}),
);
// The fake CLI writes its PID as its FIRST statement; poll tightly.
const spawnDeadline = Date.now() + 8000;
while (Date.now() < spawnDeadline) {
try {
const raw = fs.readFileSync(pidFile, 'utf-8').trim();
if (raw) {
cliPid = Number.parseInt(raw, 10);
break;
}
} catch {
/* not written yet */
}
await new Promise((r) => setTimeout(r, 10));
}
expect(cliPid).toBeGreaterThan(0);
// The augment really took the npx fallback arm, not the direct arm.
expect(fs.existsSync(npxMarkerPath)).toBe(true);
// Kill the hook while the npx → CLI chain is alive (orphan topology).
hookChild.kill('SIGKILL');
// The npx call site's wrapper budget is 13s (= ceil((7000+5000)/
// 1000)+1) from the guard's start; the group SIGKILL lands then.
// Poll past it with margin, far short of the CLI's 30s sleep.
const reapDeadline = Date.now() + 18000;
let alive = isFakeCliAlive();
while (alive && Date.now() < reapDeadline) {
await new Promise((r) => setTimeout(r, 100));
alive = isFakeCliAlive();
}
expect(alive).toBe(false);
} finally {
// PID-reuse guard (#2169 review): re-run the detection loop's
// /proc/<pid>/cmdline identity check before the cleanup SIGKILL, so
// a PID already reaped and recycled by the OS is never signalled.
if (cliPid > 0 && isFakeCliAlive()) {
try {
process.kill(cliPid, 'SIGKILL');
} catch {
/* already gone */
}
}
try {
hookChild?.kill('SIGKILL');
} catch {
/* ignore */
}
// The hook claims a slot before probing; it died holding it.
const lockDir = path.join(gitNexusDir, '.hook-locks');
try {
for (const f of fs.readdirSync(lockDir)) fs.unlinkSync(path.join(lockDir, f));
} catch {
/* ignore */
}
try {
fs.rmdirSync(lockDir);
} catch {
/* ignore */
}
fs.rmSync(lbugPath, { force: true });
fs.rmSync(pidFile, { force: true });
fs.rmSync(npxMarkerPath, { force: true });
fs.rmSync(stagedDir, { recursive: true, force: true });
fs.rmSync(binDir, { recursive: true, force: true });
fs.rmSync(emptyProcRoot, { recursive: true, force: true });
}
}, 45000);
},
);
// ─── Wrapping equivalence: disabled guard ⇒ pre-wrap augment behavior ──
describe.skipIf(process.platform === 'win32')(
'Augment CLI guard wrap degrades cleanly when disabled (#2163 follow-up)',
() => {
// T6-style equivalence pin for the AUGMENT path: with the wrapper
// explicitly off (the `disabled` sentinel, NOT a bogus path — invalid
// values fall through to the real candidate list by design) the augment
// path must behave exactly as it did before the wrap existed: the probe
// fails open on an idle DB, the CLI runs unwrapped, and its context is
// emitted verbatim. (The wrapped arm's equivalence is covered by the
// whole existing augment suite, which now runs under the host's real
// guard on the Linux/macOS lanes.)
for (const [label, hookPath] of [
['CJS', CJS_HOOK],
['Plugin', PLUGIN_HOOK],
] as const) {
it(`${label}: augment runs and emits context with the wrapper disabled`, () => {
const markerPath = path.join(os.tmpdir(), `gn-hook-nowrap-aug-${process.pid}-${label}`);
const lbugPath = path.join(gitNexusDir, 'lbug');
fs.writeFileSync(lbugPath, '');
fs.rmSync(markerPath, { force: true });
const binDir = createHookToolDir({
gitnexusMarkerPath: markerPath,
gitnexusStderr: '[GitNexus] 1 related symbol found:\n\nvalidateUser (src/auth.ts)\n',
lsofOutput: '',
psOutput: '',
});
try {
const result = runHook(
hookPath,
{
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'validateUser' },
cwd: tmpDir,
},
undefined,
{
env: {
...hookEnv(binDir),
GITNEXUS_HOOK_TIMEOUT_PATH: 'disabled',
},
},
);
const output = parseHookOutput(result.stdout);
expect(output).not.toBeNull();
expect(output!.additionalContext).toContain('[GitNexus] 1 related symbol found');
expect(fs.existsSync(markerPath)).toBe(true);
} finally {
fs.rmSync(lbugPath, { force: true });
fs.rmSync(markerPath, { force: true });
fs.rmSync(binDir, { recursive: true, force: true });
}
});
}
},
);
// ─── Source: augment CLI guard wrap present in every adapter (#2163 f-up) ──
describe('Augment CLI guard wrap (source, #2163 follow-up)', () => {
const ANTIGRAVITY_HOOK = path.resolve(
__dirname,
'..',
'..',
'hooks',
'antigravity',
'gitnexus-antigravity-hook.cjs',
);
// The cursor integration is deliberately ABSENT from this list: it does
// not install hook-db-lock-probe.cjs (see gitnexus-cursor-integration/
// README.md "What's installed manually vs. automated"), so there is no
// resolver sibling to require — wrapping its augment child is the
// "cursor probe" item on the #2163 follow-up list.
for (const [label, hookPath] of [
['CJS', CJS_HOOK],
['Plugin', PLUGIN_HOOK],
['Antigravity', ANTIGRAVITY_HOOK],
] as const) {
it(`${label}: runGitNexusCli wraps via resolveUnixGuardTimeout with a ceil(ms/1000)+1 budget`, () => {
const source = fs.readFileSync(hookPath, 'utf-8');
const start = source.indexOf('function runGitNexusCli');
expect(start).toBeGreaterThanOrEqual(0);
const end = source.indexOf('\nfunction ', start + 1);
const fn = source.slice(start, end === -1 ? undefined : end);
// Consults the probe's exported resolver (memo shared with the probe),
// and never on Windows — the npx.cmd / gitnexus.cmd argv stay exactly
// as before the wrap. The typeof check is the probe version-skew guard
// (#2169 review): an old probe without the resolveUnixGuardTimeout
// export must degrade to the unwrapped argv, not throw a TypeError
// that the caller's catch swallows into a silently dead augment.
expect(fn).toMatch(
/isWin \|\| typeof resolveUnixGuardTimeout !== 'function'\s*\?\s*null\s*:\s*resolveUnixGuardTimeout\(\)/,
);
// Coreutils `-k 1` escalation…
expect(fn).toContain("'-k',");
// …with a budget STRICTLY above each branch's inner spawnSync timeout:
// ceil(inner/1000)+1 for both the direct (timeout) and npx
// (timeout + 5000) call sites. The direct-budget formula is counted
// exactly — once per wrapped direct-exec branch (the Plugin adapter has
// two: GITNEXUS_HOOK_CLI_PATH and the PATH-direct `gitnexus` branch,
// its most common production path) — so a partial revert of any single
// branch cannot pass unnoticed.
const directBudgetCount = (fn.match(/Math\.ceil\(timeout \/ 1000\) \+ 1/g) ?? []).length;
expect(directBudgetCount).toBe(label === 'Plugin' ? 2 : 1);
expect(fn).toMatch(/Math\.ceil\(\(timeout \+ 5000\) \/ 1000\) \+ 1/);
// Argv-order pin (#2169 review): the budget token must appear BEFORE
// the command word — `timeout … <budget> <cmd>` — or coreutils would
// parse the command word as its DURATION argument. Token presence and
// the counts above alone would let a transposed argv pass. Every
// direct-exec budget must be immediately followed by its command token
// (process.execPath, or the PATH-direct 'gitnexus' on Plugin), and the
// npx budget by 'npx'.
const directOrderCount = (
fn.match(
/String\(Math\.ceil\(timeout \/ 1000\) \+ 1\),\s*(?:process\.execPath|'gitnexus')/g,
) ?? []
).length;
expect(directOrderCount).toBe(label === 'Plugin' ? 2 : 1);
expect(fn).toMatch(/String\(Math\.ceil\(\(timeout \+ 5000\) \/ 1000\) \+ 1\),\s*'npx'/);
// npx-branch grandchild containment (#2169 review): the npx wrapped
// arm must SIGKILL the process group at budget (`-s KILL`) — a group
// SIGTERM there kills only the obedient npx parent, `timeout` returns
// before its `-k` escalation fires, and a SIGTERM-immune CLI
// grandchild escapes unbounded.
expect(fn).toMatch(
/'-s',\s*'KILL',\s*'-k',\s*'1',\s*String\(Math\.ceil\(\(timeout \+ 5000\)/,
);
// …and the direct-exec arm(s) must NOT lead with `-s KILL`: TERM-first
// is gentler and sufficient there (the CLI is the guard's direct
// child), so `-s` appears exactly once — in the npx arm.
expect((fn.match(/'-s',/g) ?? []).length).toBe(1);
});
}
for (const [label, probePath] of [
['CJS', CJS_HOOK_DB_PROBE],
['Plugin', PLUGIN_HOOK_DB_PROBE],
] as const) {
it(`${label} probe exports resolveUnixGuardTimeout`, () => {
const source = fs.readFileSync(probePath, 'utf-8');
const exportsSlice = source.slice(source.indexOf('module.exports'));
expect(exportsSlice).toContain('resolveUnixGuardTimeout');
});
}
});
// ─── Source: cursor hook slot-skip diagnostic (#2163 follow-up) ─────
describe('Cursor hook slot-skip diagnostic (source, #2163 follow-up)', () => {
const CURSOR_HOOK = path.resolve(
__dirname,
'..',
'..',
'..',
'gitnexus-cursor-integration',
'hooks',
'gitnexus-hook.cjs',
);
it('debug-gates the slot-saturated skip under the cursor truthy convention', () => {
const source = fs.readFileSync(CURSOR_HOOK, 'utf-8');
const idx = source.indexOf('augment skipped: hook slots saturated');
expect(idx).toBeGreaterThanOrEqual(0);
// Must sit inside the cursor hook's own debug gate (truthy
// `process.env.GITNEXUS_DEBUG`, unlike the claude adapters' strict
// '1'/'true' gate) so the default path stays silent.
const before = source.slice(Math.max(0, idx - 600), idx);
expect(before).toContain('process.env.GITNEXUS_DEBUG');
});
});
// ─── Integration: PreToolUse augmentation filtering (#1492) ─────────
describe('PreToolUse augmentation filtering (integration)', () => {
for (const [label, hookPath] of [
['CJS', CJS_HOOK],
['Plugin', PLUGIN_HOOK],
] as const) {
it(`${label}: emits valid GitNexus augmentation context`, () => {
const binDir = createHookToolDir({
gitnexusStderr: '[GitNexus] 1 related symbol found:\n\nvalidateUser (src/auth.ts)\n',
});
try {
const result = runHook(
hookPath,
{
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'validateUser' },
cwd: tmpDir,
},
undefined,
{ env: hookEnv(binDir) },
);
const output = parseHookOutput(result.stdout);
expect(output).not.toBeNull();
expect(output!.hookEventName).toBe('PreToolUse');
expect(output!.additionalContext).toContain('[GitNexus] 1 related symbol found');
} finally {
fs.rmSync(binDir, { recursive: true, force: true });
}
});
it(`${label}: suppresses LadybugDB lock warnings from augment stderr`, () => {
const markerPath = path.join(os.tmpdir(), 'gn-hook-lockwarn-' + process.pid + '-' + label);
fs.rmSync(markerPath, { force: true });
const binDir = createHookToolDir({
gitnexusMarkerPath: markerPath,
gitnexusStderr:
'GitNexus: FTS extension load failed: IO exception: Could not set lock on file : /tmp/repo/.gitnexus/lbug\n',
});
try {
const result = runHook(
hookPath,
{
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'validateUser' },
cwd: tmpDir,
},
undefined,
{ env: hookEnv(binDir) },
);
expect(result.stdout.trim()).toBe('');
expect(fs.existsSync(markerPath)).toBe(true);
// Finding #18: when GITNEXUS_DEBUG=1 is set, the discarded prefix is
// recoverable on the hook's stderr (not silently dropped).
const debugResult = runHook(
hookPath,
{
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'validateUser' },
cwd: tmpDir,
},
undefined,
{ env: { ...hookEnv(binDir), GITNEXUS_DEBUG: '1' } },
);
expect(debugResult.stderr).toContain('augment stderr discarded prefix');
expect(debugResult.stderr).toContain('Could not set lock on file');
} finally {
fs.rmSync(markerPath, { force: true });
fs.rmSync(binDir, { recursive: true, force: true });
}
});
// Issue #1913: the MCP-owned-DB skip is a NORMAL (non-error) path, so by
// default it must stay completely silent — empty stdout AND empty stderr,
// exit 0 — so strict hook runners (e.g. Codex `PreToolUse`) never see
// unexpected output. GITNEXUS_DEBUG is forced off to keep the assertion
// deterministic regardless of the ambient environment.
it.skipIf(SKIP_LSOF_PATH)(
`${label}: skips augment SILENTLY when a GitNexus MCP process owns the repo DB`,
() => {
const markerPath = path.join(os.tmpdir(), `gitnexus-hook-called-${process.pid}-${label}`);
const lbugPath = path.join(gitNexusDir, 'lbug');
fs.writeFileSync(lbugPath, '');
fs.rmSync(markerPath, { force: true });
const binDir = createHookToolDir({
gitnexusMarkerPath: markerPath,
lsofOutput: '12345\n',
psOutput: 'node /tmp/node_modules/.bin/gitnexus mcp\n',
});
try {
const result = runHook(
hookPath,
{
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'validateUser' },
cwd: tmpDir,
},
undefined,
{ env: { ...hookEnv(binDir), GITNEXUS_DEBUG: '' } },
);
expect(result.stdout.trim()).toBe('');
expect(result.stderr.trim()).toBe('');
expect(result.status).toBe(0);
expect(fs.existsSync(markerPath)).toBe(false);
} finally {
fs.rmSync(lbugPath, { force: true });
fs.rmSync(markerPath, { force: true });
fs.rmSync(binDir, { recursive: true, force: true });
}
},
);
// Issue #1913: the skip reason remains recoverable for operators who opt in
// via GITNEXUS_DEBUG=1 — stdout stays empty (no augment ran), the diagnostic
// appears on stderr.
it.skipIf(SKIP_LSOF_PATH)(
`${label}: surfaces the MCP-owner skip reason only under GITNEXUS_DEBUG`,
() => {
const markerPath = path.join(os.tmpdir(), `gitnexus-hook-dbg-${process.pid}-${label}`);
const lbugPath = path.join(gitNexusDir, 'lbug');
fs.writeFileSync(lbugPath, '');
fs.rmSync(markerPath, { force: true });
const binDir = createHookToolDir({
gitnexusMarkerPath: markerPath,
lsofOutput: '12345\n',
psOutput: 'node /tmp/node_modules/.bin/gitnexus mcp\n',
});
try {
const result = runHook(
hookPath,
{
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'validateUser' },
cwd: tmpDir,
},
undefined,
{ env: { ...hookEnv(binDir), GITNEXUS_DEBUG: '1' } },
);
expect(result.stdout.trim()).toBe('');
expect(result.status).toBe(0);
expect(result.stderr).toContain('[GitNexus] augment skipped: MCP server owns DB');
expect(fs.existsSync(markerPath)).toBe(false);
} finally {
fs.rmSync(lbugPath, { force: true });
fs.rmSync(markerPath, { force: true });
fs.rmSync(binDir, { recursive: true, force: true });
}
},
);
// #1913: the GITNEXUS_DEBUG contract is strict — ONLY '1' and 'true' enable
// diagnostics. Pin that non-canonical truthy-looking values ('0', 'false')
// are treated as OFF, so the skip stays silent. A truthy-gated reader would
// have emitted on these; this guards the unified strict gate (incl. the
// main() catch handler) across the claude/plugin copies.
for (const debugValue of ['0', 'false']) {
it.skipIf(SKIP_LSOF_PATH)(
`${label}: MCP-owner skip stays SILENT with GITNEXUS_DEBUG='${debugValue}' (strict contract)`,
() => {
const markerPath = path.join(
os.tmpdir(),
`gitnexus-hook-dbg-${debugValue}-${process.pid}-${label}`,
);
const lbugPath = path.join(gitNexusDir, 'lbug');
fs.writeFileSync(lbugPath, '');
fs.rmSync(markerPath, { force: true });
const binDir = createHookToolDir({
gitnexusMarkerPath: markerPath,
lsofOutput: '12345\n',
psOutput: 'node /tmp/node_modules/.bin/gitnexus mcp\n',
});
try {
const result = runHook(
hookPath,
{
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'validateUser' },
cwd: tmpDir,
},
undefined,
{ env: { ...hookEnv(binDir), GITNEXUS_DEBUG: debugValue } },
);
expect(result.stdout.trim()).toBe('');
expect(result.stderr.trim()).toBe('');
expect(result.status).toBe(0);
expect(fs.existsSync(markerPath)).toBe(false);
} finally {
fs.rmSync(lbugPath, { force: true });
fs.rmSync(markerPath, { force: true });
fs.rmSync(binDir, { recursive: true, force: true });
}
},
);
}
}
});
describe.skipIf(SKIP_LSOF_PATH)(
'Ladybug DB owner guard — production-shaped ps + failure modes (#1493)',
() => {
// These tests assert owner *detection* via the lsof + ps backend: a positive
// skip is signalled by the `[GitNexus] augment skipped` diagnostic. Since
// #1913 made that diagnostic debug-gated (silent by default for strict hook
// runners), they run with GITNEXUS_DEBUG=1 so the discriminator remains
// observable. Default-silence itself is covered by the 'augmentation
// filtering' describe above.
//
// #2180: skipped on Linux (SKIP_LSOF_PATH) — Linux no longer routes through
// lsof/ps, so these would no longer exercise the real dispatch there. They
// stay as the macOS/other-Unix lsof+ps lane; the equivalent Linux owner-
// detection (incl. the EACCES / cross-user fail-closed edge and the budget
// timeout fail-closed) is covered directly against a fake /proc in
// test/unit/hook-db-lock-probe.test.ts.
for (const [label, hookPath] of [
['CJS', CJS_HOOK],
['Plugin', PLUGIN_HOOK],
] as const) {
it(`${label}: skips augment for real node_modules/gitnexus ps line (npx child)`, () => {
const markerPath = path.join(os.tmpdir(), `gn-hook-prodps-${process.pid}-${label}`);
const lbugPath = path.join(gitNexusDir, 'lbug');
fs.writeFileSync(lbugPath, '');
fs.rmSync(markerPath, { force: true });
const binDir = createHookToolDir({
gitnexusMarkerPath: markerPath,
lsofOutput: '99901\n',
psOutput: 'node /tmp/node_modules/gitnexus/dist/cli/index.js mcp\n',
});
try {
const result = runHook(
hookPath,
{
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'validateUser' },
cwd: tmpDir,
},
undefined,
{ env: { ...hookEnv(binDir), GITNEXUS_DEBUG: '1' } },
);
expect(result.stdout.trim()).toBe('');
expect(result.status).toBe(0);
expect(result.stderr).toContain('[GitNexus] augment skipped');
expect(fs.existsSync(markerPath)).toBe(false);
} finally {
fs.rmSync(markerPath, { force: true });
fs.rmSync(binDir, { recursive: true, force: true });
}
});
it(`${label}: npx parent command line is NOT treated as GitNexus server owner`, () => {
const markerPath = path.join(os.tmpdir(), `gn-hook-npx-${process.pid}-${label}`);
const lbugPath = path.join(gitNexusDir, 'lbug');
fs.writeFileSync(lbugPath, '');
fs.rmSync(markerPath, { force: true });
const binDir = createHookToolDir({
gitnexusMarkerPath: markerPath,
gitnexusStderr: '[GitNexus] 1 related symbol found:\n\nvalidateUser (src/auth.ts)\n',
lsofOutput: '99902\n',
psOutput: 'npx -y gitnexus@latest mcp\n',
});
try {
const result = runHook(
hookPath,
{
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'validateUser' },
cwd: tmpDir,
},
undefined,
{ env: hookEnv(binDir) },
);
const output = parseHookOutput(result.stdout);
expect(output).not.toBeNull();
expect(fs.existsSync(markerPath)).toBe(true);
} finally {
fs.rmSync(markerPath, { force: true });
fs.rmSync(binDir, { recursive: true, force: true });
}
});
it(`${label}: skips augment for gitnexus serve child`, () => {
const markerPath = path.join(os.tmpdir(), `gn-hook-serve-${process.pid}-${label}`);
const lbugPath = path.join(gitNexusDir, 'lbug');
fs.writeFileSync(lbugPath, '');
fs.rmSync(markerPath, { force: true });
const binDir = createHookToolDir({
gitnexusMarkerPath: markerPath,
lsofOutput: '99903\n',
psOutput: 'node /repo/node_modules/gitnexus/dist/cli/index.js serve\n',
});
try {
const result = runHook(
hookPath,
{
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'validateUser' },
cwd: tmpDir,
},
undefined,
{ env: { ...hookEnv(binDir), GITNEXUS_DEBUG: '1' } },
);
expect(result.stdout.trim()).toBe('');
expect(result.status).toBe(0);
expect(result.stderr).toContain('[GitNexus] augment skipped');
expect(fs.existsSync(markerPath)).toBe(false);
} finally {
fs.rmSync(markerPath, { force: true });
fs.rmSync(binDir, { recursive: true, force: true });
}
});
it(`${label}: ENOENT lsof → augment still runs (fail-open)`, () => {
const markerPath = path.join(os.tmpdir(), `gn-hook-enoent-${process.pid}-${label}`);
const lbugPath = path.join(gitNexusDir, 'lbug');
fs.writeFileSync(lbugPath, '');
fs.rmSync(markerPath, { force: true });
const binDir = createHookToolDir({
gitnexusMarkerPath: markerPath,
gitnexusStderr: '[GitNexus] 1 related symbol found:\n\nvalidateUser (src/auth.ts)\n',
lsofOutput: '',
psOutput: '',
});
try {
const env = {
...hookEnv(binDir),
GITNEXUS_HOOK_LSOF_PATH: path.join(binDir, '__missing_lsof__'),
};
const result = runHook(
hookPath,
{
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'validateUser' },
cwd: tmpDir,
},
undefined,
{ env },
);
const output = parseHookOutput(result.stdout);
expect(output).not.toBeNull();
expect(fs.existsSync(markerPath)).toBe(true);
} finally {
fs.rmSync(markerPath, { force: true });
fs.rmSync(binDir, { recursive: true, force: true });
}
});
it(`${label}: ETIMEDOUT lsof → augment skipped (fail-closed)`, () => {
const markerPath = path.join(os.tmpdir(), `gn-hook-etime-${process.pid}-${label}`);
const lbugPath = path.join(gitNexusDir, 'lbug');
fs.writeFileSync(lbugPath, '');
fs.rmSync(markerPath, { force: true });
const binDir = createHookToolDir({
gitnexusMarkerPath: markerPath,
lsofSleepMs: 5000,
psOutput: '',
});
try {
const result = runHook(
hookPath,
{
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'validateUser' },
cwd: tmpDir,
},
undefined,
{ env: { ...hookEnv(binDir), GITNEXUS_DEBUG: '1' } },
);
expect(result.stdout.trim()).toBe('');
expect(result.status).toBe(0);
expect(result.stderr).toContain('[GitNexus] augment skipped');
expect(fs.existsSync(markerPath)).toBe(false);
} finally {
fs.rmSync(markerPath, { force: true });
fs.rmSync(binDir, { recursive: true, force: true });
}
});
// #1913: the fail-closed (probe-timeout) skip routes through the SAME gated
// line as the MCP-owner skip, so it too must be silent by default. Symmetric
// counterpart to the debug-on test above, so a regression that ungated the
// ETIMEDOUT path specifically would still be caught.
it(`${label}: ETIMEDOUT lsof → augment skipped SILENTLY by default`, () => {
const markerPath = path.join(os.tmpdir(), `gn-hook-etime-silent-${process.pid}-${label}`);
const lbugPath = path.join(gitNexusDir, 'lbug');
fs.writeFileSync(lbugPath, '');
fs.rmSync(markerPath, { force: true });
const binDir = createHookToolDir({
gitnexusMarkerPath: markerPath,
lsofSleepMs: 5000,
psOutput: '',
});
try {
const result = runHook(
hookPath,
{
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'validateUser' },
cwd: tmpDir,
},
undefined,
{ env: { ...hookEnv(binDir), GITNEXUS_DEBUG: '' } },
);
expect(result.stdout.trim()).toBe('');
expect(result.stderr.trim()).toBe('');
expect(result.status).toBe(0);
expect(fs.existsSync(markerPath)).toBe(false);
} finally {
fs.rmSync(lbugPath, { force: true });
fs.rmSync(markerPath, { force: true });
fs.rmSync(binDir, { recursive: true, force: true });
}
});
// T6 (#2163): with the timeout wrapper explicitly disabled the probe must
// degrade to EXACTLY the pre-wrapper behavior — lsof ETIMEDOUT stays
// fail-closed and the augment is silently skipped. Uses the `disabled`
// sentinel, NOT a bogus path: an invalid GITNEXUS_HOOK_TIMEOUT_PATH falls
// through to the real candidate list by design. CI lane note: the sibling
// wrapped ETIMEDOUT tests above exercise GNU /usr/bin/timeout on the
// Linux lane, and on macos-latest hit BSD /usr/bin/timeout (macOS ≥13,
// `-k`-compatible) or Homebrew gtimeout — a de-facto BSD-wrapper
// regression test.
it(`${label}: ETIMEDOUT lsof with wrapper disabled → identical fail-closed skip`, () => {
const markerPath = path.join(os.tmpdir(), `gn-hook-nowrap-${process.pid}-${label}`);
const lbugPath = path.join(gitNexusDir, 'lbug');
fs.writeFileSync(lbugPath, '');
fs.rmSync(markerPath, { force: true });
const binDir = createHookToolDir({
gitnexusMarkerPath: markerPath,
lsofSleepMs: 5000,
psOutput: '',
});
try {
const result = runHook(
hookPath,
{
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'validateUser' },
cwd: tmpDir,
},
undefined,
{
env: {
...hookEnv(binDir),
GITNEXUS_DEBUG: '1',
GITNEXUS_HOOK_TIMEOUT_PATH: 'disabled',
},
},
);
expect(result.stdout.trim()).toBe('');
expect(result.status).toBe(0);
expect(result.stderr).toContain('[GitNexus] augment skipped');
expect(fs.existsSync(markerPath)).toBe(false);
} finally {
fs.rmSync(lbugPath, { force: true });
fs.rmSync(markerPath, { force: true });
fs.rmSync(binDir, { recursive: true, force: true });
}
});
// T7 (#2163): a wrapper that fails the `-k` self-test (busybox <1.34,
// toybox, broken symlink…) must be REJECTED — resolution falls through
// to the built-in candidates (or, with none usable, to the unwrapped
// status quo); either way ETIMEDOUT stays fail-closed. Adopted blindly,
// the bad wrapper would exit with a usage error before ever running
// lsof — empty stdout, status≠0, no ETIMEDOUT — silently flipping the
// fail-closed contract to fail-open.
it(`${label}: bad wrapper (no -k support) is rejected by the self-test → still fail-closed`, () => {
const markerPath = path.join(os.tmpdir(), `gn-hook-badwrap-${process.pid}-${label}`);
const lbugPath = path.join(gitNexusDir, 'lbug');
fs.writeFileSync(lbugPath, '');
fs.rmSync(markerPath, { force: true });
const binDir = createHookToolDir({
gitnexusMarkerPath: markerPath,
lsofSleepMs: 5000,
psOutput: '',
});
// Models busybox <1.34: `-k` unsupported → usage error, exit 2.
const badTimeout = path.join(binDir, 'bad-timeout');
fs.writeFileSync(
badTimeout,
`#!/usr/bin/env node\nprocess.stderr.write('usage: timeout [-t SECS] [-s SIG] PROG ARGS\\n');\nprocess.exit(2);\n`,
{ mode: 0o755 },
);
try {
const result = runHook(
hookPath,
{
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'validateUser' },
cwd: tmpDir,
},
undefined,
{
env: {
...hookEnv(binDir),
GITNEXUS_DEBUG: '1',
GITNEXUS_HOOK_TIMEOUT_PATH: badTimeout,
},
},
);
expect(result.stdout.trim()).toBe('');
expect(result.status).toBe(0);
expect(result.stderr).toContain('[GitNexus] augment skipped');
expect(fs.existsSync(markerPath)).toBe(false);
} finally {
fs.rmSync(lbugPath, { force: true });
fs.rmSync(markerPath, { force: true });
fs.rmSync(binDir, { recursive: true, force: true });
}
});
// F5-1 (#2165 review): pin the LIVE arm of the 124 mapping — a guard
// that passes the `-k` self-test and then reports coreutils budget
// expiry (exit 124) must map to "unresponsive holder" → fail-closed
// skip. The fake guard distinguishes the self-test invocation
// (`-k 1 1 /bin/sh -c 'exit 42'`) from a real wrap by the `exit 42`
// argv token, and PROPAGATES the requested status — the self-test now
// demands exit-status propagation (status 42), not just exit 0
// (#2169 review).
it(`${label}: guard exit 124 (budget expiry) → fail-closed skip`, () => {
const markerPath = path.join(os.tmpdir(), `gn-hook-guard124-${process.pid}-${label}`);
const lbugPath = path.join(gitNexusDir, 'lbug');
fs.writeFileSync(lbugPath, '');
fs.rmSync(markerPath, { force: true });
const binDir = createHookToolDir({
gitnexusMarkerPath: markerPath,
gitnexusStderr: '[GitNexus] 1 related symbol found:\n\nvalidateUser (src/auth.ts)\n',
lsofOutput: '',
psOutput: '',
});
const fakeGuard = path.join(binDir, 'guard-exit-124');
fs.writeFileSync(
fakeGuard,
`#!/usr/bin/env node\nif (process.argv.includes('exit 42')) process.exit(42);\nprocess.exit(124);\n`,
{ mode: 0o755 },
);
try {
const result = runHook(
hookPath,
{
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'validateUser' },
cwd: tmpDir,
},
undefined,
{
env: {
...hookEnv(binDir),
GITNEXUS_DEBUG: '1',
GITNEXUS_HOOK_TIMEOUT_PATH: fakeGuard,
// '1', NOT '0' — see the slot-gate test above.
GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS: '1',
},
},
);
expect(result.stdout.trim()).toBe('');
expect(result.status).toBe(0);
expect(result.stderr).toContain('[GitNexus] augment skipped');
expect(fs.existsSync(markerPath)).toBe(false);
} finally {
fs.rmSync(lbugPath, { force: true });
fs.rmSync(markerPath, { force: true });
fs.rmSync(binDir, { recursive: true, force: true });
}
});
// F5-2 (#2165 review): a guard-wrapped probe that dies BY SIGNAL with
// no spawnSync .error must fail closed. coreutils timeout SELF-RAISES
// the signal when `-k` escalates to SIGKILL, so spawnSync sees
// {status: null, signal: 'SIGKILL'} — NOT exit 137. The same shape
// appears when the hook is frozen >2s (SIGSTOP / laptop suspend) and
// resumes after the guard expired. Before the F1 patch this shape fell
// through every check → empty stdout → fail-open, silently inverting
// this call's fail-closed contract.
it(`${label}: guard signal-death (status null + signal, no error) → fail-closed skip`, () => {
const markerPath = path.join(os.tmpdir(), `gn-hook-guardsig-${process.pid}-${label}`);
const lbugPath = path.join(gitNexusDir, 'lbug');
fs.writeFileSync(lbugPath, '');
fs.rmSync(markerPath, { force: true });
const binDir = createHookToolDir({
gitnexusMarkerPath: markerPath,
gitnexusStderr: '[GitNexus] 1 related symbol found:\n\nvalidateUser (src/auth.ts)\n',
lsofOutput: '',
psOutput: '',
});
const fakeGuard = path.join(binDir, 'guard-sigkill');
fs.writeFileSync(
fakeGuard,
`#!/usr/bin/env node\nif (process.argv.includes('exit 42')) process.exit(42);\nprocess.kill(process.pid, 'SIGKILL');\n`,
{ mode: 0o755 },
);
try {
const result = runHook(
hookPath,
{
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'validateUser' },
cwd: tmpDir,
},
undefined,
{
env: {
...hookEnv(binDir),
GITNEXUS_DEBUG: '1',
GITNEXUS_HOOK_TIMEOUT_PATH: fakeGuard,
// '1', NOT '0' — see the slot-gate test above.
GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS: '1',
},
},
);
expect(result.stdout.trim()).toBe('');
expect(result.status).toBe(0);
expect(result.stderr).toContain('[GitNexus] augment skipped');
expect(fs.existsSync(markerPath)).toBe(false);
} finally {
fs.rmSync(lbugPath, { force: true });
fs.rmSync(markerPath, { force: true });
fs.rmSync(binDir, { recursive: true, force: true });
}
});
// F5-4 (#2169 review; F5-3 was taken by the #2165 slot-gate test
// above): an always-exit-0 stub (/bin/true shape) at
// GITNEXUS_HOOK_TIMEOUT_PATH must be REJECTED by the self-test. The
// old self-test only demanded exit 0, which such a stub satisfies
// without ever RUNNING the wrapped command — once adopted it instantly
// "succeeds" every wrapped spawn with empty output, turning the probe
// into a constant no-owner answer and, worse, the augment into a
// silent no-op (status 0 + empty stderr passes the success check with
// no context, so the feature dies without a trace). The propagation
// self-test (`sh -c 'exit 42'` must yield 42) rejects the stub;
// resolution falls through to the built-in candidates (or, with none
// usable, to the unwrapped status quo) and the augment runs for real.
it(`${label}: always-exit-0 stub guard is rejected → augment still runs and emits context`, () => {
const markerPath = path.join(os.tmpdir(), `gn-hook-stubguard-${process.pid}-${label}`);
const lbugPath = path.join(gitNexusDir, 'lbug');
fs.writeFileSync(lbugPath, '');
fs.rmSync(markerPath, { force: true });
const binDir = createHookToolDir({
gitnexusMarkerPath: markerPath,
gitnexusStderr: '[GitNexus] 1 related symbol found:\n\nvalidateUser (src/auth.ts)\n',
lsofOutput: '',
psOutput: '',
});
// Models /bin/true: exits 0 for ANY argv without running anything.
const stubGuard = path.join(binDir, 'true-stub');
fs.writeFileSync(stubGuard, `#!/usr/bin/env node\nprocess.exit(0);\n`, { mode: 0o755 });
try {
const result = runHook(
hookPath,
{
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'validateUser' },
cwd: tmpDir,
},
undefined,
{
env: {
...hookEnv(binDir),
GITNEXUS_HOOK_TIMEOUT_PATH: stubGuard,
// '1', NOT '0' — see the slot-gate test above.
GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS: '1',
},
},
);
const output = parseHookOutput(result.stdout);
expect(output).not.toBeNull();
expect(output!.additionalContext).toContain('[GitNexus] 1 related symbol found');
expect(fs.existsSync(markerPath)).toBe(true);
} finally {
fs.rmSync(lbugPath, { force: true });
fs.rmSync(markerPath, { force: true });
fs.rmSync(binDir, { recursive: true, force: true });
}
});
it(`${label}: non-GitNexus ps line → augment runs`, () => {
const markerPath = path.join(os.tmpdir(), `gn-hook-other-${process.pid}-${label}`);
const lbugPath = path.join(gitNexusDir, 'lbug');
fs.writeFileSync(lbugPath, '');
fs.rmSync(markerPath, { force: true });
const binDir = createHookToolDir({
gitnexusMarkerPath: markerPath,
gitnexusStderr: '[GitNexus] 1 related symbol found:\n\nvalidateUser (src/auth.ts)\n',
lsofOutput: '99904\n',
psOutput: '/usr/bin/bash -l\n',
});
try {
const result = runHook(
hookPath,
{
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'validateUser' },
cwd: tmpDir,
},
undefined,
{ env: hookEnv(binDir) },
);
const output = parseHookOutput(result.stdout);
expect(output).not.toBeNull();
expect(fs.existsSync(markerPath)).toBe(true);
} finally {
fs.rmSync(markerPath, { force: true });
fs.rmSync(binDir, { recursive: true, force: true });
}
});
it(`${label}: multiple PIDs — skip if any ps line is GitNexus MCP`, () => {
const markerPath = path.join(os.tmpdir(), `gn-hook-multi-${process.pid}-${label}`);
const lbugPath = path.join(gitNexusDir, 'lbug');
fs.writeFileSync(lbugPath, '');
fs.rmSync(markerPath, { force: true });
const binDir = createHookToolDir({
gitnexusMarkerPath: markerPath,
gitnexusStderr: '[GitNexus] 1 related symbol found:\n\nvalidateUser (src/auth.ts)\n',
lsofOutputLines: ['111', '222'],
psOutputByPid: {
'111': 'vim /tmp/x\n',
'222': 'node /x/node_modules/gitnexus/dist/cli/index.js mcp\n',
},
});
try {
const result = runHook(
hookPath,
{
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'validateUser' },
cwd: tmpDir,
},
undefined,
{ env: { ...hookEnv(binDir), GITNEXUS_DEBUG: '1' } },
);
expect(result.stdout.trim()).toBe('');
expect(result.status).toBe(0);
expect(result.stderr).toContain('[GitNexus] augment skipped');
expect(fs.existsSync(markerPath)).toBe(false);
} finally {
fs.rmSync(markerPath, { force: true });
fs.rmSync(binDir, { recursive: true, force: true });
}
});
it(`${label}: ps ENOENT → augment runs (ignore that PID)`, () => {
const markerPath = path.join(os.tmpdir(), `gn-hook-pseno-${process.pid}-${label}`);
const lbugPath = path.join(gitNexusDir, 'lbug');
fs.writeFileSync(lbugPath, '');
fs.rmSync(markerPath, { force: true });
const binDir = createHookToolDir({
gitnexusMarkerPath: markerPath,
gitnexusStderr: '[GitNexus] 1 related symbol found:\n\nvalidateUser (src/auth.ts)\n',
lsofOutput: '99905\n',
psOutput: '',
});
try {
const env = {
...hookEnv(binDir),
GITNEXUS_HOOK_PS_PATH: path.join(binDir, '__missing_ps__'),
};
const result = runHook(
hookPath,
{
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'validateUser' },
cwd: tmpDir,
},
undefined,
{ env },
);
const output = parseHookOutput(result.stdout);
expect(output).not.toBeNull();
expect(fs.existsSync(markerPath)).toBe(true);
} finally {
fs.rmSync(markerPath, { force: true });
fs.rmSync(binDir, { recursive: true, force: true });
}
});
}
},
);
// ─── Integration: PostToolUse staleness detection ───────────────────
describe('PostToolUse staleness detection (integration)', () => {
for (const [label, hookPath] of [
['CJS', CJS_HOOK],
['Plugin', PLUGIN_HOOK],
] as const) {
it(`${label}: emits stale notification when HEAD differs from meta`, () => {
// Write meta.json with a different commit
fs.writeFileSync(
path.join(gitNexusDir, 'meta.json'),
JSON.stringify({ lastCommit: 'aaaaaaa0000000000000000000000000deadbeef', stats: {} }),
);
const result = runHook(hookPath, {
hook_event_name: 'PostToolUse',
tool_name: 'Bash',
tool_input: { command: 'git commit -m "test"' },
tool_output: { exit_code: 0 },
cwd: tmpDir,
});
const output = parseHookOutput(result.stdout);
expect(output).not.toBeNull();
expect(output!.hookEventName).toBe('PostToolUse');
expect(output!.additionalContext).toContain('stale');
expect(output!.additionalContext).toContain('aaaaaaa');
});
it(`${label}: silent when HEAD matches meta lastCommit`, () => {
const head = getHeadCommit();
fs.writeFileSync(
path.join(gitNexusDir, 'meta.json'),
JSON.stringify({ lastCommit: head, stats: {} }),
);
const result = runHook(hookPath, {
hook_event_name: 'PostToolUse',
tool_name: 'Bash',
tool_input: { command: 'git commit -m "test"' },
tool_output: { exit_code: 0 },
cwd: tmpDir,
});
expect(result.stdout.trim()).toBe('');
});
it(`${label}: silent when tool is not Bash`, () => {
const result = runHook(hookPath, {
hook_event_name: 'PostToolUse',
tool_name: 'Grep',
tool_input: { command: 'git commit -m "test"' },
cwd: tmpDir,
});
expect(result.stdout.trim()).toBe('');
});
it(`${label}: silent when command is not a git mutation`, () => {
const result = runHook(hookPath, {
hook_event_name: 'PostToolUse',
tool_name: 'Bash',
tool_input: { command: 'git status' },
tool_output: { exit_code: 0 },
cwd: tmpDir,
});
expect(result.stdout.trim()).toBe('');
});
it(`${label}: silent when exit code is non-zero`, () => {
const result = runHook(hookPath, {
hook_event_name: 'PostToolUse',
tool_name: 'Bash',
tool_input: { command: 'git commit -m "fail"' },
tool_output: { exit_code: 1 },
cwd: tmpDir,
});
expect(result.stdout.trim()).toBe('');
});
it(`${label}: includes --embeddings in suggestion when meta had embeddings`, () => {
fs.writeFileSync(
path.join(gitNexusDir, 'meta.json'),
JSON.stringify({ lastCommit: 'deadbeef', stats: { embeddings: 42 } }),
);
const result = runHook(hookPath, {
hook_event_name: 'PostToolUse',
tool_name: 'Bash',
tool_input: { command: 'git merge feature' },
tool_output: { exit_code: 0 },
cwd: tmpDir,
});
const output = parseHookOutput(result.stdout);
expect(output).not.toBeNull();
expect(output!.additionalContext).toContain('--embeddings');
});
it(`${label}: omits --embeddings when meta had no embeddings`, () => {
fs.writeFileSync(
path.join(gitNexusDir, 'meta.json'),
JSON.stringify({ lastCommit: 'deadbeef', stats: { embeddings: 0 } }),
);
const result = runHook(hookPath, {
hook_event_name: 'PostToolUse',
tool_name: 'Bash',
tool_input: { command: 'git commit -m "test"' },
tool_output: { exit_code: 0 },
cwd: tmpDir,
});
const output = parseHookOutput(result.stdout);
expect(output).not.toBeNull();
expect(output!.additionalContext).not.toContain('--embeddings');
});
it(`${label}: detects git rebase as a mutation`, () => {
fs.writeFileSync(
path.join(gitNexusDir, 'meta.json'),
JSON.stringify({ lastCommit: 'oldcommit', stats: {} }),
);
const result = runHook(hookPath, {
hook_event_name: 'PostToolUse',
tool_name: 'Bash',
tool_input: { command: 'git rebase main' },
tool_output: { exit_code: 0 },
cwd: tmpDir,
});
const output = parseHookOutput(result.stdout);
expect(output).not.toBeNull();
expect(output!.additionalContext).toContain('stale');
});
it(`${label}: detects git cherry-pick as a mutation`, () => {
fs.writeFileSync(
path.join(gitNexusDir, 'meta.json'),
JSON.stringify({ lastCommit: 'oldcommit', stats: {} }),
);
const result = runHook(hookPath, {
hook_event_name: 'PostToolUse',
tool_name: 'Bash',
tool_input: { command: 'git cherry-pick abc123' },
tool_output: { exit_code: 0 },
cwd: tmpDir,
});
const output = parseHookOutput(result.stdout);
expect(output).not.toBeNull();
});
it(`${label}: detects git pull as a mutation`, () => {
fs.writeFileSync(
path.join(gitNexusDir, 'meta.json'),
JSON.stringify({ lastCommit: 'oldcommit', stats: {} }),
);
const result = runHook(hookPath, {
hook_event_name: 'PostToolUse',
tool_name: 'Bash',
tool_input: { command: 'git pull origin main' },
tool_output: { exit_code: 0 },
cwd: tmpDir,
});
const output = parseHookOutput(result.stdout);
expect(output).not.toBeNull();
});
}
});
// ─── Integration: cwd validation rejects relative paths ─────────────
describe('cwd validation (integration)', () => {
for (const [label, hookPath] of [
['CJS', CJS_HOOK],
['Plugin', PLUGIN_HOOK],
] as const) {
it(`${label}: PostToolUse silent when cwd is relative`, () => {
const result = runHook(hookPath, {
hook_event_name: 'PostToolUse',
tool_name: 'Bash',
tool_input: { command: 'git commit -m "test"' },
tool_output: { exit_code: 0 },
cwd: 'relative/path',
});
expect(result.stdout.trim()).toBe('');
});
it(`${label}: PreToolUse silent when cwd is relative`, () => {
const result = runHook(hookPath, {
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'validateUser' },
cwd: 'relative/path',
});
expect(result.stdout.trim()).toBe('');
});
}
});
// ─── Integration: global registry lookup ────────────────────────────
describe('Global registry lookup', () => {
for (const [label, hookPath] of [
['CJS', CJS_HOOK],
['Plugin', PLUGIN_HOOK],
] as const) {
it(`${label}: PostToolUse stays silent for unindexed repo under global registry`, () => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-home-'));
const repoDir = path.join(homeDir, 'work', 'unindexed');
try {
createGlobalRegistry(homeDir);
fs.mkdirSync(repoDir, { recursive: true });
initGitRepo(repoDir);
const result = runHook(hookPath, {
hook_event_name: 'PostToolUse',
tool_name: 'Bash',
tool_input: { command: 'git commit -m "test"' },
tool_output: { exit_code: 0 },
cwd: repoDir,
});
expect(result.stdout.trim()).toBe('');
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
}
});
it(`${label}: PreToolUse stays silent for unindexed repo under global registry`, () => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-home-'));
const repoDir = path.join(homeDir, 'work', 'unindexed');
try {
createGlobalRegistry(homeDir);
fs.mkdirSync(repoDir, { recursive: true });
initGitRepo(repoDir);
const result = runHook(hookPath, {
hook_event_name: 'PreToolUse',
tool_name: 'Grep',
tool_input: { pattern: 'validateUser' },
cwd: repoDir,
});
expect(result.stdout.trim()).toBe('');
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
}
});
it(`${label}: PostToolUse emits stale for indexed repo under parent global registry`, () => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-home-'));
const repoDir = path.join(homeDir, 'work', 'indexed-repo');
try {
createGlobalRegistry(homeDir);
fs.mkdirSync(path.join(repoDir, '.gitnexus'), { recursive: true });
initGitRepo(repoDir);
fs.writeFileSync(
path.join(repoDir, '.gitnexus', 'meta.json'),
JSON.stringify({ lastCommit: 'oldcommit', stats: {} }),
);
const result = runHook(hookPath, {
hook_event_name: 'PostToolUse',
tool_name: 'Bash',
tool_input: { command: 'git commit -m "test"' },
tool_output: { exit_code: 0 },
cwd: repoDir,
});
const output = parseHookOutput(result.stdout);
expect(output).not.toBeNull();
expect(output!.additionalContext).toContain('stale');
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
}
});
for (const marker of ['registry', 'repos'] as const) {
it(`${label}: PostToolUse skips global registry with only ${marker} marker`, () => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-home-'));
const repoDir = path.join(homeDir, 'work', `unindexed-${marker}`);
try {
createGlobalRegistry(homeDir, marker);
fs.mkdirSync(repoDir, { recursive: true });
initGitRepo(repoDir);
const result = runHook(hookPath, {
hook_event_name: 'PostToolUse',
tool_name: 'Bash',
tool_input: { command: 'git commit -m "test"' },
tool_output: { exit_code: 0 },
cwd: repoDir,
});
expect(result.stdout.trim()).toBe('');
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
}
});
}
}
});
// ─── Integration: linked-worktree resolution (#1224) ───────────────
describe('Linked git worktree resolution', () => {
for (const [label, hookPath] of [
['CJS', CJS_HOOK],
['Plugin', PLUGIN_HOOK],
] as const) {
it(`${label}: PostToolUse emits stale from a linked worktree pointing at an indexed canonical repo`, () => {
// Layout mirrors `git worktree add ../<repo>-worktrees/feature-x`:
// <root>/main-repo/.git (canonical)
// <root>/main-repo/.gitnexus/ (only here)
// <root>/main-repo-worktrees/feat/ (linked worktree, no .gitnexus)
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-worktree-'));
const mainRepo = path.join(root, 'main-repo');
const worktreePath = path.join(root, 'main-repo-worktrees', 'feat');
try {
fs.mkdirSync(mainRepo, { recursive: true });
initGitRepo(mainRepo);
fs.mkdirSync(path.join(mainRepo, '.gitnexus'), { recursive: true });
fs.writeFileSync(
path.join(mainRepo, '.gitnexus', 'meta.json'),
JSON.stringify({ lastCommit: 'oldcommit', stats: {} }),
);
// Create the linked worktree on a new branch.
fs.mkdirSync(path.dirname(worktreePath), { recursive: true });
runGit(mainRepo, ['worktree', 'add', '-b', 'feat', worktreePath]);
// Sanity: walking up from the worktree never reaches `.gitnexus`.
expect(fs.existsSync(path.join(worktreePath, '.gitnexus'))).toBe(false);
expect(fs.existsSync(path.join(path.dirname(worktreePath), '.gitnexus'))).toBe(false);
const result = runHook(hookPath, {
hook_event_name: 'PostToolUse',
tool_name: 'Bash',
tool_input: { command: 'git commit -m "test"' },
tool_output: { exit_code: 0 },
cwd: worktreePath,
});
const output = parseHookOutput(result.stdout);
expect(output).not.toBeNull();
expect(output!.additionalContext).toContain('stale');
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
it(`${label}: PostToolUse silent from a linked worktree when canonical repo has no .gitnexus`, () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-worktree-'));
const mainRepo = path.join(root, 'main-repo');
const worktreePath = path.join(root, 'main-repo-worktrees', 'feat');
try {
fs.mkdirSync(mainRepo, { recursive: true });
initGitRepo(mainRepo);
// Note: NO .gitnexus/ in the canonical repo.
fs.mkdirSync(path.dirname(worktreePath), { recursive: true });
runGit(mainRepo, ['worktree', 'add', '-b', 'feat', worktreePath]);
const result = runHook(hookPath, {
hook_event_name: 'PostToolUse',
tool_name: 'Bash',
tool_input: { command: 'git commit -m "test"' },
tool_output: { exit_code: 0 },
cwd: worktreePath,
});
expect(result.stdout.trim()).toBe('');
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
}
});
// ─── Integration: dispatch map routes correctly ─────────────────────
describe('Dispatch map routing (integration)', () => {
for (const [label, hookPath] of [
['CJS', CJS_HOOK],
['Plugin', PLUGIN_HOOK],
] as const) {
it(`${label}: unknown hook_event_name produces no output`, () => {
const result = runHook(hookPath, {
hook_event_name: 'UnknownEvent',
tool_name: 'Bash',
tool_input: { command: 'echo hello' },
cwd: tmpDir,
});
expect(result.stdout.trim()).toBe('');
expect(result.status).toBe(0);
});
it(`${label}: empty hook_event_name produces no output`, () => {
const result = runHook(hookPath, {
hook_event_name: '',
tool_name: 'Bash',
cwd: tmpDir,
});
expect(result.stdout.trim()).toBe('');
expect(result.status).toBe(0);
});
it(`${label}: missing hook_event_name produces no output`, () => {
const result = runHook(hookPath, {
tool_name: 'Bash',
cwd: tmpDir,
});
expect(result.stdout.trim()).toBe('');
expect(result.status).toBe(0);
});
it(`${label}: invalid JSON input exits cleanly`, () => {
const result = spawnSync(process.execPath, [hookPath], {
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(`${label}: empty stdin exits cleanly`, () => {
const result = spawnSync(process.execPath, [hookPath], {
input: '',
encoding: 'utf-8',
timeout: 10000,
stdio: ['pipe', 'pipe', 'pipe'],
});
expect(result.status).toBe(0);
});
}
});
// ─── Integration: PostToolUse with missing meta.json ────────────────
describe('PostToolUse with missing/corrupt meta.json', () => {
for (const [label, hookPath] of [
['CJS', CJS_HOOK],
['Plugin', PLUGIN_HOOK],
] as const) {
it(`${label}: emits stale when meta.json does not exist`, () => {
const metaPath = path.join(gitNexusDir, 'meta.json');
const hadMeta = fs.existsSync(metaPath);
if (hadMeta) fs.unlinkSync(metaPath);
try {
const result = runHook(hookPath, {
hook_event_name: 'PostToolUse',
tool_name: 'Bash',
tool_input: { command: 'git commit -m "test"' },
tool_output: { exit_code: 0 },
cwd: tmpDir,
});
const output = parseHookOutput(result.stdout);
expect(output).not.toBeNull();
expect(output!.additionalContext).toContain('never');
} finally {
// Restore meta.json for subsequent tests
fs.writeFileSync(metaPath, JSON.stringify({ lastCommit: 'old', stats: {} }));
}
});
it(`${label}: emits stale when meta.json is corrupt`, () => {
const metaPath = path.join(gitNexusDir, 'meta.json');
fs.writeFileSync(metaPath, 'not valid json!!!');
const result = runHook(hookPath, {
hook_event_name: 'PostToolUse',
tool_name: 'Bash',
tool_input: { command: 'git commit -m "test"' },
tool_output: { exit_code: 0 },
cwd: tmpDir,
});
const output = parseHookOutput(result.stdout);
expect(output).not.toBeNull();
expect(output!.additionalContext).toContain('never');
// Restore
fs.writeFileSync(metaPath, JSON.stringify({ lastCommit: 'old', stats: {} }));
});
}
});