Browse Source

Rules: Add `paths:` conditional logic (don't wire it up yet) [ENG-1469] (#8648)

* feat(rules): Add paths conditional evaluation.

* feat(rules): Add missing picomatch dependency
CandiedUniverse 5 days ago
parent
commit
3210c4bc4b

+ 94 - 83
package-lock.json

@@ -84,6 +84,7 @@
 				"p-timeout": "^6.1.4",
 				"p-wait-for": "^5.0.2",
 				"pdf-parse": "^1.1.1",
+				"picomatch": "^4.0.3",
 				"posthog-node": "^5.8.0",
 				"puppeteer-chromium-resolver": "^23.0.0",
 				"puppeteer-core": "^23.4.0",
@@ -1182,6 +1183,7 @@
 			"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
 			"dev": true,
 			"license": "MIT",
+			"peer": true,
 			"dependencies": {
 				"@babel/code-frame": "^7.27.1",
 				"@babel/generator": "^7.28.3",
@@ -2644,6 +2646,7 @@
 		"node_modules/@grpc/grpc-js": {
 			"version": "1.9.15",
 			"license": "Apache-2.0",
+			"peer": true,
 			"dependencies": {
 				"@grpc/proto-loader": "^0.7.8",
 				"@types/node": ">=12.12.47"
@@ -3227,6 +3230,7 @@
 			"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.1.tgz",
 			"integrity": "sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ==",
 			"license": "MIT",
+			"peer": true,
 			"dependencies": {
 				"@hono/node-server": "^1.19.7",
 				"ajv": "^8.17.1",
@@ -3295,6 +3299,7 @@
 			"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
 			"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
 			"license": "Apache-2.0",
+			"peer": true,
 			"engines": {
 				"node": ">=8.0.0"
 			}
@@ -4910,8 +4915,7 @@
 			"optional": true,
 			"os": [
 				"android"
-			],
-			"peer": true
+			]
 		},
 		"node_modules/@rollup/rollup-android-arm64": {
 			"version": "4.52.4",
@@ -4924,8 +4928,7 @@
 			"optional": true,
 			"os": [
 				"android"
-			],
-			"peer": true
+			]
 		},
 		"node_modules/@rollup/rollup-darwin-arm64": {
 			"version": "4.52.4",
@@ -4938,8 +4941,7 @@
 			"optional": true,
 			"os": [
 				"darwin"
-			],
-			"peer": true
+			]
 		},
 		"node_modules/@rollup/rollup-darwin-x64": {
 			"version": "4.52.4",
@@ -4952,8 +4954,7 @@
 			"optional": true,
 			"os": [
 				"darwin"
-			],
-			"peer": true
+			]
 		},
 		"node_modules/@rollup/rollup-freebsd-arm64": {
 			"version": "4.52.4",
@@ -4966,8 +4967,7 @@
 			"optional": true,
 			"os": [
 				"freebsd"
-			],
-			"peer": true
+			]
 		},
 		"node_modules/@rollup/rollup-freebsd-x64": {
 			"version": "4.52.4",
@@ -4980,8 +4980,7 @@
 			"optional": true,
 			"os": [
 				"freebsd"
-			],
-			"peer": true
+			]
 		},
 		"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
 			"version": "4.52.4",
@@ -4994,8 +4993,7 @@
 			"optional": true,
 			"os": [
 				"linux"
-			],
-			"peer": true
+			]
 		},
 		"node_modules/@rollup/rollup-linux-arm-musleabihf": {
 			"version": "4.52.4",
@@ -5008,8 +5006,7 @@
 			"optional": true,
 			"os": [
 				"linux"
-			],
-			"peer": true
+			]
 		},
 		"node_modules/@rollup/rollup-linux-arm64-gnu": {
 			"version": "4.52.4",
@@ -5022,8 +5019,7 @@
 			"optional": true,
 			"os": [
 				"linux"
-			],
-			"peer": true
+			]
 		},
 		"node_modules/@rollup/rollup-linux-arm64-musl": {
 			"version": "4.52.4",
@@ -5036,8 +5032,7 @@
 			"optional": true,
 			"os": [
 				"linux"
-			],
-			"peer": true
+			]
 		},
 		"node_modules/@rollup/rollup-linux-loong64-gnu": {
 			"version": "4.52.4",
@@ -5050,8 +5045,7 @@
 			"optional": true,
 			"os": [
 				"linux"
-			],
-			"peer": true
+			]
 		},
 		"node_modules/@rollup/rollup-linux-ppc64-gnu": {
 			"version": "4.52.4",
@@ -5064,8 +5058,7 @@
 			"optional": true,
 			"os": [
 				"linux"
-			],
-			"peer": true
+			]
 		},
 		"node_modules/@rollup/rollup-linux-riscv64-gnu": {
 			"version": "4.52.4",
@@ -5078,8 +5071,7 @@
 			"optional": true,
 			"os": [
 				"linux"
-			],
-			"peer": true
+			]
 		},
 		"node_modules/@rollup/rollup-linux-riscv64-musl": {
 			"version": "4.52.4",
@@ -5092,8 +5084,7 @@
 			"optional": true,
 			"os": [
 				"linux"
-			],
-			"peer": true
+			]
 		},
 		"node_modules/@rollup/rollup-linux-s390x-gnu": {
 			"version": "4.52.4",
@@ -5106,8 +5097,7 @@
 			"optional": true,
 			"os": [
 				"linux"
-			],
-			"peer": true
+			]
 		},
 		"node_modules/@rollup/rollup-linux-x64-gnu": {
 			"version": "4.52.4",
@@ -5120,8 +5110,7 @@
 			"optional": true,
 			"os": [
 				"linux"
-			],
-			"peer": true
+			]
 		},
 		"node_modules/@rollup/rollup-linux-x64-musl": {
 			"version": "4.52.4",
@@ -5134,8 +5123,7 @@
 			"optional": true,
 			"os": [
 				"linux"
-			],
-			"peer": true
+			]
 		},
 		"node_modules/@rollup/rollup-openharmony-arm64": {
 			"version": "4.52.4",
@@ -5148,8 +5136,7 @@
 			"optional": true,
 			"os": [
 				"openharmony"
-			],
-			"peer": true
+			]
 		},
 		"node_modules/@rollup/rollup-win32-arm64-msvc": {
 			"version": "4.52.4",
@@ -5162,8 +5149,7 @@
 			"optional": true,
 			"os": [
 				"win32"
-			],
-			"peer": true
+			]
 		},
 		"node_modules/@rollup/rollup-win32-ia32-msvc": {
 			"version": "4.52.4",
@@ -5176,8 +5162,7 @@
 			"optional": true,
 			"os": [
 				"win32"
-			],
-			"peer": true
+			]
 		},
 		"node_modules/@rollup/rollup-win32-x64-gnu": {
 			"version": "4.52.4",
@@ -5190,8 +5175,7 @@
 			"optional": true,
 			"os": [
 				"win32"
-			],
-			"peer": true
+			]
 		},
 		"node_modules/@rollup/rollup-win32-x64-msvc": {
 			"version": "4.52.4",
@@ -5204,8 +5188,7 @@
 			"optional": true,
 			"os": [
 				"win32"
-			],
-			"peer": true
+			]
 		},
 		"node_modules/@sap-ai-sdk/ai-api": {
 			"version": "2.1.0",
@@ -6768,8 +6751,7 @@
 			"version": "1.0.8",
 			"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
 			"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
-			"license": "MIT",
-			"peer": true
+			"license": "MIT"
 		},
 		"node_modules/@types/get-folder-size": {
 			"version": "3.0.4",
@@ -6801,6 +6783,7 @@
 			"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.21.tgz",
 			"integrity": "sha512-CsGG2P3I5y48RPMfprQGfy4JPRZ6csfC3ltBZSRItG3ngggmNY/qs2uZKp4p9VbrpqNNSMzUZNFZKzgOGnd/VA==",
 			"license": "MIT",
+			"peer": true,
 			"dependencies": {
 				"undici-types": "~6.21.0"
 			}
@@ -7006,6 +6989,19 @@
 				"url": "https://github.com/sponsors/isaacs"
 			}
 		},
+		"node_modules/@vscode/test-cli/node_modules/picomatch": {
+			"version": "2.3.1",
+			"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+			"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+			"dev": true,
+			"license": "MIT",
+			"engines": {
+				"node": ">=8.6"
+			},
+			"funding": {
+				"url": "https://github.com/sponsors/jonschlinkert"
+			}
+		},
 		"node_modules/@vscode/test-cli/node_modules/readdirp": {
 			"version": "3.6.0",
 			"dev": true,
@@ -7504,6 +7500,7 @@
 			"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
 			"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
 			"license": "MIT",
+			"peer": true,
 			"bin": {
 				"acorn": "bin/acorn"
 			},
@@ -7651,6 +7648,19 @@
 				"node": ">= 8"
 			}
 		},
+		"node_modules/anymatch/node_modules/picomatch": {
+			"version": "2.3.1",
+			"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+			"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+			"dev": true,
+			"license": "MIT",
+			"engines": {
+				"node": ">=8.6"
+			},
+			"funding": {
+				"url": "https://github.com/sponsors/jonschlinkert"
+			}
+		},
 		"node_modules/append-transform": {
 			"version": "2.0.0",
 			"resolved": "https://registry.npmjs.org/append-transform/-/append-transform-2.0.0.tgz",
@@ -8270,6 +8280,7 @@
 				}
 			],
 			"license": "MIT",
+			"peer": true,
 			"dependencies": {
 				"baseline-browser-mapping": "^2.8.3",
 				"caniuse-lite": "^1.0.30001741",
@@ -9483,7 +9494,8 @@
 		},
 		"node_modules/devtools-protocol": {
 			"version": "0.0.1342118",
-			"license": "BSD-3-Clause"
+			"license": "BSD-3-Clause",
+			"peer": true
 		},
 		"node_modules/diff": {
 			"version": "5.2.0",
@@ -12459,6 +12471,7 @@
 			"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
 			"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
 			"license": "MIT",
+			"peer": true,
 			"bin": {
 				"jiti": "lib/jiti-cli.mjs"
 			}
@@ -12692,6 +12705,7 @@
 			"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz",
 			"integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==",
 			"license": "MPL-2.0",
+			"peer": true,
 			"dependencies": {
 				"detect-libc": "^2.0.3"
 			},
@@ -13568,6 +13582,18 @@
 				"node": ">=8.6"
 			}
 		},
+		"node_modules/micromatch/node_modules/picomatch": {
+			"version": "2.3.1",
+			"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+			"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+			"license": "MIT",
+			"engines": {
+				"node": ">=8.6"
+			},
+			"funding": {
+				"url": "https://github.com/sponsors/jonschlinkert"
+			}
+		},
 		"node_modules/mime": {
 			"version": "1.6.0",
 			"dev": true,
@@ -13760,6 +13786,19 @@
 				"node": ">=10"
 			}
 		},
+		"node_modules/mocha/node_modules/picomatch": {
+			"version": "2.3.1",
+			"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+			"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+			"dev": true,
+			"license": "MIT",
+			"engines": {
+				"node": ">=8.6"
+			},
+			"funding": {
+				"url": "https://github.com/sponsors/jonschlinkert"
+			}
+		},
 		"node_modules/mocha/node_modules/readdirp": {
 			"version": "3.6.0",
 			"dev": true,
@@ -15389,10 +15428,13 @@
 			"license": "ISC"
 		},
 		"node_modules/picomatch": {
-			"version": "2.3.1",
+			"version": "4.0.3",
+			"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+			"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
 			"license": "MIT",
+			"peer": true,
 			"engines": {
-				"node": ">=8.6"
+				"node": ">=12"
 			},
 			"funding": {
 				"url": "https://github.com/sponsors/jonschlinkert"
@@ -15572,7 +15614,6 @@
 				}
 			],
 			"license": "MIT",
-			"peer": true,
 			"dependencies": {
 				"nanoid": "^3.3.11",
 				"picocolors": "^1.1.1",
@@ -15593,7 +15634,6 @@
 				}
 			],
 			"license": "MIT",
-			"peer": true,
 			"bin": {
 				"nanoid": "bin/nanoid.cjs"
 			},
@@ -16261,7 +16301,6 @@
 			"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz",
 			"integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==",
 			"license": "MIT",
-			"peer": true,
 			"dependencies": {
 				"@types/estree": "1.0.8"
 			},
@@ -17687,7 +17726,6 @@
 			"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
 			"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
 			"license": "MIT",
-			"peer": true,
 			"dependencies": {
 				"fdir": "^6.5.0",
 				"picomatch": "^4.0.3"
@@ -17704,7 +17742,6 @@
 			"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
 			"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
 			"license": "MIT",
-			"peer": true,
 			"engines": {
 				"node": ">=12.0.0"
 			},
@@ -17717,19 +17754,6 @@
 				}
 			}
 		},
-		"node_modules/tinyglobby/node_modules/picomatch": {
-			"version": "4.0.3",
-			"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
-			"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
-			"license": "MIT",
-			"peer": true,
-			"engines": {
-				"node": ">=12"
-			},
-			"funding": {
-				"url": "https://github.com/sponsors/jonschlinkert"
-			}
-		},
 		"node_modules/tmp": {
 			"version": "0.2.5",
 			"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
@@ -18068,6 +18092,7 @@
 			"version": "5.5.3",
 			"dev": true,
 			"license": "Apache-2.0",
+			"peer": true,
 			"bin": {
 				"tsc": "bin/tsc",
 				"tsserver": "bin/tsserver"
@@ -18327,7 +18352,6 @@
 			"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
 			"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
 			"license": "MIT",
-			"peer": true,
 			"dependencies": {
 				"esbuild": "^0.25.0",
 				"fdir": "^6.5.0",
@@ -18402,7 +18426,6 @@
 			"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
 			"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
 			"license": "MIT",
-			"peer": true,
 			"engines": {
 				"node": ">=12.0.0"
 			},
@@ -18415,19 +18438,6 @@
 				}
 			}
 		},
-		"node_modules/vite/node_modules/picomatch": {
-			"version": "4.0.3",
-			"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
-			"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
-			"license": "MIT",
-			"peer": true,
-			"engines": {
-				"node": ">=12"
-			},
-			"funding": {
-				"url": "https://github.com/sponsors/jonschlinkert"
-			}
-		},
 		"node_modules/voca": {
 			"version": "1.4.1",
 			"resolved": "https://registry.npmjs.org/voca/-/voca-1.4.1.tgz",
@@ -19098,6 +19108,7 @@
 		"node_modules/zod": {
 			"version": "3.25.76",
 			"license": "MIT",
+			"peer": true,
 			"funding": {
 				"url": "https://github.com/sponsors/colinhacks"
 			}
@@ -19112,4 +19123,4 @@
 			}
 		}
 	}
-}
+}

+ 1 - 0
package.json

@@ -532,6 +532,7 @@
 		"p-timeout": "^6.1.4",
 		"p-wait-for": "^5.0.2",
 		"pdf-parse": "^1.1.1",
+		"picomatch": "^4.0.3",
 		"posthog-node": "^5.8.0",
 		"puppeteer-chromium-resolver": "^23.0.0",
 		"puppeteer-core": "^23.4.0",

+ 54 - 0
src/core/context/instructions/user-instructions/__tests__/rule-conditionals.test.ts

@@ -0,0 +1,54 @@
+import { expect } from "chai"
+import { evaluateRuleConditionals, extractPathLikeStrings } from "../rule-conditionals"
+
+describe("rule-conditionals", () => {
+	describe("evaluateRuleConditionals(paths)", () => {
+		it("treats missing paths as universal", () => {
+			const res = evaluateRuleConditionals({}, { paths: [] })
+			expect(res.passed).to.equal(true)
+		})
+
+		it("treats empty paths list in frontmatter as match-nothing (fail-closed)", () => {
+			const res = evaluateRuleConditionals({ paths: [] }, { paths: ["src/index.ts"] })
+			expect(res.passed).to.equal(false)
+		})
+
+		it("does not activate path-scoped rules with empty context", () => {
+			const res = evaluateRuleConditionals({ paths: ["src/**"] }, { paths: [] })
+			expect(res.passed).to.equal(false)
+		})
+
+		it("matches when any candidate path matches any glob", () => {
+			const res = evaluateRuleConditionals({ paths: ["src/**", "apps/**"] }, { paths: ["src/index.ts"] })
+			expect(res.passed).to.equal(true)
+			expect(res.matchedConditions.paths).to.deep.equal(["src/**"])
+		})
+
+		it("ignores invalid paths type (fail-open)", () => {
+			const res = evaluateRuleConditionals({ paths: "src/**" as any }, { paths: [] })
+			expect(res.passed).to.equal(true)
+		})
+	})
+
+	describe("extractPathLikeStrings", () => {
+		it("extracts basic relative paths", () => {
+			const res = extractPathLikeStrings("edit apps/web/src/App.tsx and packages/foo/src")
+			expect(res).to.deep.equal(["apps/web/src/App.tsx", "packages/foo/src"])
+		})
+
+		it("extracts simple filenames with extensions (no slashes)", () => {
+			const res = extractPathLikeStrings("Does foo.md exist? If not, create foo.md")
+			expect(res).to.deep.equal(["foo.md"])
+		})
+
+		it("does not extract bare words without an extension", () => {
+			const res = extractPathLikeStrings("Please create foo and then update bar")
+			expect(res).to.deep.equal([])
+		})
+
+		it("ignores URLs", () => {
+			const res = extractPathLikeStrings("see https://example.com/a/b and edit src/index.ts")
+			expect(res).to.deep.equal(["src/index.ts"])
+		})
+	})
+})

+ 117 - 0
src/core/context/instructions/user-instructions/__tests__/rule-loading.test.ts

@@ -0,0 +1,117 @@
+import { expect } from "chai"
+import fs from "fs/promises"
+import os from "os"
+import path from "path"
+import { getRuleFilesTotalContentWithMetadata } from "../rule-helpers"
+
+describe("rule loading with paths frontmatter", () => {
+	it("filters rules by evaluationContext.paths", async () => {
+		const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "cline-rules-test-"))
+		try {
+			const rulesDir = path.join(tmp, ".clinerules")
+			await fs.mkdir(rulesDir, { recursive: true })
+			await fs.writeFile(path.join(rulesDir, "universal.md"), "Always on")
+			await fs.writeFile(path.join(rulesDir, "scoped.md"), `---\npaths:\n  - "src/**"\n---\n\nOnly for src`)
+
+			const files = ["universal.md", "scoped.md"]
+			const toggles: Record<string, boolean> = {
+				[path.join(rulesDir, "universal.md")]: true,
+				[path.join(rulesDir, "scoped.md")]: true,
+			}
+
+			const res1 = await getRuleFilesTotalContentWithMetadata(files, rulesDir, toggles, {
+				evaluationContext: { paths: ["src/index.ts"] },
+			})
+			expect(res1.content).to.contain("universal.md")
+			expect(res1.content).to.contain("scoped.md")
+			expect(res1.content).to.not.contain("paths:")
+			expect(res1.activatedConditionalRules.map((r) => r.name)).to.include("scoped.md")
+
+			const res2 = await getRuleFilesTotalContentWithMetadata(files, rulesDir, toggles, {
+				evaluationContext: { paths: ["docs/readme.md"] },
+			})
+			expect(res2.content).to.contain("universal.md")
+			expect(res2.content).to.not.contain("scoped.md")
+		} finally {
+			await fs.rm(tmp, { recursive: true, force: true })
+		}
+	})
+
+	it("treats invalid YAML frontmatter as fail-open and preserves the raw frontmatter for the LLM", async () => {
+		const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "cline-rules-test-"))
+		try {
+			const rulesDir = path.join(tmp, ".clinerules")
+			await fs.mkdir(rulesDir, { recursive: true })
+			// Intentionally invalid YAML (unquoted '*' is a YAML alias indicator)
+			await fs.writeFile(
+				path.join(rulesDir, "invalid.md"),
+				`---\npaths: *\n---\n\nInvalid YAML, but should still be included`,
+			)
+
+			const files = ["invalid.md"]
+			const toggles: Record<string, boolean> = {
+				[path.join(rulesDir, "invalid.md")]: true,
+			}
+
+			const res = await getRuleFilesTotalContentWithMetadata(files, rulesDir, toggles, {
+				evaluationContext: { paths: ["src/index.ts"] },
+			})
+
+			// Fail-open: included even though frontmatter cannot be parsed.
+			expect(res.content).to.contain("invalid.md")
+			// Preserve raw frontmatter fence/content for the LLM.
+			expect(res.content).to.contain("---")
+			expect(res.content).to.contain("paths:")
+		} finally {
+			await fs.rm(tmp, { recursive: true, force: true })
+		}
+	})
+
+	it("treats paths: [] as match-nothing (fail-closed)", async () => {
+		const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "cline-rules-test-"))
+		try {
+			const rulesDir = path.join(tmp, ".clinerules")
+			await fs.mkdir(rulesDir, { recursive: true })
+			await fs.writeFile(path.join(rulesDir, "scoped-empty.md"), `---\npaths: []\n---\n\nShould never activate`)
+
+			const files = ["scoped-empty.md"]
+			const toggles: Record<string, boolean> = {
+				[path.join(rulesDir, "scoped-empty.md")]: true,
+			}
+
+			const res = await getRuleFilesTotalContentWithMetadata(files, rulesDir, toggles, {
+				evaluationContext: { paths: ["src/index.ts"] },
+			})
+
+			expect(res.content).to.not.contain("scoped-empty.md")
+		} finally {
+			await fs.rm(tmp, { recursive: true, force: true })
+		}
+	})
+
+	it("keeps activatedConditionalRules order stable (matches input file order)", async () => {
+		const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "cline-rules-test-"))
+		try {
+			const rulesDir = path.join(tmp, ".clinerules")
+			await fs.mkdir(rulesDir, { recursive: true })
+			await fs.writeFile(path.join(rulesDir, "a.md"), `---\npaths:\n  - "src/**"\n---\n\nA`)
+			await fs.writeFile(path.join(rulesDir, "b.md"), `---\npaths:\n  - "src/**"\n---\n\nB`)
+			await fs.writeFile(path.join(rulesDir, "c.md"), `---\npaths:\n  - "src/**"\n---\n\nC`)
+
+			const files = ["a.md", "b.md", "c.md"]
+			const toggles: Record<string, boolean> = {
+				[path.join(rulesDir, "a.md")]: true,
+				[path.join(rulesDir, "b.md")]: true,
+				[path.join(rulesDir, "c.md")]: true,
+			}
+
+			const res = await getRuleFilesTotalContentWithMetadata(files, rulesDir, toggles, {
+				evaluationContext: { paths: ["src/index.ts"] },
+			})
+
+			expect(res.activatedConditionalRules.map((r) => r.name)).to.deep.equal(files)
+		} finally {
+			await fs.rm(tmp, { recursive: true, force: true })
+		}
+	})
+})

+ 161 - 0
src/core/context/instructions/user-instructions/rule-conditionals.ts

@@ -0,0 +1,161 @@
+/**
+ * Rule frontmatter conditional evaluation.
+ *
+ * This module implements a small conditional "DSL" for Cline Rules YAML frontmatter.
+ * It is used to decide whether a rule should be activated for a given request context.
+ *
+ * Notes:
+ * - Unknown conditional keys are ignored for forward compatibility.
+ * - The `paths` conditional matches if any candidate path matches any glob pattern.
+ * - Candidate paths are expected to be workspace-root-relative POSIX paths.
+ */
+import * as path from "path"
+import picomatch from "picomatch"
+
+export type RuleEvaluationContext = {
+	/**
+	 * Candidate workspace-relative paths that represent the current request context.
+	 * These should be POSIX-style paths, relative to their workspace root.
+	 */
+	paths?: string[]
+}
+
+export type ConditionalEvaluator = (frontmatterValue: unknown, context: RuleEvaluationContext) => boolean
+
+type MatchedConditions = Record<string, string[]>
+
+type ConditionalEvaluatorResult = {
+	passed: boolean
+	matched?: string[]
+}
+
+type ConditionalEvaluatorWithMatch = (frontmatterValue: unknown, context: RuleEvaluationContext) => ConditionalEvaluatorResult
+
+function toPosix(p: string): string {
+	return p.replace(/\\/g, "/")
+}
+
+function isNonEmptyStringArray(value: unknown): value is string[] {
+	return Array.isArray(value) && value.every((v) => typeof v === "string" && v.length > 0)
+}
+
+const evaluatePathsConditional: ConditionalEvaluatorWithMatch = (frontmatterValue: unknown, context: RuleEvaluationContext) => {
+	// Invalid type -> ignore conditional (fail-open)
+	if (!isNonEmptyStringArray(frontmatterValue)) {
+		return { passed: true }
+	}
+
+	const patterns = frontmatterValue.map((p) => p.trim()).filter(Boolean)
+	// Policy:
+	// - `paths` omitted => universal (because this evaluator is never invoked)
+	// - `paths: []` (or `paths` that trims to no usable patterns) => match nothing (fail-closed)
+	//   This gives users an explicit way to disable a rule via frontmatter, while omission
+	//   remains the mechanism for "always on" rules.
+	if (patterns.length === 0) {
+		return { passed: false }
+	}
+
+	const candidatePaths = (context.paths || []).map((p) => toPosix(p)).filter(Boolean)
+	// Conservative: no evidence => do not activate path-scoped rules
+	if (candidatePaths.length === 0) {
+		return { passed: false }
+	}
+
+	const matchedPatterns: string[] = []
+
+	for (const pattern of patterns) {
+		const matcher = picomatch(pattern, { dot: true })
+		if (candidatePaths.some((candidate) => matcher(candidate))) {
+			matchedPatterns.push(pattern)
+		}
+	}
+
+	return { passed: matchedPatterns.length > 0, matched: matchedPatterns.length > 0 ? matchedPatterns : undefined }
+}
+
+const conditionalEvaluators: Record<string, ConditionalEvaluatorWithMatch> = {
+	paths: evaluatePathsConditional,
+}
+
+export function evaluateRuleConditionals(
+	frontmatter: Record<string, unknown>,
+	context: RuleEvaluationContext,
+): {
+	passed: boolean
+	matchedConditions: MatchedConditions
+} {
+	const matchedConditions: MatchedConditions = {}
+
+	for (const [key, value] of Object.entries(frontmatter)) {
+		const evaluator = conditionalEvaluators[key]
+		if (!evaluator) {
+			continue // unknown conditional: ignore
+		}
+
+		const result = evaluator(value, context)
+		if (!result.passed) {
+			return { passed: false, matchedConditions: {} }
+		}
+		if (result.matched && result.matched.length > 0) {
+			matchedConditions[key] = result.matched
+		}
+	}
+
+	return { passed: true, matchedConditions }
+}
+
+/**
+ * Extracts path-like strings from user text to help enable first-turn activation.
+ * This is intentionally heuristic and conservative.
+ */
+export function extractPathLikeStrings(text: string): string[] {
+	if (!text) return []
+
+	// 1) Remove URLs to avoid false positives.
+	const withoutUrls = text.replace(/\b\w+:\/\/[^\s]+/g, " ")
+
+	// 2) Match tokens that look like paths.
+	//    - Either contain at least one slash (e.g. src/index.ts)
+	//    - Or look like a simple filename with an extension (e.g. foo.md)
+	//      (no slashes; conservative to reduce false positives).
+	const tokenRegex =
+		/(?:^|[\s([{"'`])((?:[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)+\/?|[A-Za-z0-9_.-]+\.[A-Za-z0-9]{1,10}))(?=$|[\s)\]}"'`,.;:!?])/g
+	const matches: string[] = []
+	let match: RegExpExecArray | null
+	while ((match = tokenRegex.exec(withoutUrls))) {
+		const candidate = match[1]
+		if (!candidate) continue
+		// Normalize away leading ./
+		const normalized = candidate.startsWith("./") ? candidate.slice(2) : candidate
+		// Avoid absurdly long tokens
+		if (normalized.length > 300) continue
+		matches.push(normalized)
+	}
+
+	// De-dupe while preserving order
+	const seen = new Set<string>()
+	const result: string[] = []
+	for (const m of matches) {
+		const posix = m.replace(/\\/g, "/")
+		if (posix === "/" || posix.startsWith("/") || posix.includes("..")) {
+			// We only want repo/workspace-relative hints here.
+			continue
+		}
+		if (!seen.has(posix)) {
+			seen.add(posix)
+			result.push(posix)
+		}
+	}
+	return result
+}
+
+/**
+ * Normalize an absolute filesystem path to a workspace-root-relative POSIX path.
+ * Returns undefined if the absolute path is not within the given root.
+ */
+export function toWorkspaceRelativePosixPath(absPath: string, workspaceRoot: string): string | undefined {
+	const rel = path.relative(workspaceRoot, absPath)
+	// Outside the root
+	if (rel.startsWith("..") || path.isAbsolute(rel)) return undefined
+	return toPosix(rel)
+}

+ 102 - 5
src/core/context/instructions/user-instructions/rule-helpers.ts

@@ -5,6 +5,8 @@ import { fileExistsAtPath, isDirectory, readDirectory } from "@utils/fs"
 import fs from "fs/promises"
 import * as path from "path"
 import { Controller } from "@/core/controller"
+import { parseYamlFrontmatter } from "./frontmatter"
+import { evaluateRuleConditionals, RuleEvaluationContext } from "./rule-conditionals"
 
 /**
  * Recursively traverses directory and finds all files, including checking for optional whitelisted file extension
@@ -143,19 +145,114 @@ export function combineRuleToggles(toggles1: ClineRulesToggles, toggles2: ClineR
  * Read the content of rules files
  */
 export const getRuleFilesTotalContent = async (rulesFilePaths: string[], basePath: string, toggles: ClineRulesToggles) => {
-	const ruleFilesTotalContent = await Promise.all(
+	return (await getRuleFilesTotalContentWithMetadata(rulesFilePaths, basePath, toggles)).content
+}
+
+export type ActivatedConditionalRule = {
+	name: string
+	matchedConditions: Record<string, string[]>
+}
+
+export type RuleLoadResult = {
+	content: string
+	activatedConditionalRules: ActivatedConditionalRule[]
+}
+
+export const getRuleFilesTotalContentWithMetadata = async (
+	rulesFilePaths: string[],
+	basePath: string,
+	toggles: ClineRulesToggles,
+	opts?: { evaluationContext?: RuleEvaluationContext },
+): Promise<RuleLoadResult> => {
+	const evaluationContext = opts?.evaluationContext ?? {}
+
+	type RuleLoadPart = {
+		contentPart: string | null
+		activatedRule: ActivatedConditionalRule | null
+	}
+
+	const parts: RuleLoadPart[] = await Promise.all(
 		rulesFilePaths.map(async (filePath) => {
 			const ruleFilePath = path.resolve(basePath, filePath)
 			const ruleFilePathRelative = path.relative(basePath, ruleFilePath)
 
 			if (ruleFilePath in toggles && toggles[ruleFilePath] === false) {
-				return null
+				return { contentPart: null, activatedRule: null }
+			}
+
+			const raw = (await fs.readFile(ruleFilePath, "utf8")).trim()
+			if (!raw) {
+				return { contentPart: null, activatedRule: null }
+			}
+			const { data, body, hadFrontmatter, parseError } = parseYamlFrontmatter(raw)
+			// YAML parse errors are treated as fail-open.
+			// NOTE: We intentionally preserve the raw frontmatter fence/content here so the LLM can still
+			// see the author's intended scoping (e.g., `paths:`) and reason about it, even if it cannot be
+			// evaluated reliably due to invalid YAML.
+			if (hadFrontmatter && parseError) {
+				return { contentPart: `${ruleFilePathRelative}\n${raw}`, activatedRule: null }
+			}
+
+			const { passed, matchedConditions } = evaluateRuleConditionals(data, evaluationContext)
+			if (!passed) {
+				return { contentPart: null, activatedRule: null }
 			}
+			const activatedRule =
+				hadFrontmatter && Object.keys(matchedConditions).length > 0
+					? { name: ruleFilePathRelative, matchedConditions }
+					: null
 
-			return `${ruleFilePathRelative}\n` + (await fs.readFile(ruleFilePath, "utf8")).trim()
+			return { contentPart: `${ruleFilePathRelative}\n${body.trim()}`, activatedRule }
 		}),
-	).then((contents) => contents.filter(Boolean).join("\n\n"))
-	return ruleFilesTotalContent
+	)
+
+	return {
+		content: parts
+			.map((p) => p.contentPart)
+			.filter(Boolean)
+			.join("\n\n"),
+		activatedConditionalRules: parts
+			.map((p) => p.activatedRule)
+			.filter((rule): rule is ActivatedConditionalRule => rule !== null),
+	}
+}
+
+export function getRemoteRulesTotalContentWithMetadata(
+	remoteRules: GlobalInstructionsFile[],
+	remoteToggles: ClineRulesToggles,
+	opts?: { evaluationContext?: RuleEvaluationContext },
+): RuleLoadResult {
+	const activatedConditionalRules: ActivatedConditionalRule[] = []
+	const evaluationContext = opts?.evaluationContext ?? {}
+	let combinedContent = ""
+
+	for (const rule of remoteRules) {
+		const isEnabled = rule.alwaysEnabled || remoteToggles[rule.name] !== false
+		if (!isEnabled) continue
+
+		const raw = (rule.contents || "").trim()
+		if (!raw) continue
+
+		const { data, body, hadFrontmatter, parseError } = parseYamlFrontmatter(raw)
+		if (hadFrontmatter && parseError) {
+			// Fail open: include entire raw contents
+			if (combinedContent) combinedContent += "\n\n"
+			combinedContent += `${rule.name}\n${raw}`
+			continue
+		}
+
+		const { passed, matchedConditions } = evaluateRuleConditionals(data, evaluationContext)
+		if (!passed) continue
+
+		if (hadFrontmatter && Object.keys(matchedConditions).length > 0) {
+			activatedConditionalRules.push({ name: rule.name, matchedConditions })
+		}
+
+		if (combinedContent) combinedContent += "\n\n"
+		combinedContent += `${rule.name}\n${body.trim()}`
+	}
+
+	return { content: combinedContent, activatedConditionalRules }
 }
 
 /**

+ 15 - 0
src/types/picomatch.d.ts

@@ -0,0 +1,15 @@
+declare module "picomatch" {
+	type PicomatchOptions = {
+		dot?: boolean
+		nocase?: boolean
+		ignore?: string | string[]
+		posix?: boolean
+		windows?: boolean
+	}
+
+	type PicomatchMatcher = (input: string) => boolean
+
+	function picomatch(pattern: string | string[], options?: PicomatchOptions): PicomatchMatcher
+
+	export default picomatch
+}

+ 1 - 0
tsconfig.test.json

@@ -17,6 +17,7 @@
 		],
 		"typeRoots": [
 			"./node_modules/@types",
+			"./src/types",
 			"./src/test/types"
 		],
 		"outDir": "out",

+ 5 - 1
tsconfig.unit-test.json

@@ -2,7 +2,11 @@
 	"extends": "./tsconfig.json",
 	"compilerOptions": {
 		"module": "commonjs",
-		"moduleResolution": "node"
+		"moduleResolution": "node",
+		"typeRoots": [
+			"./node_modules/@types",
+			"./src/types"
+		]
 	},
 	"ts-node": {
 		"require": [