From 2b8ba87d8115ca24e7438eedf186f14141e792c4 Mon Sep 17 00:00:00 2001 From: figmar Date: Sun, 11 Oct 2026 19:33:44 +0800 Subject: [PATCH] chore: add check-plugin-api.mjs contract check + verify script - Copy check-plugin-api.mjs into scripts/ (from WorkBuddy diagnosis) - Add 'verify' script: node scripts/check-plugin-api.mjs - 5-section contract check: dependency versions vs DSH runtime, import symbols vs runtime exports, slot registration, removed/deprecated APIs, inline package dependency closure - All 5 sections pass (4 deprecation warnings, non-blocking) --- package.json | 1 + scripts/check-plugin-api.mjs | 241 +++++++++++++++++++++++++++++++++++ 2 files changed, 242 insertions(+) create mode 100644 scripts/check-plugin-api.mjs diff --git a/package.json b/package.json index d8f688d..c4c2d8f 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,7 @@ "test:mount:aggregate": "bash scripts/e2e-aggregate-mount.sh", "clean": "node -e \"require('node:fs').rmSync('lib',{recursive:true,force:true})\"", "check:consumer-types": "bash scripts/check-consumer-types.sh", + "verify": "node scripts/check-plugin-api.mjs", "lint": "eslint .", "switch": "bash scripts/switch.sh", "switch:status": "bash scripts/switch.sh --status", diff --git a/scripts/check-plugin-api.mjs b/scripts/check-plugin-api.mjs new file mode 100644 index 0000000..14f227a --- /dev/null +++ b/scripts/check-plugin-api.mjs @@ -0,0 +1,241 @@ +#!/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, statSync, existsSync } from 'node:fs' +import { join, relative, dirname } 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) }