wdyt 0.1.11 → 0.1.15

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.
@@ -0,0 +1,341 @@
1
+ /**
2
+ * Tests for reference finding module
3
+ */
4
+
5
+ import { describe, test, expect, beforeAll, afterAll } from "bun:test";
6
+ import {
7
+ findReferences,
8
+ findReferencesForSymbols,
9
+ formatReferences,
10
+ isGitRepository,
11
+ type Reference,
12
+ } from "./references";
13
+ import { join } from "path";
14
+ import { mkdir, writeFile, rm } from "fs/promises";
15
+ import { spawn } from "child_process";
16
+
17
+ // Test fixtures directory - using a git-initialized temp directory
18
+ const FIXTURES_DIR = join(import.meta.dir, "..", "..", ".test-fixtures-refs");
19
+
20
+ /**
21
+ * Helper to run git commands
22
+ */
23
+ async function git(args: string[], cwd: string): Promise<void> {
24
+ return new Promise((resolve, reject) => {
25
+ const process = spawn("git", args, { cwd, stdio: "ignore" });
26
+ process.on("close", (code) => {
27
+ if (code === 0) resolve();
28
+ else reject(new Error(`git ${args.join(" ")} failed with code ${code}`));
29
+ });
30
+ process.on("error", reject);
31
+ });
32
+ }
33
+
34
+ describe("Reference finding", () => {
35
+ // Set up a temp git repo with test files
36
+ beforeAll(async () => {
37
+ await mkdir(FIXTURES_DIR, { recursive: true });
38
+
39
+ // Initialize git repo
40
+ await git(["init"], FIXTURES_DIR);
41
+ await git(["config", "user.email", "test@test.com"], FIXTURES_DIR);
42
+ await git(["config", "user.name", "Test User"], FIXTURES_DIR);
43
+
44
+ // Create test files
45
+ await writeFile(
46
+ join(FIXTURES_DIR, "utils.ts"),
47
+ `
48
+ export function helper() {
49
+ return "helper";
50
+ }
51
+
52
+ export function otherFunc() {
53
+ return helper();
54
+ }
55
+ `
56
+ );
57
+
58
+ await writeFile(
59
+ join(FIXTURES_DIR, "main.ts"),
60
+ `
61
+ import { helper } from './utils';
62
+
63
+ function main() {
64
+ const result = helper();
65
+ console.log(result);
66
+ }
67
+
68
+ export { main };
69
+ `
70
+ );
71
+
72
+ await writeFile(
73
+ join(FIXTURES_DIR, "test.ts"),
74
+ `
75
+ import { helper } from './utils';
76
+
77
+ test('helper works', () => {
78
+ expect(helper()).toBe('helper');
79
+ });
80
+ `
81
+ );
82
+
83
+ await writeFile(
84
+ join(FIXTURES_DIR, "noRefs.ts"),
85
+ `
86
+ export function uniqueFunction() {
87
+ return "unique";
88
+ }
89
+ `
90
+ );
91
+
92
+ // Stage and commit files
93
+ await git(["add", "."], FIXTURES_DIR);
94
+ await git(["commit", "-m", "Initial commit"], FIXTURES_DIR);
95
+ });
96
+
97
+ afterAll(async () => {
98
+ await rm(FIXTURES_DIR, { recursive: true, force: true });
99
+ });
100
+
101
+ describe("findReferences", () => {
102
+ test("finds references to a symbol", async () => {
103
+ const refs = await findReferences({
104
+ symbol: "helper",
105
+ cwd: FIXTURES_DIR,
106
+ });
107
+
108
+ expect(refs.length).toBeGreaterThan(0);
109
+
110
+ // Should find references in main.ts and test.ts
111
+ const files = refs.map((r) => r.file);
112
+ expect(files).toContain("main.ts");
113
+ expect(files).toContain("test.ts");
114
+ });
115
+
116
+ test("excludes definition file from results", async () => {
117
+ const refs = await findReferences({
118
+ symbol: "helper",
119
+ definitionFile: "utils.ts",
120
+ cwd: FIXTURES_DIR,
121
+ });
122
+
123
+ // Should not include utils.ts
124
+ const files = refs.map((r) => r.file);
125
+ expect(files).not.toContain("utils.ts");
126
+ });
127
+
128
+ test("returns line numbers", async () => {
129
+ const refs = await findReferences({
130
+ symbol: "helper",
131
+ cwd: FIXTURES_DIR,
132
+ });
133
+
134
+ for (const ref of refs) {
135
+ expect(typeof ref.line).toBe("number");
136
+ expect(ref.line).toBeGreaterThan(0);
137
+ }
138
+ });
139
+
140
+ test("returns context for each reference", async () => {
141
+ const refs = await findReferences({
142
+ symbol: "helper",
143
+ cwd: FIXTURES_DIR,
144
+ });
145
+
146
+ for (const ref of refs) {
147
+ expect(typeof ref.context).toBe("string");
148
+ expect(ref.context.length).toBeGreaterThan(0);
149
+ }
150
+ });
151
+
152
+ test("respects limit option", async () => {
153
+ const refs = await findReferences({
154
+ symbol: "helper",
155
+ cwd: FIXTURES_DIR,
156
+ limit: 2,
157
+ });
158
+
159
+ expect(refs.length).toBeLessThanOrEqual(2);
160
+ });
161
+
162
+ test("handles symbols with no references gracefully", async () => {
163
+ const refs = await findReferences({
164
+ symbol: "nonExistentSymbol12345",
165
+ cwd: FIXTURES_DIR,
166
+ });
167
+
168
+ expect(refs).toEqual([]);
169
+ });
170
+
171
+ test("handles file patterns option", async () => {
172
+ const refs = await findReferences({
173
+ symbol: "helper",
174
+ cwd: FIXTURES_DIR,
175
+ filePatterns: ["*.ts"],
176
+ });
177
+
178
+ expect(refs.length).toBeGreaterThan(0);
179
+
180
+ // All results should be .ts files
181
+ for (const ref of refs) {
182
+ expect(ref.file.endsWith(".ts")).toBe(true);
183
+ }
184
+ });
185
+
186
+ test("uses word boundary matching", async () => {
187
+ // Create a file with similar but different symbol names
188
+ await writeFile(
189
+ join(FIXTURES_DIR, "partial.ts"),
190
+ `
191
+ const helperFunc = 1;
192
+ const myhelper = 2;
193
+ const helper = 3;
194
+ `
195
+ );
196
+ await git(["add", "partial.ts"], FIXTURES_DIR);
197
+ await git(["commit", "-m", "Add partial matches file"], FIXTURES_DIR);
198
+
199
+ const refs = await findReferences({
200
+ symbol: "helper",
201
+ cwd: FIXTURES_DIR,
202
+ });
203
+
204
+ // Should only find exact "helper" matches, not "helperFunc" or "myhelper"
205
+ for (const ref of refs) {
206
+ if (ref.file === "partial.ts") {
207
+ // The context should contain "helper" as a whole word
208
+ expect(ref.context).toMatch(/\bhelper\b/);
209
+ // Should not be the helperFunc or myhelper lines
210
+ expect(ref.context).not.toMatch(/helperFunc/);
211
+ expect(ref.context).not.toMatch(/myhelper/);
212
+ }
213
+ }
214
+ });
215
+ });
216
+
217
+ describe("findReferencesForSymbols", () => {
218
+ test("finds references for multiple symbols", async () => {
219
+ const results = await findReferencesForSymbols(
220
+ ["helper", "main"],
221
+ undefined,
222
+ FIXTURES_DIR
223
+ );
224
+
225
+ expect(results.size).toBe(2);
226
+ expect(results.has("helper")).toBe(true);
227
+ expect(results.has("main")).toBe(true);
228
+ });
229
+
230
+ test("excludes definition file for all symbols", async () => {
231
+ const results = await findReferencesForSymbols(
232
+ ["helper", "otherFunc"],
233
+ "utils.ts",
234
+ FIXTURES_DIR
235
+ );
236
+
237
+ // Neither should have utils.ts in results
238
+ for (const [, refs] of results) {
239
+ const files = refs.map((r) => r.file);
240
+ expect(files).not.toContain("utils.ts");
241
+ }
242
+ });
243
+
244
+ test("respects per-symbol limit", async () => {
245
+ const results = await findReferencesForSymbols(
246
+ ["helper"],
247
+ undefined,
248
+ FIXTURES_DIR,
249
+ 1
250
+ );
251
+
252
+ const helperRefs = results.get("helper") ?? [];
253
+ expect(helperRefs.length).toBeLessThanOrEqual(1);
254
+ });
255
+
256
+ test("handles symbols with no references", async () => {
257
+ const results = await findReferencesForSymbols(
258
+ ["helper", "nonExistent12345"],
259
+ undefined,
260
+ FIXTURES_DIR
261
+ );
262
+
263
+ expect(results.get("nonExistent12345")).toEqual([]);
264
+ });
265
+ });
266
+
267
+ describe("formatReferences", () => {
268
+ test("formats references as file:line:context", () => {
269
+ const refs: Reference[] = [
270
+ { file: "main.ts", line: 4, context: "const result = helper();" },
271
+ { file: "test.ts", line: 5, context: "expect(helper()).toBe('helper');" },
272
+ ];
273
+
274
+ const formatted = formatReferences(refs);
275
+
276
+ expect(formatted).toEqual([
277
+ "main.ts:4:const result = helper();",
278
+ "test.ts:5:expect(helper()).toBe('helper');",
279
+ ]);
280
+ });
281
+
282
+ test("handles empty array", () => {
283
+ expect(formatReferences([])).toEqual([]);
284
+ });
285
+ });
286
+
287
+ describe("isGitRepository", () => {
288
+ test("returns true for git repository", async () => {
289
+ const result = await isGitRepository(FIXTURES_DIR);
290
+ expect(result).toBe(true);
291
+ });
292
+
293
+ test("returns false for non-git directory", async () => {
294
+ const tempDir = join(FIXTURES_DIR, "non-git");
295
+ await mkdir(tempDir, { recursive: true });
296
+
297
+ // This dir is inside the git repo, but let's test with a clearly non-git path
298
+ // Actually, since it's inside the repo, it would still return true
299
+ // Let's use /tmp instead
300
+ const result = await isGitRepository("/tmp");
301
+ // /tmp might or might not be a git repo, but we can at least verify the function runs
302
+ expect(typeof result).toBe("boolean");
303
+ });
304
+ });
305
+
306
+ describe("Edge cases", () => {
307
+ test("handles special regex characters in symbol names", async () => {
308
+ // Git grep -w handles word boundaries, but let's make sure special chars work
309
+ // Most special chars wouldn't be valid symbol names anyway
310
+ const refs = await findReferences({
311
+ symbol: "test",
312
+ cwd: FIXTURES_DIR,
313
+ });
314
+
315
+ // Should not throw
316
+ expect(Array.isArray(refs)).toBe(true);
317
+ });
318
+
319
+ test("handles definition file with path prefix", async () => {
320
+ // Test that definition file exclusion works with various path formats
321
+ const refs = await findReferences({
322
+ symbol: "helper",
323
+ definitionFile: "./utils.ts",
324
+ cwd: FIXTURES_DIR,
325
+ });
326
+
327
+ const files = refs.map((r) => r.file);
328
+ expect(files).not.toContain("utils.ts");
329
+ });
330
+
331
+ test("handles empty symbol", async () => {
332
+ const refs = await findReferences({
333
+ symbol: "",
334
+ cwd: FIXTURES_DIR,
335
+ });
336
+
337
+ // Git grep with empty pattern returns error, we should handle gracefully
338
+ expect(Array.isArray(refs)).toBe(true);
339
+ });
340
+ });
341
+ });
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Reference finding module for wdyt
3
+ *
4
+ * Finds where symbols are used across the codebase using git grep.
5
+ */
6
+
7
+ import { spawn } from "child_process";
8
+
9
+ /** Reference information for a symbol */
10
+ export interface Reference {
11
+ file: string;
12
+ line: number;
13
+ context: string;
14
+ }
15
+
16
+ /** Options for finding references */
17
+ export interface FindReferencesOptions {
18
+ /** The symbol name to search for */
19
+ symbol: string;
20
+ /** File path where the symbol is defined (will be excluded from results) */
21
+ definitionFile?: string;
22
+ /** Working directory (defaults to cwd) */
23
+ cwd?: string;
24
+ /** Maximum number of results to return (default: 20) */
25
+ limit?: number;
26
+ /** File patterns to search (e.g., "*.ts", "*.js") */
27
+ filePatterns?: string[];
28
+ }
29
+
30
+ /** Default maximum results per symbol */
31
+ const DEFAULT_LIMIT = 20;
32
+
33
+ /**
34
+ * Execute git grep and return raw output
35
+ */
36
+ async function execGitGrep(
37
+ args: string[],
38
+ cwd: string
39
+ ): Promise<{ stdout: string; stderr: string; exitCode: number }> {
40
+ return new Promise((resolve) => {
41
+ const process = spawn("git", ["grep", ...args], {
42
+ cwd,
43
+ stdio: ["ignore", "pipe", "pipe"],
44
+ });
45
+
46
+ let stdout = "";
47
+ let stderr = "";
48
+
49
+ process.stdout.on("data", (data) => {
50
+ stdout += data.toString();
51
+ });
52
+
53
+ process.stderr.on("data", (data) => {
54
+ stderr += data.toString();
55
+ });
56
+
57
+ process.on("close", (exitCode) => {
58
+ resolve({ stdout, stderr, exitCode: exitCode ?? 0 });
59
+ });
60
+
61
+ process.on("error", () => {
62
+ resolve({ stdout: "", stderr: "Failed to spawn git grep", exitCode: 1 });
63
+ });
64
+ });
65
+ }
66
+
67
+ /**
68
+ * Parse git grep output line into Reference object
69
+ *
70
+ * git grep -n output format: file:line:context
71
+ */
72
+ function parseGrepLine(line: string): Reference | null {
73
+ // Handle format: file:line:context
74
+ // Need to handle Windows paths (C:\...) and colons in context
75
+ const match = line.match(/^([^:]+):(\d+):(.*)$/);
76
+ if (!match) return null;
77
+
78
+ const [, file, lineStr, context] = match;
79
+ const lineNum = parseInt(lineStr, 10);
80
+
81
+ if (isNaN(lineNum)) return null;
82
+
83
+ return {
84
+ file,
85
+ line: lineNum,
86
+ context: context.trim(),
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Normalize file path for comparison
92
+ */
93
+ function normalizePath(filePath: string): string {
94
+ return filePath.replace(/\\/g, "/").replace(/^\.\//, "");
95
+ }
96
+
97
+ /**
98
+ * Find references to a symbol across the codebase using git grep
99
+ *
100
+ * @param options - Search options
101
+ * @returns Array of references found
102
+ */
103
+ export async function findReferences(
104
+ options: FindReferencesOptions
105
+ ): Promise<Reference[]> {
106
+ const {
107
+ symbol,
108
+ definitionFile,
109
+ cwd = process.cwd(),
110
+ limit = DEFAULT_LIMIT,
111
+ filePatterns,
112
+ } = options;
113
+
114
+ // Build git grep arguments
115
+ // -w: word boundary matching (whole word only)
116
+ // -n: show line numbers
117
+ const args: string[] = ["-w", "-n", symbol];
118
+
119
+ // Add file patterns if specified
120
+ if (filePatterns && filePatterns.length > 0) {
121
+ args.push("--");
122
+ args.push(...filePatterns);
123
+ }
124
+
125
+ const { stdout, exitCode } = await execGitGrep(args, cwd);
126
+
127
+ // Exit code 1 means no matches found (not an error)
128
+ if (exitCode !== 0 && exitCode !== 1) {
129
+ return [];
130
+ }
131
+
132
+ // Parse output lines
133
+ const lines = stdout.split("\n").filter((line) => line.trim() !== "");
134
+ const references: Reference[] = [];
135
+
136
+ const normalizedDefinitionFile = definitionFile
137
+ ? normalizePath(definitionFile)
138
+ : null;
139
+
140
+ for (const line of lines) {
141
+ const ref = parseGrepLine(line);
142
+ if (!ref) continue;
143
+
144
+ // Exclude the definition file
145
+ if (normalizedDefinitionFile) {
146
+ const normalizedRefFile = normalizePath(ref.file);
147
+ if (
148
+ normalizedRefFile === normalizedDefinitionFile ||
149
+ normalizedRefFile.endsWith(`/${normalizedDefinitionFile}`) ||
150
+ normalizedDefinitionFile.endsWith(`/${normalizedRefFile}`)
151
+ ) {
152
+ continue;
153
+ }
154
+ }
155
+
156
+ references.push(ref);
157
+
158
+ // Stop if we've reached the limit
159
+ if (references.length >= limit) {
160
+ break;
161
+ }
162
+ }
163
+
164
+ return references;
165
+ }
166
+
167
+ /**
168
+ * Find references for multiple symbols
169
+ *
170
+ * @param symbols - Array of symbol names
171
+ * @param definitionFile - File where symbols are defined (excluded from results)
172
+ * @param cwd - Working directory
173
+ * @param limitPerSymbol - Maximum references per symbol (default: 10)
174
+ * @returns Map of symbol name to references
175
+ */
176
+ export async function findReferencesForSymbols(
177
+ symbols: string[],
178
+ definitionFile?: string,
179
+ cwd?: string,
180
+ limitPerSymbol: number = 10
181
+ ): Promise<Map<string, Reference[]>> {
182
+ const results = new Map<string, Reference[]>();
183
+
184
+ // Process symbols in parallel for better performance
185
+ const promises = symbols.map(async (symbol) => {
186
+ const refs = await findReferences({
187
+ symbol,
188
+ definitionFile,
189
+ cwd,
190
+ limit: limitPerSymbol,
191
+ });
192
+ return { symbol, refs };
193
+ });
194
+
195
+ const resolvedResults = await Promise.all(promises);
196
+
197
+ for (const { symbol, refs } of resolvedResults) {
198
+ results.set(symbol, refs);
199
+ }
200
+
201
+ return results;
202
+ }
203
+
204
+ /**
205
+ * Format references as file:line:context strings
206
+ *
207
+ * @param references - References to format
208
+ * @returns Array of formatted strings
209
+ */
210
+ export function formatReferences(references: Reference[]): string[] {
211
+ return references.map((ref) => `${ref.file}:${ref.line}:${ref.context}`);
212
+ }
213
+
214
+ /**
215
+ * Check if git is available and cwd is a git repository
216
+ */
217
+ export async function isGitRepository(cwd?: string): Promise<boolean> {
218
+ return new Promise((resolve) => {
219
+ const process = spawn("git", ["rev-parse", "--git-dir"], {
220
+ cwd: cwd ?? process.cwd(),
221
+ stdio: ["ignore", "pipe", "pipe"],
222
+ });
223
+
224
+ process.on("close", (exitCode) => {
225
+ resolve(exitCode === 0);
226
+ });
227
+
228
+ process.on("error", () => {
229
+ resolve(false);
230
+ });
231
+ });
232
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Tests for re-review cache-busting module
3
+ */
4
+
5
+ import { describe, test, expect, beforeEach } from "bun:test";
6
+ import {
7
+ buildReReviewPreamble,
8
+ detectReReview,
9
+ recordReview,
10
+ getPreviousReviewState,
11
+ clearReviewState,
12
+ processReReview,
13
+ } from "./rereview";
14
+
15
+ describe("buildReReviewPreamble", () => {
16
+ test("builds preamble with changed files", () => {
17
+ const files = ["src/auth.ts", "src/types.ts"];
18
+ const preamble = buildReReviewPreamble(files, "implementation");
19
+
20
+ expect(preamble).toContain("## IMPORTANT: Re-review After Fixes");
21
+ expect(preamble).toContain("This is a RE-REVIEW");
22
+ expect(preamble).toContain("- src/auth.ts");
23
+ expect(preamble).toContain("- src/types.ts");
24
+ expect(preamble).toContain("implementation review");
25
+ });
26
+
27
+ test("truncates files list at 30 files", () => {
28
+ const files = Array.from({ length: 40 }, (_, i) => `src/file${i}.ts`);
29
+ const preamble = buildReReviewPreamble(files, "plan");
30
+
31
+ expect(preamble).toContain("- src/file0.ts");
32
+ expect(preamble).toContain("- src/file29.ts");
33
+ expect(preamble).not.toContain("- src/file30.ts");
34
+ expect(preamble).toContain("... and 10 more files");
35
+ });
36
+
37
+ test("uses correct review type in message", () => {
38
+ const files = ["src/spec.md"];
39
+ const preamble = buildReReviewPreamble(files, "plan");
40
+
41
+ expect(preamble).toContain("plan review");
42
+ });
43
+ });
44
+
45
+ describe("detectReReview", () => {
46
+ beforeEach(() => {
47
+ clearReviewState();
48
+ });
49
+
50
+ test("returns false when no chatId and no explicit flag", () => {
51
+ const result = detectReReview({});
52
+ expect(result).toBe(false);
53
+ });
54
+
55
+ test("returns true when isReReview is explicitly true", () => {
56
+ const result = detectReReview({ isReReview: true });
57
+ expect(result).toBe(true);
58
+ });
59
+
60
+ test("returns false when chatId has no previous review", () => {
61
+ const result = detectReReview({ chatId: "new-chat-id" });
62
+ expect(result).toBe(false);
63
+ });
64
+
65
+ test("returns true when chatId has previous review", () => {
66
+ recordReview("existing-chat-id", ["file1.ts"]);
67
+ const result = detectReReview({ chatId: "existing-chat-id" });
68
+ expect(result).toBe(true);
69
+ });
70
+ });
71
+
72
+ describe("recordReview and getPreviousReviewState", () => {
73
+ beforeEach(() => {
74
+ clearReviewState();
75
+ });
76
+
77
+ test("records and retrieves review state", () => {
78
+ const files = ["src/auth.ts", "src/types.ts"];
79
+ recordReview("test-chat-id", files);
80
+
81
+ const state = getPreviousReviewState("test-chat-id");
82
+ expect(state).toBeDefined();
83
+ expect(state?.files).toEqual(files);
84
+ expect(state?.timestamp).toBeGreaterThan(0);
85
+ });
86
+
87
+ test("returns undefined for unknown chat id", () => {
88
+ const state = getPreviousReviewState("unknown-chat-id");
89
+ expect(state).toBeUndefined();
90
+ });
91
+
92
+ test("clearReviewState removes all state", () => {
93
+ recordReview("chat-1", ["file1.ts"]);
94
+ recordReview("chat-2", ["file2.ts"]);
95
+
96
+ clearReviewState();
97
+
98
+ expect(getPreviousReviewState("chat-1")).toBeUndefined();
99
+ expect(getPreviousReviewState("chat-2")).toBeUndefined();
100
+ });
101
+ });
102
+
103
+ describe("processReReview", () => {
104
+ beforeEach(() => {
105
+ clearReviewState();
106
+ });
107
+
108
+ test("returns isReReview false when not a re-review", async () => {
109
+ const result = await processReReview({});
110
+ expect(result.isReReview).toBe(false);
111
+ expect(result.preamble).toBeUndefined();
112
+ });
113
+
114
+ test("returns preamble when is re-review", async () => {
115
+ recordReview("prev-chat", ["file.ts"]);
116
+ const result = await processReReview({
117
+ chatId: "prev-chat",
118
+ reviewType: "implementation",
119
+ });
120
+
121
+ expect(result.isReReview).toBe(true);
122
+ expect(result.preamble).toBeDefined();
123
+ expect(result.preamble).toContain("RE-REVIEW");
124
+ });
125
+
126
+ test("uses explicit isReReview flag", async () => {
127
+ const result = await processReReview({
128
+ isReReview: true,
129
+ reviewType: "plan",
130
+ });
131
+
132
+ expect(result.isReReview).toBe(true);
133
+ expect(result.preamble).toBeDefined();
134
+ });
135
+ });