diff --git a/package-lock.json b/package-lock.json index 1098d30578..9cc57924ca 100644 --- a/package-lock.json +++ b/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 @@ } } } -} \ No newline at end of file +} diff --git a/package.json b/package.json index 95d34956a0..4de465e443 100644 --- a/package.json +++ b/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", diff --git a/src/core/context/instructions/user-instructions/__tests__/rule-conditionals.test.ts b/src/core/context/instructions/user-instructions/__tests__/rule-conditionals.test.ts new file mode 100644 index 0000000000..9766b689bb --- /dev/null +++ b/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"]) + }) + }) +}) diff --git a/src/core/context/instructions/user-instructions/__tests__/rule-loading.test.ts b/src/core/context/instructions/user-instructions/__tests__/rule-loading.test.ts new file mode 100644 index 0000000000..43c4f1dcc9 --- /dev/null +++ b/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 = { + [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 = { + [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 = { + [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 = { + [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 }) + } + }) +}) diff --git a/src/core/context/instructions/user-instructions/rule-conditionals.ts b/src/core/context/instructions/user-instructions/rule-conditionals.ts new file mode 100644 index 0000000000..7466b34e20 --- /dev/null +++ b/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 + +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 = { + paths: evaluatePathsConditional, +} + +export function evaluateRuleConditionals( + frontmatter: Record, + 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() + 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) +} diff --git a/src/core/context/instructions/user-instructions/rule-helpers.ts b/src/core/context/instructions/user-instructions/rule-helpers.ts index 97b4f33d7f..6e2f4f9f4c 100644 --- a/src/core/context/instructions/user-instructions/rule-helpers.ts +++ b/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 +} + +export type RuleLoadResult = { + content: string + activatedConditionalRules: ActivatedConditionalRule[] +} + +export const getRuleFilesTotalContentWithMetadata = async ( + rulesFilePaths: string[], + basePath: string, + toggles: ClineRulesToggles, + opts?: { evaluationContext?: RuleEvaluationContext }, +): Promise => { + 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 } } - return `${ruleFilePathRelative}\n` + (await fs.readFile(ruleFilePath, "utf8")).trim() + 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 { 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 } } /** diff --git a/src/types/picomatch.d.ts b/src/types/picomatch.d.ts new file mode 100644 index 0000000000..3c34ccc4aa --- /dev/null +++ b/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 +} diff --git a/tsconfig.test.json b/tsconfig.test.json index a4018e952a..253f80bc9a 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -17,6 +17,7 @@ ], "typeRoots": [ "./node_modules/@types", + "./src/types", "./src/test/types" ], "outDir": "out", diff --git a/tsconfig.unit-test.json b/tsconfig.unit-test.json index 199d416150..828eab1f62 100644 --- a/tsconfig.unit-test.json +++ b/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": [