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
42 changes: 42 additions & 0 deletions scripts/audit-scoping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
discoverRuleFiles,
isVendoredPath,
parseArgs,
rulesyncGeneratesInstructionPair,
} from "./audit.ts";

describe("audit --skip-dirs", () => {
Expand Down Expand Up @@ -110,3 +111,44 @@ describe("nested AGENTS.md/CLAUDE.md mirror families", () => {
expect(accidentalPaths).not.toContain("CLAUDE.md");
});
});

describe("rulesyncGeneratesInstructionPair", () => {
test("detects a config that targets both surfaces, in either shape", () => {
const objectForm = mkdtempSync(join(tmpdir(), "anvil-rs-obj-"));
writeFileSync(
join(objectForm, "rulesync.jsonc"),
'{\n // comment tolerated\n "targets": { "agentsmd": ["rules"], "claudecode": ["rules"] }\n}\n',
);
expect(rulesyncGeneratesInstructionPair(objectForm)).toBe(true);

const arrayForm = mkdtempSync(join(tmpdir(), "anvil-rs-arr-"));
writeFileSync(
join(arrayForm, "rulesync.jsonc"),
'{ "targets": ["agentsmd", "claudecode", "cursor"] }\n',
);
expect(rulesyncGeneratesInstructionPair(arrayForm)).toBe(true);
});

test("is false when only one surface is generated", () => {
// 360training targets claudecode + cursor only, so its AGENTS.md is hand-written and a
// difference between the two files is real drift rather than generator output.
const root = mkdtempSync(join(tmpdir(), "anvil-rs-one-"));
writeFileSync(
join(root, "rulesync.jsonc"),
'{ "targets": ["claudecode", "cursor"] }\n',
);
expect(rulesyncGeneratesInstructionPair(root)).toBe(false);
});

test("is false with no config, and survives an unparseable one", () => {
expect(
rulesyncGeneratesInstructionPair(
mkdtempSync(join(tmpdir(), "anvil-rs-none-")),
),
).toBe(false);

const broken = mkdtempSync(join(tmpdir(), "anvil-rs-bad-"));
writeFileSync(join(broken, "rulesync.json"), "{ not json");
expect(rulesyncGeneratesInstructionPair(broken)).toBe(false);
});
});
70 changes: 64 additions & 6 deletions scripts/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,13 @@ export type DuplicateGroup = {
export type MirrorConfig = {
hasConfig: boolean;
agents: string[];
/**
* True when a generator declares it owns BOTH AGENTS.md and CLAUDE.md. Their contents then
* differ on purpose — rulesync gives AGENTS.md a TOON index of the path-scoped rules that
* CLAUDE.md does not need, because Claude reads those through .claude/rules instead. That is
* one source with two tool-appropriate outputs, not two copies that fell out of sync.
*/
generatesInstructionPair: boolean;
};

export type MirrorStatus =
Expand Down Expand Up @@ -1135,18 +1142,47 @@ function parseAiRulesAgents(configText: string): string[] {
return agents;
}

/** True when a rulesync config targets both the AGENTS.md and Claude Code rule surfaces. */
export function rulesyncGeneratesInstructionPair(projectRoot: string): boolean {
for (const name of ["rulesync.jsonc", "rulesync.json"]) {
const path = join(projectRoot, name);
if (!existsSync(path)) continue;
try {
// Strip comments so the .jsonc form parses; targets appear as either an array of tool
// names or an object keyed by tool name.
const raw = readFileSync(path, "utf8").replace(/^\s*\/\/.*$/gm, "");
const config = JSON.parse(raw) as { targets?: unknown };
const targets = config.targets;
const names = Array.isArray(targets)
? targets.map(String)
: targets && typeof targets === "object"
? Object.keys(targets)
: [];
if (names.includes("agentsmd") && names.includes("claudecode"))
return true;
} catch {
// Unparseable config: fall through and treat the pair as hand-written.
}
}
return false;
}

function loadMirrorConfig(projectRoot: string): MirrorConfig {
const generatesInstructionPair =
rulesyncGeneratesInstructionPair(projectRoot);
const configPath = join(projectRoot, "ai-rules", "ai-rules-config.yaml");
if (!existsSync(configPath)) return { hasConfig: false, agents: [] };
if (!existsSync(configPath))
return { hasConfig: false, agents: [], generatesInstructionPair };

try {
const content = readFileSync(configPath, "utf8");
return {
hasConfig: true,
agents: [...new Set(parseAiRulesAgents(content))],
generatesInstructionPair,
};
} catch {
return { hasConfig: true, agents: [] };
return { hasConfig: true, agents: [], generatesInstructionPair };
}
}

Expand Down Expand Up @@ -1247,7 +1283,10 @@ function comparableMirrorFingerprint(file: RuleFile): string {
}
}

function buildMirrorGroups(ruleFiles: RuleFile[]): {
function buildMirrorGroups(
ruleFiles: RuleFile[],
projectRoot?: string,
): {
groups: MirrorGroup[];
byPath: Map<string, MirrorDescriptor>;
healthyCount: number;
Expand Down Expand Up @@ -1291,11 +1330,25 @@ function buildMirrorGroups(ruleFiles: RuleFile[]): {
const fingerprintCount = new Set(
row.members.map((m) => comparableMirrorFingerprint(m)),
).size;
const status = classifyMirrorStatus(
let status = classifyMirrorStatus(
row.sources.length,
row.projections.length,
fingerprintCount,
);
// A generator that owns both sides produces them from one source, so differing content is
// the intended output shape rather than drift between two copies. The generator config
// lives beside the files it owns, which in a repo-of-clones is a nested directory rather
// than the scan root — checking only the root missed every nested repo's config.
if (
status === "drifted" &&
projectRoot &&
key.startsWith("agent-instructions/")
) {
const familyDir = key.slice("agent-instructions/".length);
const dirRoot =
familyDir === "root" ? projectRoot : join(projectRoot, familyDir);
if (rulesyncGeneratesInstructionPair(dirRoot)) status = "healthy";
}
if (status === "healthy") healthyCount++;
if (status === "drifted") driftedCount++;
if (status === "orphan-projection") orphanProjectionCount++;
Expand Down Expand Up @@ -1387,6 +1440,7 @@ function isExpectedDuplicateGroup(
export function buildRuleInventory(
ruleFiles: RuleFile[],
mirrorConfig: MirrorConfig,
projectRoot?: string,
): RuleInventory {
const groups = new Map<string, RuleFile[]>();
for (const rf of ruleFiles) {
Expand Down Expand Up @@ -1419,7 +1473,7 @@ export function buildRuleInventory(
}
}

const mirrorGroupsSummary = buildMirrorGroups(ruleFiles);
const mirrorGroupsSummary = buildMirrorGroups(ruleFiles, projectRoot);
const expectedDuplicateGroups: DuplicateGroup[] = [];
const accidentalDuplicateGroups: DuplicateGroup[] = [];
for (const group of duplicateGroups) {
Expand Down Expand Up @@ -5727,7 +5781,11 @@ export async function runAudit(args: ParsedArgs): Promise<AuditResult> {
logProgress("");

const mirrorConfig = loadMirrorConfig(projectRoot);
const ruleInventory = buildRuleInventory(ruleFiles, mirrorConfig);
const ruleInventory = buildRuleInventory(
ruleFiles,
mirrorConfig,
projectRoot,
);
logProgress("🧩 Rule surface segmentation...");
logProgress(` Canonical unique: ${ruleInventory.canonicalFiles.length}`);
logProgress(
Expand Down