Files
apes-sidebar/scripts/check-plugin-api.mjs
T
figmar 99bef0f877
CI / ci (pull_request) Canceled after 0s
CI / ci-windows (pull_request) Canceled after 0s
CI / plugin-mount (pull_request) Canceled after 0s
chore(lint): drop unused imports flagged at HEAD
eslint was red at HEAD (5b715ac) in files untouched by this branch: unused statSync/dirname (check-plugin-api.mjs), unused PrefsSchema (src/index.ts, tests/smoke.spec.ts) and two Function types (tests/smoke.spec.ts). No behaviour change.
2026-10-11 21:18:08 +08:00

242 lines
13 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
/**
* check-plugin-api.mjs — DSH 插件「运行时契约」守卫
*
* 背景:dsh-apes-sidebar 长期出现「typecheck / build 全绿,运行时仍然报错」的
* 假验证问题。根因是本地 devDependencies 与 DSH 实际运行时(App 内 node_modules)
* 版本不一致,类型检查校验的是旧 API 契约。
*
* 本脚本真正比对的是「源码 → DSH 运行时」,而不是「源码 → 本地 node_modules」:
* 1) devDeps / peerDeps 与运行时版本漂移
* 2) 从 @deepseek-ai/* 导入的值符号是否真实存在于运行时包中(缺失即 React #130 元凶)
* 3) plugins 注册的 slot 是否满足宿主声明的 kind 要求(list→id / keyed→key / chain→select)
* 4) 是否使用了运行时已移除的 API(如 settings.register)
*
* 用法:
* node check-plugin-api.mjs [repoDir] [--app "/Applications/DSH Desktop.app"]
* 退出码:0 = 通过;1 = 发现问题(可接进 CI / build 前置)
*/
import { readFileSync, readdirSync, existsSync } from 'node:fs'
import { join, relative } from 'node:path'
const args = process.argv.slice(2)
const repoArg = args.find(a => !a.startsWith('--'))
const appArgIdx = args.indexOf('--app')
const APP = appArgIdx >= 0 && args[appArgIdx + 1]
? args[appArgIdx + 1]
: '/Applications/DSH Desktop.app'
const REPO = repoArg ?? process.cwd()
const APP_MODULES = join(APP, 'Contents/Resources/app/node_modules')
const problems = []
const notes = []
const fail = (m) => problems.push(m)
const note = (m) => notes.push(m)
const readJson = (p) => JSON.parse(readFileSync(p, 'utf8'))
function walk(dir, out = []) {
for (const e of readdirSync(dir, { withFileTypes: true })) {
if (e.name === 'node_modules' || e.name.startsWith('.')) continue
const p = join(dir, e.name)
if (e.isDirectory()) walk(p, out)
else if (/\.(ts|tsx)$/.test(e.name)) out.push(p)
}
return out
}
/** 运行时包的导出符号集合(解析 ESM 尾部 export { ... })。 */
function runtimeExports(pkgName) {
const p = join(APP_MODULES, pkgName)
if (!existsSync(p)) return null
const pkg = readJson(join(p, 'package.json'))
const entry = join(p, typeof pkg.exports?.['.']?.default === 'string'
? pkg.exports['.'].default : pkg.main ?? 'lib/index.js')
if (!existsSync(entry)) return { version: pkg.version, names: new Set(), entry: null }
const src = readFileSync(entry, 'utf8')
const names = new Set()
for (const m of src.matchAll(/export\s*\{([^}]*)\}/g)) {
for (const raw of m[1].split(',')) {
const n = raw.trim().split(/\s+as\s+/).pop()?.trim()
if (n) names.add(n)
}
}
return { version: pkg.version, names, entry }
}
/** 去掉注释,避免把文档里的 API 名字误判为真实调用。 */
const stripComments = (s) =>
s.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/(^|[^:])\/\/[^\n]*/g, '$1')
// ── 1. 版本漂移:package.json 声明 vs DSH 运行时 ──────────────────────────
console.log('■ 1. 依赖版本 vs DSH 运行时')
const pkg = readJson(join(REPO, 'package.json'))
const versionsUsed = new Set()
for (const f of walk(join(REPO, 'src'))) {
const s = stripComments(readFileSync(f, 'utf8'))
for (const m of s.matchAll(/from\s*['"](@deepseek-ai\/[a-z0-9-]+)['"]/g)) versionsUsed.add(m[1])
}
const drift = []
for (const dep of versionsUsed) {
const rt = runtimeExports(dep)
if (!rt) { note(` · ${dep}: 运行时未提供(宿主可能未安装该包)`); continue }
const dev = pkg.devDependencies?.[dep]
const peer = pkg.peerDependencies?.[dep]
const mark = dev === undefined ? '· ' : (dev === rt.version ? '✅' : '❌')
console.log(` ${mark} ${dep}: devDep=${dev ?? '(无)'} peer=${peer ?? '(无)'} runtime=${rt.version}`)
if (dev === undefined) continue
if (dev !== rt.version) {
drift.push(`${dep}: devDep ${dev} ≠ runtime ${rt.version}`)
fail(`devDependencies 精确锁定 ${dep}@${dev},与运行时 ${rt.version} 不一致,typecheck 校验的是旧契约`)
}
}
if (!drift.length) console.log(' ✅ 无漂移')
// ── 2. 导入的值符号是否存在于运行时 ──────────────────────────────────────
console.log('\n■ 2. 源码导入符号 vs 运行时导出')
let missingCount = 0
for (const f of walk(join(REPO, 'src'))) {
const src = stripComments(readFileSync(f, 'utf8'))
const re = /import\s+(type\s+)?\{([^}]*)\}\s*from\s*['"](@deepseek-ai\/[a-z0-9-]+)['"]/g
for (const m of src.matchAll(re)) {
if (m[1]) continue // 整块 type-only import
const pkgName = m[3]
const rt = runtimeExports(pkgName)
if (!rt || rt.names.size === 0) continue
for (const raw of m[2].split(',')) {
const name = raw.trim().split(/\s+as\s+/)[0]?.trim()
if (!name || name.startsWith('type ')) continue // 行内 type 成员
if (!rt.names.has(name)) {
missingCount++
fail(`运行时不存在符号:${name}(${pkgName})← ${relative(REPO, f)} — 渲染时会 e(undefined) → React #130`)
}
}
}
}
if (!missingCount) console.log(' ✅ 所有值导入在运行时均存在')
// ── 3. slot 注册是否满足宿主声明的 kind ─────────────────────────────────
console.log('\n■ 3. slot 注册 vs 宿主声明的 kind')
const hostKinds = new Map()
for (const pkg of readdirSync(APP_MODULES).filter(n => n.startsWith('dsh-') || n.startsWith('@'))) {
const base = pkg.startsWith('@') ? join(APP_MODULES, pkg) : APP_MODULES
const list = pkg.startsWith('@') ? readdirSync(base).filter(n => n.startsWith('dsh-')) : [pkg]
for (const name of list) {
const dir = join(base, name, 'lib')
if (!existsSync(dir)) continue
for (const f of readdirSync(dir)) {
if (!f.endsWith('.js')) continue
const s = readFileSync(join(dir, f), 'utf8')
for (const m of s.matchAll(/["']([a-zA-Z0-9._-]+\.[a-zA-Z0-9._-]+)["']\s*:\s*\{\s*kind:\s*["'](\w+)["']/g)) {
if (!hostKinds.has(m[1])) hostKinds.set(m[1], m[2])
}
}
}
}
let slotIssues = 0
for (const f of walk(join(REPO, 'src'))) {
const src = stripComments(readFileSync(f, 'utf8'))
for (const m of src.matchAll(/slots\.register\(\{([\s\S]*?)\}\s*,\s*[A-Za-z_$][\w$]*\s*\)/g)) {
const body = m[1]
const nameMatch = body.match(/name:\s*["']([^"']+)["']/)
if (!nameMatch) continue
const name = nameMatch[1]
const kind = hostKinds.get(name)
if (!kind || kind === 'single') continue
const has = (k) => new RegExp(`\\b${k}:`).test(body)
const need = { list: 'id', keyed: 'key', chain: 'select' }[kind]
if (need && !has(need)) {
slotIssues++
fail(`slot "${name}" 宿主声明为 ${kind},注册必须带 ${need} ← ${relative(REPO, f)}`)
}
}
}
if (!slotIssues) console.log(` ✅ 已核对 ${hostKinds.size} 个宿主 slot 声明,无缺参`)
// ── 4. 已移除 API -------------------------------------------------------
// 分两档:removed = 运行时确实不存在,调用即抛错(fail);
// deprecated = 运行时仍可用,但官方已挂 Note 要求迁移(note,不阻塞构建)。
console.log('\n■ 4. 已移除 / 变更 API')
const removed = [
{ re: /settings\.register\s*\(/, msg: 'settings.register 已移除,改用 settings.describe / define' },
]
const deprecated = [
{ re: /\.snapshotEvents\s*\(/, msg: 'snapshotEvents 已弃用(运行时仍可用;官方 Note 2026-09-09-deprecate-synchronous-session-event-reads 要求迁异步读)' },
{ re: /\.ownEvents\s*\(/, msg: 'ownEvents 已弃用(运行时仍可用,内部转调 snapshotEvents)' },
]
let removedHits = 0
let deprecatedHits = 0
for (const f of walk(join(REPO, 'src'))) {
const src = stripComments(readFileSync(f, 'utf8'))
for (const b of removed) if (b.re.test(src)) { removedHits++; fail(`${b.msg} ← ${relative(REPO, f)}`) }
for (const b of deprecated) if (b.re.test(src)) { deprecatedHits++; note(`⚠️ ${b.msg} ← ${relative(REPO, f)}`) }
}
if (!removedHits) console.log(' ✅ 未发现运行时已移除的 API')
if (deprecatedHits) console.log(` ⚠️ ${deprecatedHits} 处使用了已弃用但仍可用的 API(列为备注,不阻塞)`)
// ── 5. inline 包的运行期依赖闭包 ─────────────────────────────────────────
// 背景:vitest 的 `server.deps.inline` 会让 Vite 去解析被 inline 包的全部 import。
// 上游 `dsh-client-ui-primitives@0.2.0-rc.2` 把包内 `dependencies` 声明成了空,
// 但它的 `lib/index.js` 实际 import 了 23 个外部包 —— 工程未声明的那些在 pnpm
// 严格布局下解析不到,会让「间接 import 它的每个 spec 文件」收集失败(0 test)。
// 危险点:vitest 此时仍打印 `Tests 1015 passed`,只有 `Test Files 38 failed` 与
// 退出码 1 暴露真相 —— 极易被当成「环境噪音」放过。
console.log('\n■ 5. inline 包的运行期依赖闭包')
const vitestCfg = existsSync(join(REPO, 'vitest.config.ts'))
? readFileSync(join(REPO, 'vitest.config.ts'), 'utf8') : ''
// 解析 `server.deps.inline`:先还原正则里的 `\/` 转义,再按 package 名 token 提取,
// 构造前缀匹配(兼容 `/@scope\/pkg-.*/` 这类写法)。
const inlineSource = [...vitestCfg.matchAll(/inline:\s*\[([\s\S]*?)\]/g)]
.map(m => m[1].replace(/\\\//g, '/')).join(' ')
const inlinePats = [...inlineSource.matchAll(/@[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+/g)]
.map(m => new RegExp('^' + m[0].replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '.*$'))
const NODE_BUILTINS = new Set(['fs', 'path', 'url', 'util', 'os', 'crypto', 'events', 'stream',
'assert', 'buffer', 'child_process', 'process', 'module', 'timers', 'zlib', 'http', 'https',
'net', 'tty', 'worker_threads', 'perf_hooks', 'readline', 'string_decoder', 'querystring',
'v8', 'vm', 'constants', 'punycode', 'async_hooks', 'dns', 'dgram', 'cluster', 'inspector'])
const rootOf = (spec) => spec.startsWith('@') ? spec.split('/').slice(0, 2).join('/') : spec.split('/')[0]
const isExternalSpec = (spec) => !spec.startsWith('.') && !spec.startsWith('node:') && !NODE_BUILTINS.has(rootOf(spec))
const IMPORT_RE = /(?:from\s*|import\s*\(?\s*)['"]([^'"]+)['"]/g
const declaredAll = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}), ...(pkg.peerDependencies ?? {}) }
let inlineChecked = 0
let inlineMissing = 0
for (const dep of versionsUsed) {
if (!inlinePats.some(re => re.test(dep))) continue
const dir = join(APP_MODULES, dep)
if (!existsSync(dir)) continue
const meta = readJson(join(dir, 'package.json'))
const entryRel = typeof meta.exports?.['.']?.default === 'string'
? meta.exports['.'].default : (meta.main ?? 'lib/index.js')
const entry = join(dir, entryRel)
if (!existsSync(entry)) continue
inlineChecked++
const src = readFileSync(entry, 'utf8')
const specs = [...src.matchAll(IMPORT_RE)].map(m => m[1]).filter(isExternalSpec)
const roots = new Set(specs.map(rootOf))
const missingDeps = [...roots].filter(r => !declaredAll[r]).sort()
if (missingDeps.length) {
inlineMissing++
fail(`${dep}@${meta.version} 被 vitest inline,但其运行期依赖有 ${missingDeps.length}/${roots.size} 个未在本工程声明:`
+ `\n ${missingDeps.join(', ')}`
+ `\n → Vite 解析失败会让每个间接 import 它的 spec 文件收集失败(0 test),`
+ `而 vitest 仍会打印 "Tests N passed";以上游包 package.json 的 dependencies 为空时尤其要查`)
}
if (/sourceMappingURL=/.test(src) && !existsSync(entry + '.map')) {
note(`⚠️ ${dep}: 入口带 sourceMappingURL 注释但包内无 .map(上游打包缺陷)→ vitest 报 "trying to read the map file" ENOENT。用 vitest plugin 的 transform 剥掉该注释,不要手改 node_modules(pnpm install 即失效)`)
}
}
if (!inlineChecked) console.log(' ✅ vitest.config 未配置 server.deps.inline(或无匹配包),无需检查')
else if (!inlineMissing) console.log(` ✅ 已核对 ${inlineChecked} 个 inline 包的依赖闭包`)
// ── 结论 ────────────────────────────────────────────────────────────────
console.log('\n' + '─'.repeat(60))
if (problems.length) {
console.log(`❌ 发现 ${problems.length} 个问题:\n`)
for (const p of problems) console.log(' · ' + p)
if (notes.length) { console.log('\n备注:'); for (const n of notes) console.log(n) }
process.exit(1)
}
console.log('✅ 运行时契约检查通过')
if (notes.length) { console.log('\n备注:'); for (const n of notes) console.log(n) }