GitNexus/gitnexus/test/unit/opencode-plugin.test.ts
rgb-vgx 338dee53ee
feat(opencode): ship hooks as auto-loaded global plugin for full support (#3619)
* feat(opencode): ship hooks as auto-loaded global plugin for full support

* fix(opencode): honor -e/--regexp as pattern and surface non-ENOENT plugin stat errors

* fix(opencode): block shell-metachar patterns in Windows fallback and unlink dangling plugin symlinks

* fix(opencode): quote-aware argv tokenization for quoted search phrases

* fix(opencode): skip plugin write on literal drift, glob token extraction, segment-anchored mutation check

* fix(opencode): parse shell search arguments safely

* fix(opencode): check multiline git mutations

* fix(opencode): recognize quoted git command boundaries

* fix(opencode): skip ambiguous search options

* docs(opencode): clarify compound search limits

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-10-28 13:45:08 +00:00

284 lines
11 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import fs from 'node:fs';
import { GitNexusPlugin } from '../../hooks/opencode/gitnexus-plugin.js';
const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() }));
vi.mock('node:child_process', () => ({ execFile: execFileMock }));
type Callback = (error: Error | null, stdout: string, stderr: string) => void;
const platform = Object.getOwnPropertyDescriptor(process, 'platform')!;
async function invoke(tool: string, args: unknown, value: unknown = 'hits') {
const hooks = await GitNexusPlugin({ directory: '/repo' });
const output = { output: value };
await hooks['tool.execute.after']({ tool, args }, output);
return output.output;
}
beforeEach(() => {
vi.stubEnv('GITNEXUS_HOOK_CLI_PATH', '');
execFileMock.mockReset();
execFileMock.mockImplementation(
(_cmd: string, argv: string[], _options: unknown, callback: Callback) => {
callback(
null,
argv.includes('status') ? '{"status":"up-to-date"}' : '',
'[GitNexus] callers: A',
);
},
);
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
Object.defineProperty(process, 'platform', platform);
});
describe('search augmentation through the public hook', () => {
it.each([
['grep', { pattern: 'UserService' }, 'UserService'],
['grep', { query: 'fallback' }, 'fallback'],
['glob', { pattern: '**/UserService*' }, 'UserService'],
['glob', { pattern: 'src/**/*.ts' }, 'src'],
['glob', { pattern: '**/*.service.ts' }, 'service'],
['bash', { command: 'rg --type ts UserService .' }, 'UserService'],
['bash', { command: 'grep -r UserService .' }, 'UserService'],
['bash', { command: 'rg -n -e UserService setup.ts' }, 'UserService'],
['bash', { command: 'rg --regexp UserService .' }, 'UserService'],
['bash', { command: 'grep --expression UserService .' }, 'UserService'],
['bash', { command: 'rg "foo bar" .' }, 'foo bar'],
['bash', { command: "grep -r 'user service' src" }, 'user service'],
['bash', { command: 'rg -e "foo bar" file.ts' }, 'foo bar'],
['bash', { command: 'rg --max-columns 100 UserService .' }, 'UserService'],
['bash', { command: 'rg --max-columns=100 UserService .' }, 'UserService'],
['bash', { command: 'rg -M 100 UserService .' }, 'UserService'],
['bash', { command: 'rg -- -foobar file.ts' }, '-foobar'],
['bash', { command: 'grep -- -foobar file.ts' }, '-foobar'],
['bash', { command: '/usr/bin/rg UserService .' }, 'UserService'],
['bash', { command: 'rg "foo;bar" file.ts | head' }, 'foo;bar'],
['bash', { command: 'rg UserService . && echo done' }, 'UserService'],
['bash', { command: 'rg --regexp=UserService .' }, 'UserService'],
['bash', { command: 'rg -eUserService .' }, 'UserService'],
['bash', { command: 'rg src -e UserService' }, 'UserService'],
['bash', { command: 'rg foo\\ bar src' }, 'foo bar'],
['bash', { command: 'rg "foo\\\"bar" src' }, 'foo"bar'],
['bash', { command: 'rg "foo\\dbar" src' }, 'foo\\dbar'],
['bash', { command: "rg '$literal' src" }, '$literal'],
['bash', { command: 'rg UserService # comment' }, 'UserService'],
['bash', { command: 'rg \\\nUserService src' }, 'UserService'],
['bash', { command: 'rg -r replacement UserService src' }, 'UserService'],
['bash', { command: 'rg --hidden -i UserService src' }, 'UserService'],
])('augments %s %j with %s', async (tool, args, pattern) => {
expect(await invoke(tool, args)).toBe('hits\n\n[GitNexus] callers: A');
expect(execFileMock).toHaveBeenCalledTimes(1);
expect(execFileMock.mock.calls[0][1].slice(-3)).toEqual(['augment', '--', pattern]);
expect(execFileMock.mock.calls[0][2]).toMatchObject({
cwd: '/repo',
timeout: 10000,
maxBuffer: 1024 * 1024,
});
});
it.each([
['grep', { pattern: 'ab' }],
['grep', {}],
['grep', null],
['grep', 'UserService'],
['glob', { pattern: '**/*.{a,b}' }],
['glob', {}],
['bash', { command: 'rg -n "ab" .' }],
['bash', { command: 'npm test' }],
['bash', { command: 'echo rg UserService' }],
['bash', { command: 'printf "rg UserService"' }],
['bash', { command: 'rg -f patterns.txt src' }],
['bash', { command: 'rg --file patterns.txt src' }],
['bash', { command: 'rg --file=patterns.txt src' }],
['bash', { command: 'rg -fpatterns.txt src' }],
['bash', { command: 'rg src -f patterns.txt' }],
['bash', { command: 'rg --files src' }],
['bash', { command: 'rg -n; echo UserService' }],
['bash', { command: 'rg -n\necho UserService' }],
['bash', { command: 'rg "" src' }],
['bash', { command: 'rg "UserService src' }],
['bash', { command: 'rg $pattern src' }],
['bash', { command: 'rg "${pattern}" src' }],
['bash', { command: 'rg `query` src' }],
['bash', { command: 'rg UserService\\' }],
['bash', { command: 'rg -n # UserService' }],
['bash', { command: 'rg --unknown-option value UserService src' }],
['bash', { command: 'rg --sort path UserService src' }],
['bash', { command: 'rg --iglob "*.ts" UserService src' }],
['bash', { command: 'rg -rreplacement UserService src' }],
['bash', { command: null }],
['bash', {}],
['read', { filePath: 'a.ts' }],
['edit', { filePath: 'a.ts' }],
])('leaves %s %j untouched', async (tool, args) => {
expect(await invoke(tool, args)).toBe('hits');
expect(execFileMock).not.toHaveBeenCalled();
});
it('strips diagnostics before the context marker and handles an empty tool result', async () => {
execFileMock.mockImplementation(
(_cmd: string, _argv: string[], _options: unknown, callback: Callback) =>
callback(null, '', 'diagnostic\n[GitNexus] callers: A\n'),
);
expect(await invoke('grep', { pattern: 'UserService' }, '')).toBe('[GitNexus] callers: A');
});
it('discards unmarked diagnostics and never coerces structured results', async () => {
execFileMock.mockImplementation(
(_cmd: string, _argv: string[], _options: unknown, callback: Callback) =>
callback(null, '', 'diagnostic'),
);
expect(await invoke('grep', { pattern: 'UserService' })).toBe('hits');
expect(await invoke('grep', { pattern: 'UserService' }, { structured: true })).toEqual({
structured: true,
});
});
});
describe('stale-index hints', () => {
it.each([
'git commit -m x',
'git merge main',
'git rebase main',
'git cherry-pick abc',
'git pull',
'cd src && git commit -m x',
'npm test || git pull',
'git status\ngit commit -m x',
'git status\r\ngit merge main',
'git -C /repo commit -m x',
'git -C/repo -c user.name=Agent commit -m x',
'git -cuser.name=Agent --no-pager commit -m x',
'git --git-dir /repo/.git --work-tree=/repo commit -m x',
'git --config-env user.name=GIT_AUTHOR_NAME commit -m x',
'git commit -m "$MESSAGE"',
'git commit -m "text; no second command"',
'echo "text;git status"; git commit -m x',
'git \\\ncommit -m x',
'/usr/bin/git commit -m x',
])('checks %s', async (command) => {
execFileMock.mockImplementation(
(_cmd: string, _argv: string[], _options: unknown, callback: Callback) =>
callback(null, '{"status":"stale","index":{"commit":"abc1234567"}}', ''),
);
expect(await invoke('bash', { command })).toBe(
'hits\n\n[GitNexus] GitNexus index is stale (last indexed: abc1234). Run `gitnexus analyze` to update the knowledge graph.',
);
expect(execFileMock.mock.calls[0][1].slice(-2)).toEqual(['status', '--json']);
expect(execFileMock.mock.calls[0][2]).toMatchObject({ timeout: 15000 });
});
it.each([
'git status',
'git diff',
'git stash push',
"echo 'git commit'",
"grep 'git commit' file.txt",
'cat notes.md | grep git-pull',
"echo 'text;git commit -m x'",
'echo "text|git commit -m x"',
"echo 'text\ngit commit -m x'",
'echo text\\;git commit -m x',
'echo text # ; git commit -m x',
'cat <<EOF\ngit commit -m x\nEOF',
'git -C commit',
'git -c alias.ship=commit status',
'git --git-dir commit status',
'git --help commit',
'git --version commit',
'git -C "$OTHER_REPO" commit -m x',
])('does not check %s', async (command) => {
await invoke('bash', { command });
expect(execFileMock.mock.calls.filter((call) => call[1].includes('status'))).toEqual([]);
});
it.each(['{"status":"up-to-date"}', '{"error":"not-indexed"}', 'not json', '', 'null'])(
'stays silent for %s',
async (stdout) => {
execFileMock.mockImplementation(
(_cmd: string, _argv: string[], _options: unknown, callback: Callback) =>
callback(null, stdout, ''),
);
expect(await invoke('bash', { command: 'git commit -m x' })).toBe('hits');
},
);
});
describe('CLI boundaries', () => {
it('uses the CLI override with an interpreter, never the OpenCode host binary', async () => {
vi.stubEnv('GITNEXUS_HOOK_CLI_PATH', '/custom/cli.js');
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
await invoke('grep', { pattern: 'UserService' });
expect(execFileMock.mock.calls[0].slice(0, 2)).toEqual([
'node',
['/custom/cli.js', 'augment', '--', 'UserService'],
]);
expect(execFileMock.mock.calls[0][2].shell).toBeUndefined();
});
it.each([
'foo & calc',
'foo|bar',
'a > b',
'a < b',
'x^&y',
'a"b',
"a'b",
'a`b',
'a$b',
'a;b',
'a(b)',
'a%PATH%',
'a\nb',
'a\rb',
])('drops unsafe Windows shell input %s', async (pattern) => {
vi.spyOn(fs, 'existsSync').mockReturnValue(false);
Object.defineProperty(process, 'platform', { value: 'win32' });
expect(await invoke('grep', { pattern })).toBe('hits');
expect(execFileMock).not.toHaveBeenCalled();
});
it('quotes multi-word Windows PATH fallback arguments', async () => {
vi.spyOn(fs, 'existsSync').mockReturnValue(false);
Object.defineProperty(process, 'platform', { value: 'win32' });
await invoke('grep', { pattern: 'User Service' });
expect(execFileMock.mock.calls[0].slice(0, 2)).toEqual([
'gitnexus',
['augment', '--', '"User Service"'],
]);
expect(execFileMock.mock.calls[0][2].shell).toBe(true);
});
it('passes metacharacters literally without a shell when the CLI file exists', async () => {
vi.stubEnv('GITNEXUS_HOOK_CLI_PATH', '/custom/cli.js');
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
await invoke('grep', { pattern: 'foo & calc' });
expect(execFileMock.mock.calls[0][1].slice(-3)).toEqual(['augment', '--', 'foo & calc']);
expect(execFileMock.mock.calls[0][2].shell).toBeUndefined();
});
it.each(['timeout', 'nonzero exit', 'buffer exceeded'])(
'preserves output after %s',
async (reason) => {
execFileMock.mockImplementation(
(_cmd: string, _argv: string[], _options: unknown, callback: Callback) =>
callback(new Error(reason), '', '[GitNexus] incomplete'),
);
expect(await invoke('grep', { pattern: 'UserService' })).toBe('hits');
expect(await invoke('bash', { command: 'git commit -m x' })).toBe('hits');
},
);
it('never throws for a synchronous spawn error or malformed hook arguments', async () => {
execFileMock.mockImplementation(() => {
throw new Error('spawn failed');
});
expect(await invoke('grep', { pattern: 'UserService' })).toBe('hits');
const hooks = await GitNexusPlugin();
await expect(hooks['tool.execute.after'](null, null)).resolves.toBeUndefined();
});
});