350 lines
13 KiB
TypeScript
350 lines
13 KiB
TypeScript
/**
|
||||
|
|
* 插件兼容性预检 —— 判据实现在此(档案 71 / 交接单 T05)
|
|||
|
|
*
|
|||
|
|
* 背景:`@anysearch/anysearch-dsh` 与平台 dsh 0.1.2-rc.1 不兼容,装进实例后
|
|||
|
|
* `plugin tree failed to load`(缺 `assertNever`)→ 实例崩溃循环(档案 70)。
|
|||
|
|
* 现有链路只查「来源/内容安全」,不查「与平台版本兼不兼容」→ 本模块补上。
|
|||
|
|
*
|
|||
|
|
* 两条判据(均已在生产实测复现该 bug):
|
|||
|
|
*
|
|||
|
|
* A. 依赖范围(semver,**默认语义,不传 includePrerelease**)
|
|||
|
|
* 插件声明的 `@deepseek-ai/*` 依赖范围必须**接受**平台内置的同名包版本。
|
|||
|
|
* ⚠️ 为什么必须用默认语义:npm/pnpm 的实际安装判定就是默认语义 ——
|
|||
|
|
* anysearch 写 `>=0.1.1-rc.1 <0.1.2`,默认语义下 **不满足** 0.1.2-rc.1,
|
|||
|
|
* 于是 pnpm 给它装了自带的 `[email protected]` → 与平台 dsh-llm 冲突。
|
|||
|
|
* 若传 includePrerelease 会把它误判为「满足」而漏掉这个 bug(实测已确认)。
|
|||
|
|
*
|
|||
|
|
* B. 导出符号(运行时真值)
|
|||
|
|
* 插件 bundle 里 `import { X } from '@deepseek-ai/Y'`,`X` 必须在平台包 Y 的
|
|||
|
|
* **运行时真实导出**里存在。取法 = `await import()` 后取 `Object.keys()`,
|
|||
|
|
* 比解析 `.d.ts` 可靠(实测平台 223 个包中 220 个可取到导出清单)。
|
|||
|
|
*
|
|||
|
|
* 覆盖边界(重要):判据 B 只看**被扫描目录内**的 import。若插件把平台 API 的调用
|
|||
|
|
* 藏在**传递依赖**里(anysearch 就是:越界的是它的依赖 dsh-tool-web,不在 tgz 内),
|
|||
|
|
* 本模块扫不出来 —— 那种情况由「启用前扫 profile node_modules」那一层负责(T05 §五.4)。
|
|||
|
|
*
|
|||
|
|
* 设计原则:**任何异常都不得让上传流程失败** —— 判不出来就返回 `unknown`(放行 + 标记待复核),
|
|||
|
|
* 绝不误伤正常插件。
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
|
|||
|
|
import { join, extname } from 'node:path'
|
|||
|
|
import { pathToFileURL } from 'node:url'
|
|||
// 平台内置包的落点**不能写死**:`npm root -g` 随发行版变(`/usr/local/lib` vs `/usr/lib`),
|
||||
|
|
// 写死只会在另一种布局上静默退化成「平台包目录不可读」而不报错(测试服实测 = 0)。
|
|||
|
|
// 统一走 `dsh-install.ts` 的按序探测。
|
|||
|
|
import { dshScopeDir } from './dsh-install.js'
|
|||
|
||||
|
|
const DS_SCOPE = '@deepseek-ai/'
|
|||
|
|
|
|||
|
|
export interface CompatFinding {
|
|||
|
|
/** dep-range = 依赖范围不接受平台版本;missing-export = 平台包缺该导出 */
|
|||
|
|
kind: 'dep-range' | 'missing-export'
|
|||
|
|
pkg: string
|
|||
|
|
detail: string
|
|||
|
|
file?: string
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface CompatResult {
|
|||
|
|
/** incompatible = 已判定不兼容(调用方默认拒绝,admin 可显式信任);unknown = 静态判不了(放行 + 标记) */
|
|||
|
|
level: 'ok' | 'incompatible' | 'unknown'
|
|||
|
|
findings: CompatFinding[]
|
|||
|
|
/** 平台内置包数量(快照,供回显) */
|
|||
|
|
platformPkgs: number
|
|||
|
|
/** 是否真的看到了 @deepseek-ai 的依赖声明或 import(区分 ok / unknown 用) */
|
|||
|
|
sawPlatformRef: boolean
|
|||
|
|
/** 判定过程出错时的说明(此时 level=unknown) */
|
|||
|
|
note?: string
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ------------------------------------------------------------------ *
|
|||
|
|
* 平台侧:版本表(读 package.json)与导出符号(按需 import,带缓存)
|
|||
|
|
* ------------------------------------------------------------------ */
|
|||
|
|
|
|||
|
|
const versionCache = new Map<string, string | null>()
|
|||
|
|
const exportCache = new Map<string, Set<string> | null>()
|
|||
|
|
|
|||
|
|
function shortName(pkg: string): string {
|
|||
|
|
return pkg.startsWith(DS_SCOPE) ? pkg.slice(DS_SCOPE.length) : pkg
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function platformPkgDir(pkg: string): string {
|
|||
return join(dshScopeDir(), shortName(pkg))
|
||||
}
|
||||
|
|
|
|||
/** 是否已经就「平台包目录不可读」告过一次警(避免刷日志)。 */
|
||||
|
|
let warnedScopeUnreadable = false
|
|||
|
|
|
|||
/** 平台内置 @deepseek-ai 包的数量(目录级,低成本)。 */
|
||||
|
|
export function platformPkgCount(): number {
|
|||
|
|
try {
|
|||
return readdirSync(dshScopeDir()).filter((n) => !n.startsWith('.')).length
|
||||
} catch {
|
||||
// ⚠️ 这里**不能只返回 0**:调用方会把它当成"没有平台包"⇒ 预检直接退化成
|
||||
|
|
// 「平台包目录不可读」——**安全网静默失效**(上传不兼容插件不再被拦)。
|
|||
|
|
// 2026-09-14:加一次告警,让"读不到"这件事可观测(见 `04-调整方案/88`)。
|
|||
|
|
if (!warnedScopeUnreadable) {
|
|||
|
|
warnedScopeUnreadable = true
|
|||
|
|
console.warn(
|
|||
|
|
`[plugin-compat] 平台包目录不可读:${dshScopeDir()} —— 插件兼容性预检将退化为「不检查」。` +
|
|||
|
|
' 检查 DSHS_DSH_BIN / DSH_COMPAT_ROOT 是否正确(见 src/web/dsh-install.ts)。',
|
|||
|
|
)
|
|||
|
|
}
|
|||
return 0
|
||||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 平台内置某包的版本;平台没有该包 → null。 */
|
|||
|
|
function platformVersion(pkg: string): string | null {
|
|||
|
|
const hit = versionCache.get(pkg)
|
|||
|
|
if (hit !== undefined) return hit
|
|||
|
|
let v: string | null = null
|
|||
|
|
try {
|
|||
|
|
const pj = join(platformPkgDir(pkg), 'package.json')
|
|||
|
|
if (existsSync(pj)) {
|
|||
|
|
const meta = JSON.parse(readFileSync(pj, 'utf8')) as { version?: unknown }
|
|||
|
|
v = typeof meta.version === 'string' ? meta.version : null
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
v = null
|
|||
|
|
}
|
|||
|
|
versionCache.set(pkg, v)
|
|||
|
|
return v
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function resolveEntryFile(pkg: string): string | null {
|
|||
|
|
const base = platformPkgDir(pkg)
|
|||
|
|
const cands: string[] = []
|
|||
|
|
try {
|
|||
|
|
const meta = JSON.parse(readFileSync(join(base, 'package.json'), 'utf8')) as {
|
|||
|
|
main?: unknown
|
|||
|
|
module?: unknown
|
|||
|
|
exports?: unknown
|
|||
|
|
}
|
|||
|
|
if (typeof meta.module === 'string') cands.push(meta.module)
|
|||
|
|
if (typeof meta.main === 'string') cands.push(meta.main)
|
|||
|
|
} catch {
|
|||
|
|
/* 忽略:退回默认候选 */
|
|||
|
|
}
|
|||
|
|
cands.push('lib/index.js', 'dist/index.js', 'esm/index.js', 'index.js', 'lib/index.mjs')
|
|||
|
|
for (const c of cands) {
|
|||
|
|
const f = join(base, c)
|
|||
|
|
try {
|
|||
|
|
if (existsSync(f) && statSync(f).isFile()) return f
|
|||
|
|
} catch {
|
|||
|
|
/* 忽略 */
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return null
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 平台某包的运行时导出符号集合。返回 null = 无法判定(**不能**据此断言不兼容)。
|
|||
|
|
* 只对「插件真的 import 了的包」调用,避免启动成本。
|
|||
|
|
*/
|
|||
|
|
async function platformExports(pkg: string): Promise<Set<string> | null> {
|
|||
|
|
const hit = exportCache.get(pkg)
|
|||
|
|
if (hit !== undefined) return hit
|
|||
|
|
let keys: Set<string> | null = null
|
|||
|
|
try {
|
|||
|
|
const entry = resolveEntryFile(pkg)
|
|||
|
|
if (entry !== null) {
|
|||
|
|
const mod = (await import(pathToFileURL(entry).href)) as Record<string, unknown>
|
|||
|
|
keys = new Set(Object.keys(mod))
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
keys = null // import 失败可能是环境原因,不能当作「插件不兼容」的证据
|
|||
|
|
}
|
|||
|
|
exportCache.set(pkg, keys)
|
|||
|
|
return keys
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type SemverLike = { satisfies(v: string, r: string): boolean }
|
|||
|
|
let semverPromise: Promise<SemverLike | null> | null = null
|
|||
|
|
|
|||
|
|
function loadSemver(): Promise<SemverLike | null> {
|
|||
|
|
semverPromise ??= (async () => {
|
|||
|
|
try {
|
|||
|
|
const m = (await import('semver')) as unknown as { default?: SemverLike } & SemverLike
|
|||
|
|
return (m.default ?? m) as SemverLike
|
|||
|
|
} catch {
|
|||
|
|
return null
|
|||
|
|
}
|
|||
|
|
})()
|
|||
|
|
return semverPromise
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ------------------------------------------------------------------ *
|
|||
|
|
* 插件侧:依赖声明 + bundle 内的 @deepseek-ai import
|
|||
|
|
* ------------------------------------------------------------------ */
|
|||
|
|
|
|||
|
|
const MAX_SCAN_FILES = 4000
|
|||
|
|
const SCAN_EXT = new Set(['.js', '.mjs', '.cjs'])
|
|||
|
|
const IMPORT_RE =
|
|||
|
|
/(?:import|export)\s*(?:\*\s*as\s*[\w$]+|\{([^}]*)\})\s*from\s*["'](@deepseek-ai\/[^"']+)["']/g
|
|||
|
|
|
|||
|
|
interface ImportHit {
|
|||
|
|
pkg: string
|
|||
|
|
named: string[]
|
|||
|
|
file: string
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function findPackageJson(root: string): string | null {
|
|||
|
|
const direct = join(root, 'package.json')
|
|||
|
|
if (existsSync(direct)) return direct
|
|||
|
|
try {
|
|||
|
|
for (const e of readdirSync(root, { withFileTypes: true })) {
|
|||
|
|
if (e.isDirectory()) {
|
|||
|
|
const nested = findPackageJson(join(root, e.name))
|
|||
|
|
if (nested !== null) return nested
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
/* 忽略 */
|
|||
|
|
}
|
|||
|
|
return null
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function collectJsFiles(dir: string, out: string[] = [], depth = 0): string[] {
|
|||
|
|
if (out.length >= MAX_SCAN_FILES || depth > 8) return out
|
|||
|
|
let entries: import('node:fs').Dirent[]
|
|||
|
|
try {
|
|||
|
|
entries = readdirSync(dir, { withFileTypes: true })
|
|||
|
|
} catch {
|
|||
|
|
return out
|
|||
|
|
}
|
|||
|
|
for (const e of entries) {
|
|||
|
|
if (out.length >= MAX_SCAN_FILES) break
|
|||
|
|
if (e.isDirectory()) {
|
|||
|
|
if (e.name === 'node_modules' || e.name === '.git') continue
|
|||
|
|
collectJsFiles(join(dir, e.name), out, depth + 1)
|
|||
|
|
} else if (SCAN_EXT.has(extname(e.name))) {
|
|||
|
|
out.push(join(dir, e.name))
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return out
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function scanImports(root: string): { hits: ImportHit[]; fileCount: number } {
|
|||
|
|
const files = collectJsFiles(root)
|
|||
|
|
const hits: ImportHit[] = []
|
|||
|
|
for (const f of files) {
|
|||
|
|
let text: string
|
|||
|
|
try {
|
|||
|
|
text = readFileSync(f, 'utf8')
|
|||
|
|
} catch {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
if (!text.includes(DS_SCOPE)) continue
|
|||
|
|
IMPORT_RE.lastIndex = 0
|
|||
|
|
let m: RegExpExecArray | null
|
|||
|
|
while ((m = IMPORT_RE.exec(text)) !== null) {
|
|||
|
|
const spec = m[1] ?? ''
|
|||
|
|
const named = spec
|
|||
|
|
.split(',')
|
|||
|
|
.map((s) => s.trim().split(/\s+as\s+/)[0]?.trim() ?? '')
|
|||
|
|
.filter((s) => s !== '' && !s.startsWith('type '))
|
|||
|
|
hits.push({ pkg: m[2], named, file: f.slice(root.length + 1) })
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return { hits, fileCount: files.length }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function declaredDeepseekDeps(pkgPath: string): Array<[string, string]> {
|
|||
|
|
let meta: {
|
|||
|
|
dependencies?: Record<string, string>
|
|||
|
|
peerDependencies?: Record<string, string>
|
|||
|
|
optionalDependencies?: Record<string, string>
|
|||
|
|
}
|
|||
|
|
try {
|
|||
|
|
meta = JSON.parse(readFileSync(pkgPath, 'utf8')) as typeof meta
|
|||
|
|
} catch {
|
|||
|
|
return []
|
|||
|
|
}
|
|||
|
|
const merged = { ...meta.peerDependencies, ...meta.optionalDependencies, ...meta.dependencies }
|
|||
|
|
return Object.entries(merged).filter(([k]) => k.startsWith(DS_SCOPE))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ------------------------------------------------------------------ *
|
|||
|
|
* 主入口
|
|||
|
|
* ------------------------------------------------------------------ */
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 对一个**已解包**的插件目录做兼容性预检(只读)。
|
|||
|
|
* @param root 解包后的插件根目录(含 package.json 的那一层或其上层)
|
|||
|
|
*/
|
|||
|
|
export async function checkPluginCompat(root: string): Promise<CompatResult> {
|
|||
|
|
const out: CompatResult = {
|
|||
|
|
level: 'unknown',
|
|||
|
|
findings: [],
|
|||
|
|
platformPkgs: platformPkgCount(),
|
|||
|
|
sawPlatformRef: false,
|
|||
|
|
}
|
|||
|
|
try {
|
|||
|
|
if (out.platformPkgs === 0) {
|
|||
out.note = `平台包目录不可读:${dshScopeDir()}`
|
||||
return out
|
||||
|
|
}
|
|||
|
|
const pkgPath = findPackageJson(root)
|
|||
|
|
if (pkgPath === null) {
|
|||
|
|
out.note = '未找到 package.json'
|
|||
|
|
return out
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---- 判据 A:依赖范围 ----
|
|||
|
|
const deps = declaredDeepseekDeps(pkgPath)
|
|||
|
|
if (deps.length > 0) out.sawPlatformRef = true
|
|||
|
|
const semver = deps.length > 0 ? await loadSemver() : null
|
|||
|
|
for (const [pkg, range] of deps) {
|
|||
|
|
const v = platformVersion(pkg)
|
|||
|
|
if (v === null) continue // 平台没这个包:不算不兼容(插件自带)
|
|||
|
|
if (semver === null) {
|
|||
|
|
out.note = 'semver 不可用,依赖范围未判定'
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
let ok: boolean
|
|||
|
|
try {
|
|||
|
|
ok = semver.satisfies(v, range) // **默认语义**:与 pnpm 的实际安装判定一致
|
|||
|
|
} catch {
|
|||
|
|
continue // 范围写法无法解析:不据此断言
|
|||
|
|
}
|
|||
|
|
if (!ok) {
|
|||
|
|
out.findings.push({
|
|||
|
|
kind: 'dep-range',
|
|||
|
|
pkg,
|
|||
|
|
detail: `平台为 ${v},插件要求 ${range}(不满足)`,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---- 判据 B:导出符号 ----
|
|||
|
|
const { hits, fileCount } = scanImports(root)
|
|||
|
|
if (hits.length > 0) out.sawPlatformRef = true
|
|||
|
|
for (const h of hits) {
|
|||
|
|
if (h.named.length === 0) continue
|
|||
|
|
if (platformVersion(h.pkg) === null) continue // 非平台内置包:跳过
|
|||
|
|
const exports = await platformExports(h.pkg)
|
|||
|
|
if (exports === null) continue // 拿不到导出清单 → 不判定
|
|||
|
|
const missing = h.named.filter((n) => !exports.has(n))
|
|||
|
|
if (missing.length > 0) {
|
|||
|
|
out.findings.push({
|
|||
|
|
kind: 'missing-export',
|
|||
|
|
pkg: h.pkg,
|
|||
|
|
detail: `平台包未导出:${missing.join(', ')}`,
|
|||
|
|
file: h.file,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
out.level = out.findings.length > 0 ? 'incompatible' : out.sawPlatformRef ? 'ok' : 'unknown'
|
|||
|
|
if (out.level === 'unknown' && out.note === undefined) {
|
|||
|
|
out.note = `插件未声明也未直接 import 任何 @deepseek-ai 包(扫描 ${fileCount} 个 js 文件)→ 需装后扫 node_modules 复核`
|
|||
|
|
}
|
|||
|
|
return out
|
|||
|
|
} catch (err) {
|
|||
|
|
// 任何意外都不影响上传流程:降级为 unknown(放行 + 标记)
|
|||
|
|
out.level = 'unknown'
|
|||
|
|
out.note = `预检异常(已降级为待复核):${err instanceof Error ? err.message : String(err)}`
|
|||
|
|
return out
|
|||
|
|
}
|
|||
|
|
}
|