GitNexus/gitnexus/test/unit/grep-params.test.ts
azizur100389 f8036ac349
feat(analyze): add explicit FTS opt-out (#3205)
* feat(analyze): add explicit FTS opt-out

* chore(autofix): apply prettier + eslint fixes via /autofix command

* fix(analyze): treat flag and env FTS opt-out as one mode

Avoid a same-commit rebuild when only the skipReason discriminator
changes, and advertise disablement on dirty meta before leftover
indexes are wiped.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-11 14:05:28 +00:00

162 lines
6.7 KiB
TypeScript

/**
* Unit Tests: /api/grep query parsing (gitnexus/src/server/grep-params.ts)
*
* Patch 12 contract fix — the grep tool schema promised regex +
* fileFilter + caseSensitive, but the handler escaped every pattern into
* a literal substring. These tests pin the restored semantics:
* - regex is real regex (alternation, classes, quantifiers work)
* - literal=1 keeps the old escaped-substring behaviour opt-in
* - fileFilter normalizes to a lowercase path substring
* - caseSensitive=1 drops the 'i' flag
* - malformed input throws BadRequestError (→ 400 via statusFromError)
*/
import { describe, it, expect } from 'vitest';
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
parseGrepQuery,
GREP_PATTERN_MAX_LENGTH,
GREP_DEFAULT_LIMIT,
GREP_MAX_LIMIT,
} from '../../src/server/grep-params.js';
import { BadRequestError } from '../../src/server/validation.js';
describe('parseGrepQuery — regex semantics (restored contract)', () => {
it('honours alternation, the case the agent was burned by', () => {
const { regex } = parseGrepQuery({ pattern: 'sign|Sign' });
expect(regex.test('signOrder(order)')).toBe(true);
expect(regex.test('SignOffOrderAfterDecorator')).toBe(true);
expect(regex.test('assign a value')).toBe(true); // substring semantics, like grep
expect(regex.test('unrelated line')).toBe(false);
});
it('supports the schema example "console\\.log" against real code lines', () => {
const { regex } = parseGrepQuery({ pattern: 'console\\.log' });
expect(regex.test('console.log("hi")')).toBe(true);
expect(regex.test('consolexlog("hi")')).toBe(false);
});
it('is case-insensitive by default', () => {
const { regex } = parseGrepQuery({ pattern: 'todo' });
expect(regex.test('TODO: fix me')).toBe(true);
});
it('caseSensitive=1 drops the i flag', () => {
const { regex } = parseGrepQuery({ pattern: 'todo', caseSensitive: '1' });
expect(regex.test('TODO: fix me')).toBe(false);
expect(regex.test('todo: fix me')).toBe(true);
});
it('caseSensitive=true is accepted alongside the bare flag form', () => {
const { regex } = parseGrepQuery({ pattern: 'todo', caseSensitive: 'true' });
expect(regex.test('TODO')).toBe(false);
});
it('literal=1 restores the old escaped-substring behaviour', () => {
const literal = parseGrepQuery({ pattern: 'a.b', literal: '1' });
expect(literal.regex.test('a.b')).toBe(true);
expect(literal.regex.test('axb')).toBe(false);
const regexMode = parseGrepQuery({ pattern: 'a.b' });
expect(regexMode.regex.test('axb')).toBe(true);
});
it('matches CJK identifiers/comments (the primary consumer is a Chinese-codebase team)', () => {
const { regex } = parseGrepQuery({ pattern: '签署|signOrder' });
expect(regex.test('public void signOrder() { // 医嘱签署')).toBe(true);
expect(regex.test('// 签署接口')).toBe(true);
expect(regex.test('// 撤销接口')).toBe(false);
});
it('anchors ^/$ at line boundaries — the handler tests one line at a time, so "m" semantics are irrelevant', () => {
const { regex } = parseGrepQuery({ pattern: '^import .*$' });
expect(regex.test('import path from "path";')).toBe(true);
expect(regex.test(' import path from "path";')).toBe(false);
});
});
describe('parseGrepQuery — fileFilter', () => {
it('normalizes to a lowercase path substring', () => {
const { fileFilter } = parseGrepQuery({ pattern: 'x', fileFilter: 'Controller.JAVA' });
expect(fileFilter).toBe('controller.java');
});
it('empty / missing fileFilter disables path filtering', () => {
expect(parseGrepQuery({ pattern: 'x' }).fileFilter).toBe('');
expect(parseGrepQuery({ pattern: 'x', fileFilter: '' }).fileFilter).toBe('');
});
it('array-form fileFilter is type-confusion-rejected, not partially read', () => {
expect(() => parseGrepQuery({ pattern: 'x', fileFilter: ['a', 'b'] })).toThrow(BadRequestError);
});
});
describe('parseGrepQuery — limit clamping', () => {
it('clamps above the max', () => {
expect(parseGrepQuery({ pattern: 'x', limit: '5000' }).limit).toBe(GREP_MAX_LIMIT);
});
it('clamps below 1', () => {
expect(parseGrepQuery({ pattern: 'x', limit: '-5' }).limit).toBe(1);
});
it('falls back to the default on non-numeric input', () => {
expect(parseGrepQuery({ pattern: 'x', limit: 'abc' }).limit).toBe(GREP_DEFAULT_LIMIT);
});
it('defaults when absent', () => {
expect(parseGrepQuery({ pattern: 'x' }).limit).toBe(GREP_DEFAULT_LIMIT);
});
});
describe('parseGrepQuery — error paths (→ 400 via statusFromError)', () => {
it('rejects a missing pattern', () => {
expect(() => parseGrepQuery({})).toThrow(/Missing "pattern" query parameter/);
expect(() => parseGrepQuery({ pattern: '' })).toThrow(/Missing "pattern" query parameter/);
});
it('rejects array-form patterns (type-confusion guard unchanged)', () => {
try {
parseGrepQuery({ pattern: ['a', 'b'] });
expect.unreachable();
} catch (err) {
expect(err).toBeInstanceOf(BadRequestError);
expect((err as BadRequestError).status).toBe(400);
expect((err as Error).message).toContain('pattern');
}
});
it('rejects over-long patterns at the same 200-char cap', () => {
const long = 'a'.repeat(GREP_PATTERN_MAX_LENGTH + 1);
expect(() => parseGrepQuery({ pattern: long })).toThrow(/max 200 characters/);
expect(() => parseGrepQuery({ pattern: 'a'.repeat(GREP_PATTERN_MAX_LENGTH) })).not.toThrow();
});
it('rejects syntactically invalid regex', () => {
expect(() => parseGrepQuery({ pattern: '((' })).toThrow(/Invalid regex/);
});
});
describe('/api/grep handler wiring (source-level, api-readonly-wiring.test.ts style)', () => {
const readSource = async () => fs.readFile(SRC_PATH, 'utf-8');
const SRC_PATH = path.join(
path.dirname(fileURLToPath(import.meta.url)),
'../../src/server/api.ts',
);
it('threaded through: parseGrepQuery call, worker scan, fileFilter filter, timedOut flag', async () => {
const source = await readSource();
const grepSection = source.match(/app\.get\('\/api\/grep'[\s\S]*?\n \}\);/);
expect(grepSection).not.toBeNull();
const section = grepSection![0];
expect(section).toContain('parseGrepQuery(');
expect(section).toContain('GREP_TIME_BUDGET_MS');
expect(section).toContain('runGrepScanInWorker(');
expect(section).toMatch(/filePath\.toLowerCase\(\)\.includes\(fileFilter\)/);
expect(section).toContain('timedOut: true');
// Unchanged read-only DB open — now expressed via readOnlyFtsOptions(skipFts),
// whose read-only guarantee is pinned in api-readonly-wiring.test.ts.
expect(section).toMatch(/readOnly: true|readOnlyFtsOptions\(/);
});
});