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,275 @@
|
|
|
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 { bundleContext, formatBundleAsMarkdown } from "./bundler.js";
|
|
6
|
+
// Test the sensitive path detection by attempting to include sensitive files
|
|
7
|
+
describe("bundleContext - sensitive path handling", () => {
|
|
8
|
+
const tmpDir = path.join(os.tmpdir(), "bundler-sensitive-test-" + Date.now());
|
|
9
|
+
beforeAll(() => {
|
|
10
|
+
fs.mkdirSync(path.join(tmpDir, "src"), { recursive: true });
|
|
11
|
+
fs.mkdirSync(path.join(tmpDir, ".ssh"), { recursive: true });
|
|
12
|
+
fs.mkdirSync(path.join(tmpDir, ".aws"), { recursive: true });
|
|
13
|
+
// Create normal files
|
|
14
|
+
fs.writeFileSync(path.join(tmpDir, "src", "index.ts"), "export const main = 1;");
|
|
15
|
+
// Create sensitive files
|
|
16
|
+
fs.writeFileSync(path.join(tmpDir, ".ssh", "id_rsa"), "PRIVATE KEY");
|
|
17
|
+
fs.writeFileSync(path.join(tmpDir, ".aws", "credentials"), "aws_secret=xxx");
|
|
18
|
+
fs.writeFileSync(path.join(tmpDir, ".env"), "DATABASE_URL=secret");
|
|
19
|
+
fs.writeFileSync(path.join(tmpDir, "secrets.json"), '{"api_key": "xxx"}');
|
|
20
|
+
});
|
|
21
|
+
afterAll(() => {
|
|
22
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
23
|
+
});
|
|
24
|
+
it("blocks .ssh directory files", async () => {
|
|
25
|
+
const bundle = await bundleContext({
|
|
26
|
+
projectPath: tmpDir,
|
|
27
|
+
includeFiles: [".ssh/id_rsa"],
|
|
28
|
+
includeConversation: false,
|
|
29
|
+
includeDependencies: false,
|
|
30
|
+
includeDependents: false,
|
|
31
|
+
includeTests: false,
|
|
32
|
+
includeTypes: false,
|
|
33
|
+
});
|
|
34
|
+
expect(bundle.files).toHaveLength(0);
|
|
35
|
+
expect(bundle.omittedFiles.some((f) => f.reason === "sensitive_path")).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
it("blocks .aws directory files", async () => {
|
|
38
|
+
const bundle = await bundleContext({
|
|
39
|
+
projectPath: tmpDir,
|
|
40
|
+
includeFiles: [".aws/credentials"],
|
|
41
|
+
includeConversation: false,
|
|
42
|
+
includeDependencies: false,
|
|
43
|
+
includeDependents: false,
|
|
44
|
+
includeTests: false,
|
|
45
|
+
includeTypes: false,
|
|
46
|
+
});
|
|
47
|
+
expect(bundle.files).toHaveLength(0);
|
|
48
|
+
expect(bundle.omittedFiles.some((f) => f.reason === "sensitive_path")).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
it("blocks .env files", async () => {
|
|
51
|
+
const bundle = await bundleContext({
|
|
52
|
+
projectPath: tmpDir,
|
|
53
|
+
includeFiles: [".env"],
|
|
54
|
+
includeConversation: false,
|
|
55
|
+
includeDependencies: false,
|
|
56
|
+
includeDependents: false,
|
|
57
|
+
includeTests: false,
|
|
58
|
+
includeTypes: false,
|
|
59
|
+
});
|
|
60
|
+
expect(bundle.files).toHaveLength(0);
|
|
61
|
+
expect(bundle.omittedFiles.some((f) => f.reason === "sensitive_path")).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
it("blocks secrets.json files", async () => {
|
|
64
|
+
const bundle = await bundleContext({
|
|
65
|
+
projectPath: tmpDir,
|
|
66
|
+
includeFiles: ["secrets.json"],
|
|
67
|
+
includeConversation: false,
|
|
68
|
+
includeDependencies: false,
|
|
69
|
+
includeDependents: false,
|
|
70
|
+
includeTests: false,
|
|
71
|
+
includeTypes: false,
|
|
72
|
+
});
|
|
73
|
+
expect(bundle.files).toHaveLength(0);
|
|
74
|
+
expect(bundle.omittedFiles.some((f) => f.reason === "sensitive_path")).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
it("allows normal source files", async () => {
|
|
77
|
+
const bundle = await bundleContext({
|
|
78
|
+
projectPath: tmpDir,
|
|
79
|
+
includeFiles: ["src/index.ts"],
|
|
80
|
+
maxTokens: 10000, // Enough budget for the file
|
|
81
|
+
includeConversation: false,
|
|
82
|
+
includeDependencies: false,
|
|
83
|
+
includeDependents: false,
|
|
84
|
+
includeTests: false,
|
|
85
|
+
includeTypes: false,
|
|
86
|
+
});
|
|
87
|
+
expect(bundle.files).toHaveLength(1);
|
|
88
|
+
expect(bundle.files[0].path).toContain("index.ts");
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
describe("bundleContext - external files", () => {
|
|
92
|
+
const tmpDir = path.join(os.tmpdir(), "bundler-external-test-" + Date.now());
|
|
93
|
+
const externalDir = path.join(os.tmpdir(), "bundler-external-other-" + Date.now());
|
|
94
|
+
beforeAll(() => {
|
|
95
|
+
fs.mkdirSync(path.join(tmpDir, "src"), { recursive: true });
|
|
96
|
+
fs.mkdirSync(externalDir, { recursive: true });
|
|
97
|
+
fs.writeFileSync(path.join(tmpDir, "src", "index.ts"), "export const main = 1;");
|
|
98
|
+
fs.writeFileSync(path.join(externalDir, "external.ts"), "export const ext = 1;");
|
|
99
|
+
});
|
|
100
|
+
afterAll(() => {
|
|
101
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
102
|
+
fs.rmSync(externalDir, { recursive: true, force: true });
|
|
103
|
+
});
|
|
104
|
+
it("blocks external files by default", async () => {
|
|
105
|
+
const bundle = await bundleContext({
|
|
106
|
+
projectPath: tmpDir,
|
|
107
|
+
includeFiles: [externalDir + "/external.ts"],
|
|
108
|
+
includeConversation: false,
|
|
109
|
+
includeDependencies: false,
|
|
110
|
+
includeDependents: false,
|
|
111
|
+
includeTests: false,
|
|
112
|
+
includeTypes: false,
|
|
113
|
+
});
|
|
114
|
+
expect(bundle.files).toHaveLength(0);
|
|
115
|
+
expect(bundle.omittedFiles.some((f) => f.reason === "outside_project_requires_allowExternalFiles")).toBe(true);
|
|
116
|
+
});
|
|
117
|
+
it("allows external files when allowExternalFiles is true", async () => {
|
|
118
|
+
const bundle = await bundleContext({
|
|
119
|
+
projectPath: tmpDir,
|
|
120
|
+
includeFiles: [externalDir + "/external.ts"],
|
|
121
|
+
allowExternalFiles: true,
|
|
122
|
+
includeConversation: false,
|
|
123
|
+
includeDependencies: false,
|
|
124
|
+
includeDependents: false,
|
|
125
|
+
includeTests: false,
|
|
126
|
+
includeTypes: false,
|
|
127
|
+
});
|
|
128
|
+
expect(bundle.files).toHaveLength(1);
|
|
129
|
+
expect(bundle.files[0].path).toContain("external.ts");
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
describe("bundleContext - token budget", () => {
|
|
133
|
+
const tmpDir = path.join(os.tmpdir(), "bundler-budget-test-" + Date.now());
|
|
134
|
+
beforeAll(() => {
|
|
135
|
+
fs.mkdirSync(path.join(tmpDir, "src"), { recursive: true });
|
|
136
|
+
// Create files of known sizes
|
|
137
|
+
// Budget allocation for explicit files is 15% of maxTokens
|
|
138
|
+
// With maxTokens=1000, explicit budget = 150 tokens
|
|
139
|
+
fs.writeFileSync(path.join(tmpDir, "src", "small.ts"), "x".repeat(100)); // ~25 tokens
|
|
140
|
+
fs.writeFileSync(path.join(tmpDir, "src", "large.ts"), "x".repeat(2000)); // ~500 tokens
|
|
141
|
+
});
|
|
142
|
+
afterAll(() => {
|
|
143
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
144
|
+
});
|
|
145
|
+
it("respects maxTokens budget", async () => {
|
|
146
|
+
// With maxTokens=1000, explicit budget = 150 tokens
|
|
147
|
+
// small.ts (~25 tokens) should fit, large.ts (~500 tokens) should not
|
|
148
|
+
const bundle = await bundleContext({
|
|
149
|
+
projectPath: tmpDir,
|
|
150
|
+
includeFiles: ["src/small.ts", "src/large.ts"],
|
|
151
|
+
maxTokens: 1000,
|
|
152
|
+
includeConversation: false,
|
|
153
|
+
includeDependencies: false,
|
|
154
|
+
includeDependents: false,
|
|
155
|
+
includeTests: false,
|
|
156
|
+
includeTypes: false,
|
|
157
|
+
});
|
|
158
|
+
// Small file should be included
|
|
159
|
+
expect(bundle.files.some((f) => f.path.includes("small.ts"))).toBe(true);
|
|
160
|
+
// Large file should be omitted due to budget
|
|
161
|
+
expect(bundle.omittedFiles.some((f) => f.reason === "budget_exceeded")).toBe(true);
|
|
162
|
+
expect(bundle.omittedFiles.some((f) => f.path.includes("large.ts"))).toBe(true);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
describe("formatBundleAsMarkdown", () => {
|
|
166
|
+
it("formats bundle with categories", () => {
|
|
167
|
+
const bundle = {
|
|
168
|
+
conversationContext: "## Conversation\nUser asked for help",
|
|
169
|
+
files: [
|
|
170
|
+
{
|
|
171
|
+
path: "/project/src/index.ts",
|
|
172
|
+
content: "const x = 1;",
|
|
173
|
+
category: "session",
|
|
174
|
+
tokenEstimate: 10,
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
path: "/project/src/utils.ts",
|
|
178
|
+
content: "export const util = 1;",
|
|
179
|
+
category: "dependency",
|
|
180
|
+
tokenEstimate: 15,
|
|
181
|
+
},
|
|
182
|
+
],
|
|
183
|
+
omittedFiles: [],
|
|
184
|
+
totalTokens: 25,
|
|
185
|
+
categories: {
|
|
186
|
+
session: 10,
|
|
187
|
+
git: 0,
|
|
188
|
+
dependency: 15,
|
|
189
|
+
dependent: 0,
|
|
190
|
+
test: 0,
|
|
191
|
+
type: 0,
|
|
192
|
+
explicit: 0,
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
const markdown = formatBundleAsMarkdown(bundle, "/project");
|
|
196
|
+
expect(markdown).toContain("## Conversation");
|
|
197
|
+
expect(markdown).toContain("Modified Files (from Claude session)");
|
|
198
|
+
expect(markdown).toContain("Dependencies (files imported by modified code)");
|
|
199
|
+
expect(markdown).toContain("### src/index.ts");
|
|
200
|
+
expect(markdown).toContain("### src/utils.ts");
|
|
201
|
+
expect(markdown).toContain("**Total files:** 2");
|
|
202
|
+
});
|
|
203
|
+
it("includes omitted files section when files were omitted", () => {
|
|
204
|
+
const bundle = {
|
|
205
|
+
conversationContext: "",
|
|
206
|
+
files: [],
|
|
207
|
+
omittedFiles: [
|
|
208
|
+
{
|
|
209
|
+
path: "/project/.env",
|
|
210
|
+
category: "explicit",
|
|
211
|
+
tokenEstimate: 0,
|
|
212
|
+
reason: "sensitive_path",
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
path: "/project/large-file.ts",
|
|
216
|
+
category: "session",
|
|
217
|
+
tokenEstimate: 50000,
|
|
218
|
+
reason: "budget_exceeded",
|
|
219
|
+
},
|
|
220
|
+
],
|
|
221
|
+
totalTokens: 0,
|
|
222
|
+
categories: {
|
|
223
|
+
session: 0,
|
|
224
|
+
git: 0,
|
|
225
|
+
dependency: 0,
|
|
226
|
+
dependent: 0,
|
|
227
|
+
test: 0,
|
|
228
|
+
type: 0,
|
|
229
|
+
explicit: 0,
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
const markdown = formatBundleAsMarkdown(bundle, "/project");
|
|
233
|
+
expect(markdown).toContain("### Omitted Files");
|
|
234
|
+
expect(markdown).toContain("**Blocked (sensitive path):**");
|
|
235
|
+
expect(markdown).toContain(".env");
|
|
236
|
+
expect(markdown).toContain("**Budget exceeded:**");
|
|
237
|
+
expect(markdown).toContain("large-file.ts");
|
|
238
|
+
});
|
|
239
|
+
it("shows context summary with token breakdown", () => {
|
|
240
|
+
const bundle = {
|
|
241
|
+
conversationContext: "",
|
|
242
|
+
files: [
|
|
243
|
+
{
|
|
244
|
+
path: "/project/a.ts",
|
|
245
|
+
content: "a",
|
|
246
|
+
category: "session",
|
|
247
|
+
tokenEstimate: 100,
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
path: "/project/b.ts",
|
|
251
|
+
content: "b",
|
|
252
|
+
category: "test",
|
|
253
|
+
tokenEstimate: 50,
|
|
254
|
+
},
|
|
255
|
+
],
|
|
256
|
+
omittedFiles: [],
|
|
257
|
+
totalTokens: 150,
|
|
258
|
+
categories: {
|
|
259
|
+
session: 100,
|
|
260
|
+
git: 0,
|
|
261
|
+
dependency: 0,
|
|
262
|
+
dependent: 0,
|
|
263
|
+
test: 50,
|
|
264
|
+
type: 0,
|
|
265
|
+
explicit: 0,
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
const markdown = formatBundleAsMarkdown(bundle, "/project");
|
|
269
|
+
expect(markdown).toContain("## Context Summary");
|
|
270
|
+
expect(markdown).toContain("**Total files:** 2");
|
|
271
|
+
expect(markdown).toContain("**Estimated tokens:** 150");
|
|
272
|
+
expect(markdown).toContain("session: 100 tokens");
|
|
273
|
+
expect(markdown).toContain("test: 50 tokens");
|
|
274
|
+
});
|
|
275
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface GitChanges {
|
|
2
|
+
staged: string[];
|
|
3
|
+
unstaged: string[];
|
|
4
|
+
untracked: string[];
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Check if a directory is a git repository
|
|
8
|
+
*/
|
|
9
|
+
export declare function isGitRepo(projectPath: string): boolean;
|
|
10
|
+
/**
|
|
11
|
+
* Get all modified files from git
|
|
12
|
+
*/
|
|
13
|
+
export declare function getGitChanges(projectPath: string): GitChanges;
|
|
14
|
+
/**
|
|
15
|
+
* Get the diff content for a file
|
|
16
|
+
*/
|
|
17
|
+
export declare function getFileDiff(projectPath: string, filePath: string): string | null;
|
|
18
|
+
/**
|
|
19
|
+
* Get all modified files (staged + unstaged + untracked) as a single list
|
|
20
|
+
*/
|
|
21
|
+
export declare function getAllModifiedFiles(projectPath: string): string[];
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { execSync, spawnSync } from "child_process";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
/**
|
|
4
|
+
* Check if a directory is a git repository
|
|
5
|
+
*/
|
|
6
|
+
export function isGitRepo(projectPath) {
|
|
7
|
+
try {
|
|
8
|
+
execSync("git rev-parse --git-dir", {
|
|
9
|
+
cwd: projectPath,
|
|
10
|
+
stdio: "pipe",
|
|
11
|
+
});
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Get all modified files from git
|
|
20
|
+
*/
|
|
21
|
+
export function getGitChanges(projectPath) {
|
|
22
|
+
const changes = {
|
|
23
|
+
staged: [],
|
|
24
|
+
unstaged: [],
|
|
25
|
+
untracked: [],
|
|
26
|
+
};
|
|
27
|
+
if (!isGitRepo(projectPath)) {
|
|
28
|
+
return changes;
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
// Staged changes
|
|
32
|
+
const staged = execSync("git diff --cached --name-only", {
|
|
33
|
+
cwd: projectPath,
|
|
34
|
+
encoding: "utf-8",
|
|
35
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
36
|
+
});
|
|
37
|
+
changes.staged = staged
|
|
38
|
+
.split("\n")
|
|
39
|
+
.filter(Boolean)
|
|
40
|
+
.map((f) => path.join(projectPath, f));
|
|
41
|
+
// Unstaged changes
|
|
42
|
+
const unstaged = execSync("git diff --name-only", {
|
|
43
|
+
cwd: projectPath,
|
|
44
|
+
encoding: "utf-8",
|
|
45
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
46
|
+
});
|
|
47
|
+
changes.unstaged = unstaged
|
|
48
|
+
.split("\n")
|
|
49
|
+
.filter(Boolean)
|
|
50
|
+
.map((f) => path.join(projectPath, f));
|
|
51
|
+
// Untracked files
|
|
52
|
+
const untracked = execSync("git ls-files --others --exclude-standard", {
|
|
53
|
+
cwd: projectPath,
|
|
54
|
+
encoding: "utf-8",
|
|
55
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
56
|
+
});
|
|
57
|
+
changes.untracked = untracked
|
|
58
|
+
.split("\n")
|
|
59
|
+
.filter(Boolean)
|
|
60
|
+
.map((f) => path.join(projectPath, f));
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// Git commands failed, return empty
|
|
64
|
+
}
|
|
65
|
+
return changes;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Get the diff content for a file
|
|
69
|
+
*/
|
|
70
|
+
export function getFileDiff(projectPath, filePath) {
|
|
71
|
+
if (!isGitRepo(projectPath)) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
const relativePath = path.relative(projectPath, filePath);
|
|
76
|
+
// Use spawnSync with argument array to prevent command injection
|
|
77
|
+
const result = spawnSync("git", ["diff", "HEAD", "--", relativePath], {
|
|
78
|
+
cwd: projectPath,
|
|
79
|
+
encoding: "utf-8",
|
|
80
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
81
|
+
});
|
|
82
|
+
if (result.error || result.status !== 0) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
return result.stdout || null;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Get all modified files (staged + unstaged + untracked) as a single list
|
|
93
|
+
*/
|
|
94
|
+
export function getAllModifiedFiles(projectPath) {
|
|
95
|
+
const changes = getGitChanges(projectPath);
|
|
96
|
+
const allFiles = new Set([
|
|
97
|
+
...changes.staged,
|
|
98
|
+
...changes.unstaged,
|
|
99
|
+
...changes.untracked,
|
|
100
|
+
]);
|
|
101
|
+
return Array.from(allFiles);
|
|
102
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extract import paths from a file's content
|
|
3
|
+
*/
|
|
4
|
+
export declare function extractImports(content: string): string[];
|
|
5
|
+
/**
|
|
6
|
+
* Check if a path is within the project bounds
|
|
7
|
+
* Resolves symlinks to handle cases like /var -> /private/var on macOS
|
|
8
|
+
*/
|
|
9
|
+
export declare function isWithinProject(filePath: string, projectPath: string): boolean;
|
|
10
|
+
/**
|
|
11
|
+
* Resolve an import path to an actual file path
|
|
12
|
+
*/
|
|
13
|
+
export declare function resolveImportPath(importPath: string, fromFile: string, projectPath: string): string | null;
|
|
14
|
+
/**
|
|
15
|
+
* Get all files that a given file imports (dependencies)
|
|
16
|
+
*/
|
|
17
|
+
export declare function getDependencies(filePath: string, projectPath: string): string[];
|
|
18
|
+
/**
|
|
19
|
+
* Get dependencies for multiple files
|
|
20
|
+
*/
|
|
21
|
+
export declare function getDependenciesForFiles(files: string[], projectPath: string): string[];
|
|
22
|
+
/**
|
|
23
|
+
* Import index for efficient dependent lookups
|
|
24
|
+
* Maps each file to the files that import it (reverse dependency map)
|
|
25
|
+
*/
|
|
26
|
+
export interface ImportIndex {
|
|
27
|
+
importedBy: Map<string, Set<string>>;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Build an import index for the project
|
|
31
|
+
* Scans all files once and builds both forward and reverse dependency maps
|
|
32
|
+
*/
|
|
33
|
+
export declare function buildImportIndex(projectPath: string): Promise<ImportIndex>;
|
|
34
|
+
/**
|
|
35
|
+
* Get dependents for multiple files using a pre-built index
|
|
36
|
+
* O(M) lookups instead of O(M*N) file reads
|
|
37
|
+
*/
|
|
38
|
+
export declare function getDependentsFromIndex(files: string[], index: ImportIndex): string[];
|
|
39
|
+
/**
|
|
40
|
+
* Get dependents for multiple files
|
|
41
|
+
* Uses an index-based approach for better performance
|
|
42
|
+
*/
|
|
43
|
+
export declare function getDependentsForFiles(files: string[], projectPath: string): Promise<string[]>;
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { glob } from "glob";
|
|
4
|
+
// Regex patterns for different import styles
|
|
5
|
+
const IMPORT_PATTERNS = [
|
|
6
|
+
// ES6 imports: import x from 'y', import { x } from 'y', import * as x from 'y'
|
|
7
|
+
/import\s+(?:[\w*{}\s,]+\s+from\s+)?['"]([^'"]+)['"]/g,
|
|
8
|
+
// ES6 dynamic imports: import('y')
|
|
9
|
+
/import\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
|
|
10
|
+
// CommonJS requires: require('y')
|
|
11
|
+
/require\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
|
|
12
|
+
// ES6 export from: export { x } from 'y'
|
|
13
|
+
/export\s+(?:[\w*{}\s,]+\s+)?from\s+['"]([^'"]+)['"]/g,
|
|
14
|
+
];
|
|
15
|
+
// File extensions to consider for imports
|
|
16
|
+
const CODE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"];
|
|
17
|
+
/**
|
|
18
|
+
* Extract import paths from a file's content
|
|
19
|
+
*/
|
|
20
|
+
export function extractImports(content) {
|
|
21
|
+
const imports = new Set();
|
|
22
|
+
for (const pattern of IMPORT_PATTERNS) {
|
|
23
|
+
// Reset regex state
|
|
24
|
+
pattern.lastIndex = 0;
|
|
25
|
+
let match;
|
|
26
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
27
|
+
imports.add(match[1]);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return Array.from(imports);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Check if a path is within the project bounds
|
|
34
|
+
* Resolves symlinks to handle cases like /var -> /private/var on macOS
|
|
35
|
+
*/
|
|
36
|
+
export function isWithinProject(filePath, projectPath) {
|
|
37
|
+
// Use realpathSync to resolve symlinks (falls back to normalize if path doesn't exist)
|
|
38
|
+
let realFile;
|
|
39
|
+
let realProject;
|
|
40
|
+
try {
|
|
41
|
+
realFile = fs.realpathSync(filePath);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
realFile = path.normalize(path.resolve(filePath));
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
realProject = fs.realpathSync(projectPath);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
realProject = path.normalize(path.resolve(projectPath));
|
|
51
|
+
}
|
|
52
|
+
return realFile.startsWith(realProject + path.sep) ||
|
|
53
|
+
realFile === realProject;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Resolve an import path to an actual file path
|
|
57
|
+
*/
|
|
58
|
+
export function resolveImportPath(importPath, fromFile, projectPath) {
|
|
59
|
+
// Skip node_modules and external packages
|
|
60
|
+
if (!importPath.startsWith(".") &&
|
|
61
|
+
!importPath.startsWith("/") &&
|
|
62
|
+
!importPath.startsWith("@/")) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
const fromDir = path.dirname(fromFile);
|
|
66
|
+
let targetPath;
|
|
67
|
+
// Handle @ alias (common in many projects)
|
|
68
|
+
if (importPath.startsWith("@/")) {
|
|
69
|
+
targetPath = path.join(projectPath, "src", importPath.slice(2));
|
|
70
|
+
}
|
|
71
|
+
else if (importPath.startsWith("/")) {
|
|
72
|
+
targetPath = path.join(projectPath, importPath);
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
targetPath = path.resolve(fromDir, importPath);
|
|
76
|
+
}
|
|
77
|
+
// Try with different extensions
|
|
78
|
+
const candidates = [
|
|
79
|
+
targetPath,
|
|
80
|
+
...CODE_EXTENSIONS.map((ext) => targetPath + ext),
|
|
81
|
+
...CODE_EXTENSIONS.map((ext) => path.join(targetPath, "index" + ext)),
|
|
82
|
+
];
|
|
83
|
+
for (const candidate of candidates) {
|
|
84
|
+
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
|
|
85
|
+
// Bounds check: ensure resolved path is within project
|
|
86
|
+
if (!isWithinProject(candidate, projectPath)) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
return candidate;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Get all files that a given file imports (dependencies)
|
|
96
|
+
*/
|
|
97
|
+
export function getDependencies(filePath, projectPath) {
|
|
98
|
+
if (!fs.existsSync(filePath)) {
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
103
|
+
const importPaths = extractImports(content);
|
|
104
|
+
const dependencies = [];
|
|
105
|
+
for (const importPath of importPaths) {
|
|
106
|
+
const resolved = resolveImportPath(importPath, filePath, projectPath);
|
|
107
|
+
if (resolved) {
|
|
108
|
+
dependencies.push(resolved);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return dependencies;
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return [];
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Get dependencies for multiple files
|
|
119
|
+
*/
|
|
120
|
+
export function getDependenciesForFiles(files, projectPath) {
|
|
121
|
+
const allDeps = new Set();
|
|
122
|
+
for (const file of files) {
|
|
123
|
+
const deps = getDependencies(file, projectPath);
|
|
124
|
+
for (const dep of deps) {
|
|
125
|
+
// Don't include files that are already in our modified set
|
|
126
|
+
if (!files.includes(dep)) {
|
|
127
|
+
allDeps.add(dep);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return Array.from(allDeps);
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Build an import index for the project
|
|
135
|
+
* Scans all files once and builds both forward and reverse dependency maps
|
|
136
|
+
*/
|
|
137
|
+
export async function buildImportIndex(projectPath) {
|
|
138
|
+
const index = {
|
|
139
|
+
importedBy: new Map(),
|
|
140
|
+
};
|
|
141
|
+
// Get all code files in the project
|
|
142
|
+
const patterns = CODE_EXTENSIONS.map((ext) => `**/*${ext}`);
|
|
143
|
+
const allFiles = await glob(patterns, {
|
|
144
|
+
cwd: projectPath,
|
|
145
|
+
absolute: true,
|
|
146
|
+
ignore: ["**/node_modules/**", "**/dist/**", "**/build/**", "**/.git/**"],
|
|
147
|
+
});
|
|
148
|
+
// Build the reverse dependency map by scanning each file once
|
|
149
|
+
for (const file of allFiles) {
|
|
150
|
+
try {
|
|
151
|
+
const content = fs.readFileSync(file, "utf-8");
|
|
152
|
+
const importPaths = extractImports(content);
|
|
153
|
+
for (const importPath of importPaths) {
|
|
154
|
+
const resolved = resolveImportPath(importPath, file, projectPath);
|
|
155
|
+
if (resolved) {
|
|
156
|
+
if (!index.importedBy.has(resolved)) {
|
|
157
|
+
index.importedBy.set(resolved, new Set());
|
|
158
|
+
}
|
|
159
|
+
index.importedBy.get(resolved).add(file);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
// Skip files that can't be read
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return index;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Get dependents for multiple files using a pre-built index
|
|
171
|
+
* O(M) lookups instead of O(M*N) file reads
|
|
172
|
+
*/
|
|
173
|
+
export function getDependentsFromIndex(files, index) {
|
|
174
|
+
const allDependents = new Set();
|
|
175
|
+
const fileSet = new Set(files);
|
|
176
|
+
for (const file of files) {
|
|
177
|
+
const dependents = index.importedBy.get(file);
|
|
178
|
+
if (dependents) {
|
|
179
|
+
for (const dep of dependents) {
|
|
180
|
+
// Don't include files that are already in our modified set
|
|
181
|
+
if (!fileSet.has(dep)) {
|
|
182
|
+
allDependents.add(dep);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return Array.from(allDependents);
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Get dependents for multiple files
|
|
191
|
+
* Uses an index-based approach for better performance
|
|
192
|
+
*/
|
|
193
|
+
export async function getDependentsForFiles(files, projectPath) {
|
|
194
|
+
// Build index once, then do O(1) lookups for each file
|
|
195
|
+
const index = await buildImportIndex(projectPath);
|
|
196
|
+
return getDependentsFromIndex(files, index);
|
|
197
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|