@aaroncql/pim-agent 0.0.1 → 0.2.0
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/README.md +94 -66
- package/bin/pim.ts +55 -3
- package/package.json +20 -5
- package/src/extensions/_init/index.ts +3 -2
- package/src/extensions/apply-patch/coordinator.ts +49 -0
- package/src/extensions/apply-patch/executor.ts +566 -0
- package/src/extensions/apply-patch/index.ts +74 -0
- package/src/extensions/apply-patch/matcher.ts +66 -0
- package/src/extensions/apply-patch/model.ts +34 -0
- package/src/extensions/apply-patch/parser.ts +381 -0
- package/src/extensions/apply-patch/render.ts +261 -0
- package/src/extensions/apply-patch/schema.ts +43 -0
- package/src/extensions/apply-patch/types.ts +30 -0
- package/src/extensions/bash/index.ts +3 -3
- package/src/extensions/edit/index.ts +2 -1
- package/src/extensions/glob/index.ts +3 -1
- package/src/extensions/glob/schema.ts +2 -1
- package/src/extensions/grep/index.ts +3 -1
- package/src/extensions/grep/render.ts +18 -4
- package/src/extensions/grep/schema.ts +1 -1
- package/src/extensions/read/index.ts +36 -9
- package/src/extensions/read/render.ts +31 -3
- package/src/extensions/subagent/index.ts +4 -1
- package/src/extensions/todo/index.ts +4 -3
- package/src/extensions/web-search/index.ts +2 -1
- package/src/extensions/write/index.ts +2 -1
- package/src/shared/PatchSummary.ts +82 -0
- package/src/telegram/Renderer.ts +190 -4
- package/src/extensions/bash/capture.test.ts +0 -126
- package/src/extensions/bash/format.test.ts +0 -240
- package/src/extensions/bash/run.test.ts +0 -262
- package/src/extensions/command-picker/ranker.test.ts +0 -46
- package/src/extensions/edit/edit.test.ts +0 -285
- package/src/extensions/file-picker/catalog.test.ts +0 -263
- package/src/extensions/file-picker/index.test.ts +0 -168
- package/src/extensions/file-picker/ranker.test.ts +0 -94
- package/src/extensions/footer/git.test.ts +0 -76
- package/src/extensions/footer/index.test.ts +0 -161
- package/src/extensions/footer/segments.test.ts +0 -164
- package/src/extensions/glob/glob.test.ts +0 -171
- package/src/extensions/glob/index.test.ts +0 -68
- package/src/extensions/glob/render.test.ts +0 -126
- package/src/extensions/grep/grep.test.ts +0 -387
- package/src/extensions/grep/index.test.ts +0 -68
- package/src/extensions/grep/render.test.ts +0 -269
- package/src/extensions/read/read.test.ts +0 -177
- package/src/extensions/read/render.test.ts +0 -61
- package/src/extensions/subagent/index.test.ts +0 -44
- package/src/extensions/subagent/render.test.ts +0 -292
- package/src/extensions/subagent/subagent.test.ts +0 -315
- package/src/extensions/system-prompt/prompt.test.ts +0 -64
- package/src/extensions/todo/index.test.ts +0 -244
- package/src/extensions/todo/render.test.ts +0 -180
- package/src/extensions/todo/todo.test.ts +0 -222
- package/src/extensions/tps/index.test.ts +0 -254
- package/src/extensions/web-fetch/WebViewMarkdownSnapshot.test.ts +0 -119
- package/src/extensions/web-fetch/fetch.test.ts +0 -244
- package/src/extensions/web-fetch/render.test.ts +0 -56
- package/src/extensions/web-search/ExaMcpClient.test.ts +0 -143
- package/src/extensions/web-search/render.test.ts +0 -21
- package/src/extensions/web-search/search.test.ts +0 -53
- package/src/extensions/working-indicator/index.test.ts +0 -21
- package/src/extensions/write/render.test.ts +0 -64
- package/src/extensions/write/write.test.ts +0 -108
- package/src/shared/DiffLines.test.ts +0 -193
- package/src/shared/DiffRenderer.test.ts +0 -206
- package/src/shared/EditMatcher.test.ts +0 -123
- package/src/shared/FileScanner.test.ts +0 -158
- package/src/shared/FuzzyMatcher.test.ts +0 -114
- package/src/shared/GitignoreFilter.test.ts +0 -64
- package/src/shared/Lines.test.ts +0 -25
- package/src/shared/McpClient.test.ts +0 -235
- package/src/shared/OutputBudget.test.ts +0 -99
- package/src/shared/Paths.test.ts +0 -51
- package/src/shared/PimSettings.test.ts +0 -90
- package/src/shared/Renderer.test.ts +0 -190
- package/src/shared/SpillCache.test.ts +0 -94
- package/src/shared/Tools.test.ts +0 -392
- package/src/telegram/Config.test.ts +0 -275
- package/src/telegram/Markdown.test.ts +0 -143
- package/src/telegram/Renderer.test.ts +0 -216
- package/src/telegram/SessionRegistry.test.ts +0 -89
- package/src/telegram/TaskScheduler.test.ts +0 -278
- package/src/telegram/TaskTool.test.ts +0 -179
|
@@ -1,206 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { DiffLines, type ToolDiffHunk } from "./DiffLines";
|
|
3
|
-
import { DiffRenderer, type DiffHighlighter } from "./DiffRenderer";
|
|
4
|
-
|
|
5
|
-
const tagHighlighter: DiffHighlighter = (block) =>
|
|
6
|
-
block.split("\n").map((line) => `<H>${line}</H>`);
|
|
7
|
-
|
|
8
|
-
const recordingHighlighter = (calls: string[][]): DiffHighlighter => {
|
|
9
|
-
return (block) => {
|
|
10
|
-
const lines = block.split("\n");
|
|
11
|
-
calls.push(lines);
|
|
12
|
-
return lines.map((line) => `<H>${line}</H>`);
|
|
13
|
-
};
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
const firstHunk = (
|
|
17
|
-
oldText: readonly string[],
|
|
18
|
-
newText: readonly string[]
|
|
19
|
-
): ToolDiffHunk => {
|
|
20
|
-
const diff = DiffLines.buildToolDiff(
|
|
21
|
-
"foo.ts",
|
|
22
|
-
{ lines: oldText, hasTrailingNewline: true },
|
|
23
|
-
{ lines: newText, hasTrailingNewline: true },
|
|
24
|
-
1
|
|
25
|
-
);
|
|
26
|
-
|
|
27
|
-
if (diff === undefined || diff.hunks[0] === undefined) {
|
|
28
|
-
throw new Error("expected at least one hunk");
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
return diff.hunks[0];
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
describe("DiffRenderer.highlightHunkLines", () => {
|
|
35
|
-
test("highlights added lines using the new-side block", () => {
|
|
36
|
-
const hunk = firstHunk(["a", "b", "c"], ["a", "b", "c", "d"]);
|
|
37
|
-
const result = DiffRenderer.highlightHunkLines(hunk, tagHighlighter);
|
|
38
|
-
|
|
39
|
-
expect(result).toEqual(hunk.lines.map((line) => `<H>${line.text}</H>`));
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
test("highlights removed lines using the old-side block", () => {
|
|
43
|
-
const hunk = firstHunk(["a", "b", "c"], ["a", "c"]);
|
|
44
|
-
const result = DiffRenderer.highlightHunkLines(hunk, tagHighlighter);
|
|
45
|
-
|
|
46
|
-
expect(result).toEqual(hunk.lines.map((line) => `<H>${line.text}</H>`));
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
test("passes old and new versions as multi-line blocks (not per line)", () => {
|
|
50
|
-
const hunk = firstHunk(
|
|
51
|
-
["line1", "old-mid", "line3"],
|
|
52
|
-
["line1", "new-mid", "line3"]
|
|
53
|
-
);
|
|
54
|
-
const calls: string[][] = [];
|
|
55
|
-
DiffRenderer.highlightHunkLines(hunk, recordingHighlighter(calls));
|
|
56
|
-
|
|
57
|
-
expect(calls).toHaveLength(2);
|
|
58
|
-
expect(calls).toContainEqual(["line1", "old-mid", "line3"]);
|
|
59
|
-
expect(calls).toContainEqual(["line1", "new-mid", "line3"]);
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
test("maps each diff line to the correct highlighted entry", () => {
|
|
63
|
-
const hunk = firstHunk(
|
|
64
|
-
["keep", "drop1", "drop2", "tail"],
|
|
65
|
-
["keep", "add1", "add2", "tail"]
|
|
66
|
-
);
|
|
67
|
-
const result = DiffRenderer.highlightHunkLines(hunk, tagHighlighter);
|
|
68
|
-
|
|
69
|
-
for (let i = 0; i < hunk.lines.length; i += 1) {
|
|
70
|
-
expect(result[i]).toBe(`<H>${hunk.lines[i]?.text}</H>`);
|
|
71
|
-
}
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
test("falls back to raw text when highlighter returns shorter array", () => {
|
|
75
|
-
const hunk = firstHunk(["a"], ["a", "b"]);
|
|
76
|
-
const truncating: DiffHighlighter = () => [];
|
|
77
|
-
const result = DiffRenderer.highlightHunkLines(hunk, truncating);
|
|
78
|
-
|
|
79
|
-
expect(result).toEqual(hunk.lines.map((line) => line.text));
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
test("skips highlighter calls when one side is empty", () => {
|
|
83
|
-
const hunk = firstHunk(["a", "b"], ["a", "b", "c", "d"]);
|
|
84
|
-
const calls: string[][] = [];
|
|
85
|
-
DiffRenderer.highlightHunkLines(hunk, recordingHighlighter(calls));
|
|
86
|
-
|
|
87
|
-
for (const call of calls) {
|
|
88
|
-
expect(call.length).toBeGreaterThan(0);
|
|
89
|
-
}
|
|
90
|
-
});
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
describe("DiffRenderer.render", () => {
|
|
94
|
-
const stubTheme = {
|
|
95
|
-
name: "pim-dark",
|
|
96
|
-
fg: (_color: string, text: string) => text,
|
|
97
|
-
} as unknown as Parameters<typeof DiffRenderer.render>[0]["theme"];
|
|
98
|
-
|
|
99
|
-
test("returns empty string for empty diff", () => {
|
|
100
|
-
const out = DiffRenderer.render({
|
|
101
|
-
toolDiff: { path: "foo.ts", hunks: [] },
|
|
102
|
-
theme: stubTheme,
|
|
103
|
-
});
|
|
104
|
-
expect(out).toBe("");
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
test("includes content for each diff line", () => {
|
|
108
|
-
const diff = DiffLines.buildToolDiff(
|
|
109
|
-
"foo.ts",
|
|
110
|
-
{ lines: ["alpha", "beta", "gamma"], hasTrailingNewline: true },
|
|
111
|
-
{ lines: ["alpha", "BETA", "gamma"], hasTrailingNewline: true },
|
|
112
|
-
1
|
|
113
|
-
);
|
|
114
|
-
|
|
115
|
-
if (diff === undefined) {
|
|
116
|
-
throw new Error("expected diff");
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const out = DiffRenderer.render({ toolDiff: diff, theme: stubTheme });
|
|
120
|
-
|
|
121
|
-
expect(out).toContain("alpha");
|
|
122
|
-
expect(out).toContain("beta");
|
|
123
|
-
expect(out).toContain("BETA");
|
|
124
|
-
expect(out).toContain("gamma");
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
test("emits emphasis bg for paired changed lines", () => {
|
|
128
|
-
const diff = DiffLines.buildToolDiff(
|
|
129
|
-
"foo.ts",
|
|
130
|
-
{ lines: ["const x = 1;"], hasTrailingNewline: true },
|
|
131
|
-
{ lines: ["const y = 2;"], hasTrailingNewline: true },
|
|
132
|
-
1
|
|
133
|
-
);
|
|
134
|
-
|
|
135
|
-
if (diff === undefined) {
|
|
136
|
-
throw new Error("expected diff");
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
const out = DiffRenderer.render({ toolDiff: diff, theme: stubTheme });
|
|
140
|
-
|
|
141
|
-
expect(out).toContain("\x1b[48;2;26;81;47m");
|
|
142
|
-
expect(out).toContain("\x1b[48;2;100;35;35m");
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
test("does not render any EOF newline marker (EOF state is surfaced by callers, not the renderer)", () => {
|
|
146
|
-
const diff = DiffLines.buildToolDiff(
|
|
147
|
-
"foo.ts",
|
|
148
|
-
{ lines: ["alpha"], hasTrailingNewline: false },
|
|
149
|
-
{ lines: ["beta"], hasTrailingNewline: true },
|
|
150
|
-
1
|
|
151
|
-
);
|
|
152
|
-
|
|
153
|
-
if (diff === undefined) {
|
|
154
|
-
throw new Error("expected diff");
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
const out = DiffRenderer.render({ toolDiff: diff, theme: stubTheme });
|
|
158
|
-
expect(out).not.toContain("No newline at end of file");
|
|
159
|
-
expect(out).not.toContain("Newline added");
|
|
160
|
-
});
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
describe("DiffRenderer.applyEmphasis", () => {
|
|
164
|
-
const lineBg = "\x1b[48;2;0;0;0m";
|
|
165
|
-
const emphBg = "\x1b[48;2;255;255;255m";
|
|
166
|
-
|
|
167
|
-
test("returns text unchanged when no ranges", () => {
|
|
168
|
-
expect(DiffRenderer.applyEmphasis("hello", [], lineBg, emphBg)).toBe(
|
|
169
|
-
"hello"
|
|
170
|
-
);
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
test("wraps an emphasized range with emph then line bg", () => {
|
|
174
|
-
const out = DiffRenderer.applyEmphasis(
|
|
175
|
-
"hello world",
|
|
176
|
-
[{ start: 6, end: 11 }],
|
|
177
|
-
lineBg,
|
|
178
|
-
emphBg
|
|
179
|
-
);
|
|
180
|
-
expect(out).toBe(`hello ${emphBg}world${lineBg}`);
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
test("counts visible chars only, ignoring ANSI escape sequences", () => {
|
|
184
|
-
const colored = `\x1b[31mhello\x1b[39m world`;
|
|
185
|
-
const out = DiffRenderer.applyEmphasis(
|
|
186
|
-
colored,
|
|
187
|
-
[{ start: 6, end: 11 }],
|
|
188
|
-
lineBg,
|
|
189
|
-
emphBg
|
|
190
|
-
);
|
|
191
|
-
expect(out).toBe(`\x1b[31mhello\x1b[39m ${emphBg}world${lineBg}`);
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
test("supports multiple non-overlapping ranges", () => {
|
|
195
|
-
const out = DiffRenderer.applyEmphasis(
|
|
196
|
-
"abcdef",
|
|
197
|
-
[
|
|
198
|
-
{ start: 0, end: 2 },
|
|
199
|
-
{ start: 4, end: 6 },
|
|
200
|
-
],
|
|
201
|
-
lineBg,
|
|
202
|
-
emphBg
|
|
203
|
-
);
|
|
204
|
-
expect(out).toBe(`${emphBg}ab${lineBg}cd${emphBg}ef${lineBg}`);
|
|
205
|
-
});
|
|
206
|
-
});
|
|
@@ -1,123 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { EditMatcher } from "./EditMatcher";
|
|
3
|
-
|
|
4
|
-
const replace = (
|
|
5
|
-
content: string,
|
|
6
|
-
oldString: string,
|
|
7
|
-
newString: string,
|
|
8
|
-
replaceAll = false
|
|
9
|
-
): string => {
|
|
10
|
-
const resolved = EditMatcher.resolve(content, oldString, replaceAll);
|
|
11
|
-
const ranges = "ranges" in resolved ? resolved.ranges : [resolved.range];
|
|
12
|
-
return EditMatcher.applyAll(
|
|
13
|
-
content,
|
|
14
|
-
ranges.map((range) => ({ range, newString }))
|
|
15
|
-
);
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
describe("EditMatcher", () => {
|
|
19
|
-
test("resolves exact matches", () => {
|
|
20
|
-
expect(replace("alpha\nbeta\ngamma", "beta", "delta")).toBe(
|
|
21
|
-
"alpha\ndelta\ngamma"
|
|
22
|
-
);
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
test("uses lineTrimmed fallback", () => {
|
|
26
|
-
expect(replace("alpha\n beta\ngamma", "beta ", "delta")).toBe(
|
|
27
|
-
"alpha\ndelta\ngamma"
|
|
28
|
-
);
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
test("uses whitespaceNormalized fallback", () => {
|
|
32
|
-
expect(replace("alpha\nfoo bar\ngamma", "foo bar", "baz")).toBe(
|
|
33
|
-
"alpha\nbaz\ngamma"
|
|
34
|
-
);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
test("uses indentationFlexible fallback", () => {
|
|
38
|
-
const content = "root\n if (ok) {\n run()\n }\nend";
|
|
39
|
-
const oldString = "if (ok) {\n run()\n}";
|
|
40
|
-
expect(replace(content, oldString, "done()")).toBe("root\ndone()\nend");
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
test("uses escapeNormalized fallback", () => {
|
|
44
|
-
expect(replace("alpha\nbeta\ngamma", "beta\\ngamma", "delta")).toBe(
|
|
45
|
-
"alpha\ndelta"
|
|
46
|
-
);
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
test("uses trimmedBoundary fallback", () => {
|
|
50
|
-
expect(replace("alpha\nbeta\ngamma", "\n beta \n", "delta")).toBe(
|
|
51
|
-
"alpha\ndelta\ngamma"
|
|
52
|
-
);
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
test("uses unicodeNormalized fallback", () => {
|
|
56
|
-
expect(replace("say “hello” now", 'say "hello" now', "done")).toBe("done");
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
test("uses blockAnchor fallback with same-line-count constraint", () => {
|
|
60
|
-
const content = [
|
|
61
|
-
"start",
|
|
62
|
-
"actual middle",
|
|
63
|
-
"end",
|
|
64
|
-
"start",
|
|
65
|
-
"one",
|
|
66
|
-
"two",
|
|
67
|
-
"three",
|
|
68
|
-
"end",
|
|
69
|
-
].join("\n");
|
|
70
|
-
|
|
71
|
-
expect(replace(content, "start\nexpected middle\nend", "done")).toBe(
|
|
72
|
-
["done", "start", "one", "two", "three", "end"].join("\n")
|
|
73
|
-
);
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
test("blockAnchor matches 3-line region with drifted middle", () => {
|
|
77
|
-
const content = ["start", "drifted middle", "end"].join("\n");
|
|
78
|
-
expect(replace(content, "start\nexpected middle\nend", "done")).toBe(
|
|
79
|
-
"done"
|
|
80
|
-
);
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
test("uses contextAware fallback", () => {
|
|
84
|
-
const content = "start\nsame\nactual\nend";
|
|
85
|
-
expect(replace(content, "start\nsame\nexpected\nend", "done")).toBe("done");
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
test("replaceAll returns every occurrence", () => {
|
|
89
|
-
expect(replace("foo\nbar\nfoo", "foo", "baz", true)).toBe("baz\nbar\nbaz");
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
test("throws multiple matches without replaceAll", () => {
|
|
93
|
-
expect(() => EditMatcher.resolve("foo\nbar\nfoo", "foo")).toThrow(
|
|
94
|
-
/matched multiple regions/
|
|
95
|
-
);
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
test("not found includes closest regions above threshold", () => {
|
|
99
|
-
const closest = EditMatcher.findClosestRegions(
|
|
100
|
-
"alpha\nbeta\ngamma",
|
|
101
|
-
"betx"
|
|
102
|
-
);
|
|
103
|
-
expect(closest[0]?.startLine).toBe(2);
|
|
104
|
-
expect(closest[0]?.similarity).toBeGreaterThan(0.5);
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
test("not found returns no regions below threshold", () => {
|
|
108
|
-
const closest = EditMatcher.findClosestRegions("aaaa\nbbbb", "zzzz");
|
|
109
|
-
expect(closest).toEqual([]);
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
test("escape-drift guard rejects new escape sequences after fuzzy match", () => {
|
|
113
|
-
const resolved = EditMatcher.resolve(" beta", "beta ");
|
|
114
|
-
const range = "range" in resolved ? resolved.range : resolved.ranges[0]!;
|
|
115
|
-
expect(() =>
|
|
116
|
-
EditMatcher.assertNoEscapeDrift(
|
|
117
|
-
resolved.strategy,
|
|
118
|
-
"new\\nvalue",
|
|
119
|
-
" beta".slice(range[0], range[1])
|
|
120
|
-
)
|
|
121
|
-
).toThrow(/newString contains literal escape text/);
|
|
122
|
-
});
|
|
123
|
-
});
|
|
@@ -1,158 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,114 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,64 +0,0 @@
|
|
|
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
|
-
});
|
package/src/shared/Lines.test.ts
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { Lines } from "./Lines";
|
|
3
|
-
|
|
4
|
-
describe("Lines.continuationLine", () => {
|
|
5
|
-
test("resumes on the partial last line when the cut is mid-line", () => {
|
|
6
|
-
expect(Lines.continuationLine("a\nb\nc")).toBe(3);
|
|
7
|
-
});
|
|
8
|
-
|
|
9
|
-
test("resumes on the next line when the cut lands on a newline", () => {
|
|
10
|
-
expect(Lines.continuationLine("a\nb\n")).toBe(3);
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
test("treats a single unterminated line as line 1", () => {
|
|
14
|
-
expect(Lines.continuationLine("a")).toBe(1);
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
test("never returns below 1 for empty input", () => {
|
|
18
|
-
expect(Lines.continuationLine("")).toBe(1);
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
test("counts normalized newlines (CRLF and CR)", () => {
|
|
22
|
-
expect(Lines.continuationLine("a\r\nb\r\nc")).toBe(3);
|
|
23
|
-
expect(Lines.continuationLine("a\rb")).toBe(2);
|
|
24
|
-
});
|
|
25
|
-
});
|