From 4fa9ad781ec1e9e6cffc71d9765a290390e66f3d Mon Sep 17 00:00:00 2001 From: Santhi Prakash Date: Fri, 21 Aug 2026 07:03:15 +0000 Subject: [PATCH] fix(core): match rule globs against file paths containing spaces Rule globs like `docs/**/*.md` or the exact glob `docs/foo bar.md` silently failed to match files whose names contain spaces, so any rule whose target files lived under `docs/foo bar.md` was excluded from the system prompt. The root cause is in extractPathsFromCodeBlocks: its filename regex excluded whitespace, so a code block header like ```typescript docs/foo bar.md` was truncated to just `bar.md`. Replace the regex with a small parser that strips the optional language tag and trailing line range but preserves any spaces inside the path. Fixes #13135 --- .../rules/getSystemMessageWithRules.vitest.ts | 51 +++++++++++++++ .../utils/extractPathsFromCodeBlocks.test.ts | 13 ++++ core/llm/utils/extractPathsFromCodeBlocks.ts | 65 ++++++++++++++----- .../extractPathsFromCodeBlocks.vitest.ts | 61 +++++++++++++++++ 4 files changed, 173 insertions(+), 17 deletions(-) create mode 100644 core/llm/utils/extractPathsFromCodeBlocks.vitest.ts diff --git a/core/llm/rules/getSystemMessageWithRules.vitest.ts b/core/llm/rules/getSystemMessageWithRules.vitest.ts index 104ceecf8ca..8a727cff6d7 100644 --- a/core/llm/rules/getSystemMessageWithRules.vitest.ts +++ b/core/llm/rules/getSystemMessageWithRules.vitest.ts @@ -476,4 +476,55 @@ describe("Content pattern matching", () => { shouldApplyRule(nestedPatternRule, [utilFilePath], {}, utilContents), ).toBe(false); }); + + // Regression for https://github.com/continuedev/continue/issues/13135 + it("should apply rules to files whose names contain spaces", () => { + const docsRule: RuleWithSource = { + name: "Docs Rule", + rule: "Write docs in the active voice", + globs: "docs/**/*.md", + source: "rules-block", + sourceFile: "docs/rules.md", + }; + + // Code block headers are "``` ()", where the + // language and range are both optional. + const messages: UserChatMessage[] = [ + { + role: "user", + content: "What do you think?\n```docs/foo bar.md\n# Title\n```", + }, + { + role: "user", + content: "What do you think?\n```md docs/foo bar.md\n# Title\n```", + }, + { + role: "user", + content: + "What do you think?\n```md docs/foo bar.md (1-1)\n# Title\n```", + }, + ]; + + for (const message of messages) { + expect(getApplicableRules(message, [docsRule], [])).toHaveLength(1); + } + }); + + // Regression for https://github.com/continuedev/continue/issues/13135 + it("should match exact globs whose filenames contain spaces", () => { + const exactRule: RuleWithSource = { + name: "Exact Docs Rule", + rule: "Only docs/foo bar.md", + globs: "docs/foo bar.md", + source: "rules-block", + sourceFile: "docs/rules.md", + }; + + const message: UserChatMessage = { + role: "user", + content: "What do you think?\n```docs/foo bar.md\n# Title\n```", + }; + + expect(getApplicableRules(message, [exactRule], [])).toHaveLength(1); + }); }); diff --git a/core/llm/utils/extractPathsFromCodeBlocks.test.ts b/core/llm/utils/extractPathsFromCodeBlocks.test.ts index b318348dc9e..81abf35968e 100644 --- a/core/llm/utils/extractPathsFromCodeBlocks.test.ts +++ b/core/llm/utils/extractPathsFromCodeBlocks.test.ts @@ -28,6 +28,19 @@ describe("extractPathsFromCodeBlocks", () => { expect(result.length).toBe(3); }); + // Regression for https://github.com/continuedev/continue/issues/13135 + it("should extract paths whose filenames contain spaces", () => { + expect( + extractPathsFromCodeBlocks("```docs/foo bar.md\n# Title\n```"), + ).toEqual(["docs/foo bar.md"]); + expect( + extractPathsFromCodeBlocks("```md docs/foo bar.md\n# Title\n```"), + ).toEqual(["docs/foo bar.md"]); + expect( + extractPathsFromCodeBlocks("```md docs/foo bar.md (1-3)\n# Title\n```"), + ).toEqual(["docs/foo bar.md"]); + }); + it("should not extract paths from code blocks without file paths", () => { const content = "```typescript\nconst x = 1;\n```"; expect(extractPathsFromCodeBlocks(content)).toEqual([]); diff --git a/core/llm/utils/extractPathsFromCodeBlocks.ts b/core/llm/utils/extractPathsFromCodeBlocks.ts index 99f2764b21d..05a8cfd025d 100644 --- a/core/llm/utils/extractPathsFromCodeBlocks.ts +++ b/core/llm/utils/extractPathsFromCodeBlocks.ts @@ -13,24 +13,55 @@ export function extractPathsFromCodeBlocks(content: string): string[] { const codeBlockStarts = content.match(/```[^\n]+/g) || []; for (const blockStart of codeBlockStarts) { - // Try to extract a valid filename with extension - const filenameMatches = blockStart.match(/([^\s()```]+\.[a-zA-Z0-9]+)/); - - if (filenameMatches && filenameMatches[1]) { - const filename = filenameMatches[1]; - - // Verify this is a legitimate filename (not part of something else) - if ( - // Check if valid extension - /\.[a-zA-Z0-9]+$/.test(filename) && - // Make sure it's not a URL - !filename.includes("://") && - // Avoid duplicates - !paths.includes(filename) - ) { - paths.push(filename); - } + const path = extractPathFromCodeBlockStart(blockStart); + + // Verify this is a legitimate path (not part of something else) + if ( + path && + // Check if valid extension + /\.[a-zA-Z0-9]+$/.test(path) && + // Make sure it's not a URL + !path.includes("://") && + // Avoid duplicates + !paths.includes(path) + ) { + paths.push(path); } } return paths; } + +/** + * Extracts the file path from a single code block opening line, e.g. + * "```typescript src/main.ts (1-10)" -> "src/main.ts". + * + * The opening line can take any of these shapes: + * ``` + * ``` + * ``` (-) + * + * The path itself may contain spaces, so we only use spaces to split the + * optional language tag from the path. + */ +function extractPathFromCodeBlockStart(blockStart: string): string | undefined { + let path = blockStart + .replace(/^`+/, "") + // Drop a trailing line range, e.g. " (1-10)" + .replace(/\s+\([\d-]+\)$/, "") + .trim(); + + if (!path) return undefined; + + // A leading language tag (e.g. "typescript", "md") is a single token with + // no path separators. Anything containing "/" or "\" must be part of the + // path so that paths with spaces (e.g. "docs/foo bar.md") survive intact. + const firstSpaceIndex = path.search(/\s/); + if (firstSpaceIndex !== -1) { + const firstToken = path.slice(0, firstSpaceIndex); + if (!firstToken.includes("/") && !firstToken.includes("\\")) { + path = path.slice(firstSpaceIndex + 1).trim(); + } + } + + return path || undefined; +} diff --git a/core/llm/utils/extractPathsFromCodeBlocks.vitest.ts b/core/llm/utils/extractPathsFromCodeBlocks.vitest.ts new file mode 100644 index 00000000000..345c540e557 --- /dev/null +++ b/core/llm/utils/extractPathsFromCodeBlocks.vitest.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { extractPathsFromCodeBlocks } from "./extractPathsFromCodeBlocks"; + +describe("extractPathsFromCodeBlocks", () => { + it("should extract paths from code blocks with language and filepath", () => { + const content = "```typescript src/main.ts\nconst x = 1;\n```"; + expect(extractPathsFromCodeBlocks(content)).toEqual(["src/main.ts"]); + }); + + it("should extract paths from code blocks with filepath only (no language)", () => { + const content = "```test.js\nclass Calculator { }\n```"; + expect(extractPathsFromCodeBlocks(content)).toEqual(["test.js"]); + }); + + it("should extract paths from code blocks with line ranges", () => { + const content = "```js test.js (19-25)\nfunction test() {}\n```"; + expect(extractPathsFromCodeBlocks(content)).toEqual(["test.js"]); + }); + + it("should handle multiple code blocks with different formats", () => { + const content = + "```typescript src/main.ts\nconst x = 1;\n```\n" + + "```test.js\nclass Calculator { }\n```\n" + + "```js utils.js (19-25)\nfunction test() {}\n```"; + const result = extractPathsFromCodeBlocks(content); + expect(result).toContain("src/main.ts"); + expect(result).toContain("test.js"); + expect(result).toContain("utils.js"); + expect(result.length).toBe(3); + }); + + it("should not extract paths from code blocks without file paths", () => { + const content = "```typescript\nconst x = 1;\n```"; + expect(extractPathsFromCodeBlocks(content)).toEqual([]); + }); + + // Regression for https://github.com/continuedev/continue/issues/13135 + it("should extract paths whose filenames contain spaces (no language)", () => { + expect( + extractPathsFromCodeBlocks("```docs/foo bar.md\n# Title\n```"), + ).toEqual(["docs/foo bar.md"]); + }); + + it("should extract paths whose filenames contain spaces (with language)", () => { + expect( + extractPathsFromCodeBlocks("```md docs/foo bar.md\n# Title\n```"), + ).toEqual(["docs/foo bar.md"]); + }); + + it("should extract paths whose filenames contain spaces (with language and range)", () => { + expect( + extractPathsFromCodeBlocks("```md docs/foo bar.md (1-3)\n# Title\n```"), + ).toEqual(["docs/foo bar.md"]); + }); + + it("should extract paths with spaces in nested directory segments", () => { + expect( + extractPathsFromCodeBlocks("```ts src/my folder/file.ts\ncode\n```"), + ).toEqual(["src/my folder/file.ts"]); + }); +});