From 9b5c48aab1b6f53bb06707f50eaf6b11795b44e7 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Wed, 9 Sep 2026 19:39:58 +0200 Subject: [PATCH 1/4] test(cli): kill the surviving mutants of the tool capabilities, formats and registry Tests written for the survivors stryker named in the tools scope's domain layer: the mcp json-to-toml transform and user-prime merge, the command frontmatter and path builders, the agents, rules, skills, commands, settings, hooks and plugins capabilities, the registry's signal scan and activation queries, the flat hooks merges, the marketplace catalog builders, the host registration answers and the marketplace source conflict. Every test was watched red under a hand-applied mutation before the source was restored. Scope measured 80.2 before this pass (1358 killed, 67 timed out, 232 survived, 120 uncovered of 1777) and 96.8 after this lot and the infrastructure lot (1687 killed, 34 timed out, 50 survived, 6 uncovered). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011x4ms5qcGuZgYhCxfdHMUb AIDD-Session-Id: 4acc9a1c-19bc-4468-b8b6-e86644bcba60 --- .../agents-capability.unit.test.ts | 165 +++++++++++++++ .../commands-capability.unit.test.ts | 18 ++ .../hooks-capability.unit.test.ts | 58 ++++++ .../capabilities/mcp-capability.unit.test.ts | 28 +++ .../plugins-capability.unit.test.ts | 87 ++++++++ .../rules-capability.unit.test.ts | 66 ++++++ .../settings-capability.unit.test.ts | 37 ++++ .../skills-capability.unit.test.ts | 54 +++++ .../tools/domain/formats/command.unit.test.ts | 84 ++++++++ .../cursor-hooks-project-merge.unit.test.ts | 33 +++ .../formats/flat-hooks-merge.unit.test.ts | 180 ++++++++++++++++ .../domain/formats/mcp-format.unit.test.ts | 192 ++++++++++++++++++ .../host-plugin-registration.unit.test.ts | 36 ++++ .../domain/marketplace-catalog.unit.test.ts | 139 +++++++++++++ .../marketplace-source-conflict.unit.test.ts | 37 ++++ .../tools/domain/mcp-exclusion.unit.test.ts | 26 ++- .../tools/domain/registry.unit.test.ts | 164 +++++++++++++++ 17 files changed, 1403 insertions(+), 1 deletion(-) create mode 100644 cli/tests/contexts/tools/domain/formats/command.unit.test.ts create mode 100644 cli/tests/contexts/tools/domain/formats/mcp-format.unit.test.ts create mode 100644 cli/tests/contexts/tools/domain/registry.unit.test.ts diff --git a/cli/tests/contexts/tools/domain/capabilities/agents-capability.unit.test.ts b/cli/tests/contexts/tools/domain/capabilities/agents-capability.unit.test.ts index 5b736f58b..ca835c969 100644 --- a/cli/tests/contexts/tools/domain/capabilities/agents-capability.unit.test.ts +++ b/cli/tests/contexts/tools/domain/capabilities/agents-capability.unit.test.ts @@ -105,3 +105,168 @@ describe("AgentsCapability", () => { }); }); }); + +describe("AgentsCapability install paths", () => { + const markdown = new AgentsCapability({ + directory: ".claude/", + toolSuffix: ".claude.md", + format: "markdown", + }); + const toml = new AgentsCapability({ + directory: ".codex/", + toolSuffix: ".codex.md", + format: "toml", + }); + + it("installs a markdown agent under agents/ by its file name alone, the tool suffix dropped", () => { + expect(markdown.buildInstallPath("a/b/planner.claude.md")).toBe(".claude/agents/planner.md"); + }); + + it("leaves a markdown file carrying another tool's suffix as it is named", () => { + expect(markdown.buildInstallPath("planner.cursor.md")).toBe(".claude/agents/planner.cursor.md"); + }); + + it("installs a toml agent as .toml whether the source carried the tool suffix, .md, or neither", () => { + expect(toml.buildInstallPath("sub/planner.codex.md")).toBe(".codex/agents/planner.toml"); + expect(toml.buildInstallPath("planner.md")).toBe(".codex/agents/planner.toml"); + expect(toml.buildInstallPath("planner.txt")).toBe(".codex/agents/planner.txt.toml"); + }); + + it("asks the installer the tool declares before deriving a path itself", () => { + const delegating = new AgentsCapability({ + directory: ".x/", + toolSuffix: ".x.md", + format: "markdown", + buildInstallPath: (fileName) => `custom/${fileName}`, + }); + + expect(delegating.buildInstallPath("planner.x.md")).toBe("custom/planner.x.md"); + }); +}); + +describe("AgentsCapability file acceptance", () => { + const cap = new AgentsCapability({ + directory: ".claude/", + toolSuffix: ".claude.md", + format: "markdown", + }); + const suffixes = [".claude.md", ".cursor.md", ".codex.md"]; + + it("accepts its own suffix and a suffix belonging to no tool", () => { + expect(cap.acceptsFileName("planner.claude.md", suffixes)).toBe(true); + expect(cap.acceptsFileName("planner.md", suffixes)).toBe(true); + }); + + it("refuses another tool's suffix wherever the file sits", () => { + expect(cap.acceptsFileName("a/b/planner.cursor.md", suffixes)).toBe(false); + }); +}); + +describe("AgentsCapability frontmatter conversion", () => { + const markdown = new AgentsCapability({ + directory: ".claude/", + toolSuffix: ".claude.md", + format: "markdown", + }); + const toml = new AgentsCapability({ + directory: ".codex/", + toolSuffix: ".codex.md", + format: "toml", + }); + + it("keeps the declared name and description, nothing else", () => { + expect( + markdown.convertFrontmatter({ name: "planner", description: "d", tools: ["x"] }) + ).toStrictEqual({ name: "planner", description: "d" }); + }); + + it("derives the name from the file's own base name when the frontmatter declares none", () => { + expect( + markdown.convertFrontmatter({ description: "d" }, "agents/sub/planner.md") + ).toStrictEqual({ name: "planner", description: "d" }); + }); + + it("leaves the name undefined when neither the frontmatter nor a file name gives one", () => { + expect(markdown.convertFrontmatter({ description: "d" })).toStrictEqual({ + name: undefined, + description: "d", + }); + }); + + it("refuses a declared name that is not a string", () => { + expect(markdown.convertFrontmatter({ name: 7, description: "d" })).toStrictEqual({ + name: undefined, + description: "d", + }); + }); + + it("carries the model into a toml agent only when one is declared", () => { + expect(toml.convertFrontmatter({ name: "p", description: "d", model: "m" })).toStrictEqual({ + name: "p", + description: "d", + model: "m", + }); + expect(toml.convertFrontmatter({ name: "p", description: "d" })).toStrictEqual({ + name: "p", + description: "d", + }); + }); + + it("asks the converter the tool declares before converting itself", () => { + const delegating = new AgentsCapability({ + directory: ".x/", + toolSuffix: ".x.md", + format: "markdown", + convertFrontmatter: (fm, fileName) => ({ file: fileName, keys: Object.keys(fm) }), + }); + + expect(delegating.convertFrontmatter({ name: "p" }, "p.md")).toStrictEqual({ + file: "p.md", + keys: ["name"], + }); + }); +}); + +describe("AgentsCapability toml serialization", () => { + const toml = new AgentsCapability({ + directory: ".codex/", + toolSuffix: ".codex.md", + format: "toml", + }); + + it("writes name, description and the body as developer instructions, one key per line", () => { + expect(toml.serialize({ name: "p", description: "d" }, "body")).toBe( + 'name = "p"\ndescription = "d"\ndeveloper_instructions = """\nbody\n"""\n' + ); + }); + + it("writes an empty name and description when the frontmatter declares none", () => { + expect(toml.serialize({}, "body")).toBe( + 'name = ""\ndescription = ""\ndeveloper_instructions = """\nbody\n"""\n' + ); + }); + + it("escapes a quote and a backslash inside a value", () => { + expect(toml.serialize({ name: 'a"b\\c', description: "d" }, "body")).toBe( + 'name = "a\\"b\\\\c"\ndescription = "d"\ndeveloper_instructions = """\nbody\n"""\n' + ); + }); +}); + +describe("AgentsCapability equality", () => { + const base = { directory: ".claude/", toolSuffix: ".claude.md", format: "markdown" as const }; + + it("differs on the tool suffix", () => { + expect( + new AgentsCapability(base).equals(new AgentsCapability({ ...base, toolSuffix: ".x.md" })) + ).toBe(false); + }); + + it("differs on the user file extension", () => { + expect( + new AgentsCapability({ ...base, userFileExt: ".md" }).equals( + new AgentsCapability({ ...base, userFileExt: ".toml" }) + ) + ).toBe(false); + }); +}); diff --git a/cli/tests/contexts/tools/domain/capabilities/commands-capability.unit.test.ts b/cli/tests/contexts/tools/domain/capabilities/commands-capability.unit.test.ts index a4682f7ba..951253147 100644 --- a/cli/tests/contexts/tools/domain/capabilities/commands-capability.unit.test.ts +++ b/cli/tests/contexts/tools/domain/capabilities/commands-capability.unit.test.ts @@ -49,3 +49,21 @@ describe("CommandsCapability", () => { }); }); }); + +describe("CommandsCapability file acceptance", () => { + const cap = new CommandsCapability({ + directory: ".claude/", + toolSuffix: ".claude.md", + buildInstallPath: (fileName) => fileName, + convertFrontmatter: (fm) => fm, + }); + + it("accepts its own suffix and a suffix belonging to no tool", () => { + expect(cap.acceptsFileName("01-plan/plan.claude.md")).toBe(true); + expect(cap.acceptsFileName("01-plan/plan.md")).toBe(true); + }); + + it("refuses another tool's suffix wherever the file sits", () => { + expect(cap.acceptsFileName("a/b/plan.cursor.md")).toBe(false); + }); +}); diff --git a/cli/tests/contexts/tools/domain/capabilities/hooks-capability.unit.test.ts b/cli/tests/contexts/tools/domain/capabilities/hooks-capability.unit.test.ts index 06fb67507..3bb6b9f85 100644 --- a/cli/tests/contexts/tools/domain/capabilities/hooks-capability.unit.test.ts +++ b/cli/tests/contexts/tools/domain/capabilities/hooks-capability.unit.test.ts @@ -37,3 +37,61 @@ describe("HooksCapability", () => { }); }); }); + +describe("HooksCapability declarations", () => { + it("consumes nothing unless told otherwise", () => { + expect(new HooksCapability({ outputPath: "h.json" }).consumes).toStrictEqual([]); + expect( + new HooksCapability({ outputPath: "h.json", consumes: ["hooks"] }).consumes + ).toStrictEqual(["hooks"]); + }); + + it("merges with the function the tool declares, and takes the incoming content whole without one", () => { + expect( + new HooksCapability({ outputPath: "h.json", mergeFn: (a, b) => `${a}+${b}` }).merge("x", "y") + ).toBe("x+y"); + expect(new HooksCapability({ outputPath: "h.json" }).merge("x", "y")).toBe("y"); + }); + + it("lets the user's file win unless the tool declares otherwise", () => { + expect(new HooksCapability({ outputPath: "h.json" }).getMergeStrategy()).toBe("user-prime"); + expect( + new HooksCapability({ outputPath: "h.json", mergeStrategy: "none" }).getMergeStrategy() + ).toBe("none"); + }); + + it("names its entry section only when one is declared", () => { + expect(new HooksCapability({ outputPath: "h.json" }).getEntrySection()).toBeNull(); + expect( + new HooksCapability({ outputPath: "h.json", entrySection: "hooks" }).getEntrySection() + ).toBe("hooks"); + }); + + it("differs on the merge strategy or the entry section alone", () => { + const cap = new HooksCapability({ + outputPath: "h.json", + mergeStrategy: "none", + entrySection: "a", + }); + + expect( + cap.equals( + new HooksCapability({ outputPath: "h.json", mergeStrategy: "none", entrySection: "a" }) + ) + ).toBe(true); + expect( + cap.equals( + new HooksCapability({ + outputPath: "h.json", + mergeStrategy: "user-prime", + entrySection: "a", + }) + ) + ).toBe(false); + expect( + cap.equals( + new HooksCapability({ outputPath: "h.json", mergeStrategy: "none", entrySection: "b" }) + ) + ).toBe(false); + }); +}); diff --git a/cli/tests/contexts/tools/domain/capabilities/mcp-capability.unit.test.ts b/cli/tests/contexts/tools/domain/capabilities/mcp-capability.unit.test.ts index 7922c7070..77a681544 100644 --- a/cli/tests/contexts/tools/domain/capabilities/mcp-capability.unit.test.ts +++ b/cli/tests/contexts/tools/domain/capabilities/mcp-capability.unit.test.ts @@ -91,3 +91,31 @@ describe("McpCapability", () => { }); }); }); + +describe("McpCapability declarations", () => { + it("consumes nothing unless told otherwise", () => { + expect(new McpCapability({ outputPath: ".mcp.json", format: "json" }).consumes).toStrictEqual( + [] + ); + expect( + new McpCapability({ outputPath: ".mcp.json", format: "json", consumes: ["mcp"] }).consumes + ).toStrictEqual(["mcp"]); + }); + + it("is not equal to a capability that differs in entrySection or mergeStrategy alone", () => { + const base = { outputPath: ".mcp.json", format: "json" as const }; + const cap = new McpCapability({ ...base, entrySection: "mcp", mergeStrategy: "user-prime" }); + + expect( + cap.equals(new McpCapability({ ...base, entrySection: "mcp", mergeStrategy: "user-prime" })) + ).toBe(true); + expect( + cap.equals( + new McpCapability({ ...base, entrySection: "servers", mergeStrategy: "user-prime" }) + ) + ).toBe(false); + expect( + cap.equals(new McpCapability({ ...base, entrySection: "mcp", mergeStrategy: "none" })) + ).toBe(false); + }); +}); diff --git a/cli/tests/contexts/tools/domain/capabilities/plugins-capability.unit.test.ts b/cli/tests/contexts/tools/domain/capabilities/plugins-capability.unit.test.ts index ec22d508d..5e8565199 100644 --- a/cli/tests/contexts/tools/domain/capabilities/plugins-capability.unit.test.ts +++ b/cli/tests/contexts/tools/domain/capabilities/plugins-capability.unit.test.ts @@ -247,3 +247,90 @@ describe("PluginsCapability", () => { }); }); }); + +describe("PluginsCapability base directory resolution", () => { + const native = { + mode: "native" as const, + acceptsHooks: true as const, + pluginsDir: ".x/plugins/", + pluginManifestRelativePath: null, + }; + + it("stays in the project when the scope is project, even with a user directory declared", () => { + const cap = new PluginsCapability({ + ...native, + userPluginsDir: (home) => `${home}/.x/plugins`, + }); + + expect(cap.resolvePluginsBaseDir("/repo", "/home/me")).toBe("/repo"); + expect(cap.userPluginsBaseDir("/home/me")).toBe("/home/me/.x/plugins"); + }); + + it("stays in the project for a capability declaring no user directory at all", () => { + const cap = new PluginsCapability(native); + + expect(cap.resolvePluginsBaseDir("/repo", "/home/me")).toBe("/repo"); + expect(cap.userPluginsBaseDir("/home/me")).toBeNull(); + }); + + it("names a plugin's own directory under the plugins directory in native mode only", () => { + expect(new PluginsCapability(native).pluginOutputDir("p")).toBe(".x/plugins/p/"); + expect( + new PluginsCapability({ + mode: "flat", + acceptsHooks: true, + flatHooksDir: ".x/hooks/", + flatNamespacePrefix: "aidd-", + }).pluginOutputDir("p") + ).toBeNull(); + }); + + it("refuses a project hooks destination that names no project hooks file", () => { + expect(() => new PluginsCapability({ ...native, hooksDestination: "project" })).toThrow( + "hooksDestination 'project' requires a projectHooksRelativePath." + ); + }); +}); + +describe("PluginsCapability defaults each mode falls back to", () => { + it("delivers no mcp, lands hooks in the plugin, and reads matcher-shaped hooks unless a native tool says otherwise", () => { + const cap = new PluginsCapability({ + mode: "native", + acceptsHooks: true, + pluginsDir: ".x/plugins/", + pluginManifestRelativePath: null, + }); + + expect([cap.acceptsMcp, cap.hooksDestination, cap.hooksContentFormat]).toStrictEqual([ + false, + "plugin", + "matchers", + ]); + }); + + it("fixes the same three answers for a flat tool", () => { + const cap = new PluginsCapability({ + mode: "flat", + acceptsHooks: true, + flatHooksDir: ".x/hooks/", + flatNamespacePrefix: "aidd-", + }); + + expect([cap.acceptsMcp, cap.hooksDestination, cap.hooksContentFormat]).toStrictEqual([ + false, + "plugin", + "matchers", + ]); + }); + + it("accepts neither hooks nor mcp for a tool hosting no plugin at all", () => { + const cap = new PluginsCapability({ mode: "unsupported", hooksUnsupportedReason: "none" }); + + expect([ + cap.acceptsHooks, + cap.acceptsMcp, + cap.hooksDestination, + cap.hooksContentFormat, + ]).toStrictEqual([false, false, "plugin", "matchers"]); + }); +}); diff --git a/cli/tests/contexts/tools/domain/capabilities/rules-capability.unit.test.ts b/cli/tests/contexts/tools/domain/capabilities/rules-capability.unit.test.ts index e99f8a1b3..749cf2b1c 100644 --- a/cli/tests/contexts/tools/domain/capabilities/rules-capability.unit.test.ts +++ b/cli/tests/contexts/tools/domain/capabilities/rules-capability.unit.test.ts @@ -83,3 +83,69 @@ describe("RulesCapability", () => { }); }); }); + +describe("RulesCapability installed location edge cases", () => { + it("answers an empty directory for an installer that files a rule at the project root", () => { + const cap = new RulesCapability({ + directory: ".x/", + toolSuffix: ".x.md", + buildInstallPath: (fileName) => fileName.replace(/\.x\.md$/, ".mdc"), + convertFrontmatter: (fm) => fm, + }); + + expect(cap.installedLocation()).toStrictEqual({ directory: "", extension: ".mdc" }); + }); + + it("reads the extension even when the installer prefixes the stem", () => { + const cap = new RulesCapability({ + directory: ".x/", + toolSuffix: ".x.md", + buildInstallPath: (fileName) => `.x/rules/_${fileName.replace(/\.x\.md$/, ".mdc")}`, + convertFrontmatter: (fm) => fm, + }); + + expect(cap.installedLocation()).toStrictEqual({ directory: ".x/rules/", extension: ".mdc" }); + }); + + it("answers nothing when the installer rewrote the stem past recognition", () => { + const cap = new RulesCapability({ + directory: ".x/", + toolSuffix: ".x.md", + buildInstallPath: () => ".x/rules/renamed.mdc", + convertFrontmatter: (fm) => fm, + }); + + expect(cap.installedLocation()).toBeNull(); + }); +}); + +describe("RulesCapability file acceptance", () => { + const cap = new RulesCapability({ + directory: ".claude/", + toolSuffix: ".claude.md", + buildInstallPath: (fileName) => fileName, + convertFrontmatter: (fm) => fm, + }); + + it("accepts its own suffix and a suffix belonging to no tool", () => { + expect(cap.acceptsFileName("rules/a.claude.md")).toBe(true); + expect(cap.acceptsFileName("rules/a.md")).toBe(true); + }); + + it("refuses another tool's suffix wherever the file sits", () => { + expect(cap.acceptsFileName("a/b/c.cursor.md")).toBe(false); + }); + + it("reads the input suffix as its own when the tool declares one", () => { + const mdc = new RulesCapability({ + directory: ".cursor/", + toolSuffix: ".mdc", + inputSuffix: ".cursor.md", + buildInstallPath: (fileName) => fileName, + convertFrontmatter: (fm) => fm, + }); + + expect(mdc.acceptsFileName("a.cursor.md")).toBe(true); + expect(mdc.acceptsFileName("a.claude.md")).toBe(false); + }); +}); diff --git a/cli/tests/contexts/tools/domain/capabilities/settings-capability.unit.test.ts b/cli/tests/contexts/tools/domain/capabilities/settings-capability.unit.test.ts index 1099f47cc..e2784f40e 100644 --- a/cli/tests/contexts/tools/domain/capabilities/settings-capability.unit.test.ts +++ b/cli/tests/contexts/tools/domain/capabilities/settings-capability.unit.test.ts @@ -146,3 +146,40 @@ describe("SettingsCapability", () => { }); }); }); + +describe("SettingsCapability path acceptance", () => { + it("accepts exactly its own output path", () => { + const cap = new SettingsCapability({ + outputPath: ".vscode/settings.json", + mergeStrategy: "none", + }); + + expect(cap.accepts(".vscode/settings.json")).toBe(true); + expect(cap.accepts(".vscode/extensions.json")).toBe(false); + expect(cap.accepts("x/.vscode/settings.json")).toBe(false); + }); +}); + +describe("SettingsCapability equality", () => { + const base = { outputPath: ".vscode/settings.json", mergeStrategy: "none" as const }; + + it("is equal on the same output path and merge strategy", () => { + expect(new SettingsCapability(base).equals(new SettingsCapability({ ...base }))).toBe(true); + }); + + it("differs on the output path", () => { + expect( + new SettingsCapability(base).equals( + new SettingsCapability({ ...base, outputPath: ".vscode/extensions.json" }) + ) + ).toBe(false); + }); + + it("differs on the merge strategy", () => { + expect( + new SettingsCapability(base).equals( + new SettingsCapability({ ...base, mergeStrategy: "user-prime" }) + ) + ).toBe(false); + }); +}); diff --git a/cli/tests/contexts/tools/domain/capabilities/skills-capability.unit.test.ts b/cli/tests/contexts/tools/domain/capabilities/skills-capability.unit.test.ts index 034af43f7..75d57d0b7 100644 --- a/cli/tests/contexts/tools/domain/capabilities/skills-capability.unit.test.ts +++ b/cli/tests/contexts/tools/domain/capabilities/skills-capability.unit.test.ts @@ -100,3 +100,57 @@ describe("SkillsCapability", () => { }); }); }); + +describe("SkillsCapability without a tool suffix", () => { + it("names the skill file by its bare name", () => { + const cap = new SkillsCapability({ + directory: ".claude/", + buildInstallPath: (fileName) => fileName, + convertFrontmatter: (fm) => fm, + }); + + expect(cap.buildOutputPath("planning")).toBe(".claude/skills/planning"); + }); + + it("names what a capability lacking both prefix and directory is missing", () => { + expect( + () => + new SkillsCapability({ + buildInstallPath: (fileName) => fileName, + convertFrontmatter: (fm) => fm, + }) + ).toThrow("SkillsCapability requires either prefix or directory"); + }); +}); + +describe("SkillsCapability file acceptance", () => { + const cap = () => + new SkillsCapability({ + directory: ".claude/", + toolSuffix: ".claude.md", + buildInstallPath: (fileName) => fileName, + convertFrontmatter: (fm) => fm, + }); + + it("accepts its own suffix and a suffix belonging to no tool", () => { + expect(cap().acceptsFileName("skills/a.claude.md")).toBe(true); + expect(cap().acceptsFileName("skills/a/SKILL.md")).toBe(true); + }); + + it("refuses another tool's suffix wherever the file sits", () => { + expect(cap().acceptsFileName("a/b/c.cursor.md")).toBe(false); + }); + + it("differs from a capability with another tool suffix", () => { + expect( + cap().equals( + new SkillsCapability({ + directory: ".claude/", + toolSuffix: ".x.md", + buildInstallPath: (fileName) => fileName, + convertFrontmatter: (fm) => fm, + }) + ) + ).toBe(false); + }); +}); diff --git a/cli/tests/contexts/tools/domain/formats/command.unit.test.ts b/cli/tests/contexts/tools/domain/formats/command.unit.test.ts new file mode 100644 index 000000000..f2032a7c9 --- /dev/null +++ b/cli/tests/contexts/tools/domain/formats/command.unit.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { + buildAiddCommandFilePath, + convertCommandFrontmatter, + convertCommandFrontmatterNoHint, + stripToolSuffix, +} from "../../../../../src/contexts/tools/domain/formats/command.js"; + +describe("stripToolSuffix", () => { + it("strips the suffix off the last segment of a deeply nested path only", () => { + expect(stripToolSuffix(".claude.md", "a/b/c.claude.md")).toBe("a/b/c.md"); + }); +}); + +describe("convertCommandFrontmatter", () => { + it("namespaces the name under the phase its directory is numbered with", () => { + expect( + convertCommandFrontmatter({ name: "plan", description: "d" }, "01-plan/plan.md") + ).toStrictEqual({ name: "aidd:01:plan", description: "d" }); + }); + + it("keeps the bare name when the directory carries no leading number", () => { + expect( + convertCommandFrontmatter({ name: "plan", description: "d" }, "phase2/plan.md") + ).toStrictEqual({ name: "plan", description: "d" }); + }); + + it("reads an absent name as empty rather than as a placeholder", () => { + expect(convertCommandFrontmatter({ description: "d" }, "01-plan/plan.md")).toStrictEqual({ + name: "aidd:01:", + description: "d", + }); + }); + + it("carries the argument hint through only when the source declares one", () => { + expect( + convertCommandFrontmatter( + { name: "plan", description: "d", "argument-hint": "" }, + "plan.md" + ) + ).toStrictEqual({ name: "plan", description: "d", "argument-hint": "" }); + expect(convertCommandFrontmatter({ name: "plan", description: "d" }, "plan.md")).toStrictEqual({ + name: "plan", + description: "d", + }); + }); + + it("drops the argument hint for a tool that reads none", () => { + expect( + convertCommandFrontmatterNoHint( + { name: "plan", description: "d", "argument-hint": "" }, + "01-plan/plan.md" + ) + ).toStrictEqual({ name: "aidd:01:plan", description: "d" }); + }); +}); + +describe("buildAiddCommandFilePath", () => { + it("files a command under its numbered phase", () => { + expect(buildAiddCommandFilePath(".claude/", "01-plan/plan.md")).toBe( + ".claude/commands/aidd/01/plan.md" + ); + }); + + it("reads a directory named by its number alone as that phase", () => { + expect(buildAiddCommandFilePath(".claude/", "1/plan.md")).toBe( + ".claude/commands/aidd/1/plan.md" + ); + }); + + it("flattens a command whose directory carries no leading number", () => { + expect(buildAiddCommandFilePath(".claude/", "phase2/plan.md")).toBe( + ".claude/commands/aidd/plan.md" + ); + }); + + it("keeps only the file name of a deeper unnumbered path", () => { + expect(buildAiddCommandFilePath(".claude/", "a/b/c.md")).toBe(".claude/commands/aidd/c.md"); + }); + + it("files a bare file name directly under the aidd namespace", () => { + expect(buildAiddCommandFilePath(".claude/", "plan.md")).toBe(".claude/commands/aidd/plan.md"); + }); +}); diff --git a/cli/tests/contexts/tools/domain/formats/cursor-hooks-project-merge.unit.test.ts b/cli/tests/contexts/tools/domain/formats/cursor-hooks-project-merge.unit.test.ts index 7e0f9ba06..e21af7120 100644 --- a/cli/tests/contexts/tools/domain/formats/cursor-hooks-project-merge.unit.test.ts +++ b/cli/tests/contexts/tools/domain/formats/cursor-hooks-project-merge.unit.test.ts @@ -81,3 +81,36 @@ describe("cursorProjectHooksScriptDir", () => { expect(cursorProjectHooksScriptDir("aidd-telemetry")).toBe(".cursor/hooks/aidd-telemetry/"); }); }); + +describe("rewriting a plugin's root token for the project hooks file", () => { + it("relocates a command under hooks/ to the plugin's own script directory", () => { + const { content } = mergeCursorProjectHooksJson(null, PLUGIN_A_HOOKS, "plugin-a"); + + expect(JSON.parse(content)).toStrictEqual({ + version: 1, + hooks: { postToolUse: [{ command: "node ./.cursor/hooks/plugin-a/journal.cjs" }] }, + }); + }); + + it("leaves a plugin-relative path outside hooks/ where it was", () => { + const { content } = mergeCursorProjectHooksJson( + null, + hooksJson(`node ${PLUGIN_ROOT_VAR}/scripts/run.cjs`), + "plugin-a" + ); + + expect(JSON.parse(content)).toStrictEqual({ + version: 1, + hooks: { postToolUse: [{ command: "node scripts/run.cjs" }] }, + }); + }); + + it("names where a hook script lands, with or without its hooks/ prefix", () => { + expect(cursorProjectHooksScriptPath("plugin-a", "hooks/journal.cjs")).toBe( + ".cursor/hooks/plugin-a/journal.cjs" + ); + expect(cursorProjectHooksScriptPath("plugin-a", "lib/x.cjs")).toBe( + ".cursor/hooks/plugin-a/lib/x.cjs" + ); + }); +}); diff --git a/cli/tests/contexts/tools/domain/formats/flat-hooks-merge.unit.test.ts b/cli/tests/contexts/tools/domain/formats/flat-hooks-merge.unit.test.ts index 3fc669cec..38c580fd4 100644 --- a/cli/tests/contexts/tools/domain/formats/flat-hooks-merge.unit.test.ts +++ b/cli/tests/contexts/tools/domain/formats/flat-hooks-merge.unit.test.ts @@ -5,6 +5,7 @@ import { mergeClaudeSettingsHooks, mergeCodexFrameworkHooksJson, mergeCursorFlatHooks, + renameCodexHookEvents, } from "../../../../../src/contexts/tools/domain/formats/flat-hooks-merge.js"; describe("mergeClaudeSettingsHooks", () => { @@ -410,3 +411,182 @@ describe("hookCommandsForEvent", () => { expect(hookCommandsForEvent(JSON.stringify({ hooks: [] }), "SessionStart")).toEqual([]); }); }); + +describe("renameCodexHookEvents", () => { + it("returns a document without hooks byte for byte", () => { + expect(renameCodexHookEvents('{"x":1}')).toBe('{"x":1}'); + }); + + it("renames Stop to SessionEnd and leaves the other events under their own names", () => { + const renamed = renameCodexHookEvents( + JSON.stringify({ + hooks: { + Stop: [{ hooks: [{ type: "command", command: "a" }] }], + PreToolUse: [{ hooks: [] }], + }, + }) + ); + + expect(renamed).toBe( + `${JSON.stringify( + { + hooks: { + SessionEnd: [{ hooks: [{ type: "command", command: "a" }] }], + PreToolUse: [{ hooks: [] }], + }, + }, + null, + 2 + )}\n` + ); + }); +}); + +describe("flattenCopilotHooksShape, entry by entry", () => { + it("omits an event whose groups hold no runnable entry", () => { + const flat = flattenCopilotHooksShape( + JSON.stringify({ hooks: { PreToolUse: [{ hooks: [] }, { hooks: [{ type: "command" }] }] } }) + ); + + expect(JSON.parse(flat)).toStrictEqual({ version: 1 }); + }); + + it("defaults a missing type to command, keeps a declared one, and carries timeout only when set", () => { + const flat = flattenCopilotHooksShape( + JSON.stringify({ + hooks: { + Stop: [{ hooks: [{ command: "a" }, { type: "prompt", command: "b", timeout: 5 }] }], + }, + }) + ); + + expect(JSON.parse(flat)).toStrictEqual({ + version: 1, + hooks: { + Stop: [ + { type: "command", command: "a" }, + { type: "prompt", command: "b", timeout: 5 }, + ], + }, + }); + }); +}); + +describe("mergeCursorFlatHooks, entry by entry", () => { + it("keeps only the entries that carry a command string", () => { + const { content } = mergeCursorFlatHooks( + null, + JSON.stringify({ + hooks: { Stop: [{ hooks: [{ type: "command" }, { command: "a" }, { command: 5 }] }] }, + }) + ); + + expect(JSON.parse(content)).toStrictEqual({ + version: 1, + hooks: { stop: [{ command: "a" }], sessionEnd: [{ command: "a" }] }, + }); + }); +}); + +describe("mergeCodexFrameworkHooksJson, entry by entry", () => { + it("writes a matcher only when the group declares one, and drops an item without a command", () => { + const { content } = mergeCodexFrameworkHooksJson( + null, + JSON.stringify({ + hooks: { + PreToolUse: [ + { matcher: "Bash", hooks: [{ command: "a" }, { type: "command" }] }, + { hooks: [{ type: "prompt", command: "b" }] }, + ], + }, + }) + ); + + expect(JSON.parse(content)).toStrictEqual({ + hooks: { + PreToolUse: [ + { matcher: "Bash", hooks: [{ type: "command", command: "a" }] }, + { hooks: [{ type: "prompt", command: "b" }] }, + ], + }, + }); + }); + + it("carries timeout and statusMessage only when each is declared in its own type", () => { + const { content } = mergeCodexFrameworkHooksJson( + null, + JSON.stringify({ + hooks: { + Stop: [ + { + hooks: [ + { command: "a", timeout: 5, statusMessage: "working" }, + { command: "b", timeout: "5", statusMessage: 7 }, + ], + }, + ], + }, + }) + ); + + expect(JSON.parse(content)).toStrictEqual({ + hooks: { + SessionEnd: [ + { + hooks: [ + { type: "command", command: "a", timeout: 5, statusMessage: "working" }, + { type: "command", command: "b" }, + ], + }, + ], + }, + }); + }); +}); + +describe("hookCommandsForEvent, on content that is not a hooks file", () => { + it("answers nothing for a document that is not an object", () => { + expect(hookCommandsForEvent("[]", "Stop")).toStrictEqual([]); + expect(hookCommandsForEvent("5", "Stop")).toStrictEqual([]); + }); + + it("skips an entry that is not an object, and a command that is not a string", () => { + const content = JSON.stringify({ + hooks: { + Stop: [null, "x", { command: 5 }, { command: "c" }, { hooks: [{ command: "d" }, 3] }], + }, + }); + + expect(hookCommandsForEvent(content, "Stop")).toStrictEqual(["c", "d"]); + }); +}); + +describe("a matcher group that declares no hooks list", () => { + const groupless = JSON.stringify({ + hooks: { Stop: [{ matcher: "x" }, { hooks: [{ command: "a" }] }] }, + }); + + it("is skipped by the Copilot flattening", () => { + expect(JSON.parse(flattenCopilotHooksShape(groupless))).toStrictEqual({ + version: 1, + hooks: { Stop: [{ type: "command", command: "a" }] }, + }); + }); + + it("is skipped by the Cursor merge", () => { + expect(JSON.parse(mergeCursorFlatHooks(null, groupless).content)).toStrictEqual({ + version: 1, + hooks: { stop: [{ command: "a" }], sessionEnd: [{ command: "a" }] }, + }); + }); +}); + +describe("hookCommandsForEvent, for an event Cursor never renames", () => { + it("reads the event under its own name alone", () => { + const content = JSON.stringify({ + hooks: { PreCompact: [{ command: "a" }], preCompact: [{ command: "b" }] }, + }); + + expect(hookCommandsForEvent(content, "PreCompact")).toStrictEqual(["a"]); + }); +}); diff --git a/cli/tests/contexts/tools/domain/formats/mcp-format.unit.test.ts b/cli/tests/contexts/tools/domain/formats/mcp-format.unit.test.ts new file mode 100644 index 000000000..d1909bcbf --- /dev/null +++ b/cli/tests/contexts/tools/domain/formats/mcp-format.unit.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it } from "vitest"; +import { + deepMerge, + mcpJsonToToml, + mergeJsonUserPrime, +} from "../../../../../src/contexts/tools/domain/formats/mcp-format.js"; + +function mcpJson(servers: Record): string { + return JSON.stringify({ mcpServers: servers }); +} + +describe("mcpJsonToToml", () => { + describe("a stdio server", () => { + it("carries its command alone when nothing else is declared", () => { + expect(mcpJsonToToml(mcpJson({ fs: { command: "npx" } }))).toBe( + '[mcp_servers.fs]\ncommand = "npx"\n' + ); + }); + + it("carries args, cwd, every universal field, and env as its own table", () => { + const toml = mcpJsonToToml( + mcpJson({ + fs: { + command: "npx", + args: ["-y", "x"], + env: { A: "1" }, + cwd: "/w", + startup_timeout_sec: 5, + tool_timeout_sec: 9, + enabled: true, + required: false, + enabled_tools: ["a"], + disabled_tools: ["b"], + }, + }) + ); + + expect(toml).toBe( + [ + "[mcp_servers.fs]", + 'command = "npx"', + 'args = [ "-y", "x" ]', + 'cwd = "/w"', + "startup_timeout_sec = 5", + "tool_timeout_sec = 9", + "enabled = true", + "required = false", + 'enabled_tools = [ "a" ]', + 'disabled_tools = [ "b" ]', + "", + "[mcp_servers.fs.env]", + 'A = "1"', + "", + ].join("\n") + ); + }); + + it("drops a field the source spells but Codex does not read", () => { + expect(mcpJsonToToml(mcpJson({ fs: { command: "npx", type: "stdio" } }))).toBe( + '[mcp_servers.fs]\ncommand = "npx"\n' + ); + }); + }); + + describe("an http server", () => { + it("carries its url alone when nothing else is declared", () => { + expect(mcpJsonToToml(mcpJson({ web: { url: "https://h" } }))).toBe( + '[mcp_servers.web]\nurl = "https://h"\n' + ); + }); + + it("renames bearerTokenEnvVar to Codex's own spelling and keeps both header maps", () => { + const toml = mcpJsonToToml( + mcpJson({ + web: { + url: "https://h", + bearerTokenEnvVar: "T", + http_headers: { X: "1" }, + env_http_headers: { Y: "Z" }, + tool_timeout_sec: 2, + }, + }) + ); + + expect(toml).toBe( + [ + "[mcp_servers.web]", + 'url = "https://h"', + 'bearer_token_env_var = "T"', + "tool_timeout_sec = 2", + "", + "[mcp_servers.web.http_headers]", + 'X = "1"', + "", + "[mcp_servers.web.env_http_headers]", + 'Y = "Z"', + "", + ].join("\n") + ); + }); + + it("carries every universal field an http server declares", () => { + const toml = mcpJsonToToml( + mcpJson({ + web: { + url: "https://h", + startup_timeout_sec: 1, + enabled: false, + required: true, + enabled_tools: ["a"], + disabled_tools: [], + }, + }) + ); + + expect(toml).toBe( + [ + "[mcp_servers.web]", + 'url = "https://h"', + "startup_timeout_sec = 1", + "enabled = false", + "required = true", + 'enabled_tools = [ "a" ]', + "disabled_tools = []", + "", + ].join("\n") + ); + }); + }); + + it("emits an empty table for a server that is neither stdio nor http", () => { + expect(mcpJsonToToml(mcpJson({ odd: { transport: "sse" } }))).toBe("[mcp_servers.odd]\n"); + }); + + it("emits nothing when the source declares no server", () => { + expect(mcpJsonToToml(mcpJson({}))).toBe(""); + expect(mcpJsonToToml("{}")).toBe(""); + }); +}); + +describe("mergeJsonUserPrime", () => { + it("takes the incoming document whole when nothing existed", () => { + expect(mergeJsonUserPrime("", '{"mcpServers":{"a":{"command":"x"}}}')).toBe( + JSON.stringify({ mcpServers: { a: { command: "x" } } }, null, 2) + ); + }); + + it("treats a whitespace-only existing file as absent rather than as malformed", () => { + expect(mergeJsonUserPrime(" \n", '{"a":1}')).toBe('{\n "a": 1\n}'); + }); + + it("keeps the existing value of a key both sides declare", () => { + expect(JSON.parse(mergeJsonUserPrime('{"a":1,"b":2}', '{"a":9,"c":3}'))).toStrictEqual({ + a: 1, + c: 3, + b: 2, + }); + }); + + it("merges nested objects key by key, the existing side winning", () => { + const merged = mergeJsonUserPrime( + '{"mcpServers":{"a":{"command":"user"}}}', + '{"mcpServers":{"a":{"command":"plugin","args":["-y"]},"b":{"command":"y"}}}' + ); + + expect(JSON.parse(merged)).toStrictEqual({ + mcpServers: { a: { command: "user", args: ["-y"] }, b: { command: "y" } }, + }); + }); +}); + +describe("deepMerge", () => { + it("replaces an array rather than merging it as an object", () => { + expect(deepMerge({ a: { b: 1 } }, { a: [1] })).toStrictEqual({ a: [1] }); + expect(deepMerge({ a: [1, 2] }, { a: [3] })).toStrictEqual({ a: [3] }); + }); + + it("lets a null from the source replace an object in the target", () => { + expect(deepMerge({ a: { b: 1 } }, { a: null })).toStrictEqual({ a: null }); + }); + + it("recurses into an object both sides carry, and leaves the target's other keys", () => { + expect(deepMerge({ a: { b: 1, c: { d: 1 } }, e: 5 }, { a: { c: { f: 2 } } })).toStrictEqual({ + a: { b: 1, c: { d: 1, f: 2 } }, + e: 5, + }); + }); + + it("copies a source object over a scalar in the target", () => { + expect(deepMerge({ a: 1 }, { a: { b: 2 } })).toStrictEqual({ a: { b: 2 } }); + }); +}); diff --git a/cli/tests/contexts/tools/domain/host-plugin-registration.unit.test.ts b/cli/tests/contexts/tools/domain/host-plugin-registration.unit.test.ts index 532bd1317..af6f3ba03 100644 --- a/cli/tests/contexts/tools/domain/host-plugin-registration.unit.test.ts +++ b/cli/tests/contexts/tools/domain/host-plugin-registration.unit.test.ts @@ -99,3 +99,39 @@ describe("what a host's own registry says about a plugin AIDD installed", () => expect(buildHostRegistration([])).toEqual({ entries: [] }); }); }); + +describe("the sentence each answer carries", () => { + it("names the missing marketplace, and builds no ref, when none was recorded or it is empty", () => { + for (const marketplace of [undefined, ""]) { + expect(only(evidence({ plugins: [{ name: "p", marketplace }] }))).toStrictEqual({ + tool: "claude", + plugin: "p", + answer: "unanswerable", + detail: "AIDD records no marketplace for it, so no host registry can be asked", + }); + } + }); + + it("names the registry and the ref it records as disabled", () => { + const entry = only( + evidence({ + reading: { + location: REGISTRY, + refs: new Map([["aidd-telemetry@aidd-framework", { enabled: false }]]), + }, + }) + ); + + expect(entry.answer).toBe("registered-disabled"); + expect(entry.detail).toBe( + `${REGISTRY} carries aidd-telemetry@aidd-framework and records it disabled` + ); + }); + + it("says a registry that gave no reason could not be read, without inventing one", () => { + const entry = only(evidence({ reading: { location: REGISTRY } })); + + expect(entry.answer).toBe("unanswerable"); + expect(entry.detail).toBe(`${REGISTRY} could not be read — no reason given`); + }); +}); diff --git a/cli/tests/contexts/tools/domain/marketplace-catalog.unit.test.ts b/cli/tests/contexts/tools/domain/marketplace-catalog.unit.test.ts index a3b9a9d53..64d558553 100644 --- a/cli/tests/contexts/tools/domain/marketplace-catalog.unit.test.ts +++ b/cli/tests/contexts/tools/domain/marketplace-catalog.unit.test.ts @@ -2,9 +2,16 @@ import { describe, expect, it } from "vitest"; import type { PluginPresence } from "../../../../src/contexts/tools/domain/build-contract.js"; import { buildClaudeStyleCatalogEntry, + buildClaudeStyleEntry, buildClaudeStyleMarketplace, + buildCodexMarketplace, + buildCodexMarketplaceEntry, + resolveDescription, + resolveVersion, synthesizeClaudeStyleManifest, } from "../../../../src/contexts/tools/domain/marketplace-catalog.js"; +import { InvalidSourceMarketplaceError } from "../../../../src/kernel/errors.js"; +import { InMemoryFileAdapter } from "../../../helpers/ports/index.js"; const EMPTY_PRESENCE: PluginPresence = { hasAgents: false, @@ -261,3 +268,135 @@ describe("buildClaudeStyleCatalogEntry", () => { expect(typeof entry.strict).toBe("boolean"); }); }); + +describe("synthesizeClaudeStyleManifest, field by field", () => { + const opts = { agentsField: true, hooksField: true }; + + it("writes no key at all for a source declaring nothing", () => { + expect(synthesizeClaudeStyleManifest({}, EMPTY_PRESENCE, opts)).toStrictEqual({}); + }); + + it("keeps an author given as an object or a string, and drops one of any other type", () => { + expect( + synthesizeClaudeStyleManifest({ author: { name: "B" } }, EMPTY_PRESENCE, opts) + ).toStrictEqual({ author: { name: "B" } }); + expect(synthesizeClaudeStyleManifest({ author: "B" }, EMPTY_PRESENCE, opts)).toStrictEqual({ + author: "B", + }); + expect(synthesizeClaudeStyleManifest({ author: 7 }, EMPTY_PRESENCE, opts)).toStrictEqual({}); + }); + + it("drops a name, description or version that is not a string", () => { + expect( + synthesizeClaudeStyleManifest({ name: 1, description: 2, version: 3 }, EMPTY_PRESENCE, opts) + ).toStrictEqual({}); + }); +}); + +describe("buildClaudeStyleMarketplace, field by field", () => { + it("writes only the name and the plugins for a source declaring nothing else", () => { + expect(buildClaudeStyleMarketplace({ name: "m" }, [])).toStrictEqual({ + name: "m", + plugins: [], + }); + }); +}); + +describe("resolving a catalog entry's version and description", () => { + const MANIFEST = "/out/plugins/p/.claude-plugin/plugin.json"; + + it("takes the marketplace entry's own version and description without opening the manifest", async () => { + const fs = new InMemoryFileAdapter(); + + expect( + await resolveVersion(fs, "p", { version: "9.9.9" }, "/out", ".claude-plugin/plugin.json") + ).toBe("9.9.9"); + expect( + await resolveDescription( + fs, + "p", + { description: "from entry" }, + "/out", + ".claude-plugin/plugin.json" + ) + ).toBe("from entry"); + }); + + it("falls back to the built manifest, and names what is missing when neither side has it", async () => { + const fs = new InMemoryFileAdapter({ + [MANIFEST]: JSON.stringify({ version: "1.0.0", description: "" }), + }); + + expect(await resolveVersion(fs, "p", undefined, "/out", ".claude-plugin/plugin.json")).toBe( + "1.0.0" + ); + await expect( + resolveDescription(fs, "p", {}, "/out", ".claude-plugin/plugin.json") + ).rejects.toThrow( + new InvalidSourceMarketplaceError( + "plugin 'p' has no description in marketplace entry or plugin.json" + ) + ); + await expect( + resolveVersion( + new InMemoryFileAdapter({ [MANIFEST]: "{}" }), + "p", + {}, + "/out", + ".claude-plugin/plugin.json" + ) + ).rejects.toThrow( + new InvalidSourceMarketplaceError( + "plugin 'p' has no version in marketplace entry or plugin.json" + ) + ); + }); + + it("shapes the whole entry from both resolutions", async () => { + const fs = new InMemoryFileAdapter({ + [MANIFEST]: JSON.stringify({ version: "1.0.0", description: "built" }), + }); + + expect( + await buildClaudeStyleEntry("p", "/out", { strict: true }, ".claude-plugin/plugin.json", fs) + ).toStrictEqual({ + name: "p", + source: "./plugins/p", + description: "built", + version: "1.0.0", + strict: true, + }); + }); +}); + +describe("a Codex marketplace catalog", () => { + it("falls back to the marketplace name as its display name", () => { + expect(buildCodexMarketplace({ name: "m" }, [])).toStrictEqual({ + name: "m", + interface: { displayName: "m" }, + plugins: [], + }); + expect(buildCodexMarketplace({ name: "m", displayName: "Mine" }, []).interface).toStrictEqual({ + displayName: "Mine", + }); + }); + + it("defaults an entry's authentication and category, and takes a string override for each", () => { + expect(buildCodexMarketplaceEntry("p", undefined)).toStrictEqual({ + name: "p", + source: { source: "local", path: "./plugins/p" }, + policy: { installation: "AVAILABLE", authentication: "ON_USE" }, + category: "Developer Tools", + }); + expect( + buildCodexMarketplaceEntry("p", { authentication: "NEVER", category: "Testing" }) + ).toMatchObject({ + policy: { installation: "AVAILABLE", authentication: "NEVER" }, + category: "Testing", + }); + expect(buildCodexMarketplaceEntry("p", { authentication: 1, category: null })).toMatchObject({ + policy: { authentication: "ON_USE" }, + category: "Developer Tools", + }); + }); +}); diff --git a/cli/tests/contexts/tools/domain/marketplace-source-conflict.unit.test.ts b/cli/tests/contexts/tools/domain/marketplace-source-conflict.unit.test.ts index 52342b76d..e6229c74d 100644 --- a/cli/tests/contexts/tools/domain/marketplace-source-conflict.unit.test.ts +++ b/cli/tests/contexts/tools/domain/marketplace-source-conflict.unit.test.ts @@ -121,3 +121,40 @@ describe("pluginSetDifference / describePluginDiff", () => { expect(describePluginDiff(diff)).toBe("match, but the declared name differs"); }); }); + +describe("marketplaceSourceConflict, at the edges", () => { + it("is not a conflict when the name is absent, even with a registered identity in hand", () => { + const reading = { location: LOCATION, entries: new Map([["other", "/src"]]) }; + + expect( + marketplaceSourceConflict(reading, "probe-mkt", "/req", IDENTITY_B, IDENTITY_A) + ).toBeUndefined(); + }); + + it("is a conflict when the requested plugin set merely extends the registered one", () => { + const reading = { location: LOCATION, entries: new Map([["probe-mkt", "/src"]]) }; + const wider = { name: "probe-mkt", pluginNames: ["sample-plugin", "zeta-plugin"] }; + + expect( + marketplaceSourceConflict(reading, "probe-mkt", "/req", IDENTITY_A, wider) + ).toBeDefined(); + }); + + it("compares plugin sets whichever side lists them out of order", () => { + const reading = { location: LOCATION, entries: new Map([["probe-mkt", "/src"]]) }; + const unordered = { name: "probe-mkt", pluginNames: ["b", "a"] }; + const ordered = { name: "probe-mkt", pluginNames: ["a", "b"] }; + + expect( + marketplaceSourceConflict(reading, "probe-mkt", "/req", unordered, ordered) + ).toBeUndefined(); + }); +}); + +describe("describePluginDiff, with several names on each side", () => { + it("lists every added and removed name, comma separated", () => { + expect(describePluginDiff({ added: ["a", "b"], removed: ["c", "d"] })).toBe( + "differ (+a, b, -c, d)" + ); + }); +}); diff --git a/cli/tests/contexts/tools/domain/mcp-exclusion.unit.test.ts b/cli/tests/contexts/tools/domain/mcp-exclusion.unit.test.ts index dfec36814..960de5f14 100644 --- a/cli/tests/contexts/tools/domain/mcp-exclusion.unit.test.ts +++ b/cli/tests/contexts/tools/domain/mcp-exclusion.unit.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { transformFor } from "../../../../src/contexts/tools/domain/mcp-exclusion.js"; +import { + mcpExclusionEquals, + transformFor, +} from "../../../../src/contexts/tools/domain/mcp-exclusion.js"; function makeConfig(servers: Record): string { return JSON.stringify({ mcpServers: servers }, null, 2); @@ -80,3 +83,24 @@ describe("transformFor()", () => { }); }); }); + +describe("the win32 transform on a config without servers", () => { + it("re-serializes the document untouched", () => { + const transform = transformFor("win32"); + if (transform === undefined) throw new Error("win32 declares a transform"); + + expect(transform('{"other":{"command":"npx"}}')).toBe( + JSON.stringify({ other: { command: "npx" } }, null, 2) + ); + }); +}); + +describe("mcpExclusionEquals", () => { + it("is equal only when both the config path and the entry key match", () => { + const one = { configPath: ".mcp.json", entryKey: "a" }; + + expect(mcpExclusionEquals(one, { configPath: ".mcp.json", entryKey: "a" })).toBe(true); + expect(mcpExclusionEquals(one, { configPath: ".mcp.json", entryKey: "b" })).toBe(false); + expect(mcpExclusionEquals(one, { configPath: "other.json", entryKey: "a" })).toBe(false); + }); +}); diff --git a/cli/tests/contexts/tools/domain/registry.unit.test.ts b/cli/tests/contexts/tools/domain/registry.unit.test.ts new file mode 100644 index 000000000..16c44bd1a --- /dev/null +++ b/cli/tests/contexts/tools/domain/registry.unit.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from "vitest"; +import { + type NativePluginsParams, + PluginsCapability, +} from "../../../../src/contexts/tools/domain/capabilities/plugins-capability.js"; +import type { AiTool } from "../../../../src/contexts/tools/domain/contracts.js"; +import { + getToolConfig, + hasToolSignals, + nativeActivationOf, + projectHooksFileOf, + registerTool, + supportsUserScopeActivation, + userMachineLocalFilesOf, +} from "../../../../src/contexts/tools/domain/registry.js"; +import type { AiToolId } from "../../../../src/kernel/tool.js"; +import { InMemoryFileAdapter } from "../../../helpers/ports/index.js"; + +function stubTool( + toolId: AiToolId, + capabilities: unknown, + signalDir: string | null +): AiTool { + return { + kind: "ai", + toolId, + directory: `.${toolId}/`, + toolSuffix: `.${toolId}.md`, + signalDir, + displayName: toolId, + telemetryLocalRead: { kind: "unsupported", reason: "a stub reads nothing" }, + telemetryTaskAttributable: false, + capabilities, + rewriteContent: (content: string) => content, + }; +} + +function nativePlugins(extra: Partial = {}): PluginsCapability { + return new PluginsCapability({ + mode: "native", + acceptsHooks: true, + pluginsDir: ".x/plugins/", + pluginManifestRelativePath: null, + ...extra, + }); +} + +describe("a tool's own files as a signal it is in use", () => { + it("answers nothing for a tool declaring no signal directory", async () => { + registerTool(stubTool("cursor", {}, null)); + const fs = new InMemoryFileAdapter({ "/p/.cursor/commands/a.md": "name: aidd:x" }); + + expect(await hasToolSignals(fs, getToolConfig("cursor"), "/p")).toStrictEqual([]); + }); + + it("answers nothing when the signal directory does not exist", async () => { + registerTool(stubTool("cursor", {}, ".cursor/commands")); + + expect( + await hasToolSignals(new InMemoryFileAdapter(), getToolConfig("cursor"), "/p") + ).toStrictEqual([]); + }); + + it("names every markdown file whose frontmatter declares an aidd name, and only those", async () => { + registerTool(stubTool("cursor", {}, ".cursor/commands")); + const fs = new InMemoryFileAdapter({ + "/p/.cursor/commands/colon.md": "---\nname: aidd:plan\n---", + "/p/.cursor/commands/quoted.md": "---\nname: 'aidd_plan'\n---", + "/p/.cursor/commands/tight.md": "name:aidd:plan", + "/p/.cursor/commands/indented.md": " name: aidd:plan", + "/p/.cursor/commands/other.md": "name: mine", + "/p/.cursor/commands/notes.txt": "name: aidd:plan", + }); + + expect((await hasToolSignals(fs, getToolConfig("cursor"), "/p")).sort()).toStrictEqual([ + ".cursor/commands/colon.md", + ".cursor/commands/quoted.md", + ".cursor/commands/tight.md", + ]); + }); +}); + +describe("what a tool's plugin capability declares about activation", () => { + it("declares no native activation, no project hooks file and no user scope without a plugins capability", () => { + registerTool(stubTool("cursor", {}, null)); + + expect(nativeActivationOf("cursor")).toBeUndefined(); + expect(projectHooksFileOf("cursor")).toBeUndefined(); + expect(supportsUserScopeActivation("cursor")).toBe(false); + }); + + it("supports a user scope through its own CLI, or through a user-scope install directory, and not otherwise", () => { + registerTool(stubTool("cursor", { plugins: nativePlugins() }, null)); + expect(supportsUserScopeActivation("cursor")).toBe(false); + + registerTool( + stubTool( + "cursor", + { plugins: nativePlugins({ nativeActivation: { binary: "codex" } }) }, + null + ) + ); + expect(supportsUserScopeActivation("cursor")).toBe(true); + + registerTool( + stubTool( + "cursor", + { + plugins: nativePlugins({ installScope: "user", userPluginsDir: (home) => `${home}/.x` }), + }, + null + ) + ); + expect(supportsUserScopeActivation("cursor")).toBe(true); + }); + + it("names the project hooks file only for a tool merging hooks into the project", () => { + registerTool( + stubTool( + "cursor", + { + plugins: nativePlugins({ + hooksDestination: "project", + projectHooksRelativePath: ".cursor/hooks.json", + }), + }, + null + ) + ); + expect(projectHooksFileOf("cursor")).toBe(".cursor/hooks.json"); + + registerTool(stubTool("cursor", { plugins: nativePlugins() }, null)); + expect(projectHooksFileOf("cursor")).toBeUndefined(); + }); + + it("names a user settings file only for a tool whose activation declares one", () => { + registerTool( + stubTool( + "cursor", + { plugins: nativePlugins({ nativeActivation: { binary: "codex" } }) }, + null + ) + ); + expect(userMachineLocalFilesOf("cursor", "/home/me", () => undefined)).toStrictEqual([]); + + registerTool( + stubTool( + "cursor", + { + plugins: nativePlugins({ + nativeActivation: { + binary: "codex", + userSettingsPath: (home, env) => `${home}/${env("X") ?? "settings.json"}`, + }, + }), + }, + null + ) + ); + expect(userMachineLocalFilesOf("cursor", "/home/me", () => undefined)).toStrictEqual([ + "/home/me/settings.json", + ]); + }); +}); From 5f9c415ad60686843e1fe44a8755144006e57169 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Wed, 9 Sep 2026 19:40:12 +0200 Subject: [PATCH 2/4] test(cli): kill the surviving mutants of the tool host readers and plugin CLI adapter Tests written for the survivors stryker named in the tools scope's infrastructure: the plugin CLI adapter's marketplace removal, source check, declared verbs, failure messages and batch-shim spawn; the PATH lookup's empty segments, quoting and host description; Claude's, Codex's and Copilot's plugin registries at the edges of their shapes; and Claude's marketplace registry on documents that are not objects, entries without an install location and a dead path. Every test was watched red under a hand-applied mutation before the source was restored. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011x4ms5qcGuZgYhCxfdHMUb AIDD-Session-Id: 4acc9a1c-19bc-4468-b8b6-e86644bcba60 --- ...ive-plugin-cli-adapter.integration.test.ts | 228 ++++++++++++++++++ .../executable-on-path.unit.test.ts | 63 +++++ ...egistry-reader-adapter.integration.test.ts | 57 +++++ ...egistry-reader-adapter.integration.test.ts | 128 ++++++++++ 4 files changed, 476 insertions(+) create mode 100644 cli/tests/contexts/tools/infrastructure/abstract-native-plugin-cli-adapter.integration.test.ts diff --git a/cli/tests/contexts/tools/infrastructure/abstract-native-plugin-cli-adapter.integration.test.ts b/cli/tests/contexts/tools/infrastructure/abstract-native-plugin-cli-adapter.integration.test.ts new file mode 100644 index 000000000..3a6aeecac --- /dev/null +++ b/cli/tests/contexts/tools/infrastructure/abstract-native-plugin-cli-adapter.integration.test.ts @@ -0,0 +1,228 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { windowsCommandLine } from "../../../../src/contexts/tools/infrastructure/executable-on-path.js"; +import { + NativePluginCliAdapter, + type NativePluginCliShape, +} from "../../../../src/contexts/tools/infrastructure/native-plugin-cli-adapter.js"; +import { NativePluginCliError } from "../../../../src/kernel/errors.js"; + +vi.mock("node:child_process", () => ({ + spawnSync: vi.fn(), +})); + +const mockSpawnSync = vi.mocked(spawnSync); + +function makeResult(overrides: Partial>) { + return { + pid: 1, + output: [], + stdout: "", + stderr: "", + status: 0, + signal: null, + error: undefined, + ...overrides, + } as ReturnType; +} + +const RUN_OPTIONS = { timeout: 120000, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }; +const PROBE_OPTIONS = { timeout: 120000, stdio: ["ignore", "ignore", "ignore"], encoding: "utf-8" }; + +const FULL_SHAPE: NativePluginCliShape = { + scopeArgs: { project: ["--scope", "local"], user: ["--scope", "user"] }, + forceRemoveArgs: ["--force"], + sourceCheckVerb: "update", + upgradeVerb: "update", + enableVerb: "install", + disableVerb: "uninstall", + pluginArgs: ["--yes"], +}; + +function adapter(shape: NativePluginCliShape = FULL_SHAPE): NativePluginCliAdapter { + return new NativePluginCliAdapter("probe-tool", shape); +} + +afterEach(() => { + mockSpawnSync.mockReset(); +}); + +describe("registering and removing a marketplace through the tool's own CLI", () => { + it("removes a marketplace by name at the scope asked for, and nothing more by default", () => { + mockSpawnSync.mockReturnValue(makeResult({})); + + adapter().removeMarketplace("aidd-framework", "user"); + + expect(mockSpawnSync).toHaveBeenCalledWith( + "probe-tool", + ["plugin", "marketplace", "remove", "aidd-framework", "--scope", "user"], + RUN_OPTIONS + ); + }); + + it("forces a removal past installed plugins only when asked, with the arguments the tool declares", () => { + mockSpawnSync.mockReturnValue(makeResult({})); + + adapter().removeMarketplace("aidd-framework", "project", { force: true }); + adapter().removeMarketplace("aidd-framework", "project", { force: false }); + + expect(mockSpawnSync.mock.calls.map((call) => call[1])).toStrictEqual([ + ["plugin", "marketplace", "remove", "aidd-framework", "--scope", "local", "--force"], + ["plugin", "marketplace", "remove", "aidd-framework", "--scope", "local"], + ]); + }); + + it("forces nothing for a tool declaring no force arguments", () => { + mockSpawnSync.mockReturnValue(makeResult({})); + + adapter({}).removeMarketplace("aidd-framework", "project", { force: true }); + + expect(mockSpawnSync).toHaveBeenCalledWith( + "probe-tool", + ["plugin", "marketplace", "remove", "aidd-framework"], + RUN_OPTIONS + ); + }); +}); + +describe("telling a live registration from a dead one", () => { + it("answers unknown, without running anything, for a tool declaring no source check", () => { + expect(adapter({}).registrationState("aidd-framework")).toBe("unknown"); + expect(mockSpawnSync).not.toHaveBeenCalled(); + }); + + it("answers live when the source check exits cleanly, asking only for its exit code", () => { + mockSpawnSync.mockReturnValue(makeResult({})); + + expect(adapter().registrationState("aidd-framework")).toBe("live"); + expect(mockSpawnSync).toHaveBeenCalledWith( + "probe-tool", + ["plugin", "marketplace", "update", "aidd-framework"], + PROBE_OPTIONS + ); + }); + + it("answers dead when the source check fails, or cannot be spawned at all", () => { + mockSpawnSync.mockReturnValueOnce(makeResult({ status: 1 })); + expect(adapter().registrationState("aidd-framework")).toBe("dead"); + + mockSpawnSync.mockReturnValueOnce(makeResult({ error: new Error("ENOENT") })); + expect(adapter().registrationState("aidd-framework")).toBe("dead"); + }); +}); + +describe("driving the verbs a tool declares, and only those", () => { + it("re-indexes marketplaces with the declared verb, and does nothing without one", () => { + mockSpawnSync.mockReturnValue(makeResult({})); + + adapter().upgradeMarketplaces(); + adapter({}).upgradeMarketplaces(); + + expect(mockSpawnSync.mock.calls.map((call) => call[1])).toStrictEqual([ + ["plugin", "marketplace", "update"], + ]); + }); + + it("enables and uninstalls a plugin with the declared verb, the plugin arguments and the scope", () => { + mockSpawnSync.mockReturnValue(makeResult({})); + + adapter().enablePlugin("p@m"); + adapter().uninstallPlugin("p@m", "user"); + + expect(mockSpawnSync.mock.calls.map((call) => call[1])).toStrictEqual([ + ["plugin", "install", "p@m", "--yes", "--scope", "local"], + ["plugin", "uninstall", "p@m", "--yes", "--scope", "user"], + ]); + }); + + it("neither enables nor uninstalls for a tool that loads plugins from a file this CLI writes", () => { + adapter({}).enablePlugin("p@m"); + adapter({}).uninstallPlugin("p@m"); + + expect(adapter({}).enablesPlugins()).toBe(false); + expect(adapter().enablesPlugins()).toBe(true); + expect(mockSpawnSync).not.toHaveBeenCalled(); + }); +}); + +describe("naming a failure by the tool, the step and what the tool said", () => { + it("carries the spawn error's own message when the process never started", () => { + mockSpawnSync.mockReturnValue(makeResult({ error: new Error("spawn ENOENT") })); + + expect(() => adapter().addMarketplace("/src", "project")).toThrow( + new NativePluginCliError("probe-tool marketplace add /src failed: spawn ENOENT") + ); + }); + + it("carries the tool's trimmed stderr on a non-zero exit", () => { + mockSpawnSync.mockReturnValue(makeResult({ status: 1, stderr: " boom \n" })); + + expect(() => adapter().upgradeMarketplaces()).toThrow( + new NativePluginCliError("probe-tool marketplace update failed: boom") + ); + }); + + it("names the exit code when the tool said nothing, and unknown when there is no code", () => { + mockSpawnSync.mockReturnValueOnce(makeResult({ status: 2, stderr: "" })); + expect(() => adapter().enablePlugin("p@m")).toThrow( + new NativePluginCliError("probe-tool plugin install p@m failed: exited with code 2") + ); + + mockSpawnSync.mockReturnValueOnce(makeResult({ status: null, signal: "SIGKILL", stderr: "" })); + expect(() => adapter().uninstallPlugin("p@m")).toThrow( + new NativePluginCliError("probe-tool plugin uninstall p@m failed: exited with code unknown") + ); + }); + + it("names the marketplace removal that failed", () => { + mockSpawnSync.mockReturnValue(makeResult({ status: 1, stderr: "not found" })); + + expect(() => adapter().removeMarketplace("m", "project")).toThrow( + new NativePluginCliError("probe-tool marketplace remove m failed: not found") + ); + }); +}); + +describe("a batch shim on PATH", () => { + let dir: string | undefined; + const previousPath = process.env.PATH; + + afterEach(() => { + process.env.PATH = previousPath; + if (dir !== undefined) rmSync(dir, { recursive: true, force: true }); + dir = undefined; + }); + + it("runs through the command interpreter as one quoted command line", () => { + dir = mkdtempSync(join(tmpdir(), "aidd-bin-")); + writeFileSync(join(dir, "probe.cmd"), "#!/bin/sh\n", { mode: 0o755 }); + process.env.PATH = dir; + mockSpawnSync.mockReturnValue(makeResult({})); + + new NativePluginCliAdapter("probe.cmd", FULL_SHAPE).addMarketplace("/my src", "project"); + + expect(mockSpawnSync).toHaveBeenCalledWith( + windowsCommandLine(join(dir, "probe.cmd"), [ + "plugin", + "marketplace", + "add", + "/my src", + "--scope", + "local", + ]), + { ...RUN_OPTIONS, shell: true } + ); + }); + + it("is what makes the tool available, while a bare name absent from PATH is not", () => { + dir = mkdtempSync(join(tmpdir(), "aidd-bin-")); + writeFileSync(join(dir, "probe.cmd"), "#!/bin/sh\n", { mode: 0o755 }); + process.env.PATH = dir; + + expect(new NativePluginCliAdapter("probe.cmd", FULL_SHAPE).isAvailable()).toBe(true); + expect(adapter().isAvailable()).toBe(false); + }); +}); diff --git a/cli/tests/contexts/tools/infrastructure/executable-on-path.unit.test.ts b/cli/tests/contexts/tools/infrastructure/executable-on-path.unit.test.ts index f6ee37d5b..fd8ac975f 100644 --- a/cli/tests/contexts/tools/infrastructure/executable-on-path.unit.test.ts +++ b/cli/tests/contexts/tools/infrastructure/executable-on-path.unit.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { candidateExecutableNames, type ExecutableLookup, + hostExecutableLookup, resolveExecutableOnPath, runsThroughShell, windowsCommandLine, @@ -75,3 +76,65 @@ describe("finding a tool's own CLI on PATH", () => { ); }); }); + +describe("the shape of a PATH lookup", () => { + it("skips an empty PATHEXT segment rather than spelling the bare name twice", () => { + expect(candidateExecutableNames("claude", "win32", ".EXE;;.CMD;")).toEqual([ + "claude", + "claude.EXE", + "claude.exe", + "claude.CMD", + "claude.cmd", + ]); + }); + + it("asks about every spelling in every non-empty PATH directory, in order, and nowhere else", () => { + const asked: string[] = []; + const found = resolveExecutableOnPath("claude", { + platform: "linux", + pathExt: undefined, + pathEnv: "/usr/local/bin::/opt/bin:", + isExecutable: (path) => { + asked.push(path); + return false; + }, + }); + + expect(found).toBeUndefined(); + expect(asked).toStrictEqual(["/usr/local/bin/claude", "/opt/bin/claude"]); + }); + + it("asks nothing at all when PATH is unset", () => { + const asked: string[] = []; + resolveExecutableOnPath("claude", { + platform: "linux", + pathExt: undefined, + pathEnv: undefined, + isExecutable: (path) => { + asked.push(path); + return true; + }, + }); + + expect(asked).toStrictEqual([]); + }); + + it("recognises a shim by its final extension only", () => { + expect(runsThroughShell("C:\\tools\\claude.cmd.txt")).toBe(false); + }); + + it("quotes an empty argument, doubles an embedded quote, and quotes the interpreter's own characters", () => { + expect(windowsCommandLine("claude.cmd", ["", 'say "hi"', "100%", "a^b", "x!y", "(p)"])).toBe( + 'claude.cmd "" "say ""hi""" "100%" "a^b" "x!y" "(p)"' + ); + }); + + it("describes this machine, and answers false rather than nothing for a path that is not there", () => { + const lookup = hostExecutableLookup(); + + expect(lookup.platform).toBe(process.platform); + expect(lookup.pathEnv).toBe(process.env.PATH); + expect(lookup.pathExt).toBe(process.env.PATHEXT); + expect(lookup.isExecutable("/definitely/not/here/claude")).toBe(false); + }); +}); diff --git a/cli/tests/contexts/tools/infrastructure/host-marketplace-registry-reader-adapter.integration.test.ts b/cli/tests/contexts/tools/infrastructure/host-marketplace-registry-reader-adapter.integration.test.ts index 6129034b6..3a8045a45 100644 --- a/cli/tests/contexts/tools/infrastructure/host-marketplace-registry-reader-adapter.integration.test.ts +++ b/cli/tests/contexts/tools/infrastructure/host-marketplace-registry-reader-adapter.integration.test.ts @@ -117,3 +117,60 @@ describe("Claude Code's own known_marketplaces.json", () => { expect(reading.unreadable).toBeDefined(); }); }); + +describe("which hosts declare a marketplace registry to read", () => { + it("is claude alone, read off the profiles rather than a list", () => { + expect([...hostMarketplaceRegistryReaders(home).keys()]).toStrictEqual(["claude"]); + }); +}); + +describe("Claude Code's known_marketplaces.json, at the edges of its shape", () => { + it("reads a document that is not an object as unreadable, and says so", async () => { + for (const content of ["[]", "null", '"x"']) { + await write(content); + + const reading = await reader().read(); + + expect(reading.entries).toBeUndefined(); + expect(reading.unreadable).toBe("not a JSON object"); + } + }); + + it("names the parse failure of malformed JSON rather than calling it a wrong shape", async () => { + await write("{ not json"); + + const reading = await reader().read(); + + expect(reading.unreadable).toMatch(/JSON/); + expect(reading.unreadable).not.toBe("not a JSON object"); + }); + + it("skips an entry that names no install location, whatever else it carries", async () => { + const target = join(home, "srcA"); + await mkdir(target, { recursive: true }); + await write( + JSON.stringify({ + "null-entry": null, + "string-entry": "x", + "no-location": { source: { path: target } }, + "wrong-type": { installLocation: 5 }, + real: { installLocation: target }, + }) + ); + + const reading = await reader().read(); + + expect([...(reading.entries ?? [])]).toStrictEqual([["real", target]]); + }); +}); + +describe("Claude Code's known_marketplaces.json, a registration whose source is gone", () => { + it("keeps the dead path as written rather than dropping the entry or failing the read", async () => { + const gone = join(home, "deleted-src"); + await write(JSON.stringify({ dead: { installLocation: gone } })); + + const reading = await reader().read(); + + expect([...(reading.entries ?? [])]).toStrictEqual([["dead", gone]]); + }); +}); diff --git a/cli/tests/contexts/tools/infrastructure/host-plugin-registry-reader-adapter.integration.test.ts b/cli/tests/contexts/tools/infrastructure/host-plugin-registry-reader-adapter.integration.test.ts index 3bd5298b8..2d3c22910 100644 --- a/cli/tests/contexts/tools/infrastructure/host-plugin-registry-reader-adapter.integration.test.ts +++ b/cli/tests/contexts/tools/infrastructure/host-plugin-registry-reader-adapter.integration.test.ts @@ -311,3 +311,131 @@ describe("Copilot's own settings.json", () => { expect((await readerFor("copilot").read(PROJECT)).unreadable).toBe("ENOENT"); }); }); + +describe("Claude Code's registry, at the edges of its shape", () => { + const PATH = ".claude/plugins/installed_plugins.json"; + + it("reads a plugins field that is not an object as unreadable, naming what was expected", async () => { + await write(PATH, JSON.stringify({ version: 1, plugins: 5 })); + + const reading = await readerFor("claude").read(PROJECT); + + expect(reading.refs).toBeUndefined(); + expect(reading.unreadable).toBe("no `plugins` object"); + }); + + it("ignores a ref whose entries are not a list", async () => { + await write(PATH, JSON.stringify({ version: 1, plugins: { "a@m": { scope: "user" } } })); + + const reading = await readerFor("claude").read(PROJECT); + + expect(reading.refs?.size).toBe(0); + }); + + it("answers user scope when one entry is user-scoped, whatever the others say", async () => { + await write( + PATH, + JSON.stringify({ + version: 1, + plugins: { + "a@m": [{ scope: "project", projectPath: "/repo/other" }, { scope: "user" }], + }, + }) + ); + + const reading = await readerFor("claude").read(PROJECT); + + expect(reading.refs?.get("a@m")).toStrictEqual({ enabled: true, scope: "user" }); + }); +}); + +describe("Codex's config.toml, at the edges of its shape", () => { + const PATH = ".codex/config.toml"; + + it("reads a plugin table whose header is indented", async () => { + await write(PATH, ' [plugins."a@m"]\n enabled = false\n'); + + const reading = await readerFor("codex").read(PROJECT); + + expect(reading.refs?.get("a@m")).toStrictEqual({ enabled: false }); + }); + + it("reads no table off a line that only contains a header, or carries text after one", async () => { + await write(PATH, 'x[plugins."a@m"]\n[plugins."b@m"] junk\n'); + + const reading = await readerFor("codex").read(PROJECT); + + expect(reading.refs?.size).toBe(0); + }); + + it("reads an enabled line however the spaces around its equals sign fall", async () => { + await write( + PATH, + '[plugins."a@m"]\nenabled=false\n[plugins."b@m"]\nenabled = false\n[plugins."c@m"]\nenabled =false # off\n' + ); + + const reading = await readerFor("codex").read(PROJECT); + + expect([...(reading.refs ?? [])]).toStrictEqual([ + ["a@m", { enabled: false }], + ["b@m", { enabled: false }], + ["c@m", { enabled: false }], + ]); + }); + + it("reads a value that is not a bare boolean as no answer, so the default stands", async () => { + await write(PATH, '[plugins."a@m"]\nenabled = falsey\n'); + + const reading = await readerFor("codex").read(PROJECT); + + expect(reading.refs?.get("a@m")).toStrictEqual({ enabled: true }); + }); + + it("does not mistake a bracket inside a value for the next table", async () => { + await write(PATH, '[plugins."a@m"]\nargs = ["x"]\nenabled = false\n'); + + const reading = await readerFor("codex").read(PROJECT); + + expect(reading.refs?.get("a@m")).toStrictEqual({ enabled: false }); + }); + + it("reads past a multi-line string inside the table body, and a header spelled inside it", async () => { + await write( + PATH, + '[plugins."a@m"]\nnote = """\n[plugins."fake@m"]\nenabled = true\n"""\nenabled = false\n' + ); + + const reading = await readerFor("codex").read(PROJECT); + + expect([...(reading.refs ?? [])]).toStrictEqual([["a@m", { enabled: false }]]); + }); + + it("treats the last table in the file as enabled when it declares nothing", async () => { + await write(PATH, '[projects."/x"]\ntrust = "trusted"\n[plugins."a@m"]\n'); + + const reading = await readerFor("codex").read(PROJECT); + + expect(reading.refs?.get("a@m")).toStrictEqual({ enabled: true }); + }); +}); + +describe("Copilot's settings.json, at the edges of its shape", () => { + it("reads malformed JSON as unreadable, never as carrying no plugins", async () => { + await write(".copilot/settings.json", "// comment\n{ not json"); + + const reading = await readerFor("copilot").read(PROJECT); + + expect(reading.refs).toBeUndefined(); + expect(reading.unreadable).toBeDefined(); + }); +}); + +describe("Codex's config.toml, keys that merely end in enabled", () => { + it("does not read another key ending in enabled as the plugin's own flag", async () => { + await write(".codex/config.toml", '[plugins."a@m"]\nauto_enabled = false\n'); + + const reading = await readerFor("codex").read(PROJECT); + + expect(reading.refs?.get("a@m")).toStrictEqual({ enabled: true }); + }); +}); From 5a7abd2253c4c9e80ea6eef7e9577090c0181b5a Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Wed, 9 Sep 2026 19:40:22 +0200 Subject: [PATCH 3/4] test(cli): raise the tools mutation floor to 94 Measured 96.8 under run-mutation.mjs tools --force after the two test lots; the floor sits two under the measurement, as the other scopes' floors do. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011x4ms5qcGuZgYhCxfdHMUb AIDD-Session-Id: 4acc9a1c-19bc-4468-b8b6-e86644bcba60 --- cli/mutation-scopes.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/mutation-scopes.json b/cli/mutation-scopes.json index dfa9e4f57..31037cf77 100644 --- a/cli/mutation-scopes.json +++ b/cli/mutation-scopes.json @@ -14,7 +14,7 @@ "!src/contexts/tools/domain/profiles/cursor/**/*.ts", "!src/contexts/tools/domain/profiles/opencode/**/*.ts" ], - "break": 76 + "break": 94 }, "telemetry": { "mutate": "src/contexts/telemetry/**/*.ts", From 1328533aca01feeb2f15e219d1e7471766703f21 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Wed, 9 Sep 2026 19:55:21 +0200 Subject: [PATCH 4/4] test(cli): build the expected signal paths with the platform separator hasToolSignals joins its answers with node's own path.join, so on Windows the registry test compared a backslash path against a slash literal and failed there alone. The expected list is now built the same way the source builds it; the intention of the test is unchanged. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011x4ms5qcGuZgYhCxfdHMUb AIDD-Session-Id: 4acc9a1c-19bc-4468-b8b6-e86644bcba60 --- cli/tests/contexts/tools/domain/registry.unit.test.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/cli/tests/contexts/tools/domain/registry.unit.test.ts b/cli/tests/contexts/tools/domain/registry.unit.test.ts index 16c44bd1a..31c9db3d4 100644 --- a/cli/tests/contexts/tools/domain/registry.unit.test.ts +++ b/cli/tests/contexts/tools/domain/registry.unit.test.ts @@ -1,3 +1,4 @@ +import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { type NativePluginsParams, @@ -72,11 +73,9 @@ describe("a tool's own files as a signal it is in use", () => { "/p/.cursor/commands/notes.txt": "name: aidd:plan", }); - expect((await hasToolSignals(fs, getToolConfig("cursor"), "/p")).sort()).toStrictEqual([ - ".cursor/commands/colon.md", - ".cursor/commands/quoted.md", - ".cursor/commands/tight.md", - ]); + expect((await hasToolSignals(fs, getToolConfig("cursor"), "/p")).sort()).toStrictEqual( + ["colon.md", "quoted.md", "tight.md"].map((file) => join(".cursor/commands", file)) + ); }); });