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 <martinae@microsoft.com>
This commit is contained in:
Copilot
2026-03-05 07:09:39 +00:00
committed by GitHub
parent 5ee6f4f532
commit d4e6af8390
2 changed files with 85 additions and 8 deletions

View File

@@ -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<URI[]> {
private async resolveFilesAtLocation(location: URI, type: PromptsType, token: CancellationToken, depth: number = 0): Promise<URI[]> {
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);
}
}

View File

@@ -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 () => {