Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,7 @@ Defines rules installed into each provider's rules directory or instructions fil
**Shared instruction files.** Several providers read `AGENTS.md` (Codex, Cursor, OpenCode, Gemini CLI, …). Which file each provider reads depends only on the `providers` list:
- Gemini CLI reads `AGENTS.md` when it is the only `AGENTS.md` reader. Otherwise capa gives it its own generated `GEMINI.md` (agent snippets plus the rules Gemini may see). Capa makes sure `.gemini/settings.json` → `context.fileName` includes that file (keeping Gemini's `GEMINI.md` default), records what it added in `capabilities.lock`, and `capa clean` removes only those entries.
- A rule restricted with `providers` that would still be visible to another reader of the same file (e.g. a Codex-only rule while Cursor also reads `AGENTS.md`) is a **visibility conflict**.
- A provider with a rules directory that also reads the file a rule is folded into (e.g. Cursor reading the `AGENTS.md` capa writes for Codex) gets the folded copy only; its native rule file is skipped so the rule isn't delivered twice. If the fold is a root `> Applies to:` fallback, capa warns (`scope-widened`) even under `scope: best-effort`, since that provider loses its native `appliesTo` scope. Use directory globs to keep the scope for every provider.

**`appliesTo` for folded rules.** Directory globs (`src/**`, `packages/api/**/*`) become marker blocks in nested files (`src/AGENTS.md`, `src/GEMINI.md`) for providers that read nested instruction files (Codex, Gemini CLI). The directory must already exist. Other globs (`**/*.py`) can't be scoped natively, so they are folded at the project root with an `> Applies to:` note; this is a **scope conflict**.

Expand Down
82 changes: 79 additions & 3 deletions src/cli/utils/__tests__/rules-shared-instructions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,8 +154,8 @@ describe('rules in shared instruction files', () => {
const bodies = new Map([['py', 'Py.']]);
const cursorFile = join(projectPath, '.cursor', 'rules', 'py.mdc');

// Installed earlier under warn mode: both providers have it.
installRules(projectPath, [rule], ['codex', 'cursor'], bodies);
// Installed earlier when Cursor was the only provider.
installRules(projectPath, [rule], ['cursor'], bodies);
expect(existsSync(cursorFile)).toBe(true);

const prune = pruneRules(projectPath, ['codex', 'cursor'], [rule], [cursorFile], {
Expand All @@ -168,7 +168,83 @@ describe('rules in shared instruction files', () => {
expect(prune.diagnostics.map((d) => d.level)).toEqual(['error']);
expect(install.diagnostics.map((d) => d.level)).toEqual(['error']);
expect(existsSync(cursorFile)).toBe(false);
expect(read('AGENTS.md')).not.toContain('Py.');
expect(exists('AGENTS.md')).toBe(false);
});

it('delivers a rule once to cursor when codex folds it into AGENTS.md (#260)', () => {
mkdirSync(join(projectPath, 'services'));
const rules: Rule[] = [
{ id: 'all', type: 'inline', content: 'All.' },
{ id: 'svc', type: 'inline', appliesTo: ['services/**'], content: 'Svc.' },
];
const { install } = sync(rules, ['codex', 'cursor']);

expect(read('AGENTS.md')).toContain('All.');
expect(read('services/AGENTS.md')).toContain('Svc.');
expect(exists('.cursor/rules/all.mdc')).toBe(false);
expect(exists('.cursor/rules/svc.mdc')).toBe(false);
expect(install.diagnostics).toEqual([]);
});

it('warns when codex best-effort fold widens cursor scope, even under best-effort (#260)', () => {
const rule: Rule = {
id: 'py',
type: 'inline',
appliesTo: ['**/*.py'],
scope: 'best-effort',
content: 'Py.',
};
const { install } = sync([rule], ['codex', 'cursor']);

expect(read('AGENTS.md')).toContain('Py.');
expect(exists('.cursor/rules/py.mdc')).toBe(false);
expect(install.diagnostics.map((d) => [d.code, d.level])).toEqual([['scope-widened', 'warn']]);
});

it('dedupes cursor even when gemini is isolated onto GEMINI.md', () => {
const rule: Rule = { id: 'all', type: 'inline', content: 'All.' };
sync([rule], ['codex', 'gemini-cli', 'cursor']);

expect(read('AGENTS.md')).toContain('All.');
expect(read('GEMINI.md')).toContain('All.');
expect(exists('.cursor/rules/all.mdc')).toBe(false);
});

it('keeps the native cursor rule when the nested folded target dir is missing', () => {
const rule: Rule = { id: 'svc', type: 'inline', appliesTo: ['services/**'], content: 'Svc.' };
const bodies = new Map([['svc', 'Svc.']]);
const cursorFile = join(projectPath, '.cursor', 'rules', 'svc.mdc');
installRules(projectPath, [rule], ['cursor'], bodies);

const prune = pruneRules(projectPath, ['codex', 'cursor'], [rule], [cursorFile]);
installRules(projectPath, [rule], ['codex', 'cursor'], bodies);

expect(prune.removedFiles).toEqual([]);
expect(existsSync(cursorFile)).toBe(true);
expect(exists('services/AGENTS.md')).toBe(false);
});

it('keeps the native cursor rule when codex does not receive the rule', () => {
const rule: Rule = { id: 'cur', type: 'inline', providers: ['cursor'], content: 'Cur.' };
sync([rule], ['codex', 'cursor']);

expect(exists('.cursor/rules/cur.mdc')).toBe(true);
expect(exists('AGENTS.md')).toBe(false);
});

it('prunes a now-duplicate native cursor rule when codex is added', () => {
const rule: Rule = { id: 'all', type: 'inline', content: 'All.' };
const bodies = new Map([['all', 'All.']]);
const cursorFile = join(projectPath, '.cursor', 'rules', 'all.mdc');
installRules(projectPath, [rule], ['cursor'], bodies);
expect(existsSync(cursorFile)).toBe(true);

const prune = pruneRules(projectPath, ['codex', 'cursor'], [rule], [cursorFile]);
installRules(projectPath, [rule], ['codex', 'cursor'], bodies);

expect(prune.removedFiles).toEqual([cursorFile]);
expect(existsSync(cursorFile)).toBe(false);
expect(read('AGENTS.md')).toContain('All.');
});

it('cleanRules finds nested targets from the rules when the DB has no record', () => {
Expand Down
14 changes: 12 additions & 2 deletions src/cli/utils/rules-installer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,12 @@ export interface InstallRulesResult {
installedRuleIds: string[];
}

/** Mirrors the nested-file checks in {@link installRules}. */
function canWriteNested(projectPath: string, relPath: string): boolean {
const filePath = join(projectPath, relPath);
return existsSync(dirname(filePath)) && isCapaOwnedInstallPath(projectPath, filePath);
}

/**
* Install rules for all active providers.
*
Expand All @@ -266,6 +272,7 @@ export function installRules(
readerProviders: options.readerProviders ?? providers,
targetProviders: providers,
conflicts: options.conflicts,
canWrite: (rel) => canWriteNested(projectPath, rel),
});
// A rule with an error-level conflict is skipped for every provider, native
// rules directories included.
Expand All @@ -277,8 +284,9 @@ export function installRules(
const provider = getProvider(pid);
if (!provider?.rules) continue;

const covered = plan.nativeCovered.get(provider.id);
const applicableRules = rules.filter((r) => {
if (skipped.has(r.id)) return false;
if (skipped.has(r.id) || covered?.has(r.id)) return false;
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
if (!r.providers || r.providers.length === 0) return true;
return r.providers.includes(pid);
});
Expand Down Expand Up @@ -451,6 +459,7 @@ export function pruneRules(
rules: currentRules,
readerProviders: providers,
conflicts: options.conflicts,
canWrite: (rel) => canWriteNested(projectPath, rel),
});
const skipped = skippedRuleIds(plan.diagnostics);

Expand All @@ -459,8 +468,9 @@ export function pruneRules(
if (!provider?.rules) continue;

const desiredForProvider = new Set<string>();
const covered = plan.nativeCovered.get(provider.id);
for (const r of currentRules) {
if (skipped.has(r.id)) continue;
if (skipped.has(r.id) || covered?.has(r.id)) continue;
if (!r.providers || r.providers.length === 0 || r.providers.includes(pid)) {
desiredForProvider.add(r.id);
}
Expand Down
55 changes: 53 additions & 2 deletions src/cli/utils/rules-placement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,15 @@
* 2. Where each folded rule block goes (root or nested `dir/<file>`), and
* which placements would widen visibility or scope. Those are reported
* as diagnostics instead of happening silently.
* 3. Which providers with a native rules directory already read a folded
* copy (Cursor reads `AGENTS.md`), so their native file is skipped
* instead of delivering the rule twice.
*
* Install, prune, and clean all derive their view of the world from this
* plan, so their results don't depend on provider iteration order.
*/

import { posix } from 'path';
import type { Rule } from '../../types/rules';
import type { CapabilitiesOptions } from '../../types/capabilities';
import type { InstructionsContextConfig } from '../../types/providers';
Expand All @@ -26,6 +30,7 @@ export type RuleConflictMode = 'warn' | 'error';
export type RuleDiagnosticCode =
| 'visibility-conflict'
| 'scope-not-representable'
| 'scope-widened'
| 'invalid-glob';

export interface RuleDiagnostic {
Expand Down Expand Up @@ -56,6 +61,11 @@ export interface RulePlacementPlan {
/** Project-relative POSIX path → rule blocks placed there, in rule order. */
blocks: Map<string, PlannedRuleBlock[]>;
diagnostics: RuleDiagnostic[];
/**
* Provider id → rule ids it already receives through a folded instructions
* file. Install skips (and prune removes) the native rule file for these.
*/
nativeCovered: Map<string, Set<string>>;
}

export interface PlanRulePlacementInput {
Expand All @@ -68,6 +78,12 @@ export interface PlanRulePlacementInput {
*/
targetProviders?: string[];
conflicts?: RuleConflictMode;
/**
* Whether a nested placement (`dir/<file>`) can actually be written. A
* native rule file is only dropped in favour of folded copies that land.
* Defaults to true (pure planning, e.g. diagnostics only).
*/
canWrite?: (relPath: string) => boolean;
}

/** Resolve `options.rules.conflicts`, defaulting to `error` under `onInstallError: stop`. */
Expand Down Expand Up @@ -152,6 +168,7 @@ export function planRulePlacement(input: PlanRulePlacementInput): RulePlacementP

const blocks = new Map<string, PlannedRuleBlock[]>();
const diagnostics: RuleDiagnostic[] = [];
const nativeCovered = new Map<string, Set<string>>();

for (const rule of input.rules) {
const allowed = new Set(
Expand Down Expand Up @@ -232,8 +249,42 @@ export function planRulePlacement(input: PlanRulePlacementInput): RulePlacementP
}
}

const skipped = level === 'error' && ruleDiagnostics.length > 0;
if (!skipped && placements.length > 0) {
// An allowed provider with a native rules dir that also reads the file
// this rule is folded into (Cursor + AGENTS.md) would get it twice. The
// folded copy is at least as broad as the native one, so the native
// file adds nothing but the duplicate. Only the provider's own file
// counts (not an isolated GEMINI.md copy), and only if every location
// got a copy that will actually be written.
for (const pid of activeIds) {
if (!allowed.has(pid) || foldsRulesIntoInstructions(pid)) continue;
const file = layout.providerFile.get(pid);
const mine = placements.filter((p) => posix.basename(p.path) === file);
if (mine.length !== locations.length) continue;
if (!mine.every((p) => p.path === file || (input.canWrite?.(p.path) ?? true))) continue;
const covered = nativeCovered.get(pid) ?? new Set<string>();
covered.add(rule.id);
nativeCovered.set(pid, covered);
if (mine.some((p) => p.preamble)) {
// Reported even under `scope: best-effort`: that opt-in covers the
// folding providers, not one that could scope the rule natively.
ruleDiagnostics.push({
code: 'scope-widened',
ruleId: rule.id,
level: 'warn',
message:
`Rule "${rule.id}": ${pid} also reads ${mine.map((p) => p.path).join(', ')}, ` +
`so it gets the project-wide copy folded for ${targets.join(', ')} instead of its ` +
`native appliesTo scope (its own rule file is skipped to avoid a duplicate). ` +
`Use directory globs (e.g. "src/**") to keep the scope for every provider.`,
});
}
}
}

diagnostics.push(...ruleDiagnostics);
if (level === 'error' && ruleDiagnostics.length > 0) continue;
if (skipped) continue;

for (const { path, preamble } of placements) {
const list = blocks.get(path) ?? [];
Expand All @@ -242,7 +293,7 @@ export function planRulePlacement(input: PlanRulePlacementInput): RulePlacementP
}
}

return { layout, blocks, diagnostics };
return { layout, blocks, diagnostics, nativeCovered };
}

/** Rule body as written inside its marker block. */
Expand Down
Loading