/ scripts / lint-load-manifest.mjs
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);