Просмотр исходного кода

Handle errors more gracefully when reading custom instructions from files

Joe Manley 10 месяцев назад
Родитель
Сommit
9dce0e88cd

+ 138 - 0
src/core/prompts/sections/__tests__/custom-instructions.test.ts

@@ -0,0 +1,138 @@
+import { loadRuleFiles, addCustomInstructions } from "../custom-instructions"
+import fs from "fs/promises"
+
+// Mock fs/promises
+jest.mock("fs/promises")
+const mockedFs = jest.mocked(fs)
+
+describe("loadRuleFiles", () => {
+	beforeEach(() => {
+		jest.clearAllMocks()
+	})
+
+	it("should combine content from multiple rule files when they exist", async () => {
+		mockedFs.readFile.mockImplementation(((filePath: string | Buffer | URL | number) => {
+			if (filePath.toString().endsWith(".clinerules")) {
+				return Promise.resolve("cline rules content")
+			}
+			if (filePath.toString().endsWith(".cursorrules")) {
+				return Promise.resolve("cursor rules content")
+			}
+			return Promise.reject({ code: "ENOENT" })
+		}) as any)
+
+		const result = await loadRuleFiles("/fake/path")
+		expect(result).toBe(
+			"\n# Rules from .clinerules:\ncline rules content\n" +
+				"\n# Rules from .cursorrules:\ncursor rules content\n",
+		)
+	})
+
+	it("should handle when no rule files exist", async () => {
+		mockedFs.readFile.mockRejectedValue({ code: "ENOENT" })
+
+		const result = await loadRuleFiles("/fake/path")
+		expect(result).toBe("")
+	})
+
+	it("should throw on unexpected errors", async () => {
+		const error = new Error("Permission denied") as NodeJS.ErrnoException
+		error.code = "EPERM"
+		mockedFs.readFile.mockRejectedValue(error)
+
+		await expect(async () => {
+			await loadRuleFiles("/fake/path")
+		}).rejects.toThrow()
+	})
+
+	it("should skip directories with same name as rule files", async () => {
+		mockedFs.readFile.mockImplementation(((filePath: string | Buffer | URL | number) => {
+			if (filePath.toString().endsWith(".clinerules")) {
+				return Promise.reject({ code: "EISDIR" })
+			}
+			if (filePath.toString().endsWith(".cursorrules")) {
+				return Promise.resolve("cursor rules content")
+			}
+			return Promise.reject({ code: "ENOENT" })
+		}) as any)
+
+		const result = await loadRuleFiles("/fake/path")
+		expect(result).toBe("\n# Rules from .cursorrules:\ncursor rules content\n")
+	})
+})
+
+describe("addCustomInstructions", () => {
+	beforeEach(() => {
+		jest.clearAllMocks()
+	})
+
+	it("should combine all instruction types when provided", async () => {
+		mockedFs.readFile.mockResolvedValue("mode specific rules")
+
+		const result = await addCustomInstructions(
+			"mode instructions",
+			"global instructions",
+			"/fake/path",
+			"test-mode",
+			{ preferredLanguage: "Spanish" },
+		)
+
+		expect(result).toContain("Language Preference:")
+		expect(result).toContain("Spanish")
+		expect(result).toContain("Global Instructions:\nglobal instructions")
+		expect(result).toContain("Mode-specific Instructions:\nmode instructions")
+		expect(result).toContain("Rules from .clinerules-test-mode:\nmode specific rules")
+	})
+
+	it("should return empty string when no instructions provided", async () => {
+		mockedFs.readFile.mockRejectedValue({ code: "ENOENT" })
+
+		const result = await addCustomInstructions("", "", "/fake/path", "", {})
+		expect(result).toBe("")
+	})
+
+	it("should handle missing mode-specific rules file", async () => {
+		mockedFs.readFile.mockRejectedValue({ code: "ENOENT" })
+
+		const result = await addCustomInstructions(
+			"mode instructions",
+			"global instructions",
+			"/fake/path",
+			"test-mode",
+		)
+
+		expect(result).toContain("Global Instructions:")
+		expect(result).toContain("Mode-specific Instructions:")
+		expect(result).not.toContain("Rules from .clinerules-test-mode")
+	})
+
+	it("should throw on unexpected errors", async () => {
+		const error = new Error("Permission denied") as NodeJS.ErrnoException
+		error.code = "EPERM"
+		mockedFs.readFile.mockRejectedValue(error)
+
+		await expect(async () => {
+			await addCustomInstructions("", "", "/fake/path", "test-mode")
+		}).rejects.toThrow()
+	})
+
+	it("should skip mode-specific rule files that are directories", async () => {
+		mockedFs.readFile.mockImplementation(((filePath: string | Buffer | URL | number) => {
+			if (filePath.toString().includes(".clinerules-test-mode")) {
+				return Promise.reject({ code: "EISDIR" })
+			}
+			return Promise.reject({ code: "ENOENT" })
+		}) as any)
+
+		const result = await addCustomInstructions(
+			"mode instructions",
+			"global instructions",
+			"/fake/path",
+			"test-mode",
+		)
+
+		expect(result).toContain("Global Instructions:\nglobal instructions")
+		expect(result).toContain("Mode-specific Instructions:\nmode instructions")
+		expect(result).not.toContain("Rules from .clinerules-test-mode")
+	})
+})

+ 4 - 4
src/core/prompts/sections/custom-instructions.ts

@@ -12,8 +12,8 @@ export async function loadRuleFiles(cwd: string): Promise<string> {
 				combinedRules += `\n# Rules from ${file}:\n${content.trim()}\n`
 			}
 		} catch (err) {
-			// Silently skip if file doesn't exist
-			if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
+			const errorCode = (err as NodeJS.ErrnoException).code
+			if (!errorCode || !["ENOENT", "EISDIR"].includes(errorCode)) {
 				throw err
 			}
 		}
@@ -41,8 +41,8 @@ export async function addCustomInstructions(
 				modeRuleContent = content.trim()
 			}
 		} catch (err) {
-			// Silently skip if file doesn't exist
-			if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
+			const errorCode = (err as NodeJS.ErrnoException).code
+			if (!errorCode || !["ENOENT", "EISDIR"].includes(errorCode)) {
 				throw err
 			}
 		}

+ 103 - 147
webview-ui/package-lock.json

@@ -8,6 +8,7 @@
 			"name": "webview-ui",
 			"version": "0.1.0",
 			"dependencies": {
+				"@radix-ui/react-alert-dialog": "^1.1.6",
 				"@radix-ui/react-collapsible": "^1.1.3",
 				"@radix-ui/react-dialog": "^1.1.6",
 				"@radix-ui/react-dropdown-menu": "^2.1.5",
@@ -16,7 +17,7 @@
 				"@radix-ui/react-progress": "^1.1.2",
 				"@radix-ui/react-separator": "^1.1.2",
 				"@radix-ui/react-slider": "^1.2.3",
-				"@radix-ui/react-slot": "^1.1.1",
+				"@radix-ui/react-slot": "^1.1.2",
 				"@radix-ui/react-tooltip": "^1.1.8",
 				"@tailwindcss/vite": "^4.0.0",
 				"@vscode/webview-ui-toolkit": "^1.4.0",
@@ -3590,6 +3591,55 @@
 			"integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==",
 			"license": "MIT"
 		},
+		"node_modules/@radix-ui/react-alert-dialog": {
+			"version": "1.1.6",
+			"resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.6.tgz",
+			"integrity": "sha512-p4XnPqgej8sZAAReCAKgz1REYZEBLR8hU9Pg27wFnCWIMc8g1ccCs0FjBcy05V15VTu8pAePw/VDYeOm/uZ6yQ==",
+			"dependencies": {
+				"@radix-ui/primitive": "1.1.1",
+				"@radix-ui/react-compose-refs": "1.1.1",
+				"@radix-ui/react-context": "1.1.1",
+				"@radix-ui/react-dialog": "1.1.6",
+				"@radix-ui/react-primitive": "2.0.2",
+				"@radix-ui/react-slot": "1.1.2"
+			},
+			"peerDependencies": {
+				"@types/react": "*",
+				"@types/react-dom": "*",
+				"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+				"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+			},
+			"peerDependenciesMeta": {
+				"@types/react": {
+					"optional": true
+				},
+				"@types/react-dom": {
+					"optional": true
+				}
+			}
+		},
+		"node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-primitive": {
+			"version": "2.0.2",
+			"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz",
+			"integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==",
+			"dependencies": {
+				"@radix-ui/react-slot": "1.1.2"
+			},
+			"peerDependencies": {
+				"@types/react": "*",
+				"@types/react-dom": "*",
+				"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+				"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+			},
+			"peerDependenciesMeta": {
+				"@types/react": {
+					"optional": true
+				},
+				"@types/react-dom": {
+					"optional": true
+				}
+			}
+		},
 		"node_modules/@radix-ui/react-arrow": {
 			"version": "1.1.1",
 			"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.1.tgz",
@@ -3666,24 +3716,6 @@
 				}
 			}
 		},
-		"node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-slot": {
-			"version": "1.1.2",
-			"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
-			"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
-			"license": "MIT",
-			"dependencies": {
-				"@radix-ui/react-compose-refs": "1.1.1"
-			},
-			"peerDependencies": {
-				"@types/react": "*",
-				"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-			},
-			"peerDependenciesMeta": {
-				"@types/react": {
-					"optional": true
-				}
-			}
-		},
 		"node_modules/@radix-ui/react-collection": {
 			"version": "1.1.1",
 			"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.1.tgz",
@@ -3710,6 +3742,23 @@
 				}
 			}
 		},
+		"node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": {
+			"version": "1.1.1",
+			"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz",
+			"integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==",
+			"dependencies": {
+				"@radix-ui/react-compose-refs": "1.1.1"
+			},
+			"peerDependencies": {
+				"@types/react": "*",
+				"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+			},
+			"peerDependenciesMeta": {
+				"@types/react": {
+					"optional": true
+				}
+			}
+		},
 		"node_modules/@radix-ui/react-compose-refs": {
 			"version": "1.1.1",
 			"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz",
@@ -3875,24 +3924,6 @@
 				}
 			}
 		},
-		"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": {
-			"version": "1.1.2",
-			"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
-			"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
-			"license": "MIT",
-			"dependencies": {
-				"@radix-ui/react-compose-refs": "1.1.1"
-			},
-			"peerDependencies": {
-				"@types/react": "*",
-				"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-			},
-			"peerDependenciesMeta": {
-				"@types/react": {
-					"optional": true
-				}
-			}
-		},
 		"node_modules/@radix-ui/react-direction": {
 			"version": "1.1.0",
 			"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz",
@@ -4071,6 +4102,23 @@
 				}
 			}
 		},
+		"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": {
+			"version": "1.1.1",
+			"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz",
+			"integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==",
+			"dependencies": {
+				"@radix-ui/react-compose-refs": "1.1.1"
+			},
+			"peerDependencies": {
+				"@types/react": "*",
+				"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+			},
+			"peerDependenciesMeta": {
+				"@types/react": {
+					"optional": true
+				}
+			}
+		},
 		"node_modules/@radix-ui/react-popover": {
 			"version": "1.1.6",
 			"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.6.tgz",
@@ -4262,24 +4310,6 @@
 				}
 			}
 		},
-		"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": {
-			"version": "1.1.2",
-			"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
-			"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
-			"license": "MIT",
-			"dependencies": {
-				"@radix-ui/react-compose-refs": "1.1.1"
-			},
-			"peerDependencies": {
-				"@types/react": "*",
-				"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-			},
-			"peerDependenciesMeta": {
-				"@types/react": {
-					"optional": true
-				}
-			}
-		},
 		"node_modules/@radix-ui/react-popper": {
 			"version": "1.2.1",
 			"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.1.tgz",
@@ -4383,6 +4413,23 @@
 				}
 			}
 		},
+		"node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": {
+			"version": "1.1.1",
+			"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz",
+			"integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==",
+			"dependencies": {
+				"@radix-ui/react-compose-refs": "1.1.1"
+			},
+			"peerDependencies": {
+				"@types/react": "*",
+				"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+			},
+			"peerDependenciesMeta": {
+				"@types/react": {
+					"optional": true
+				}
+			}
+		},
 		"node_modules/@radix-ui/react-progress": {
 			"version": "1.1.2",
 			"resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.2.tgz",
@@ -4430,24 +4477,6 @@
 				}
 			}
 		},
-		"node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-slot": {
-			"version": "1.1.2",
-			"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
-			"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
-			"license": "MIT",
-			"dependencies": {
-				"@radix-ui/react-compose-refs": "1.1.1"
-			},
-			"peerDependencies": {
-				"@types/react": "*",
-				"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-			},
-			"peerDependenciesMeta": {
-				"@types/react": {
-					"optional": true
-				}
-			}
-		},
 		"node_modules/@radix-ui/react-roving-focus": {
 			"version": "1.1.1",
 			"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.1.tgz",
@@ -4525,24 +4554,6 @@
 				}
 			}
 		},
-		"node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot": {
-			"version": "1.1.2",
-			"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
-			"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
-			"license": "MIT",
-			"dependencies": {
-				"@radix-ui/react-compose-refs": "1.1.1"
-			},
-			"peerDependencies": {
-				"@types/react": "*",
-				"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-			},
-			"peerDependenciesMeta": {
-				"@types/react": {
-					"optional": true
-				}
-			}
-		},
 		"node_modules/@radix-ui/react-slider": {
 			"version": "1.2.3",
 			"resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.2.3.tgz",
@@ -4625,29 +4636,10 @@
 				}
 			}
 		},
-		"node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-slot": {
+		"node_modules/@radix-ui/react-slot": {
 			"version": "1.1.2",
 			"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
 			"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
-			"license": "MIT",
-			"dependencies": {
-				"@radix-ui/react-compose-refs": "1.1.1"
-			},
-			"peerDependencies": {
-				"@types/react": "*",
-				"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-			},
-			"peerDependenciesMeta": {
-				"@types/react": {
-					"optional": true
-				}
-			}
-		},
-		"node_modules/@radix-ui/react-slot": {
-			"version": "1.1.1",
-			"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz",
-			"integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==",
-			"license": "MIT",
 			"dependencies": {
 				"@radix-ui/react-compose-refs": "1.1.1"
 			},
@@ -4824,24 +4816,6 @@
 				}
 			}
 		},
-		"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": {
-			"version": "1.1.2",
-			"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
-			"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
-			"license": "MIT",
-			"dependencies": {
-				"@radix-ui/react-compose-refs": "1.1.1"
-			},
-			"peerDependencies": {
-				"@types/react": "*",
-				"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-			},
-			"peerDependenciesMeta": {
-				"@types/react": {
-					"optional": true
-				}
-			}
-		},
 		"node_modules/@radix-ui/react-use-callback-ref": {
 			"version": "1.1.0",
 			"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz",
@@ -5005,24 +4979,6 @@
 				}
 			}
 		},
-		"node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-slot": {
-			"version": "1.1.2",
-			"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
-			"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
-			"license": "MIT",
-			"dependencies": {
-				"@radix-ui/react-compose-refs": "1.1.1"
-			},
-			"peerDependencies": {
-				"@types/react": "*",
-				"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
-			},
-			"peerDependenciesMeta": {
-				"@types/react": {
-					"optional": true
-				}
-			}
-		},
 		"node_modules/@radix-ui/rect": {
 			"version": "1.1.0",
 			"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.0.tgz",