singularity-forge/scripts/check-skill-references.mjs
2026-05-05 14:46:18 +02:00

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);
}