second-opinion-mcp 0.1.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.
Files changed (51) hide show
  1. package/README.md +323 -0
  2. package/dist/config.d.ts +31 -0
  3. package/dist/config.js +84 -0
  4. package/dist/context/bundler.d.ts +51 -0
  5. package/dist/context/bundler.js +481 -0
  6. package/dist/context/bundler.test.d.ts +1 -0
  7. package/dist/context/bundler.test.js +275 -0
  8. package/dist/context/git.d.ts +21 -0
  9. package/dist/context/git.js +102 -0
  10. package/dist/context/imports.d.ts +43 -0
  11. package/dist/context/imports.js +197 -0
  12. package/dist/context/imports.test.d.ts +1 -0
  13. package/dist/context/imports.test.js +147 -0
  14. package/dist/context/index.d.ts +6 -0
  15. package/dist/context/index.js +6 -0
  16. package/dist/context/session.d.ts +35 -0
  17. package/dist/context/session.js +317 -0
  18. package/dist/context/tests.d.ts +13 -0
  19. package/dist/context/tests.js +83 -0
  20. package/dist/context/types.d.ts +16 -0
  21. package/dist/context/types.js +100 -0
  22. package/dist/index.d.ts +2 -0
  23. package/dist/index.js +6 -0
  24. package/dist/output/writer.d.ts +34 -0
  25. package/dist/output/writer.js +162 -0
  26. package/dist/output/writer.test.d.ts +1 -0
  27. package/dist/output/writer.test.js +175 -0
  28. package/dist/providers/base.d.ts +24 -0
  29. package/dist/providers/base.js +77 -0
  30. package/dist/providers/base.test.d.ts +1 -0
  31. package/dist/providers/base.test.js +91 -0
  32. package/dist/providers/gemini.d.ts +8 -0
  33. package/dist/providers/gemini.js +43 -0
  34. package/dist/providers/index.d.ts +8 -0
  35. package/dist/providers/index.js +31 -0
  36. package/dist/providers/openai.d.ts +8 -0
  37. package/dist/providers/openai.js +39 -0
  38. package/dist/server.d.ts +3 -0
  39. package/dist/server.js +181 -0
  40. package/dist/test-utils.d.ts +71 -0
  41. package/dist/test-utils.js +136 -0
  42. package/dist/tools/review.d.ts +76 -0
  43. package/dist/tools/review.js +199 -0
  44. package/dist/utils/index.d.ts +1 -0
  45. package/dist/utils/index.js +1 -0
  46. package/dist/utils/tokens.d.ts +26 -0
  47. package/dist/utils/tokens.js +27 -0
  48. package/package.json +61 -0
  49. package/scripts/install-config.js +51 -0
  50. package/second-opinion.skill.md +34 -0
  51. package/templates/second-opinion.md +54 -0
@@ -0,0 +1,147 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { extractImports, isWithinProject, resolveImportPath, getDependencies, getDependenciesForFiles, } from "./imports.js";
3
+ import * as path from "path";
4
+ import * as fs from "fs";
5
+ import * as os from "os";
6
+ describe("extractImports", () => {
7
+ it("extracts ES6 default imports", () => {
8
+ const code = `import foo from './foo';`;
9
+ expect(extractImports(code)).toContain("./foo");
10
+ });
11
+ it("extracts ES6 named imports", () => {
12
+ const code = `import { bar, baz } from './utils';`;
13
+ expect(extractImports(code)).toContain("./utils");
14
+ });
15
+ it("extracts ES6 namespace imports", () => {
16
+ const code = `import * as helpers from './helpers';`;
17
+ expect(extractImports(code)).toContain("./helpers");
18
+ });
19
+ it("extracts dynamic imports", () => {
20
+ const code = `const module = await import('./dynamic');`;
21
+ expect(extractImports(code)).toContain("./dynamic");
22
+ });
23
+ it("extracts CommonJS requires", () => {
24
+ const code = `const fs = require('fs');`;
25
+ expect(extractImports(code)).toContain("fs");
26
+ });
27
+ it("extracts export from statements", () => {
28
+ const code = `export { foo } from './foo';`;
29
+ expect(extractImports(code)).toContain("./foo");
30
+ });
31
+ it("extracts multiple imports from same file", () => {
32
+ const code = `
33
+ import foo from './foo';
34
+ import { bar } from './bar';
35
+ const baz = require('./baz');
36
+ `;
37
+ const imports = extractImports(code);
38
+ expect(imports).toContain("./foo");
39
+ expect(imports).toContain("./bar");
40
+ expect(imports).toContain("./baz");
41
+ });
42
+ it("deduplicates imports", () => {
43
+ const code = `
44
+ import foo from './foo';
45
+ import { bar } from './foo';
46
+ `;
47
+ const imports = extractImports(code);
48
+ expect(imports.filter((i) => i === "./foo")).toHaveLength(1);
49
+ });
50
+ });
51
+ describe("isWithinProject", () => {
52
+ it("returns true for files inside project", () => {
53
+ expect(isWithinProject("/project/src/file.ts", "/project")).toBe(true);
54
+ });
55
+ it("returns true for project root itself", () => {
56
+ expect(isWithinProject("/project", "/project")).toBe(true);
57
+ });
58
+ it("returns false for files outside project", () => {
59
+ expect(isWithinProject("/other/file.ts", "/project")).toBe(false);
60
+ });
61
+ it("returns false for sibling directories with similar names", () => {
62
+ expect(isWithinProject("/project-other/file.ts", "/project")).toBe(false);
63
+ });
64
+ it("handles path traversal attempts", () => {
65
+ expect(isWithinProject("/project/../other/file.ts", "/project")).toBe(false);
66
+ });
67
+ });
68
+ describe("resolveImportPath", () => {
69
+ // These tests need a temp directory with actual files
70
+ const tmpDir = path.join(os.tmpdir(), "imports-test-" + Date.now());
71
+ // Create test files before tests
72
+ beforeAll(() => {
73
+ fs.mkdirSync(path.join(tmpDir, "src"), { recursive: true });
74
+ fs.writeFileSync(path.join(tmpDir, "src", "foo.ts"), "export const foo = 1;");
75
+ fs.writeFileSync(path.join(tmpDir, "src", "index.ts"), "export * from './foo';");
76
+ fs.mkdirSync(path.join(tmpDir, "src", "utils"), { recursive: true });
77
+ fs.writeFileSync(path.join(tmpDir, "src", "utils", "index.ts"), "export const util = 1;");
78
+ });
79
+ afterAll(() => {
80
+ fs.rmSync(tmpDir, { recursive: true, force: true });
81
+ });
82
+ it("resolves relative imports", () => {
83
+ const result = resolveImportPath("./foo", path.join(tmpDir, "src", "index.ts"), tmpDir);
84
+ expect(result).toBe(path.join(tmpDir, "src", "foo.ts"));
85
+ });
86
+ it("resolves directory imports to index file", () => {
87
+ const result = resolveImportPath("./utils", path.join(tmpDir, "src", "index.ts"), tmpDir);
88
+ expect(result).toBe(path.join(tmpDir, "src", "utils", "index.ts"));
89
+ });
90
+ it("returns null for node_modules imports", () => {
91
+ const result = resolveImportPath("lodash", path.join(tmpDir, "src", "index.ts"), tmpDir);
92
+ expect(result).toBeNull();
93
+ });
94
+ it("returns null for non-existent files", () => {
95
+ const result = resolveImportPath("./nonexistent", path.join(tmpDir, "src", "index.ts"), tmpDir);
96
+ expect(result).toBeNull();
97
+ });
98
+ });
99
+ describe("getDependencies", () => {
100
+ const tmpDir = path.join(os.tmpdir(), "deps-test-" + Date.now());
101
+ beforeAll(() => {
102
+ fs.mkdirSync(path.join(tmpDir, "src"), { recursive: true });
103
+ fs.writeFileSync(path.join(tmpDir, "src", "main.ts"), `import { foo } from './foo';\nimport { bar } from './bar';`);
104
+ fs.writeFileSync(path.join(tmpDir, "src", "foo.ts"), "export const foo = 1;");
105
+ fs.writeFileSync(path.join(tmpDir, "src", "bar.ts"), "export const bar = 2;");
106
+ });
107
+ afterAll(() => {
108
+ fs.rmSync(tmpDir, { recursive: true, force: true });
109
+ });
110
+ it("returns all resolved dependencies", () => {
111
+ const deps = getDependencies(path.join(tmpDir, "src", "main.ts"), tmpDir);
112
+ expect(deps).toContain(path.join(tmpDir, "src", "foo.ts"));
113
+ expect(deps).toContain(path.join(tmpDir, "src", "bar.ts"));
114
+ });
115
+ it("returns empty array for non-existent file", () => {
116
+ const deps = getDependencies(path.join(tmpDir, "nonexistent.ts"), tmpDir);
117
+ expect(deps).toEqual([]);
118
+ });
119
+ });
120
+ describe("getDependenciesForFiles", () => {
121
+ const tmpDir = path.join(os.tmpdir(), "multi-deps-test-" + Date.now());
122
+ beforeAll(() => {
123
+ fs.mkdirSync(path.join(tmpDir, "src"), { recursive: true });
124
+ fs.writeFileSync(path.join(tmpDir, "src", "a.ts"), `import { shared } from './shared';`);
125
+ fs.writeFileSync(path.join(tmpDir, "src", "b.ts"), `import { shared } from './shared';`);
126
+ fs.writeFileSync(path.join(tmpDir, "src", "shared.ts"), "export const shared = 1;");
127
+ });
128
+ afterAll(() => {
129
+ fs.rmSync(tmpDir, { recursive: true, force: true });
130
+ });
131
+ it("collects dependencies from multiple files", () => {
132
+ const deps = getDependenciesForFiles([path.join(tmpDir, "src", "a.ts"), path.join(tmpDir, "src", "b.ts")], tmpDir);
133
+ expect(deps).toContain(path.join(tmpDir, "src", "shared.ts"));
134
+ });
135
+ it("excludes files already in the input list", () => {
136
+ const deps = getDependenciesForFiles([
137
+ path.join(tmpDir, "src", "a.ts"),
138
+ path.join(tmpDir, "src", "shared.ts"),
139
+ ], tmpDir);
140
+ expect(deps).not.toContain(path.join(tmpDir, "src", "shared.ts"));
141
+ });
142
+ it("deduplicates dependencies", () => {
143
+ const deps = getDependenciesForFiles([path.join(tmpDir, "src", "a.ts"), path.join(tmpDir, "src", "b.ts")], tmpDir);
144
+ const sharedCount = deps.filter((d) => d.endsWith("shared.ts")).length;
145
+ expect(sharedCount).toBe(1);
146
+ });
147
+ });
@@ -0,0 +1,6 @@
1
+ export * from "./session.js";
2
+ export * from "./git.js";
3
+ export * from "./imports.js";
4
+ export * from "./tests.js";
5
+ export * from "./types.js";
6
+ export * from "./bundler.js";
@@ -0,0 +1,6 @@
1
+ export * from "./session.js";
2
+ export * from "./git.js";
3
+ export * from "./imports.js";
4
+ export * from "./tests.js";
5
+ export * from "./types.js";
6
+ export * from "./bundler.js";
@@ -0,0 +1,35 @@
1
+ export interface SessionMessage {
2
+ role: "user" | "assistant";
3
+ content: string;
4
+ timestamp: string;
5
+ }
6
+ export interface FileOperation {
7
+ type: "read" | "write" | "edit";
8
+ filePath: string;
9
+ content?: string;
10
+ }
11
+ export interface SessionContext {
12
+ sessionId: string;
13
+ projectPath: string;
14
+ filesRead: string[];
15
+ filesWritten: string[];
16
+ filesEdited: string[];
17
+ fileContents: Map<string, string>;
18
+ conversation: SessionMessage[];
19
+ }
20
+ /**
21
+ * Find the most recent session for a project
22
+ */
23
+ export declare function findLatestSession(projectPath: string): string | null;
24
+ /**
25
+ * Get the path to a session's JSONL file
26
+ */
27
+ export declare function getSessionPath(projectPath: string, sessionId: string): string | null;
28
+ /**
29
+ * Parse a session JSONL file and extract context
30
+ */
31
+ export declare function parseSession(projectPath: string, sessionId: string): SessionContext | null;
32
+ /**
33
+ * Format session context as markdown for the reviewer
34
+ */
35
+ export declare function formatConversationContext(context: SessionContext): string;
@@ -0,0 +1,317 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { getClaudeProjectsDir } from "../config.js";
4
+ /**
5
+ * Convert a project path to the Claude projects directory name format
6
+ * e.g., /Users/noel/w/my-project -> -Users-noel-w-my-project
7
+ */
8
+ function projectPathToDir(projectPath) {
9
+ return projectPath.replace(/\//g, "-");
10
+ }
11
+ /**
12
+ * Resolve persisted content from tool results
13
+ * When tool output is too large, Claude Code saves it to a file and includes a marker
14
+ */
15
+ function resolvePersistedContent(content, projectDir) {
16
+ // Check for the persisted-output marker
17
+ const persistedMatch = content.match(/<persisted-output>[\s\S]*?Full output saved to:\s*([^\s<]+)/);
18
+ if (!persistedMatch) {
19
+ return content;
20
+ }
21
+ const savedPath = persistedMatch[1];
22
+ // Try reading the full content from the saved file
23
+ // The path might be relative to the project dir or absolute
24
+ const candidates = [
25
+ savedPath,
26
+ path.join(projectDir, savedPath),
27
+ path.join(projectDir, "tool-results", path.basename(savedPath)),
28
+ ];
29
+ for (const candidate of candidates) {
30
+ try {
31
+ if (fs.existsSync(candidate)) {
32
+ return fs.readFileSync(candidate, "utf-8");
33
+ }
34
+ }
35
+ catch {
36
+ // Continue to next candidate
37
+ }
38
+ }
39
+ // Couldn't find the persisted file, return original content
40
+ return content;
41
+ }
42
+ /**
43
+ * Find the project directory by scanning all sessions-index.json files
44
+ * and matching by originalPath or projectPath fields
45
+ */
46
+ function findProjectDir(projectPath) {
47
+ const projectsDir = getClaudeProjectsDir();
48
+ // Normalize the project path for comparison
49
+ const normalizedProjectPath = path.normalize(projectPath);
50
+ // First, try the direct mapping (fallback)
51
+ const directDir = path.join(projectsDir, projectPathToDir(projectPath));
52
+ if (fs.existsSync(path.join(directDir, "sessions-index.json"))) {
53
+ return directDir;
54
+ }
55
+ // Scan all subdirectories in the projects dir
56
+ try {
57
+ const entries = fs.readdirSync(projectsDir, { withFileTypes: true });
58
+ for (const entry of entries) {
59
+ if (!entry.isDirectory())
60
+ continue;
61
+ const indexPath = path.join(projectsDir, entry.name, "sessions-index.json");
62
+ if (!fs.existsSync(indexPath))
63
+ continue;
64
+ try {
65
+ const index = JSON.parse(fs.readFileSync(indexPath, "utf-8"));
66
+ // Check originalPath field
67
+ if (index.originalPath) {
68
+ const normalizedOriginal = path.normalize(index.originalPath);
69
+ if (normalizedOriginal === normalizedProjectPath) {
70
+ return path.join(projectsDir, entry.name);
71
+ }
72
+ }
73
+ // Check projectPath in entries
74
+ for (const session of index.entries) {
75
+ if (session.projectPath) {
76
+ const normalizedEntry = path.normalize(session.projectPath);
77
+ if (normalizedEntry === normalizedProjectPath) {
78
+ return path.join(projectsDir, entry.name);
79
+ }
80
+ }
81
+ }
82
+ }
83
+ catch {
84
+ // Skip malformed index files
85
+ }
86
+ }
87
+ }
88
+ catch {
89
+ // If we can't read the projects dir, fall back to direct mapping
90
+ }
91
+ return null;
92
+ }
93
+ /**
94
+ * Find the most recent session for a project
95
+ */
96
+ export function findLatestSession(projectPath) {
97
+ const projectDir = findProjectDir(projectPath);
98
+ if (!projectDir) {
99
+ return null;
100
+ }
101
+ const indexPath = path.join(projectDir, "sessions-index.json");
102
+ if (!fs.existsSync(indexPath)) {
103
+ return null;
104
+ }
105
+ try {
106
+ const index = JSON.parse(fs.readFileSync(indexPath, "utf-8"));
107
+ if (index.entries.length === 0) {
108
+ return null;
109
+ }
110
+ // Sort by modified date, most recent first
111
+ const sorted = [...index.entries].sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime());
112
+ return sorted[0].sessionId;
113
+ }
114
+ catch {
115
+ return null;
116
+ }
117
+ }
118
+ /**
119
+ * Get the path to a session's JSONL file
120
+ */
121
+ export function getSessionPath(projectPath, sessionId) {
122
+ const projectDir = findProjectDir(projectPath);
123
+ if (!projectDir) {
124
+ return null;
125
+ }
126
+ const sessionPath = path.join(projectDir, `${sessionId}.jsonl`);
127
+ if (fs.existsSync(sessionPath)) {
128
+ return sessionPath;
129
+ }
130
+ return null;
131
+ }
132
+ /**
133
+ * Parse a session JSONL file and extract context
134
+ */
135
+ export function parseSession(projectPath, sessionId) {
136
+ const sessionPath = getSessionPath(projectPath, sessionId);
137
+ if (!sessionPath) {
138
+ return null;
139
+ }
140
+ // Get the project directory for resolving persisted content
141
+ const projectDir = path.dirname(sessionPath);
142
+ const context = {
143
+ sessionId,
144
+ projectPath,
145
+ filesRead: [],
146
+ filesWritten: [],
147
+ filesEdited: [],
148
+ fileContents: new Map(),
149
+ conversation: [],
150
+ };
151
+ const fileReadSet = new Set();
152
+ const fileWrittenSet = new Set();
153
+ const fileEditedSet = new Set();
154
+ // Track tool_use IDs to match with results
155
+ const pendingToolUses = new Map();
156
+ const lines = fs.readFileSync(sessionPath, "utf-8").split("\n");
157
+ for (const line of lines) {
158
+ if (!line.trim())
159
+ continue;
160
+ try {
161
+ const entry = JSON.parse(line);
162
+ // Extract user messages
163
+ if (entry.type === "user" && entry.message?.content) {
164
+ const content = typeof entry.message.content === "string"
165
+ ? entry.message.content
166
+ : JSON.stringify(entry.message.content);
167
+ // Skip system/meta messages
168
+ if (!content.startsWith("<local-command") &&
169
+ !content.startsWith("<command-")) {
170
+ context.conversation.push({
171
+ role: "user",
172
+ content: content,
173
+ timestamp: entry.timestamp,
174
+ });
175
+ }
176
+ }
177
+ // Extract assistant messages
178
+ if (entry.type === "assistant" && entry.message?.content) {
179
+ const contents = Array.isArray(entry.message.content)
180
+ ? entry.message.content
181
+ : [entry.message.content];
182
+ for (const block of contents) {
183
+ // Text responses
184
+ if (block.type === "text" && block.text) {
185
+ // Skip API errors
186
+ if (!block.text.startsWith("API Error:")) {
187
+ context.conversation.push({
188
+ role: "assistant",
189
+ content: block.text,
190
+ timestamp: entry.timestamp,
191
+ });
192
+ }
193
+ }
194
+ // Tool uses - track file operations
195
+ if (block.type === "tool_use") {
196
+ const toolName = block.name;
197
+ const input = block.input || {};
198
+ const toolId = block.id;
199
+ if (toolName === "Read" && input.file_path) {
200
+ fileReadSet.add(input.file_path);
201
+ pendingToolUses.set(toolId, {
202
+ type: "read",
203
+ filePath: input.file_path,
204
+ });
205
+ }
206
+ else if (toolName === "Write" && input.file_path) {
207
+ fileWrittenSet.add(input.file_path);
208
+ if (input.content) {
209
+ context.fileContents.set(input.file_path, input.content);
210
+ }
211
+ }
212
+ else if (toolName === "Edit" && input.file_path) {
213
+ fileEditedSet.add(input.file_path);
214
+ }
215
+ }
216
+ // Tool results - capture file contents from Read operations
217
+ if (block.type === "tool_result" && block.tool_use_id) {
218
+ const pending = pendingToolUses.get(block.tool_use_id);
219
+ if (pending && pending.type === "read" && block.content) {
220
+ // Resolve persisted content if the result was saved to a file
221
+ const resolvedContent = resolvePersistedContent(block.content, projectDir);
222
+ // Store the content Claude saw
223
+ context.fileContents.set(pending.filePath, resolvedContent);
224
+ pendingToolUses.delete(block.tool_use_id);
225
+ }
226
+ }
227
+ }
228
+ }
229
+ // Also check for tool_result in the message content directly
230
+ if (entry.message?.content) {
231
+ const contents = Array.isArray(entry.message.content)
232
+ ? entry.message.content
233
+ : [];
234
+ for (const block of contents) {
235
+ if (block.type === "tool_result" && block.tool_use_id) {
236
+ const pending = pendingToolUses.get(block.tool_use_id);
237
+ if (pending && pending.type === "read" && block.content) {
238
+ // Resolve persisted content if the result was saved to a file
239
+ const resolvedContent = resolvePersistedContent(block.content, projectDir);
240
+ context.fileContents.set(pending.filePath, resolvedContent);
241
+ pendingToolUses.delete(block.tool_use_id);
242
+ }
243
+ }
244
+ }
245
+ }
246
+ // Handle top-level tool_result entries (some Claude Code versions use this format)
247
+ if (entry.type === "tool_result" && entry.tool_use_id) {
248
+ const pending = pendingToolUses.get(entry.tool_use_id);
249
+ if (pending && pending.type === "read" && entry.content) {
250
+ const content = typeof entry.content === "string"
251
+ ? entry.content
252
+ : JSON.stringify(entry.content);
253
+ const resolvedContent = resolvePersistedContent(content, projectDir);
254
+ context.fileContents.set(pending.filePath, resolvedContent);
255
+ pendingToolUses.delete(entry.tool_use_id);
256
+ }
257
+ }
258
+ }
259
+ catch {
260
+ // Skip malformed lines
261
+ }
262
+ }
263
+ context.filesRead = Array.from(fileReadSet);
264
+ context.filesWritten = Array.from(fileWrittenSet);
265
+ context.filesEdited = Array.from(fileEditedSet);
266
+ return context;
267
+ }
268
+ /**
269
+ * Remove or condense code blocks from a message to avoid stale code in conversation
270
+ * The Files section contains current code, so we don't need it duplicated here
271
+ */
272
+ function condenseCodeBlocks(content) {
273
+ // Match fenced code blocks (```...```)
274
+ const codeBlockRegex = /```[\s\S]*?```/g;
275
+ // Count code blocks and their total size
276
+ const codeBlocks = content.match(codeBlockRegex) || [];
277
+ const codeSize = codeBlocks.reduce((sum, block) => sum + block.length, 0);
278
+ // If more than 50% of the message is code, it's primarily a code dump
279
+ if (codeSize > content.length * 0.5 && codeSize > 500) {
280
+ // Replace code blocks with placeholder
281
+ return content.replace(codeBlockRegex, "\n[code snippet omitted - see Files section for current code]\n");
282
+ }
283
+ // For smaller code blocks, keep them but truncate if very long
284
+ return content.replace(codeBlockRegex, (block) => {
285
+ if (block.length > 500) {
286
+ const lines = block.split("\n");
287
+ const lang = lines[0]; // ```language
288
+ const preview = lines.slice(1, 6).join("\n"); // First few lines
289
+ return `${lang}\n${preview}\n... [truncated - see Files section]\n\`\`\``;
290
+ }
291
+ return block;
292
+ });
293
+ }
294
+ /**
295
+ * Format session context as markdown for the reviewer
296
+ */
297
+ export function formatConversationContext(context) {
298
+ if (context.conversation.length === 0) {
299
+ return "";
300
+ }
301
+ let output = "## Conversation Context\n\n";
302
+ output +=
303
+ "This is the conversation between the user and Claude that led to these changes.\n";
304
+ output +=
305
+ "*Note: Code snippets in conversation may be outdated. See the Files section for current code.*\n\n";
306
+ for (const msg of context.conversation) {
307
+ const role = msg.role === "user" ? "**User**" : "**Claude**";
308
+ // Condense code blocks to avoid stale code confusion
309
+ let content = condenseCodeBlocks(msg.content);
310
+ // Truncate very long messages
311
+ if (content.length > 2000) {
312
+ content = content.substring(0, 2000) + "\n...(truncated)";
313
+ }
314
+ output += `${role}:\n${content}\n\n`;
315
+ }
316
+ return output;
317
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Find test files that correspond to a given source file
3
+ *
4
+ * Checks common test file patterns:
5
+ * - foo.ts -> foo.test.ts, foo.spec.ts
6
+ * - foo.ts -> __tests__/foo.ts, __tests__/foo.test.ts
7
+ * - src/foo.ts -> tests/foo.test.ts, test/foo.test.ts
8
+ */
9
+ export declare function findTestFiles(filePath: string, projectPath: string): string[];
10
+ /**
11
+ * Find test files for multiple source files
12
+ */
13
+ export declare function findTestFilesForFiles(files: string[], projectPath: string): string[];
@@ -0,0 +1,83 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ /**
4
+ * Find test files that correspond to a given source file
5
+ *
6
+ * Checks common test file patterns:
7
+ * - foo.ts -> foo.test.ts, foo.spec.ts
8
+ * - foo.ts -> __tests__/foo.ts, __tests__/foo.test.ts
9
+ * - src/foo.ts -> tests/foo.test.ts, test/foo.test.ts
10
+ */
11
+ export function findTestFiles(filePath, projectPath) {
12
+ const testFiles = [];
13
+ const ext = path.extname(filePath);
14
+ const baseName = path.basename(filePath, ext);
15
+ const dir = path.dirname(filePath);
16
+ const relativeDir = path.relative(projectPath, dir);
17
+ // Common test extensions
18
+ const testExtensions = [".test" + ext, ".spec" + ext, ext];
19
+ // Pattern 1: Same directory - foo.test.ts, foo.spec.ts
20
+ for (const testExt of [".test" + ext, ".spec" + ext]) {
21
+ const candidate = path.join(dir, baseName + testExt);
22
+ if (fs.existsSync(candidate)) {
23
+ testFiles.push(candidate);
24
+ }
25
+ }
26
+ // Pattern 2: __tests__ directory in same location
27
+ const testsDir = path.join(dir, "__tests__");
28
+ if (fs.existsSync(testsDir)) {
29
+ for (const testExt of testExtensions) {
30
+ const candidate = path.join(testsDir, baseName + testExt);
31
+ if (fs.existsSync(candidate)) {
32
+ testFiles.push(candidate);
33
+ }
34
+ }
35
+ }
36
+ // Pattern 3: Top-level tests/ or test/ directory mirroring src structure
37
+ const testRootDirs = ["tests", "test", "__tests__"];
38
+ for (const testRoot of testRootDirs) {
39
+ const testRootPath = path.join(projectPath, testRoot);
40
+ if (fs.existsSync(testRootPath)) {
41
+ // Try to mirror the relative path
42
+ // e.g., src/auth/login.ts -> tests/auth/login.test.ts
43
+ let mirrorPath = relativeDir;
44
+ if (mirrorPath.startsWith("src/") || mirrorPath.startsWith("src\\")) {
45
+ mirrorPath = mirrorPath.slice(4);
46
+ }
47
+ const mirrorDir = path.join(testRootPath, mirrorPath);
48
+ if (fs.existsSync(mirrorDir)) {
49
+ for (const testExt of testExtensions) {
50
+ const candidate = path.join(mirrorDir, baseName + testExt);
51
+ if (fs.existsSync(candidate)) {
52
+ testFiles.push(candidate);
53
+ }
54
+ }
55
+ }
56
+ // Also try without mirroring - just tests/foo.test.ts
57
+ for (const testExt of testExtensions) {
58
+ const candidate = path.join(testRootPath, baseName + testExt);
59
+ if (fs.existsSync(candidate)) {
60
+ testFiles.push(candidate);
61
+ }
62
+ }
63
+ }
64
+ }
65
+ // Deduplicate
66
+ return [...new Set(testFiles)];
67
+ }
68
+ /**
69
+ * Find test files for multiple source files
70
+ */
71
+ export function findTestFilesForFiles(files, projectPath) {
72
+ const allTests = new Set();
73
+ for (const file of files) {
74
+ const tests = findTestFiles(file, projectPath);
75
+ for (const test of tests) {
76
+ // Don't include if it's already in our file list
77
+ if (!files.includes(test)) {
78
+ allTests.add(test);
79
+ }
80
+ }
81
+ }
82
+ return Array.from(allTests);
83
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Extract type-related imports from file content
3
+ */
4
+ export declare function extractTypeImports(content: string): string[];
5
+ /**
6
+ * Check if a file path looks like a type definition file
7
+ */
8
+ export declare function isTypeFile(filePath: string): boolean;
9
+ /**
10
+ * Find all type definition files in a project
11
+ */
12
+ export declare function findAllTypeFiles(projectPath: string): Promise<string[]>;
13
+ /**
14
+ * Find type files that are imported by the given files
15
+ */
16
+ export declare function findTypeFilesForFiles(files: string[], projectPath: string): Promise<string[]>;