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
6 changes: 6 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions skills/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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 |
154 changes: 154 additions & 0 deletions tests/plugin-registry-behaviour.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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<string, string>();

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);
}
}
});
76 changes: 76 additions & 0 deletions tests/skills-index-behaviour.test.ts
Original file line number Diff line number Diff line change
@@ -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}`,
);
}
});
Loading