UNPKG

claude-flow

Version:

Ruflo - Enterprise AI agent orchestration for Claude Code. Deploy 60+ specialized agents in coordinated swarms with self-learning, fault-tolerant consensus, vector memory, and MCP integration

210 lines (192 loc) 9.57 kB
#!/usr/bin/env node /** * Plugin package.json install-safety audit (regression guard for #1902/#1903/#1904). * * Scans v3/plugins/<name>/package.json and fails CI on any of: * * A. (#1903) A `@claude-flow/*` package referenced as a hard `dependencies` * entry, OR as a `peerDependencies` entry that is NOT marked * `peerDependenciesMeta[name].optional: true`, that is not in the * KNOWN_PUBLISHED allow-list. npm 7+ auto-installs non-optional peers, * so an unpublished one (e.g. `@claude-flow/ruvector-upstream`) makes * `npm install <plugin>` fail with E404. * * B. (#1902) A `peerDependencies` range for a `@claude-flow/*` or * `@ruvector/*` target that is a "bare stable" range (`>=X.Y.Z`, * `^X.Y.Z`, `~X.Y.Z`, `X.Y.Z`) with no prerelease component. Those * ranges DON'T satisfy a prerelease publish like `3.0.0-alpha.15`, so * npm can't find a matching version. Use `>=X.Y.Z-0` or `*`. * * C. (#1904, static) Every path in `main` / `module` / `types` / `bin` and * every path inside `exports` must live under a directory (or glob) that * is included in `files` — otherwise it isn't in the published tarball. * * D. (#1904, post-build) For any plugin whose `dist/` exists on disk (i.e. * it has been built), every `main`/`module`/`types`/`exports` path must * exist. This is the real catch for "exports.import → ./dist/index.mjs * but the build only emits .cjs". CI builds the plugins before running * this script so check D is live there. * * Usage: * node scripts/audit-plugin-packages.mjs # audit, exit 1 on any issue * node scripts/audit-plugin-packages.mjs --json # machine-readable report */ import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs'; import { join, dirname } from 'node:path'; const REPO_ROOT = process.cwd(); const PLUGINS_DIR = join(REPO_ROOT, 'v3', 'plugins'); const JSON_OUT = process.argv.includes('--json'); // @claude-flow/* packages known to be published to the npm registry. A hard // dep / non-optional peer on anything @claude-flow/* NOT in this set fails the // audit. Refresh with: for n in <pkg>; do npm view @claude-flow/$n version; done const KNOWN_PUBLISHED = new Set([ '@claude-flow/aidefence', '@claude-flow/browser', '@claude-flow/claims', '@claude-flow/cli', '@claude-flow/cli-core', '@claude-flow/codex', '@claude-flow/deployment', '@claude-flow/embeddings', '@claude-flow/guidance', '@claude-flow/hooks', '@claude-flow/integration', '@claude-flow/mcp', '@claude-flow/memory', '@claude-flow/neural', '@claude-flow/performance', '@claude-flow/plugins', '@claude-flow/providers', '@claude-flow/security', '@claude-flow/shared', '@claude-flow/swarm', '@claude-flow/testing', // plugin-* packages publish under @claude-flow/plugin-<name>; the plugin // store loads them by tarball, but if one plugin hard-depends on another // it must be published. Add entries here if/when that happens. ]); // A peer-range is "prerelease-safe" if it includes a prerelease tag (`-0`, // `-alpha`, …) or is the wildcard `*` / `latest` / a workspace protocol. function isPrereleaseSafeRange(range) { const r = String(range).trim(); if (r === '*' || r === 'latest' || r === '' || r.startsWith('workspace:') || r.startsWith('file:') || r.startsWith('link:')) return true; if (/-[0-9A-Za-z.]+/.test(r)) return true; // has a prerelease component somewhere // bare stable: >=X.Y.Z | ^X.Y.Z | ~X.Y.Z | X.Y.Z | >X.Y.Z (and ranges of those) if (/^[\^~]?\d+(\.\d+){0,2}$/.test(r)) return false; if (/^>=?\s*\d+(\.\d+){0,2}$/.test(r)) return false; // anything else (e.g. "3.x", "1.2 - 2.3", complex composites) — don't flag, // too noisy; the two patterns above cover the real-world offenders. return true; } // Does `files` (array of literal paths / globs) include `relPath`? function filesCovers(files, relPath) { if (!Array.isArray(files)) return true; // no `files` field → whole dir publishes const clean = relPath.replace(/^\.\//, ''); const topSeg = clean.split('/')[0]; for (const entry of files) { const e = String(entry).replace(/^\.\//, ''); if (e === clean) return true; if (e === topSeg) return true; // "dist" covers "dist/index.js" if (e.startsWith(topSeg + '/')) return true; // "dist/**" covers "dist/index.js" if (e === topSeg + '/**') return true; if (e.includes('*')) { // crude glob → regex const re = new RegExp('^' + e.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*\*/g, '.*').replace(/\*/g, '[^/]*') + '$'); if (re.test(clean)) return true; } } return false; } // Collect every file path referenced by package.json export-ish fields. function collectExportPaths(pkg) { const out = new Set(); const add = (v) => { if (typeof v === 'string' && v.startsWith('.')) out.add(v); }; add(pkg.main); add(pkg.module); add(pkg.types); add(pkg.typings); if (typeof pkg.bin === 'string') add(pkg.bin); else if (pkg.bin && typeof pkg.bin === 'object') Object.values(pkg.bin).forEach(add); const walk = (node) => { if (typeof node === 'string') return add(node); if (Array.isArray(node)) return node.forEach(walk); if (node && typeof node === 'object') return Object.values(node).forEach(walk); }; if (pkg.exports) walk(pkg.exports); return [...out]; } const plugins = existsSync(PLUGINS_DIR) ? readdirSync(PLUGINS_DIR).filter((d) => { const p = join(PLUGINS_DIR, d); return statSync(p).isDirectory() && existsSync(join(p, 'package.json')); }) : []; const issues = []; const note = (plugin, code, message) => issues.push({ plugin, code, message }); for (const dir of plugins) { const pkgPath = join(PLUGINS_DIR, dir, 'package.json'); let pkg; try { pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')); } catch (e) { note(dir, 'PARSE', `package.json is not valid JSON: ${e.message}`); continue; } const deps = pkg.dependencies || {}; const peers = pkg.peerDependencies || {}; const peerMeta = pkg.peerDependenciesMeta || {}; // --- Check A: unpublished @claude-flow/* as hard dep / non-optional peer for (const [name] of Object.entries(deps)) { if (name.startsWith('@claude-flow/') && !KNOWN_PUBLISHED.has(name)) { note(dir, 'A', `"${name}" is a hard dependency but not a published @claude-flow package — \`npm install\` will E404. Make it an optional peerDependency or remove it (the runtime should fall back when absent).`); } } for (const [name] of Object.entries(peers)) { const optional = peerMeta[name] && peerMeta[name].optional === true; if (name.startsWith('@claude-flow/') && !KNOWN_PUBLISHED.has(name) && !optional) { note(dir, 'A', `"${name}" is a non-optional peerDependency but not a published @claude-flow package — npm 7+ auto-installs peers and will E404. Add \`peerDependenciesMeta["${name}"].optional: true\`.`); } } // --- Check B: bare-stable peer ranges for prerelease @claude-flow targets. // All @claude-flow packages currently publish as 3.x prereleases, so a bare // ">=3.0.0" can never resolve. We only flag @claude-flow/* here (and only // when non-optional, or optional-but-@claude-flow since the project always // ships those as prereleases). Optional @ruvector/* WASM peers are exempt — // a bare range there at worst means the optional dep doesn't get installed. for (const [name, range] of Object.entries(peers)) { if (!name.startsWith('@claude-flow/')) continue; if (isPrereleaseSafeRange(range)) continue; note(dir, 'B', `peerDependency "${name}": "${range}" can't resolve any @claude-flow publish — they're all 3.x prereleases. Use ">=${String(range).replace(/^[\^~>=\s]+/, '')}-0" or "*".`); } // --- Check C: export-ish paths must be covered by `files` const exportPaths = collectExportPaths(pkg); for (const rel of exportPaths) { if (!filesCovers(pkg.files, rel)) { note(dir, 'C', `"${rel}" is referenced (main/module/exports) but not covered by "files" ${JSON.stringify(pkg.files)} — it won't be in the published tarball.`); } } // --- Check D: if built, export-ish paths must exist on disk const distDir = join(PLUGINS_DIR, dir, 'dist'); if (existsSync(distDir)) { for (const rel of exportPaths) { const abs = join(PLUGINS_DIR, dir, rel.replace(/^\.\//, '')); if (!existsSync(abs)) { note(dir, 'D', `"${rel}" is referenced (main/module/exports) but does not exist after build — the build emits a different filename/extension. (e.g. tsup may emit .cjs/.js, not .mjs)`); } } } } const report = { scannedPlugins: plugins.length, issueCount: issues.length, byCode: issues.reduce((m, i) => ((m[i.code] = (m[i.code] || 0) + 1), m), {}), issues, }; if (JSON_OUT) { console.log(JSON.stringify(report, null, 2)); } else { console.log(`plugin package audit — scanned ${plugins.length} plugin(s)`); if (issues.length === 0) { console.log(' ✓ no install-safety issues'); } else { const labels = { A: 'unpublished @claude-flow dep', B: 'prerelease-unsafe peer range', C: 'export not in files', D: 'export missing after build', PARSE: 'invalid package.json' }; for (const i of issues) { console.log(` ✗ [${i.code}] ${i.plugin}: ${i.message}`); } console.log(`\n${issues.length} issue(s) across codes: ${Object.entries(report.byCode).map(([c, n]) => `${c}=${n} (${labels[c] || c})`).join(', ')}`); } } process.exit(issues.length > 0 ? 1 : 0);