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
1 change: 1 addition & 0 deletions src/cli/commands/generate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
18 changes: 18 additions & 0 deletions src/config/config-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<string>(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));
}
8 changes: 8 additions & 0 deletions src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ type InferredConfigParams = z.infer<typeof ConfigParamsSchema>;
export type ConfigParams = Omit<InferredConfigParams, "targets" | "features"> & {
targets?: RulesyncConfigTargets;
features?: RulesyncFeatures;
configFileTargets?: ToolTarget[];
};

export const PartialConfigParamsSchema = z.partial(ConfigParamsSchema);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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 {
Expand Down
69 changes: 69 additions & 0 deletions src/e2e/e2e-rules.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)", () => {
Expand Down
2 changes: 2 additions & 0 deletions src/lib/generate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ describe("generate", () => {
getSilent: ReturnType<typeof vi.fn>;
getOutputRoots: ReturnType<typeof vi.fn>;
getTargets: ReturnType<typeof vi.fn>;
getConfigFileTargets: ReturnType<typeof vi.fn>;
getFeatures: ReturnType<typeof vi.fn>;
getFeatureOptions: ReturnType<typeof vi.fn>;
getDelete: ReturnType<typeof vi.fn>;
Expand All @@ -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),
Expand Down
71 changes: 62 additions & 9 deletions src/lib/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -50,14 +50,20 @@ async function processFeatureGeneration<T extends AiFile>(params: {
config: Config;
processor: FeatureProcessor;
toolFiles: T[];
skipFilePaths?: Set<string>;
}): Promise<FeatureGenerateResult> {
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;
Expand Down Expand Up @@ -102,16 +108,22 @@ async function processDirFeatureGeneration(params: {
async function processEmptyFeatureGeneration(params: {
config: Config;
processor: FeatureProcessor;
skipFilePaths?: Set<string>;
}): Promise<FeatureGenerateResult> {
const { config, processor } = params;
const { config, processor, skipFilePaths } = params;

const totalCount = 0;
let hasDiff = false;

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;
}

Expand All @@ -126,13 +138,14 @@ async function processFeatureWithRulesyncFiles(params: {
config: Config;
processor: FeatureProcessor;
rulesyncFiles: RulesyncFile[];
skipFilePaths?: Set<string>;
}): Promise<FeatureGenerateResult> {
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<Record<Feature, string>> = {
Expand Down Expand Up @@ -236,6 +249,23 @@ export async function generate(params: {
};
}

function computeRootFileOwnership(params: {
targets: ToolTarget[];
global: boolean;
}): Map<string, ToolTarget> {
const ownerByPath = new Map<string, ToolTarget>();
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;
Expand All @@ -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<string, ToolTarget>();

for (const outputRoot of config.getOutputRoots()) {
for (const toolTarget of toolTargets) {
// Check if rules feature is enabled for this specific target
Expand All @@ -273,7 +311,22 @@ async function generateRulesCore(params: {
});

const rulesyncFiles = await processor.loadRulesyncFiles();
const result = await processFeatureWithRulesyncFiles({ config, processor, rulesyncFiles });

const skipFilePaths = new Set<string>();
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);
Expand Down