playbook/outfitter-agents/scripts/lint-related-skills.ts

362 lines
10 KiB
TypeScript
Executable File

#!/usr/bin/env bun
/**
* Lint metadata.related-skills bidirectionality across all SKILL.md files.
*
* Validates that if skill A lists skill B in metadata.related-skills,
* skill B must also list skill A.
*
* Usage:
* bun scripts/lint-related-skills.ts [path]
*
* Examples:
* bun scripts/lint-related-skills.ts # Lint all SKILL.md files
* bun scripts/lint-related-skills.ts outfitter/ # Lint specific plugin
*/
import { Glob } from "bun";
import { statSync } from "node:fs";
import { basename, dirname, resolve } from "node:path";
/**
* Parsed skill metadata from SKILL.md frontmatter.
*/
interface SkillInfo {
/** File path to SKILL.md */
path: string;
/** Skill name from frontmatter or directory name */
name: string;
/** Related skills listed in metadata */
relatedSkills: string[];
}
/**
* A bidirectionality violation in related-skills metadata.
*/
interface Violation {
/** Skill that declares the relationship */
sourceSkill: string;
/** Path to source skill's SKILL.md */
sourcePath: string;
/** Skill referenced in related-skills */
targetSkill: string;
/** Description of the violation */
message: string;
}
/**
* Get the indentation level of a line (number of spaces).
*/
function getIndent(line: string): number {
const match = line.match(/^(\s*)/);
return match ? match[1].length : 0;
}
/**
* Parse YAML frontmatter from markdown content.
* Handles nested structures like metadata.related-skills.
*/
function parseYamlFrontmatter(content: string): Record<string, unknown> | null {
const match = content.match(/^---\n([\s\S]*?)\n---/);
if (!match) return null;
const lines = match[1].split("\n");
const result: Record<string, unknown> = {};
const stack: Array<{ indent: number; obj: Record<string, unknown>; key?: string }> = [
{ indent: -1, obj: result },
];
let currentArray: string[] | null = null;
let arrayIndent = -1;
for (const line of lines) {
// Skip empty lines
if (line.trim() === "") continue;
const indent = getIndent(line);
const trimmed = line.trim();
// Check for array item
if (trimmed.startsWith("- ")) {
const value = trimmed.slice(2).trim();
if (currentArray !== null) {
currentArray.push(value);
}
continue;
}
// If we were collecting an array, we're done with it
if (currentArray !== null && indent <= arrayIndent) {
currentArray = null;
arrayIndent = -1;
}
// Parse key: value
const colonIndex = trimmed.indexOf(":");
if (colonIndex === -1) continue;
const key = trimmed.slice(0, colonIndex).trim();
const value = trimmed.slice(colonIndex + 1).trim();
// Pop stack until we find the right parent
while (stack.length > 1 && stack[stack.length - 1].indent >= indent) {
stack.pop();
}
const parent = stack[stack.length - 1].obj;
if (value === "" || value === "|" || value === ">") {
// This could be a nested object or an array
// We'll find out on the next line
const newObj: Record<string, unknown> = {};
parent[key] = newObj;
stack.push({ indent, obj: newObj, key });
// Check next lines to see if it's an array
const lineIndex = lines.indexOf(line);
if (lineIndex < lines.length - 1) {
const nextLine = lines[lineIndex + 1];
if (nextLine.trim().startsWith("- ")) {
// It's an array
currentArray = [];
arrayIndent = indent;
parent[key] = currentArray;
stack.pop(); // Don't need the object
}
}
} else {
parent[key] = value;
}
}
return result;
}
/**
* Extract skill name from frontmatter or derive from path.
*/
function getSkillName(frontmatter: Record<string, unknown>, skillPath: string): string {
// Prefer name from frontmatter
if (typeof frontmatter.name === "string" && frontmatter.name) {
return frontmatter.name;
}
// Fall back to directory name (parent of SKILL.md)
return basename(dirname(skillPath));
}
/**
* Extract related-skills array from frontmatter.
* Looks in metadata.related-skills (nested structure).
*/
function getRelatedSkills(frontmatter: Record<string, unknown>): string[] {
// Check metadata.related-skills (the nested format)
const metadata = frontmatter.metadata;
if (metadata && typeof metadata === "object" && !Array.isArray(metadata)) {
const metadataObj = metadata as Record<string, unknown>;
const related = metadataObj["related-skills"];
if (Array.isArray(related)) {
return related.map((s) => String(s).trim()).filter((s) => s.length > 0);
}
}
// Also check top-level related_skills for backwards compatibility
const topLevel = frontmatter["related-skills"] || frontmatter.related_skills;
if (Array.isArray(topLevel)) {
return topLevel.map((s) => String(s).trim()).filter((s) => s.length > 0);
}
return [];
}
/**
* Find all SKILL.md files and parse their metadata.
*/
async function findSkills(searchPath: string): Promise<SkillInfo[]> {
const skills: SkillInfo[] = [];
const glob = new Glob("**/SKILL.md");
for await (const file of glob.scan({
cwd: searchPath,
absolute: true,
onlyFiles: true,
})) {
// Skip common excludes
if (
file.includes("node_modules") ||
file.includes(".git") ||
file.includes(".beads") ||
file.includes("templates/") ||
file.includes(".archive")
) {
continue;
}
try {
const content = await Bun.file(file).text();
const frontmatter = parseYamlFrontmatter(content);
if (!frontmatter) {
continue;
}
const name = getSkillName(frontmatter, file);
const relatedSkills = getRelatedSkills(frontmatter);
skills.push({
path: file,
name,
relatedSkills,
});
} catch (error) {
console.error(`Warning: Failed to parse ${file}: ${error}`);
}
}
return skills;
}
/**
* Build a map of skill name -> SkillInfo for quick lookups.
*/
function buildSkillMap(skills: SkillInfo[]): Map<string, SkillInfo> {
const map = new Map<string, SkillInfo>();
for (const skill of skills) {
if (map.has(skill.name)) {
console.error(
`Warning: Duplicate skill name "${skill.name}" found at:\n` +
` - ${map.get(skill.name)!.path}\n` +
` - ${skill.path}`
);
}
map.set(skill.name, skill);
}
return map;
}
/**
* Validate bidirectional relationships.
*/
function validateBidirectionality(
skills: SkillInfo[],
skillMap: Map<string, SkillInfo>
): Violation[] {
const violations: Violation[] = [];
for (const skill of skills) {
for (const related of skill.relatedSkills) {
const targetSkill = skillMap.get(related);
if (!targetSkill) {
// Target skill doesn't exist - this is a warning, not a bidirectionality violation
violations.push({
sourceSkill: skill.name,
sourcePath: skill.path,
targetSkill: related,
message: `"${skill.name}" references non-existent skill "${related}"`,
});
continue;
}
// Check if target skill lists source skill
if (!targetSkill.relatedSkills.includes(skill.name)) {
violations.push({
sourceSkill: skill.name,
sourcePath: skill.path,
targetSkill: related,
message: `"${skill.name}" lists "${related}" but "${related}" does not list "${skill.name}"`,
});
}
}
}
return violations;
}
/**
* Format path relative to cwd for cleaner output.
*/
function relativePath(absolutePath: string): string {
return absolutePath.replace(process.cwd() + "/", "");
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
const paths = args.filter((arg) => !arg.startsWith("--"));
const searchPath = resolve(paths[0] || ".");
// Validate search path
try {
const stat = statSync(searchPath);
if (!stat.isDirectory()) {
console.error(`Error: ${searchPath} is not a directory`);
process.exit(1);
}
} catch {
console.error(`Error: Path not found: ${searchPath}`);
process.exit(1);
}
console.log(`\nScanning for SKILL.md files in ${relativePath(searchPath)}...\n`);
// Find all skills
const skills = await findSkills(searchPath);
const skillsWithRelated = skills.filter((s) => s.relatedSkills.length > 0);
console.log(`Found ${skills.length} skills, ${skillsWithRelated.length} with metadata.related-skills\n`);
if (skillsWithRelated.length === 0) {
console.log("No skills with metadata.related-skills found. Nothing to validate.\n");
process.exit(0);
}
// Build lookup map
const skillMap = buildSkillMap(skills);
// Validate bidirectionality
const violations = validateBidirectionality(skills, skillMap);
// Separate missing skills from bidirectionality violations
const missingSkills = violations.filter((v) => v.message.includes("non-existent"));
const bidirectionalViolations = violations.filter((v) => !v.message.includes("non-existent"));
// Report missing skill references
if (missingSkills.length > 0) {
console.log("Missing skill references:");
console.log("-".repeat(50));
for (const v of missingSkills) {
console.log(` ${relativePath(v.sourcePath)}:`);
console.log(` ${v.message}`);
}
console.log();
}
// Report bidirectionality violations
if (bidirectionalViolations.length > 0) {
console.log("Bidirectionality violations:");
console.log("-".repeat(50));
for (const v of bidirectionalViolations) {
console.log(` ${relativePath(v.sourcePath)}:`);
console.log(` ${v.message}`);
}
console.log();
}
// Summary
console.log("-".repeat(50));
if (violations.length === 0) {
console.log(`\nAll ${skillsWithRelated.length} skill relationships are bidirectional.\n`);
process.exit(0);
} else {
console.log(
`\nFound ${violations.length} issue(s): ` +
`${missingSkills.length} missing reference(s), ` +
`${bidirectionalViolations.length} bidirectionality violation(s)\n`
);
process.exit(1);
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});