docs(plugins): 依赖与存储文档(L8/L9)+ 检验器入库并升级契约 0.3.0 规则

This commit is contained in:
Rick Ye
2026-09-11 15:00:59 +08:00
parent 1cb2b554c0
commit 9b451c8c25
10 changed files with 558 additions and 13 deletions
+260
View File
@@ -0,0 +1,260 @@
#!/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)
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env node
/**
* 拉取 beautifului.dev 的 shadcn registry 源到 docs/ui-ref/beautifului/。
* 组件清单来自站点 01-21 节;slug 与锚点基本一致(个别做了别名兜底)。
* 用法:node scripts/fetch-beautifului-registry.mjs
*/
import { mkdirSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
const SLUGS = [
'loading-state',
'thinking',
'thinking-state',
'streaming-text',
'approval-card',
'tool-chips',
'task-rows',
'chat',
'chat-composer',
'prompt-bar',
'recommendation-card',
'context-cards',
'diff-table',
'records-table',
'filter-table',
'sidebar-nav',
'search',
'flowchart',
'insight-cards',
'code-block',
'fine-tune-card',
'selection-actions',
'agent-screen',
'foundation',
'button',
'glide-menu',
'entity-chip',
'value-pill',
'shimmer',
'stream-text'
]
const OUT = join(process.cwd(), 'docs', 'ui-ref', 'beautifului')
mkdirSync(OUT, { recursive: true })
for (const slug of SLUGS) {
const url = `https://www.beautifului.dev/r/${slug}.json`
try {
const res = await fetch(url)
if (!res.ok) {
console.log(`[miss ] ${slug} (${res.status})`)
continue
}
const json = await res.json()
writeFileSync(join(OUT, `${slug}.json`), JSON.stringify(json, null, 2), 'utf8')
const files = (json.files ?? []).map((f) => f.path).join(', ')
console.log(`[ok ] ${slug} files: ${files || '(none)'}`)
} catch (err) {
console.log(`[error] ${slug}: ${err.message}`)
}
}