From 750547d569f66576768fe507447683c19f4ceec1 Mon Sep 17 00:00:00 2001 From: Kailas Mahavarkar <66670953+KailasMahavarkar@users.noreply.github.com> Date: Fri, 10 Apr 2026 16:30:58 +0530 Subject: [PATCH] chore: add CREDITS, bin entrypoint, cross-platform hook, CI verify, plugin fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CREDITS.md: acknowledge obra/superpowers (MIT) for enforcement pattern - README.md: add Acknowledgements section + CREDITS link; architecture note for bin/ - bin/hyperstack.mjs: Node wrapper that boots src/index.ts via tsx (replaces dist/index.js as bin target) - hooks/session-start.mjs: cross-platform SessionStart hook replacing bash-only script - hooks/hooks.json + hooks-cursor.json: point to session-start.mjs - install.md: update local install instructions to use bin/hyperstack.mjs - package.json: move tsx to prod deps, add test script, update bin entrypoint - .github/workflows/publish.yml: add verify job (matrix: 3 OS Γ— 3 Node versions) before Docker push - tests/: initial test suite for generator and workflow behaviour - fix(designer): component name extraction preserves multi-word names - fix(lenis): useReducedMotion SSR-safe with useEffect + event listener - fix(motion): correct component signatures for stagger, exit, layout variants; fix useEffect dep array - fix(reactflow): inline store code instead of comment block; add missing xyflow type imports Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/publish.yml | 35 ++++++- CREDITS.md | 32 +++++++ README.md | 19 +++- bin/hyperstack.mjs | 43 +++++++++ hooks/hooks-cursor.json | 2 +- hooks/hooks.json | 2 +- hooks/session-start.mjs | 35 +++++++ install.md | 10 +- package-lock.json | 35 +------ package.json | 6 +- .../tools/generate-implementation-plan.ts | 2 +- src/plugins/lenis/tools/generate-setup.ts | 15 ++- .../motion/tools/generate-animation.ts | 14 ++- src/plugins/reactflow/tools/generate-flow.ts | 23 +++-- tests/generator-behaviour.test.ts | 91 +++++++++++++++++++ tests/helpers.ts | 44 +++++++++ tests/runtime-behaviour.test.ts | 75 +++++++++++++++ tests/workflow-behaviour.test.ts | 27 ++++++ 18 files changed, 443 insertions(+), 67 deletions(-) create mode 100644 CREDITS.md create mode 100755 bin/hyperstack.mjs create mode 100644 hooks/session-start.mjs create mode 100644 tests/generator-behaviour.test.ts create mode 100644 tests/helpers.ts create mode 100644 tests/runtime-behaviour.test.ts create mode 100644 tests/workflow-behaviour.test.ts diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6a587fd..425566e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,8 +1,9 @@ -name: Publish Docker image +name: Verify and Publish Docker image on: push: branches: ['main'] + pull_request: release: types: [published] @@ -11,7 +12,37 @@ env: IMAGE_NAME: ${{ github.repository }} jobs: + verify: + name: Verify (${{ matrix.os }}, Node ${{ matrix.node-version }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + node-version: [18, 20, 24] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm test + + - name: Type-check + run: npm run build + build-and-push-image: + if: github.event_name != 'pull_request' + needs: verify runs-on: ubuntu-latest permissions: contents: read @@ -40,4 +71,4 @@ jobs: context: . push: true tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} \ No newline at end of file + labels: ${{ steps.meta.outputs.labels }} diff --git a/CREDITS.md b/CREDITS.md new file mode 100644 index 0000000..fda776e --- /dev/null +++ b/CREDITS.md @@ -0,0 +1,32 @@ +# Credits + +--- + +## [obra/superpowers](https://github.com/obra/superpowers) + +**License:** MIT Β© Jesse Vincent + +Hyperstack's gate skill enforcement pattern - Iron Laws, 1% Rule, rationalization tables, `` blocks, and the "spirit of the rule is the letter of the rule" clause - was adopted from superpowers. The core insight is that AI compliance gates need to be written adversarially to survive model pressure. We agreed, and built on it. + +Five Hyperstack workflow skills (`ship-gate`, `debug-discipline`, `run-plan`, `deliver`, `forge-plan`) are structurally derived from their superpowers equivalents. They have since been extended with MCP integration, DESIGN.md pipeline hooks, and Hyperstack-specific domain content. + +Everything else - the MCP server, 11 plugins, 79 tools, designer engine, DESIGN.md pipeline, React Flow / Motion / Lenis / Echo / Go / Rust / design tokens domain content, shadcn expert, and SessionStart hook - has no equivalent in superpowers and is original work. + +--- + +## Research and Prior Art + +The designer plugin's knowledge base is distilled from publicly available design research and real-world design systems: + +- **Cognitive psychology** - Fitts's Law, Hick's Law, Miller's Law, Gestalt principles, Von Restorff effect, Serial Position effect, Peak-End Rule, Doherty Threshold +- **UX heuristics** - Jakob Nielsen's 10 usability heuristics +- **Typography and layout** - Bringhurst's *Elements of Typographic Style*, MΓΌller-Brockmann's grid systems +- **Design systems** - Patterns observed across Linear, Stripe, Vercel, Apple HIG, Carbon, shadcn, Notion, Supabase, and Figma - used as reference, not reproduced + +The design-patterns-skill draws on principles from: + +- *Clean Code* - Robert C. Martin +- *The Pragmatic Programmer* - Hunt and Thomas +- *Code Complete* - Steve McConnell +- *Refactoring* - Martin Fowler +- *Design Patterns* - Gang of Four diff --git a/README.md b/README.md index 9b97720..55fca9e 100755 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ The MCP server gives you tools. The skills give you discipline. Install both: git clone https://github.com/orkait/hyperstack.git ~/.claude/skills/hyperstack ``` -After installing, the SessionStart hook (at `hooks/session-start`) will auto-inject the `using-hyperstack` skill into every session. No manual activation needed. +After installing, the SessionStart hook (at `hooks/session-start.mjs`) will auto-inject the `using-hyperstack` skill into every session. No manual activation needed. ### πŸ’» From source @@ -95,6 +95,7 @@ After installing, the SessionStart hook (at `hooks/session-start`) will auto-inj git clone https://github.com/orkait/hyperstack.git cd hyperstack npm install +node bin/hyperstack.mjs # same entrypoint the published bin uses npm start # runs via tsx, no build step npm run dev # watch mode npm run build # tsc --noEmit (type-check only, no dist output) @@ -130,7 +131,7 @@ Your AI calls these for exact API data. Memory is not acceptable. Every plugin s Markdown with adversarial enforcement. Each gate skill has an Iron Law, a 1% Rule, and a rationalization table that names the exact excuses your AI will use to skip the gate and counters each one. -The `using-hyperstack` skill is injected into every session by `hooks/session-start`. You do not have to invoke it manually. +The `using-hyperstack` skill is injected into every session by `hooks/session-start.mjs`. You do not have to invoke it manually.
🧱 Core (13) - workflow, discipline, gates used on every task @@ -361,9 +362,12 @@ Only invoked when the user explicitly chose shadcn in designer Q11b. ## πŸ—οΈ Architecture -Everything runs via `tsx` at runtime. No `dist/` output, no build step for deployment - just type checking. +Everything runs from source. The published `hyperstack` bin is a small Node wrapper that boots `src/index.ts` through `tsx`, and Docker uses the same source-first runtime. No `dist/` output, no build step for deployment - just type checking. ```text +bin/ +└── hyperstack.mjs # Published CLI wrapper - boots src/index.ts via tsx + src/ β”œβ”€β”€ index.ts # Entry - creates McpServer, loads all 11 plugins β”œβ”€β”€ registry.ts # Plugin interface + loadPlugins() @@ -389,7 +393,8 @@ skills/ hooks/ β”œβ”€β”€ hooks.json # Registers the SessionStart hook -β”œβ”€β”€ session-start # Bash script that injects using-hyperstack as context +β”œβ”€β”€ session-start.mjs # Cross-platform hook entrypoint for auto-injecting using-hyperstack +β”œβ”€β”€ session-start # Legacy shell helper └── run-hook.cmd # Windows dispatcher scripts/ @@ -433,3 +438,9 @@ npm run build # tsc --noEmit ## πŸ“„ License MIT Β© [Orkait](https://github.com/orkait) + +--- + +## πŸ™ Acknowledgements + +The enforcement philosophy behind Hyperstack's gate skills - Iron Laws, 1% Rule, rationalization tables - was adopted from [obra/superpowers](https://github.com/obra/superpowers) (MIT Β© Jesse Vincent). We agreed with how it frames AI compliance: adversarially, not politely. See [CREDITS.md](./CREDITS.md). diff --git a/bin/hyperstack.mjs b/bin/hyperstack.mjs new file mode 100755 index 0000000..77e6b39 --- /dev/null +++ b/bin/hyperstack.mjs @@ -0,0 +1,43 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const binDir = dirname(fileURLToPath(import.meta.url)); +const rootDir = resolve(binDir, ".."); +const entrypoint = resolve(rootDir, "src/index.ts"); +const nodeMajor = Number.parseInt(process.versions.node.split(".")[0] ?? "0", 10); +const tsxLoaderArgs = nodeMajor >= 20 ? ["--import", "tsx"] : ["--loader", "tsx"]; + +const child = spawn(process.execPath, [...tsxLoaderArgs, entrypoint, ...process.argv.slice(2)], { + cwd: rootDir, + env: process.env, + stdio: "inherit", +}); + +const forwardSignal = (signal) => { + if (!child.killed) { + child.kill(signal); + } +}; + +process.on("SIGINT", () => forwardSignal("SIGINT")); +process.on("SIGTERM", () => forwardSignal("SIGTERM")); + +child.on("error", (error) => { + console.error("Failed to start hyperstack:", error); + process.exit(1); +}); + +child.on("exit", (code, signal) => { + if (signal === "SIGINT") { + process.exit(130); + } + + if (signal === "SIGTERM") { + process.exit(143); + } + + process.exit(code ?? 1); +}); diff --git a/hooks/hooks-cursor.json b/hooks/hooks-cursor.json index 4247d01..b7da9b2 100644 --- a/hooks/hooks-cursor.json +++ b/hooks/hooks-cursor.json @@ -6,7 +6,7 @@ "hooks": [ { "type": "command", - "command": "\"${CURSOR_PLUGIN_ROOT}/hooks/run-hook.cmd\" session-start", + "command": "node \"${CURSOR_PLUGIN_ROOT}/hooks/session-start.mjs\"", "async": false } ] diff --git a/hooks/hooks.json b/hooks/hooks.json index 79d8cee..3b815f6 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -6,7 +6,7 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.cmd\" session-start", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/session-start.mjs\"", "async": false } ] diff --git a/hooks/session-start.mjs b/hooks/session-start.mjs new file mode 100644 index 0000000..f35b6df --- /dev/null +++ b/hooks/session-start.mjs @@ -0,0 +1,35 @@ +#!/usr/bin/env node + +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const pluginRoot = dirname(scriptDir); + +function emit(payload) { + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); +} + +try { + const skillPath = join(pluginRoot, "skills", "using-hyperstack", "SKILL.md"); + const skillContent = readFileSync(skillPath, "utf8"); + const sessionContext = `\nYou have Hyperstack.\n\n**Below is the full content of your 'hyperstack:using-hyperstack' skill - your introduction to using Hyperstack. For all other skills, use the 'Skill' tool:**\n\n${skillContent}\n`; + + if (process.env.CURSOR_PLUGIN_ROOT) { + emit({ additional_context: sessionContext }); + } else if (process.env.CLAUDE_PLUGIN_ROOT && !process.env.COPILOT_CLI) { + emit({ + hookSpecificOutput: { + hookEventName: "SessionStart", + additionalContext: sessionContext, + }, + }); + } else { + emit({ additionalContext: sessionContext }); + } +} catch (error) { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`${JSON.stringify({ error: `Hyperstack session-start hook failed: ${message}` })}\n`); + process.exit(1); +} diff --git a/install.md b/install.md index f8bded4..1d7d436 100644 --- a/install.md +++ b/install.md @@ -28,7 +28,7 @@ Clone this repository into the appropriate skills directory for your environment | **OpenCode / Codex** | Follow the platform's file-based skill installation path | | **Other** | If your environment supports local file-based skills, clone the repo to the standard skills/extensions directory for your platform. If not supported, you may skip this step and proceed to installing the MCP server, but note that the SessionStart hook and adversarial enforcement will not activate. | -The repository includes a `hooks/session-start` script that injects the `using-hyperstack` skill content into every session automatically. This is how the enforcement (Iron Laws, 1% Rule, rationalization tables) reaches the agent without manual invocation. On platforms that support `.claude-plugin/hooks.json`, this happens automatically. On others, the skill is still readable but the hook will not fire. +The repository includes a `hooks/session-start.mjs` entrypoint that injects the `using-hyperstack` skill content into every session automatically. This is how the enforcement (Iron Laws, 1% Rule, rationalization tables) reaches the agent without manual invocation. On platforms that support `.claude-plugin/hooks.json`, this happens automatically. On others, the skill is still readable but the hook will not fire. ## Step 3: Configure the MCP Server @@ -60,7 +60,7 @@ The `--memory=256m` and `--cpus=0.5` flags are intentional resource limits. Do n ### Option B: Local Node (Fallback) -If Docker is NOT available, run the server locally using Node 18+ and `tsx`: +If Docker is NOT available, run the server locally using Node 18+: 1. Navigate to the directory where you cloned the repository (e.g., `~/.claude/skills/hyperstack`) 2. Run `npm install` in that directory @@ -70,14 +70,14 @@ If Docker is NOT available, run the server locally using Node 18+ and `tsx`: { "mcpServers": { "hyperstack": { - "command": "npx", - "args": ["tsx", "/absolute/path/to/hyperstack/src/index.ts"] + "command": "node", + "args": ["/absolute/path/to/hyperstack/bin/hyperstack.mjs"] } } } ``` -There is no build step. The server runs directly from source via `tsx`. +There is no build step. The wrapper starts the server directly from source via `tsx`. ## Step 4: Verify Installation diff --git a/package-lock.json b/package-lock.json index d2d52c3..a2e42cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,14 +10,14 @@ "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.17.0", + "tsx": "^4.21.0", "zod": "^3.23.0" }, "bin": { - "hyperstack": "dist/index.js" + "hyperstack": "bin/hyperstack.mjs" }, "devDependencies": { "@types/node": "^20.0.0", - "tsx": "^4.21.0", "typescript": "^5.5.0" }, "engines": { @@ -31,7 +31,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -48,7 +47,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -65,7 +63,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -82,7 +79,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -99,7 +95,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -116,7 +111,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -133,7 +127,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -150,7 +143,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -167,7 +159,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -184,7 +175,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -201,7 +191,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -218,7 +207,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -235,7 +223,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -252,7 +239,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -269,7 +255,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -286,7 +271,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -303,7 +287,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -320,7 +303,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -337,7 +319,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -354,7 +335,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -371,7 +351,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -388,7 +367,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -405,7 +383,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -422,7 +399,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -439,7 +415,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -456,7 +431,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -796,7 +770,6 @@ "version": "0.27.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", - "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -996,7 +969,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -1057,7 +1029,6 @@ "version": "4.13.7", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", - "dev": true, "license": "MIT", "dependencies": { "resolve-pkg-maps": "^1.0.0" @@ -1418,7 +1389,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" @@ -1612,7 +1582,6 @@ "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", - "dev": true, "license": "MIT", "dependencies": { "esbuild": "~0.27.0", diff --git a/package.json b/package.json index c71912c..3316aa1 100644 --- a/package.json +++ b/package.json @@ -2,13 +2,13 @@ "name": "@orkait-ai/hyperstack", "version": "1.0.0", "description": "Disciplined MCP server + skill system. 11 plugins, 79 tools, 21 skills with adversarial enforcement. Designer/DESIGN.md pipeline, shadcn/ui, React Flow, Motion, Lenis, React 19, Echo, Go, Rust, design tokens, UI/UX.", - "main": "src/index.ts", "bin": { - "hyperstack": "src/index.ts" + "hyperstack": "bin/hyperstack.mjs" }, "type": "module", "scripts": { "build": "tsc --noEmit", + "test": "tsx --test tests/**/*.test.ts", "start": "tsx src/index.ts", "dev": "tsx watch src/index.ts" }, @@ -19,11 +19,11 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.17.0", + "tsx": "^4.21.0", "zod": "^3.23.0" }, "devDependencies": { "@types/node": "^20.0.0", - "tsx": "^4.21.0", "typescript": "^5.5.0" } } diff --git a/src/plugins/designer/tools/generate-implementation-plan.ts b/src/plugins/designer/tools/generate-implementation-plan.ts index 72cebbc..9099aee 100644 --- a/src/plugins/designer/tools/generate-implementation-plan.ts +++ b/src/plugins/designer/tools/generate-implementation-plan.ts @@ -456,7 +456,7 @@ function extractComponentsFromSection(section5Text: string): string[] { const components = new Set(); let m: RegExpExecArray | null; while ((m = regex.exec(section5Text)) !== null) { - const name = m[1]!.trim().split(/\s+/)[0]!; // first word + const name = m[1]!.trim().replace(/\s+/g, " "); if (name && !["Component", "All", "States", "Variants"].includes(name)) { components.add(name); } diff --git a/src/plugins/lenis/tools/generate-setup.ts b/src/plugins/lenis/tools/generate-setup.ts index fb57806..9fcdf3e 100644 --- a/src/plugins/lenis/tools/generate-setup.ts +++ b/src/plugins/lenis/tools/generate-setup.ts @@ -176,12 +176,23 @@ export function HorizontalScroll({ children }: { children: React.ReactNode }) { code = `// components/smooth-scroll-provider.tsx "use client"; +import { useEffect, useState } from "react"; import { ReactLenis } from "lenis/react"; import "lenis/dist/lenis.css"; function useReducedMotion(): boolean { - if (typeof window === "undefined") return false; - return window.matchMedia("(prefers-reduced-motion: reduce)").matches; + const [reduced, setReduced] = useState(false); + + useEffect(() => { + const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)"); + const update = () => setReduced(mediaQuery.matches); + + update(); + mediaQuery.addEventListener("change", update); + return () => mediaQuery.removeEventListener("change", update); + }, []); + + return reduced; } export function SmoothScrollProvider({ children }: { children: React.ReactNode }) { diff --git a/src/plugins/motion/tools/generate-animation.ts b/src/plugins/motion/tools/generate-animation.ts index eb5ac31..115e230 100644 --- a/src/plugins/motion/tools/generate-animation.ts +++ b/src/plugins/motion/tools/generate-animation.ts @@ -20,6 +20,7 @@ export function register(server: McpServer): void { let hooks = ""; let wrapBefore = ""; let wrapAfter = ""; + let componentSignature = "{ children }"; if (desc.includes("scroll") && (desc.includes("link") || desc.includes("progress") || desc.includes("parallax"))) { motionImports.add("useScroll"); @@ -39,16 +40,18 @@ export function register(server: McpServer): void { jsx = `\n {children}\n`; } else if (desc.includes("exit") || desc.includes("page transition") || desc.includes("route")) { motionImports.add("AnimatePresence"); + componentSignature = "{ children, itemKey }"; wrapBefore = `\n `; wrapAfter = `\n`; - jsx = `\n {children}\n `; + jsx = `\n {children}\n `; } else if (desc.includes("stagger") || desc.includes("list")) { motionImports.add("useAnimate"); motionImports.add("stagger"); reactImports.add("useEffect"); + componentSignature = "{ items }"; hooks += ` const [scope, animate] = useAnimate();\n\n`; - hooks += ` useEffect(() => {\n animate("li", { opacity: 1, y: 0 }, { delay: stagger(0.08) });\n }, []);\n`; - jsx = `
    \n {items.map(item => (\n \n {item}\n \n ))}\n
`; + hooks += ` useEffect(() => {\n animate("li", { opacity: 1, y: 0 }, { delay: stagger(0.08) });\n }, [animate]);\n`; + jsx = `
    \n {items.map((item) => (\n \n {item}\n \n ))}\n
`; } else if (desc.includes("drag")) { if (desc.includes("spring") || desc.includes("bounce")) { jsx = `\n {children}\n`; @@ -59,7 +62,8 @@ export function register(server: McpServer): void { jsx = `\n {children}\n`; } else if (desc.includes("layout") || desc.includes("shared")) { if (desc.includes("shared") || desc.includes("tab") || desc.includes("underline")) { - jsx = `{selected === id && (\n \n)}`; + componentSignature = "{ isSelected = false }"; + jsx = `{isSelected && (\n \n)}`; } else { jsx = `\n {children}\n`; } @@ -85,7 +89,7 @@ export function register(server: McpServer): void { ? `\nimport { ${[...reactImports].join(", ")} } from "react";` : ""; - let code = `${importLine}${reactImportLine}\n\nfunction AnimatedComponent({ children }) {\n`; + let code = `${importLine}${reactImportLine}\n\nfunction AnimatedComponent(${componentSignature}) {\n`; if (hooks) code += hooks + "\n"; code += ` return (\n ${wrapBefore}${jsx}${wrapAfter}\n );\n}`; diff --git a/src/plugins/reactflow/tools/generate-flow.ts b/src/plugins/reactflow/tools/generate-flow.ts index acb357c..2544c1e 100644 --- a/src/plugins/reactflow/tools/generate-flow.ts +++ b/src/plugins/reactflow/tools/generate-flow.ts @@ -23,7 +23,7 @@ export function register(server: McpServer): void { const imports = new Set(["ReactFlow"]); const xyflowImports = new Set(); const extraImports: string[] = []; - let storeCode = ""; + let supportCode = ""; let beforeReturn = ""; let flowProps: string[] = ["nodes={nodes}", "edges={edges}", "fitView"]; let children = ""; @@ -136,16 +136,21 @@ const nodeTypes = { custom: CustomNode }; // Store vs useState if (useStore) { + xyflowImports.add("type Node"); + xyflowImports.add("type Edge"); + xyflowImports.add("type OnNodesChange"); + xyflowImports.add("type OnEdgesChange"); + xyflowImports.add("type OnConnect"); + imports.add("applyNodeChanges"); + imports.add("applyEdgeChanges"); + imports.add("addEdge"); flowProps.push( "onNodesChange={onNodesChange}", "onEdgesChange={onEdgesChange}", "onConnect={onConnect}", ); - storeCode = ` -// --- store.ts --- -import { create } from 'zustand'; -import { type Node, type Edge, type OnNodesChange, type OnEdgesChange, type OnConnect, applyNodeChanges, applyEdgeChanges, addEdge } from '@xyflow/react'; - + extraImports.push("import { create } from 'zustand';"); + supportCode = ` const initialNodes: Node[] = [ { id: '1', position: { x: 0, y: 0 }, data: { label: 'Node 1' } }, { id: '2', position: { x: 250, y: 100 }, data: { label: 'Node 2' } }, @@ -169,12 +174,10 @@ const useFlowStore = create((set, get) => ({ })); const selector = (s: FlowState) => s; -export { useFlowStore, selector }; `; beforeReturn = ` const { nodes, edges, onNodesChange, onEdgesChange, onConnect } = useFlowStore(selector);\n` + beforeReturn; - extraImports.push("import { useFlowStore, selector } from './store';"); } else { imports.add("useNodesState"); imports.add("useEdgesState"); @@ -214,8 +217,8 @@ export { useFlowStore, selector }; code += `${imp}\n`; } - if (storeCode) { - code += `\n/*\n${storeCode}*/\n`; + if (supportCode) { + code += `\n${supportCode}\n`; } if (additionalComponents) { diff --git a/tests/generator-behaviour.test.ts b/tests/generator-behaviour.test.ts new file mode 100644 index 0000000..1e70520 --- /dev/null +++ b/tests/generator-behaviour.test.ts @@ -0,0 +1,91 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import ts from "typescript"; +import { register as registerGenerateFlow } from "../src/plugins/reactflow/tools/generate-flow.ts"; +import { register as registerGenerateAnimation } from "../src/plugins/motion/tools/generate-animation.ts"; +import { register as registerGenerateSetup } from "../src/plugins/lenis/tools/generate-setup.ts"; +import { buildTasks } from "../src/plugins/designer/tools/generate-implementation-plan.ts"; +import { captureTool, extractTextContent, extractTsxFence } from "./helpers.ts"; + +function expectNoSyntaxErrors(code: string): void { + const output = ts.transpileModule(code, { + compilerOptions: { + jsx: ts.JsxEmit.ReactJSX, + module: ts.ModuleKind.ESNext, + target: ts.ScriptTarget.ES2022, + }, + reportDiagnostics: true, + }); + + const diagnostics = output.diagnostics ?? []; + assert.equal( + diagnostics.length, + 0, + diagnostics.map((item) => item.messageText).join("\n"), + ); +} + +test("reactflow_generate_flow returns self-contained controlled-flow code", async () => { + const tool = captureTool(registerGenerateFlow); + assert.equal(tool.name, "reactflow_generate_flow"); + + const result = await tool.invoke({ description: "simple two-node flow", controlled: true }); + const text = extractTextContent(result); + const code = extractTsxFence(text); + + assert.doesNotMatch(code, /import \{ useFlowStore, selector \} from '\.\/store';/); + assert.doesNotMatch(code, /\/\*\s*\n\/\/ --- store\.ts ---/); + expectNoSyntaxErrors(code); +}); + +test("motion_generate_animation does not emit placeholder exit keys", async () => { + const tool = captureTool(registerGenerateAnimation); + assert.equal(tool.name, "motion_generate_animation"); + + const result = await tool.invoke({ description: "page transition with exit" }); + const text = extractTextContent(result); + const code = extractTsxFence(text); + + assert.doesNotMatch(code, /key=\{\/\*/); + expectNoSyntaxErrors(code); +}); + +test("motion_generate_animation declares list inputs for staggered list output", async () => { + const tool = captureTool(registerGenerateAnimation); + const result = await tool.invoke({ description: "staggered list entrance" }); + const text = extractTextContent(result); + const code = extractTsxFence(text); + + assert.match(code, /items/); + assert.doesNotMatch(code, /function AnimatedComponent\(\{ children \}\)/); + expectNoSyntaxErrors(code); +}); + +test("lenis_generate_setup uses a reactive reduced-motion check", async () => { + const tool = captureTool(registerGenerateSetup); + assert.equal(tool.name, "lenis_generate_setup"); + + const result = await tool.invoke({ description: "next.js with accessibility support" }); + const text = extractTextContent(result); + const code = extractTsxFence(text); + + assert.match(code, /useEffect/); + assert.match(code, /useState/); + assert.doesNotMatch(code, /return window\.matchMedia/); + expectNoSyntaxErrors(code); +}); + +test("buildTasks preserves multi-word component names from DESIGN.md", () => { + const tasks = buildTasks( + { + "5": "### Data Table\n\nCopy\n\n### Command Menu\n\nCopy\n", + }, + "react", + ); + + const componentTasks = tasks + .map((item) => item.name) + .filter((name) => name.startsWith("Component: ")); + + assert.deepEqual(componentTasks, ["Component: Data Table", "Component: Command Menu"]); +}); diff --git a/tests/helpers.ts b/tests/helpers.ts new file mode 100644 index 0000000..d6d4677 --- /dev/null +++ b/tests/helpers.ts @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +type ToolHandler = (args: Record) => Promise | unknown; + +export function captureTool(register: (server: McpServer) => void) { + let capturedName = ""; + let capturedHandler: ToolHandler | undefined; + + const server = { + tool(name: string, _description: string, _schema: unknown, handler: ToolHandler) { + capturedName = name; + capturedHandler = handler; + }, + } as unknown as McpServer; + + register(server); + + assert.ok(capturedName, "tool registration did not provide a name"); + assert.ok(capturedHandler, "tool registration did not provide a handler"); + + return { + name: capturedName, + async invoke(args: Record) { + return capturedHandler!(args); + }, + }; +} + +export function extractTextContent(result: unknown): string { + const payload = result as { + content?: Array<{ type?: string; text?: string }>; + }; + + const textBlock = payload.content?.find((item) => item.type === "text" && typeof item.text === "string"); + assert.ok(textBlock?.text, "tool response did not include a text content block"); + return textBlock.text; +} + +export function extractTsxFence(markdown: string): string { + const match = markdown.match(/```tsx\n([\s\S]*?)\n```/); + assert.ok(match?.[1], "tool response did not include a tsx code fence"); + return match[1]; +} diff --git a/tests/runtime-behaviour.test.ts b/tests/runtime-behaviour.test.ts new file mode 100644 index 0000000..6605d04 --- /dev/null +++ b/tests/runtime-behaviour.test.ts @@ -0,0 +1,75 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { once } from "node:events"; +import { setTimeout as delay } from "node:timers/promises"; +import { spawn } from "node:child_process"; +import test from "node:test"; + +test("Claude SessionStart hook command executes successfully on this platform", async () => { + const raw = await readFile(resolve("hooks/hooks.json"), "utf8"); + const config = JSON.parse(raw) as { + hooks: { + SessionStart: Array<{ + hooks: Array<{ command: string }>; + }>; + }; + }; + + const command = config.hooks.SessionStart[0]?.hooks[0]?.command; + assert.ok(command, "SessionStart hook command is missing"); + + const child = spawn("bash", ["-lc", command], { + cwd: process.cwd(), + env: { ...process.env, CLAUDE_PLUGIN_ROOT: process.cwd() }, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk: Buffer | string) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk: Buffer | string) => { + stderr += chunk.toString(); + }); + + const [exitCode] = (await once(child, "close")) as [number | null]; + assert.equal(exitCode, 0, `SessionStart hook failed.\nstdout:\n${stdout}\nstderr:\n${stderr}`); + + const payload = JSON.parse(stdout) as { + additionalContext?: string; + hookSpecificOutput?: { additionalContext?: string }; + }; + + assert.ok( + payload.additionalContext || payload.hookSpecificOutput?.additionalContext, + "SessionStart hook output did not include additional context", + ); +}); + +test("package bin entry starts without an immediate runtime crash", async () => { + const raw = await readFile(resolve("package.json"), "utf8"); + const pkg = JSON.parse(raw) as { + bin?: { hyperstack?: string }; + }; + + const binEntry = pkg.bin?.hyperstack; + assert.ok(binEntry, "package bin entry is missing"); + + const child = spawn(process.execPath, [resolve(binEntry)], { + cwd: process.cwd(), + stdio: ["pipe", "pipe", "pipe"], + }); + + let stderr = ""; + child.stderr.on("data", (chunk: Buffer | string) => { + stderr += chunk.toString(); + }); + + await delay(200); + assert.equal(child.exitCode, null, `bin entry crashed on startup.\nstderr:\n${stderr}`); + + child.kill("SIGTERM"); + await once(child, "close"); +}); diff --git a/tests/workflow-behaviour.test.ts b/tests/workflow-behaviour.test.ts new file mode 100644 index 0000000..978d12e --- /dev/null +++ b/tests/workflow-behaviour.test.ts @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import test from "node:test"; + +test("publish workflow verifies the package across the supported OS and Node matrix before publishing", async () => { + const workflow = await readFile(resolve(".github/workflows/publish.yml"), "utf8"); + + assert.match(workflow, /pull_request:/, "workflow should validate on pull requests"); + assert.match(workflow, /strategy:\s*\n(?:\s+.*\n)*?\s+matrix:/, "workflow should define a matrix strategy"); + assert.match( + workflow, + /os:\s*\[ubuntu-latest,\s*macos-latest,\s*windows-latest\]/, + "workflow should verify on ubuntu, macOS, and Windows", + ); + assert.match( + workflow, + /node-version:\s*\[18,\s*20,\s*24\]/, + "workflow should verify on Node 18, 20, and 24", + ); + assert.match(workflow, /needs:\s*verify/, "publish job should wait for the verification matrix"); + assert.match( + workflow, + /if:\s*github\.event_name\s*!=\s*'pull_request'/, + "publish job should not push images from pull request runs", + ); +});