Files
inkreach-agent/scripts/check-plugin.mjs
T

261 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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)