Files
santifer--career-ops/validate-plugin-registry.mjs
T
wehub-resource-sync d083df1fdb
CodeQL Analysis / Analyze (javascript-typescript) (push) Failing after 2s
Web CI / web typecheck + build (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
CodeQL Analysis / Analyze (go) (push) Failing after 16s
chore: import upstream snapshot with attribution
2026-07-13 12:02:43 +08:00

62 lines
3.3 KiB
JavaScript

#!/usr/bin/env node
// @ts-check
// validate-plugin-registry.mjs — deterministic shape gate for the plugin
// registry (plugins-registry/<id>.json, one file per plugin; legacy single-file
// plugins-registry.json still validates via the loader's fallback).
// Run locally + by the registry-validate CI. Shape/uniqueness only (no network);
// the CI additionally clones each changed entry at its pinned SHA and runs the
// min-file + manifest + audit checks in a no-secret, read-only sandbox.
import { existsSync } from 'node:fs';
import path from 'node:path';
import { loadRegistry, loadRegistryFiles, validateRegistryEntry } from './plugins/_registry.mjs';
import { HOOK_KINDS, RESERVED_ENV } from './plugins/_engine.mjs';
const ID_RE = /^[a-z0-9][a-z0-9-]*$/;
/** @returns {string[]} problems (empty = valid) */
export function validateRegistry(root) {
const reg = loadRegistry(root);
const problems = [];
if (reg.registryVersion !== 1) problems.push(`unsupported registryVersion ${JSON.stringify(reg.registryVersion)}`);
// Per-plugin-file invariants: every file parses, and the filename equals the
// entry's id — the filename IS the conflict-free uniqueness guarantee (two
// plugins can't claim one id without touching the same file).
for (const { file, entry } of loadRegistryFiles(root)) {
if (!entry || typeof entry !== 'object') { problems.push(`${file}: not a JSON object`); continue; }
if (entry.id && `${entry.id}.json` !== file) problems.push(`${file}: filename must equal "<id>.json" (id is "${entry.id}")`);
}
const names = new Set(), ids = new Set();
for (const e of reg.plugins) {
for (const er of validateRegistryEntry(e, { idRe: ID_RE, hookKinds: HOOK_KINDS, reservedEnv: RESERVED_ENV })) {
problems.push(`${e.name || e.id || '?'}: ${er}`);
}
if (e.name && names.has(e.name)) problems.push(`duplicate name: ${e.name}`);
if (e.id && ids.has(e.id)) problems.push(`duplicate id: ${e.id}`);
// A supersedesBundled entry must name a REAL bundled plugin (anti-typo/phantom):
// its id has to correspond to an in-tree plugins/<id>/ before it can be granted precedence.
if (e.supersedesBundled === true && e.id && !existsSync(path.join(root, 'plugins', e.id, 'manifest.json'))) {
problems.push(`${e.name || e.id}: supersedesBundled names "${e.id}" but no bundled plugin (plugins/${e.id}/) exists to supersede`);
}
names.add(e.name); ids.add(e.id);
}
return problems;
}
if (import.meta.url === (await import('node:url')).pathToFileURL(process.argv[1] || '').href) {
const root = process.cwd();
const deep = process.argv.includes('--deep');
const problems = validateRegistry(root);
// --deep: clone each entry at its pinned SHA + statically validate it (no
// plugin code is executed). Used by the registry-validate CI in a sandbox.
if (deep && problems.length === 0) {
const { auditRegistryEntry } = await import('./plugin-install.mjs');
const { loadRegistry } = await import('./plugins/_registry.mjs');
for (const e of loadRegistry(root).plugins) {
for (const p of auditRegistryEntry(e.repo, e.sha, e.id)) problems.push(`${e.name}: ${p}`);
}
}
if (problems.length) { for (const p of problems) console.error(`✗ ${p}`); process.exit(1); }
console.log(`✓ plugin registry is valid${deep ? ' (deep: all entries cloned + audited)' : ''}`); process.exit(0);
}