← Files ForgeMindARCHIVED FILE

src/validate.mjs

5.41 KB · Oct 2, 2026 · 00:31 UTC

↓ Download file

import { access, readFile, readdir } from 'node:fs/promises';
import path from 'node:path';

import { parseSkillFrontmatter } from './frontmatter.mjs';
import { assertContained } from './paths.mjs';

const IGNORED_DIRECTORIES = new Set(['.git', '.worktrees', 'node_modules', 'dist']);

export async function validatePlugin(pluginRoot, options = {}) {
  const root = path.resolve(pluginRoot);
  const errors = [];
  const checks = [];
  const addError = (code, message, evidence) => errors.push({ code, message, evidence });
  const pass = (name, evidence) => checks.push({ name, status: 'passed', evidence });
  let manifest;

  const manifestPath = path.join(root, '.codex-plugin', 'plugin.json');
  try {
    manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
    pass('plugin-manifest-json', relative(root, manifestPath));
  } catch (error) {
    addError('FM_MANIFEST_INVALID', `Cannot parse plugin manifest: ${error.message}`, relative(root, manifestPath));
    return report(errors, checks);
  }

  validateManifest(manifest, options, addError, pass);

  if (Object.hasOwn(manifest, 'hooks')) {
    addError('FM_MANIFEST_FIELD_UNSUPPORTED', 'plugin.json field `hooks` is not accepted by Codex validation', 'hooks');
  }

  const skillRoot = path.resolve(root, manifest.skills ?? './skills/');
  try {
    assertContained(root, skillRoot);
    const skillFiles = (await walk(skillRoot)).filter((file) => path.basename(file) === 'SKILL.md');
    if (skillFiles.length === 0) addError('FM_SKILLS_MISSING', 'No SKILL.md files found', relative(root, skillRoot));
    const names = new Map();
    for (const file of skillFiles) {
      try {
        const metadata = parseSkillFrontmatter(await readFile(file, 'utf8'), relative(root, file));
        if (names.has(metadata.name)) {
          addError('FM_SKILL_DUPLICATE', `Duplicate skill name: ${metadata.name}`, `${names.get(metadata.name)}, ${relative(root, file)}`);
        } else {
          names.set(metadata.name, relative(root, file));
        }
      } catch (error) {
        addError(error.code ?? 'FM_FRONTMATTER_INVALID', error.message, relative(root, file));
      }
    }
    pass('skills', `${skillFiles.length} skill files`);
  } catch (error) {
    addError(error.code ?? 'FM_SKILLS_INVALID', error.message, relative(root, skillRoot));
  }

  for (const [field, candidate] of Object.entries({
    composerIcon: manifest.interface?.composerIcon,
    logo: manifest.interface?.logo,
  })) {
    if (!candidate) continue;
    const asset = path.resolve(root, candidate);
    try {
      assertContained(root, asset);
      await access(asset);
      pass(`interface-${field}`, relative(root, asset));
    } catch {
      addError('FM_ASSET_MISSING', `Missing interface asset ${field}: ${candidate}`, candidate);
    }
  }

  for (const markdown of (await walk(root)).filter((file) => file.endsWith('.md'))) {
    const content = await readFile(markdown, 'utf8');
    for (const link of markdownLinks(content)) {
      if (isExternal(link)) continue;
      const clean = decodeURIComponent(link.split('#')[0].split('?')[0]);
      if (!clean) continue;
      const target = path.resolve(path.dirname(markdown), clean);
      try {
        assertContained(root, target);
        await access(target);
      } catch {
        addError('FM_LINK_BROKEN', `Broken local Markdown link: ${link}`, relative(root, markdown));
      }
    }
  }
  pass('markdown-links', 'local links inspected');

  return report(errors, checks);
}

function validateManifest(manifest, options, addError, pass) {
  for (const field of ['name', 'version', 'description', 'skills', 'interface']) {
    if (manifest[field] === undefined || manifest[field] === null || manifest[field] === '') {
      addError('FM_MANIFEST_FIELD_REQUIRED', `Missing plugin manifest field: ${field}`, field);
    }
  }
  if (manifest.name && !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(manifest.name)) {
    addError('FM_MANIFEST_NAME_INVALID', 'Plugin name must be lower-case hyphen-case', manifest.name);
  }
  if (options.strictRelease && containsPlaceholder(manifest.author)) {
    addError('FM_METADATA_PLACEHOLDER', 'Author metadata contains placeholder values', 'author');
  }
  pass('manifest-fields', manifest.name ?? 'unknown');
}

function containsPlaceholder(author) {
  if (!author) return true;
  const text = JSON.stringify(author).toLowerCase();
  return text.includes('example.com') || text.includes('todo') || text.includes('placeholder');
}

async function walk(root) {
  const files = [];
  let entries;
  try {
    entries = await readdir(root, { withFileTypes: true });
  } catch {
    return files;
  }
  for (const entry of entries) {
    if (entry.isDirectory() && IGNORED_DIRECTORIES.has(entry.name)) continue;
    const full = path.join(root, entry.name);
    if (entry.isDirectory()) files.push(...await walk(full));
    else if (entry.isFile()) files.push(full);
  }
  return files;
}

function markdownLinks(content) {
  return [...content.matchAll(/(?<!!)\[[^\]]*\]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g)].map((match) => match[1]);
}

function isExternal(link) {
  return /^(?:[a-z][a-z0-9+.-]*:|#)/i.test(link);
}

function report(errors, checks) {
  return { schemaVersion: 1, status: errors.length ? 'failed' : 'passed', checks, errors };
}

function relative(root, candidate) {
  return path.relative(root, candidate).replaceAll(path.sep, '/');
}

SHA-256: 78fc10bfd72adac55bae6aa17068805d3d0ba3176e0e537c3f7d918b118b4728