mirror of
https://github.com/vercel/vercel-plugin.git
synced 2026-09-14 15:39:47 +08:00
8e24d0694a
Add a chainTo frontmatter field to skills that triggers follow-up skill injection when PostToolUse file contents match regex patterns. Add upgradeToSkill/upgradeWhy fields to validation rules so validate errors can recommend loading a specific skill. Register posttooluse-bash-chain.mjs in hooks.json. Add ChainToRule interface to patterns.mts and skill-map-frontmatter.mts, with parseChainToRules() parser. Add duplicate-key detection to the YAML parser. Reset dedup claim dir and session file on clear/compact events in session-start-seen-skills so skills re-inject into fresh context windows. Add chainTo rules to: agent-browser-verify, agent-browser, ai-elements, ai-gateway, ai-generation-persistence, ai-sdk, auth, bootstrap, chat-sdk, cms, cron-jobs, deployments-cicd, email, env-vars, geist, investigation-mode, json-render, marketplace, micro, ncc, next-forge, nextjs, observability, payments, react-best-practices, routing-middleware, runtime-cache, satori, shadcn, sign-in-with-vercel, swr, turbopack, turborepo, v0-dev, vercel-agent, vercel-api, vercel-cli, vercel-firewall, vercel-flags, vercel-functions, vercel-queues, vercel-sandbox, vercel-storage, verification, workflow. Add upgradeToSkill to ai-elements and ai-sdk validate rules. Expand ai-sdk validate messages with Run Skill() hints. Update nextjs, vercel-storage, runtime-cache, workflow, turborepo skill bodies. Add new skills: geistdocs (Geist design system docs), zzz-test-meta-name-mask (test fixture). Add skills/_chain-audit.md chain coverage audit doc. Delete .claude-plugin/marketplace.json, .claude-plugin/plugin.json (deprecated), skills/edge-runtime/SKILL.md (consolidated into vercel-functions). Add tests: posttooluse-chain.test.ts (4699 lines, chain injection e2e), ai-sdk-companion.test.ts (181 lines). Expand build-skill-map.test.ts (+335 lines), validate-rules.test.ts (+936 lines), session-start-seen-skills.test.ts (+74 lines), skill-map-frontmatter.test.ts (+50 lines), verification-skill.test.ts (+20 lines). Update build-manifest.ts to emit chainTo rules and upgradeToSkill fields. Rebuild generated/skill-manifest.json, generated/skill-catalog.md, generated/build-from-skills.manifest.json. Rebuild all compiled hooks/*.mjs. Update CLAUDE.md lexical prompt default to on. Update vercel.md ecosystem graph, docs, and cli-reference.
182 lines
6.8 KiB
TypeScript
182 lines
6.8 KiB
TypeScript
import { describe, test, expect, beforeEach } from "bun:test";
|
|
import { join, resolve } from "node:path";
|
|
|
|
const ROOT = resolve(import.meta.dirname, "..");
|
|
const HOOK_SCRIPT = join(ROOT, "hooks", "pretooluse-skill-inject.mjs");
|
|
|
|
let testSession: string;
|
|
const UNLIMITED_BUDGET = "999999";
|
|
|
|
beforeEach(() => {
|
|
testSession = `test-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
});
|
|
|
|
/**
|
|
* Extract skillInjection metadata from additionalContext.
|
|
*/
|
|
function extractSkillInjection(hookSpecificOutput: any): any {
|
|
const ctx = hookSpecificOutput?.additionalContext || "";
|
|
const match = ctx.match(/<!-- skillInjection: ({.*?}) -->/);
|
|
if (!match) return undefined;
|
|
try { return JSON.parse(match[1]); } catch { return undefined; }
|
|
}
|
|
|
|
async function runHook(
|
|
input: object,
|
|
env?: Record<string, string>,
|
|
): Promise<{ code: number; stdout: string; stderr: string; parsed: any }> {
|
|
const payload = JSON.stringify({ ...input, session_id: testSession });
|
|
const proc = Bun.spawn(["node", HOOK_SCRIPT], {
|
|
stdin: "pipe",
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
env: {
|
|
...process.env,
|
|
VERCEL_PLUGIN_INJECTION_BUDGET: UNLIMITED_BUDGET,
|
|
VERCEL_PLUGIN_SEEN_SKILLS: "",
|
|
...env,
|
|
},
|
|
});
|
|
proc.stdin.write(payload);
|
|
proc.stdin.end();
|
|
const code = await proc.exited;
|
|
const stdout = await new Response(proc.stdout).text();
|
|
const stderr = await new Response(proc.stderr).text();
|
|
let parsed: any;
|
|
try { parsed = JSON.parse(stdout); } catch { parsed = null; }
|
|
return { code, stdout, stderr, parsed };
|
|
}
|
|
|
|
describe("AI SDK companion injection (ai-elements)", () => {
|
|
// ai-elements has importPatterns for 'ai' and '@ai-sdk/*', so it already matches
|
|
// when those imports are present. The companion injection is a safety net for when
|
|
// ai-sdk matches but ai-elements doesn't (e.g., ai-sdk matched via path pattern
|
|
// on an API route file that was previously seen, then a new client .tsx is written
|
|
// without AI SDK imports but ai-sdk is already in rankedSkills via profiler boost).
|
|
|
|
test("ai-elements is injected alongside ai-sdk on client .tsx files with AI SDK imports", async () => {
|
|
const { parsed } = await runHook({
|
|
tool_name: "Write",
|
|
tool_input: {
|
|
file_path: "/project/src/components/editor-ai.tsx",
|
|
content: 'import { useChat } from "@ai-sdk/react";\nexport default function EditorAI() {}',
|
|
},
|
|
});
|
|
|
|
expect(parsed).not.toBeNull();
|
|
const meta = extractSkillInjection(parsed.hookSpecificOutput);
|
|
expect(meta).toBeDefined();
|
|
expect(meta.injectedSkills).toContain("ai-sdk");
|
|
expect(meta.injectedSkills).toContain("ai-elements");
|
|
});
|
|
|
|
test("ai-elements is injected on .jsx files too", async () => {
|
|
const { parsed } = await runHook({
|
|
tool_name: "Edit",
|
|
tool_input: {
|
|
file_path: "/project/src/components/editor-ai.jsx",
|
|
old_string: "old",
|
|
new_string: 'import { useChat } from "@ai-sdk/react";\nnew',
|
|
},
|
|
});
|
|
|
|
expect(parsed).not.toBeNull();
|
|
const meta = extractSkillInjection(parsed.hookSpecificOutput);
|
|
expect(meta).toBeDefined();
|
|
expect(meta.injectedSkills).toContain("ai-sdk");
|
|
expect(meta.injectedSkills).toContain("ai-elements");
|
|
});
|
|
|
|
test("does NOT co-inject ai-elements on API route files", async () => {
|
|
const { parsed } = await runHook({
|
|
tool_name: "Write",
|
|
tool_input: {
|
|
file_path: "/project/src/app/api/generate/route.ts",
|
|
content: 'import { streamText } from "ai";\nexport async function POST() {}',
|
|
},
|
|
});
|
|
|
|
expect(parsed).not.toBeNull();
|
|
const meta = extractSkillInjection(parsed.hookSpecificOutput);
|
|
// ai-elements should not have ai-sdk-companion trigger on server routes
|
|
if (meta?.reasons?.["ai-elements"]) {
|
|
expect(meta.reasons["ai-elements"].trigger).not.toBe("ai-sdk-companion");
|
|
}
|
|
});
|
|
|
|
test("does NOT co-inject ai-elements on server action files", async () => {
|
|
const { parsed } = await runHook({
|
|
tool_name: "Write",
|
|
tool_input: {
|
|
file_path: "/project/src/app/actions/generate.ts",
|
|
content: 'import { generateText } from "ai";\nexport async function generateAction() {}',
|
|
},
|
|
});
|
|
|
|
expect(parsed).not.toBeNull();
|
|
const meta = extractSkillInjection(parsed.hookSpecificOutput);
|
|
if (meta?.reasons?.["ai-elements"]) {
|
|
expect(meta.reasons["ai-elements"].trigger).not.toBe("ai-sdk-companion");
|
|
}
|
|
});
|
|
|
|
test("companion injects ai-elements as summary-only when already seen", async () => {
|
|
// When ai-elements is already seen AND doesn't match on its own patterns
|
|
// (no AI SDK imports, no chat/message path), the companion should still inject
|
|
// it as summary-only if ai-sdk is in rankedSkills.
|
|
// Use a file that triggers ai-sdk via profiler boost but not ai-elements directly.
|
|
const { parsed } = await runHook(
|
|
{
|
|
tool_name: "Write",
|
|
tool_input: {
|
|
file_path: "/project/src/components/editor-ai.tsx",
|
|
content: 'import { useChat } from "@ai-sdk/react";\nexport default function EditorAI() {}',
|
|
},
|
|
},
|
|
{ VERCEL_PLUGIN_SEEN_SKILLS: "ai-elements" },
|
|
);
|
|
|
|
expect(parsed).not.toBeNull();
|
|
const meta = extractSkillInjection(parsed.hookSpecificOutput);
|
|
expect(meta).toBeDefined();
|
|
expect(meta.injectedSkills).toContain("ai-sdk");
|
|
// ai-elements should be present (either via pattern match dedup bypass or companion dedup bypass)
|
|
// It may appear in summaryOnly
|
|
if (meta.summaryOnly?.includes("ai-elements")) {
|
|
expect(meta.summaryOnly).toContain("ai-elements");
|
|
}
|
|
});
|
|
|
|
test("does not duplicate when ai-elements already matched by own patterns", async () => {
|
|
// Use a path that matches BOTH ai-sdk (via import) and ai-elements (via path pattern)
|
|
const { parsed } = await runHook({
|
|
tool_name: "Write",
|
|
tool_input: {
|
|
file_path: "/project/src/components/chat.tsx",
|
|
content: 'import { useChat } from "@ai-sdk/react";\nexport default function Chat() {}',
|
|
},
|
|
});
|
|
|
|
expect(parsed).not.toBeNull();
|
|
const meta = extractSkillInjection(parsed.hookSpecificOutput);
|
|
expect(meta).toBeDefined();
|
|
expect(meta.injectedSkills).toContain("ai-elements");
|
|
// Should only appear once in injectedSkills
|
|
const count = meta.injectedSkills.filter((s: string) => s === "ai-elements").length;
|
|
expect(count).toBe(1);
|
|
});
|
|
|
|
test("does NOT trigger for non-Write/Edit tools", async () => {
|
|
const { parsed } = await runHook({
|
|
tool_name: "Read",
|
|
tool_input: { file_path: "/project/src/components/editor-ai.tsx" },
|
|
});
|
|
|
|
// Read tool should not trigger companion injection
|
|
const meta = extractSkillInjection(parsed?.hookSpecificOutput);
|
|
if (meta?.reasons?.["ai-elements"]) {
|
|
expect(meta.reasons["ai-elements"].trigger).not.toBe("ai-sdk-companion");
|
|
}
|
|
});
|
|
});
|