Compare commits

..
3 Commits
Author SHA1 Message Date
figmar 5a715acb85 fix(test): remove jsdom pragma + add window stub DOM surface
CI / ci (push) Canceled after 0s
CI / ci-windows (push) Canceled after 0s
CI / plugin-mount (push) Canceled after 0s
A/B fix per WorkBuddy review:
- browser-globals.ts: add addEventListener/removeEventListener/dispatchEvent
  to window stub, add compatMode to document stub
- Remove // @vitest-environment jsdom from 5 specs that only use
  renderToString/createElement (SSR, no DOM needed)

Test Files 131 passed (131), Tests 1367 passed, EXIT=0
2026-10-11 20:01:21 +08:00
figmar 2b8ba87d81 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)
2026-10-11 19:33:44 +08:00
figmar a6dd8aa42a fix(test): add missing devDeps + sourcemap transform + jsdom directives
WorkBuddy review found 38 of 131 spec files failed at collection time
(0 tests executed) — the '1015 passed' was a false green because vitest
only counts 'Tests failed', not 'Test Files failed'.

Root cause: dsh-client-ui-primitives@0.2.0-rc.2 declares dependencies:null
but statically imports 23 external root packages. Our project declared
only 18. Under pnpm strict layout, the missing 5 (dsh-client-store,
dsh-util-code-language, dsh-util-workspace-path, diff, simple-icons)
were unresolvable when Vite inlined the primitives package.

Fixes:
- Add 7 missing devDeps: dsh-client-store@0.2.0-rc.2,
  dsh-util-code-language@0.2.0-rc.2, dsh-util-workspace-path@0.2.0-rc.2,
  diff@9.0.0, simple-icons@16.35.0, zustand@5.0.15, immer@11.1.21
- vitest.config.ts: add transform plugin to strip sourceMappingURL
  comments from dsh-client-ui-primitives (upstream ships lib/index.js
  with trailing sourceMappingURL but package.json files excludes .map)
- 5 spec files: add @vitest-environment jsdom directive (they import
  dsh-client-ui-primitives which calls window.addEventListener at module
  load; node environment lacks window)
- Remove empty .map file hack from node_modules (ineffective + .gitignore
  blocks it from version control)

Result: Test Files 131 passed (131), Tests 1367 passed | 9 skipped,
EXIT=0.

Verified: simple-icons 25 named exports all present.
Typecheck + build pass.
2026-10-11 19:33:01 +08:00
6 changed files with 360 additions and 1 deletions
+9 -1
View File
@@ -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",
@@ -159,6 +160,7 @@
"@deepseek-ai/dsh-agent-loop": "0.2.0-rc.2",
"@deepseek-ai/dsh-attachment": "0.2.0-rc.2",
"@deepseek-ai/dsh-client-locale": "0.2.0-rc.2",
"@deepseek-ai/dsh-client-store": "0.2.0-rc.2",
"@deepseek-ai/dsh-client-ui-conversation": "0.2.0-rc.2",
"@deepseek-ai/dsh-client-ui-primitives": "0.2.0-rc.2",
"@deepseek-ai/dsh-client-ui-settings": "0.2.0-rc.2",
@@ -177,7 +179,9 @@
"@deepseek-ai/dsh-system-prompt": "0.2.0-rc.2",
"@deepseek-ai/dsh-tools": "0.2.0-rc.2",
"@deepseek-ai/dsh-user-approval": "0.2.0-rc.2",
"@deepseek-ai/dsh-util-code-language": "0.2.0-rc.2",
"@deepseek-ai/dsh-util-time": "0.2.0-rc.2",
"@deepseek-ai/dsh-util-workspace-path": "0.2.0-rc.2",
"@eslint/js": "^10.0.1",
"@huanlin/dsh-plugin-better-locale": "^0.1.0",
"@playwright/test": "^1.62.1",
@@ -191,9 +195,11 @@
"@xterm/xterm": "^5.5.0",
"anser": "^2.3.5",
"cordis": "^4.0.0-rc.8",
"diff": "9.0.0",
"eslint": "^10.9.1",
"eslint-plugin-react-hooks": "^7.1.1",
"globals": "^17.12.0",
"immer": "11.1.21",
"jsdom": "^29.1.1",
"katex": "^0.16.47",
"lightningcss": "^1.32.0",
@@ -212,11 +218,13 @@
"react": "^18.2.0",
"react-dom": "18.2.0",
"shiki": "^4.3.1",
"simple-icons": "16.35.0",
"tsdown": "^0.22.2",
"typescript": "^5.6.0",
"typescript-eslint": "^8.69.0",
"unrun": "^0.2.39",
"vitest": "^4.1.8"
"vitest": "^4.1.8",
"zustand": "5.0.15"
},
"forkedFrom": {
"name": "dsh-better-sidebar",
+89
View File
@@ -117,6 +117,9 @@ importers:
'@deepseek-ai/dsh-client-locale':
specifier: 0.2.0-rc.2
version: 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)
'@deepseek-ai/dsh-client-store':
specifier: 0.2.0-rc.2
version: 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)
'@deepseek-ai/dsh-client-ui-conversation':
specifier: 0.2.0-rc.2
version: 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)
@@ -171,9 +174,15 @@ importers:
'@deepseek-ai/dsh-user-approval':
specifier: 0.2.0-rc.2
version: 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-agent@0.2.0-rc.2(b872d4d836bb263e5b3a50782f98c977))(@deepseek-ai/dsh-brand@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))(@deepseek-ai/dsh-llm@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))(@deepseek-ai/dsh-scope@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)))(@deepseek-ai/dsh-session@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-scope@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))))(@deepseek-ai/dsh-system-prompt@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))(@deepseek-ai/dsh-llm@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))(@deepseek-ai/dsh-scope@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))))
'@deepseek-ai/dsh-util-code-language':
specifier: 0.2.0-rc.2
version: 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)
'@deepseek-ai/dsh-util-time':
specifier: 0.2.0-rc.2
version: 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)
'@deepseek-ai/dsh-util-workspace-path':
specifier: 0.2.0-rc.2
version: 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)
'@eslint/js':
specifier: ^10.0.1
version: 10.0.1(eslint@10.11.0)
@@ -213,6 +222,9 @@ importers:
cordis:
specifier: ^4.0.0-rc.8
version: 4.0.0-rc.8(@cordisjs/plugin-loader@1.0.0-rc.5)
diff:
specifier: 9.0.0
version: 9.0.0
eslint:
specifier: ^10.9.1
version: 10.11.0
@@ -222,6 +234,9 @@ importers:
globals:
specifier: ^17.12.0
version: 17.13.0
immer:
specifier: 11.1.21
version: 11.1.21
jsdom:
specifier: ^29.1.1
version: 29.1.1
@@ -276,6 +291,9 @@ importers:
shiki:
specifier: ^4.3.1
version: 4.4.3
simple-icons:
specifier: 16.35.0
version: 16.35.0
tsdown:
specifier: ^0.22.2
version: 0.22.14(typescript@5.6.2)(unrun@0.2.39)
@@ -291,6 +309,9 @@ importers:
vitest:
specifier: ^4.1.8
version: 4.1.11(@types/node@24.13.3)(jsdom@29.1.1)(vite@8.2.2(@types/node@24.13.3)(yaml@2.9.1))
zustand:
specifier: 5.0.15
version: 5.0.15(@types/react@18.3.31)(immer@11.1.21)(react@18.2.0)
packages:
@@ -613,6 +634,11 @@ packages:
peerDependencies:
'@deepseek-ai/cordis': ~4.0.4
'@deepseek-ai/dsh-client-store@0.2.0-rc.2':
resolution: {integrity: sha512-unHIDNnHudDIj05W99m+dVPXLYy7qNvMJ/D5FWb7cov0CbiQvliqbvFeMAU6LzC+u3vTkVJgSMIAwAVdQgYHEA==}
peerDependencies:
'@deepseek-ai/cordis': ~4.0.4
'@deepseek-ai/dsh-client-ui-conversation@0.2.0-rc.2':
resolution: {integrity: sha512-Y1eWKVeh84cbB0fcV0MFOMUHBeptlzBCzsw6xslZS7p1a8l9PeFcjx5ZVTkKFuFMCfwEHWTeV9HcYqWLYH0bhw==}
peerDependencies:
@@ -838,6 +864,11 @@ packages:
'@deepseek-ai/dsh-session': 0.2.0-rc.2
'@deepseek-ai/dsh-system-prompt': 0.2.0-rc.2
'@deepseek-ai/dsh-util-code-language@0.2.0-rc.2':
resolution: {integrity: sha512-MYp8KlKWKgwxRfwwMbji4zweDZxVJ1smaneMAKjJvexDc9v+C40o1pJL5Ch/atny8a7O4aiBM1uSvC4bo7/S3g==}
peerDependencies:
'@deepseek-ai/cordis': ~4.0.4
'@deepseek-ai/dsh-util-crypto@0.2.0-rc.2':
resolution: {integrity: sha512-5OvvmepY69uXTagUMlnoHqg7euI4ZMifLgfdV8xZO20OslWGGiRCkJbZRyDtcJG9GsXWz9UimfWmUu52zKXJ5w==}
peerDependencies:
@@ -853,6 +884,11 @@ packages:
peerDependencies:
'@deepseek-ai/cordis': ~4.0.4
'@deepseek-ai/dsh-util-workspace-path@0.2.0-rc.2':
resolution: {integrity: sha512-pjjpi+hN29dVf9HMitxo5gUAXKNdYcJ75vNVjKp2T6sdBy0wxcMKlfddu/bYx/xDL3teNBhh17EH7w1asYNNZQ==}
peerDependencies:
'@deepseek-ai/cordis': ~4.0.4
'@deepseek-ai/schemastery@3.18.4':
resolution: {integrity: sha512-SSXO6tYuyrIqKVbmOnIq0s+riUywYzouFMcnBluHF9n4KMo2G8HvEzhCYj6pj2617/glOjbJuVInJ9hfONsjkg==}
@@ -2137,6 +2173,10 @@ packages:
devlop@1.1.0:
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
diff@9.0.0:
resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==}
engines: {node: '>=0.3.1'}
dompurify@3.4.14:
resolution: {integrity: sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==}
@@ -2343,6 +2383,9 @@ packages:
resolution: {integrity: sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==}
engines: {node: '>= 4'}
immer@11.1.21:
resolution: {integrity: sha512-bZSadeCPGdaY5/qRLgr6mDSH1ra21rD3rqpiWk7+QTr4ayYPTE9qZYdLh/NdPhMe0wSrTvHD/Lk8q5EwhUs0aw==}
import-meta-resolve@4.2.0:
resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==}
@@ -2993,6 +3036,10 @@ packages:
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
simple-icons@16.35.0:
resolution: {integrity: sha512-TCfl/2+GTrx0nCo4pEWGvxhOb8c6NsqOU1OpwjtF4lwJAthB8IYVD0fuIMjLMg6JFuG1YBO0t1iETQqJLqkoJA==}
engines: {node: '>=0.12.18'}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -3356,6 +3403,24 @@ packages:
zod@4.4.3:
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
zustand@5.0.15:
resolution: {integrity: sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==}
engines: {node: '>=12.20.0'}
peerDependencies:
'@types/react': '>=18.0.0'
immer: '>=9.0.6'
react: '>=18.0.0'
use-sync-external-store: '>=1.2.0'
peerDependenciesMeta:
'@types/react':
optional: true
immer:
optional: true
react:
optional: true
use-sync-external-store:
optional: true
zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
@@ -3808,6 +3873,10 @@ snapshots:
'@deepseek-ai/cordis': 4.0.4(@deepseek-ai/cordis-plugin-include@1.0.9)(@deepseek-ai/cordis-plugin-loader@1.0.5)
'@deepseek-ai/schemastery': 3.18.4
'@deepseek-ai/dsh-client-store@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)':
dependencies:
'@deepseek-ai/cordis': 4.0.4(@deepseek-ai/cordis-plugin-include@1.0.9)(@deepseek-ai/cordis-plugin-loader@1.0.5)
'@deepseek-ai/dsh-client-ui-conversation@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)':
dependencies:
'@deepseek-ai/cordis': 4.0.4(@deepseek-ai/cordis-plugin-include@1.0.9)(@deepseek-ai/cordis-plugin-loader@1.0.5)
@@ -4022,6 +4091,10 @@ snapshots:
'@deepseek-ai/dsh-system-prompt': 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))(@deepseek-ai/dsh-llm@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))(@deepseek-ai/dsh-scope@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)))
'@deepseek-ai/schemastery': 3.18.4
'@deepseek-ai/dsh-util-code-language@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)':
dependencies:
'@deepseek-ai/cordis': 4.0.4(@deepseek-ai/cordis-plugin-include@1.0.9)(@deepseek-ai/cordis-plugin-loader@1.0.5)
'@deepseek-ai/dsh-util-crypto@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)':
dependencies:
'@deepseek-ai/cordis': 4.0.4(@deepseek-ai/cordis-plugin-include@1.0.9)(@deepseek-ai/cordis-plugin-loader@1.0.5)
@@ -4034,6 +4107,10 @@ snapshots:
dependencies:
'@deepseek-ai/cordis': 4.0.4(@deepseek-ai/cordis-plugin-include@1.0.9)(@deepseek-ai/cordis-plugin-loader@1.0.5)
'@deepseek-ai/dsh-util-workspace-path@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)':
dependencies:
'@deepseek-ai/cordis': 4.0.4(@deepseek-ai/cordis-plugin-include@1.0.9)(@deepseek-ai/cordis-plugin-loader@1.0.5)
'@deepseek-ai/schemastery@3.18.4':
dependencies:
'@deepseek-ai/cosmokit': 1.8.5
@@ -5202,6 +5279,8 @@ snapshots:
dependencies:
dequal: 2.0.3
diff@9.0.0: {}
dompurify@3.4.14:
optionalDependencies:
'@types/trusted-types': 2.0.7
@@ -5408,6 +5487,8 @@ snapshots:
ignore@7.0.12: {}
immer@11.1.21: {}
import-meta-resolve@4.2.0: {}
import-without-cache@0.4.0: {}
@@ -6253,6 +6334,8 @@ snapshots:
siginfo@2.0.0: {}
simple-icons@16.35.0: {}
source-map-js@1.2.1: {}
source-map-js@1.2.2: {}
@@ -6549,4 +6632,10 @@ snapshots:
zod@4.4.3: {}
zustand@5.0.15(@types/react@18.3.31)(immer@11.1.21)(react@18.2.0):
optionalDependencies:
'@types/react': 18.3.31
immer: 11.1.21
react: 18.2.0
zwitch@2.0.4: {}
+1
View File
@@ -76,3 +76,4 @@ minimumReleaseAgeExclude:
- '@deepseek-ai/dsh-workspace@0.0.1-rc.1'
- '@deepseek-ai/schemastery@3.18.1-rc.1 || 3.18.1'
- '@huanlin/dsh-plugin-better-locale@0.1.0'
- simple-icons@16.35.0
+241
View File
@@ -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) }
+6
View File
@@ -41,6 +41,9 @@ if (g.document === undefined) {
querySelector: () => null,
querySelectorAll: () => [],
styleSheets: [],
// KaTeX probes compatMode at module load; without it it warns
// "doesn't work in quirks mode" and some layout paths no-op.
compatMode: 'CSS1Compat',
}
}
@@ -50,6 +53,9 @@ if (g.navigator === undefined) {
if (g.window === undefined) {
g.window = {
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => true,
clearTimeout: () => {},
setTimeout: (_fn: () => void) => 0,
innerWidth: 1024,
+14
View File
@@ -12,7 +12,21 @@
*/
import { defineConfig } from 'vitest/config'
// 上游 dsh-client-ui-primitives 的 lib 带 sourceMappingURL 注释但未发布 map
// 文件(package.json 的 files 不含 .map)→ Vite 读 map 报 ENOENT。
// 这里在 transform 阶段剥掉该注释;不要手改 node_modules(pnpm install 即失效)。
const stripMissingSourcemapRefs = {
name: 'strip-missing-sourcemap-refs',
enforce: 'pre' as const,
transform(code: string, id: string) {
if (!id.includes('dsh-client-ui-primitives')) return
if (!code.includes('sourceMappingURL')) return
return { code: code.replace(/\/\/\/?\s*sourceMappingURL=.*$/gm, ''), map: null }
},
}
export default defineConfig({
plugins: [stripMissingSourcemapRefs],
test: {
// Bridge Node's `localStorage` accessor to jsdom's store (see the file).
setupFiles: ['tests/setup.ts'],