#!/usr/bin/env node /** * 插件检验器(plugin:check)——性能与样式标准的机器关卡。 * * 用法: * pnpm plugin:check <插件目录> [更多目录...] * node scripts/check-plugin.mjs <插件目录> * * 检查项(error = 必须修,exit 1;warn = 应当修): * 【契约】manifest.json 可解析;id/name/version(semver)/minContract 齐全; * minContract ≤ 宿主契约版本(从 plugin-host.ts 源码提取,单一事实源); * entry.main / entry.renderer / entry.page 指向的文件存在 * 【性能】renderer.mjs ≤ 8MB(宿主 readRenderer 上限)、main.mjs ≤ 2MB; * main.mjs 禁同步 IO(*Sync / execSync / spawnSync——阻塞主进程事件循环); * 任何源码禁 alert/confirm/prompt(阻塞渲染进程的同步对话框) * 【单实例】renderer.mjs 必须引用 __ASO_PLUGIN_HOST__(React shim);>300KB 警告 * 疑似打包了自带 React 副本 * 【样式】禁硬编码 hex 色值(#fff/#ffffff 除外——accent 按钮白字),一切颜色走 token * 【三态】注册了 view/panel 的 renderer 应使用 @aso/plugin-sdk/ui 的 * LoadingState/ErrorState/EmptyState/Spinner(等待/失败/空态统一呈现) * 【日志】main.mjs 禁 console.*(统一走 api.log,见 docs/plugins/06-self-diagnosis.md) * * 规则细节见 docs/plugins/08-performance.md;样式规范见 docs/plugins/DESIGN.md。 */ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' import { join, relative, sep } from 'node:path' import { fileURLToPath } from 'node:url' const ROOT = join(fileURLToPath(new URL('.', import.meta.url)), '..') const MAX_RENDERER_BYTES = 8 * 1024 * 1024 const MAX_MAIN_BYTES = 2 * 1024 * 1024 const REACT_COPY_SUSPECT_BYTES = 300 * 1024 /** 从宿主源码提取契约版本(避免检验器里出现第二份常量) */ function harnessContractVersion() { const src = join(ROOT, 'apps', 'desktop', 'src', 'main', 'plugins', 'plugin-host.ts') if (!existsSync(src)) return null const m = /HARNESS_CONTRACT_VERSION\s*=\s*'(\d+\.\d+\.\d+)'/.exec(readFileSync(src, 'utf8')) return m ? m[1] : null } const contract = harnessContractVersion() /** lucide 图标白名单:从宿主 plugin-icons.tsx 提取(单一事实源),校验 manifest.icon 用 */ function pluginIconNames() { const src = join(ROOT, 'apps', 'desktop', 'src', 'renderer', 'src', 'plugins', 'plugin-icons.tsx') if (!existsSync(src)) return null const names = [...readFileSync(src, 'utf8').matchAll(/^ ([A-Za-z0-9]+): Lu[A-Za-z0-9]+,$/gm)].map((m) => m[1]) return names.length ? new Set(names) : null } function parseSemver(v) { const m = /^(\d+)\.(\d+)\.(\d+)/.exec(String(v).trim()) return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null } function compareSemver(a, b) { const pa = parseSemver(a) ?? [0, 0, 0] const pb = parseSemver(b) ?? [0, 0, 0] for (let i = 0; i < 3; i++) { if (pa[i] !== pb[i]) return pa[i] < pb[i] ? -1 : 1 } return 0 } /** 递归收集插件目录下的源文件(跳过 node_modules/.git/out) */ function collectFiles(dir, base = dir, acc = []) { for (const name of readdirSync(dir)) { const p = join(dir, name) const st = statSync(p) if (st.isDirectory()) { if (name === 'node_modules' || name === '.git' || name === 'out') continue collectFiles(p, base, acc) } else if (/\.(mjs|js|jsx|ts|tsx|html|css)$/.test(name)) { acc.push(relative(base, p).split(sep).join('/')) } } return acc } const HEX_COLOR = /#[0-9a-fA-F]{3,8}\b/g const HEX_ALLOW = new Set(['#fff', '#ffffff']) const SYNC_IO = /\b(readFileSync|writeFileSync|appendFileSync|copyFileSync|mkdirSync|mkdtempSync|readdirSync|statSync|lstatSync|existsSync|accessSync|truncateSync|chmodSync|chownSync|unlinkSync|rmSync|rmdirSync|renameSync|cpSync|execSync|spawnSync|execFileSync)\s*\(/g const BLOCKING_DIALOG = /\b(alert|confirm|prompt)\s*\(/g const CONSOLE = /\bconsole\.(log|info|warn|error|debug|trace)\s*\(/g const STATE_COMPONENTS = /(LoadingState|ErrorState|EmptyState|Spinner)/ const UI_KIT_IMPORT = /@aso\/plugin-sdk\/ui/ /** 检查单个插件目录,返回 { errors: string[], warns: string[] } */ function checkPlugin(dir) { const errors = [] const warns = [] const err = (msg) => errors.push(msg) const warn = (msg) => warns.push(msg) if (!existsSync(dir)) { err(`目录不存在: ${dir}`) return { errors, warns } } // ---- 契约 ---- const manifestPath = join(dir, 'manifest.json') if (!existsSync(manifestPath)) { err('缺 manifest.json') return { errors, warns } } let manifest try { manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) } catch (e) { err(`manifest.json 解析失败: ${e.message}`) return { errors, warns } } if (!/^[a-z0-9][a-z0-9._-]*$/i.test(String(manifest.id ?? ''))) err(`manifest.id 非法: ${JSON.stringify(manifest.id)}`) if (!manifest.name) err('缺 manifest.name') if (!parseSemver(manifest.version)) err(`manifest.version 非法 semver: ${JSON.stringify(manifest.version)}`) // 图标规范:统一 lucide(内联 svg 仅旧兼容;未知名宿主回落拼图图标) if (typeof manifest.icon === 'string' && manifest.icon.trim() && !manifest.icon.trim().startsWith(' c.toUpperCase()) .replace(/^./, (c) => c.toUpperCase()) if (!names.has(pascal)) { warn(`manifest.icon "${manifest.icon.trim()}" 不在 lucide 白名单(宿主将回落拼图图标);名单见 docs/plugins/DESIGN.md §3`) } } } if (!manifest.minContract) { warn('缺 manifest.minContract(建议声明,便于契约升级时感知)') } else if (contract && compareSemver(manifest.minContract, contract) > 0) { err(`manifest.minContract ${manifest.minContract} 高于宿主契约 ${contract}(插件比宿主新,无法装载)`) } // ---- 依赖声明(dependencies / optionalDependencies:插件 id → semver 范围) ---- const DEP_RANGE = /^(\*|(>=|\^|~)?\d+\.\d+\.\d+)$/ const ID_OK = /^[a-z][a-z0-9-]*$/ const checkDeps = (field, primary) => { const raw = manifest[field] if (raw === undefined) return if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { err(`manifest.${field} 必须是对象(插件 id → semver 范围)`) return } for (const [id, range] of Object.entries(raw)) { if (!ID_OK.test(id)) err(`manifest.${field}.${id}: 插件 id 非法(小写字母开头,字母/数字/连字符)`) if (id === manifest.id) err(`manifest.${field}.${id}: 不能依赖自身`) if (typeof range !== 'string' || !DEP_RANGE.test(range.trim())) { err(`manifest.${field}.${id}: 范围「${JSON.stringify(range)}」非法——支持 * / ^x.y.z / ~x.y.z / >=x.y.z / 精确 x.y.z`) } if (primary && manifest.optionalDependencies && Object.hasOwn(manifest.optionalDependencies, id)) { err(`依赖 ${id} 同时出现在 dependencies 与 optionalDependencies`) } } if (Object.keys(raw).length > 0 && manifest.minContract && contract && compareSemver(manifest.minContract, '0.3.0') < 0) { warn(`声明了 ${field} 但 minContract ${manifest.minContract} < 0.3.0——旧宿主会静默忽略依赖声明,建议提升 minContract`) } } checkDeps('dependencies', true) checkDeps('optionalDependencies', false) const entry = manifest.entry ?? {} const mainFile = entry.main ?? 'main.mjs' if (entry.main && !existsSync(join(dir, entry.main))) err(`entry.main 不存在: ${entry.main}`) if (entry.renderer && !existsSync(join(dir, entry.renderer))) { // 源码形态(src/renderer.tsx 等待构建)算 warn;源码也没有才是 error const hasRendererSrc = ['tsx', 'ts', 'jsx', 'js'].some((ext) => existsSync(join(dir, 'src', `renderer.${ext}`))) if (hasRendererSrc) { warn(`entry.renderer 产物缺失(${entry.renderer})——按模板 README 的 esbuild 配方构建后放回`) } else { err(`entry.renderer 不存在: ${entry.renderer}`) } } if (entry.page && !existsSync(join(dir, entry.page))) err(`entry.page 不存在: ${entry.page}`) // ---- 尺寸 ---- const sizeOf = (f) => { const p = join(dir, f) return existsSync(p) ? statSync(p).size : -1 } if (sizeOf(mainFile) > MAX_MAIN_BYTES) err(`${mainFile} 超过 2MB 上限`) const rendererFile = entry.renderer if (rendererFile && sizeOf(rendererFile) > MAX_RENDERER_BYTES) { err(`${rendererFile} 超过 8MB 上限(宿主 readRenderer 拒绝装载)`) } // ---- main.mjs:同步 IO + 日志 ---- if (!entry.main || existsSync(join(dir, mainFile))) { const mainSrc = existsSync(join(dir, mainFile)) ? readFileSync(join(dir, mainFile), 'utf8') : '' if (mainSrc) { for (const m of mainSrc.matchAll(SYNC_IO)) { err(`${mainFile}: 同步 IO「${m[1]}(」阻塞主进程事件循环——改用 node:fs/promises 的异步版(性能铁律见 08-performance.md)`) } for (const m of mainSrc.matchAll(CONSOLE)) { const level = m[1] === 'log' || m[1] === 'trace' ? 'debug' : m[1] err(`${mainFile}: console.${m[1]}( 绕过统一日志——改用 api.log.${level}(见 L5 自诊断)`) } // 存储契约:用 SQLite 必须声明机器可读 schema(Agent 自我迭代与设置页展示同源) if (/storage\.db\.(exec|run|query|batch)\s*\(/.test(mainSrc) && !/declareSchema\s*\(/.test(mainSrc)) { err(`${mainFile}: 使用了 api.storage.db 但未调用 api.storage.declareSchema——每张表/每个字段的 description 是 Agent 内省与 UI 展示的依据(契约 0.3.0)`) } if (/requirePlugin\s*\(/.test(mainSrc) && !manifest.dependencies && !manifest.optionalDependencies) { err(`${mainFile}: 调用了 api.requirePlugin 但 manifest 未声明 dependencies/optionalDependencies——依赖声明即访问授权,未声明运行时直接抛错`) } } } // ---- renderer:单实例 + 三态 ---- if (rendererFile && existsSync(join(dir, rendererFile))) { const src = readFileSync(join(dir, rendererFile), 'utf8') if (!src.includes('__ASO_PLUGIN_HOST__')) { err(`${rendererFile}: 未引用 __ASO_PLUGIN_HOST__——React 必须经宿主 shim 单实例(esbuild --alias:react 配方见 L4),自带副本会导致 hooks 失效`) } else if (statSync(join(dir, rendererFile)).size > REACT_COPY_SUSPECT_BYTES) { warn(`${rendererFile} 超过 300KB:疑似打包了自带 React 副本或未裁剪依赖,请核对 esbuild 配方`) } const registersUi = /register(View|Panel)\s*\(/.test(src) const usesStates = STATE_COMPONENTS.test(src) || UI_KIT_IMPORT.test(src) if (registersUi && !usesStates) { warn(`${rendererFile}: 注册了 view/panel 但未使用公共三态组件(LoadingState/ErrorState/EmptyState)——异步区块请统一等待/失败呈现(见 07-ui-components.md)`) } } // ---- 全源码:硬编码色值 + 阻塞对话框 ---- for (const rel of collectFiles(dir)) { const src = readFileSync(join(dir, rel), 'utf8') src.split('\n').forEach((line, idx) => { for (const m of line.matchAll(HEX_COLOR)) { if (HEX_ALLOW.has(m[0].toLowerCase())) continue err(`${rel}:${idx + 1}: 硬编码色值 ${m[0]}——只允许 token(DESIGN.md 铁律 1;#fff 白字除外)`) } for (const m of line.matchAll(BLOCKING_DIALOG)) { warn(`${rel}:${idx + 1}: ${m[1]}( 是阻塞式同步对话框——请用组件库 Button + ErrorState/EmptyState 呈现`) } }) } return { errors, warns } } // ---- CLI ---- const args = process.argv.slice(2).filter((a) => a && !a.startsWith('-')) if (args.length === 0) { console.error('用法: pnpm plugin:check <插件目录> [更多目录...]') process.exit(2) } let totalErr = 0 let totalWarn = 0 for (const dir of args) { const { errors, warns } = checkPlugin(dir) totalErr += errors.length totalWarn += warns.length console.log(`\n== ${dir}`) for (const e of errors) console.log(` [error] ${e}`) for (const w of warns) console.log(` [warn ] ${w}`) if (errors.length === 0 && warns.length === 0) console.log(' 全部检查通过') } console.log(`\n汇总: ${totalErr} error, ${totalWarn} warn${contract ? `(宿主契约 ${contract})` : ''}`) process.exit(totalErr > 0 ? 1 : 0)