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.
- package/README.md +323 -0
- package/dist/config.d.ts +31 -0
- package/dist/config.js +84 -0
- package/dist/context/bundler.d.ts +51 -0
- package/dist/context/bundler.js +481 -0
- package/dist/context/bundler.test.d.ts +1 -0
- package/dist/context/bundler.test.js +275 -0
- package/dist/context/git.d.ts +21 -0
- package/dist/context/git.js +102 -0
- package/dist/context/imports.d.ts +43 -0
- package/dist/context/imports.js +197 -0
- package/dist/context/imports.test.d.ts +1 -0
- package/dist/context/imports.test.js +147 -0
- package/dist/context/index.d.ts +6 -0
- package/dist/context/index.js +6 -0
- package/dist/context/session.d.ts +35 -0
- package/dist/context/session.js +317 -0
- package/dist/context/tests.d.ts +13 -0
- package/dist/context/tests.js +83 -0
- package/dist/context/types.d.ts +16 -0
- package/dist/context/types.js +100 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +6 -0
- package/dist/output/writer.d.ts +34 -0
- package/dist/output/writer.js +162 -0
- package/dist/output/writer.test.d.ts +1 -0
- package/dist/output/writer.test.js +175 -0
- package/dist/providers/base.d.ts +24 -0
- package/dist/providers/base.js +77 -0
- package/dist/providers/base.test.d.ts +1 -0
- package/dist/providers/base.test.js +91 -0
- package/dist/providers/gemini.d.ts +8 -0
- package/dist/providers/gemini.js +43 -0
- package/dist/providers/index.d.ts +8 -0
- package/dist/providers/index.js +31 -0
- package/dist/providers/openai.d.ts +8 -0
- package/dist/providers/openai.js +39 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +181 -0
- package/dist/test-utils.d.ts +71 -0
- package/dist/test-utils.js +136 -0
- package/dist/tools/review.d.ts +76 -0
- package/dist/tools/review.js +199 -0
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.js +1 -0
- package/dist/utils/tokens.d.ts +26 -0
- package/dist/utils/tokens.js +27 -0
- package/package.json +61 -0
- package/scripts/install-config.js +51 -0
- package/second-opinion.skill.md +34 -0
- 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,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[]>;
|