diff --git a/src/adapters.ts b/src/adapters.ts index 52c3a7c..17d1acf 100644 --- a/src/adapters.ts +++ b/src/adapters.ts @@ -4,6 +4,7 @@ import { validateFromDefinition, withRootFiles, } from "./targets/engine.js"; +import { normalizeManagedPath, readManagedManifest } from "./managed.js"; import { targets as registry } from "./targets/registry.js"; import type { Artifact, @@ -47,7 +48,11 @@ export async function validateOutput( ): Promise { const root = path.resolve(dir); const issues: ValidationIssue[] = []; - await validateFromDefinition(root, issues, registry[target]); + const managed = await readManagedManifest(root, target); + const managedPaths = (managed?.files ?? []).map((file) => + path.resolve(root, normalizeManagedPath(file)), + ); + await validateFromDefinition(root, issues, registry[target], managedPaths); return { ok: issues.every((issue) => issue.level !== "error"), issues, diff --git a/src/targets/antigravity.ts b/src/targets/antigravity.ts index d64e379..d8399e6 100644 --- a/src/targets/antigravity.ts +++ b/src/targets/antigravity.ts @@ -95,7 +95,7 @@ export const antigravity: PluginTargetDefinition = { issues, "Antigravity output must contain at least one plugin directory.", ); - return; + return pluginDirs; } for (const pluginDir of pluginDirs) { const manifest = await readJson( @@ -121,6 +121,7 @@ export const antigravity: PluginTargetDefinition = { await validateFrontmatter(pluginDir, pluginName, "antigravity", issues); await validateHooksShape(pluginDir, pluginName, "hooks.json", issues); } + return pluginDirs; }, installSnippet: { diff --git a/src/targets/claude.ts b/src/targets/claude.ts index 39dd207..4dde86b 100644 --- a/src/targets/claude.ts +++ b/src/targets/claude.ts @@ -104,13 +104,14 @@ export const claude: PluginTargetDefinition = { ".claude-plugin", "marketplace.json", ); + const ownedPaths = [marketplacePath]; const marketplace = await readJson( marketplacePath, "Marketplace manifest", issues, ); if (!marketplace) { - return; + return ownedPaths; } validateMarketplaceBasics(marketplace, issues); const plugins = Array.isArray(marketplace.plugins) @@ -118,7 +119,7 @@ export const claude: PluginTargetDefinition = { : []; if (plugins.length === 0) { error(issues, 'Marketplace "plugins" must be a non-empty array.'); - return; + return ownedPaths; } for (const [index, entry] of plugins.entries()) { const pluginName = claude.validateMarketplaceEntry( @@ -131,6 +132,7 @@ export const claude: PluginTargetDefinition = { continue; } const pluginDir = path.join(root, entry.source); + ownedPaths.push(pluginDir); const manifest = await readJson( path.join(pluginDir, ".claude-plugin", "plugin.json"), `${pluginName} plugin manifest`, @@ -154,6 +156,7 @@ export const claude: PluginTargetDefinition = { issues, ); } + return ownedPaths; }, installSnippet: { diff --git a/src/targets/codex.ts b/src/targets/codex.ts index 2ad055a..db46b72 100644 --- a/src/targets/codex.ts +++ b/src/targets/codex.ts @@ -203,13 +203,14 @@ export const codex: PluginTargetDefinition = { "plugins", "marketplace.json", ); + const ownedPaths = [marketplacePath]; const marketplace = await readJson( marketplacePath, "Marketplace manifest", issues, ); if (!marketplace) { - return; + return ownedPaths; } validateMarketplaceBasics(marketplace, issues); const plugins = Array.isArray(marketplace.plugins) @@ -217,7 +218,7 @@ export const codex: PluginTargetDefinition = { : []; if (plugins.length === 0) { error(issues, 'Marketplace "plugins" must be a non-empty array.'); - return; + return ownedPaths; } for (const [index, entry] of plugins.entries()) { const pluginName = codex.validateMarketplaceEntry( @@ -233,6 +234,7 @@ export const codex: PluginTargetDefinition = { if (!pluginDir) { continue; } + ownedPaths.push(pluginDir); const manifest = await readJson( path.join(pluginDir, ".codex-plugin", "plugin.json"), `${pluginName} plugin manifest`, @@ -263,6 +265,7 @@ export const codex: PluginTargetDefinition = { ); await validateFrontmatter(pluginDir, pluginName, "codex", issues); } + return ownedPaths; }, installSnippet: { diff --git a/src/targets/copilot.ts b/src/targets/copilot.ts index b91bf1a..4ef9804 100644 --- a/src/targets/copilot.ts +++ b/src/targets/copilot.ts @@ -143,18 +143,23 @@ export const copilot: PluginTargetDefinition = { ".claude-plugin", "marketplace.json", ); + const mirroredMarketplacePath = path.join( + root, + ".github", + "plugin", + "marketplace.json", + ); + const ownedPaths = [marketplacePath, mirroredMarketplacePath]; const marketplace = await readJson( marketplacePath, "Marketplace manifest", issues, ); if (!marketplace) { - return; + return ownedPaths; } validateMarketplaceBasics(marketplace, issues); - if ( - !(await exists(path.join(root, ".github", "plugin", "marketplace.json"))) - ) { + if (!(await exists(mirroredMarketplacePath))) { error( issues, "Copilot output must mirror the marketplace at .github/plugin/marketplace.json.", @@ -165,7 +170,7 @@ export const copilot: PluginTargetDefinition = { : []; if (plugins.length === 0) { error(issues, 'Marketplace "plugins" must be a non-empty array.'); - return; + return ownedPaths; } for (const [index, entry] of plugins.entries()) { const pluginName = copilot.validateMarketplaceEntry( @@ -178,6 +183,7 @@ export const copilot: PluginTargetDefinition = { continue; } const pluginDir = path.join(root, entry.source); + ownedPaths.push(pluginDir); // .github/plugin/ is the authoritative copy; the root copy is only // checked for presence, not re-parsed. const manifest = await readJson( @@ -204,6 +210,7 @@ export const copilot: PluginTargetDefinition = { await validateAgentFileNames(pluginDir, pluginName, issues); await validateFrontmatter(pluginDir, pluginName, "copilot", issues); } + return ownedPaths; }, installSnippet: { diff --git a/src/targets/cursor.ts b/src/targets/cursor.ts index 2364058..bf860da 100644 --- a/src/targets/cursor.ts +++ b/src/targets/cursor.ts @@ -170,13 +170,14 @@ export const cursor: PluginTargetDefinition = { ".cursor-plugin", "marketplace.json", ); + const ownedPaths = [marketplacePath]; const marketplace = await readJson( marketplacePath, "Marketplace manifest", issues, ); if (!marketplace) { - return; + return ownedPaths; } validateMarketplaceBasics(marketplace, issues); const plugins = Array.isArray(marketplace.plugins) @@ -184,7 +185,7 @@ export const cursor: PluginTargetDefinition = { : []; if (plugins.length === 0) { error(issues, 'Marketplace "plugins" must be a non-empty array.'); - return; + return ownedPaths; } for (const [index, entry] of plugins.entries()) { const pluginName = cursor.validateMarketplaceEntry( @@ -197,6 +198,7 @@ export const cursor: PluginTargetDefinition = { continue; } const pluginDir = path.join(root, entry.source); + ownedPaths.push(pluginDir); const manifest = await readJson( path.join(pluginDir, ".cursor-plugin", "plugin.json"), `${pluginName} plugin manifest`, @@ -227,6 +229,7 @@ export const cursor: PluginTargetDefinition = { ); await validateFrontmatter(pluginDir, pluginName, "cursor", issues); } + return ownedPaths; }, installSnippet: { diff --git a/src/targets/engine.ts b/src/targets/engine.ts index 729b8a1..68e0271 100644 --- a/src/targets/engine.ts +++ b/src/targets/engine.ts @@ -250,9 +250,14 @@ export async function validateFromDefinition( root: string, issues: ValidationIssue[], definition: PluginTargetDefinition, + managedPaths: string[] = [], ): Promise { - await definition.validateOutput(root, issues); - await validateNoSurvivingPartialTags(root, issues); + const ownedPaths = await definition.validateOutput(root, issues); + await validateNoSurvivingPartialTags( + root, + [...ownedPaths, ...managedPaths], + issues, + ); } function emittedPluginMetadata( diff --git a/src/targets/types.ts b/src/targets/types.ts index 2a08152..0593c53 100644 --- a/src/targets/types.ts +++ b/src/targets/types.ts @@ -130,7 +130,15 @@ export type PluginTargetDefinition = { root: string, issues: ValidationIssue[], ) => string | null; - validateOutput: (root: string, issues: ValidationIssue[]) => Promise; + /** + * Validates native output and returns the files/directories owned by the + * target. Shared validation uses these paths instead of scanning unrelated + * authored and dependency files that may share the repository root. + */ + validateOutput: ( + root: string, + issues: ValidationIssue[], + ) => Promise; installSnippet: InstallSnippetDefinition; diff --git a/src/targets/validation-shared.ts b/src/targets/validation-shared.ts index abb6518..ab0b928 100644 --- a/src/targets/validation-shared.ts +++ b/src/targets/validation-shared.ts @@ -83,9 +83,26 @@ export function warning(issues: ValidationIssue[], message: string): void { */ export async function validateNoSurvivingPartialTags( root: string, + ownedPaths: string[], issues: ValidationIssue[], ): Promise { - for (const file of await walkFiles(root)) { + const files = ( + await Promise.all( + ownedPaths.map(async (ownedPath) => { + try { + return (await fs.stat(ownedPath)).isDirectory() + ? await walkFiles(ownedPath) + : [ownedPath]; + } catch (error) { + if (isNotFoundError(error)) { + return []; + } + throw error; + } + }), + ) + ).flat(); + for (const file of [...new Set(files)]) { const relative = toPosix(path.relative(root, file)); const tag = findPartialTag(await fs.readFile(file)); if (!tag) { diff --git a/tests/core.test.ts b/tests/core.test.ts index 76ada4e..c137066 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -1841,6 +1841,10 @@ export default defineConfig({ await access( path.join(root, "plugins/claude/acme/.claude-plugin/plugin.json"), ); + await access(path.join(root, ".agents/plugins/marketplace.json")); + await access( + path.join(root, "plugins/codex/acme/.codex-plugin/plugin.json"), + ); await access( path.join(root, "plugins/copilot/.claude-plugin/marketplace.json"), ); @@ -1851,6 +1855,26 @@ export default defineConfig({ await expect(validateOutput("claude", root)).resolves.toMatchObject({ ok: true, }); + await expect(validateOutput("codex", root)).resolves.toMatchObject({ + ok: true, + }); + }); + + it("validates surviving partials in managed repository-root files", async () => { + const project = await recommendedShapeFixture(); + const root = project.baseDir; + await build({ cwd: root }); + await writeFile(path.join(root, "config.yaml"), "auth: {{> auth}}\n"); + + const result = await validateOutput("cursor", root); + + expect(result.ok).toBe(false); + expect(result.issues).toContainEqual({ + level: "error", + message: expect.stringContaining( + "config.yaml contains an unsubstituted partial reference {{> auth}}", + ), + }); }); it("does not register generated output dirs as source plugins on rebuild", async () => { @@ -1862,7 +1886,7 @@ export default defineConfig({ // Loading config again must not treat them as source plugins. const loaded = await loadConfig(root); - expect([...loaded.plugins.keys()]).toEqual(["core"]); + expect([...loaded.plugins.keys()]).toEqual([]); }); it("merges multiple source plugins and rejects colliding files", async () => { @@ -3851,35 +3875,64 @@ async function recommendedShapeFixture(): Promise { export default defineConfig({ name: "acme-plugins", version: "1.0.0", - source: { - skills: "skills", - rootPlugin: { id: "core", description: "Acme skills." } - }, + source: { partials: "partials" }, metadata: { description: "Acme", author: { name: "Acme" }, license: "MIT" }, targets: { cursor: { outDir: ".", + repositoryFiles: "repositories/cursor", plugins: { - acme: { from: ["core"], path: "plugins/cursor/acme", components: ["skills"] } + acme: { source: "shared/acme", path: "plugins/cursor/acme" } } }, claude: { outDir: ".", pluginRoot: "plugins/claude", - plugins: { acme: { from: ["core"] } } + plugins: { acme: { source: "shared/acme" } } + }, + codex: { + outDir: ".", + plugins: { + acme: { + source: "shared/acme", + path: "plugins/codex/acme", + entry: { + policy: { installation: "AVAILABLE", authentication: "NOT_REQUIRED" }, + category: "Developer Tools" + } + } + } }, copilot: { outDir: "plugins/copilot", - plugins: { acme: { from: ["core"] } } + plugins: { acme: { source: "shared/acme" } } } } }); `, - skills: { - "release-notes": { - "SKILL.md": skill("release-notes", "Release notes skill."), + partials: { + "auth.md": "Authenticate first.", + }, + shared: { + acme: { + skills: { + "release-notes": { + "SKILL.md": `${skill("release-notes", "Release notes skill.")}\n{{> auth}}\n`, + }, + }, }, }, + // These are authored/dependency files in the repository, not generated + // target output. Validation must not interpret their template syntax. + ".github": { + workflows: { "ci.yml": "# Example partial: {{> partial}}\n" }, + }, + node_modules: { + dependency: { "index.js": 'const example = "{{> dependency}}";\n' }, + }, + repositories: { + cursor: { "config.yaml": "auth: configured\n" }, + }, }); }