From d4e6af83906d9c4acef3faaf860bf7b05d4969b2 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 07:09:39 +0000 Subject: [PATCH] Default to recursively searching for *.instructions.md in .github/instructions to match CLI & Web experiences (#298973) * Initial plan * feat: search recursively in .github/instructions for *.instructions.md files Co-authored-by: aeschli <6461412+aeschli@users.noreply.github.com> * refactor: limit recursive instructions traversal to default source folders only Co-authored-by: aeschli <6461412+aeschli@users.noreply.github.com> * refactor: limit recursive instructions traversal to non-root, wildcard-free folders with max depth 5 Co-authored-by: aeschli <6461412+aeschli@users.noreply.github.com> * fix: use isEqual from resources.ts for URI comparison in resolveFilesAtLocation Co-authored-by: aeschli <6461412+aeschli@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: aeschli <6461412+aeschli@users.noreply.github.com> Co-authored-by: Martin Aeschlimann --- .../promptSyntax/utils/promptFilesLocator.ts | 30 ++++++--- .../utils/promptFilesLocator.test.ts | 63 +++++++++++++++++++ 2 files changed, 85 insertions(+), 8 deletions(-) diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts index 5ee6297bb31..c0675b37659 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts @@ -9,10 +9,10 @@ import { ResourceSet } from '../../../../../../base/common/map.js'; import * as nls from '../../../../../../nls.js'; import { FileOperationError, FileOperationResult, IFileService } from '../../../../../../platform/files/common/files.js'; import { getPromptFileLocationsConfigKey, isTildePath, PromptsConfig } from '../config/config.js'; -import { basename, dirname, isEqualOrParent, joinPath } from '../../../../../../base/common/resources.js'; +import { basename, dirname, isEqual, isEqualOrParent, joinPath } from '../../../../../../base/common/resources.js'; import { IWorkspaceContextService, IWorkspaceFolder } from '../../../../../../platform/workspace/common/workspace.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; -import { COPILOT_CUSTOM_INSTRUCTIONS_FILENAME, AGENTS_SOURCE_FOLDER, getPromptFileExtension, getPromptFileType, LEGACY_MODE_FILE_EXTENSION, getCleanPromptName, AGENT_FILE_EXTENSION, getPromptFileDefaultLocations, SKILL_FILENAME, IPromptSourceFolder, IResolvedPromptFile, IResolvedPromptSourceFolder, PromptFileSource, isInClaudeRulesFolder } from '../config/promptFileLocations.js'; +import { COPILOT_CUSTOM_INSTRUCTIONS_FILENAME, AGENTS_SOURCE_FOLDER, getPromptFileExtension, getPromptFileType, LEGACY_MODE_FILE_EXTENSION, getCleanPromptName, AGENT_FILE_EXTENSION, getPromptFileDefaultLocations, SKILL_FILENAME, IPromptSourceFolder, IResolvedPromptFile, IResolvedPromptSourceFolder, PromptFileSource } from '../config/promptFileLocations.js'; import { PromptsType } from '../promptTypes.js'; import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; import { Schemas } from '../../../../../../base/common/network.js'; @@ -26,6 +26,11 @@ import { DisposableStore } from '../../../../../../base/common/lifecycle.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; import { IPathService } from '../../../../../services/path/common/pathService.js'; +/** + * Maximum recursion depth when traversing subdirectories for instruction files. + */ +const MAX_INSTRUCTIONS_RECURSION_DEPTH = 5; + /** * Utility class to locate prompt files. */ @@ -492,14 +497,23 @@ export class PromptFilesLocator { /** * Uses the file service to resolve the provided location and return either the file at the location of files in the directory. - * For claude rules folders (.claude/rules), this searches recursively to support subdirectories. + * For instruction folders, this searches recursively (up to {@link MAX_INSTRUCTIONS_RECURSION_DEPTH} levels deep) provided + * the location is not a workspace folder root and does not contain wildcards, to support subdirectories while avoiding + * accidentally broad traversal. */ - private async resolveFilesAtLocation(location: URI, type: PromptsType, token: CancellationToken): Promise { + private async resolveFilesAtLocation(location: URI, type: PromptsType, token: CancellationToken, depth: number = 0): Promise { if (type === PromptsType.skill) { return this.findAgentSkillsInFolder(location, token); } - // Claude rules folders support subdirectories, so search recursively - const recursive = type === PromptsType.instructions && isInClaudeRulesFolder(joinPath(location, 'dummy.md')); + // Recurse into subdirectories for instruction folders, but only if: + // - the location is not a workspace folder root (to avoid full workspace traversal) + // - the path does not contain wildcards (already filtered upstream, but guard here too) + // - the recursion depth hasn't exceeded the limit + const isWorkspaceRoot = depth === 0 && this.getWorkspaceFolders().some(f => isEqual(f.uri, location)); + const recursive = type === PromptsType.instructions + && !isWorkspaceRoot + && !hasGlobPattern(location.path) + && depth < MAX_INSTRUCTIONS_RECURSION_DEPTH; try { const info = await this.fileService.resolve(location); if (token.isCancellationRequested) { @@ -513,8 +527,8 @@ export class PromptFilesLocator { if (child.isFile) { result.push(child.resource); } else if (recursive && child.isDirectory) { - // Recursively search subdirectories for claude rules - const subFiles = await this.resolveFilesAtLocation(child.resource, type, token); + // Recursively search subdirectories for instructions + const subFiles = await this.resolveFilesAtLocation(child.resource, type, token, depth + 1); result.push(...subFiles); } } diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/utils/promptFilesLocator.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/utils/promptFilesLocator.test.ts index 5d9664f7907..2dde1dfb0a2 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/utils/promptFilesLocator.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/utils/promptFilesLocator.test.ts @@ -2366,6 +2366,69 @@ suite('PromptFilesLocator', () => { }); }); + suite('instructions', () => { + testT('finds instructions files in subdirectories of .github/instructions', async () => { + const locator = await createPromptsLocator( + { + '.github/instructions': true, + '.claude/rules': false, + '~/.copilot/instructions': false, + }, + ['/Users/legomushroom/repos/vscode'], + [ + { + name: '/Users/legomushroom/repos/vscode', + children: [ + { + name: '.github/instructions', + children: [ + { + name: 'root.instructions.md', + contents: 'root instructions', + }, + { + name: 'frontend', + children: [ + { + name: 'react.instructions.md', + contents: 'react instructions', + }, + { + name: 'css.instructions.md', + contents: 'css instructions', + }, + ], + }, + { + name: 'backend', + children: [ + { + name: 'api.instructions.md', + contents: 'api instructions', + }, + ], + }, + ], + }, + ], + }, + ], + ); + + assertOutcome( + await locator.listFiles(PromptsType.instructions, PromptsStorage.local, CancellationToken.None), + [ + '/Users/legomushroom/repos/vscode/.github/instructions/root.instructions.md', + '/Users/legomushroom/repos/vscode/.github/instructions/frontend/react.instructions.md', + '/Users/legomushroom/repos/vscode/.github/instructions/frontend/css.instructions.md', + '/Users/legomushroom/repos/vscode/.github/instructions/backend/api.instructions.md', + ], + 'Must find instructions files recursively in subdirectories of .github/instructions.', + ); + await locator.disposeAsync(); + }); + }); + suite('skills', () => { suite('findAgentSkills', () => { testT('finds skill files in configured locations', async () => {