261 lines
12 KiB
JavaScript
261 lines
12 KiB
JavaScript
#!/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('<svg')) {
|
||
const names = pluginIconNames()
|
||
if (names) {
|
||
const pascal = manifest.icon
|
||
.trim()
|
||
.replace(/[-_\s]+(.)/g, (_, c) => 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)
|