@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,158 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, rm, 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 { FileScanner } from "./FileScanner";
|
|
6
|
+
|
|
7
|
+
const tempRoots: string[] = [];
|
|
8
|
+
|
|
9
|
+
const createTempDir = async (): Promise<string> => {
|
|
10
|
+
const root = await mkdtemp(join(tmpdir(), "pim-file-scanner-"));
|
|
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 defaultOptions = {
|
|
22
|
+
includeDotfiles: false,
|
|
23
|
+
includeIgnored: false,
|
|
24
|
+
} as const;
|
|
25
|
+
|
|
26
|
+
describe("FileScanner.scan", () => {
|
|
27
|
+
test("scans a directory and returns absolute file paths", async () => {
|
|
28
|
+
const root = await createTempDir();
|
|
29
|
+
await writeFile(join(root, "a.ts"), "", "utf8");
|
|
30
|
+
await writeFile(join(root, "b.ts"), "", "utf8");
|
|
31
|
+
|
|
32
|
+
const files = await FileScanner.scan(root, "**/*.ts", defaultOptions);
|
|
33
|
+
|
|
34
|
+
expect(files.toSorted()).toEqual(
|
|
35
|
+
[join(root, "a.ts"), join(root, "b.ts")].sort()
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("respects gitignore patterns", async () => {
|
|
40
|
+
const root = await createTempDir();
|
|
41
|
+
await writeFile(join(root, ".gitignore"), "ignored.ts\n", "utf8");
|
|
42
|
+
await writeFile(join(root, "kept.ts"), "", "utf8");
|
|
43
|
+
await writeFile(join(root, "ignored.ts"), "", "utf8");
|
|
44
|
+
|
|
45
|
+
const files = await FileScanner.scan(root, "**/*.ts", defaultOptions);
|
|
46
|
+
|
|
47
|
+
expect(files).toEqual([join(root, "kept.ts")]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("respects always-ignored defaults like node_modules", async () => {
|
|
51
|
+
const root = await createTempDir();
|
|
52
|
+
await mkdir(join(root, "node_modules", "pkg"), { recursive: true });
|
|
53
|
+
await writeFile(join(root, "node_modules", "pkg", "x.ts"), "", "utf8");
|
|
54
|
+
await writeFile(join(root, "kept.ts"), "", "utf8");
|
|
55
|
+
|
|
56
|
+
const files = await FileScanner.scan(root, "**/*.ts", defaultOptions);
|
|
57
|
+
|
|
58
|
+
expect(files).toEqual([join(root, "kept.ts")]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("skips dotfiles by default", async () => {
|
|
62
|
+
const root = await createTempDir();
|
|
63
|
+
await mkdir(join(root, ".hidden"), { recursive: true });
|
|
64
|
+
await writeFile(join(root, ".hidden", "secret.ts"), "", "utf8");
|
|
65
|
+
await writeFile(join(root, "visible.ts"), "", "utf8");
|
|
66
|
+
|
|
67
|
+
const files = await FileScanner.scan(root, "**/*.ts", defaultOptions);
|
|
68
|
+
|
|
69
|
+
expect(files).toEqual([join(root, "visible.ts")]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("includes dotfiles when requested", async () => {
|
|
73
|
+
const root = await createTempDir();
|
|
74
|
+
await mkdir(join(root, ".hidden"), { recursive: true });
|
|
75
|
+
await writeFile(join(root, ".hidden", "secret.ts"), "", "utf8");
|
|
76
|
+
await writeFile(join(root, "visible.ts"), "", "utf8");
|
|
77
|
+
|
|
78
|
+
const files = await FileScanner.scan(root, "**/*.ts", {
|
|
79
|
+
...defaultOptions,
|
|
80
|
+
includeDotfiles: true,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(files.toSorted()).toEqual(
|
|
84
|
+
[join(root, ".hidden", "secret.ts"), join(root, "visible.ts")].sort()
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("includes ignored files when requested", async () => {
|
|
89
|
+
const root = await createTempDir();
|
|
90
|
+
await writeFile(join(root, ".gitignore"), "ignored.ts\n", "utf8");
|
|
91
|
+
await writeFile(join(root, "kept.ts"), "", "utf8");
|
|
92
|
+
await writeFile(join(root, "ignored.ts"), "", "utf8");
|
|
93
|
+
|
|
94
|
+
const files = await FileScanner.scan(root, "**/*.ts", {
|
|
95
|
+
...defaultOptions,
|
|
96
|
+
includeIgnored: true,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
expect(files.toSorted()).toEqual(
|
|
100
|
+
[join(root, "ignored.ts"), join(root, "kept.ts")].sort()
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("excludes patterns via the exclude option", async () => {
|
|
105
|
+
const root = await createTempDir();
|
|
106
|
+
await mkdir(join(root, "src"), { recursive: true });
|
|
107
|
+
await writeFile(join(root, "src", "app.ts"), "", "utf8");
|
|
108
|
+
await writeFile(join(root, "src", "app.test.ts"), "", "utf8");
|
|
109
|
+
|
|
110
|
+
const files = await FileScanner.scan(root, "**/*.ts", {
|
|
111
|
+
...defaultOptions,
|
|
112
|
+
exclude: ["**/*.test.ts"],
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
expect(files).toEqual([join(root, "src", "app.ts")]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("excludes multiple patterns", async () => {
|
|
119
|
+
const root = await createTempDir();
|
|
120
|
+
await mkdir(join(root, "src", "generated"), { recursive: true });
|
|
121
|
+
await writeFile(join(root, "src", "app.ts"), "", "utf8");
|
|
122
|
+
await writeFile(join(root, "src", "app.test.ts"), "", "utf8");
|
|
123
|
+
await writeFile(join(root, "src", "generated", "types.ts"), "", "utf8");
|
|
124
|
+
|
|
125
|
+
const files = await FileScanner.scan(root, "**/*.ts", {
|
|
126
|
+
...defaultOptions,
|
|
127
|
+
exclude: ["**/*.test.ts", "src/generated/**"],
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
expect(files).toEqual([join(root, "src", "app.ts")]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("returns an empty array for an empty directory", async () => {
|
|
134
|
+
const root = await createTempDir();
|
|
135
|
+
|
|
136
|
+
const files = await FileScanner.scan(root, "**/*", defaultOptions);
|
|
137
|
+
|
|
138
|
+
expect(files).toEqual([]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("scans nested directories", async () => {
|
|
142
|
+
const root = await createTempDir();
|
|
143
|
+
await mkdir(join(root, "a", "b", "c"), { recursive: true });
|
|
144
|
+
await writeFile(join(root, "a", "top.ts"), "", "utf8");
|
|
145
|
+
await writeFile(join(root, "a", "b", "mid.ts"), "", "utf8");
|
|
146
|
+
await writeFile(join(root, "a", "b", "c", "deep.ts"), "", "utf8");
|
|
147
|
+
|
|
148
|
+
const files = await FileScanner.scan(root, "**/*.ts", defaultOptions);
|
|
149
|
+
|
|
150
|
+
expect(files.toSorted()).toEqual(
|
|
151
|
+
[
|
|
152
|
+
join(root, "a", "top.ts"),
|
|
153
|
+
join(root, "a", "b", "mid.ts"),
|
|
154
|
+
join(root, "a", "b", "c", "deep.ts"),
|
|
155
|
+
].sort()
|
|
156
|
+
);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { GitignoreFilter } from "./GitignoreFilter";
|
|
3
|
+
import { GlobExclusions } from "./GlobExclusions";
|
|
4
|
+
|
|
5
|
+
export type FileScanOptions = {
|
|
6
|
+
readonly exclude?: readonly string[];
|
|
7
|
+
readonly includeDotfiles: boolean;
|
|
8
|
+
readonly includeIgnored: boolean;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export class FileScanner {
|
|
12
|
+
static async scan(
|
|
13
|
+
root: string,
|
|
14
|
+
pattern: string,
|
|
15
|
+
options: FileScanOptions
|
|
16
|
+
): Promise<readonly string[]> {
|
|
17
|
+
const absoluteRoot = resolve(root);
|
|
18
|
+
const filter = options.includeIgnored
|
|
19
|
+
? undefined
|
|
20
|
+
: await GitignoreFilter.for(absoluteRoot);
|
|
21
|
+
const excludes = GlobExclusions.compile(options.exclude);
|
|
22
|
+
const glob = new Bun.Glob(pattern);
|
|
23
|
+
const files: string[] = [];
|
|
24
|
+
|
|
25
|
+
for await (const filePath of glob.scan({
|
|
26
|
+
cwd: absoluteRoot,
|
|
27
|
+
absolute: true,
|
|
28
|
+
onlyFiles: true,
|
|
29
|
+
dot: options.includeDotfiles,
|
|
30
|
+
})) {
|
|
31
|
+
if (
|
|
32
|
+
(filter === undefined || !filter.ignores(filePath)) &&
|
|
33
|
+
!GlobExclusions.ignores(excludes, absoluteRoot, filePath)
|
|
34
|
+
) {
|
|
35
|
+
files.push(filePath);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return files;
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/shared/Fs.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { chmod, mkdir, rename, stat } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
|
|
4
|
+
export class Fs {
|
|
5
|
+
public static async readJsonOrEmpty<T>(
|
|
6
|
+
filePath: string,
|
|
7
|
+
fallback: T
|
|
8
|
+
): Promise<T> {
|
|
9
|
+
try {
|
|
10
|
+
return (await Bun.file(filePath).json()) as T;
|
|
11
|
+
} catch (err) {
|
|
12
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
13
|
+
return fallback;
|
|
14
|
+
}
|
|
15
|
+
throw new Error(`Failed to parse ${filePath}: ${(err as Error).message}`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
public static async writeAtomic(
|
|
20
|
+
filePath: string,
|
|
21
|
+
data: string,
|
|
22
|
+
mode?: number
|
|
23
|
+
): Promise<void> {
|
|
24
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
25
|
+
const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
|
26
|
+
await Bun.write(tmp, data);
|
|
27
|
+
const resolvedMode = mode ?? (await Fs.existingMode(filePath));
|
|
28
|
+
if (resolvedMode !== undefined) {
|
|
29
|
+
await chmod(tmp, resolvedMode);
|
|
30
|
+
}
|
|
31
|
+
await rename(tmp, filePath);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private static async existingMode(
|
|
35
|
+
filePath: string
|
|
36
|
+
): Promise<number | undefined> {
|
|
37
|
+
try {
|
|
38
|
+
return (await stat(filePath)).mode & 0o777;
|
|
39
|
+
} catch (err) {
|
|
40
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
throw err;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { Stats } from "node:fs";
|
|
2
|
+
import { readdir, stat } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, extname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export class FsErrors {
|
|
6
|
+
public static code(error: unknown): string | undefined {
|
|
7
|
+
return typeof error === "object" && error !== null && "code" in error
|
|
8
|
+
? String((error as { code: unknown }).code)
|
|
9
|
+
: undefined;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
public static async statOrThrow(path: string): Promise<Stats> {
|
|
13
|
+
try {
|
|
14
|
+
return await stat(path);
|
|
15
|
+
} catch (error) {
|
|
16
|
+
const code = FsErrors.code(error);
|
|
17
|
+
|
|
18
|
+
if (code === "ENOENT") {
|
|
19
|
+
throw new Error(await FsErrors.renderMissing(path));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
23
|
+
throw new Error(`Permission denied accessing ${path}.`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
throw new Error(
|
|
27
|
+
`Cannot stat ${path}: ${code ?? (error instanceof Error ? error.message : "unknown error")}.`
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
public static async renderMissing(path: string): Promise<string> {
|
|
33
|
+
const suggestions = await FsErrors.suggestSiblings(path);
|
|
34
|
+
const headline = `Path not found: ${path}. Use glob to locate the file or directory, or verify the path.`;
|
|
35
|
+
if (suggestions.length === 0) {
|
|
36
|
+
return headline;
|
|
37
|
+
}
|
|
38
|
+
return [headline, "", "Did you mean one of these?", ...suggestions].join(
|
|
39
|
+
"\n"
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private static async suggestSiblings(
|
|
44
|
+
path: string
|
|
45
|
+
): Promise<readonly string[]> {
|
|
46
|
+
const dir = dirname(path);
|
|
47
|
+
const base = basename(path).toLowerCase();
|
|
48
|
+
const stem = base.slice(0, base.length - extname(base).length);
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const entries = await readdir(dir);
|
|
52
|
+
return entries
|
|
53
|
+
.filter((entry) => {
|
|
54
|
+
const lower = entry.toLowerCase();
|
|
55
|
+
const lowerStem = lower.slice(
|
|
56
|
+
0,
|
|
57
|
+
lower.length - extname(lower).length
|
|
58
|
+
);
|
|
59
|
+
return (
|
|
60
|
+
lower.includes(base) ||
|
|
61
|
+
base.includes(lower) ||
|
|
62
|
+
(stem.length > 0 &&
|
|
63
|
+
(lowerStem.includes(stem) || stem.includes(lowerStem)))
|
|
64
|
+
);
|
|
65
|
+
})
|
|
66
|
+
.slice(0, 3)
|
|
67
|
+
.map((entry) => join(dir, entry));
|
|
68
|
+
} catch {
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { FuzzyMatcher, type FuzzyCandidate } from "./FuzzyMatcher";
|
|
3
|
+
|
|
4
|
+
type Command = {
|
|
5
|
+
readonly name: string;
|
|
6
|
+
readonly description: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const commandCandidates = (
|
|
10
|
+
commands: readonly Command[]
|
|
11
|
+
): readonly FuzzyCandidate<Command>[] =>
|
|
12
|
+
commands.map((command) => ({
|
|
13
|
+
item: command,
|
|
14
|
+
haystacks: [command.name, command.description],
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
test("empty query returns candidates sorted alphabetically by first haystack", () => {
|
|
18
|
+
const candidates = commandCandidates([
|
|
19
|
+
{ name: "rename", description: "rename" },
|
|
20
|
+
{ name: "clear", description: "clear" },
|
|
21
|
+
{ name: "help", description: "help" },
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
const hits = FuzzyMatcher.rank("", candidates);
|
|
25
|
+
|
|
26
|
+
expect(hits.map((hit) => hit.item.name)).toEqual(["clear", "help", "rename"]);
|
|
27
|
+
expect(hits[0]?.score).toBe(0);
|
|
28
|
+
expect(hits[0]?.positions.size).toBe(0);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("whitespace-only query is treated as empty", () => {
|
|
32
|
+
const candidates = commandCandidates([
|
|
33
|
+
{ name: "b", description: "b" },
|
|
34
|
+
{ name: "a", description: "a" },
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
const hits = FuzzyMatcher.rank(" ", candidates);
|
|
38
|
+
|
|
39
|
+
expect(hits.map((hit) => hit.item.name)).toEqual(["a", "b"]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("non-empty query orders results by fzf score", () => {
|
|
43
|
+
const candidates = commandCandidates([
|
|
44
|
+
{ name: "clear", description: "Clear the session." },
|
|
45
|
+
{ name: "rename", description: "Rename the session." },
|
|
46
|
+
{ name: "resume", description: "Resume a session." },
|
|
47
|
+
{ name: "help", description: "Show help." },
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
const hits = FuzzyMatcher.rank("cl", candidates);
|
|
51
|
+
|
|
52
|
+
expect(hits.length).toBeGreaterThan(0);
|
|
53
|
+
expect(hits[0]?.item.name).toBe("clear");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("limit truncates ranked results", () => {
|
|
57
|
+
const candidates = commandCandidates([
|
|
58
|
+
{ name: "alpha", description: "" },
|
|
59
|
+
{ name: "alphabet", description: "" },
|
|
60
|
+
{ name: "alphanumeric", description: "" },
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
const hits = FuzzyMatcher.rank("a", candidates, { limit: 2 });
|
|
64
|
+
|
|
65
|
+
expect(hits.length).toBe(2);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("limit also applies to the empty-query alphabetical case", () => {
|
|
69
|
+
const candidates = commandCandidates([
|
|
70
|
+
{ name: "c", description: "" },
|
|
71
|
+
{ name: "a", description: "" },
|
|
72
|
+
{ name: "b", description: "" },
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
const hits = FuzzyMatcher.rank("", candidates, { limit: 2 });
|
|
76
|
+
|
|
77
|
+
expect(hits.map((hit) => hit.item.name)).toEqual(["a", "b"]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("ties on score break toward the earlier match start", () => {
|
|
81
|
+
const candidates = commandCandidates([
|
|
82
|
+
{ name: "new", description: "Start a new session." },
|
|
83
|
+
{ name: "status", description: "Show the current session status." },
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
const hits = FuzzyMatcher.rank("sta", candidates);
|
|
87
|
+
|
|
88
|
+
expect(hits[0]?.item.name).toBe("status");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("ties on score and start break toward the shorter haystack", () => {
|
|
92
|
+
const candidates: readonly FuzzyCandidate<string>[] = [
|
|
93
|
+
{
|
|
94
|
+
item: "src/shared/DiffLines.test.ts",
|
|
95
|
+
haystacks: ["src/shared/DiffLines.test.ts"],
|
|
96
|
+
},
|
|
97
|
+
{ item: "src/shared/DiffLines.ts", haystacks: ["src/shared/DiffLines.ts"] },
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
const hits = FuzzyMatcher.rank("difflines", candidates);
|
|
101
|
+
|
|
102
|
+
expect(hits[0]?.item).toBe("src/shared/DiffLines.ts");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("matches against the second haystack when the first does not contain the query", () => {
|
|
106
|
+
const candidates = commandCandidates([
|
|
107
|
+
{ name: "noop", description: "fully unrelated" },
|
|
108
|
+
{ name: "x", description: "rename the session" },
|
|
109
|
+
]);
|
|
110
|
+
|
|
111
|
+
const hits = FuzzyMatcher.rank("rename", candidates);
|
|
112
|
+
|
|
113
|
+
expect(hits[0]?.item.name).toBe("x");
|
|
114
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { byLengthAsc, byStartAsc, Fzf } from "fzf";
|
|
2
|
+
|
|
3
|
+
export type FuzzyCandidate<T> = {
|
|
4
|
+
readonly item: T;
|
|
5
|
+
readonly haystacks: readonly string[];
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type FuzzyHit<T> = {
|
|
9
|
+
readonly item: T;
|
|
10
|
+
readonly score: number;
|
|
11
|
+
readonly positions: ReadonlySet<number>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type FuzzyRankOptions = {
|
|
15
|
+
readonly limit?: number;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type FuzzyIndex<T> = {
|
|
19
|
+
readonly find: (
|
|
20
|
+
query: string,
|
|
21
|
+
options?: FuzzyRankOptions
|
|
22
|
+
) => readonly FuzzyHit<T>[];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const HAYSTACK_SEPARATOR = " ";
|
|
26
|
+
|
|
27
|
+
export class FuzzyMatcher {
|
|
28
|
+
public static rank<T>(
|
|
29
|
+
query: string,
|
|
30
|
+
candidates: readonly FuzzyCandidate<T>[],
|
|
31
|
+
options: FuzzyRankOptions = {}
|
|
32
|
+
): readonly FuzzyHit<T>[] {
|
|
33
|
+
return FuzzyMatcher.prepare(candidates).find(query, options);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
public static prepare<T>(
|
|
37
|
+
candidates: readonly FuzzyCandidate<T>[]
|
|
38
|
+
): FuzzyIndex<T> {
|
|
39
|
+
const fzf = new Fzf<readonly FuzzyCandidate<T>[]>(candidates, {
|
|
40
|
+
selector: (candidate) => candidate.haystacks.join(HAYSTACK_SEPARATOR),
|
|
41
|
+
tiebreakers: [byStartAsc, byLengthAsc],
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
let emptyHits: readonly FuzzyHit<T>[] | undefined;
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
find: (query, options = {}) => {
|
|
48
|
+
const trimmed = query.trim();
|
|
49
|
+
const limit = options.limit ?? Infinity;
|
|
50
|
+
if (trimmed.length === 0) {
|
|
51
|
+
if (emptyHits === undefined) {
|
|
52
|
+
emptyHits = [...candidates]
|
|
53
|
+
.sort((a, b) =>
|
|
54
|
+
(a.haystacks[0] ?? "").localeCompare(b.haystacks[0] ?? "")
|
|
55
|
+
)
|
|
56
|
+
.map((candidate) => ({
|
|
57
|
+
item: candidate.item,
|
|
58
|
+
score: 0,
|
|
59
|
+
positions: new Set<number>(),
|
|
60
|
+
}));
|
|
61
|
+
}
|
|
62
|
+
return limit === Infinity ? emptyHits : emptyHits.slice(0, limit);
|
|
63
|
+
}
|
|
64
|
+
const hits = fzf.find(trimmed).map((result) => ({
|
|
65
|
+
item: result.item.item,
|
|
66
|
+
score: result.score,
|
|
67
|
+
positions: result.positions,
|
|
68
|
+
}));
|
|
69
|
+
return limit === Infinity ? hits : hits.slice(0, limit);
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterAll, expect, test } from "bun:test";
|
|
5
|
+
import { GitignoreFilter } from "./GitignoreFilter";
|
|
6
|
+
|
|
7
|
+
const tempRoots: string[] = [];
|
|
8
|
+
|
|
9
|
+
const createTempDir = async (): Promise<string> => {
|
|
10
|
+
const root = await mkdtemp(join(tmpdir(), "pim-gitignore-filter-"));
|
|
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
|
+
test("applies hardcoded defaults", async () => {
|
|
22
|
+
const root = await createTempDir();
|
|
23
|
+
const filter = await GitignoreFilter.for(root);
|
|
24
|
+
|
|
25
|
+
expect(filter.ignores(join(root, "node_modules", "pkg", "index.js"))).toBe(
|
|
26
|
+
true
|
|
27
|
+
);
|
|
28
|
+
expect(filter.ignores(join(root, ".git", "config"))).toBe(true);
|
|
29
|
+
expect(filter.ignores(join(root, "src", "index.ts"))).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("loads gitignore patterns from root up to the git boundary", async () => {
|
|
33
|
+
const root = await createTempDir();
|
|
34
|
+
const project = join(root, "project");
|
|
35
|
+
const nested = join(project, "packages", "app");
|
|
36
|
+
|
|
37
|
+
await mkdir(join(project, ".git"), { recursive: true });
|
|
38
|
+
await mkdir(nested, { recursive: true });
|
|
39
|
+
await writeFile(
|
|
40
|
+
join(project, ".gitignore"),
|
|
41
|
+
["*.tmp", "*.log", "!important.log", "logs/", "/anchored.txt"].join("\n"),
|
|
42
|
+
"utf8"
|
|
43
|
+
);
|
|
44
|
+
await writeFile(join(nested, ".gitignore"), "local.txt\n", "utf8");
|
|
45
|
+
|
|
46
|
+
const filter = await GitignoreFilter.for(nested);
|
|
47
|
+
|
|
48
|
+
expect(filter.ignores(join(nested, "scratch.tmp"))).toBe(true);
|
|
49
|
+
expect(filter.ignores(join(nested, "logs", "app.log"))).toBe(true);
|
|
50
|
+
expect(filter.ignores(join(project, "anchored.txt"))).toBe(true);
|
|
51
|
+
expect(filter.ignores(join(nested, "anchored.txt"))).toBe(false);
|
|
52
|
+
expect(filter.ignores(join(nested, "drop.log"))).toBe(true);
|
|
53
|
+
expect(filter.ignores(join(nested, "important.log"))).toBe(false);
|
|
54
|
+
expect(filter.ignores(join(nested, "local.txt"))).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("rejects relative paths", async () => {
|
|
58
|
+
const root = await createTempDir();
|
|
59
|
+
const filter = await GitignoreFilter.for(root);
|
|
60
|
+
|
|
61
|
+
expect(() => filter.ignores("src/index.ts")).toThrow(
|
|
62
|
+
"Expected absolute path"
|
|
63
|
+
);
|
|
64
|
+
});
|