diff --git a/src/cli/commands/generate.test.ts b/src/cli/commands/generate.test.ts index 75752ee64..8980b75a3 100644 --- a/src/cli/commands/generate.test.ts +++ b/src/cli/commands/generate.test.ts @@ -46,6 +46,7 @@ describe("generateCommand", () => { getSilent: vi.fn().mockReturnValue(false), getOutputRoots: vi.fn().mockReturnValue(["."]), getTargets: vi.fn().mockReturnValue(["claudecode"]), + getConfigFileTargets: vi.fn().mockReturnValue(["claudecode"]), getFeatures: vi.fn().mockReturnValue(["rules", "ignore", "mcp", "commands", "subagents"]), getFeatureOptions: vi.fn().mockReturnValue(undefined), getDelete: vi.fn().mockReturnValue(false), diff --git a/src/config/config-resolver.ts b/src/config/config-resolver.ts index 47077c120..b9352bd3d 100644 --- a/src/config/config-resolver.ts +++ b/src/config/config-resolver.ts @@ -6,6 +6,12 @@ import { RULESYNC_CONFIG_RELATIVE_FILE_PATH, RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH, } from "../constants/rulesync-paths.js"; +import { + ALL_TOOL_TARGETS, + type ToolTarget, + isRulesyncConfigTargetsObject, + type RulesyncConfigTargets, +} from "../types/tool-targets.js"; import { fileExists, getHomeDirectory, @@ -310,6 +316,7 @@ export class ConfigResolver { // captured `cwd` so the value is still deterministic. inputRoot: resolvedInputRoot !== undefined ? resolve(resolvedInputRoot) : cwd, sources: configByFile.sources ?? getDefaults().sources, + configFileTargets: extractConfigFileTargets(configByFile.targets), }; const config = new Config(configParams); // The legacy `antigravity` target is never produced by wildcard expansion @@ -343,3 +350,14 @@ function getOutputRootsInLightOfGlobal({ return outputRoots.map((outputRoot) => resolve(outputRoot)); } + +function extractConfigFileTargets( + targets: RulesyncConfigTargets | undefined, +): ToolTarget[] | undefined { + if (targets === undefined) return undefined; + const validTargets = new Set(ALL_TOOL_TARGETS); + if (isRulesyncConfigTargetsObject(targets)) { + return Object.keys(targets).filter((key): key is ToolTarget => validTargets.has(key)); + } + return targets.filter((key): key is ToolTarget => key !== "*" && validTargets.has(key)); +} diff --git a/src/config/config.ts b/src/config/config.ts index 2674374e7..694252024 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -105,6 +105,7 @@ type InferredConfigParams = z.infer; export type ConfigParams = Omit & { targets?: RulesyncConfigTargets; features?: RulesyncFeatures; + configFileTargets?: ToolTarget[]; }; export const PartialConfigParamsSchema = z.partial(ConfigParamsSchema); @@ -223,6 +224,7 @@ export class Config { * Undefined when `this.targets` is in array form. */ private readonly objectFormTargetKeys: ToolTarget[] | undefined; + private readonly configFileTargets: ToolTarget[] | undefined; private readonly verbose: boolean; private readonly delete: boolean; private readonly global: boolean; @@ -254,6 +256,7 @@ export class Config { check, inputRoot, sources, + configFileTargets, }: ConfigParams) { // Defense-in-depth: enforce the same mutual-exclusivity rule that the // file loader applies, so programmatic `new Config(...)` callers can't @@ -293,6 +296,7 @@ export class Config { this.objectFormTargetKeys = isRulesyncConfigTargetsObject(resolvedTargets) ? Config.filterValidToolTargets(Object.keys(resolvedTargets)) : undefined; + this.configFileTargets = configFileTargets; this.verbose = verbose; this.delete = isDelete; @@ -428,6 +432,10 @@ export class Config { return arrayTargets.filter((target): target is ToolTarget => target !== "*"); } + public getConfigFileTargets(): ToolTarget[] { + return this.configFileTargets ?? this.getTargets(); + } + public getFeatures(): Features; public getFeatures(target: ToolTarget): Features; public getFeatures(target?: ToolTarget): Features { diff --git a/src/e2e/e2e-rules.spec.ts b/src/e2e/e2e-rules.spec.ts index 78155e952..5de3e0e3a 100644 --- a/src/e2e/e2e-rules.spec.ts +++ b/src/e2e/e2e-rules.spec.ts @@ -3,6 +3,7 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { + RULESYNC_CONFIG_RELATIVE_FILE_PATH, RULESYNC_MCP_RELATIVE_FILE_PATH, RULESYNC_OVERVIEW_FILE_NAME, RULESYNC_RULES_RELATIVE_DIR_PATH, @@ -260,6 +261,74 @@ globs: ["src/**/*"] expect(json.mcp?.["test-server"]).toBeDefined(); expect(json.mcp["test-server"].type).toBe("local"); }); + + it("should pass check for a non-owning target when another target owns AGENTS.md", async () => { + const testDir = getTestDir(); + + const rootRuleContent = `--- +root: true +targets: ["*"] +description: "Root rule" +--- + +# Root Rule +`; + const nonRootRuleContent = `--- +targets: ["*"] +description: "Detail rule" +globs: ["src/**/*"] +--- + +# Detail Rule +`; + await writeFileContent( + join(testDir, RULESYNC_RULES_RELATIVE_DIR_PATH, RULESYNC_OVERVIEW_FILE_NAME), + rootRuleContent, + ); + await writeFileContent( + join(testDir, RULESYNC_RULES_RELATIVE_DIR_PATH, "detail.md"), + nonRootRuleContent, + ); + + await writeFileContent( + join(testDir, RULESYNC_CONFIG_RELATIVE_FILE_PATH), + JSON.stringify( + { + targets: { + codexcli: ["rules"], + "antigravity-ide": ["rules"], + }, + }, + null, + 2, + ), + ); + + // Full generate with both targets — antigravity-ide is last so it owns AGENTS.md + await runGenerate({ + target: "codexcli,antigravity-ide", + features: "rules", + env: { NODE_ENV: "e2e" }, + }); + + // AGENTS.md exists (owned by antigravity-ide) + expect(await readFileContent(join(testDir, "AGENTS.md"))).toContain("Root Rule"); + + // Codex non-root rules exist + const codexNonRoot = await readFileContent(join(testDir, ".codex", "memories", "detail.md")); + expect(codexNonRoot).toContain("Detail Rule"); + + // Check codexcli only — should pass even though AGENTS.md is owned by antigravity-ide + const { stdout, stderr } = await runGenerate({ + target: "codexcli", + features: "rules", + check: true, + env: { NODE_ENV: "e2e" }, + }); + + expect(stderr).toBe(""); + expect(stdout).toContain("All files are up to date."); + }); }); describe("E2E: rules (import)", () => { diff --git a/src/lib/generate.test.ts b/src/lib/generate.test.ts index 65db08be5..94ec411ab 100644 --- a/src/lib/generate.test.ts +++ b/src/lib/generate.test.ts @@ -96,6 +96,7 @@ describe("generate", () => { getSilent: ReturnType; getOutputRoots: ReturnType; getTargets: ReturnType; + getConfigFileTargets: ReturnType; getFeatures: ReturnType; getFeatureOptions: ReturnType; getDelete: ReturnType; @@ -114,6 +115,7 @@ describe("generate", () => { getSilent: vi.fn().mockReturnValue(false), getOutputRoots: vi.fn().mockReturnValue(["."]), getTargets: vi.fn().mockReturnValue(["claudecode"]), + getConfigFileTargets: vi.fn().mockReturnValue(["claudecode"]), getFeatures: vi.fn().mockReturnValue(["rules"]), getFeatureOptions: vi.fn().mockReturnValue(undefined), getDelete: vi.fn().mockReturnValue(false), diff --git a/src/lib/generate.ts b/src/lib/generate.ts index e38d6fa32..d27eecbe7 100644 --- a/src/lib/generate.ts +++ b/src/lib/generate.ts @@ -21,7 +21,7 @@ import type { Feature } from "../types/features.js"; import type { RulesyncFile } from "../types/rulesync-file.js"; import type { ToolTarget } from "../types/tool-targets.js"; import { formatError } from "../utils/error.js"; -import { fileExists } from "../utils/file.js"; +import { fileExists, toPosixPath } from "../utils/file.js"; import type { Logger } from "../utils/logger.js"; import type { FeatureGenerateResult } from "../utils/result.js"; @@ -50,14 +50,20 @@ async function processFeatureGeneration(params: { config: Config; processor: FeatureProcessor; toolFiles: T[]; + skipFilePaths?: Set; }): Promise { - const { config, processor, toolFiles } = params; + const { config, processor, toolFiles, skipFilePaths } = params; + + const filesToCheck = + skipFilePaths && skipFilePaths.size > 0 + ? toolFiles.filter((f) => !skipFilePaths.has(f.getRelativePathFromCwd())) + : toolFiles; let totalCount = 0; const allPaths: string[] = []; let hasDiff = false; - const writeResult = await processor.writeAiFiles(toolFiles); + const writeResult = await processor.writeAiFiles(filesToCheck); totalCount += writeResult.count; allPaths.push(...writeResult.paths); if (writeResult.count > 0) hasDiff = true; @@ -102,8 +108,9 @@ async function processDirFeatureGeneration(params: { async function processEmptyFeatureGeneration(params: { config: Config; processor: FeatureProcessor; + skipFilePaths?: Set; }): Promise { - const { config, processor } = params; + const { config, processor, skipFilePaths } = params; const totalCount = 0; let hasDiff = false; @@ -111,7 +118,12 @@ async function processEmptyFeatureGeneration(params: { if (config.getDelete()) { const existingToolFiles = await processor.loadToolFiles({ forDeletion: true }); - const orphanCount = await processor.removeOrphanAiFiles(existingToolFiles, []); + const filesToDelete = + skipFilePaths && skipFilePaths.size > 0 + ? existingToolFiles.filter((f) => !skipFilePaths.has(f.getRelativePathFromCwd())) + : existingToolFiles; + + const orphanCount = await processor.removeOrphanAiFiles(filesToDelete, []); if (orphanCount > 0) hasDiff = true; } @@ -126,13 +138,14 @@ async function processFeatureWithRulesyncFiles(params: { config: Config; processor: FeatureProcessor; rulesyncFiles: RulesyncFile[]; + skipFilePaths?: Set; }): Promise { - const { config, processor, rulesyncFiles } = params; + const { config, processor, rulesyncFiles, skipFilePaths } = params; if (rulesyncFiles.length === 0) { - return processEmptyFeatureGeneration({ config, processor }); + return processEmptyFeatureGeneration({ config, processor, skipFilePaths }); } const toolFiles = await processor.convertRulesyncFilesToToolFiles(rulesyncFiles); - return processFeatureGeneration({ config, processor, toolFiles }); + return processFeatureGeneration({ config, processor, toolFiles, skipFilePaths }); } const SIMULATE_OPTION_MAP: Partial> = { @@ -236,6 +249,23 @@ export async function generate(params: { }; } +function computeRootFileOwnership(params: { + targets: ToolTarget[]; + global: boolean; +}): Map { + const ownerByPath = new Map(); + for (const target of params.targets) { + const factory = RulesProcessor.getFactory(target); + if (!factory) continue; + const paths = factory.class.getSettablePaths({ global: params.global }); + if ("root" in paths && paths.root) { + const rootPath = toPosixPath(join(paths.root.relativeDirPath, paths.root.relativeFilePath)); + ownerByPath.set(rootPath, target); + } + } + return ownerByPath; +} + async function generateRulesCore(params: { config: Config; logger: Logger; @@ -251,6 +281,14 @@ async function generateRulesCore(params: { const toolTargets = intersection(config.getTargets(), supportedTargets); warnUnsupportedTargets({ config, supportedTargets, featureName: "rules", logger }); + const isCheck = config.getCheck(); + const rootFileOwner = isCheck + ? computeRootFileOwnership({ + targets: config.getConfigFileTargets(), + global: config.getGlobal(), + }) + : new Map(); + for (const outputRoot of config.getOutputRoots()) { for (const toolTarget of toolTargets) { // Check if rules feature is enabled for this specific target @@ -273,7 +311,22 @@ async function generateRulesCore(params: { }); const rulesyncFiles = await processor.loadRulesyncFiles(); - const result = await processFeatureWithRulesyncFiles({ config, processor, rulesyncFiles }); + + const skipFilePaths = new Set(); + if (isCheck) { + for (const [rootPath, owner] of rootFileOwner) { + if (owner !== toolTarget) { + skipFilePaths.add(rootPath); + } + } + } + + const result = await processFeatureWithRulesyncFiles({ + config, + processor, + rulesyncFiles, + skipFilePaths: skipFilePaths.size > 0 ? skipFilePaths : undefined, + }); totalCount += result.count; allPaths.push(...result.paths);