- Add TypeScript registry validator using Bun (< 1 second vs bash hanging) - Install Bun in CI workflow - Update workflow to use bun validator instead of bash script - Add npm scripts for local validation - Add comprehensive README for registry validation This fixes the CI timeout issue where bash validator hangs in GitHub Actions. The TypeScript validator is faster, more reliable, and easier to debug. Related: Unblocks PR #128 which is currently failing due to bash script timeout
543 lines
16 KiB
TypeScript
Executable file
543 lines
16 KiB
TypeScript
Executable file
#!/usr/bin/env bun
|
||
|
||
/**
|
||
* Registry Validator Script (TypeScript/Bun version)
|
||
* Validates that all paths in registry.json point to actual files
|
||
* Exit codes:
|
||
* 0 = All paths valid
|
||
* 1 = Missing files found
|
||
* 2 = Registry parse error or missing dependencies
|
||
*/
|
||
|
||
import { existsSync, readFileSync, readdirSync } from 'fs';
|
||
import { join, dirname } from 'path';
|
||
import { fileURLToPath } from 'url';
|
||
import { globSync } from 'glob';
|
||
|
||
// Colors
|
||
const colors = {
|
||
red: '\x1b[0;31m',
|
||
green: '\x1b[0;32m',
|
||
yellow: '\x1b[1;33m',
|
||
blue: '\x1b[0;34m',
|
||
cyan: '\x1b[0;36m',
|
||
bold: '\x1b[1m',
|
||
reset: '\x1b[0m',
|
||
};
|
||
|
||
// Configuration
|
||
const REGISTRY_FILE = 'registry.json';
|
||
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '../..');
|
||
|
||
// Counters
|
||
let TOTAL_PATHS = 0;
|
||
let VALID_PATHS = 0;
|
||
let MISSING_PATHS = 0;
|
||
let ORPHANED_FILES = 0;
|
||
let MISSING_DEPENDENCIES = 0;
|
||
|
||
// Arrays to store results
|
||
const MISSING_FILES: string[] = [];
|
||
const ORPHANED_COMPONENTS: string[] = [];
|
||
const MISSING_DEPS: string[] = [];
|
||
|
||
// CLI flags
|
||
let VERBOSE = false;
|
||
let FIX_MODE = false;
|
||
|
||
// Types
|
||
interface Component {
|
||
id: string;
|
||
name: string;
|
||
type: string;
|
||
path: string;
|
||
dependencies?: string[];
|
||
[key: string]: any;
|
||
}
|
||
|
||
interface Registry {
|
||
version: string;
|
||
schema_version: string;
|
||
repository: string;
|
||
categories: Record<string, string>;
|
||
components: {
|
||
agents?: Component[];
|
||
subagents?: Component[];
|
||
commands?: Component[];
|
||
tools?: Component[];
|
||
plugins?: Component[];
|
||
contexts?: Component[];
|
||
config?: Component[];
|
||
skills?: Component[];
|
||
};
|
||
}
|
||
|
||
// Utility Functions
|
||
function printHeader(): void {
|
||
console.log(`${colors.cyan}${colors.bold}`);
|
||
console.log('╔════════════════════════════════════════════════════════════════╗');
|
||
console.log('║ ║');
|
||
console.log('║ Registry Validator v2.0.0 (TypeScript) ║');
|
||
console.log('║ ║');
|
||
console.log('╚════════════════════════════════════════════════════════════════╝');
|
||
console.log(`${colors.reset}`);
|
||
}
|
||
|
||
function printSuccess(msg: string): void {
|
||
console.log(`${colors.green}✓${colors.reset} ${msg}`);
|
||
}
|
||
|
||
function printError(msg: string): void {
|
||
console.log(`${colors.red}✗${colors.reset} ${msg}`);
|
||
}
|
||
|
||
function printWarning(msg: string): void {
|
||
console.log(`${colors.yellow}⚠${colors.reset} ${msg}`);
|
||
}
|
||
|
||
function printInfo(msg: string): void {
|
||
console.log(`${colors.blue}ℹ${colors.reset} ${msg}`);
|
||
}
|
||
|
||
function usage(): void {
|
||
console.log('Usage: bun run scripts/registry/validate-registry.ts [OPTIONS]');
|
||
console.log('');
|
||
console.log('Options:');
|
||
console.log(' -v, --verbose Show detailed validation output');
|
||
console.log(' -f, --fix Suggest fixes for missing files');
|
||
console.log(' -h, --help Show this help message');
|
||
console.log('');
|
||
console.log('Exit codes:');
|
||
console.log(' 0 = All paths valid');
|
||
console.log(' 1 = Missing files found');
|
||
console.log(' 2 = Registry parse error or missing dependencies');
|
||
process.exit(0);
|
||
}
|
||
|
||
// Registry Validation
|
||
function validateRegistryFile(): Registry {
|
||
const registryPath = join(REPO_ROOT, REGISTRY_FILE);
|
||
|
||
if (!existsSync(registryPath)) {
|
||
printError(`Registry file not found: ${REGISTRY_FILE}`);
|
||
process.exit(2);
|
||
}
|
||
|
||
try {
|
||
const content = readFileSync(registryPath, 'utf-8');
|
||
const registry = JSON.parse(content) as Registry;
|
||
printSuccess('Registry file is valid JSON');
|
||
return registry;
|
||
} catch (error) {
|
||
printError('Registry file is not valid JSON');
|
||
console.error(error);
|
||
process.exit(2);
|
||
}
|
||
}
|
||
|
||
function validateComponentPaths(
|
||
category: keyof Registry['components'],
|
||
categoryDisplay: string,
|
||
registry: Registry
|
||
): void {
|
||
console.error(`Checking ${categoryDisplay}...`);
|
||
|
||
const components = registry.components[category];
|
||
|
||
if (!components || components.length === 0) {
|
||
console.error(`No ${categoryDisplay} found`);
|
||
return;
|
||
}
|
||
|
||
for (const component of components) {
|
||
const { id, path, name } = component;
|
||
|
||
TOTAL_PATHS++;
|
||
|
||
const fullPath = join(REPO_ROOT, path);
|
||
|
||
if (existsSync(fullPath)) {
|
||
VALID_PATHS++;
|
||
if (VERBOSE) {
|
||
printSuccess(`${categoryDisplay}: ${name} (${id})`);
|
||
}
|
||
} else {
|
||
MISSING_PATHS++;
|
||
MISSING_FILES.push(`${category}:${id}|${name}|${path}`);
|
||
printError(`${categoryDisplay}: ${name} (${id}) - File not found: ${path}`);
|
||
|
||
if (FIX_MODE) {
|
||
suggestFix(path, id);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function suggestFix(missingPath: string, componentId: string): void {
|
||
const dir = dirname(missingPath);
|
||
const baseDir = dir.split('/').slice(0, 3).join('/');
|
||
const searchPath = join(REPO_ROOT, baseDir);
|
||
|
||
try {
|
||
// Search for markdown files matching the component ID
|
||
const pattern = join(searchPath, '**', '*.md');
|
||
const files = globSync(pattern, { nodir: true });
|
||
|
||
const matches = files.filter(file =>
|
||
file.toLowerCase().includes(componentId.toLowerCase())
|
||
);
|
||
|
||
if (matches.length > 0) {
|
||
console.log(` ${colors.yellow}→ Possible matches:${colors.reset}`);
|
||
matches.forEach(file => {
|
||
const relPath = file.replace(REPO_ROOT + '/', '');
|
||
console.log(` ${colors.cyan}${relPath}${colors.reset}`);
|
||
});
|
||
} else {
|
||
console.log(` ${colors.yellow}→ No similar files found in ${baseDir}${colors.reset}`);
|
||
}
|
||
} catch (error) {
|
||
console.log(` ${colors.yellow}→ Search in: ${baseDir}${colors.reset}`);
|
||
console.log(` ${colors.yellow}→ Looking for files matching: ${componentId}${colors.reset}`);
|
||
}
|
||
}
|
||
|
||
function checkDependencyExists(dep: string, registry: Registry): string {
|
||
// Parse dependency format: type:id
|
||
const match = dep.match(/^([^:]+):(.+)$/);
|
||
|
||
if (!match) {
|
||
return 'invalid_format';
|
||
}
|
||
|
||
const [, depType, depId] = match;
|
||
|
||
// Map dependency type to registry category
|
||
const categoryMap: Record<string, keyof Registry['components']> = {
|
||
agent: 'agents',
|
||
subagent: 'subagents',
|
||
command: 'commands',
|
||
tool: 'tools',
|
||
plugin: 'plugins',
|
||
context: 'contexts',
|
||
config: 'config',
|
||
skill: 'skills',
|
||
};
|
||
|
||
const registryCategory = categoryMap[depType];
|
||
|
||
if (!registryCategory) {
|
||
return 'unknown_type';
|
||
}
|
||
|
||
const components = registry.components[registryCategory];
|
||
|
||
if (!components) {
|
||
return 'not_found';
|
||
}
|
||
|
||
// Check if component exists in registry - exact ID match
|
||
const exists = components.find((c) => c.id === depId);
|
||
|
||
if (exists) {
|
||
return 'found';
|
||
}
|
||
|
||
// For context dependencies, also try path-based lookup
|
||
if (depType === 'context') {
|
||
// Check for wildcard pattern (e.g., context:core/context-system/*)
|
||
if (depId.includes('*')) {
|
||
const prefix = depId.split('*')[0];
|
||
const matches = components.find((c) =>
|
||
c.path.startsWith(`.opencode/context/${prefix}`)
|
||
);
|
||
|
||
if (matches) {
|
||
return 'found';
|
||
}
|
||
} else {
|
||
// Try exact path match
|
||
const contextPath = `.opencode/context/${depId}.md`;
|
||
const existsByPath = components.find((c) => c.path === contextPath);
|
||
|
||
if (existsByPath) {
|
||
return 'found';
|
||
}
|
||
}
|
||
}
|
||
|
||
return 'not_found';
|
||
}
|
||
|
||
function validateComponentDependencies(registry: Registry): void {
|
||
console.log('');
|
||
printInfo('Validating component dependencies...');
|
||
console.log('');
|
||
|
||
const componentTypes = Object.keys(registry.components) as Array<
|
||
keyof Registry['components']
|
||
>;
|
||
|
||
for (const compType of componentTypes) {
|
||
const components = registry.components[compType];
|
||
|
||
if (!components || components.length === 0) {
|
||
continue;
|
||
}
|
||
|
||
for (const component of components) {
|
||
const { id, name, dependencies } = component;
|
||
|
||
if (!dependencies || dependencies.length === 0) {
|
||
continue;
|
||
}
|
||
|
||
for (const dep of dependencies) {
|
||
if (!dep) {
|
||
continue;
|
||
}
|
||
|
||
const result = checkDependencyExists(dep, registry);
|
||
|
||
switch (result) {
|
||
case 'found':
|
||
if (VERBOSE) {
|
||
printSuccess(`Dependency OK: ${name} → ${dep}`);
|
||
}
|
||
break;
|
||
case 'not_found':
|
||
MISSING_DEPENDENCIES++;
|
||
MISSING_DEPS.push(`${compType}|${id}|${name}|${dep}`);
|
||
printError(
|
||
`Missing dependency: ${name} (${compType.replace(/s$/, '')}) depends on "${dep}" (not found in registry)`
|
||
);
|
||
break;
|
||
case 'invalid_format':
|
||
MISSING_DEPENDENCIES++;
|
||
MISSING_DEPS.push(`${compType}|${id}|${name}|${dep}`);
|
||
printError(
|
||
`Invalid dependency format: ${name} (${compType.replace(/s$/, '')}) has invalid dependency "${dep}" (expected format: type:id)`
|
||
);
|
||
break;
|
||
case 'unknown_type':
|
||
MISSING_DEPENDENCIES++;
|
||
MISSING_DEPS.push(`${compType}|${id}|${name}|${dep}`);
|
||
printError(
|
||
`Unknown dependency type: ${name} (${compType.replace(/s$/, '')}) has unknown dependency type in "${dep}"`
|
||
);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function scanDirectory(dir: string, registryPaths: Set<string>): void {
|
||
if (!existsSync(dir)) {
|
||
return;
|
||
}
|
||
|
||
const entries = readdirSync(dir, { withFileTypes: true });
|
||
|
||
for (const entry of entries) {
|
||
const fullPath = join(dir, entry.name);
|
||
const relPath = fullPath.replace(REPO_ROOT + '/', '');
|
||
|
||
if (entry.isDirectory()) {
|
||
// Skip node_modules
|
||
if (entry.name === 'node_modules') continue;
|
||
// Skip plugin internal directories
|
||
if (relPath.includes('/plugin/docs/') || relPath.includes('/plugin/tests/')) continue;
|
||
// Skip scripts directories
|
||
if (relPath.includes('/scripts/')) continue;
|
||
|
||
scanDirectory(fullPath, registryPaths);
|
||
} else if (entry.isFile()) {
|
||
// Only check .md and .ts files
|
||
if (!entry.name.endsWith('.md') && !entry.name.endsWith('.ts')) continue;
|
||
|
||
// Skip exclusions
|
||
if (entry.name === 'README.md') continue;
|
||
if (entry.name.endsWith('-template.md')) continue;
|
||
if (relPath.endsWith('/tool/index.ts')) continue;
|
||
if (relPath.endsWith('/tool/template/index.ts')) continue;
|
||
if (relPath.endsWith('/plugin/agent-validator.ts')) continue;
|
||
|
||
// Skip skill support files (only SKILL.md needs to be in registry, all other files are copied with the skill)
|
||
if (relPath.includes('/skill/') && !entry.name.match(/^SKILL\.md$/i)) continue;
|
||
|
||
// Check if in registry
|
||
if (!registryPaths.has(relPath)) {
|
||
ORPHANED_FILES++;
|
||
ORPHANED_COMPONENTS.push(relPath);
|
||
if (VERBOSE) {
|
||
printWarning(`Orphaned file (not in registry): ${relPath}`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function scanForOrphanedFiles(registry: Registry): void {
|
||
if (!VERBOSE) return;
|
||
|
||
console.log('');
|
||
console.log(`${colors.bold}Scanning for orphaned files...${colors.reset}`);
|
||
|
||
// Get all paths from registry
|
||
const registryPaths = new Set<string>();
|
||
for (const category of Object.keys(registry.components)) {
|
||
const components = registry.components[category as keyof Registry['components']];
|
||
if (components) {
|
||
components.forEach(c => registryPaths.add(c.path));
|
||
}
|
||
}
|
||
|
||
const categories = ['agent', 'command', 'tool', 'plugin', 'context', 'skill'];
|
||
|
||
for (const category of categories) {
|
||
const categoryDir = join(REPO_ROOT, '.opencode', category);
|
||
scanDirectory(categoryDir, registryPaths);
|
||
}
|
||
}
|
||
|
||
function printSummary(): boolean {
|
||
console.log('');
|
||
console.log(`${colors.bold}═══════════════════════════════════════════════════════════════${colors.reset}`);
|
||
console.log(`${colors.bold}Validation Summary${colors.reset}`);
|
||
console.log(`${colors.bold}═══════════════════════════════════════════════════════════════${colors.reset}`);
|
||
console.log('');
|
||
console.log(`Total paths checked: ${colors.cyan}${TOTAL_PATHS}${colors.reset}`);
|
||
console.log(`Valid paths: ${colors.green}${VALID_PATHS}${colors.reset}`);
|
||
console.log(`Missing paths: ${colors.red}${MISSING_PATHS}${colors.reset}`);
|
||
console.log(`Missing dependencies: ${colors.red}${MISSING_DEPENDENCIES}${colors.reset}`);
|
||
|
||
if (VERBOSE) {
|
||
console.log(`Orphaned files: ${colors.yellow}${ORPHANED_FILES}${colors.reset}`);
|
||
}
|
||
|
||
console.log('');
|
||
|
||
let hasErrors = false;
|
||
|
||
// Check for missing paths
|
||
if (MISSING_PATHS > 0) {
|
||
hasErrors = true;
|
||
printError(`Found ${MISSING_PATHS} missing file(s)`);
|
||
console.log('');
|
||
console.log('Missing files:');
|
||
for (const entry of MISSING_FILES) {
|
||
const [catId, name, path] = entry.split('|');
|
||
console.log(` - ${path} (${catId})`);
|
||
}
|
||
console.log('');
|
||
|
||
if (!FIX_MODE) {
|
||
printInfo('Run with --fix flag to see suggested fixes');
|
||
console.log('');
|
||
}
|
||
}
|
||
|
||
// Check for missing dependencies
|
||
if (MISSING_DEPENDENCIES > 0) {
|
||
hasErrors = true;
|
||
printError(`Found ${MISSING_DEPENDENCIES} missing or invalid dependencies`);
|
||
console.log('');
|
||
console.log('Missing dependencies:');
|
||
for (const entry of MISSING_DEPS) {
|
||
const [compType, id, name, dep] = entry.split('|');
|
||
console.log(` - ${name} (${compType.replace(/s$/, '')}) → ${dep}`);
|
||
}
|
||
console.log('');
|
||
printInfo('Fix by either:');
|
||
console.log(' 1. Adding the missing component to the registry');
|
||
console.log(' 2. Removing the dependency from the component\'s frontmatter');
|
||
console.log('');
|
||
}
|
||
|
||
// Success case
|
||
if (!hasErrors) {
|
||
printSuccess('All registry paths are valid!');
|
||
printSuccess('All component dependencies are valid!');
|
||
|
||
if (ORPHANED_FILES > 0 && VERBOSE) {
|
||
console.log('');
|
||
printWarning(`Found ${ORPHANED_FILES} orphaned file(s) not in registry`);
|
||
console.log('');
|
||
console.log('Orphaned files:');
|
||
for (const file of ORPHANED_COMPONENTS) {
|
||
console.log(` - ${file}`);
|
||
}
|
||
console.log('');
|
||
console.log('Consider adding these to registry.json or removing them.');
|
||
}
|
||
|
||
return true;
|
||
} else {
|
||
console.log('Please fix these issues before proceeding.');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// Main
|
||
function main(): void {
|
||
// Parse arguments
|
||
const args = process.argv.slice(2);
|
||
|
||
for (const arg of args) {
|
||
switch (arg) {
|
||
case '-v':
|
||
case '--verbose':
|
||
VERBOSE = true;
|
||
break;
|
||
case '-f':
|
||
case '--fix':
|
||
FIX_MODE = true;
|
||
VERBOSE = true;
|
||
break;
|
||
case '-h':
|
||
case '--help':
|
||
usage();
|
||
break;
|
||
default:
|
||
console.log(`Unknown option: ${arg}`);
|
||
usage();
|
||
}
|
||
}
|
||
|
||
printHeader();
|
||
|
||
// Validate registry file
|
||
const registry = validateRegistryFile();
|
||
|
||
console.log('');
|
||
printInfo('Validating component paths...');
|
||
console.log('');
|
||
|
||
// Validate each category
|
||
validateComponentPaths('agents', 'Agents', registry);
|
||
validateComponentPaths('subagents', 'Subagents', registry);
|
||
validateComponentPaths('commands', 'Commands', registry);
|
||
validateComponentPaths('tools', 'Tools', registry);
|
||
validateComponentPaths('plugins', 'Plugins', registry);
|
||
validateComponentPaths('contexts', 'Contexts', registry);
|
||
validateComponentPaths('config', 'Config', registry);
|
||
validateComponentPaths('skills', 'Skills', registry);
|
||
|
||
// Validate component dependencies
|
||
validateComponentDependencies(registry);
|
||
|
||
// Scan for orphaned files if verbose
|
||
if (VERBOSE) {
|
||
scanForOrphanedFiles(registry);
|
||
}
|
||
|
||
// Print summary and exit with appropriate code
|
||
if (printSummary()) {
|
||
process.exit(0);
|
||
} else {
|
||
process.exit(1);
|
||
}
|
||
}
|
||
|
||
main();
|