Skip to content

Commit ee8c280

Browse files
cm-dyoshikawaclaude
andcommitted
fix: address review findings for the checks feature
- Merge the tool-scoped 'amp:' frontmatter section with precedence over canonical values (per feature-change-guidelines), exclude all tool-target sections from the passthrough so they no longer leak into generated check files, and keep 'name' pinned to the file basename. - Reuse AMP_GLOBAL_DIR for AMP_CHECKS_GLOBAL_DIR. - Add checksCount to the result-shape assertions in the MCP tests and add checks feature suites to lib/import and lib/convert tests. - Update the DIR_FEATURES comment to mention checks. - Document the tool-scoped section behavior for checks. The reviewer's note about 'does not support the feature checks' warning noise for wildcard features is intentionally unchanged: it is the established warnUnsupportedTargets pattern shared by every feature core, and changing warning policy is a cross-feature UX decision out of scope here. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 78cb273 commit ee8c280

12 files changed

Lines changed: 192 additions & 5 deletions

File tree

docs/reference/file-formats.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -448,7 +448,7 @@ The emitted Amp frontmatter is derived from the source as follows:
448448
| `severity-default` | `severity` |
449449
| `tools` | `tools` |
450450

451-
The frontmatter schema is loose, so any extra Amp-specific keys survive a generate/import round-trip. On import, `severity-default` maps back to the generic `severity` field, and the `name` field is dropped because it is re-derived from the file name on the next generate.
451+
The frontmatter schema is loose, so any extra Amp-specific keys survive a generate/import round-trip. A tool-scoped section (e.g. `amp: { "severity-default": "critical" }`) overrides the canonical values for that tool — the tool-specific value takes precedence, and the section itself is not emitted (except `name`, which always comes from the file name). On import, `severity-default` maps back to the generic `severity` field, and the `name` field is dropped because it is re-derived from the file name on the next generate.
452452

453453
> **v1 limitation:** Amp also discovers subtree-scoped checks (e.g. `api/.agents/checks/`), but rulesync sources carry no directory-placement semantics, so those subtree-scoped checks are not generated. See the [Amp manual](https://ampcode.com/manual).
454454

skills/rulesync/file-formats.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -448,7 +448,7 @@ The emitted Amp frontmatter is derived from the source as follows:
448448
| `severity-default` | `severity` |
449449
| `tools` | `tools` |
450450

451-
The frontmatter schema is loose, so any extra Amp-specific keys survive a generate/import round-trip. On import, `severity-default` maps back to the generic `severity` field, and the `name` field is dropped because it is re-derived from the file name on the next generate.
451+
The frontmatter schema is loose, so any extra Amp-specific keys survive a generate/import round-trip. A tool-scoped section (e.g. `amp: { "severity-default": "critical" }`) overrides the canonical values for that tool — the tool-specific value takes precedence, and the section itself is not emitted (except `name`, which always comes from the file name). On import, `severity-default` maps back to the generic `severity` field, and the `name` field is dropped because it is re-derived from the file name on the next generate.
452452

453453
> **v1 limitation:** Amp also discovers subtree-scoped checks (e.g. `api/.agents/checks/`), but rulesync sources carry no directory-placement semantics, so those subtree-scoped checks are not generated. See the [Amp manual](https://ampcode.com/manual).
454454

src/cli/commands/gitignore-derive.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ const deriveRulesEntries = (): GitignoreEntryTag[] => {
132132
return entries;
133133
};
134134

135-
// commands/skills/subagents emit a directory tree; mcp/hooks/permissions/ignore
135+
// commands/skills/subagents/checks emit a directory tree; mcp/hooks/permissions/ignore
136136
// emit a single file; rules has a composite root+nonRoot shape.
137137
const DIR_FEATURES = new Set<Feature>(["commands", "skills", "subagents", "checks"]);
138138
const FILE_FEATURES = new Set<Feature>(["mcp", "hooks", "permissions", "ignore"]);

src/constants/amp-paths.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ export const AMP_AGENTS_DIR = ".agents";
66
export const AMP_SKILLS_PROJECT_DIR = join(AMP_AGENTS_DIR, "skills");
77
export const AMP_SKILLS_GLOBAL_DIR = join(".config", "agents", "skills");
88
export const AMP_CHECKS_PROJECT_DIR = join(AMP_AGENTS_DIR, "checks");
9-
export const AMP_CHECKS_GLOBAL_DIR = join(".config", "amp", "checks");
9+
export const AMP_CHECKS_GLOBAL_DIR = join(AMP_GLOBAL_DIR, "checks");
1010
export const AMP_RULE_FILE_NAME = "AGENTS.md";
1111
export const AMP_SETTINGS_FILE_NAME = "settings.json";
1212
export const AMP_SETTINGS_JSONC_FILE_NAME = "settings.jsonc";

src/features/checks/amp-check.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,36 @@ describe("AmpCheck.fromRulesyncCheck", () => {
8585
});
8686
expect(ampCheck.getRelativeDirPath()).toBe(AMP_CHECKS_GLOBAL_DIR);
8787
});
88+
89+
it("should let the amp-specific section win and not leak tool sections", () => {
90+
const rulesyncCheck = new RulesyncCheck({
91+
relativeDirPath: ".rulesync/checks",
92+
relativeFilePath: "security.md",
93+
frontmatter: {
94+
targets: ["*"],
95+
severity: "low",
96+
custom: "kept",
97+
amp: { "severity-default": "critical", name: "ignored" },
98+
claudecode: { model: "opus" },
99+
},
100+
body: "Look for issues.",
101+
});
102+
103+
const ampCheck = AmpCheck.fromRulesyncCheck({
104+
rulesyncCheck,
105+
relativeDirPath: ".rulesync/checks",
106+
});
107+
const frontmatter = ampCheck.getFrontmatter();
108+
109+
// The tool-specific value takes precedence over the canonical one.
110+
expect(frontmatter["severity-default"]).toBe("critical");
111+
// The check identity is the file basename; the section cannot rename it.
112+
expect(frontmatter.name).toBe("security");
113+
// Unknown non-tool keys pass through; tool sections themselves must not leak.
114+
expect(frontmatter).toHaveProperty("custom", "kept");
115+
expect(frontmatter).not.toHaveProperty("amp");
116+
expect(frontmatter).not.toHaveProperty("claudecode");
117+
});
88118
});
89119

90120
describe("AmpCheck.toRulesyncCheck", () => {

src/features/checks/amp-check.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { z } from "zod/mini";
55
import { AMP_CHECKS_GLOBAL_DIR, AMP_CHECKS_PROJECT_DIR } from "../../constants/amp-paths.js";
66
import { RULESYNC_CHECKS_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js";
77
import { AiFileParams, ValidationResult } from "../../types/ai-file.js";
8+
import { ALL_TOOL_TARGETS } from "../../types/tool-targets.js";
89
import { formatError } from "../../utils/error.js";
910
import { readFileContent } from "../../utils/file.js";
1011
import { parseFrontmatter, stringifyFrontmatter } from "../../utils/frontmatter.js";
@@ -129,12 +130,24 @@ export class AmpCheck extends ToolCheck {
129130
// `severity-default`; everything else (including unknown keys) passes through.
130131
const { targets: _targets, description, severity, tools, ...restFields } = rulesyncFrontmatter;
131132

133+
// Tool-target sections (e.g. `amp:`) are per-tool overrides, not check
134+
// content: exclude them all from the passthrough, then merge this tool's
135+
// own section last so a tool-specific value takes precedence over the
136+
// canonical one. `name` is excluded from the override because the check
137+
// identity is carried by the file basename.
138+
const toolTargetKeys = new Set<string>(ALL_TOOL_TARGETS);
139+
const passthroughFields = Object.fromEntries(
140+
Object.entries(restFields).filter(([key]) => !toolTargetKeys.has(key) && key !== "name"),
141+
);
142+
const ampSection = this.filterToolSpecificSection(rulesyncFrontmatter.amp ?? {}, ["name"]);
143+
132144
const rawAmpFrontmatter = {
133145
name,
134146
...(description !== undefined && { description }),
135147
...(severity !== undefined && { "severity-default": severity }),
136148
...(tools !== undefined && { tools }),
137-
...restFields,
149+
...passthroughFields,
150+
...ampSection,
138151
};
139152

140153
const result = AmpCheckFrontmatterSchema.safeParse(rawAmpFrontmatter);

src/features/checks/tool-check.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,4 +76,17 @@ export abstract class ToolCheck extends ToolFile {
7676

7777
return false;
7878
}
79+
80+
protected static filterToolSpecificSection(
81+
rawSection: Record<string, unknown>,
82+
excludeFields: string[],
83+
): Record<string, unknown> {
84+
const filtered: Record<string, unknown> = {};
85+
for (const [key, value] of Object.entries(rawSection)) {
86+
if (!excludeFields.includes(key)) {
87+
filtered[key] = value;
88+
}
89+
}
90+
return filtered;
91+
}
7992
}

src/lib/convert.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22

3+
import { ChecksProcessor } from "../features/checks/checks-processor.js";
34
import { CommandsProcessor } from "../features/commands/commands-processor.js";
45
import { HooksProcessor } from "../features/hooks/hooks-processor.js";
56
import { IgnoreProcessor } from "../features/ignore/ignore-processor.js";
@@ -35,6 +36,7 @@ vi.mock("../features/commands/commands-processor.js");
3536
vi.mock("../features/skills/skills-processor.js");
3637
vi.mock("../features/hooks/hooks-processor.js");
3738
vi.mock("../features/permissions/permissions-processor.js");
39+
vi.mock("../features/checks/checks-processor.js");
3840

3941
describe("convertFromTool", () => {
4042
let mockConfig: {
@@ -68,6 +70,7 @@ describe("convertFromTool", () => {
6870
vi.mocked(SkillsProcessor.getToolTargets).mockReturnValue(["claudecode", "cursor"]);
6971
vi.mocked(HooksProcessor.getToolTargets).mockReturnValue(["claudecode"]);
7072
vi.mocked(PermissionsProcessor.getToolTargets).mockReturnValue(["claudecode"]);
73+
vi.mocked(ChecksProcessor.getToolTargets).mockReturnValue(["cursor", "claudecode"]);
7174

7275
vi.mocked(RulesProcessor).mockImplementation(function () {
7376
return createMockProcessor() as unknown as RulesProcessor;
@@ -93,6 +96,9 @@ describe("convertFromTool", () => {
9396
vi.mocked(PermissionsProcessor).mockImplementation(function () {
9497
return createMockProcessor() as unknown as PermissionsProcessor;
9598
});
99+
vi.mocked(ChecksProcessor).mockImplementation(function () {
100+
return createMockProcessor() as unknown as ChecksProcessor;
101+
});
96102
});
97103

98104
afterEach(() => {
@@ -396,6 +402,45 @@ describe("convertFromTool", () => {
396402
});
397403
});
398404

405+
describe("checks feature", () => {
406+
it("should convert check files", async () => {
407+
mockConfig.getFeatures.mockReturnValue(["checks"]);
408+
409+
const result = await convertFromTool({
410+
logger,
411+
config: mockConfig as never,
412+
fromTool: "cursor",
413+
toTools: ["claudecode"],
414+
});
415+
416+
expect(result.checksCount).toBe(1);
417+
});
418+
419+
it("should return 0 when source has no check files", async () => {
420+
mockConfig.getFeatures.mockReturnValue(["checks"]);
421+
422+
const mockProc = {
423+
loadToolFiles: vi.fn().mockResolvedValue([]),
424+
convertToolFilesToRulesyncFiles: vi.fn(),
425+
convertRulesyncFilesToToolFiles: vi.fn(),
426+
writeAiFiles: vi.fn(),
427+
};
428+
vi.mocked(ChecksProcessor).mockImplementation(function () {
429+
return mockProc as unknown as ChecksProcessor;
430+
});
431+
432+
const result = await convertFromTool({
433+
logger,
434+
config: mockConfig as never,
435+
fromTool: "cursor",
436+
toTools: ["claudecode"],
437+
});
438+
439+
expect(result.checksCount).toBe(0);
440+
expect(mockProc.convertToolFilesToRulesyncFiles).not.toHaveBeenCalled();
441+
});
442+
});
443+
399444
describe("all features combined", () => {
400445
it("should return empty result when no features are enabled", async () => {
401446
mockConfig.getFeatures.mockReturnValue([]);
@@ -415,6 +460,7 @@ describe("convertFromTool", () => {
415460
expect(result.skillsCount).toBe(0);
416461
expect(result.hooksCount).toBe(0);
417462
expect(result.permissionsCount).toBe(0);
463+
expect(result.checksCount).toBe(0);
418464
});
419465
});
420466
});

src/lib/import.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { join } from "node:path";
33
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
44

55
import { RULESYNC_RELATIVE_DIR_PATH } from "../constants/rulesync-paths.js";
6+
import { ChecksProcessor } from "../features/checks/checks-processor.js";
67
import { CommandsProcessor } from "../features/commands/commands-processor.js";
78
import { HooksProcessor } from "../features/hooks/hooks-processor.js";
89
import { IgnoreProcessor } from "../features/ignore/ignore-processor.js";
@@ -38,6 +39,7 @@ vi.mock("../features/commands/commands-processor.js");
3839
vi.mock("../features/skills/skills-processor.js");
3940
vi.mock("../features/hooks/hooks-processor.js");
4041
vi.mock("../features/permissions/permissions-processor.js");
42+
vi.mock("../features/checks/checks-processor.js");
4143

4244
describe("importFromTool", () => {
4345
let mockConfig: {
@@ -69,6 +71,7 @@ describe("importFromTool", () => {
6971
vi.mocked(SkillsProcessor.getToolTargets).mockReturnValue(["claudecode"]);
7072
vi.mocked(HooksProcessor.getToolTargets).mockReturnValue(["claudecode"]);
7173
vi.mocked(PermissionsProcessor.getToolTargets).mockReturnValue(["claudecode"]);
74+
vi.mocked(ChecksProcessor.getToolTargets).mockReturnValue(["claudecode"]);
7275

7376
vi.mocked(RulesProcessor).mockImplementation(function () {
7477
return createMockProcessor() as unknown as RulesProcessor;
@@ -94,6 +97,9 @@ describe("importFromTool", () => {
9497
vi.mocked(PermissionsProcessor).mockImplementation(function () {
9598
return createMockProcessor() as unknown as PermissionsProcessor;
9699
});
100+
vi.mocked(ChecksProcessor).mockImplementation(function () {
101+
return createMockProcessor() as unknown as ChecksProcessor;
102+
});
97103
});
98104

99105
afterEach(() => {
@@ -760,6 +766,79 @@ describe("importFromTool", () => {
760766
});
761767
});
762768

769+
describe("checks feature", () => {
770+
it("should import checks when feature is enabled", async () => {
771+
mockConfig.getFeatures.mockReturnValue(["checks"]);
772+
773+
const result = await importFromTool({
774+
logger,
775+
config: mockConfig as never,
776+
tool: "claudecode",
777+
});
778+
779+
expect(result.checksCount).toBe(1);
780+
expect(ChecksProcessor).toHaveBeenCalledWith(
781+
expect.objectContaining({
782+
outputRoot: ".",
783+
toolTarget: "claudecode",
784+
global: false,
785+
}),
786+
);
787+
});
788+
789+
it("should return 0 checks when feature is not enabled", async () => {
790+
mockConfig.getFeatures.mockReturnValue([]);
791+
792+
const result = await importFromTool({
793+
logger,
794+
config: mockConfig as never,
795+
tool: "claudecode",
796+
});
797+
798+
expect(result.checksCount).toBe(0);
799+
expect(ChecksProcessor).not.toHaveBeenCalled();
800+
});
801+
802+
it("should return 0 when tool is not supported", async () => {
803+
mockConfig.getFeatures.mockReturnValue(["checks"]);
804+
vi.mocked(ChecksProcessor.getToolTargets).mockReturnValue(["amp"]);
805+
806+
const result = await importFromTool({
807+
logger,
808+
config: mockConfig as never,
809+
tool: "claudecode",
810+
});
811+
812+
expect(result.checksCount).toBe(0);
813+
expect(ChecksProcessor).not.toHaveBeenCalled();
814+
});
815+
816+
it("should return 0 when no tool files found", async () => {
817+
mockConfig.getFeatures.mockReturnValue(["checks"]);
818+
819+
const mockProcessor = {
820+
loadToolFiles: vi.fn().mockResolvedValue([]),
821+
convertToolFilesToRulesyncFiles: vi.fn(),
822+
writeAiFiles: vi.fn(),
823+
};
824+
vi.mocked(ChecksProcessor).mockImplementation(function () {
825+
return mockProcessor as unknown as ChecksProcessor;
826+
});
827+
828+
const result = await importFromTool({
829+
logger,
830+
config: mockConfig as never,
831+
tool: "claudecode",
832+
});
833+
834+
expect(result.checksCount).toBe(0);
835+
expect(mockProcessor.convertToolFilesToRulesyncFiles).not.toHaveBeenCalled();
836+
expect(logger.warn).toHaveBeenCalledWith(
837+
expect.stringContaining("No check files found for claudecode"),
838+
);
839+
});
840+
});
841+
763842
describe("all features combined", () => {
764843
it("should import all features when all are enabled", async () => {
765844
mockConfig.getFeatures.mockReturnValue([
@@ -770,6 +849,7 @@ describe("importFromTool", () => {
770849
"subagents",
771850
"skills",
772851
"permissions",
852+
"checks",
773853
]);
774854

775855
const result = await importFromTool({
@@ -786,6 +866,7 @@ describe("importFromTool", () => {
786866
expect(result.skillsCount).toBe(1);
787867
expect(result.hooksCount).toBe(0);
788868
expect(result.permissionsCount).toBe(1);
869+
expect(result.checksCount).toBe(1);
789870
});
790871

791872
it("should return empty result when no features are enabled", async () => {
@@ -805,6 +886,7 @@ describe("importFromTool", () => {
805886
expect(result.skillsCount).toBe(0);
806887
expect(result.hooksCount).toBe(0);
807888
expect(result.permissionsCount).toBe(0);
889+
expect(result.checksCount).toBe(0);
808890
});
809891
});
810892

src/mcp/convert.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ Body.
106106
skillsCount: expect.any(Number),
107107
hooksCount: expect.any(Number),
108108
permissionsCount: expect.any(Number),
109+
checksCount: expect.any(Number),
109110
totalCount: expect.any(Number),
110111
});
111112
});

0 commit comments

Comments
 (0)