diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 68a7647..a99a7b1 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -31,6 +31,12 @@ jobs: - name: Install dependencies run: bun install + - name: Compile runtime context + run: bun run compile:context + + - name: Regenerate skills index + run: bash scripts/generate-skills-index.sh + - name: Run tests run: bun test diff --git a/skills/INDEX.md b/skills/INDEX.md index a1a81b2..bfb8c83 100644 --- a/skills/INDEX.md +++ b/skills/INDEX.md @@ -33,7 +33,7 @@ Categories: | Skill | Description | |---|---| | `behaviour-analysis` | Systematic UI/UX behaviour analysis for interactive applications. Audits every user action, state transition, view mode, | -| `designer` | Evidence-based design decision engine. An intention gate that produces non-slop | +| `designer` | Evidence-based design decision engine. Intention gate that produces non-slop | | `design-patterns-skill` | Apply core programming principles and design patterns from Clean Code, The Pragmatic Programmer, Code Complete, Refactor | | `readme-writer` | Writes or rewrites project README files using repository evidence instead of generic filler. Use when creating a new REA | | `security-review` | Security code review for vulnerabilities. Use when asked to security review, find vulnerabilities, check for security is | @@ -44,4 +44,4 @@ Categories: | Skill | Description | |---|---| | `testing-skills` | Use when creating or editing Hyperstack skills, before shipping them, to verify they actually work under pressure and re | -| `using-hyperstack` | Bootstrap - establishes Hyperstack MCP tools and skills before any technical work. Auto-loaded at session start via Se | +| `using-hyperstack` | Bootstrap - establishes Hyperstack MCP tools and skills before any technical work. Auto-loaded at session start via Sess | diff --git a/tests/plugin-registry-behaviour.test.ts b/tests/plugin-registry-behaviour.test.ts new file mode 100644 index 0000000..fee6f35 --- /dev/null +++ b/tests/plugin-registry-behaviour.test.ts @@ -0,0 +1,154 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +import { reactflowPlugin } from "../src/plugins/reactflow/index.ts"; +import { motionPlugin } from "../src/plugins/motion/index.ts"; +import { lenisPlugin } from "../src/plugins/lenis/index.ts"; +import { reactPlugin } from "../src/plugins/react/index.ts"; +import { echoPlugin } from "../src/plugins/echo/index.ts"; +import { golangPlugin } from "../src/plugins/golang/index.ts"; +import { rustPlugin } from "../src/plugins/rust/index.ts"; +import { designTokensPlugin } from "../src/plugins/design-tokens/index.ts"; +import { uiUxPlugin } from "../src/plugins/ui-ux/index.ts"; +import { designerPlugin } from "../src/plugins/designer/index.ts"; +import { shadcnPlugin } from "../src/plugins/shadcn/index.ts"; + +interface RegisteredTool { + name: string; + description: string; +} + +function captureRegisteredTools(plugin: { name: string; register: (s: McpServer) => void }): RegisteredTool[] { + const tools: RegisteredTool[] = []; + + const server = { + tool(name: string, description: string, _schema: unknown, _handler: unknown) { + tools.push({ name, description }); + }, + resource(_name: string, _uri: unknown, _meta: unknown, _handler: unknown) { + // MCP resources - not tools, skip + }, + } as unknown as McpServer; + + plugin.register(server); + return tools; +} + +const ALL_PLUGINS = [ + reactflowPlugin, + motionPlugin, + lenisPlugin, + reactPlugin, + echoPlugin, + golangPlugin, + rustPlugin, + designTokensPlugin, + uiUxPlugin, + designerPlugin, + shadcnPlugin, +]; + +test("all 11 plugins register at least one tool", () => { + assert.equal(ALL_PLUGINS.length, 11, "Expected 11 plugins"); + + for (const plugin of ALL_PLUGINS) { + const tools = captureRegisteredTools(plugin); + assert.ok(tools.length > 0, `Plugin "${plugin.name}" registered zero tools`); + } +}); + +test("every registered tool has a non-empty name and description", () => { + for (const plugin of ALL_PLUGINS) { + const tools = captureRegisteredTools(plugin); + + for (const tool of tools) { + assert.ok(tool.name.length > 0, `Plugin "${plugin.name}" registered a tool with empty name`); + assert.ok( + tool.description.length > 0, + `Plugin "${plugin.name}" tool "${tool.name}" has empty description`, + ); + } + } +}); + +test("tool names follow namespace convention (plugin_name_action)", () => { + const NAMESPACE_MAP: Record = { + reactflow: "reactflow_", + motion: "motion_", + lenis: "lenis_", + react: "react_", + echo: "echo_", + golang: "golang_", + rust: "rust_", + "design-tokens": "design_tokens_", + "ui-ux": "ui_ux_", + designer: "designer_", + shadcn: "shadcn_", + }; + + for (const plugin of ALL_PLUGINS) { + const expectedPrefix = NAMESPACE_MAP[plugin.name]; + assert.ok(expectedPrefix, `No namespace mapping for plugin: ${plugin.name}`); + + const tools = captureRegisteredTools(plugin); + for (const tool of tools) { + assert.ok( + tool.name.startsWith(expectedPrefix), + `Plugin "${plugin.name}" tool "${tool.name}" does not start with expected prefix "${expectedPrefix}"`, + ); + } + } +}); + +test("designer plugin registers the required MCP tools referenced in skills", () => { + const REQUIRED_DESIGNER_TOOLS = [ + "designer_resolve_intent", + "designer_get_personality", + "designer_get_page_template", + "designer_get_anti_patterns", + "designer_get_preset", + "designer_list_presets", + "designer_get_font_pairing", + ]; + + const tools = captureRegisteredTools(designerPlugin); + const toolNames = new Set(tools.map((t) => t.name)); + + for (const required of REQUIRED_DESIGNER_TOOLS) { + assert.ok(toolNames.has(required), `designer plugin missing required tool: ${required}`); + } +}); + +test("shadcn plugin registers the required MCP tools referenced in skills", () => { + const REQUIRED_SHADCN_TOOLS = [ + "shadcn_get_rules", + "shadcn_get_component", + "shadcn_get_snippet", + "shadcn_get_composition", + "shadcn_list_components", + ]; + + const tools = captureRegisteredTools(shadcnPlugin); + const toolNames = new Set(tools.map((t) => t.name)); + + for (const required of REQUIRED_SHADCN_TOOLS) { + assert.ok(toolNames.has(required), `shadcn plugin missing required tool: ${required}`); + } +}); + +test("no two plugins register a tool with the same name", () => { + const seen = new Map(); + + for (const plugin of ALL_PLUGINS) { + const tools = captureRegisteredTools(plugin); + for (const tool of tools) { + const existing = seen.get(tool.name); + assert.ok( + !existing, + `Duplicate tool name "${tool.name}" registered by both "${existing}" and "${plugin.name}"`, + ); + seen.set(tool.name, plugin.name); + } + } +}); diff --git a/tests/skills-index-behaviour.test.ts b/tests/skills-index-behaviour.test.ts new file mode 100644 index 0000000..b75552e --- /dev/null +++ b/tests/skills-index-behaviour.test.ts @@ -0,0 +1,76 @@ +import assert from "node:assert/strict"; +import { execSync } from "node:child_process"; +import { readFileSync, readdirSync, existsSync } from "node:fs"; +import { resolve, join } from "node:path"; +import test from "node:test"; + +const SKILLS_DIR = resolve("skills"); +const INDEX_PATH = resolve("skills/INDEX.md"); + +function getSkillDirs(): string[] { + return readdirSync(SKILLS_DIR, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name) + .filter((name) => existsSync(join(SKILLS_DIR, name, "SKILL.md"))); +} + +function getSkillCategory(skillName: string): string | null { + const skillFile = join(SKILLS_DIR, skillName, "SKILL.md"); + const content = readFileSync(skillFile, "utf8"); + const match = content.match(/^category:\s*(.+)$/m); + return match ? match[1].trim() : null; +} + +test("skills/INDEX.md stays in sync with actual skill directories", () => { + const currentIndex = readFileSync(INDEX_PATH, "utf8"); + + execSync("bash scripts/generate-skills-index.sh", { stdio: "pipe" }); + + const regenerated = readFileSync(INDEX_PATH, "utf8"); + assert.equal( + currentIndex, + regenerated, + "skills/INDEX.md is stale. Run `bash scripts/generate-skills-index.sh` to regenerate.", + ); +}); + +test("every skill directory has a SKILL.md with required frontmatter fields", () => { + const skillDirs = getSkillDirs(); + assert.ok(skillDirs.length > 0, "No skill directories found"); + + for (const skillName of skillDirs) { + const skillFile = join(SKILLS_DIR, skillName, "SKILL.md"); + const content = readFileSync(skillFile, "utf8"); + + assert.match(content, /^---\n/m, `${skillName}/SKILL.md missing frontmatter opening`); + assert.match(content, /^name:\s*.+$/m, `${skillName}/SKILL.md missing 'name:' field`); + assert.match(content, /^category:\s*.+$/m, `${skillName}/SKILL.md missing 'category:' field`); + assert.match(content, /^description:\s*.+/ms, `${skillName}/SKILL.md missing 'description:' field`); + } +}); + +test("every skill has a valid category (core | domain | meta)", () => { + const VALID_CATEGORIES = new Set(["core", "domain", "meta"]); + const skillDirs = getSkillDirs(); + + for (const skillName of skillDirs) { + const category = getSkillCategory(skillName); + assert.ok( + category && VALID_CATEGORIES.has(category), + `${skillName}/SKILL.md has invalid category: "${category}". Must be core | domain | meta`, + ); + } +}); + +test("skills/INDEX.md references every skill directory that has a SKILL.md", () => { + const indexContent = readFileSync(INDEX_PATH, "utf8"); + const skillDirs = getSkillDirs(); + + for (const skillName of skillDirs) { + assert.match( + indexContent, + new RegExp(`\`${skillName}\``), + `skills/INDEX.md does not reference skill: ${skillName}`, + ); + } +});