Files
vercel__vercel-plugin/scripts/build-manifest.ts

234 lines
7.6 KiB
TypeScript

#!/usr/bin/env bun
/**
* Build-time script that generates a static skill manifest from SKILL.md
* frontmatter. The PreToolUse hook reads this manifest instead of scanning
* and parsing every SKILL.md on each invocation.
*
* Usage: bun run scripts/build-manifest.ts
* node scripts/build-manifest.ts (also works via bun shim)
*/
import { resolve, join } from "node:path";
import { writeFileSync, mkdirSync } from "node:fs";
// Import the canonical skill-map builder (ESM)
import { globToRegex, importPatternToRegex } from "../hooks/patterns.mjs";
import type { SkillEntry, ManifestSkill } from "../hooks/patterns.mjs";
import type { ChainToRule, ValidationRule } from "../hooks/skill-map-frontmatter.mjs";
import { loadValidatedSkillMap } from "../src/shared/skill-map-loader.ts";
export { buildManifest, writeManifestFile, synthesizeChainToFromValidate };
const ROOT = resolve(import.meta.dir, "..");
const SKILLS_DIR = join(ROOT, "skills");
const OUT_DIR = join(ROOT, "generated");
const OUT_FILE = join(OUT_DIR, "skill-manifest.json");
interface ManifestSkillWithBody extends ManifestSkill {
bodyPath: string;
}
interface Manifest {
generatedAt: string;
version: 2;
skills: Record<string, ManifestSkillWithBody>;
}
/**
* Compile regex sources for a skill config at build time.
* Path globs → globToRegex().source, bash patterns → RegExp source,
* import patterns → importPatternToRegex() source+flags.
*
* Returns paired arrays: patterns and regex sources stay in sync so that
* index N of pathPatterns always corresponds to index N of pathRegexSources.
* Invalid patterns are dropped from both arrays to prevent index drift.
*/
function compileRegexSources(config: SkillEntry) {
const pathPatterns: string[] = [];
const pathRegexSources: string[] = [];
for (const p of config.pathPatterns) {
try {
pathRegexSources.push(globToRegex(p).source);
pathPatterns.push(p);
} catch {
// Skip invalid — validation catches these
}
}
const bashPatterns: string[] = [];
const bashRegexSources: string[] = [];
for (const p of config.bashPatterns) {
try {
new RegExp(p); // validate
bashRegexSources.push(p);
bashPatterns.push(p);
} catch {
// Skip invalid
}
}
const importPatterns: string[] = [];
const importRegexSources: Array<{ source: string; flags: string }> = [];
for (const p of config.importPatterns) {
try {
const re = importPatternToRegex(p);
importRegexSources.push({ source: re.source, flags: re.flags });
importPatterns.push(p);
} catch {
// Skip invalid
}
}
return { pathPatterns, pathRegexSources, bashPatterns, bashRegexSources, importPatterns, importRegexSources };
}
/**
* Auto-synthesize chainTo entries from validate rules that have upgradeToSkill
* with severity "error" or "recommended", unless a matching chainTo already
* exists for that targetSkill in the same skill.
*
* Returns the number of synthesized entries added.
*/
function synthesizeChainToFromValidate(
skills: Record<string, SkillEntry>,
allSlugs: Set<string>,
): { count: number; warnings: string[] } {
let count = 0;
const warnings: string[] = [];
for (const [slug, config] of Object.entries(skills)) {
if (!config.validate?.length) continue;
// Collect existing chainTo targets for this skill
const existingTargets = new Set(
(config.chainTo ?? []).map((c: ChainToRule) => c.targetSkill),
);
for (const rule of config.validate as ValidationRule[]) {
if (!rule.upgradeToSkill) continue;
if (rule.severity !== "error" && rule.severity !== "recommended") continue;
if (existingTargets.has(rule.upgradeToSkill)) continue;
if (!allSlugs.has(rule.upgradeToSkill)) {
warnings.push(
`skill "${slug}": cannot synthesize chainTo for upgradeToSkill "${rule.upgradeToSkill}" — target skill does not exist`,
);
continue;
}
const message =
rule.upgradeWhy ||
`${rule.message} — loading ${rule.upgradeToSkill} guidance.`;
const synthesized: ChainToRule = {
pattern: rule.pattern,
targetSkill: rule.upgradeToSkill,
message,
synthesized: true,
};
if (!config.chainTo) {
config.chainTo = [];
}
config.chainTo.push(synthesized);
existingTargets.add(rule.upgradeToSkill);
count++;
}
}
return { count, warnings };
}
/**
* Build the skill manifest object from the skills directory.
* Exported so validate.ts can reuse this without duplicating logic.
*/
function buildManifest(skillsDir: string): { manifest: Manifest; warnings: string[]; errors: string[] } {
const { validation, buildDiagnostics } = loadValidatedSkillMap(skillsDir);
const allWarnings: string[] = [...buildDiagnostics];
if (!validation.ok) {
return { manifest: null as any, warnings: allWarnings, errors: validation.errors };
}
if (validation.warnings?.length) {
allWarnings.push(...validation.warnings);
}
// Auto-synthesize chainTo from upgradeToSkill validate rules
const normalizedSkills = validation.normalizedSkillMap.skills as Record<string, SkillEntry>;
const allSlugs = new Set(Object.keys(normalizedSkills));
const { count: synthCount, warnings: synthWarnings } =
synthesizeChainToFromValidate(normalizedSkills, allSlugs);
allWarnings.push(...synthWarnings);
if (synthCount > 0) {
console.error(` ⤳ Synthesized ${synthCount} chainTo rule(s) from upgradeToSkill validate rules`);
}
const skills: Record<string, ManifestSkillWithBody> = {};
for (const [slug, config] of Object.entries(normalizedSkills) as [string, SkillEntry][]) {
const { pathPatterns, pathRegexSources, bashPatterns, bashRegexSources, importPatterns, importRegexSources } = compileRegexSources(config);
skills[slug] = {
priority: config.priority,
summary: config.summary,
docs: config.docs,
...(config.sitemap ? { sitemap: config.sitemap } : {}),
pathPatterns,
bashPatterns,
importPatterns,
bodyPath: `skills/${slug}/SKILL.md`,
pathRegexSources,
bashRegexSources,
importRegexSources,
...(config.validate?.length ? { validate: config.validate } : {}),
...(config.chainTo?.length ? { chainTo: config.chainTo } : {}),
...(config.promptSignals ? { promptSignals: config.promptSignals } : {}),
...(config.retrieval ? { retrieval: config.retrieval } : {}),
};
}
const manifest: Manifest = {
generatedAt: new Date().toISOString(),
version: 2,
skills,
};
return { manifest, warnings: allWarnings, errors: [] };
}
/**
* Write the manifest JSON to generated/skill-manifest.json.
* Returns the number of skills written.
*/
function writeManifestFile(manifest: Manifest, outDir = OUT_DIR, outFile = OUT_FILE): number {
mkdirSync(outDir, { recursive: true });
writeFileSync(outFile, JSON.stringify(manifest, null, 2) + "\n");
return Object.keys(manifest.skills).length;
}
// ---------------------------------------------------------------------------
// CLI entry point (only when run directly)
// ---------------------------------------------------------------------------
function isMain() {
try {
return resolve(process.argv[1] || "") === resolve(import.meta.filename);
} catch {
return false;
}
}
if (isMain()) {
const { manifest, warnings, errors } = buildManifest(SKILLS_DIR);
for (const w of warnings) console.warn(`[warn] ${w}`);
if (errors.length > 0) {
console.error("[error] Skill map validation failed:");
for (const e of errors) console.error(` - ${e}`);
process.exit(1);
}
const count = writeManifestFile(manifest);
console.log(`✓ Wrote ${count} skills to ${OUT_FILE}`);
}