diff --git a/package.json b/package.json index 27fe39d..cebae38 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bc-github-actions", - "version": "1.0.0", + "version": "1.0.1", "description": "Reusable GitHub Actions workflows for Claude Code plugin marketplaces", "private": false, "scripts": { diff --git a/scripts/dist/discover-components.cjs b/scripts/dist/discover-components.cjs index 148ca56..d042bcc 100755 --- a/scripts/dist/discover-components.cjs +++ b/scripts/dist/discover-components.cjs @@ -7278,6 +7278,7 @@ function loadConfig() { discovery: { excludeDirs: [".git", "node_modules", ".github", ".claude", "templates", "test-components"], excludePatterns: ["**/template/**", "**/*template*/**"], + pluginCategories: [], maxDepth: 10, skillFilename: "SKILL.md", commandsDir: "commands", @@ -7828,42 +7829,90 @@ function discoverAllComponents(rootDir, config) { errors: allErrors }; } -function discoverPlugins(rootDir, config) { - const categories = ["code", "analysis", "communication", "documents"]; - const plugins = []; - const absoluteRoot = path.resolve(rootDir); - function isPluginDirectory(dir) { - return ["agents", "commands", "skills"].some( - (sub) => fs.existsSync(path.join(dir, sub)) - ); +function getCategoryNames(config) { + const defaultCategories = ["code", "analysis", "communication", "documents"]; + const globs = config.discovery.pluginCategories; + if (!globs || globs.length === 0) { + return defaultCategories; } - for (const category of categories) { - const categoryPath = path.join(absoluteRoot, category); - if (!fs.existsSync(categoryPath)) + return globs.map((glob) => glob.split("/")[0]).filter(Boolean); +} +function groupIntoPlugins(components, rootDir, config) { + const absoluteRoot = path.resolve(rootDir); + const validCategories = getCategoryNames(config); + const pluginMap = /* @__PURE__ */ new Map(); + const orphanedPaths = []; + const allPaths = [ + ...components.skills.map((p) => ({ absPath: p, type: "skill" })), + ...components.commands.map((p) => ({ absPath: p, type: "command" })), + ...components.agents.map((p) => ({ absPath: p, type: "agent" })) + ]; + for (const { absPath, type } of allPaths) { + const relPath = path.relative(absoluteRoot, absPath); + const parts = relPath.split(path.sep); + if (parts.length < 2) { + orphanedPaths.push(relPath); continue; - let entries; - try { - entries = fs.readdirSync(categoryPath, { withFileTypes: true }); - } catch (err) { - console.warn(`Cannot read category directory ${categoryPath}: ${err.message}`); + } + const category = parts[0]; + const pluginName = parts[1]; + if (!validCategories.includes(category)) { + orphanedPaths.push(relPath); continue; } - for (const entry of entries) { - if (!entry.isDirectory()) - continue; - const pluginPath = path.join(categoryPath, entry.name); - if (isPluginDirectory(pluginPath)) { - const pluginComponents = discoverAllComponents(pluginPath, config); - plugins.push({ - name: entry.name, - category, - path: pluginPath, - source: `./${category}/${entry.name}`, - components: pluginComponents - }); - } + const key = `${category}/${pluginName}`; + if (!pluginMap.has(key)) { + pluginMap.set(key, { + name: pluginName, + category, + path: path.join(absoluteRoot, category, pluginName), + source: `./${category}/${pluginName}`, + components: { + skills: [], + commands: [], + agents: [], + hooks: null, + mcpServers: null, + hooksFiles: [], + mcpFiles: [], + errors: [] + } + }); + } + const plugin = pluginMap.get(key); + if (type === "skill") { + plugin.components.skills.push(absPath); + } else if (type === "command") { + plugin.components.commands.push(absPath); + } else if (type === "agent") { + plugin.components.agents.push(absPath); + } + } + for (const plugin of pluginMap.values()) { + const associatedHooks = (components.hooksFiles || []).filter( + (file) => file.path.startsWith(plugin.path + path.sep) + ); + if (associatedHooks.length > 0) { + plugin.components.hooks = mergeHooks(associatedHooks); + plugin.components.hooksFiles = associatedHooks; + } + const associatedMcp = (components.mcpFiles || []).filter( + (file) => file.path.startsWith(plugin.path + path.sep) + ); + if (associatedMcp.length > 0) { + const mcpResult = mergeMcpServers(associatedMcp); + plugin.components.mcpServers = mcpResult.servers; + plugin.components.mcpFiles = associatedMcp; } } + return { + plugins: Array.from(pluginMap.values()), + orphanedPaths + }; +} +function discoverPlugins(rootDir, config) { + const components = discoverAllComponents(rootDir, config); + const { plugins } = groupIntoPlugins(components, rootDir, config); return plugins; } function generatePluginJson(plugin, config) { @@ -7961,6 +8010,8 @@ module.exports = { validateAgent, findDuplicateNames, discoverAllComponents, + getCategoryNames, + groupIntoPlugins, discoverPlugins, generatePluginJson, generateMarketplace, @@ -8078,7 +8129,13 @@ Found ${components.agents.length} agent(s) to validate } } else if (command === "generate") { const config = loadConfig(); - const plugins = discoverPlugins(".", config); + const components = discoverAllComponents(".", config); + const { plugins, orphanedPaths } = groupIntoPlugins(components, ".", config); + if (orphanedPaths.length > 0) { + console.warn("\n[WARN] Components not mapped to any plugin (not in a recognized category/plugin-name path):"); + orphanedPaths.forEach((p) => console.warn(` - ${p}`)); + console.warn(""); + } writePluginJsonFiles(plugins, config); const marketplace = generateMarketplace(plugins, config); const marketplacePath = path.join(".claude-plugin", "marketplace.json"); diff --git a/scripts/src/discover-components.js b/scripts/src/discover-components.js index a92a740..6cb8012 100644 --- a/scripts/src/discover-components.js +++ b/scripts/src/discover-components.js @@ -93,6 +93,7 @@ function loadConfig() { discovery: { excludeDirs: ['.git', 'node_modules', '.github', '.claude', 'templates', 'test-components'], excludePatterns: ['**/template/**', '**/*template*/**'], + pluginCategories: [], maxDepth: 10, skillFilename: 'SKILL.md', commandsDir: 'commands', @@ -1065,54 +1066,132 @@ function findAssociatedJson(componentPath, jsonFiles) { } /** - * Discovers plugins in two-level hierarchy: category/plugin-name/ - * A directory is considered a plugin if it contains agents/, commands/, or skills/ subdirectories. - * @param {string} rootDir - Root directory to start discovery + * Extracts valid category directory names from pluginCategories config globs. + * e.g. ["code/**", "analysis/**"] → ["code", "analysis"] + * Falls back to hardcoded defaults when not set. * @param {Object} config - Configuration object - * @returns {Array<{name: string, category: string, path: string, source: string, components: Object}>} Array of plugin metadata + * @returns {string[]} Array of category directory names */ -function discoverPlugins(rootDir, config) { - const categories = ['code', 'analysis', 'communication', 'documents']; - const plugins = []; - const absoluteRoot = path.resolve(rootDir); +function getCategoryNames(config) { + const defaultCategories = ['code', 'analysis', 'communication', 'documents']; + const globs = config.discovery.pluginCategories; - function isPluginDirectory(dir) { - return ['agents', 'commands', 'skills'].some(sub => - fs.existsSync(path.join(dir, sub)) - ); + if (!globs || globs.length === 0) { + return defaultCategories; } - for (const category of categories) { - const categoryPath = path.join(absoluteRoot, category); - if (!fs.existsSync(categoryPath)) continue; + return globs.map(glob => glob.split('/')[0]).filter(Boolean); +} - let entries; - try { - entries = fs.readdirSync(categoryPath, { withFileTypes: true }); - } catch (err) { - console.warn(`Cannot read category directory ${categoryPath}: ${err.message}`); +/** + * Groups discovered components into plugins by deriving plugin identity from paths. + * Pure data transformation — no filesystem access. + * + * For each component path, extracts category/plugin-name from its path relative to rootDir. + * Components whose paths don't match category/plugin-name/... are returned as orphans. + * + * @param {Object} components - Output of discoverAllComponents() + * @param {string} rootDir - Root directory (for computing relative paths) + * @param {Object} config - Configuration object + * @returns {{ plugins: Array, orphanedPaths: string[] }} + */ +function groupIntoPlugins(components, rootDir, config) { + const absoluteRoot = path.resolve(rootDir); + const validCategories = getCategoryNames(config); + const pluginMap = new Map(); // key: "category/plugin-name" + const orphanedPaths = []; + + // All component paths to process: skills (dirs), commands (files), agents (files) + const allPaths = [ + ...components.skills.map(p => ({ absPath: p, type: 'skill' })), + ...components.commands.map(p => ({ absPath: p, type: 'command' })), + ...components.agents.map(p => ({ absPath: p, type: 'agent' })) + ]; + + for (const { absPath, type } of allPaths) { + const relPath = path.relative(absoluteRoot, absPath); + const parts = relPath.split(path.sep); + + // Need at least category/plugin-name + if (parts.length < 2) { + orphanedPaths.push(relPath); continue; } - for (const entry of entries) { - if (!entry.isDirectory()) continue; - - const pluginPath = path.join(categoryPath, entry.name); - if (isPluginDirectory(pluginPath)) { - // Discover components within this plugin - const pluginComponents = discoverAllComponents(pluginPath, config); - - plugins.push({ - name: entry.name, - category, - path: pluginPath, - source: `./${category}/${entry.name}`, - components: pluginComponents - }); - } + const category = parts[0]; + const pluginName = parts[1]; + + if (!validCategories.includes(category)) { + orphanedPaths.push(relPath); + continue; + } + + const key = `${category}/${pluginName}`; + if (!pluginMap.has(key)) { + pluginMap.set(key, { + name: pluginName, + category, + path: path.join(absoluteRoot, category, pluginName), + source: `./${category}/${pluginName}`, + components: { + skills: [], + commands: [], + agents: [], + hooks: null, + mcpServers: null, + hooksFiles: [], + mcpFiles: [], + errors: [] + } + }); + } + + const plugin = pluginMap.get(key); + if (type === 'skill') { + plugin.components.skills.push(absPath); + } else if (type === 'command') { + plugin.components.commands.push(absPath); + } else if (type === 'agent') { + plugin.components.agents.push(absPath); } } + // Associate hooks and MCP files with plugins by directory proximity + for (const plugin of pluginMap.values()) { + const associatedHooks = (components.hooksFiles || []).filter( + file => file.path.startsWith(plugin.path + path.sep) + ); + if (associatedHooks.length > 0) { + plugin.components.hooks = mergeHooks(associatedHooks); + plugin.components.hooksFiles = associatedHooks; + } + + const associatedMcp = (components.mcpFiles || []).filter( + file => file.path.startsWith(plugin.path + path.sep) + ); + if (associatedMcp.length > 0) { + const mcpResult = mergeMcpServers(associatedMcp); + plugin.components.mcpServers = mcpResult.servers; + plugin.components.mcpFiles = associatedMcp; + } + } + + return { + plugins: Array.from(pluginMap.values()), + orphanedPaths + }; +} + +/** + * Discovers plugins in two-level hierarchy: category/plugin-name/ + * Uses discoverAllComponents() for a single traversal, then groups by path. + * @param {string} rootDir - Root directory to start discovery + * @param {Object} config - Configuration object + * @returns {Array<{name: string, category: string, path: string, source: string, components: Object}>} Array of plugin metadata + */ +function discoverPlugins(rootDir, config) { + const components = discoverAllComponents(rootDir, config); + const { plugins } = groupIntoPlugins(components, rootDir, config); return plugins; } @@ -1247,6 +1326,8 @@ module.exports = { validateAgent, findDuplicateNames, discoverAllComponents, + getCategoryNames, + groupIntoPlugins, discoverPlugins, generatePluginJson, generateMarketplace, @@ -1380,7 +1461,14 @@ if (require.main === module) { } } else if (command === 'generate') { const config = loadConfig(); - const plugins = discoverPlugins('.', config); + const components = discoverAllComponents('.', config); + const { plugins, orphanedPaths } = groupIntoPlugins(components, '.', config); + + if (orphanedPaths.length > 0) { + console.warn('\n[WARN] Components not mapped to any plugin (not in a recognized category/plugin-name path):'); + orphanedPaths.forEach(p => console.warn(` - ${p}`)); + console.warn(''); + } // Write individual plugin.json files writePluginJsonFiles(plugins, config); diff --git a/scripts/test/discover-components.test.js b/scripts/test/discover-components.test.js new file mode 100644 index 0000000..8334b25 --- /dev/null +++ b/scripts/test/discover-components.test.js @@ -0,0 +1,232 @@ +#!/usr/bin/env node +/** + * Tests for discover-components.js + * Run: node scripts/test/discover-components.test.js + */ + +const path = require('path'); +const assert = require('assert'); + +const { + loadConfig, + discoverAllComponents, + getCategoryNames, + groupIntoPlugins, + discoverPlugins, + validateSkill +} = require('../src/discover-components.js'); + +const FIXTURES_DIR = path.resolve(__dirname, '../../test-fixtures/valid'); + +let passed = 0; +let failed = 0; + +function test(name, fn) { + try { + fn(); + console.log(` [PASS] ${name}`); + passed++; + } catch (err) { + console.error(` [FAIL] ${name}`); + console.error(` ${err.message}`); + failed++; + } +} + +// Load config from fixtures directory (CWD-relative) +function loadFixtureConfig() { + const originalCwd = process.cwd(); + try { + process.chdir(FIXTURES_DIR); + return loadConfig(); + } finally { + process.chdir(originalCwd); + } +} + +// --- getCategoryNames --- + +console.log('\ngetCategoryNames'); + +test('extracts category names from globs', () => { + const config = { discovery: { pluginCategories: ['code/**', 'analysis/**'] } }; + const names = getCategoryNames(config); + assert.deepStrictEqual(names, ['code', 'analysis']); +}); + +test('returns defaults when pluginCategories is empty', () => { + const config = { discovery: { pluginCategories: [] } }; + const names = getCategoryNames(config); + assert.deepStrictEqual(names, ['code', 'analysis', 'communication', 'documents']); +}); + +test('returns defaults when pluginCategories is not set', () => { + const config = { discovery: {} }; + const names = getCategoryNames(config); + assert.deepStrictEqual(names, ['code', 'analysis', 'communication', 'documents']); +}); + +// --- groupIntoPlugins --- + +console.log('\ngroupIntoPlugins'); + +test('groups a standalone skill into the correct plugin', () => { + const rootDir = '/fake/root'; + const config = { discovery: { pluginCategories: ['code/**', 'analysis/**'] } }; + const components = { + skills: ['/fake/root/code/standalone-skill'], + commands: [], + agents: [], + hooksFiles: [], + mcpFiles: [] + }; + + const { plugins, orphanedPaths } = groupIntoPlugins(components, rootDir, config); + + assert.strictEqual(plugins.length, 1); + assert.strictEqual(plugins[0].name, 'standalone-skill'); + assert.strictEqual(plugins[0].category, 'code'); + assert.strictEqual(plugins[0].source, './code/standalone-skill'); + assert.deepStrictEqual(plugins[0].components.skills, ['/fake/root/code/standalone-skill']); + assert.strictEqual(orphanedPaths.length, 0); +}); + +test('groups a plugin with subdirectories correctly', () => { + const rootDir = '/fake/root'; + const config = { discovery: { pluginCategories: ['analysis/**'] } }; + const components = { + skills: ['/fake/root/analysis/my-plugin/skills/some-skill'], + commands: ['/fake/root/analysis/my-plugin/commands/do-thing.md'], + agents: ['/fake/root/analysis/my-plugin/agents/helper.md'], + hooksFiles: [], + mcpFiles: [] + }; + + const { plugins, orphanedPaths } = groupIntoPlugins(components, rootDir, config); + + assert.strictEqual(plugins.length, 1); + assert.strictEqual(plugins[0].name, 'my-plugin'); + assert.strictEqual(plugins[0].category, 'analysis'); + assert.deepStrictEqual(plugins[0].components.skills, ['/fake/root/analysis/my-plugin/skills/some-skill']); + assert.deepStrictEqual(plugins[0].components.commands, ['/fake/root/analysis/my-plugin/commands/do-thing.md']); + assert.deepStrictEqual(plugins[0].components.agents, ['/fake/root/analysis/my-plugin/agents/helper.md']); + assert.strictEqual(orphanedPaths.length, 0); +}); + +test('respects pluginCategories filter — unrecognized categories become orphans', () => { + const rootDir = '/fake/root'; + const config = { discovery: { pluginCategories: ['code/**'] } }; + const components = { + skills: ['/fake/root/code/good-skill', '/fake/root/random/bad-skill'], + commands: [], + agents: [], + hooksFiles: [], + mcpFiles: [] + }; + + const { plugins, orphanedPaths } = groupIntoPlugins(components, rootDir, config); + + assert.strictEqual(plugins.length, 1); + assert.strictEqual(plugins[0].name, 'good-skill'); + assert.deepStrictEqual(orphanedPaths, ['random/bad-skill']); +}); + +test('components at repo root are orphaned', () => { + const rootDir = '/fake/root'; + const config = { discovery: { pluginCategories: ['code/**'] } }; + const components = { + skills: ['/fake/root/lonely-skill'], + commands: [], + agents: [], + hooksFiles: [], + mcpFiles: [] + }; + + const { plugins, orphanedPaths } = groupIntoPlugins(components, rootDir, config); + + assert.strictEqual(plugins.length, 0); + assert.deepStrictEqual(orphanedPaths, ['lonely-skill']); +}); + +test('multiple plugins across categories', () => { + const rootDir = '/fake/root'; + const config = { discovery: { pluginCategories: ['code/**', 'analysis/**'] } }; + const components = { + skills: ['/fake/root/code/skill-a', '/fake/root/analysis/skill-b'], + commands: ['/fake/root/code/skill-a/commands/cmd.md'], + agents: [], + hooksFiles: [], + mcpFiles: [] + }; + + const { plugins } = groupIntoPlugins(components, rootDir, config); + + assert.strictEqual(plugins.length, 2); + const names = plugins.map(p => p.name).sort(); + assert.deepStrictEqual(names, ['skill-a', 'skill-b']); + + const skillA = plugins.find(p => p.name === 'skill-a'); + assert.strictEqual(skillA.components.skills.length, 1); + assert.strictEqual(skillA.components.commands.length, 1); +}); + +// --- discoverPlugins integration with discoverAllComponents (no silent drops) --- + +console.log('\ndiscoverPlugins (integration with test fixtures)'); + +test('discovers standalone skill in test fixtures', () => { + const config = loadFixtureConfig(); + const plugins = discoverPlugins(FIXTURES_DIR, config); + + const standalonePlugin = plugins.find(p => p.name === 'standalone-skill'); + assert(standalonePlugin, 'standalone-skill plugin should be discovered'); + assert.strictEqual(standalonePlugin.category, 'code'); + assert.strictEqual(standalonePlugin.components.skills.length, 1); +}); + +test('discovers test-plugin with commands in test fixtures', () => { + const config = loadFixtureConfig(); + const plugins = discoverPlugins(FIXTURES_DIR, config); + + const testPlugin = plugins.find(p => p.name === 'test-plugin'); + assert(testPlugin, 'test-plugin should be discovered'); + assert.strictEqual(testPlugin.category, 'analysis'); + assert.strictEqual(testPlugin.components.commands.length, 1); +}); + +test('discoverPlugins returns consistent results with discoverAllComponents — no silent drops', () => { + const config = loadFixtureConfig(); + const components = discoverAllComponents(FIXTURES_DIR, config); + const plugins = discoverPlugins(FIXTURES_DIR, config); + + // Count total components found via discoverAllComponents + const totalDiscovered = components.skills.length + components.commands.length + components.agents.length; + + // Count total components in plugins + let totalInPlugins = 0; + for (const plugin of plugins) { + totalInPlugins += plugin.components.skills.length; + totalInPlugins += plugin.components.commands.length; + totalInPlugins += plugin.components.agents.length; + } + + assert.strictEqual(totalInPlugins, totalDiscovered, + `All ${totalDiscovered} discovered components should appear in plugins, but only ${totalInPlugins} found`); +}); + +test('standalone skill validates successfully', () => { + const config = loadFixtureConfig(); + const skillPath = path.join(FIXTURES_DIR, 'code', 'standalone-skill'); + const result = validateSkill(skillPath, config); + + assert(result.valid, `standalone-skill should be valid, errors: ${result.errors.join(', ')}`); + assert.strictEqual(result.name, 'standalone-skill'); +}); + +// --- Summary --- + +console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed`); + +if (failed > 0) { + process.exit(1); +} diff --git a/test-fixtures/valid/.claude-plugin/generator.config.toml b/test-fixtures/valid/.claude-plugin/generator.config.toml index e0de71a..013b876 100644 --- a/test-fixtures/valid/.claude-plugin/generator.config.toml +++ b/test-fixtures/valid/.claude-plugin/generator.config.toml @@ -9,7 +9,7 @@ name = "Test Owner" email = "test@example.com" [discovery] -pluginCategories = ["analysis/**"] +pluginCategories = ["analysis/**", "code/**"] excludeDirs = [".git", "node_modules", ".github", ".claude", "templates", "test-components", "docs"] excludePatterns = ["**/template/**", "**/*template*/**"] maxDepth = 10 diff --git a/test-fixtures/valid/code/standalone-skill/SKILL.md b/test-fixtures/valid/code/standalone-skill/SKILL.md new file mode 100644 index 0000000..5637136 --- /dev/null +++ b/test-fixtures/valid/code/standalone-skill/SKILL.md @@ -0,0 +1,8 @@ +--- +name: standalone-skill +description: A standalone skill with no subdirectories for testing +--- + +# Standalone Skill + +This skill has only a SKILL.md file — no agents/, commands/, or skills/ subdirectories.