@aaroncql/pim-agent 0.0.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/LICENSE +21 -0
- package/README.md +212 -0
- package/bin/pim.ts +109 -0
- package/package.json +49 -0
- package/src/extensions/_init/index.ts +109 -0
- package/src/extensions/bash/capture.test.ts +126 -0
- package/src/extensions/bash/capture.ts +80 -0
- package/src/extensions/bash/format.test.ts +240 -0
- package/src/extensions/bash/format.ts +76 -0
- package/src/extensions/bash/index.ts +86 -0
- package/src/extensions/bash/run.test.ts +262 -0
- package/src/extensions/bash/run.ts +207 -0
- package/src/extensions/bash/schema.ts +54 -0
- package/src/extensions/command-picker/index.ts +52 -0
- package/src/extensions/command-picker/ranker.test.ts +46 -0
- package/src/extensions/command-picker/ranker.ts +17 -0
- package/src/extensions/edit/edit.test.ts +285 -0
- package/src/extensions/edit/edit.ts +382 -0
- package/src/extensions/edit/index.ts +54 -0
- package/src/extensions/edit/schema.ts +37 -0
- package/src/extensions/file-picker/catalog.test.ts +263 -0
- package/src/extensions/file-picker/catalog.ts +219 -0
- package/src/extensions/file-picker/index.test.ts +168 -0
- package/src/extensions/file-picker/index.ts +119 -0
- package/src/extensions/file-picker/ranker.test.ts +94 -0
- package/src/extensions/file-picker/ranker.ts +76 -0
- package/src/extensions/footer/git.test.ts +76 -0
- package/src/extensions/footer/git.ts +87 -0
- package/src/extensions/footer/index.test.ts +161 -0
- package/src/extensions/footer/index.ts +148 -0
- package/src/extensions/footer/powerline.ts +87 -0
- package/src/extensions/footer/segments.test.ts +164 -0
- package/src/extensions/footer/segments.ts +234 -0
- package/src/extensions/glob/glob.test.ts +171 -0
- package/src/extensions/glob/glob.ts +34 -0
- package/src/extensions/glob/index.test.ts +68 -0
- package/src/extensions/glob/index.ts +136 -0
- package/src/extensions/glob/render.test.ts +126 -0
- package/src/extensions/glob/render.ts +74 -0
- package/src/extensions/glob/schema.ts +52 -0
- package/src/extensions/grep/grep.test.ts +387 -0
- package/src/extensions/grep/grep.ts +215 -0
- package/src/extensions/grep/index.test.ts +68 -0
- package/src/extensions/grep/index.ts +158 -0
- package/src/extensions/grep/render.test.ts +269 -0
- package/src/extensions/grep/render.ts +243 -0
- package/src/extensions/grep/schema.ts +92 -0
- package/src/extensions/read/index.ts +84 -0
- package/src/extensions/read/read.test.ts +177 -0
- package/src/extensions/read/read.ts +206 -0
- package/src/extensions/read/render.test.ts +61 -0
- package/src/extensions/read/render.ts +33 -0
- package/src/extensions/read/schema.ts +27 -0
- package/src/extensions/subagent/index.test.ts +44 -0
- package/src/extensions/subagent/index.ts +30 -0
- package/src/extensions/subagent/render.test.ts +292 -0
- package/src/extensions/subagent/render.ts +359 -0
- package/src/extensions/subagent/schema.ts +9 -0
- package/src/extensions/subagent/subagent.test.ts +315 -0
- package/src/extensions/subagent/subagent.ts +418 -0
- package/src/extensions/system-prompt/index.ts +28 -0
- package/src/extensions/system-prompt/prompt.test.ts +64 -0
- package/src/extensions/system-prompt/prompt.ts +213 -0
- package/src/extensions/todo/index.test.ts +244 -0
- package/src/extensions/todo/index.ts +122 -0
- package/src/extensions/todo/render.test.ts +180 -0
- package/src/extensions/todo/render.ts +172 -0
- package/src/extensions/todo/schema.ts +24 -0
- package/src/extensions/todo/todo.test.ts +222 -0
- package/src/extensions/todo/todo.ts +188 -0
- package/src/extensions/tps/index.test.ts +254 -0
- package/src/extensions/tps/index.ts +136 -0
- package/src/extensions/web-fetch/JinaReaderClient.ts +230 -0
- package/src/extensions/web-fetch/WebViewFetchClient.ts +186 -0
- package/src/extensions/web-fetch/WebViewMarkdownSnapshot.test.ts +119 -0
- package/src/extensions/web-fetch/WebViewMarkdownSnapshot.ts +511 -0
- package/src/extensions/web-fetch/fetch.test.ts +244 -0
- package/src/extensions/web-fetch/fetch.ts +249 -0
- package/src/extensions/web-fetch/index.ts +107 -0
- package/src/extensions/web-fetch/render.test.ts +56 -0
- package/src/extensions/web-fetch/render.ts +39 -0
- package/src/extensions/web-fetch/schema.ts +23 -0
- package/src/extensions/web-search/ExaMcpClient.test.ts +143 -0
- package/src/extensions/web-search/ExaMcpClient.ts +258 -0
- package/src/extensions/web-search/index.ts +118 -0
- package/src/extensions/web-search/render.test.ts +21 -0
- package/src/extensions/web-search/render.ts +9 -0
- package/src/extensions/web-search/schema.ts +21 -0
- package/src/extensions/web-search/search.test.ts +53 -0
- package/src/extensions/web-search/search.ts +23 -0
- package/src/extensions/working-indicator/index.test.ts +21 -0
- package/src/extensions/working-indicator/index.ts +77 -0
- package/src/extensions/write/index.ts +76 -0
- package/src/extensions/write/render.test.ts +64 -0
- package/src/extensions/write/schema.ts +14 -0
- package/src/extensions/write/write.test.ts +108 -0
- package/src/extensions/write/write.ts +104 -0
- package/src/shared/DiffLines.test.ts +193 -0
- package/src/shared/DiffLines.ts +307 -0
- package/src/shared/DiffRenderer.test.ts +206 -0
- package/src/shared/DiffRenderer.ts +396 -0
- package/src/shared/DiffView.ts +199 -0
- package/src/shared/EditMatcher.test.ts +123 -0
- package/src/shared/EditMatcher.ts +826 -0
- package/src/shared/FileScanner.test.ts +158 -0
- package/src/shared/FileScanner.ts +41 -0
- package/src/shared/Fs.ts +46 -0
- package/src/shared/FsErrors.ts +72 -0
- package/src/shared/FuzzyMatcher.test.ts +114 -0
- package/src/shared/FuzzyMatcher.ts +73 -0
- package/src/shared/GitignoreFilter.test.ts +64 -0
- package/src/shared/GitignoreFilter.ts +142 -0
- package/src/shared/GlobExclusions.ts +23 -0
- package/src/shared/Levenshtein.ts +33 -0
- package/src/shared/Lines.test.ts +25 -0
- package/src/shared/Lines.ts +77 -0
- package/src/shared/McpClient.test.ts +235 -0
- package/src/shared/McpClient.ts +406 -0
- package/src/shared/OutputBudget.test.ts +99 -0
- package/src/shared/OutputBudget.ts +79 -0
- package/src/shared/Paths.test.ts +51 -0
- package/src/shared/Paths.ts +52 -0
- package/src/shared/PimSettings.test.ts +90 -0
- package/src/shared/PimSettings.ts +124 -0
- package/src/shared/Renderer.test.ts +190 -0
- package/src/shared/Renderer.ts +256 -0
- package/src/shared/SpillCache.test.ts +94 -0
- package/src/shared/SpillCache.ts +89 -0
- package/src/shared/Tools.test.ts +392 -0
- package/src/shared/Tools.ts +636 -0
- package/src/telegram/Bot.ts +198 -0
- package/src/telegram/Commands.ts +721 -0
- package/src/telegram/Config.test.ts +275 -0
- package/src/telegram/Config.ts +162 -0
- package/src/telegram/Markdown.test.ts +143 -0
- package/src/telegram/Markdown.ts +177 -0
- package/src/telegram/Message.ts +211 -0
- package/src/telegram/Renderer.test.ts +216 -0
- package/src/telegram/Renderer.ts +713 -0
- package/src/telegram/SendFileSchema.ts +19 -0
- package/src/telegram/SendFileTool.ts +94 -0
- package/src/telegram/Session.ts +579 -0
- package/src/telegram/SessionRegistry.test.ts +89 -0
- package/src/telegram/SessionRegistry.ts +170 -0
- package/src/telegram/Supervisor.ts +357 -0
- package/src/telegram/TaskScheduler.test.ts +278 -0
- package/src/telegram/TaskScheduler.ts +293 -0
- package/src/telegram/TaskSchema.ts +88 -0
- package/src/telegram/TaskStore.ts +73 -0
- package/src/telegram/TaskTool.test.ts +179 -0
- package/src/telegram/TaskTool.ts +159 -0
- package/src/telegram/TypingIndicator.ts +43 -0
- package/src/telegram/index.ts +32 -0
- package/src/themes/pim-dark.json +84 -0
- package/src/themes/pim-light.json +84 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { GlobMatch } from "./glob";
|
|
3
|
+
import { formatTitle, renderFiles } from "./render";
|
|
4
|
+
|
|
5
|
+
const fixture: readonly GlobMatch[] = [
|
|
6
|
+
{ path: "/repo/newer.ts", mtime: 2_000 },
|
|
7
|
+
{ path: "/repo/older.ts", mtime: 1_000 },
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
const relativeOptions = {
|
|
11
|
+
cwd: "/repo",
|
|
12
|
+
pathFormat: "relative",
|
|
13
|
+
} as const;
|
|
14
|
+
|
|
15
|
+
const absoluteOptions = {
|
|
16
|
+
cwd: "/repo",
|
|
17
|
+
pathFormat: "absolute",
|
|
18
|
+
} as const;
|
|
19
|
+
|
|
20
|
+
describe("renderFiles", () => {
|
|
21
|
+
test("joins paths with newlines, newest first, relative by default", () => {
|
|
22
|
+
const outcome = renderFiles(fixture, 1000, relativeOptions);
|
|
23
|
+
expect(outcome.body).toBe("newer.ts\nolder.ts");
|
|
24
|
+
expect(outcome.totalItems).toBe(2);
|
|
25
|
+
expect(outcome.visibleItems).toBe(2);
|
|
26
|
+
expect(outcome.truncated).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("can render absolute paths", () => {
|
|
30
|
+
const outcome = renderFiles(fixture, 1000, absoluteOptions);
|
|
31
|
+
expect(outcome.body).toBe("/repo/newer.ts\n/repo/older.ts");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("flips truncated when results exceed headLimit", () => {
|
|
35
|
+
const outcome = renderFiles(fixture, 1, relativeOptions);
|
|
36
|
+
expect(outcome.body).toBe("newer.ts");
|
|
37
|
+
expect(outcome.truncated).toBe(true);
|
|
38
|
+
expect(outcome.visibleItems).toBe(1);
|
|
39
|
+
expect(outcome.totalItems).toBe(2);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("returns a no-match outcome when there are no results", () => {
|
|
43
|
+
const outcome = renderFiles([], 1000, relativeOptions);
|
|
44
|
+
expect(outcome.body).toBe("No matches.");
|
|
45
|
+
expect(outcome.truncated).toBe(false);
|
|
46
|
+
expect(outcome.totalItems).toBe(0);
|
|
47
|
+
expect(outcome.visibleItems).toBe(0);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("formatTitle", () => {
|
|
52
|
+
test("uses relative path under cwd", () => {
|
|
53
|
+
const title = formatTitle({
|
|
54
|
+
pattern: "**/*.ts",
|
|
55
|
+
path: "/repo/src",
|
|
56
|
+
cwd: "/repo",
|
|
57
|
+
});
|
|
58
|
+
expect(title).toBe("**/*.ts in src");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("omits location when path is undefined", () => {
|
|
62
|
+
const title = formatTitle({
|
|
63
|
+
pattern: "**/*.ts",
|
|
64
|
+
path: undefined,
|
|
65
|
+
cwd: "/repo",
|
|
66
|
+
});
|
|
67
|
+
expect(title).toBe("**/*.ts");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("omits location when path resolves to cwd", () => {
|
|
71
|
+
const title = formatTitle({
|
|
72
|
+
pattern: "**/*.ts",
|
|
73
|
+
path: ".",
|
|
74
|
+
cwd: "/repo",
|
|
75
|
+
});
|
|
76
|
+
expect(title).toBe("**/*.ts");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("omits location when path is the absolute cwd", () => {
|
|
80
|
+
const title = formatTitle({
|
|
81
|
+
pattern: "**/*.ts",
|
|
82
|
+
path: "/repo",
|
|
83
|
+
cwd: "/repo",
|
|
84
|
+
});
|
|
85
|
+
expect(title).toBe("**/*.ts");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("uses '...' placeholder when pattern is undefined", () => {
|
|
89
|
+
const title = formatTitle({
|
|
90
|
+
pattern: undefined,
|
|
91
|
+
path: undefined,
|
|
92
|
+
cwd: "/repo",
|
|
93
|
+
});
|
|
94
|
+
expect(title).toBe("...");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("appends pluralized file count when provided", () => {
|
|
98
|
+
const title = formatTitle({
|
|
99
|
+
pattern: "**/*.ts",
|
|
100
|
+
path: "/repo/src",
|
|
101
|
+
cwd: "/repo",
|
|
102
|
+
fileCount: 3,
|
|
103
|
+
});
|
|
104
|
+
expect(title).toBe("**/*.ts in src (3 files)");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("uses singular noun for a single file", () => {
|
|
108
|
+
const title = formatTitle({
|
|
109
|
+
pattern: "**/*.ts",
|
|
110
|
+
path: undefined,
|
|
111
|
+
cwd: "/repo",
|
|
112
|
+
fileCount: 1,
|
|
113
|
+
});
|
|
114
|
+
expect(title).toBe("**/*.ts (1 file)");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("shows zero count without omitting the suffix", () => {
|
|
118
|
+
const title = formatTitle({
|
|
119
|
+
pattern: "**/*.ts",
|
|
120
|
+
path: undefined,
|
|
121
|
+
cwd: "/repo",
|
|
122
|
+
fileCount: 0,
|
|
123
|
+
});
|
|
124
|
+
expect(title).toBe("**/*.ts (0 files)");
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { OutputBudget } from "../../shared/OutputBudget";
|
|
2
|
+
import { Paths } from "../../shared/Paths";
|
|
3
|
+
import type { GlobMatch } from "./glob";
|
|
4
|
+
import type { GlobPathFormat } from "./schema";
|
|
5
|
+
|
|
6
|
+
export type RenderOutcome = {
|
|
7
|
+
readonly body: string;
|
|
8
|
+
readonly totalItems: number;
|
|
9
|
+
readonly visibleItems: number;
|
|
10
|
+
readonly truncated: boolean;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type RenderOptions = {
|
|
14
|
+
readonly cwd: string;
|
|
15
|
+
readonly pathFormat: GlobPathFormat;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function renderFiles(
|
|
19
|
+
matches: readonly GlobMatch[],
|
|
20
|
+
headLimit: number,
|
|
21
|
+
options: RenderOptions
|
|
22
|
+
): RenderOutcome {
|
|
23
|
+
if (matches.length === 0) {
|
|
24
|
+
return {
|
|
25
|
+
body: "No matches.",
|
|
26
|
+
totalItems: 0,
|
|
27
|
+
visibleItems: 0,
|
|
28
|
+
truncated: false,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const lines = matches.map((match) => formatPath(match.path, options));
|
|
33
|
+
const headCapped = lines.slice(0, headLimit);
|
|
34
|
+
const { visible } = OutputBudget.applyByteCap(headCapped);
|
|
35
|
+
const truncated = visible.length < lines.length;
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
body: visible.join("\n"),
|
|
39
|
+
totalItems: lines.length,
|
|
40
|
+
visibleItems: visible.length,
|
|
41
|
+
truncated,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type TitleOptions = {
|
|
46
|
+
readonly pattern: string | undefined;
|
|
47
|
+
readonly path: string | undefined;
|
|
48
|
+
readonly cwd: string;
|
|
49
|
+
readonly fileCount?: number;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export function formatTitle(options: TitleOptions): string {
|
|
53
|
+
const pattern = options.pattern ?? "...";
|
|
54
|
+
const resolved =
|
|
55
|
+
options.path === undefined
|
|
56
|
+
? undefined
|
|
57
|
+
: Paths.resolve(options.path, options.cwd);
|
|
58
|
+
const target =
|
|
59
|
+
resolved === undefined || resolved === options.cwd
|
|
60
|
+
? undefined
|
|
61
|
+
: Paths.displayRelative(resolved, options.cwd);
|
|
62
|
+
const location = target ? ` in ${target}` : "";
|
|
63
|
+
const suffix =
|
|
64
|
+
options.fileCount === undefined
|
|
65
|
+
? ""
|
|
66
|
+
: ` (${options.fileCount} ${options.fileCount === 1 ? "file" : "files"})`;
|
|
67
|
+
return `${pattern}${location}${suffix}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function formatPath(path: string, options: RenderOptions): string {
|
|
71
|
+
return options.pathFormat === "absolute"
|
|
72
|
+
? path
|
|
73
|
+
: Paths.displayRelative(path, options.cwd);
|
|
74
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
|
+
import { type Static, Type } from "typebox";
|
|
3
|
+
|
|
4
|
+
export const GLOB_HEAD_LIMIT_MAX = 1000;
|
|
5
|
+
|
|
6
|
+
export const GLOB_PATH_FORMATS = ["relative", "absolute"] as const;
|
|
7
|
+
|
|
8
|
+
export const globSchema = Type.Object({
|
|
9
|
+
pattern: Type.String({
|
|
10
|
+
description: "Glob pattern relative to path (eg. **/*.ts).",
|
|
11
|
+
}),
|
|
12
|
+
path: Type.Optional(
|
|
13
|
+
Type.String({
|
|
14
|
+
description:
|
|
15
|
+
"Absolute or relative path to directory (resolved against cwd). Defaults to cwd.",
|
|
16
|
+
})
|
|
17
|
+
),
|
|
18
|
+
exclude: Type.Optional(
|
|
19
|
+
Type.Array(Type.String(), {
|
|
20
|
+
description:
|
|
21
|
+
"Glob patterns to exclude, relative to path (eg. **/*.test.ts or dist/**).",
|
|
22
|
+
})
|
|
23
|
+
),
|
|
24
|
+
includeDotfiles: Type.Optional(
|
|
25
|
+
Type.Boolean({
|
|
26
|
+
description:
|
|
27
|
+
"Include dot-prefixed files and directories such as .env or .github. Defaults to false.",
|
|
28
|
+
})
|
|
29
|
+
),
|
|
30
|
+
includeIgnored: Type.Optional(
|
|
31
|
+
Type.Boolean({
|
|
32
|
+
description:
|
|
33
|
+
"Include gitignored and normally ignored paths such as node_modules. Defaults to false.",
|
|
34
|
+
})
|
|
35
|
+
),
|
|
36
|
+
pathFormat: Type.Optional(
|
|
37
|
+
StringEnum(GLOB_PATH_FORMATS, {
|
|
38
|
+
description:
|
|
39
|
+
"`relative` (default): output paths relative to cwd when possible. `absolute`: output absolute paths.",
|
|
40
|
+
})
|
|
41
|
+
),
|
|
42
|
+
headLimit: Type.Optional(
|
|
43
|
+
Type.Integer({
|
|
44
|
+
minimum: 1,
|
|
45
|
+
maximum: GLOB_HEAD_LIMIT_MAX,
|
|
46
|
+
description: `Maximum returned entries. Defaults to ${GLOB_HEAD_LIMIT_MAX}.`,
|
|
47
|
+
})
|
|
48
|
+
),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
export type GlobInput = Static<typeof globSchema>;
|
|
52
|
+
export type GlobPathFormat = (typeof GLOB_PATH_FORMATS)[number];
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, rm, utimes, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
5
|
+
import { buildMatcher, findMatches } from "./grep";
|
|
6
|
+
|
|
7
|
+
const tempRoots: string[] = [];
|
|
8
|
+
|
|
9
|
+
const tempRoot = async (): Promise<string> => {
|
|
10
|
+
const root = await mkdtemp(join(tmpdir(), "pim-grep-tool-"));
|
|
11
|
+
tempRoots.push(root);
|
|
12
|
+
return root;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
afterAll(async () => {
|
|
16
|
+
await Promise.all(
|
|
17
|
+
tempRoots.map((root) => rm(root, { force: true, recursive: true }))
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const makeMatcher = (
|
|
22
|
+
pattern: string,
|
|
23
|
+
options?: {
|
|
24
|
+
readonly caseInsensitive?: boolean;
|
|
25
|
+
readonly matchAcrossLines?: boolean;
|
|
26
|
+
}
|
|
27
|
+
) =>
|
|
28
|
+
buildMatcher({
|
|
29
|
+
pattern,
|
|
30
|
+
caseInsensitive: options?.caseInsensitive ?? false,
|
|
31
|
+
matchAcrossLines: options?.matchAcrossLines ?? false,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const defaultScanOptions = {
|
|
35
|
+
includeDotfiles: false,
|
|
36
|
+
includeIgnored: false,
|
|
37
|
+
} as const;
|
|
38
|
+
|
|
39
|
+
describe("buildMatcher", () => {
|
|
40
|
+
test("compiles regexes with no flags by default", () => {
|
|
41
|
+
const matcher = makeMatcher("alpha");
|
|
42
|
+
expect(matcher.regex.flags).toBe("");
|
|
43
|
+
expect(matcher.regex.test("alpha")).toBe(true);
|
|
44
|
+
expect(matcher.regex.test("Alpha")).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("applies the i flag for caseInsensitive regexes", () => {
|
|
48
|
+
const matcher = makeMatcher("alpha", { caseInsensitive: true });
|
|
49
|
+
expect(matcher.regex.flags).toBe("i");
|
|
50
|
+
expect(matcher.regex.test("Alpha")).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("applies the s flag for matchAcrossLines regexes", () => {
|
|
54
|
+
const matcher = makeMatcher(".", { matchAcrossLines: true });
|
|
55
|
+
expect(matcher.regex.flags).toBe("s");
|
|
56
|
+
expect(matcher.regex.test("\n")).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("throws an actionable error on invalid regex syntax", () => {
|
|
60
|
+
expect(() => makeMatcher("(")).toThrow(/Invalid regular expression/);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("findMatches", () => {
|
|
65
|
+
test("returns content matches with line numbers", async () => {
|
|
66
|
+
const root = await tempRoot();
|
|
67
|
+
const nested = join(root, "nested");
|
|
68
|
+
const older = join(root, "older.txt");
|
|
69
|
+
const newer = join(nested, "newer.txt");
|
|
70
|
+
|
|
71
|
+
await mkdir(nested);
|
|
72
|
+
await writeFile(older, "alpha\nbeta", "utf8");
|
|
73
|
+
await writeFile(newer, "gamma\nalphabet\nalpha", "utf8");
|
|
74
|
+
await utimes(
|
|
75
|
+
older,
|
|
76
|
+
new Date("2024-01-01T00:00:00Z"),
|
|
77
|
+
new Date("2024-01-01T00:00:00Z")
|
|
78
|
+
);
|
|
79
|
+
await utimes(
|
|
80
|
+
newer,
|
|
81
|
+
new Date("2024-01-02T00:00:00Z"),
|
|
82
|
+
new Date("2024-01-02T00:00:00Z")
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const matches = await findMatches(
|
|
86
|
+
root,
|
|
87
|
+
undefined,
|
|
88
|
+
makeMatcher("alpha"),
|
|
89
|
+
defaultScanOptions
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
expect(matches.map((match) => match.filePath)).toEqual([newer, older]);
|
|
93
|
+
expect(matches[0]?.lines).toEqual([
|
|
94
|
+
{ lineNumber: 2, text: "alphabet" },
|
|
95
|
+
{ lineNumber: 3, text: "alpha" },
|
|
96
|
+
]);
|
|
97
|
+
expect(matches[1]?.lines).toEqual([{ lineNumber: 1, text: "alpha" }]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("escapes regex metacharacters when searching literal text", async () => {
|
|
101
|
+
const root = await tempRoot();
|
|
102
|
+
const path = join(root, "code.ts");
|
|
103
|
+
await writeFile(path, "useFoo(\nfoo.bar[0]\n", "utf8");
|
|
104
|
+
|
|
105
|
+
const matches = await findMatches(
|
|
106
|
+
root,
|
|
107
|
+
undefined,
|
|
108
|
+
makeMatcher("foo\\.bar\\[0\\]"),
|
|
109
|
+
defaultScanOptions
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
expect(matches.map((match) => match.filePath)).toEqual([path]);
|
|
113
|
+
expect(matches[0]?.lines).toEqual([{ lineNumber: 2, text: "foo.bar[0]" }]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("supports regular expressions", async () => {
|
|
117
|
+
const root = await tempRoot();
|
|
118
|
+
const path = join(root, "code.ts");
|
|
119
|
+
await writeFile(path, "alpha\nbeta\n", "utf8");
|
|
120
|
+
|
|
121
|
+
const matches = await findMatches(
|
|
122
|
+
root,
|
|
123
|
+
undefined,
|
|
124
|
+
makeMatcher("^a.*a$"),
|
|
125
|
+
defaultScanOptions
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
expect(matches.map((match) => match.filePath)).toEqual([path]);
|
|
129
|
+
expect(matches[0]?.lines).toEqual([{ lineNumber: 1, text: "alpha" }]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("matches escaped dots and alternation as regex syntax", async () => {
|
|
133
|
+
const root = await tempRoot();
|
|
134
|
+
const schema = join(root, "src", "extensions", "todo", "schema.ts");
|
|
135
|
+
const helper = join(root, "src", "shared", "arrays.ts");
|
|
136
|
+
|
|
137
|
+
await mkdir(join(root, "src", "extensions", "todo"), { recursive: true });
|
|
138
|
+
await mkdir(join(root, "src", "shared"), { recursive: true });
|
|
139
|
+
await writeFile(schema, "const x = Type.Union([Type.String()]);\n", "utf8");
|
|
140
|
+
await writeFile(
|
|
141
|
+
helper,
|
|
142
|
+
"export const oneOrMany = normalizeArray;\n",
|
|
143
|
+
"utf8"
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
const typeUnionMatches = await findMatches(
|
|
147
|
+
root,
|
|
148
|
+
"src/extensions/**/schema.ts",
|
|
149
|
+
makeMatcher("Type\\.Union"),
|
|
150
|
+
defaultScanOptions
|
|
151
|
+
);
|
|
152
|
+
const alternationMatches = await findMatches(
|
|
153
|
+
root,
|
|
154
|
+
"src/**/*.ts",
|
|
155
|
+
makeMatcher("StringOrArray|OneOrMany|oneOrMany|normalizeArray"),
|
|
156
|
+
defaultScanOptions
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
expect(typeUnionMatches.map((match) => match.filePath)).toEqual([schema]);
|
|
160
|
+
expect(alternationMatches.map((match) => match.filePath)).toEqual([helper]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("matchAcrossLines enables regex matches spanning line breaks", async () => {
|
|
164
|
+
const root = await tempRoot();
|
|
165
|
+
const path = join(root, "block.txt");
|
|
166
|
+
await writeFile(path, "before\nBEGIN\nmiddle\nEND\nafter\n", "utf8");
|
|
167
|
+
|
|
168
|
+
const withoutAcrossLines = await findMatches(
|
|
169
|
+
root,
|
|
170
|
+
undefined,
|
|
171
|
+
makeMatcher("BEGIN.*END"),
|
|
172
|
+
defaultScanOptions
|
|
173
|
+
);
|
|
174
|
+
const withAcrossLines = await findMatches(
|
|
175
|
+
root,
|
|
176
|
+
undefined,
|
|
177
|
+
makeMatcher("BEGIN.*END", { matchAcrossLines: true }),
|
|
178
|
+
defaultScanOptions
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
expect(withoutAcrossLines).toEqual([]);
|
|
182
|
+
expect(withAcrossLines.map((match) => match.filePath)).toEqual([path]);
|
|
183
|
+
expect(withAcrossLines[0]?.ranges).toEqual([
|
|
184
|
+
{ startLineNumber: 2, endLineNumber: 4 },
|
|
185
|
+
]);
|
|
186
|
+
expect(withAcrossLines[0]?.lines).toEqual([
|
|
187
|
+
{ lineNumber: 2, text: "BEGIN" },
|
|
188
|
+
{ lineNumber: 3, text: "middle" },
|
|
189
|
+
{ lineNumber: 4, text: "END" },
|
|
190
|
+
]);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("matchAcrossLines enables exact regex matches spanning line breaks", async () => {
|
|
194
|
+
const root = await tempRoot();
|
|
195
|
+
const path = join(root, "block.txt");
|
|
196
|
+
await writeFile(path, "BEGIN\nmiddle\nEND\n", "utf8");
|
|
197
|
+
|
|
198
|
+
const matches = await findMatches(
|
|
199
|
+
root,
|
|
200
|
+
undefined,
|
|
201
|
+
makeMatcher("BEGIN\nmiddle", { matchAcrossLines: true }),
|
|
202
|
+
defaultScanOptions
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
expect(matches.map((match) => match.filePath)).toEqual([path]);
|
|
206
|
+
expect(matches[0]?.ranges).toEqual([
|
|
207
|
+
{ startLineNumber: 1, endLineNumber: 2 },
|
|
208
|
+
]);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("respects gitignore, dotfiles, and the always-ignored defaults", async () => {
|
|
212
|
+
const root = await tempRoot();
|
|
213
|
+
const src = join(root, "src");
|
|
214
|
+
const ignored = join(src, "ignored.ts");
|
|
215
|
+
const kept = join(src, "kept.ts");
|
|
216
|
+
const nodeModules = join(root, "node_modules", "pkg", "x.ts");
|
|
217
|
+
const dot = join(root, ".secret", "x.ts");
|
|
218
|
+
|
|
219
|
+
await mkdir(src, { recursive: true });
|
|
220
|
+
await mkdir(join(root, "node_modules", "pkg"), { recursive: true });
|
|
221
|
+
await mkdir(join(root, ".secret"), { recursive: true });
|
|
222
|
+
await writeFile(join(root, ".gitignore"), "ignored.ts\n", "utf8");
|
|
223
|
+
await writeFile(ignored, "needle\n", "utf8");
|
|
224
|
+
await writeFile(kept, "needle\n", "utf8");
|
|
225
|
+
await writeFile(nodeModules, "needle\n", "utf8");
|
|
226
|
+
await writeFile(dot, "needle\n", "utf8");
|
|
227
|
+
|
|
228
|
+
const matches = await findMatches(
|
|
229
|
+
root,
|
|
230
|
+
undefined,
|
|
231
|
+
makeMatcher("needle"),
|
|
232
|
+
defaultScanOptions
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
expect(matches.map((match) => match.filePath)).toEqual([kept]);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("can include dotfiles and ignored paths", async () => {
|
|
239
|
+
const root = await tempRoot();
|
|
240
|
+
const kept = join(root, "kept.ts");
|
|
241
|
+
const ignored = join(root, "ignored.ts");
|
|
242
|
+
const dot = join(root, ".secret", "x.ts");
|
|
243
|
+
|
|
244
|
+
await mkdir(join(root, ".secret"), { recursive: true });
|
|
245
|
+
await writeFile(join(root, ".gitignore"), "ignored.ts\n", "utf8");
|
|
246
|
+
await writeFile(kept, "needle\n", "utf8");
|
|
247
|
+
await writeFile(ignored, "needle\n", "utf8");
|
|
248
|
+
await writeFile(dot, "needle\n", "utf8");
|
|
249
|
+
|
|
250
|
+
const matches = await findMatches(root, undefined, makeMatcher("needle"), {
|
|
251
|
+
includeDotfiles: true,
|
|
252
|
+
includeIgnored: true,
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
expect(matches.map((match) => match.filePath).sort()).toEqual(
|
|
256
|
+
[dot, ignored, kept].sort()
|
|
257
|
+
);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("searches direct file paths even when they are dotfiles or ignored", async () => {
|
|
261
|
+
const root = await tempRoot();
|
|
262
|
+
const ignored = join(root, "ignored.ts");
|
|
263
|
+
const dotfile = join(root, ".env");
|
|
264
|
+
|
|
265
|
+
await writeFile(join(root, ".gitignore"), "ignored.ts\n", "utf8");
|
|
266
|
+
await writeFile(ignored, "needle\n", "utf8");
|
|
267
|
+
await writeFile(dotfile, "needle\n", "utf8");
|
|
268
|
+
|
|
269
|
+
const ignoredMatches = await findMatches(
|
|
270
|
+
ignored,
|
|
271
|
+
undefined,
|
|
272
|
+
makeMatcher("needle"),
|
|
273
|
+
defaultScanOptions
|
|
274
|
+
);
|
|
275
|
+
const dotfileMatches = await findMatches(
|
|
276
|
+
dotfile,
|
|
277
|
+
undefined,
|
|
278
|
+
makeMatcher("needle"),
|
|
279
|
+
defaultScanOptions
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
expect(ignoredMatches.map((match) => match.filePath)).toEqual([ignored]);
|
|
283
|
+
expect(dotfileMatches.map((match) => match.filePath)).toEqual([dotfile]);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test("filters by glob", async () => {
|
|
287
|
+
const root = await tempRoot();
|
|
288
|
+
const ts = join(root, "a.ts");
|
|
289
|
+
const md = join(root, "a.md");
|
|
290
|
+
|
|
291
|
+
await writeFile(ts, "needle", "utf8");
|
|
292
|
+
await writeFile(md, "needle", "utf8");
|
|
293
|
+
|
|
294
|
+
const matches = await findMatches(
|
|
295
|
+
root,
|
|
296
|
+
"**/*.ts",
|
|
297
|
+
makeMatcher("needle"),
|
|
298
|
+
defaultScanOptions
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
expect(matches.map((match) => match.filePath)).toEqual([ts]);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("excludes a single glob pattern", async () => {
|
|
305
|
+
const root = await tempRoot();
|
|
306
|
+
const source = join(root, "src", "app.ts");
|
|
307
|
+
const test = join(root, "src", "app.test.ts");
|
|
308
|
+
|
|
309
|
+
await mkdir(join(root, "src"), { recursive: true });
|
|
310
|
+
await writeFile(source, "needle", "utf8");
|
|
311
|
+
await writeFile(test, "needle", "utf8");
|
|
312
|
+
|
|
313
|
+
const matches = await findMatches(root, "**/*.ts", makeMatcher("needle"), {
|
|
314
|
+
...defaultScanOptions,
|
|
315
|
+
exclude: ["**/*.test.ts"],
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
expect(matches.map((match) => match.filePath)).toEqual([source]);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("excludes multiple glob patterns", async () => {
|
|
322
|
+
const root = await tempRoot();
|
|
323
|
+
const source = join(root, "src", "app.ts");
|
|
324
|
+
const test = join(root, "src", "app.test.ts");
|
|
325
|
+
const generated = join(root, "src", "generated", "types.ts");
|
|
326
|
+
|
|
327
|
+
await mkdir(join(root, "src", "generated"), { recursive: true });
|
|
328
|
+
await writeFile(source, "needle", "utf8");
|
|
329
|
+
await writeFile(test, "needle", "utf8");
|
|
330
|
+
await writeFile(generated, "needle", "utf8");
|
|
331
|
+
|
|
332
|
+
const matches = await findMatches(root, "**/*.ts", makeMatcher("needle"), {
|
|
333
|
+
...defaultScanOptions,
|
|
334
|
+
exclude: ["**/*.test.ts", "src/generated/**"],
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
expect(matches.map((match) => match.filePath)).toEqual([source]);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test("skips binary files", async () => {
|
|
341
|
+
const root = await tempRoot();
|
|
342
|
+
const text = join(root, "text.txt");
|
|
343
|
+
const binary = join(root, "data.bin");
|
|
344
|
+
|
|
345
|
+
await writeFile(text, "needle\n", "utf8");
|
|
346
|
+
await Bun.write(binary, new Uint8Array([0x6e, 0x00, 0x65, 0x65]));
|
|
347
|
+
|
|
348
|
+
const matches = await findMatches(
|
|
349
|
+
root,
|
|
350
|
+
undefined,
|
|
351
|
+
makeMatcher("n"),
|
|
352
|
+
defaultScanOptions
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
expect(matches.map((match) => match.filePath)).toEqual([text]);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test("matches a single file path directly", async () => {
|
|
359
|
+
const root = await tempRoot();
|
|
360
|
+
const path = join(root, "notes.txt");
|
|
361
|
+
await writeFile(path, "alpha\nbeta\nalphabet", "utf8");
|
|
362
|
+
|
|
363
|
+
const matches = await findMatches(
|
|
364
|
+
path,
|
|
365
|
+
undefined,
|
|
366
|
+
makeMatcher("alpha"),
|
|
367
|
+
defaultScanOptions
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
expect(matches.length).toBe(1);
|
|
371
|
+
expect(matches[0]?.lines).toEqual([
|
|
372
|
+
{ lineNumber: 1, text: "alpha" },
|
|
373
|
+
{ lineNumber: 3, text: "alphabet" },
|
|
374
|
+
]);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test("throws an actionable error when the path does not exist", async () => {
|
|
378
|
+
const root = await tempRoot();
|
|
379
|
+
const missing = join(root, "nope");
|
|
380
|
+
|
|
381
|
+
await expect(
|
|
382
|
+
findMatches(missing, undefined, makeMatcher("x"), defaultScanOptions)
|
|
383
|
+
).rejects.toThrow(
|
|
384
|
+
`Path not found: ${missing}. Use glob to locate the file or directory, or verify the path.`
|
|
385
|
+
);
|
|
386
|
+
});
|
|
387
|
+
});
|