Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/adapters.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -47,7 +48,11 @@ export async function validateOutput(
): Promise<ValidationResult> {
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,
Expand Down
3 changes: 2 additions & 1 deletion src/targets/antigravity.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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(
Expand All@@ -121,6 +121,7 @@ export const antigravity: PluginTargetDefinition = {
await validateFrontmatter(pluginDir, pluginName, "antigravity", issues);
await validateHooksShape(pluginDir, pluginName, "hooks.json", issues);
}
return pluginDirs;
},

installSnippet: {
Expand Down
7 changes: 5 additions & 2 deletions src/targets/claude.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -104,21 +104,22 @@ 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)
? marketplace.plugins
: [];
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(
Expand All@@ -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`,
Expand All@@ -154,6 +156,7 @@ export const claude: PluginTargetDefinition = {
issues,
);
}
return ownedPaths;
},

installSnippet: {
Expand Down
7 changes: 5 additions & 2 deletions src/targets/codex.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -203,21 +203,22 @@ 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)
? marketplace.plugins
: [];
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(
Expand All@@ -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`,
Expand DownExpand Up@@ -263,6 +265,7 @@ export const codex: PluginTargetDefinition = {
);
await validateFrontmatter(pluginDir, pluginName, "codex", issues);
}
return ownedPaths;
},

installSnippet: {
Expand Down
17 changes: 12 additions & 5 deletions src/targets/copilot.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.",
Expand All@@ -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(
Expand All@@ -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(
Expand All@@ -204,6 +210,7 @@ export const copilot: PluginTargetDefinition = {
await validateAgentFileNames(pluginDir, pluginName, issues);
await validateFrontmatter(pluginDir, pluginName, "copilot", issues);
}
return ownedPaths;
},

installSnippet: {
Expand Down
7 changes: 5 additions & 2 deletions src/targets/cursor.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -170,21 +170,22 @@ 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)
? marketplace.plugins
: [];
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(
Expand All@@ -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`,
Expand DownExpand Up@@ -227,6 +229,7 @@ export const cursor: PluginTargetDefinition = {
);
await validateFrontmatter(pluginDir, pluginName, "cursor", issues);
}
return ownedPaths;
},

installSnippet: {
Expand Down
9 changes: 7 additions & 2 deletions src/targets/engine.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -250,9 +250,14 @@ export async function validateFromDefinition(
root: string,
issues: ValidationIssue[],
definition: PluginTargetDefinition,
managedPaths: string[] = [],
): Promise<void> {
await definition.validateOutput(root, issues);
await validateNoSurvivingPartialTags(root, issues);
const ownedPaths = await definition.validateOutput(root, issues);
await validateNoSurvivingPartialTags(
root,
[...ownedPaths, ...managedPaths],
issues,
);
}

function emittedPluginMetadata(
Expand Down
10 changes: 9 additions & 1 deletion src/targets/types.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -130,7 +130,15 @@ export type PluginTargetDefinition = {
root: string,
issues: ValidationIssue[],
) => string | null;
validateOutput: (root: string, issues: ValidationIssue[]) => Promise<void>;
/**
* 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<string[]>;

installSnippet: InstallSnippetDefinition;

Expand Down
19 changes: 18 additions & 1 deletion src/targets/validation-shared.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -83,9 +83,26 @@ export function warning(issues: ValidationIssue[], message: string): void {
*/
export async function validateNoSurvivingPartialTags(
root: string,
ownedPaths: string[],
issues: ValidationIssue[],
): Promise<void> {
for (const file of await walkFiles(root)) {
const files = (
await Promise.all(
ownedPaths.map(async (ownedPath) => {
Comment thread
steve-calvert-glean marked this conversation as resolved.
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) {
Expand Down
75 changes: 64 additions & 11 deletions tests/core.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"),
);
Expand All@@ -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 () => {
Expand All@@ -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 () => {
Expand DownExpand Up@@ -3851,35 +3875,64 @@ async function recommendedShapeFixture(): Promise<Project> {
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" },
},
});
}

Expand Down