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,100 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import { glob } from "glob";
|
|
3
|
+
import { resolveImportPath } from "./imports.js";
|
|
4
|
+
// Patterns for type imports
|
|
5
|
+
const TYPE_IMPORT_PATTERNS = [
|
|
6
|
+
// import type { X } from 'y'
|
|
7
|
+
/import\s+type\s+\{[^}]+\}\s+from\s+['"]([^'"]+)['"]/g,
|
|
8
|
+
// import { type X } from 'y'
|
|
9
|
+
/import\s+\{[^}]*type\s+\w+[^}]*\}\s+from\s+['"]([^'"]+)['"]/g,
|
|
10
|
+
// Regular imports from type-ish locations
|
|
11
|
+
/import\s+(?:[\w*{}\s,]+\s+from\s+)?['"]([^'"]*(?:types?|interfaces?|models?)[^'"]*)['"]/gi,
|
|
12
|
+
];
|
|
13
|
+
// Common type file/directory patterns
|
|
14
|
+
const TYPE_LOCATIONS = [
|
|
15
|
+
"types",
|
|
16
|
+
"types/**",
|
|
17
|
+
"interfaces",
|
|
18
|
+
"interfaces/**",
|
|
19
|
+
"models",
|
|
20
|
+
"models/**",
|
|
21
|
+
"@types",
|
|
22
|
+
"@types/**",
|
|
23
|
+
"src/types",
|
|
24
|
+
"src/types/**",
|
|
25
|
+
"src/interfaces",
|
|
26
|
+
"src/interfaces/**",
|
|
27
|
+
"**/*.d.ts",
|
|
28
|
+
];
|
|
29
|
+
/**
|
|
30
|
+
* Extract type-related imports from file content
|
|
31
|
+
*/
|
|
32
|
+
export function extractTypeImports(content) {
|
|
33
|
+
const typeImports = new Set();
|
|
34
|
+
for (const pattern of TYPE_IMPORT_PATTERNS) {
|
|
35
|
+
pattern.lastIndex = 0;
|
|
36
|
+
let match;
|
|
37
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
38
|
+
typeImports.add(match[1]);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return Array.from(typeImports);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Check if a file path looks like a type definition file
|
|
45
|
+
*/
|
|
46
|
+
export function isTypeFile(filePath) {
|
|
47
|
+
const normalized = filePath.toLowerCase();
|
|
48
|
+
return (normalized.endsWith(".d.ts") ||
|
|
49
|
+
normalized.includes("/types/") ||
|
|
50
|
+
normalized.includes("/interfaces/") ||
|
|
51
|
+
normalized.includes("/models/") ||
|
|
52
|
+
normalized.includes("/@types/") ||
|
|
53
|
+
/\/types?\.(ts|js)$/.test(normalized) ||
|
|
54
|
+
/\/interfaces?\.(ts|js)$/.test(normalized));
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Find all type definition files in a project
|
|
58
|
+
*/
|
|
59
|
+
export async function findAllTypeFiles(projectPath) {
|
|
60
|
+
const typeFiles = await glob(TYPE_LOCATIONS, {
|
|
61
|
+
cwd: projectPath,
|
|
62
|
+
absolute: true,
|
|
63
|
+
nodir: true,
|
|
64
|
+
ignore: ["**/node_modules/**", "**/dist/**", "**/build/**"],
|
|
65
|
+
});
|
|
66
|
+
return typeFiles.filter((f) => /\.(ts|tsx|js|jsx)$/.test(f));
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Find type files that are imported by the given files
|
|
70
|
+
*/
|
|
71
|
+
export async function findTypeFilesForFiles(files, projectPath) {
|
|
72
|
+
const typeFiles = new Set();
|
|
73
|
+
// Get all type files in the project
|
|
74
|
+
const allTypes = await findAllTypeFiles(projectPath);
|
|
75
|
+
const typeFileSet = new Set(allTypes);
|
|
76
|
+
for (const file of files) {
|
|
77
|
+
if (!fs.existsSync(file))
|
|
78
|
+
continue;
|
|
79
|
+
try {
|
|
80
|
+
const content = fs.readFileSync(file, "utf-8");
|
|
81
|
+
const typeImports = extractTypeImports(content);
|
|
82
|
+
// Also check regular imports that resolve to type files
|
|
83
|
+
const allImportPattern = /import\s+(?:[\w*{}\s,]+\s+from\s+)?['"]([^'"]+)['"]/g;
|
|
84
|
+
let match;
|
|
85
|
+
while ((match = allImportPattern.exec(content)) !== null) {
|
|
86
|
+
const importPath = match[1];
|
|
87
|
+
const resolved = resolveImportPath(importPath, file, projectPath);
|
|
88
|
+
if (resolved && (typeFileSet.has(resolved) || isTypeFile(resolved))) {
|
|
89
|
+
if (!files.includes(resolved)) {
|
|
90
|
+
typeFiles.add(resolved);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// Skip files that can't be read
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return Array.from(typeFiles);
|
|
100
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export interface ReviewMetadata {
|
|
2
|
+
sessionName: string;
|
|
3
|
+
provider: string;
|
|
4
|
+
model: string;
|
|
5
|
+
timestamp: string;
|
|
6
|
+
filesReviewed: string[];
|
|
7
|
+
tokensUsed?: number;
|
|
8
|
+
task?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface EgressSummary {
|
|
11
|
+
projectFilesSent: number;
|
|
12
|
+
projectFilePaths: string[];
|
|
13
|
+
externalFilesSent: number;
|
|
14
|
+
externalFilePaths: string[];
|
|
15
|
+
externalLocations: string[];
|
|
16
|
+
blockedFiles: {
|
|
17
|
+
path: string;
|
|
18
|
+
reason: string;
|
|
19
|
+
}[];
|
|
20
|
+
provider: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Write a review/response to a markdown file
|
|
24
|
+
*/
|
|
25
|
+
export declare function writeReview(projectPath: string, reviewsDir: string, metadata: ReviewMetadata, review: string): string;
|
|
26
|
+
/**
|
|
27
|
+
* Derive a session name from the conversation context
|
|
28
|
+
*/
|
|
29
|
+
export declare function deriveSessionName(conversationContext: string, fallback?: string): string;
|
|
30
|
+
/**
|
|
31
|
+
* Write an egress manifest JSON file for audit trail
|
|
32
|
+
* This records exactly what files were sent to the external LLM
|
|
33
|
+
*/
|
|
34
|
+
export declare function writeEgressManifest(projectPath: string, reviewsDir: string, metadata: ReviewMetadata, egressData: EgressSummary): string;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
/**
|
|
4
|
+
* Generate a slug from a session name
|
|
5
|
+
*/
|
|
6
|
+
function slugify(text) {
|
|
7
|
+
return text
|
|
8
|
+
.toLowerCase()
|
|
9
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
10
|
+
.replace(/^-+|-+$/g, "")
|
|
11
|
+
.substring(0, 50);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Validate that reviewsDir is safe (relative, no traversal)
|
|
15
|
+
*/
|
|
16
|
+
function validateReviewsDir(reviewsDir) {
|
|
17
|
+
// Must be relative
|
|
18
|
+
if (path.isAbsolute(reviewsDir)) {
|
|
19
|
+
throw new Error(`reviewsDir must be relative, got: ${reviewsDir}`);
|
|
20
|
+
}
|
|
21
|
+
// Must not contain path traversal
|
|
22
|
+
if (reviewsDir.includes("..")) {
|
|
23
|
+
throw new Error(`reviewsDir contains path traversal: ${reviewsDir}`);
|
|
24
|
+
}
|
|
25
|
+
// Normalize and verify no traversal after normalization
|
|
26
|
+
const normalized = path.normalize(reviewsDir);
|
|
27
|
+
if (normalized.startsWith("..")) {
|
|
28
|
+
throw new Error(`reviewsDir resolves outside project: ${reviewsDir}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Derive a short task slug from a task description
|
|
33
|
+
*/
|
|
34
|
+
function deriveTaskSlug(task) {
|
|
35
|
+
// Take first few meaningful words
|
|
36
|
+
const words = task
|
|
37
|
+
.toLowerCase()
|
|
38
|
+
.replace(/[^a-z0-9\s]/g, " ")
|
|
39
|
+
.split(/\s+/)
|
|
40
|
+
.filter((w) => w.length > 2)
|
|
41
|
+
.slice(0, 4);
|
|
42
|
+
if (words.length === 0) {
|
|
43
|
+
return "task";
|
|
44
|
+
}
|
|
45
|
+
return words.join("-").substring(0, 30);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Generate base filename (without extension) for output files
|
|
49
|
+
*/
|
|
50
|
+
function generateBaseFilename(metadata) {
|
|
51
|
+
const sessionSlug = slugify(metadata.sessionName);
|
|
52
|
+
if (metadata.task) {
|
|
53
|
+
const taskSlug = deriveTaskSlug(metadata.task);
|
|
54
|
+
return `${sessionSlug}.${metadata.provider}.${taskSlug}`;
|
|
55
|
+
}
|
|
56
|
+
return `${sessionSlug}.${metadata.provider}.review`;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Ensure output directory exists and return its path
|
|
60
|
+
*/
|
|
61
|
+
function ensureOutputDir(projectPath, reviewsDir) {
|
|
62
|
+
validateReviewsDir(reviewsDir);
|
|
63
|
+
const outputDirPath = path.join(projectPath, reviewsDir);
|
|
64
|
+
if (!fs.existsSync(outputDirPath)) {
|
|
65
|
+
fs.mkdirSync(outputDirPath, { recursive: true });
|
|
66
|
+
}
|
|
67
|
+
return outputDirPath;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Write a review/response to a markdown file
|
|
71
|
+
*/
|
|
72
|
+
export function writeReview(projectPath, reviewsDir, metadata, review) {
|
|
73
|
+
const outputDirPath = ensureOutputDir(projectPath, reviewsDir);
|
|
74
|
+
const filePath = path.join(outputDirPath, `${generateBaseFilename(metadata)}.md`);
|
|
75
|
+
// Build the document
|
|
76
|
+
const lines = [];
|
|
77
|
+
// Header based on task or review
|
|
78
|
+
if (metadata.task) {
|
|
79
|
+
lines.push(`# Second Opinion - ${metadata.sessionName}`);
|
|
80
|
+
lines.push("");
|
|
81
|
+
lines.push(`**Task:** ${metadata.task.substring(0, 200)}${metadata.task.length > 200 ? "..." : ""}`);
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
lines.push(`# Code Review - ${metadata.sessionName}`);
|
|
85
|
+
}
|
|
86
|
+
lines.push("");
|
|
87
|
+
lines.push(`**Provider:** ${metadata.provider} (${metadata.model})`);
|
|
88
|
+
lines.push(`**Date:** ${metadata.timestamp}`);
|
|
89
|
+
if (metadata.tokensUsed) {
|
|
90
|
+
lines.push(`**Tokens Used:** ${metadata.tokensUsed.toLocaleString()}`);
|
|
91
|
+
}
|
|
92
|
+
lines.push("");
|
|
93
|
+
if (metadata.filesReviewed.length > 0) {
|
|
94
|
+
lines.push("## Files Analyzed");
|
|
95
|
+
lines.push("");
|
|
96
|
+
for (const file of metadata.filesReviewed) {
|
|
97
|
+
const relativePath = path.relative(projectPath, file);
|
|
98
|
+
lines.push(`- ${relativePath}`);
|
|
99
|
+
}
|
|
100
|
+
lines.push("");
|
|
101
|
+
}
|
|
102
|
+
lines.push("---");
|
|
103
|
+
lines.push("");
|
|
104
|
+
lines.push(review);
|
|
105
|
+
lines.push("");
|
|
106
|
+
lines.push("---");
|
|
107
|
+
lines.push("*Generated by second-opinion MCP server*");
|
|
108
|
+
// Write the file
|
|
109
|
+
fs.writeFileSync(filePath, lines.join("\n"));
|
|
110
|
+
return filePath;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Derive a session name from the conversation context
|
|
114
|
+
*/
|
|
115
|
+
export function deriveSessionName(conversationContext, fallback = "review") {
|
|
116
|
+
// Try to extract from the first user message
|
|
117
|
+
const userMatch = conversationContext.match(/\*\*User\*\*:\n(.+?)(?:\n|$)/);
|
|
118
|
+
if (userMatch) {
|
|
119
|
+
const firstMessage = userMatch[1].trim();
|
|
120
|
+
// Take first 50 chars, clean up
|
|
121
|
+
const name = firstMessage
|
|
122
|
+
.substring(0, 50)
|
|
123
|
+
.replace(/[^a-zA-Z0-9\s-]/g, "")
|
|
124
|
+
.trim();
|
|
125
|
+
if (name.length > 3) {
|
|
126
|
+
return name;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return fallback;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Write an egress manifest JSON file for audit trail
|
|
133
|
+
* This records exactly what files were sent to the external LLM
|
|
134
|
+
*/
|
|
135
|
+
export function writeEgressManifest(projectPath, reviewsDir, metadata, egressData) {
|
|
136
|
+
const outputDirPath = ensureOutputDir(projectPath, reviewsDir);
|
|
137
|
+
const filePath = path.join(outputDirPath, `${generateBaseFilename(metadata)}.egress.json`);
|
|
138
|
+
// Build the manifest
|
|
139
|
+
const manifest = {
|
|
140
|
+
timestamp: metadata.timestamp,
|
|
141
|
+
provider: egressData.provider,
|
|
142
|
+
model: metadata.model,
|
|
143
|
+
sessionName: metadata.sessionName,
|
|
144
|
+
task: metadata.task || null,
|
|
145
|
+
egress: {
|
|
146
|
+
projectFiles: {
|
|
147
|
+
count: egressData.projectFilesSent,
|
|
148
|
+
paths: egressData.projectFilePaths.map((p) => path.relative(projectPath, p)),
|
|
149
|
+
},
|
|
150
|
+
externalFiles: {
|
|
151
|
+
count: egressData.externalFilesSent,
|
|
152
|
+
paths: egressData.externalFilePaths,
|
|
153
|
+
locations: egressData.externalLocations,
|
|
154
|
+
},
|
|
155
|
+
blockedFiles: egressData.blockedFiles,
|
|
156
|
+
totalFilesSent: egressData.projectFilesSent + egressData.externalFilesSent,
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
// Write the file
|
|
160
|
+
fs.writeFileSync(filePath, JSON.stringify(manifest, null, 2));
|
|
161
|
+
return filePath;
|
|
162
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import * as os from "os";
|
|
5
|
+
import { writeReview, writeEgressManifest, deriveSessionName, } from "./writer.js";
|
|
6
|
+
describe("deriveSessionName", () => {
|
|
7
|
+
it("extracts name from first user message", () => {
|
|
8
|
+
const context = "## Conversation Context\n\n**User**:\nFix the login bug\n\n**Claude**:\nI'll look into it.";
|
|
9
|
+
expect(deriveSessionName(context)).toBe("Fix the login bug");
|
|
10
|
+
});
|
|
11
|
+
it("truncates long messages", () => {
|
|
12
|
+
const longMessage = "A".repeat(100);
|
|
13
|
+
const context = `**User**:\n${longMessage}\n`;
|
|
14
|
+
const result = deriveSessionName(context);
|
|
15
|
+
expect(result.length).toBeLessThanOrEqual(50);
|
|
16
|
+
});
|
|
17
|
+
it("returns fallback for empty context", () => {
|
|
18
|
+
expect(deriveSessionName("")).toBe("review");
|
|
19
|
+
expect(deriveSessionName("", "custom-fallback")).toBe("custom-fallback");
|
|
20
|
+
});
|
|
21
|
+
it("returns fallback for context without user message", () => {
|
|
22
|
+
const context = "**Claude**:\nHere's the review.";
|
|
23
|
+
expect(deriveSessionName(context)).toBe("review");
|
|
24
|
+
});
|
|
25
|
+
it("strips special characters from name", () => {
|
|
26
|
+
const context = "**User**:\nFix the `bug` in [auth]!\n";
|
|
27
|
+
const result = deriveSessionName(context);
|
|
28
|
+
expect(result).not.toContain("`");
|
|
29
|
+
expect(result).not.toContain("[");
|
|
30
|
+
expect(result).not.toContain("!");
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
describe("writeReview", () => {
|
|
34
|
+
const tmpDir = path.join(os.tmpdir(), "writer-test-" + Date.now());
|
|
35
|
+
beforeAll(() => {
|
|
36
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
37
|
+
});
|
|
38
|
+
afterAll(() => {
|
|
39
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
40
|
+
});
|
|
41
|
+
it("creates review file with correct filename for review", () => {
|
|
42
|
+
const metadata = {
|
|
43
|
+
sessionName: "Fix Login Bug",
|
|
44
|
+
provider: "gemini",
|
|
45
|
+
model: "gemini-2.0-flash-exp",
|
|
46
|
+
timestamp: "2024-01-01T00:00:00Z",
|
|
47
|
+
filesReviewed: [],
|
|
48
|
+
};
|
|
49
|
+
const filePath = writeReview(tmpDir, "reviews", metadata, "# Review\nLooks good!");
|
|
50
|
+
expect(fs.existsSync(filePath)).toBe(true);
|
|
51
|
+
expect(filePath).toContain("fix-login-bug.gemini.review.md");
|
|
52
|
+
});
|
|
53
|
+
it("creates review file with task slug when task provided", () => {
|
|
54
|
+
const metadata = {
|
|
55
|
+
sessionName: "Auth Refactor",
|
|
56
|
+
provider: "openai",
|
|
57
|
+
model: "gpt-4o",
|
|
58
|
+
timestamp: "2024-01-01T00:00:00Z",
|
|
59
|
+
filesReviewed: [],
|
|
60
|
+
task: "Check for security vulnerabilities in the authentication flow",
|
|
61
|
+
};
|
|
62
|
+
const filePath = writeReview(tmpDir, "reviews", metadata, "# Analysis\nNo issues found.");
|
|
63
|
+
expect(filePath).toContain("auth-refactor.openai.check-for-security");
|
|
64
|
+
expect(filePath.endsWith(".md")).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
it("creates output directory if it doesn't exist", () => {
|
|
67
|
+
const metadata = {
|
|
68
|
+
sessionName: "Test",
|
|
69
|
+
provider: "gemini",
|
|
70
|
+
model: "gemini-2.0-flash-exp",
|
|
71
|
+
timestamp: "2024-01-01T00:00:00Z",
|
|
72
|
+
filesReviewed: [],
|
|
73
|
+
};
|
|
74
|
+
const filePath = writeReview(tmpDir, "new-dir", metadata, "Review content");
|
|
75
|
+
expect(fs.existsSync(path.join(tmpDir, "new-dir"))).toBe(true);
|
|
76
|
+
expect(fs.existsSync(filePath)).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
it("includes metadata in review content", () => {
|
|
79
|
+
const metadata = {
|
|
80
|
+
sessionName: "Metadata Test",
|
|
81
|
+
provider: "gemini",
|
|
82
|
+
model: "gemini-2.0-flash-exp",
|
|
83
|
+
timestamp: "2024-01-01T00:00:00Z",
|
|
84
|
+
filesReviewed: [path.join(tmpDir, "src", "file.ts")],
|
|
85
|
+
tokensUsed: 1000,
|
|
86
|
+
};
|
|
87
|
+
const filePath = writeReview(tmpDir, "reviews", metadata, "Review body");
|
|
88
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
89
|
+
expect(content).toContain("**Provider:** gemini (gemini-2.0-flash-exp)");
|
|
90
|
+
expect(content).toContain("**Date:** 2024-01-01T00:00:00Z");
|
|
91
|
+
expect(content).toContain("**Tokens Used:** 1,000");
|
|
92
|
+
expect(content).toContain("Review body");
|
|
93
|
+
});
|
|
94
|
+
it("throws on absolute reviewsDir", () => {
|
|
95
|
+
const metadata = {
|
|
96
|
+
sessionName: "Test",
|
|
97
|
+
provider: "gemini",
|
|
98
|
+
model: "gemini-2.0-flash-exp",
|
|
99
|
+
timestamp: "2024-01-01T00:00:00Z",
|
|
100
|
+
filesReviewed: [],
|
|
101
|
+
};
|
|
102
|
+
expect(() => writeReview(tmpDir, "/absolute/path", metadata, "content")).toThrow("reviewsDir must be relative");
|
|
103
|
+
});
|
|
104
|
+
it("throws on path traversal in reviewsDir", () => {
|
|
105
|
+
const metadata = {
|
|
106
|
+
sessionName: "Test",
|
|
107
|
+
provider: "gemini",
|
|
108
|
+
model: "gemini-2.0-flash-exp",
|
|
109
|
+
timestamp: "2024-01-01T00:00:00Z",
|
|
110
|
+
filesReviewed: [],
|
|
111
|
+
};
|
|
112
|
+
expect(() => writeReview(tmpDir, "../escape", metadata, "content")).toThrow("path traversal");
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
describe("writeEgressManifest", () => {
|
|
116
|
+
const tmpDir = path.join(os.tmpdir(), "egress-test-" + Date.now());
|
|
117
|
+
beforeAll(() => {
|
|
118
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
119
|
+
});
|
|
120
|
+
afterAll(() => {
|
|
121
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
122
|
+
});
|
|
123
|
+
it("creates egress manifest with correct structure", () => {
|
|
124
|
+
const metadata = {
|
|
125
|
+
sessionName: "Egress Test",
|
|
126
|
+
provider: "gemini",
|
|
127
|
+
model: "gemini-2.0-flash-exp",
|
|
128
|
+
timestamp: "2024-01-01T00:00:00Z",
|
|
129
|
+
filesReviewed: [],
|
|
130
|
+
};
|
|
131
|
+
const egressData = {
|
|
132
|
+
projectFilesSent: 5,
|
|
133
|
+
projectFilePaths: [
|
|
134
|
+
path.join(tmpDir, "src/a.ts"),
|
|
135
|
+
path.join(tmpDir, "src/b.ts"),
|
|
136
|
+
],
|
|
137
|
+
externalFilesSent: 1,
|
|
138
|
+
externalFilePaths: ["/external/file.ts"],
|
|
139
|
+
externalLocations: ["/external"],
|
|
140
|
+
blockedFiles: [{ path: "/blocked/secret.ts", reason: "sensitive_path" }],
|
|
141
|
+
provider: "gemini",
|
|
142
|
+
};
|
|
143
|
+
const filePath = writeEgressManifest(tmpDir, "reviews", metadata, egressData);
|
|
144
|
+
expect(filePath).toContain("egress-test.gemini.review.egress.json");
|
|
145
|
+
const content = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
146
|
+
expect(content.timestamp).toBe("2024-01-01T00:00:00Z");
|
|
147
|
+
expect(content.provider).toBe("gemini");
|
|
148
|
+
expect(content.egress.projectFiles.count).toBe(5);
|
|
149
|
+
expect(content.egress.externalFiles.count).toBe(1);
|
|
150
|
+
expect(content.egress.blockedFiles).toHaveLength(1);
|
|
151
|
+
expect(content.egress.totalFilesSent).toBe(6);
|
|
152
|
+
});
|
|
153
|
+
it("relativizes project file paths", () => {
|
|
154
|
+
const metadata = {
|
|
155
|
+
sessionName: "Path Test",
|
|
156
|
+
provider: "gemini",
|
|
157
|
+
model: "gemini-2.0-flash-exp",
|
|
158
|
+
timestamp: "2024-01-01T00:00:00Z",
|
|
159
|
+
filesReviewed: [],
|
|
160
|
+
};
|
|
161
|
+
const egressData = {
|
|
162
|
+
projectFilesSent: 1,
|
|
163
|
+
projectFilePaths: [path.join(tmpDir, "src", "file.ts")],
|
|
164
|
+
externalFilesSent: 0,
|
|
165
|
+
externalFilePaths: [],
|
|
166
|
+
externalLocations: [],
|
|
167
|
+
blockedFiles: [],
|
|
168
|
+
provider: "gemini",
|
|
169
|
+
};
|
|
170
|
+
const filePath = writeEgressManifest(tmpDir, "reviews", metadata, egressData);
|
|
171
|
+
const content = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
172
|
+
// Should be relative path, not absolute
|
|
173
|
+
expect(content.egress.projectFiles.paths[0]).toBe(path.join("src", "file.ts"));
|
|
174
|
+
});
|
|
175
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export interface ReviewRequest {
|
|
2
|
+
instructions: string;
|
|
3
|
+
context: string;
|
|
4
|
+
task?: string;
|
|
5
|
+
focusAreas?: string[];
|
|
6
|
+
customPrompt?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface ReviewResponse {
|
|
9
|
+
review: string;
|
|
10
|
+
model: string;
|
|
11
|
+
tokensUsed?: number;
|
|
12
|
+
}
|
|
13
|
+
export interface ReviewProvider {
|
|
14
|
+
name: string;
|
|
15
|
+
review(request: ReviewRequest): Promise<ReviewResponse>;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Get the system prompt based on whether a custom task is provided
|
|
19
|
+
*/
|
|
20
|
+
export declare function getSystemPrompt(hasTask: boolean): string;
|
|
21
|
+
/**
|
|
22
|
+
* Build the full prompt for the LLM
|
|
23
|
+
*/
|
|
24
|
+
export declare function buildReviewPrompt(request: ReviewRequest): string;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Get the system prompt based on whether a custom task is provided
|
|
3
|
+
*/
|
|
4
|
+
export function getSystemPrompt(hasTask) {
|
|
5
|
+
return hasTask
|
|
6
|
+
? "You are an expert software engineer. Complete the requested task thoroughly and provide clear, actionable output."
|
|
7
|
+
: "You are an expert software engineer performing a code review. Be thorough, constructive, and actionable.";
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Build the full prompt for the LLM
|
|
11
|
+
*/
|
|
12
|
+
export function buildReviewPrompt(request) {
|
|
13
|
+
const parts = [];
|
|
14
|
+
// When a task is provided, it becomes the primary objective
|
|
15
|
+
if (request.task) {
|
|
16
|
+
parts.push("# Task");
|
|
17
|
+
parts.push("");
|
|
18
|
+
parts.push(request.task);
|
|
19
|
+
parts.push("");
|
|
20
|
+
// Focus areas if specified
|
|
21
|
+
if (request.focusAreas && request.focusAreas.length > 0) {
|
|
22
|
+
parts.push("## Focus Areas");
|
|
23
|
+
parts.push("");
|
|
24
|
+
for (const area of request.focusAreas) {
|
|
25
|
+
parts.push(`- ${area}`);
|
|
26
|
+
}
|
|
27
|
+
parts.push("");
|
|
28
|
+
}
|
|
29
|
+
// Additional instructions if specified
|
|
30
|
+
if (request.customPrompt) {
|
|
31
|
+
parts.push("## Additional Instructions");
|
|
32
|
+
parts.push("");
|
|
33
|
+
parts.push(request.customPrompt);
|
|
34
|
+
parts.push("");
|
|
35
|
+
}
|
|
36
|
+
// Include instructions as reference material
|
|
37
|
+
if (request.instructions) {
|
|
38
|
+
parts.push("---");
|
|
39
|
+
parts.push("");
|
|
40
|
+
parts.push("## Reference Instructions");
|
|
41
|
+
parts.push("");
|
|
42
|
+
parts.push("*Use the following instructions as reference where relevant to your task:*");
|
|
43
|
+
parts.push("");
|
|
44
|
+
parts.push(request.instructions);
|
|
45
|
+
parts.push("");
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
// Default: code review mode
|
|
50
|
+
parts.push(request.instructions);
|
|
51
|
+
parts.push("");
|
|
52
|
+
// Focus areas if specified
|
|
53
|
+
if (request.focusAreas && request.focusAreas.length > 0) {
|
|
54
|
+
parts.push("## Specific Focus Areas for This Review");
|
|
55
|
+
parts.push("");
|
|
56
|
+
for (const area of request.focusAreas) {
|
|
57
|
+
parts.push(`- ${area}`);
|
|
58
|
+
}
|
|
59
|
+
parts.push("");
|
|
60
|
+
}
|
|
61
|
+
// Custom prompt if specified (legacy support)
|
|
62
|
+
if (request.customPrompt) {
|
|
63
|
+
parts.push("## Additional Instructions");
|
|
64
|
+
parts.push("");
|
|
65
|
+
parts.push(request.customPrompt);
|
|
66
|
+
parts.push("");
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// Separator
|
|
70
|
+
parts.push("---");
|
|
71
|
+
parts.push("");
|
|
72
|
+
parts.push("# Code Context");
|
|
73
|
+
parts.push("");
|
|
74
|
+
// The actual context
|
|
75
|
+
parts.push(request.context);
|
|
76
|
+
return parts.join("\n");
|
|
77
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { buildReviewPrompt, getSystemPrompt } from "./base.js";
|
|
3
|
+
describe("getSystemPrompt", () => {
|
|
4
|
+
it("returns task prompt when hasTask is true", () => {
|
|
5
|
+
const prompt = getSystemPrompt(true);
|
|
6
|
+
expect(prompt).toContain("Complete the requested task");
|
|
7
|
+
expect(prompt).not.toContain("code review");
|
|
8
|
+
});
|
|
9
|
+
it("returns review prompt when hasTask is false", () => {
|
|
10
|
+
const prompt = getSystemPrompt(false);
|
|
11
|
+
expect(prompt).toContain("code review");
|
|
12
|
+
expect(prompt).toContain("constructive");
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
describe("buildReviewPrompt", () => {
|
|
16
|
+
it("builds prompt with task as primary objective", () => {
|
|
17
|
+
const request = {
|
|
18
|
+
instructions: "Review guidelines here",
|
|
19
|
+
context: "# Code\n```ts\nconst x = 1;\n```",
|
|
20
|
+
task: "Analyze security vulnerabilities",
|
|
21
|
+
};
|
|
22
|
+
const prompt = buildReviewPrompt(request);
|
|
23
|
+
expect(prompt).toContain("# Task");
|
|
24
|
+
expect(prompt).toContain("Analyze security vulnerabilities");
|
|
25
|
+
expect(prompt).toContain("## Reference Instructions");
|
|
26
|
+
expect(prompt).toContain("# Code Context");
|
|
27
|
+
expect(prompt).toContain("const x = 1;");
|
|
28
|
+
});
|
|
29
|
+
it("builds prompt in review mode when no task", () => {
|
|
30
|
+
const request = {
|
|
31
|
+
instructions: "Review guidelines here",
|
|
32
|
+
context: "# Code\n```ts\nconst x = 1;\n```",
|
|
33
|
+
};
|
|
34
|
+
const prompt = buildReviewPrompt(request);
|
|
35
|
+
expect(prompt).not.toContain("# Task");
|
|
36
|
+
expect(prompt).toContain("Review guidelines here");
|
|
37
|
+
expect(prompt).toContain("# Code Context");
|
|
38
|
+
});
|
|
39
|
+
it("includes focus areas when provided", () => {
|
|
40
|
+
const request = {
|
|
41
|
+
instructions: "Guidelines",
|
|
42
|
+
context: "Code",
|
|
43
|
+
focusAreas: ["Security", "Performance"],
|
|
44
|
+
};
|
|
45
|
+
const prompt = buildReviewPrompt(request);
|
|
46
|
+
expect(prompt).toContain("Focus Areas");
|
|
47
|
+
expect(prompt).toContain("- Security");
|
|
48
|
+
expect(prompt).toContain("- Performance");
|
|
49
|
+
});
|
|
50
|
+
it("includes focus areas in task mode", () => {
|
|
51
|
+
const request = {
|
|
52
|
+
instructions: "Guidelines",
|
|
53
|
+
context: "Code",
|
|
54
|
+
task: "Analyze code",
|
|
55
|
+
focusAreas: ["Security"],
|
|
56
|
+
};
|
|
57
|
+
const prompt = buildReviewPrompt(request);
|
|
58
|
+
expect(prompt).toContain("## Focus Areas");
|
|
59
|
+
expect(prompt).toContain("- Security");
|
|
60
|
+
});
|
|
61
|
+
it("includes custom prompt as additional instructions", () => {
|
|
62
|
+
const request = {
|
|
63
|
+
instructions: "Guidelines",
|
|
64
|
+
context: "Code",
|
|
65
|
+
customPrompt: "Pay special attention to error handling",
|
|
66
|
+
};
|
|
67
|
+
const prompt = buildReviewPrompt(request);
|
|
68
|
+
expect(prompt).toContain("## Additional Instructions");
|
|
69
|
+
expect(prompt).toContain("Pay special attention to error handling");
|
|
70
|
+
});
|
|
71
|
+
it("includes custom prompt in task mode", () => {
|
|
72
|
+
const request = {
|
|
73
|
+
instructions: "Guidelines",
|
|
74
|
+
context: "Code",
|
|
75
|
+
task: "Review",
|
|
76
|
+
customPrompt: "Be concise",
|
|
77
|
+
};
|
|
78
|
+
const prompt = buildReviewPrompt(request);
|
|
79
|
+
expect(prompt).toContain("## Additional Instructions");
|
|
80
|
+
expect(prompt).toContain("Be concise");
|
|
81
|
+
});
|
|
82
|
+
it("separates sections with markdown separators", () => {
|
|
83
|
+
const request = {
|
|
84
|
+
instructions: "Guidelines",
|
|
85
|
+
context: "Code content here",
|
|
86
|
+
};
|
|
87
|
+
const prompt = buildReviewPrompt(request);
|
|
88
|
+
expect(prompt).toContain("---");
|
|
89
|
+
expect(prompt).toContain("# Code Context");
|
|
90
|
+
});
|
|
91
|
+
});
|