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,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 {};