lint-load-manifest.mjs
1 import fs from "node:fs/promises"; 2 import path from "node:path"; 3 import { fileURLToPath } from "node:url"; 4 5 const scriptPath = fileURLToPath(import.meta.url); 6 const scriptDir = path.dirname(scriptPath); 7 const rootDir = path.resolve(scriptDir, ".."); 8 const manifestPath = path.join(rootDir, "load-manifest.json"); 9 const skillPath = path.join(rootDir, "SKILL.md"); 10 11 const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8")); 12 const skillText = await fs.readFile(skillPath, "utf8"); 13 14 function collectPaths(node, acc = new Set()) { 15 if (typeof node === "string") { 16 if ( 17 node.startsWith("references/") || 18 node.startsWith("templates/") || 19 node.startsWith("scripts/") 20 ) { 21 acc.add(node); 22 } 23 return acc; 24 } 25 26 if (Array.isArray(node)) { 27 for (const item of node) { 28 collectPaths(item, acc); 29 } 30 return acc; 31 } 32 33 if (!node || typeof node !== "object") { 34 return acc; 35 } 36 37 for (const value of Object.values(node)) { 38 collectPaths(value, acc); 39 } 40 41 return acc; 42 } 43 44 async function walkFiles(relativeDir) { 45 const dir = path.join(rootDir, relativeDir); 46 const results = []; 47 48 async function walk(currentDir) { 49 const entries = await fs.readdir(currentDir, { withFileTypes: true }); 50 51 for (const entry of entries) { 52 const absolutePath = path.join(currentDir, entry.name); 53 const relativePath = path.relative(rootDir, absolutePath); 54 55 if (entry.isDirectory()) { 56 await walk(absolutePath); 57 continue; 58 } 59 60 results.push(relativePath); 61 } 62 } 63 64 await walk(dir); 65 return results.sort(); 66 } 67 68 function collectSkillPathReferences(text) { 69 const candidates = new Set(); 70 const backtickPattern = /`([^`]+)`/g; 71 72 for (const match of text.matchAll(backtickPattern)) { 73 const value = match[1].trim(); 74 if ( 75 !value || 76 value.includes("<") || 77 value.includes(">") || 78 value.includes("://") || 79 value.startsWith("__") 80 ) { 81 continue; 82 } 83 84 if ( 85 /(^|\/)[A-Za-z0-9._-]+\.(md|jsx|js|css|mjs|sh|yaml|json)$/.test(value) 86 ) { 87 candidates.add(value); 88 } 89 } 90 91 return [...candidates].sort(); 92 } 93 94 const manifestPaths = collectPaths(manifest); 95 const referencePaths = (await walkFiles("references")).filter((file) => 96 file.endsWith(".md") 97 ); 98 const templatePaths = await walkFiles("templates"); 99 100 const missingPaths = []; 101 for (const relativePath of manifestPaths) { 102 try { 103 await fs.access(path.join(rootDir, relativePath)); 104 } catch { 105 missingPaths.push(relativePath); 106 } 107 } 108 109 const coveredRuntimePaths = new Set( 110 [...manifestPaths].filter( 111 (file) => file.startsWith("references/") || file.startsWith("templates/") 112 ) 113 ); 114 115 const skillPathReferences = collectSkillPathReferences(skillText); 116 const invalidSkillReferences = []; 117 for (const relativePath of skillPathReferences) { 118 try { 119 await fs.access(path.join(rootDir, relativePath)); 120 } catch { 121 invalidSkillReferences.push(relativePath); 122 } 123 } 124 125 const undocumentedCheckpoints = Object.keys(manifest.checkpoints ?? {}).filter( 126 (checkpoint) => !skillText.includes(checkpoint) 127 ); 128 129 const missingDescriptions = []; 130 for (const group of ["taskTypes", "checkpoints", "optionalInspirations"]) { 131 for (const [name, config] of Object.entries(manifest[group] ?? {})) { 132 if (!config.description) { 133 missingDescriptions.push(`${group}.${name}`); 134 } 135 } 136 } 137 138 const uncoveredReferences = referencePaths.filter( 139 (file) => !coveredRuntimePaths.has(file) 140 ); 141 const uncoveredTemplates = templatePaths.filter( 142 (file) => !coveredRuntimePaths.has(file) 143 ); 144 145 if ( 146 missingPaths.length === 0 && 147 uncoveredReferences.length === 0 && 148 uncoveredTemplates.length === 0 && 149 invalidSkillReferences.length === 0 && 150 undocumentedCheckpoints.length === 0 && 151 missingDescriptions.length === 0 152 ) { 153 console.log("load-manifest OK"); 154 process.exit(0); 155 } 156 157 if (missingPaths.length > 0) { 158 console.error("Missing manifest paths:"); 159 for (const file of missingPaths) { 160 console.error(`- ${file}`); 161 } 162 } 163 164 if (uncoveredReferences.length > 0) { 165 console.error("Untracked reference files:"); 166 for (const file of uncoveredReferences) { 167 console.error(`- ${file}`); 168 } 169 } 170 171 if (uncoveredTemplates.length > 0) { 172 console.error("Untracked template files:"); 173 for (const file of uncoveredTemplates) { 174 console.error(`- ${file}`); 175 } 176 } 177 178 if (invalidSkillReferences.length > 0) { 179 console.error("Invalid SKILL.md file references:"); 180 for (const file of invalidSkillReferences) { 181 console.error(`- ${file}`); 182 } 183 } 184 185 if (undocumentedCheckpoints.length > 0) { 186 console.error("Checkpoint reasons missing from SKILL.md:"); 187 for (const checkpoint of undocumentedCheckpoints) { 188 console.error(`- ${checkpoint}`); 189 } 190 } 191 192 if (missingDescriptions.length > 0) { 193 console.error("Bundles missing description field:"); 194 for (const key of missingDescriptions) { 195 console.error(`- ${key}`); 196 } 197 } 198 199 process.exit(1);