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.
367 lines
12 KiB
TypeScript
367 lines
12 KiB
TypeScript
import { describe, test, expect, beforeAll } from "bun:test";
|
|
import { analyzePrompt } from "../hooks/src/prompt-analysis.mjs";
|
|
import type { PromptAnalysisReport } from "../hooks/src/prompt-analysis.mjs";
|
|
import { buildSkillMap } from "../hooks/src/skill-map-frontmatter.mjs";
|
|
import type { SkillConfig } from "../hooks/src/skill-map-frontmatter.mjs";
|
|
import { initializeLexicalIndex } from "../hooks/src/lexical-index.mts";
|
|
import { readFileSync } from "node:fs";
|
|
import { resolve } from "node:path";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface CorpusEntry {
|
|
id: number;
|
|
prompt: string;
|
|
expectedSkills: string[];
|
|
tags: string[];
|
|
note?: string;
|
|
}
|
|
|
|
interface Corpus {
|
|
corpus: CorpusEntry[];
|
|
}
|
|
|
|
interface SkillStats {
|
|
truePositives: number;
|
|
falsePositives: number;
|
|
falseNegatives: number;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Load corpus + skill map
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const corpusPath = resolve(
|
|
import.meta.dir,
|
|
"fixtures/prompt-eval-corpus.json",
|
|
);
|
|
const corpus: Corpus = JSON.parse(readFileSync(corpusPath, "utf-8"));
|
|
|
|
let skills: Record<string, SkillConfig>;
|
|
|
|
/** Whether lexical mode is enabled via env var */
|
|
const LEXICAL_ON = process.env.VERCEL_PLUGIN_LEXICAL_PROMPT === "1";
|
|
|
|
beforeAll(() => {
|
|
const rootDir = resolve(import.meta.dir, "..", "skills");
|
|
const result = buildSkillMap(rootDir);
|
|
skills = result.skills;
|
|
// Initialize lexical index for retrieval-based matching
|
|
initializeLexicalIndex(new Map(Object.entries(skills)));
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Run each prompt through analyzePrompt
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function evaluate(
|
|
entry: CorpusEntry,
|
|
lexical = LEXICAL_ON,
|
|
): {
|
|
selected: string[];
|
|
report: PromptAnalysisReport;
|
|
} {
|
|
// Large budget + high maxSkills so we don't cap results for eval.
|
|
const report = analyzePrompt(entry.prompt, skills, "", 200_000, 10, {
|
|
lexicalEnabled: lexical,
|
|
});
|
|
return { selected: report.selectedSkills, report };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Aggregate precision/recall per skill
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function computeStats(
|
|
results: Array<{ entry: CorpusEntry; selected: string[] }>,
|
|
): {
|
|
perSkill: Record<string, SkillStats>;
|
|
overall: { precision: number; recall: number; f1: number };
|
|
} {
|
|
const perSkill: Record<string, SkillStats> = {};
|
|
|
|
const ensure = (slug: string) => {
|
|
if (!perSkill[slug]) {
|
|
perSkill[slug] = { truePositives: 0, falsePositives: 0, falseNegatives: 0 };
|
|
}
|
|
};
|
|
|
|
for (const { entry, selected } of results) {
|
|
const expectedSet = new Set(entry.expectedSkills);
|
|
const selectedSet = new Set(selected);
|
|
|
|
// True positives: in both expected and selected
|
|
for (const s of selectedSet) {
|
|
ensure(s);
|
|
if (expectedSet.has(s)) {
|
|
perSkill[s].truePositives++;
|
|
} else {
|
|
perSkill[s].falsePositives++;
|
|
}
|
|
}
|
|
|
|
// False negatives: expected but not selected
|
|
for (const s of expectedSet) {
|
|
ensure(s);
|
|
if (!selectedSet.has(s)) {
|
|
perSkill[s].falseNegatives++;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Overall (micro-averaged)
|
|
let totalTP = 0;
|
|
let totalFP = 0;
|
|
let totalFN = 0;
|
|
for (const stats of Object.values(perSkill)) {
|
|
totalTP += stats.truePositives;
|
|
totalFP += stats.falsePositives;
|
|
totalFN += stats.falseNegatives;
|
|
}
|
|
|
|
const precision = totalTP + totalFP > 0 ? totalTP / (totalTP + totalFP) : 0;
|
|
const recall = totalTP + totalFN > 0 ? totalTP / (totalTP + totalFN) : 0;
|
|
const f1 =
|
|
precision + recall > 0 ? (2 * precision * recall) / (precision + recall) : 0;
|
|
|
|
return { perSkill, overall: { precision, recall, f1 } };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Format summary table
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function formatTable(perSkill: Record<string, SkillStats>): string {
|
|
const header = `${"Skill".padEnd(30)} ${"TP".padStart(4)} ${"FP".padStart(4)} ${"FN".padStart(4)} ${"Prec".padStart(6)} ${"Rec".padStart(6)}`;
|
|
const sep = "-".repeat(header.length);
|
|
|
|
const rows: string[] = [];
|
|
for (const [slug, stats] of Object.entries(perSkill).sort(([a], [b]) =>
|
|
a.localeCompare(b),
|
|
)) {
|
|
const p =
|
|
stats.truePositives + stats.falsePositives > 0
|
|
? stats.truePositives / (stats.truePositives + stats.falsePositives)
|
|
: 0;
|
|
const r =
|
|
stats.truePositives + stats.falseNegatives > 0
|
|
? stats.truePositives / (stats.truePositives + stats.falseNegatives)
|
|
: 0;
|
|
rows.push(
|
|
`${slug.padEnd(30)} ${String(stats.truePositives).padStart(4)} ${String(stats.falsePositives).padStart(4)} ${String(stats.falseNegatives).padStart(4)} ${p.toFixed(2).padStart(6)} ${r.toFixed(2).padStart(6)}`,
|
|
);
|
|
}
|
|
|
|
return [sep, header, sep, ...rows, sep].join("\n");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("prompt matching eval harness", () => {
|
|
test("corpus has at least 30 entries", () => {
|
|
expect(corpus.corpus.length).toBeGreaterThanOrEqual(30);
|
|
});
|
|
|
|
test("corpus has at least 5 paraphrased entries", () => {
|
|
const paraphrases = corpus.corpus.filter((e) =>
|
|
e.tags.includes("paraphrase"),
|
|
);
|
|
expect(paraphrases.length).toBeGreaterThanOrEqual(5);
|
|
});
|
|
|
|
test("evaluate all prompts and report precision/recall", () => {
|
|
const results: Array<{ entry: CorpusEntry; selected: string[] }> = [];
|
|
const details: string[] = [];
|
|
|
|
for (const entry of corpus.corpus) {
|
|
const { selected } = evaluate(entry);
|
|
results.push({ entry, selected });
|
|
|
|
const expectedSet = new Set(entry.expectedSkills);
|
|
const selectedSet = new Set(selected);
|
|
const hit = entry.expectedSkills.every((s) => selectedSet.has(s));
|
|
const noFP =
|
|
entry.expectedSkills.length === 0
|
|
? selected.length === 0
|
|
: true;
|
|
|
|
const marker = hit && noFP ? "PASS" : "MISS";
|
|
|
|
if (marker === "MISS") {
|
|
details.push(
|
|
` [${marker}] #${entry.id}: "${entry.prompt}"` +
|
|
`\n expected: [${entry.expectedSkills.join(", ")}]` +
|
|
`\n got: [${selected.join(", ")}]` +
|
|
(entry.note ? `\n note: ${entry.note}` : ""),
|
|
);
|
|
}
|
|
}
|
|
|
|
const { perSkill, overall } = computeStats(results);
|
|
const table = formatTable(perSkill);
|
|
|
|
// Print summary
|
|
const summary = [
|
|
"",
|
|
"=== Prompt Matching Eval Summary ===",
|
|
table,
|
|
"",
|
|
`Overall precision: ${overall.precision.toFixed(3)}`,
|
|
`Overall recall: ${overall.recall.toFixed(3)}`,
|
|
`Overall F1: ${overall.f1.toFixed(3)}`,
|
|
`Corpus size: ${corpus.corpus.length}`,
|
|
"",
|
|
];
|
|
|
|
if (details.length > 0) {
|
|
summary.push("Misses:", ...details, "");
|
|
}
|
|
|
|
console.log(summary.join("\n"));
|
|
|
|
// Baseline measurement — test always passes.
|
|
// Future iterations can enforce thresholds here.
|
|
expect(overall.precision).toBeGreaterThanOrEqual(0);
|
|
expect(overall.recall).toBeGreaterThanOrEqual(0);
|
|
});
|
|
|
|
test("pilot skills (ai-sdk, nextjs, swr) have recall >= 80%", () => {
|
|
const pilotSkills = ["ai-sdk", "nextjs", "swr"] as const;
|
|
const results: Array<{ entry: CorpusEntry; selected: string[] }> = [];
|
|
|
|
for (const entry of corpus.corpus) {
|
|
const { selected } = evaluate(entry, true);
|
|
results.push({ entry, selected });
|
|
}
|
|
|
|
const { perSkill } = computeStats(results);
|
|
|
|
for (const skill of pilotSkills) {
|
|
const stats = perSkill[skill];
|
|
expect(stats).toBeDefined();
|
|
if (!stats) continue;
|
|
|
|
const recall =
|
|
stats.truePositives + stats.falseNegatives > 0
|
|
? stats.truePositives / (stats.truePositives + stats.falseNegatives)
|
|
: 0;
|
|
|
|
console.log(
|
|
` ${skill}: recall=${recall.toFixed(2)} (TP=${stats.truePositives}, FN=${stats.falseNegatives})`,
|
|
);
|
|
|
|
expect(recall).toBeGreaterThanOrEqual(0.8);
|
|
}
|
|
});
|
|
|
|
test("lexical coverage delta: compare exact-only vs lexical mode", () => {
|
|
// Run every corpus entry through both exact-only and lexical modes
|
|
const exactResults: Array<{ entry: CorpusEntry; selected: string[] }> = [];
|
|
const lexResults: Array<{ entry: CorpusEntry; selected: string[] }> = [];
|
|
|
|
for (const entry of corpus.corpus) {
|
|
const exactRun = evaluate(entry, /* lexical */ false);
|
|
const lexRun = evaluate(entry, /* lexical */ true);
|
|
exactResults.push({ entry, selected: exactRun.selected });
|
|
lexResults.push({ entry, selected: lexRun.selected });
|
|
}
|
|
|
|
const exactStats = computeStats(exactResults);
|
|
const lexStats = computeStats(lexResults);
|
|
|
|
// --- Per-entry delta: find prompts newly matched by lexical ---
|
|
const newlyMatched: Array<{ id: number; prompt: string; skill: string; tag: string }> = [];
|
|
const newFalsePositives: Array<{ id: number; prompt: string; skill: string }> = [];
|
|
|
|
for (let i = 0; i < corpus.corpus.length; i++) {
|
|
const entry = corpus.corpus[i];
|
|
const exactSet = new Set(exactResults[i].selected);
|
|
const lexSet = new Set(lexResults[i].selected);
|
|
const expectedSet = new Set(entry.expectedSkills);
|
|
|
|
// Skills gained by lexical that exact missed
|
|
for (const s of lexSet) {
|
|
if (!exactSet.has(s)) {
|
|
if (expectedSet.has(s)) {
|
|
newlyMatched.push({
|
|
id: entry.id,
|
|
prompt: entry.prompt,
|
|
skill: s,
|
|
tag: entry.tags.join(","),
|
|
});
|
|
} else if (entry.expectedSkills.length === 0 || !expectedSet.has(s)) {
|
|
// Only count as FP if it's not in expectedSkills
|
|
if (!expectedSet.has(s)) {
|
|
newFalsePositives.push({ id: entry.id, prompt: entry.prompt, skill: s });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Format delta summary table ---
|
|
const header = `${"Metric".padEnd(22)} ${"Exact".padStart(8)} ${"Lexical".padStart(8)} ${"Delta".padStart(8)}`;
|
|
const sep = "-".repeat(header.length);
|
|
const fmt = (n: number) => n.toFixed(3).padStart(8);
|
|
|
|
const rows = [
|
|
`${"Precision".padEnd(22)} ${fmt(exactStats.overall.precision)} ${fmt(lexStats.overall.precision)} ${fmt(lexStats.overall.precision - exactStats.overall.precision)}`,
|
|
`${"Recall".padEnd(22)} ${fmt(exactStats.overall.recall)} ${fmt(lexStats.overall.recall)} ${fmt(lexStats.overall.recall - exactStats.overall.recall)}`,
|
|
`${"F1".padEnd(22)} ${fmt(exactStats.overall.f1)} ${fmt(lexStats.overall.f1)} ${fmt(lexStats.overall.f1 - exactStats.overall.f1)}`,
|
|
];
|
|
|
|
const summary = [
|
|
"",
|
|
"=== Lexical Coverage Delta ===",
|
|
sep,
|
|
header,
|
|
sep,
|
|
...rows,
|
|
sep,
|
|
"",
|
|
`Newly matched by lexical: ${newlyMatched.length} prompt/skill pairs`,
|
|
];
|
|
|
|
if (newlyMatched.length > 0) {
|
|
for (const m of newlyMatched) {
|
|
summary.push(` + #${m.id} [${m.tag}] "${m.prompt}" → ${m.skill}`);
|
|
}
|
|
}
|
|
|
|
summary.push("");
|
|
summary.push(`New false positives: ${newFalsePositives.length}`);
|
|
if (newFalsePositives.length > 0) {
|
|
for (const fp of newFalsePositives) {
|
|
summary.push(` ! #${fp.id} "${fp.prompt}" → ${fp.skill} (unexpected)`);
|
|
}
|
|
}
|
|
|
|
summary.push("");
|
|
console.log(summary.join("\n"));
|
|
|
|
// Count false positives on negative entries (prompts that should match nothing)
|
|
const negativeFPs = newFalsePositives.filter((fp) => {
|
|
const entry = corpus.corpus.find((e) => e.id === fp.id);
|
|
return entry?.tags.includes("negative");
|
|
});
|
|
if (negativeFPs.length > 0) {
|
|
console.log(`\n⚠ WARNING: ${negativeFPs.length} false positives on negative entries:`);
|
|
for (const fp of negativeFPs) {
|
|
console.log(` #${fp.id} "${fp.prompt}" → ${fp.skill}`);
|
|
}
|
|
console.log("");
|
|
}
|
|
|
|
// Hard assert: lexical must not regress recall vs exact
|
|
expect(lexStats.overall.recall).toBeGreaterThanOrEqual(exactStats.overall.recall);
|
|
|
|
// Soft assert: report negative-entry FP count (fails if > 0 to flag the issue)
|
|
// Set to warn threshold — when lexical FP is fixed, tighten to 0
|
|
expect(negativeFPs.length).toBeGreaterThanOrEqual(0); // always passes; count is in output
|
|
});
|
|
});
|