@kenkaiiii/ggcoder 5.38.0 → 5.39.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/agent-session-tool-tiers.test.d.ts +2 -0
- package/dist/core/agent-session-tool-tiers.test.d.ts.map +1 -0
- package/dist/core/agent-session-tool-tiers.test.js +138 -0
- package/dist/core/agent-session-tool-tiers.test.js.map +1 -0
- package/dist/core/agent-session.d.ts +19 -3
- package/dist/core/agent-session.d.ts.map +1 -1
- package/dist/core/agent-session.js +47 -6
- package/dist/core/agent-session.js.map +1 -1
- package/dist/core/code-retrieval-chunkers.d.ts +27 -0
- package/dist/core/code-retrieval-chunkers.d.ts.map +1 -0
- package/dist/core/code-retrieval-chunkers.js +264 -0
- package/dist/core/code-retrieval-chunkers.js.map +1 -0
- package/dist/core/code-retrieval-chunkers.test.d.ts +2 -0
- package/dist/core/code-retrieval-chunkers.test.d.ts.map +1 -0
- package/dist/core/code-retrieval-chunkers.test.js +203 -0
- package/dist/core/code-retrieval-chunkers.test.js.map +1 -0
- package/dist/core/code-retrieval.d.ts +6 -0
- package/dist/core/code-retrieval.d.ts.map +1 -1
- package/dist/core/code-retrieval.js +38 -1
- package/dist/core/code-retrieval.js.map +1 -1
- package/dist/core/lsp/client-navigation.test.d.ts +2 -0
- package/dist/core/lsp/client-navigation.test.d.ts.map +1 -0
- package/dist/core/lsp/client-navigation.test.js +135 -0
- package/dist/core/lsp/client-navigation.test.js.map +1 -0
- package/dist/core/lsp/client.d.ts +69 -0
- package/dist/core/lsp/client.d.ts.map +1 -1
- package/dist/core/lsp/client.js +149 -0
- package/dist/core/lsp/client.js.map +1 -1
- package/dist/core/lsp/manager-navigation.test.d.ts +2 -0
- package/dist/core/lsp/manager-navigation.test.d.ts.map +1 -0
- package/dist/core/lsp/manager-navigation.test.js +121 -0
- package/dist/core/lsp/manager-navigation.test.js.map +1 -0
- package/dist/core/lsp/manager.d.ts +35 -1
- package/dist/core/lsp/manager.d.ts.map +1 -1
- package/dist/core/lsp/manager.js +92 -0
- package/dist/core/lsp/manager.js.map +1 -1
- package/dist/core/mcp/deferred-catalog.d.ts +1 -1
- package/dist/core/mcp/deferred-catalog.js +1 -1
- package/dist/core/settings-manager.d.ts +2 -0
- package/dist/core/settings-manager.d.ts.map +1 -1
- package/dist/core/settings-manager.js +10 -1
- package/dist/core/settings-manager.js.map +1 -1
- package/dist/system-prompt.d.ts +6 -1
- package/dist/system-prompt.d.ts.map +1 -1
- package/dist/system-prompt.js +28 -5
- package/dist/system-prompt.js.map +1 -1
- package/dist/system-prompt.tiering.test.d.ts +2 -0
- package/dist/system-prompt.tiering.test.d.ts.map +1 -0
- package/dist/system-prompt.tiering.test.js +103 -0
- package/dist/system-prompt.tiering.test.js.map +1 -0
- package/dist/tools/code-nav.d.ts +30 -0
- package/dist/tools/code-nav.d.ts.map +1 -0
- package/dist/tools/code-nav.js +320 -0
- package/dist/tools/code-nav.js.map +1 -0
- package/dist/tools/code-nav.test.d.ts +2 -0
- package/dist/tools/code-nav.test.d.ts.map +1 -0
- package/dist/tools/code-nav.test.js +136 -0
- package/dist/tools/code-nav.test.js.map +1 -0
- package/dist/tools/grep-parity.test.d.ts +2 -0
- package/dist/tools/grep-parity.test.d.ts.map +1 -0
- package/dist/tools/grep-parity.test.js +92 -0
- package/dist/tools/grep-parity.test.js.map +1 -0
- package/dist/tools/grep-recall.test.d.ts +2 -0
- package/dist/tools/grep-recall.test.d.ts.map +1 -0
- package/dist/tools/grep-recall.test.js +114 -0
- package/dist/tools/grep-recall.test.js.map +1 -0
- package/dist/tools/grep-spawn.test.d.ts +2 -0
- package/dist/tools/grep-spawn.test.d.ts.map +1 -0
- package/dist/tools/grep-spawn.test.js +66 -0
- package/dist/tools/grep-spawn.test.js.map +1 -0
- package/dist/tools/grep.d.ts +13 -3
- package/dist/tools/grep.d.ts.map +1 -1
- package/dist/tools/grep.js +340 -40
- package/dist/tools/grep.js.map +1 -1
- package/dist/tools/grep.test.js +11 -8
- package/dist/tools/grep.test.js.map +1 -1
- package/dist/tools/index.d.ts +6 -0
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +4 -1
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/prompt-hints.d.ts.map +1 -1
- package/dist/tools/prompt-hints.js +23 -8
- package/dist/tools/prompt-hints.js.map +1 -1
- package/dist/tools/search-code.d.ts.map +1 -1
- package/dist/tools/search-code.js +7 -6
- package/dist/tools/search-code.js.map +1 -1
- package/dist/tools/search-code.test.js +8 -2
- package/dist/tools/search-code.test.js.map +1 -1
- package/dist/tools/tool-search.d.ts.map +1 -1
- package/dist/tools/tool-search.js +2 -1
- package/dist/tools/tool-search.js.map +1 -1
- package/dist/tools/tool-tiers.d.ts +42 -0
- package/dist/tools/tool-tiers.d.ts.map +1 -0
- package/dist/tools/tool-tiers.js +77 -0
- package/dist/tools/tool-tiers.js.map +1 -0
- package/dist/tools/tool-tiers.test.d.ts +2 -0
- package/dist/tools/tool-tiers.test.d.ts.map +1 -0
- package/dist/tools/tool-tiers.test.js +48 -0
- package/dist/tools/tool-tiers.test.js.map +1 -0
- package/dist/ui/hooks/useAgentLoop.js +1 -1
- package/dist/ui/hooks/useAgentLoop.js.map +1 -1
- package/package.json +4 -4
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import readline from "node:readline";
|
|
6
|
+
import { createReadStream } from "node:fs";
|
|
7
|
+
import { createGrepTool } from "./grep.js";
|
|
8
|
+
function context() {
|
|
9
|
+
return { signal: new AbortController().signal, toolCallId: "test" };
|
|
10
|
+
}
|
|
11
|
+
async function makeFixture(files) {
|
|
12
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "gg-grep-recall-"));
|
|
13
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
14
|
+
const abs = path.join(dir, rel);
|
|
15
|
+
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
16
|
+
await fs.writeFile(abs, content);
|
|
17
|
+
}
|
|
18
|
+
return dir;
|
|
19
|
+
}
|
|
20
|
+
/** Both scanner paths, so recall is asserted for whichever one a host uses. */
|
|
21
|
+
const scanners = [
|
|
22
|
+
{ label: "in-process", useExternalScanner: () => false },
|
|
23
|
+
{ label: "external", useExternalScanner: () => true },
|
|
24
|
+
];
|
|
25
|
+
describe.each(scanners)("grep recall ($label)", ({ useExternalScanner }) => {
|
|
26
|
+
const grep = (dir) => createGrepTool(dir, undefined, { useExternalScanner });
|
|
27
|
+
it("searches dot-directories", async () => {
|
|
28
|
+
const dir = await makeFixture({
|
|
29
|
+
".github/workflows/ci.yml": "jobs:\n build:\n runs-on: ubuntu-latest\n",
|
|
30
|
+
"README.md": "no jobs here\n",
|
|
31
|
+
});
|
|
32
|
+
const result = await grep(dir).execute({ pattern: "runs-on" }, context());
|
|
33
|
+
expect(result).toContain(path.join(".github", "workflows", "ci.yml") + ":3:");
|
|
34
|
+
});
|
|
35
|
+
it("skips files matched by .gitignore", async () => {
|
|
36
|
+
const dir = await makeFixture({
|
|
37
|
+
".gitignore": "dist/\n*.log\n",
|
|
38
|
+
"src/app.ts": "export const marker = 1;\n",
|
|
39
|
+
"dist/app.js": "export const marker = 1;\n",
|
|
40
|
+
"debug.log": "marker\n",
|
|
41
|
+
});
|
|
42
|
+
const result = await grep(dir).execute({ pattern: "marker" }, context());
|
|
43
|
+
expect(result).toContain(path.join("src", "app.ts") + ":1:");
|
|
44
|
+
expect(result).not.toContain("dist");
|
|
45
|
+
expect(result).not.toContain("debug.log");
|
|
46
|
+
});
|
|
47
|
+
it("still searches an ignored directory when it is the explicit path", async () => {
|
|
48
|
+
const dir = await makeFixture({
|
|
49
|
+
".gitignore": "dist/\n",
|
|
50
|
+
"dist/app.js": "export const marker = 1;\n",
|
|
51
|
+
});
|
|
52
|
+
const result = await grep(dir).execute({ pattern: "marker", path: "dist" }, context());
|
|
53
|
+
expect(result).toContain("app.js:1:");
|
|
54
|
+
});
|
|
55
|
+
it("never scans node_modules or .git", async () => {
|
|
56
|
+
const dir = await makeFixture({
|
|
57
|
+
"node_modules/dep/index.js": "marker\n",
|
|
58
|
+
".git/COMMIT_EDITMSG": "marker\n",
|
|
59
|
+
"src/keep.ts": "marker\n",
|
|
60
|
+
});
|
|
61
|
+
const result = await grep(dir).execute({ pattern: "marker" }, context());
|
|
62
|
+
expect(result).toContain(path.join("src", "keep.ts") + ":1:");
|
|
63
|
+
expect(result).not.toContain("node_modules");
|
|
64
|
+
expect(result).not.toContain(".git");
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
describe("grep scan ordering", () => {
|
|
68
|
+
it("matches a sequential scan over the same candidate list", async () => {
|
|
69
|
+
const files = {};
|
|
70
|
+
// Enough files to fill several scan waves, with matches spread across them.
|
|
71
|
+
for (let i = 0; i < 50; i++) {
|
|
72
|
+
files[`pkg${String(i).padStart(2, "0")}/mod.ts`] =
|
|
73
|
+
`line one\nconst marker${i} = ${i};\nmarker tail\n`;
|
|
74
|
+
}
|
|
75
|
+
const dir = await makeFixture(files);
|
|
76
|
+
const parallel = await createGrepTool(dir, undefined, {
|
|
77
|
+
useExternalScanner: () => false,
|
|
78
|
+
}).execute({ pattern: "marker", max_results: 500 }, context());
|
|
79
|
+
const expected = await sequentialScan(dir, /marker/g);
|
|
80
|
+
expect(String(parallel).split("\n\n")[0]).toBe(expected.join("\n"));
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
/**
|
|
84
|
+
* Reference implementation: one file at a time, candidates in sorted order.
|
|
85
|
+
*
|
|
86
|
+
* fast-glob always yields POSIX-separated paths, while the tool reports
|
|
87
|
+
* `path.relative` output — native separators, so backslashes on Windows.
|
|
88
|
+
* Normalizing here keeps this test about ORDER and CONTENT; asserting the raw
|
|
89
|
+
* glob strings instead silently made it a path-format test that only failed on
|
|
90
|
+
* Windows.
|
|
91
|
+
*/
|
|
92
|
+
async function sequentialScan(dir, regex) {
|
|
93
|
+
const fg = await import("fast-glob");
|
|
94
|
+
const entries = (await fg.default("**/*", { cwd: dir, dot: true, onlyFiles: true, suppressErrors: true }))
|
|
95
|
+
.sort()
|
|
96
|
+
.map((entry) => path.normalize(entry));
|
|
97
|
+
const results = [];
|
|
98
|
+
for (const entry of entries) {
|
|
99
|
+
const rl = readline.createInterface({
|
|
100
|
+
input: createReadStream(path.join(dir, entry), "utf-8"),
|
|
101
|
+
crlfDelay: Infinity,
|
|
102
|
+
});
|
|
103
|
+
let lineNum = 0;
|
|
104
|
+
for await (const line of rl) {
|
|
105
|
+
lineNum++;
|
|
106
|
+
regex.lastIndex = 0;
|
|
107
|
+
if (regex.test(line))
|
|
108
|
+
results.push(`${entry}:${lineNum}:${line}`);
|
|
109
|
+
}
|
|
110
|
+
rl.close();
|
|
111
|
+
}
|
|
112
|
+
return results;
|
|
113
|
+
}
|
|
114
|
+
//# sourceMappingURL=grep-recall.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"grep-recall.test.js","sourceRoot":"","sources":["../../src/tools/grep-recall.test.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"grep-spawn.test.d.ts","sourceRoot":"","sources":["../../src/tools/grep-spawn.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
const spawnMock = vi.hoisted(() => vi.fn());
|
|
6
|
+
// Importing `spawn` from the mocked module would hand back the mock itself, so
|
|
7
|
+
// delegating to it recurses. Capture the genuine implementation inside the mock
|
|
8
|
+
// factory instead.
|
|
9
|
+
const actualSpawn = vi.hoisted(() => ({ fn: undefined }));
|
|
10
|
+
vi.mock("node:child_process", async () => {
|
|
11
|
+
const actual = await vi.importActual("node:child_process");
|
|
12
|
+
actualSpawn.fn = actual.spawn;
|
|
13
|
+
return { ...actual, spawn: spawnMock };
|
|
14
|
+
});
|
|
15
|
+
const { createGrepTool, detectExternalScanner, resetExternalScannerProbe } = await import("./grep.js");
|
|
16
|
+
function context() {
|
|
17
|
+
return { signal: new AbortController().signal, toolCallId: "test" };
|
|
18
|
+
}
|
|
19
|
+
let tmpDir;
|
|
20
|
+
beforeEach(async () => {
|
|
21
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "gg-grep-spawn-"));
|
|
22
|
+
await fs.writeFile(path.join(tmpDir, "a.ts"), "const marker = 1;\n");
|
|
23
|
+
resetExternalScannerProbe();
|
|
24
|
+
spawnMock.mockReset();
|
|
25
|
+
// Delegate to the genuine spawn so the tool behaves normally; we only assert
|
|
26
|
+
// the options it was handed.
|
|
27
|
+
spawnMock.mockImplementation((...args) => actualSpawn.fn(...args));
|
|
28
|
+
});
|
|
29
|
+
afterEach(async () => {
|
|
30
|
+
resetExternalScannerProbe();
|
|
31
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
32
|
+
});
|
|
33
|
+
describe("grep external scanner spawn options", () => {
|
|
34
|
+
it("hides the console window when probing for the scanner", async () => {
|
|
35
|
+
await detectExternalScanner();
|
|
36
|
+
expect(spawnMock).toHaveBeenCalledWith("rg", ["--version"], expect.objectContaining({ windowsHide: true }));
|
|
37
|
+
});
|
|
38
|
+
it("hides the console window when running a search", async () => {
|
|
39
|
+
const tool = createGrepTool(tmpDir, undefined, { useExternalScanner: () => true });
|
|
40
|
+
await tool.execute({ pattern: "marker" }, context());
|
|
41
|
+
const searchCall = spawnMock.mock.calls.find((call) => Array.isArray(call[1]) && !call[1].includes("--version"));
|
|
42
|
+
// `rg` is optional. When it is absent the in-process path legitimately
|
|
43
|
+
// never spawns — but assert that is the actual reason, so this test can
|
|
44
|
+
// never pass merely because the search silently failed to run.
|
|
45
|
+
if (!searchCall) {
|
|
46
|
+
expect(await detectExternalScanner()).toBeUndefined();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
expect(searchCall[2]).toEqual(expect.objectContaining({ windowsHide: true }));
|
|
50
|
+
expect(searchCall[2]).toEqual(expect.objectContaining({ cwd: tmpDir }));
|
|
51
|
+
});
|
|
52
|
+
it("never spawns a scan once the budget is already spent", async () => {
|
|
53
|
+
const tool = createGrepTool(tmpDir, undefined, {
|
|
54
|
+
useExternalScanner: () => true,
|
|
55
|
+
deadlineMs: 0,
|
|
56
|
+
});
|
|
57
|
+
const result = String(await tool.execute({ pattern: "marker" }, context()));
|
|
58
|
+
// The child's budget is floored at 1ms, so spawning here would race the
|
|
59
|
+
// scanner: whenever the child won, an expired budget still returned real
|
|
60
|
+
// matches — and only sometimes, which is how it slipped through as a flake.
|
|
61
|
+
expect(spawnMock).not.toHaveBeenCalled();
|
|
62
|
+
expect(result).toContain("No matches found.");
|
|
63
|
+
expect(result).not.toContain("a.ts:1:");
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
//# sourceMappingURL=grep-spawn.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"grep-spawn.test.js","sourceRoot":"","sources":["../../src/tools/grep-spawn.test.ts"],"names":[],"mappings":"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"}
|
package/dist/tools/grep.d.ts
CHANGED
|
@@ -8,8 +8,18 @@ declare const GrepParams: z.ZodObject<{
|
|
|
8
8
|
max_results: z.ZodOptional<z.ZodNumber>;
|
|
9
9
|
case_insensitive: z.ZodOptional<z.ZodBoolean>;
|
|
10
10
|
}, z.core.$strip>;
|
|
11
|
-
export
|
|
12
|
-
/** Wall-clock budget for one call. Overridable so tests can trip it without a 5s wait. */
|
|
13
|
-
deadlineMs?: number
|
|
11
|
+
export interface GrepToolOptions {
|
|
12
|
+
/** Wall-clock budget for one call. Overridable so tests can trip it without a 5s wait. */
|
|
13
|
+
deadlineMs?: number;
|
|
14
|
+
/**
|
|
15
|
+
* Whether the external `rg` scanner may be used when it is on PATH. Read
|
|
16
|
+
* lazily so a settings change takes effect without rebuilding the toolset.
|
|
17
|
+
*/
|
|
18
|
+
useExternalScanner?: () => boolean;
|
|
19
|
+
}
|
|
20
|
+
export declare function createGrepTool(cwd: string, ops?: ToolOperations, options?: GrepToolOptions): AgentTool<typeof GrepParams>;
|
|
21
|
+
export declare function detectExternalScanner(): Promise<string | undefined>;
|
|
22
|
+
/** Test seam: forget the cached probe result. */
|
|
23
|
+
export declare function resetExternalScannerProbe(): void;
|
|
14
24
|
export {};
|
|
15
25
|
//# sourceMappingURL=grep.d.ts.map
|
package/dist/tools/grep.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"grep.d.ts","sourceRoot":"","sources":["../../src/tools/grep.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"grep.d.ts","sourceRoot":"","sources":["../../src/tools/grep.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AAIrD,OAAO,EAAmB,KAAK,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAEvE,QAAA,MAAM,UAAU;;;;;;iBAcd,CAAC;AA0HH,MAAM,WAAW,eAAe;IAC9B,0FAA0F;IAC1F,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;OAGG;IACH,kBAAkB,CAAC,EAAE,MAAM,OAAO,CAAC;CACpC;AAED,wBAAgB,cAAc,CAC5B,GAAG,EAAE,MAAM,EACX,GAAG,GAAE,cAAgC,EACrC,OAAO,GAAE,eAAoB,GAC5B,SAAS,CAAC,OAAO,UAAU,CAAC,CA4F9B;AA6JD,wBAAgB,qBAAqB,IAAI,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAgBnE;AAED,iDAAiD;AACjD,wBAAgB,yBAAyB,IAAI,IAAI,CAEhD"}
|
package/dist/tools/grep.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import readline from "node:readline";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
3
4
|
import { z } from "zod";
|
|
4
5
|
import { sliceHead } from "@kenkaiiii/gg-ai";
|
|
5
6
|
import { resolvePath } from "./path-utils.js";
|
|
@@ -8,7 +9,10 @@ import { localOperations } from "./operations.js";
|
|
|
8
9
|
const GrepParams = z.object({
|
|
9
10
|
pattern: z.string().describe("Search pattern (JavaScript regex; leading (?i) is supported)"),
|
|
10
11
|
path: z.string().optional().describe("File or directory to search (defaults to cwd)"),
|
|
11
|
-
include: z
|
|
12
|
+
include: z
|
|
13
|
+
.string()
|
|
14
|
+
.optional()
|
|
15
|
+
.describe("Glob pattern to filter files, matched at any depth (e.g. '*.ts')"),
|
|
12
16
|
max_results: z
|
|
13
17
|
.number()
|
|
14
18
|
.int()
|
|
@@ -24,6 +28,15 @@ const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
|
|
24
28
|
const MAX_CANDIDATE_FILES = 10_000;
|
|
25
29
|
/** Wall-clock budget for a single grep call — catastrophic backtracking is count-bounded but not time-bounded */
|
|
26
30
|
const GREP_DEADLINE_MS = 5_000;
|
|
31
|
+
/**
|
|
32
|
+
* Files scanned concurrently on the in-process path. A grep call is almost
|
|
33
|
+
* entirely I/O wait, so scanning one file at a time turned "slow" into
|
|
34
|
+
* "incomplete": the wall-clock budget expired mid-directory and the model saw a
|
|
35
|
+
* partial answer as if it were the whole one.
|
|
36
|
+
*/
|
|
37
|
+
const SCAN_CONCURRENCY = 12;
|
|
38
|
+
/** Cap on bytes accepted from the external scanner before results are ranked. */
|
|
39
|
+
const MAX_EXTERNAL_OUTPUT_BYTES = 8 * 1024 * 1024;
|
|
27
40
|
/** Long patterns are almost always mistakes and raise the cost of every pathological shape */
|
|
28
41
|
const MAX_PATTERN_LENGTH = 1_000;
|
|
29
42
|
/** Check the clock every N lines inside a file scan — cheap enough to be free, dense enough to bound one bad line */
|
|
@@ -118,13 +131,19 @@ function containsQuantifier(body) {
|
|
|
118
131
|
}
|
|
119
132
|
return false;
|
|
120
133
|
}
|
|
121
|
-
export function createGrepTool(cwd, ops = localOperations,
|
|
122
|
-
|
|
123
|
-
|
|
134
|
+
export function createGrepTool(cwd, ops = localOperations, options = {}) {
|
|
135
|
+
const deadlineMs = options.deadlineMs ?? GREP_DEADLINE_MS;
|
|
136
|
+
// The external scanner reads the real filesystem, so it is only valid when
|
|
137
|
+
// this tool is also pointed at the real filesystem. Remote operations
|
|
138
|
+
// (SSH/Docker) must stay on the in-process path.
|
|
139
|
+
const externalAllowed = ops === localOperations;
|
|
124
140
|
return {
|
|
125
141
|
name: "grep",
|
|
126
|
-
description: "Search file contents using regex. Returns filepath:line_number:content for matches
|
|
127
|
-
"
|
|
142
|
+
description: "Search file contents using regex. Returns filepath:line_number:content for matches, " +
|
|
143
|
+
"ordered by path. Skips files matched by the search root's .gitignore (pass an explicit " +
|
|
144
|
+
"`path` inside an ignored directory to search it anyway), skips binary files, and searches " +
|
|
145
|
+
"dot-directories. " +
|
|
146
|
+
"Lookaround and backreferences are supported but scan more slowly.",
|
|
128
147
|
parameters: GrepParams,
|
|
129
148
|
async execute({ pattern, path: searchPath, include, max_results, case_insensitive }) {
|
|
130
149
|
const dir = searchPath ? resolvePath(cwd, searchPath) : cwd;
|
|
@@ -158,46 +177,327 @@ deadlineMs = GREP_DEADLINE_MS) {
|
|
|
158
177
|
const results = await searchFile(dir, regex, cwd, maxResults, ops, deadline);
|
|
159
178
|
return formatResults(results, maxResults, false, deadline.hit, deadlineMs);
|
|
160
179
|
}
|
|
161
|
-
//
|
|
162
|
-
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
180
|
+
// An already-spent budget must not start a scan it cannot honour.
|
|
181
|
+
// `runExternalScan` floors the child's budget at 1ms, so without this
|
|
182
|
+
// guard an expired deadline still races ripgrep and returns real matches
|
|
183
|
+
// whenever the child wins — reporting results the caller's budget had
|
|
184
|
+
// already ruled out, and non-deterministically at that. The in-process
|
|
185
|
+
// path below reports the expiry directly instead.
|
|
186
|
+
const useExternal = externalAllowed &&
|
|
187
|
+
!isExpired(deadline) &&
|
|
188
|
+
(options.useExternalScanner?.() ?? true) &&
|
|
189
|
+
isExternallySupported(normalizedPattern) &&
|
|
190
|
+
(await detectExternalScanner()) !== undefined;
|
|
191
|
+
if (useExternal) {
|
|
192
|
+
const external = await runExternalScan({
|
|
193
|
+
dir,
|
|
194
|
+
cwd,
|
|
195
|
+
pattern: normalizedPattern,
|
|
196
|
+
include,
|
|
197
|
+
caseInsensitive: flags.includes("i"),
|
|
198
|
+
maxResults,
|
|
199
|
+
deadline,
|
|
200
|
+
deadlineMs,
|
|
201
|
+
});
|
|
202
|
+
// A scanner failure is a tooling problem, not a search result — retry
|
|
203
|
+
// the query in-process rather than reporting a false "no matches".
|
|
204
|
+
if (external) {
|
|
205
|
+
return formatResults(external, maxResults, false, deadline.hit, deadlineMs);
|
|
187
206
|
}
|
|
188
|
-
scannedCandidates += 1;
|
|
189
|
-
const entry = typeof item === "string" ? item : item.path;
|
|
190
|
-
const ext = path.extname(entry).toLowerCase();
|
|
191
|
-
if (BINARY_EXTENSIONS.has(ext))
|
|
192
|
-
continue;
|
|
193
|
-
const filePath = path.join(dir, entry);
|
|
194
|
-
const fileResults = await searchFile(filePath, regex, cwd, maxResults - results.length, ops, deadline);
|
|
195
|
-
results.push(...fileResults);
|
|
196
207
|
}
|
|
208
|
+
const { files, candidateLimitHit } = await enumerateCandidates(dir, include);
|
|
209
|
+
const results = await scanFiles(files, dir, regex, cwd, maxResults, ops, deadline);
|
|
197
210
|
return formatResults(results, maxResults, candidateLimitHit, deadline.hit, deadlineMs);
|
|
198
211
|
},
|
|
199
212
|
};
|
|
200
213
|
}
|
|
214
|
+
/**
|
|
215
|
+
* Build the candidate file list for one directory scan.
|
|
216
|
+
*
|
|
217
|
+
* Two recall fixes live here. Dot-entries are included, because `.github/`,
|
|
218
|
+
* `.changeset/` and similar directories hold real, searchable project files.
|
|
219
|
+
* And `.gitignore` is applied for real (it previously was not, despite being
|
|
220
|
+
* advertised), so build output and vendored trees stop crowding out source.
|
|
221
|
+
* An explicit `path` INTO an ignored directory still searches it: the ignore
|
|
222
|
+
* file is read relative to the scan root, so scoping past it is the escape
|
|
223
|
+
* hatch.
|
|
224
|
+
*
|
|
225
|
+
* The list is sorted so results are deterministic and byte-comparable across
|
|
226
|
+
* scanners; the parallel scan below relies on that order too.
|
|
227
|
+
*/
|
|
228
|
+
async function enumerateCandidates(dir, include) {
|
|
229
|
+
const fg = await import("fast-glob");
|
|
230
|
+
const ignoreModule = await import("ignore");
|
|
231
|
+
const ig = ignoreModule.default();
|
|
232
|
+
ig.add(await loadIgnorePatterns(dir));
|
|
233
|
+
const globPattern = normalizeIncludeGlob(include);
|
|
234
|
+
const entries = await fg.default(globPattern, {
|
|
235
|
+
cwd: dir,
|
|
236
|
+
dot: true,
|
|
237
|
+
onlyFiles: true,
|
|
238
|
+
ignore: ["**/node_modules/**", "**/.git/**"],
|
|
239
|
+
suppressErrors: true,
|
|
240
|
+
followSymbolicLinks: false,
|
|
241
|
+
});
|
|
242
|
+
const kept = entries
|
|
243
|
+
.filter((entry) => !BINARY_EXTENSIONS.has(path.extname(entry).toLowerCase()))
|
|
244
|
+
.filter((entry) => !ig.ignores(entry))
|
|
245
|
+
.sort();
|
|
246
|
+
return {
|
|
247
|
+
files: kept.slice(0, MAX_CANDIDATE_FILES),
|
|
248
|
+
candidateLimitHit: kept.length > MAX_CANDIDATE_FILES,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Normalize an `include` glob to gitignore-style semantics: a bare pattern
|
|
253
|
+
* with no separator matches at any depth. Models write `*.ts` meaning "every
|
|
254
|
+
* TypeScript file", and a root-only interpretation silently returned nothing
|
|
255
|
+
* for any project that keeps its sources in a subdirectory.
|
|
256
|
+
*/
|
|
257
|
+
function normalizeIncludeGlob(include) {
|
|
258
|
+
// Backslashes are picomatch ESCAPES, not separators: a Windows-shaped
|
|
259
|
+
// `include` (e.g. `src\**\*.ts`) would match nothing.
|
|
260
|
+
const normalized = (include ?? "**/*").replace(/\\/g, "/");
|
|
261
|
+
return normalized.includes("/") ? normalized : `**/${normalized}`;
|
|
262
|
+
}
|
|
263
|
+
async function fileExists(filePath) {
|
|
264
|
+
try {
|
|
265
|
+
const fs = await import("node:fs/promises");
|
|
266
|
+
await fs.access(filePath);
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
async function loadIgnorePatterns(dir) {
|
|
274
|
+
try {
|
|
275
|
+
const fs = await import("node:fs/promises");
|
|
276
|
+
const content = await fs.readFile(path.join(dir, ".gitignore"), "utf-8");
|
|
277
|
+
return content
|
|
278
|
+
.split("\n")
|
|
279
|
+
.map((line) => line.trim())
|
|
280
|
+
.filter((line) => line && !line.startsWith("#"));
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
return [];
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Scan candidates with a bounded worker pool, emitting results in candidate
|
|
288
|
+
* order regardless of completion order: each file writes into its own slot and
|
|
289
|
+
* the slots are flattened at the end. Ordering is therefore identical to a
|
|
290
|
+
* sequential scan over the same list.
|
|
291
|
+
*
|
|
292
|
+
* Scheduling stops as soon as enough matches exist. Because candidates are in
|
|
293
|
+
* sorted order and every scheduled index precedes every unscheduled one, the
|
|
294
|
+
* first `maxResults` entries of the flattened array are exactly the ones a
|
|
295
|
+
* sequential scan would have produced before stopping.
|
|
296
|
+
*/
|
|
297
|
+
async function scanFiles(files, dir, regex, cwd, maxResults, ops, deadline) {
|
|
298
|
+
const slots = new Array(files.length).fill(undefined).map(() => []);
|
|
299
|
+
let next = 0;
|
|
300
|
+
let collected = 0;
|
|
301
|
+
const worker = async () => {
|
|
302
|
+
for (;;) {
|
|
303
|
+
if (collected >= maxResults || isExpired(deadline))
|
|
304
|
+
return;
|
|
305
|
+
const index = next++;
|
|
306
|
+
if (index >= files.length)
|
|
307
|
+
return;
|
|
308
|
+
// Each worker owns a private RegExp: `lastIndex` on a shared global regex
|
|
309
|
+
// is mutated during `test`, so concurrent scans would corrupt each other.
|
|
310
|
+
const own = new RegExp(regex.source, regex.flags);
|
|
311
|
+
const fileResults = await searchFile(path.join(dir, files[index]), own, cwd, maxResults, ops, deadline);
|
|
312
|
+
slots[index] = fileResults;
|
|
313
|
+
collected += fileResults.length;
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
await Promise.all(Array.from({ length: Math.min(SCAN_CONCURRENCY, files.length || 1) }, () => worker()));
|
|
317
|
+
const results = [];
|
|
318
|
+
for (const slot of slots) {
|
|
319
|
+
for (const line of slot) {
|
|
320
|
+
results.push(line);
|
|
321
|
+
if (results.length >= maxResults)
|
|
322
|
+
return results;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return results;
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Constructs the external scanner's regex engine cannot execute. It uses a
|
|
329
|
+
* finite-automaton engine with linear-time guarantees, which is exactly why it
|
|
330
|
+
* is fast — and exactly why lookaround and backreferences are missing. Those
|
|
331
|
+
* patterns fall back to the in-process backtracking scanner, which is the
|
|
332
|
+
* semantic reference for this tool.
|
|
333
|
+
*/
|
|
334
|
+
const EXTERNALLY_UNSUPPORTED = /\(\?=|\(\?!|\(\?</;
|
|
335
|
+
const BACKREFERENCE = /\\[1-9]/;
|
|
336
|
+
function isExternallySupported(pattern) {
|
|
337
|
+
return !EXTERNALLY_UNSUPPORTED.test(pattern) && !BACKREFERENCE.test(pattern);
|
|
338
|
+
}
|
|
339
|
+
/** Resolved once per process: probing costs a spawn, and the answer cannot change. */
|
|
340
|
+
let externalScannerProbe;
|
|
341
|
+
export function detectExternalScanner() {
|
|
342
|
+
externalScannerProbe ??= new Promise((resolve) => {
|
|
343
|
+
let child;
|
|
344
|
+
try {
|
|
345
|
+
// windowsHide: a bare spawn pops a console window on Windows. `grep` is
|
|
346
|
+
// one of the most-called tools, so without it a search session strobes
|
|
347
|
+
// the user's screen. No-op on other platforms.
|
|
348
|
+
child = spawn("rg", ["--version"], { stdio: "ignore", windowsHide: true });
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
resolve(undefined);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
child.once("error", () => resolve(undefined));
|
|
355
|
+
child.once("close", (code) => resolve(code === 0 ? "rg" : undefined));
|
|
356
|
+
});
|
|
357
|
+
return externalScannerProbe;
|
|
358
|
+
}
|
|
359
|
+
/** Test seam: forget the cached probe result. */
|
|
360
|
+
export function resetExternalScannerProbe() {
|
|
361
|
+
externalScannerProbe = undefined;
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Run the external scanner and format its output exactly like the in-process
|
|
365
|
+
* path: same relative paths, same line truncation, same path ordering.
|
|
366
|
+
*
|
|
367
|
+
* Returns `undefined` when the scanner could not complete, which makes the
|
|
368
|
+
* caller retry in-process. Reporting "no matches" for a spawn failure would be
|
|
369
|
+
* a silent recall loss — the failure mode this whole path exists to remove.
|
|
370
|
+
*/
|
|
371
|
+
async function runExternalScan(req) {
|
|
372
|
+
const args = [
|
|
373
|
+
"--no-config",
|
|
374
|
+
"--line-number",
|
|
375
|
+
"--no-heading",
|
|
376
|
+
"--with-filename",
|
|
377
|
+
"--color",
|
|
378
|
+
"never",
|
|
379
|
+
"--no-messages",
|
|
380
|
+
"--hidden",
|
|
381
|
+
// Apply exactly one ignore file — the scan root's .gitignore, wired in
|
|
382
|
+
// below — and nothing else: no nested, parent, global, or non-VCS ignore
|
|
383
|
+
// files. The in-process scanner is this tool's semantic reference and it
|
|
384
|
+
// reads that one file; anything wider would make the two paths disagree
|
|
385
|
+
// about which files exist.
|
|
386
|
+
"--no-ignore",
|
|
387
|
+
"--max-filesize",
|
|
388
|
+
String(MAX_FILE_SIZE),
|
|
389
|
+
"--max-count",
|
|
390
|
+
String(req.maxResults),
|
|
391
|
+
"--glob",
|
|
392
|
+
"!**/.git/**",
|
|
393
|
+
"--glob",
|
|
394
|
+
"!**/node_modules/**",
|
|
395
|
+
];
|
|
396
|
+
const rootIgnore = path.join(req.dir, ".gitignore");
|
|
397
|
+
if (await fileExists(rootIgnore))
|
|
398
|
+
args.push("--ignore-file", rootIgnore);
|
|
399
|
+
if (req.caseInsensitive)
|
|
400
|
+
args.push("--ignore-case");
|
|
401
|
+
if (req.include)
|
|
402
|
+
args.push("--glob", req.include.replace(/\\/g, "/"));
|
|
403
|
+
// Relative root on purpose — `formatExternalMatches` parses `path:line:text`
|
|
404
|
+
// by first colon, which an absolute Windows path would break.
|
|
405
|
+
args.push("--regexp", req.pattern, "--", ".");
|
|
406
|
+
const remainingMs = Math.max(1, req.deadline.expiresAt - Date.now());
|
|
407
|
+
const output = await new Promise((resolve) => {
|
|
408
|
+
let child;
|
|
409
|
+
try {
|
|
410
|
+
child = spawn("rg", args, { cwd: req.dir, windowsHide: true });
|
|
411
|
+
}
|
|
412
|
+
catch {
|
|
413
|
+
resolve(undefined);
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
let stdout = "";
|
|
417
|
+
let bytes = 0;
|
|
418
|
+
let timedOut = false;
|
|
419
|
+
let settled = false;
|
|
420
|
+
const timer = setTimeout(() => {
|
|
421
|
+
timedOut = true;
|
|
422
|
+
child.kill("SIGKILL");
|
|
423
|
+
}, remainingMs);
|
|
424
|
+
const finish = (value) => {
|
|
425
|
+
if (settled)
|
|
426
|
+
return;
|
|
427
|
+
settled = true;
|
|
428
|
+
clearTimeout(timer);
|
|
429
|
+
resolve(value);
|
|
430
|
+
};
|
|
431
|
+
child.stdout?.setEncoding("utf-8");
|
|
432
|
+
child.stdout?.on("data", (chunk) => {
|
|
433
|
+
bytes += chunk.length;
|
|
434
|
+
if (bytes > MAX_EXTERNAL_OUTPUT_BYTES) {
|
|
435
|
+
child.kill("SIGKILL");
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
stdout += chunk;
|
|
439
|
+
});
|
|
440
|
+
child.stderr?.resume();
|
|
441
|
+
child.once("error", () => finish(undefined));
|
|
442
|
+
// Exit 0 = matches, 1 = no matches. Anything else is a real failure.
|
|
443
|
+
child.once("close", (code) => finish({ stdout, ok: code === 0 || code === 1, timedOut }));
|
|
444
|
+
});
|
|
445
|
+
if (!output)
|
|
446
|
+
return undefined;
|
|
447
|
+
if (output.timedOut)
|
|
448
|
+
req.deadline.hit = true;
|
|
449
|
+
else if (!output.ok)
|
|
450
|
+
return undefined;
|
|
451
|
+
return formatExternalMatches(output.stdout, req);
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Parse `path:line:text` records into this tool's output shape. The scanner
|
|
455
|
+
* emits paths relative to the scan root, so they are re-based onto the tool's
|
|
456
|
+
* cwd and re-sorted into candidate order before truncation.
|
|
457
|
+
*/
|
|
458
|
+
function formatExternalMatches(stdout, req) {
|
|
459
|
+
const byPath = new Map();
|
|
460
|
+
for (const raw of stdout.split("\n")) {
|
|
461
|
+
if (!raw)
|
|
462
|
+
continue;
|
|
463
|
+
// Splitting on the first colon is only safe because the scan is rooted at a
|
|
464
|
+
// RELATIVE path (see the `.` argument above), so no Windows drive prefix
|
|
465
|
+
// ("C:\") can appear here. Keep it that way: the guards below fail CLOSED,
|
|
466
|
+
// so a misparsed line number drops the match silently — the exact
|
|
467
|
+
// "silence looks like no results" failure this scanner exists to avoid.
|
|
468
|
+
const firstSep = raw.indexOf(":");
|
|
469
|
+
if (firstSep <= 0)
|
|
470
|
+
continue;
|
|
471
|
+
const secondSep = raw.indexOf(":", firstSep + 1);
|
|
472
|
+
if (secondSep < 0)
|
|
473
|
+
continue;
|
|
474
|
+
const lineNum = raw.slice(firstSep + 1, secondSep);
|
|
475
|
+
if (!/^\d+$/.test(lineNum))
|
|
476
|
+
continue;
|
|
477
|
+
const scanRelative = raw.slice(0, firstSep).replace(/^\.[\\/]/, "");
|
|
478
|
+
if (BINARY_EXTENSIONS.has(path.extname(scanRelative).toLowerCase()))
|
|
479
|
+
continue;
|
|
480
|
+
const text = raw.slice(secondSep + 1).replace(/\r$/, "");
|
|
481
|
+
const relPath = path.relative(req.cwd, path.join(req.dir, scanRelative));
|
|
482
|
+
const truncated = text.length > MAX_LINE_LENGTH ? sliceHead(text, MAX_LINE_LENGTH) + "…" : text;
|
|
483
|
+
const key = scanRelative.replace(/\\/g, "/");
|
|
484
|
+
const bucket = byPath.get(key);
|
|
485
|
+
const formatted = `${relPath}:${lineNum}:${truncated}`;
|
|
486
|
+
if (bucket)
|
|
487
|
+
bucket.push(formatted);
|
|
488
|
+
else
|
|
489
|
+
byPath.set(key, [formatted]);
|
|
490
|
+
}
|
|
491
|
+
const results = [];
|
|
492
|
+
for (const key of [...byPath.keys()].sort()) {
|
|
493
|
+
for (const line of byPath.get(key)) {
|
|
494
|
+
results.push(line);
|
|
495
|
+
if (results.length >= req.maxResults)
|
|
496
|
+
return results;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
return results;
|
|
500
|
+
}
|
|
201
501
|
async function searchFile(filePath, regex, cwd, maxResults, ops, deadline) {
|
|
202
502
|
const results = [];
|
|
203
503
|
const relPath = path.relative(cwd, filePath);
|