166 lines
4.7 KiB
JavaScript
166 lines
4.7 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
/**
|
|
* Validates that relative .md file references in bundled skills point to
|
|
* files that actually exist on disk.
|
|
*
|
|
* Focused on catching broken cross-file references within skills:
|
|
* - Markdown links to .md files: [text](path/to/file.md)
|
|
* - Backtick-quoted .md paths that use relative navigation: `../foo/bar.md`
|
|
* or skill subdirectory paths: `references/foo.md`, `workflows/bar.md`
|
|
*
|
|
* Deliberately ignores:
|
|
* - URLs (http://, https://)
|
|
* - Paths starting with ~ (home-dir references, not repo-relative)
|
|
* - Glob patterns containing * or {}
|
|
* - Template placeholders containing {{ or {word}
|
|
* - Bare extensions like `.md`, `.ts`
|
|
* - Example/placeholder paths (path/to/...)
|
|
* - Paths that reference files outside the skills tree via ../ beyond the
|
|
* skills root (those are cross-concern refs, not validatable here)
|
|
*
|
|
* Exit 0 if all references resolve. Exit 1 if any are broken.
|
|
*/
|
|
|
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
import { dirname, extname, join, resolve } from "node:path";
|
|
|
|
const SKILLS_DIR = resolve("src/resources/skills");
|
|
|
|
/** Recursively collect all .md files under a directory. */
|
|
function collectMdFiles(dir) {
|
|
const results = [];
|
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
const full = join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
results.push(...collectMdFiles(full));
|
|
} else if (entry.isFile() && extname(entry.name) === ".md") {
|
|
results.push(full);
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
|
|
/** Return true if this reference should be validated. */
|
|
function shouldValidate(ref) {
|
|
// Must end with .md (we only validate markdown cross-references)
|
|
if (!ref.endsWith(".md")) return false;
|
|
// Skip URLs
|
|
if (/^https?:\/\//.test(ref)) return false;
|
|
// Skip home-dir paths
|
|
if (ref.startsWith("~")) return false;
|
|
// Skip glob patterns
|
|
if (/[*{}]/.test(ref)) return false;
|
|
// Skip template placeholders like {{foo}} or {foo}
|
|
if (/\{[^}]+\}/.test(ref)) return false;
|
|
// Skip bare extensions like ".md"
|
|
if (/^\.\w+$/.test(ref)) return false;
|
|
// Skip obvious example paths
|
|
if (/^path\/to\//.test(ref)) return false;
|
|
// Skip absolute paths
|
|
if (ref.startsWith("/")) return false;
|
|
// Only validate paths that look like structural skill references:
|
|
// relative navigation (../ or ./) or skill subdirectories (references/, workflows/)
|
|
if (
|
|
!ref.startsWith("./") &&
|
|
!ref.startsWith("../") &&
|
|
!ref.startsWith("references/") &&
|
|
!ref.startsWith("workflows/") &&
|
|
!ref.startsWith("scripts/") &&
|
|
!ref.startsWith("templates/")
|
|
) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/** Strip trailing anchor: foo.md#section -> foo.md */
|
|
function stripAnchor(ref) {
|
|
const idx = ref.indexOf("#");
|
|
return idx >= 0 ? ref.slice(0, idx) : ref;
|
|
}
|
|
|
|
/**
|
|
* Extract validatable .md references from markdown content.
|
|
* Returns array of { ref, line }.
|
|
*/
|
|
function extractReferences(content) {
|
|
const refs = [];
|
|
const lines = content.split("\n");
|
|
|
|
let inCodeBlock = false;
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
const lineNum = i + 1;
|
|
|
|
// Track fenced code blocks (``` or ~~~)
|
|
if (/^(\s*)(`{3,}|~{3,})/.test(line)) {
|
|
inCodeBlock = !inCodeBlock;
|
|
continue;
|
|
}
|
|
if (inCodeBlock) continue;
|
|
|
|
// Pattern 1: Markdown links [text](path.md) or [text](path.md#anchor)
|
|
const mdLinkRe = /\[(?:[^\]]*)\]\(([^)]+)\)/g;
|
|
let match;
|
|
while ((match = mdLinkRe.exec(line)) !== null) {
|
|
const raw = stripAnchor(match[1].trim());
|
|
if (shouldValidate(raw)) {
|
|
refs.push({ ref: raw, line: lineNum });
|
|
}
|
|
}
|
|
|
|
// Pattern 2: Backtick-quoted paths to .md files
|
|
const backtickRe = /`([^`]+\.md(?:#[^`]*)?)`/g;
|
|
while ((match = backtickRe.exec(line)) !== null) {
|
|
const raw = stripAnchor(match[1].trim());
|
|
if (shouldValidate(raw)) {
|
|
refs.push({ ref: raw, line: lineNum });
|
|
}
|
|
}
|
|
}
|
|
|
|
return refs;
|
|
}
|
|
|
|
// --- Main ---
|
|
|
|
if (!existsSync(SKILLS_DIR)) {
|
|
console.error(`Skills directory not found: ${SKILLS_DIR}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const mdFiles = collectMdFiles(SKILLS_DIR);
|
|
let brokenCount = 0;
|
|
let checkedCount = 0;
|
|
|
|
for (const file of mdFiles) {
|
|
const content = readFileSync(file, "utf-8");
|
|
const refs = extractReferences(content);
|
|
const fileDir = dirname(file);
|
|
const displayPath = file.replace(resolve(".") + "/", "");
|
|
|
|
for (const { ref, line } of refs) {
|
|
checkedCount++;
|
|
const resolved = resolve(fileDir, ref);
|
|
if (!existsSync(resolved)) {
|
|
console.error(
|
|
`ERROR: ${displayPath}:${line} references "${ref}" but file does not exist`,
|
|
);
|
|
brokenCount++;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (brokenCount > 0) {
|
|
console.error(
|
|
`\n${brokenCount} broken reference(s) found across ${mdFiles.length} skill files.`,
|
|
);
|
|
process.exit(1);
|
|
} else {
|
|
console.log(
|
|
`All references valid. Checked ${checkedCount} reference(s) across ${mdFiles.length} skill file(s).`,
|
|
);
|
|
process.exit(0);
|
|
}
|