@jayyuen66/dsh-plugin-shared 0.1.8 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.zh-CN.md CHANGED
@@ -4,15 +4,15 @@
4
4
 
5
5
  <h1 align="center">dsh-plugin-shared</h1>
6
6
 
7
- <p align="center"><em>dsh 插件家族共用的库包:一份 tool 事件台账、一套卡片骨架、一个项目键与一套 locale 面——只被插件 import,不由用户启用。</em></p>
7
+ <p align="center"><em>dsh 插件家族共用的库包:一份工具事件记录表、卡片状态骨架、一套统一设置卡 UI、一个项目键与一组 locale 支持——只被插件 import,不由用户启用。</em></p>
8
8
 
9
9
  <p align="center">
10
10
  <a href="https://www.npmjs.com/package/@jayyuen66/dsh-plugin-shared"><img src="https://img.shields.io/npm/v/@jayyuen66/dsh-plugin-shared?style=flat-square&logo=npm&logoColor=white" alt="npm version" /></a>
11
- <a href="https://www.npmjs.com/package/@jayyuen66/dsh-plugin-shared"><img src="https://img.shields.io/npm/dm/@jayyuen66/dsh-plugin-shared?style=flat-square" alt="npm downloads" /></a>
11
+ <a href="https://www.npmjs.com/package/@jayyuen66/dsh-plugin-shared"><img src="https://img.shields.io/npm/d18m/@jayyuen66/dsh-plugin-shared?style=flat-square" alt="npm downloads (total)" /></a>
12
12
  <a href="https://github.com/JayYuen666/dsh-plugin-shared/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/JayYuen666/dsh-plugin-shared/ci.yml?branch=main&style=flat-square&label=ci" alt="ci" /></a>
13
13
  <img src="https://img.shields.io/badge/license-MIT-4c1?style=flat-square" alt="MIT" />
14
14
  <img src="https://img.shields.io/badge/node-%3E%3D22.12-4c1?style=flat-square&logo=nodedotjs&logoColor=white" alt="node" />
15
- <img src="https://img.shields.io/badge/dsh-%5E0.2.1--rc.2-658EFF?style=flat-square" alt="dsh" />
15
+ <img src="https://img.shields.io/badge/dsh-%5E0.2.1--rc.3-658EFF?style=flat-square" alt="dsh" />
16
16
  <a href="https://github.com/deepseek-ai/deepseek-harness"><img src="https://img.shields.io/badge/powered_by_dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white" alt="powered by dsh" /></a>
17
17
  </p>
18
18
 
@@ -27,16 +27,16 @@
27
27
  <a href="./README.md"><img src="https://img.shields.io/badge/lang-English-658EFF?style=flat-square" alt="English" /></a>
28
28
  </p>
29
29
 
30
- **一个 [dsh](https://github.com/deepseek-ai/deepseek-harness) 库包:同一作者的插件家族共用的运行期切面与 lint/test 基线。它只被插件包 import,用户不会启用它。**
30
+ **一个 [dsh](https://github.com/deepseek-ai/deepseek-harness) 库包:同一作者的插件家族共用的运行期模块与 lint/test 基线。它只被插件包 import,用户不会启用它。**
31
31
 
32
32
  ## <img src="https://api.iconify.design/lucide/file-text.svg?color=%23658EFF&height=22" height="22" width="22" align="absmiddle" alt="" /> 目录
33
33
 
34
34
  - [它做什么](#它做什么)
35
35
  - [安装](#安装)
36
- - [确认它真的在工作](#确认它真的在工作)
36
+ - [确认它在正常工作](#确认它在正常工作)
37
37
  - [它不提供什么](#它不提供什么)
38
38
  - [模块清单](#模块清单)
39
- - [config 面](#config-面)
39
+ - [config 导出](#config-导出)
40
40
  - [已知边界](#已知边界)
41
41
  - [数据与隐私](#数据与隐私)
42
42
  - [版本与兼容](#版本与兼容)
@@ -46,19 +46,19 @@
46
46
 
47
47
  ## <img src="https://api.iconify.design/lucide/shield.svg?color=%23658EFF&height=22" height="22" width="22" align="absmiddle" alt="" /> 它做什么
48
48
 
49
- - **它是库,不是插件。** `package.json` 里没有 `dsh` 字段,包目录下也没有 `cordis.patch.yml`,所以躺在 `node_modules` 里的副本不会被宿主登记成插件。它没有设置卡、没有 client 半,也没有自己的路由。
50
- - **它收的是各插件逐字重复的部分:**
51
- - tool 事件台账(`./lib/tool-events`):一次扫描把 `SessionEvent[]` 变成调用表与结果表,PTC 子调用一并归并;`editPathOf` 负责台账看得见的编辑目标。
52
- - 客户端卡片骨架(`./lib/card-apply`、`./lib/card-diff`):跨 HMR 的 apply 幂等守卫,以及把 `null`、`undefined` 与键缺失当成同一种值的表单差异比较。
53
- - 运行期管道(`./lib/project-key`、`./lib/locale`、`./lib/lesson-bus`、`./lib/text`、`./lib/record`、`./lib/errors`、`./lib/jsonl`、`./lib/job-outcome`):那些每个插件都会重写一遍的小决定。
54
- - 构建期与门禁基线(`./lib/canonicalize-region-paths`、`./config/*`):产物正典化,加上给每个消费方的 oxlint、vitest 与 tsconfig 起点。
55
- - **它只做标记,不做决策。** 哪些工具算编辑、路径怎么过滤、预算多大、门禁何时该响,都留在各插件。共享层对交到它手里的领域没有意见。
49
+ - **它是库,不是插件。** `package.json` 里没有 `dsh` 字段,包目录下也没有 `cordis.patch.yml`,所以位于 `node_modules` 的副本不会被宿主登记成插件。它没有设置卡,没有客户端部分,也没有自己的路由。
50
+ - **它收录的是各插件逐字重复的部分:**
51
+ - 工具事件记录表(`./lib/tool-events`):一次扫描把 `SessionEvent[]` 变成调用表与结果表,PTC 子调用一并合并;`editPathOf` 提取其中可见的编辑目标。
52
+ - 客户端卡片基础组件(`./lib/card-apply`、`./lib/card-diff`):跨 HMR 保证 apply 幂等的保护逻辑,以及把 `null`、`undefined` 与键缺失当成同一种值的表单差异比较。
53
+ - 运行期工具(`./lib/project-key`、`./lib/locale`、`./lib/lesson-bus`、`./lib/text`、`./lib/record`、`./lib/errors`、`./lib/jsonl`、`./lib/job-outcome`):各个插件原本都要重写一遍的小逻辑。
54
+ - 构建期与检查基线(`./lib/canonicalize-region-paths`、`./config/*`):产物路径规范化,加上给每个消费方的 oxlint、vitest 与 tsconfig 起点。
55
+ - **它只做通用处理,不做领域决策。** 哪些工具算编辑、路径怎么过滤、预算多大、检查何时该报错,都留在各插件。共享层对交给它的领域不做判断。
56
56
 
57
57
  ### 收录边界
58
58
 
59
- - **逐字同构是收录判据。** 同一份函数体在两处以上出现才收。看起来相似、但失败语义、缓存方式或覆盖形状不同的,留在原地。
59
+ - **逐字相同是收录标准。** 同一份函数体在两处以上出现才收录。看起来相似、但失败语义、缓存方式或覆盖方式不同的,留在原地。
60
60
  - **领域判定留在外面。** 依赖某个插件自己的工具清单、策略或面向用户的措辞的谓词,不是样板,即使它出现了两次。
61
- - **发布面就是契约。** 新增切面要同时加子路径、barrel 转出(构建期那一枚除外)、构建脚本入口、构建 `include` 与用例;漏一处会被「开发」一节的 parity 门判红。
61
+ - **发布出去的接口就是承诺。** 新增模块要同时加子路径、barrel 再导出(构建期那一项除外)、构建脚本入口、构建 `include` 与用例;漏一处会被「开发」一节的一致性检查判定失败。
62
62
 
63
63
  ## <img src="https://api.iconify.design/lucide/package.svg?color=%23658EFF&height=22" height="22" width="22" align="absmiddle" alt="" /> 安装
64
64
 
@@ -74,52 +74,52 @@ pnpm add @jayyuen66/dsh-plugin-shared
74
74
  import { isRecord, fieldOf } from "@jayyuen66/dsh-plugin-shared/lib/record";
75
75
  ```
76
76
 
77
- 终端用户不需要单独安装或启用它:任一消费包都会顺带把它带进去。它缺位时的失败是 `ERR_MODULE_NOT_FOUND`(说明符指向 `@jayyuen66/dsh-plugin-shared/...`),不会静默降级。本包没有 default 导出;请从 barrel 或子路径用具名符号。
77
+ 终端用户不需要单独安装或启用它:任一消费包安装时都会自动包含它。它缺失时的失败是 `ERR_MODULE_NOT_FOUND`(说明符指向 `@jayyuen66/dsh-plugin-shared/...`),不会静默变成其他行为。本包没有 default 导出;请从 barrel 或子路径使用具名导入。
78
78
 
79
79
  | 要求 | 值 |
80
80
  | --- | --- |
81
- | Node.js | `>=22.12.0`——插件家族共用的发布侧下限,声明同一枚 `engines.node` 的包不会因为这个库而被拒 |
82
- | dsh | `^0.2.1-rc.2`,声明为 **optional** peer(import 本包不需要宿主在场) |
81
+ | Node.js | `>=22.12.0`——插件家族共用的发布侧下限,声明同一个 `engines.node` 的包不会因为这个库而被拒 |
82
+ | dsh | `^0.2.1-rc.2`,声明为 **optional** peer(import 本包不需要宿主存在) |
83
83
  | 模块形态 | 仅 ESM。`require()` 只在支持 `require(esm)` 的 Node 上可用 |
84
84
  | Tree shaking | `"sideEffects": false` |
85
85
 
86
- ### 依赖契约
86
+ ### 依赖要求
87
87
 
88
- `dependencies` 为空;所有 `@deepseek-ai/*` 只出现在 `peerDependencies` 与 `devDependencies` 里。`test/publish-manifest.test.ts` 钉住这条,理由是一次启动期故障,不是风格偏好:
88
+ `dependencies` 为空;所有 `@deepseek-ai/*` 只出现在 `peerDependencies` 与 `devDependencies` 里。`test/publish-manifest.test.ts` 固定了这条要求,理由是一次启动期故障,不是风格偏好:
89
89
 
90
- - **宿主包写进 `dependencies` 会让消费方的宿主瘫掉。** profile 用 `nodeLinker: hoisted` 与 `autoInstallPeers: false` 安装,于是列在 `dependencies` 里的宿主包会被落成真实目录,与宿主进程已加载的那份并存。`@deepseek-ai/dsh-tools` 的工具运行期调度键是普通 `Symbol`(不是 `Symbol.for`),符号身份按模块实例计算:宿主从副本上读那个键得到 `undefined`,于是每一次原生工具调用都抛错。构建、类型检查与测试在此之前全部照绿;这条不变式只能落在 manifest 上。
90
+ - **宿主包写进 `dependencies` 会让消费方的宿主无法工作。** profile 用 `nodeLinker: hoisted` 与 `autoInstallPeers: false` 安装,于是列在 `dependencies` 里的宿主包会被安装成真实目录,与宿主进程已加载的那份并存。`@deepseek-ai/dsh-tools` 的工具运行期调度键是普通 `Symbol`(不是 `Symbol.for`),符号身份按模块实例计算:宿主从副本上读那个键得到 `undefined`,于是每一次原生工具调用都抛错。构建、类型检查与测试在此之前全部通过;这条不变式只能写在 manifest 上。
91
91
  - **`@deepseek-ai/dsh-output-retention` 是唯一必需 peer。** 它是值导入(`truncateEnd`),运行期由宿主提供;在 `peerDependenciesMeta` 里明写成 `"optional": false`,不靠"没写就等于必需",因为没写进那张表对 npm 7+ 就是硬要求。
92
- - **其余 peer 都是 optional。** 除裸 `@deepseek-ai/dsh`(只声明宿主版本线)外,它们只承托类型面。放在 `peerDependencies` 里让发布出去的 `.d.ts` 保持诚实;`devDependencies` 让本仓能编译与测试。profile 不装 peer,所以不会产生副本。
93
- - **版本口径跟着宿主走。** `dsh-*` 与 `cordis` 精确钉在宿主声明的版本上,保证复用同一份。`test/publish-manifest.test.ts` 还要求每条 `dsh-*` 钉版与 `@deepseek-ai/dsh` peer 的版本一致。
92
+ - **其余 peer 都是 optional。** 除裸 `@deepseek-ai/dsh`(只声明宿主版本线)外,它们只承载类型声明。放在 `peerDependencies` 里让发布出去的 `.d.ts` 与实际依赖一致;`devDependencies` 让本仓能编译与测试。profile 不装 peer,所以不会产生副本。
93
+ - **版本要求跟着宿主走。** `dsh-*` 与 `cordis` 精确锁定在宿主声明的版本上,保证复用同一份。`test/publish-manifest.test.ts` 还要求每条 `dsh-*` 锁定版本与 `@deepseek-ai/dsh` peer 的版本一致。
94
94
  - 已知上游限制:`@deepseek-ai/dsh-llm` 的声明文件引用了它自己没声明的 `@deepseek-ai/dsh-attachment`。它经 `dsh-session` 传递进来,所以开 `skipLibCheck: false` 的消费方会看到来自那个包的 `TS2307`,不是来自本包。
95
95
 
96
96
  ### shared 的版本下限
97
97
 
98
- 消费者的 profile 可以用 pnpm `overrides` 把本包钉住。钉得低于某个 `./lib/...` 子路径被引入的那个版本,宿主起来时就会报 `N entries did not activate` 与 `does not provide an export named ...`:包装得上、永远不加载,而任何单仓 CI 都看不见。要钉版本请先对照本包的 `exports` 与 CHANGELOG。
98
+ 消费者的 profile 可以用 pnpm `overrides` 把本包固定在某个版本。固定得低于某个 `./lib/...` 子路径首次出现的版本,宿主启动时就会报 `N entries did not activate` 与 `does not provide an export named ...`:包装得上、永远不加载,而任何单仓 CI 都看不见。要固定版本请先对照本包的 `exports` 与 CHANGELOG。
99
99
 
100
100
  ### 随包发什么
101
101
 
102
- - `main` 与 `exports` 只指 `dist/`,不存在第二套源码形态的 manifest。Node 对 `node_modules` 里的 `.ts` 直接抛 `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`,而发布出去的包正是躺在那里被载入的,所以产物是必需的。
102
+ - `main` 与 `exports` 只指 `dist/`,不存在第二套源码形态的 manifest。Node 对 `node_modules` 里的 `.ts` 直接抛 `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`,而发布出去的包正是在那里被载入的,所以产物是必需的。
103
103
  - `files` 是 `icon.svg`、`dist`、`config/*.json` 与 `README.zh-CN.md`。npm 永远附带 `package.json`、`LICENSE` 与 `README.md`;中文版显式列出才会一起发。
104
- - `publishConfig` 只带 `access` 与 `registry`。把入口字段放在那里是陷阱:pnpm 发布时会把它合并到顶层,npm 不会——换一个发布器就会发出一个入口指向 tarball 里不存在文件的包。
105
- - `build` 先删除 `dist/`,再产出运行期产物、配置面产物、类型声明与生成的 barrel 声明。两个构建脚本都压缩;`test/dist-size.test.ts` 把运行期面与配置面各自守在预算内。
106
- - `prepare` 挂 `build`。消费方从 registry 安装时它不执行;它存在是为了让打包、发布与 workspace link 拿到的都是新鲜 `dist/`。
107
- - 新增切面要同时改五处:`lib/index.ts` 的 barrel、`exports`、`build-host.mjs` 的 `facets` 数组、`tsconfig.build.json` 的 `include`,外加一份 `test/<模块>.test.ts`。漏一处会被「开发」一节的 parity 门判红。
104
+ - `publishConfig` 只带 `access` 与 `registry`。把入口字段放在那里容易出错:pnpm 发布时会把它合并到顶层,npm 不会——换一个发布器就会发出一个入口指向 tarball 里不存在文件的包。
105
+ - `build` 先删除 `dist/`,再产出运行期产物、配置产物、类型声明与生成的 barrel 声明。两个构建脚本都压缩;`test/dist-size.test.ts` 把运行期产物与配置产物各自限制在预算内。
106
+ - `prepare` 指向 `build`。消费方从 registry 安装时它不执行;它存在是为了让打包、发布与 workspace link 拿到的都是新鲜 `dist/`。
107
+ - 新增模块要同时改五处:`lib/index.ts` 的 barrel、`exports`、`build-host.mjs` 的 `facets` 数组、`tsconfig.build.json` 的 `include`,外加一份 `test/<模块>.test.ts`。漏一处会被「开发」一节的一致性检查判定失败。
108
108
 
109
- ## <img src="https://api.iconify.design/lucide/circle-check.svg?color=%23658EFF&height=22" height="22" width="22" align="absmiddle" alt="" /> 确认它真的在工作
109
+ ## <img src="https://api.iconify.design/lucide/circle-check.svg?color=%23658EFF&height=22" height="22" width="22" align="absmiddle" alt="" /> 确认它在正常工作
110
110
 
111
111
  1. **子路径能解析。** 在消费包里运行 `node -e 'import("@jayyuen66/dsh-plugin-shared/lib/record").then((m) => console.log(typeof m.isRecord))'`,输出 `function`。`ERR_MODULE_NOT_FOUND` 表示依赖没装上;`does not provide an export named ...` 表示装到的那份比你 import 的子路径旧。
112
- 2. **barrel 能解析。** 换成裸包名做同一件事,应能取到运行期切面。`canonicalize-region-paths` 是唯一刻意不在 barrel 里的切面;构建脚本需要时按子路径取。
113
- 3. **消费方宿主能起来。** 启动声明该插件的 profile。若启动日志出现 `N entries did not activate` 与 `does not provide an export named ...`,说明 profile 的 `overrides` 钉得比包含该子路径的版本低——抬高钉版,不要改插件。
114
- 4. **本地源码改动能到消费方。** 消费方载入的是 `dist/`,不是 `lib/`。改完先跑 `pnpm run build`;`test/build-host.test.ts` 会把磁盘上的运行期产物与内存构建逐字节比对,产物过期即红。"代码改了却没生效"多半是产物过期,不是缓存问题。
112
+ 2. **barrel 能解析。** 换成裸包名做同一件事,应能取到运行期模块。`canonicalize-region-paths` 是唯一刻意不在 barrel 里的模块;构建脚本需要时按子路径取。
113
+ 3. **消费方宿主能启动。** 启动声明该插件的 profile。若启动日志出现 `N entries did not activate` 与 `does not provide an export named ...`,说明 profile 的 `overrides` 固定得比包含该子路径的版本低——调高固定版本,不要改插件。
114
+ 4. **本地源码改动能反映到消费方。** 消费方载入的是 `dist/`,不是 `lib/`。改完先运行 `pnpm run build`;`test/build-host.test.ts` 会把磁盘上的运行期产物与内存构建逐字节比对,产物过期即报错。"代码改了却没生效"多半是产物过期,不是缓存问题。
115
115
 
116
116
  ## <img src="https://api.iconify.design/lucide/circle-slash.svg?color=%23658EFF&height=22" height="22" width="22" align="absmiddle" alt="" /> 它不提供什么
117
117
 
118
118
  - **不注册插件。** 没有 `dsh` 字段,没有 `cordis.patch.yml`,`--dump-config` 里没有它这一层;`dsh plugin add` 是错误动作。
119
- - **没有设置卡,也没有 client 半。** 本包没有浏览器入口,也没有客户端状态。
120
- - **没有 HTTP 路由,也没有信任闸门。** 真正注册路由的插件由宿主的 `/api` 网关负责认证、Host / Origin 判据与 body 上限。
119
+ - **没有设置卡,也没有客户端部分。** 本包没有浏览器入口,也没有客户端状态。
120
+ - **没有 HTTP 路由,也没有请求信任检查。** 真正注册路由的插件由宿主的 `/api` 网关负责认证、Host / Origin 校验与 body 上限。
121
121
  - **没有 CLI 子命令,也没有模型工具。**
122
- - **不做领域判定。** 它不知道哪些工具算编辑、哪条路径是密钥、预算多大、门禁何时该响。这些属于调用方的插件。
122
+ - **不做领域判定。** 它不知道哪些工具算编辑、哪条路径是密钥、预算多大、检查何时该报错。这些属于调用方的插件。
123
123
 
124
124
  ## <img src="https://api.iconify.design/lucide/package.svg?color=%23658EFF&height=22" height="22" width="22" align="absmiddle" alt="" /> 模块清单
125
125
 
@@ -127,81 +127,84 @@ import { isRecord, fieldOf } from "@jayyuen66/dsh-plugin-shared/lib/record";
127
127
 
128
128
  | 子路径 | 提供什么 |
129
129
  | --- | --- |
130
- | `.` | 下面列出的运行期切面的 barrel。`canonicalize-region-paths` 刻意不进,运行期消费方不必为它付一次导入 |
131
- | `./lib/tool-events` | `scanToolEvents` 返回 `calls` / `results` 两张表,PTC 子调用一并归并;`toolEventRowsOf` 是单事件的那一步,交回的记录已带归一后的 `arguments` 与 `badArguments`。`editPathOf` 返回按 `kind` 判别的 `EditTarget`,只有 `write` / `read-view` 两支;路径取不到时为 `undefined`。台账字段一律 `readonly`。并转出官方 `SessionEvent` 与两枚 PTC 事件类型 |
132
- | `./lib/card-apply` | `claimApply` + `CardApplyCtx`:apply 幂等守卫,建立在 `globalThis` 上的**自有**标记(symbol,或外部写入的 `true`)加 `ctx.effect` 清理。标记是进程级的,所以同一页面的第二个 ctx 会被挡住——这是设计 |
133
- | `./lib/card-diff` | `diffTouched`:touched 层里哪些字段真的与快照不同,按表单值比较。`undefined`、`null` 与键缺失是同一种值;数组与对象逐元素比较;循环引用会收敛 |
134
- | `./lib/project-key` | `deriveProjectKey` + `ProjectKeyOptions`:cwd → `尾目录名-<sha256(norm) 前 8 位 hex>`;非字符串、空串或归一后的根目录落到 `default`。`realpathSync` 会折叠软链,失败则退回解析后的路径。Windows 的 `\` 与 `/` 折进同一个桶 |
135
- | `./lib/locale` | `Locale`(官方 `BuiltInLocaleId` 的别名)、`DEFAULT_LOCALE`、`resolveLocale`、`resolveLocalePreference`、`messagesFor`、`MessagesCatalog`,加 `LOCALE_SETTINGS_NAMESPACE` 与 `LOCALE_PREFERENCE_FIELD`。只管 host 侧字典;client 用官方 locale 包 |
136
- | `./lib/lesson-bus` | `settleLessonCall`:同步抛错与异步 rejection 归到同一个 `onFailure` 出口。函数型 thenable 也算可等待;`onFailure` 自己抛错或拒绝时被统一收口,不会分叉成"抛给调用方"或"未处理拒绝" |
137
- | `./lib/text` | `truncateEnd` 转交官方 `truncateWithoutSplittingSurrogatePair`;`truncateStart` 是官方导出面没有的后切形态。两者都按代理对安全切;预算不足一枚码元(含 `<= 0` 与 NaN)返回空串 |
138
- | `./lib/record` | `isRecord`、`fieldOf`、`hasMethods`。`fieldOf` 按设计走原型链(cordis 的服务方法挂在那里);敌意 getter 或 Proxy 陷阱降级为 `undefined`。`hasMethods` 要求点名的每个方法都可调用,并且不把 `Object.prototype` 的成员当成服务实现 |
139
- | `./lib/errors` | `errorText` 对齐官方 `@deepseek-ai/dsh-llm` 的 `errorChain` 语义(cause 链、`AggregateError` 成员、空 message 回退 `name`、跨 realm 取 `message`、敌意 getter 降级),本地零依赖实现。`isSettingsConflict` 由 `code` / `name` 认出宿主的设置冲突形状,跨 realm 抛来的错误也认得 |
140
- | `./lib/jsonl` | `shrinkJsonlTail`:JSONL 尾部收缩的纯决策件。触发策略与错误出口留给调用方;字节预算是否有限由调用方保证 |
141
- | `./lib/job-outcome` | `jobOutcomeOf` + `SettledProcess` + `JobOutcome` 把落定的子进程映射成官方作业注册表的结局;`killProc` + `KillableProcess` 收掉子进程句柄,对抛错或拒绝的 `kill()` 一律忽略(例如进程已被回收) |
142
- | `./lib/canonicalize-region-paths` | 构建期字符串件:把 rolldown 写的 `//#region <路径>` 折成与 `process.cwd()` 无关的形态。只给子路径,是为了让构建脚本取得到它,同时不把运行期 barrel 撑大 |
143
-
144
- ## <img src="https://api.iconify.design/lucide/sliders-horizontal.svg?color=%23658EFF&height=22" height="22" width="22" align="absmiddle" alt="" /> config 面
130
+ | `.` | 下面列出的运行期模块的 barrel。`canonicalize-region-paths` 刻意不进,运行期消费方不必为它增加一次导入;客户端专用的 `card-ui` 同样不进,理由还多一条:根 barrel 不得把 `react` 带进每个消费方 |
131
+ | `./lib/tool-events` | `scanToolEvents` 返回 `calls` / `results` 两张表,PTC 子调用一并合并;`toolEventRowsOf` 处理单个事件,交回的记录已带归一后的 `arguments` 与 `badArguments`。`editPathOf` 返回按 `kind` 判别的 `EditTarget`,只有 `write` / `read-view` 两个分支;路径取不到时为 `undefined`。记录字段一律 `readonly`。同时再导出官方 `SessionEvent` 与两个 PTC 事件类型 |
132
+ | `./lib/card-apply` | `claimApply` + `CardApplyCtx`:保证 apply 幂等的保护逻辑,建立在 `globalThis` 上的**自有**标记(symbol,或外部写入的 `true`)加 `ctx.effect` 清理。标记是进程级的,所以同一页面的第二个 ctx 会被拒绝——这是有意的设计 |
133
+ | `./lib/card-diff` | `diffTouched`:touched 层里哪些字段真的与快照不同,按表单值比较。`undefined`、`null` 与键缺失是同一种值;数组与对象逐元素比较;循环引用也能正常结束 |
134
+ | `./lib/card-ui` | 客户端专用设置卡组件:`CardForm`、`Section`、`FieldGrid`、`StatusBar`、`Field`、`FieldLabel`、`InfoTip`、`ToggleField`、`NumberField`、`TextField`、`ListField`、`SelectField`、`SaveBar`、`splitHint` 与样式字符串 `CARD_UI_CSS`。只用 `createElement`;`react` 是 optional peer,该子路径永不进根 barrel。只用 React 18 运行时 API(`useId`、`useState`、`useEffect`、refs) |
135
+ | `./lib/project-key` | `deriveProjectKey` + `ProjectKeyOptions`:cwd → `尾目录名-<sha256(norm) 前 8 位 hex>`;非字符串、空串或归一后的根目录归入 `default`。`realpathSync` 会解析软链接,失败则退回解析后的路径。Windows 的 `\` 与 `/` 产生同一个键 |
136
+ | `./lib/locale` | `Locale`(官方 `BuiltInLocaleId` 的别名)、`DEFAULT_LOCALE`、`resolveLocale`、`resolveLocalePreference`、`messagesFor`、`MessagesCatalog`,加 `LOCALE_SETTINGS_NAMESPACE` 与 `LOCALE_PREFERENCE_FIELD`。只负责宿主侧字典;客户端用官方 locale 包 |
137
+ | `./lib/lesson-bus` | `settleLessonCall`:同步抛错与异步 rejection 归到同一个 `onFailure` 出口。函数型 thenable 也算可等待;`onFailure` 自己抛错或拒绝时被统一处理,不会分成"抛给调用方"或"未处理拒绝"两种结果 |
138
+ | `./lib/text` | `truncateEnd` 转交官方 `truncateWithoutSplittingSurrogatePair`;`truncateStart` 是官方导出内容里没有的后切形式。两者都按代理对安全切;预算不足一个码元(含 `<= 0` 与 NaN)返回空串 |
139
+ | `./lib/record` | `isRecord`、`fieldOf`、`hasMethods`。`fieldOf` 按设计走原型链(cordis 的服务方法定义在那里);遇到恶意 getter 或 Proxy trap 时改返回 `undefined`,不抛错。`hasMethods` 要求指定的每个方法都可调用,并且不把 `Object.prototype` 的成员当成服务实现 |
140
+ | `./lib/errors` | `errorText` 对齐官方 `@deepseek-ai/dsh-llm` 的 `errorChain` 语义(cause 链、`AggregateError` 成员、空 message 回退 `name`、跨 realm 取 `message`、恶意 getter 改用占位文本),本地零依赖实现。`isSettingsConflict` 由 `code` / `name` 识别宿主的设置冲突特征,跨 realm 抛来的错误也能识别 |
141
+ | `./lib/jsonl` | `shrinkJsonlTail`:JSONL 尾部裁剪的纯逻辑模块。触发策略与错误处理留给调用方;字节预算是否有限由调用方保证 |
142
+ | `./lib/job-outcome` | `jobOutcomeOf` + `SettledProcess` + `JobOutcome` 把已结束的子进程映射成官方作业注册表的结局;`killProc` + `KillableProcess` 释放子进程句柄,对抛错或拒绝的 `kill()` 一律忽略(例如进程已被回收) |
143
+ | `./lib/canonicalize-region-paths` | 构建期字符串处理:把 rolldown 写下的 `//#region <路径>` 改写成与 `process.cwd()` 无关的形态。只提供子路径,是为了让构建脚本取得到它,同时不把运行期 barrel 增大 |
144
+
145
+ `card-ui` 是唯一刻意只走子路径的运行期模块:把它加进 barrel 会让每个 host 侧 importer 都去解析 `react`。
146
+
147
+ ## <img src="https://api.iconify.design/lucide/sliders-horizontal.svg?color=%23658EFF&height=22" height="22" width="22" align="absmiddle" alt="" /> config 导出
145
148
 
146
149
  这些是给插件作者用的 lint/test 基线,不是运行期代码。它们依赖你的包自己提供的工具链:
147
150
 
148
151
  | 子路径 | 提供什么 | 你需要装什么 |
149
152
  | --- | --- | --- |
150
- | `./config/oxlint` | `definePluginConfig`、`makeTestFileOverrides`、`OFF_JUSTIFICATIONS` 与共享规则基线 | `oxlint`;要挂 sonarjs 规则面还要 `eslint-plugin-sonarjs` |
151
- | `./config/vitest.base` | `definePackageConfig`:四项 100% 覆盖率阈值、20 秒测试超时、共享覆盖率排除表与用例匹配范围 | `vitest` 与 `@vitest/coverage-v8`(基线把 `coverage.provider` 钉成 `"v8"`) |
153
+ | `./config/oxlint` | `definePluginConfig`、`makeTestFileOverrides`、`OFF_JUSTIFICATIONS` 与共享规则基线 | `oxlint`;要启用 sonarjs 规则还要 `eslint-plugin-sonarjs` |
154
+ | `./config/vitest.base` | `definePackageConfig`:四项 100% 覆盖率阈值、20 秒测试超时、共享覆盖率排除表与用例匹配范围 | `vitest` 与 `@vitest/coverage-v8`(基线把 `coverage.provider` 固定为 `"v8"`) |
152
155
  | `./config/tsconfig.base.json`、`./config/tsconfig.client.base.json` | 给 `extends` 用的纯 JSON | 什么都不用 |
153
156
 
154
- 这些工具链包**不**写进 `peerDependencies`:npm 7+ 连 optional peer 都会去满足,而 `oxlint` 自己的 `peerOptional vite-plus` 把 `vitest` 钉在一个与 `>=5.0.2` 无交集的版本上,结果是本包的普通 `npm install` 直接 `ERESOLVE` 失败。这条约束因此写在文档里,而不是 manifest 里。
157
+ 这些工具链包**不**写进 `peerDependencies`:npm 7+ 连 optional peer 都会去满足,而 `oxlint` 自己的 `peerOptional vite-plus` 把 `vitest` 锁定在一个与 `>=5.0.2` 无交集的版本上,结果是本包的普通 `npm install` 直接 `ERESOLVE` 失败。这条约束因此写在文档里,而不是 manifest 里。
155
158
 
156
- - sonarjs 的入口在 `definePluginConfig()` 内部惰性解析。没装 sonarjs 时导入 `./config/oxlint` 仍然成功;既没装 sonarjs、`jsPlugins` 里也没带 sonarjs 入口时,调用 `definePluginConfig()` 才抛错,消息里点名试过哪两个说明符,以及那条自备入口的出路。
157
- - 基线里每条被关成 `off` 的规则都要在 `OFF_JUSTIFICATIONS` 里有 `kind` + `measured` + `note`,否则配置加载即抛。包级例外写进那个包自己的 `offReasons`。
158
- - `definePackageConfig` 拒绝空 `coverageInclude`,也拒绝 `test.coverage` 块:两者任一都会让四项阈值或统计面静默消失。要例外就在需要例外的包自己的 `coverage` 覆盖项旁边写明理由。
159
+ - sonarjs 的入口在 `definePluginConfig()` 内部惰性解析。没装 sonarjs 时导入 `./config/oxlint` 仍然成功;既没装 sonarjs、`jsPlugins` 里也没带 sonarjs 入口时,调用 `definePluginConfig()` 才抛错,消息里写明试过哪两个说明符,以及自备入口的方法。
160
+ - 基线里每条关闭为 `off` 的规则都要在 `OFF_JUSTIFICATIONS` 里有 `kind` + `measured` + `note`,否则配置加载即抛。包级例外写进那个包自己的 `offReasons`。
161
+ - `definePackageConfig` 拒绝空 `coverageInclude`,也拒绝 `test.coverage` 块:两者任一都会让四项阈值或统计范围静默消失。要例外就在需要例外的包自己的 `coverage` 覆盖项旁边写明理由。
159
162
 
160
163
  ## <img src="https://api.iconify.design/lucide/triangle-alert.svg?color=%23658EFF&height=22" height="22" width="22" align="absmiddle" alt="" /> 已知边界
161
164
 
162
- 按类登记,每类一条;每条都由测试按现状钉住。
165
+ 按类登记,每类一条;每条都由测试按现状固定。
163
166
 
164
- - **`fieldOf` 刻意走原型链。** 改成 `Object.hasOwn` 会让所有 cordis 服务方法都取不到。读用户设置值的代码必须改用自有属性判据;`resolveLocalePreference` 就是例子,两个方向都有用例钉住。
165
- - **`claimApply` 是进程级标记,不是 per-context 锁。** 同一页面上第二个 ctx 挂同一张卡会被挡住。需要多 ctx 并存的消费方应自己按 ctx 记账,而不是"修"这道守卫。
166
- - **`shrinkJsonlTail` 不归一预算。** `NaN` 与 `+Infinity` 会整段关掉收缩;`0`、负数与 `-Infinity` 仍会收缩到最多两行。触发策略(包括"0 表示永不裁剪")与错误出口都归调用方。
167
- - **`jobOutcomeOf` 抄的是宿主 bash 工具的文案。** 测试钉的是 `exit code: N` / `signal: NAME` 这些字面量,不是与上游函数逐字相等——那个函数无法从已发布包里 import。上游哪天改措辞,本包不会红,显示文本仍是本包自己的。
168
- - **`editPathOf` 只认一种只读写法。** 只有 `str_replace_editor` 且 `command: "view"` 才是 `read-view`;其余带 `file_path` 的工具一律按 `write` 处理,不再往下看。
167
+ - **`fieldOf` 刻意走原型链。** 改成 `Object.hasOwn` 会让所有 cordis 服务方法都取不到。读用户设置值的代码必须改用自有属性判断;`resolveLocalePreference` 就是例子,两个方向都有用例覆盖。
168
+ - **`claimApply` 是进程级标记,不是 per-context 锁。** 同一页面上第二个 ctx 申请同一张卡会被拒绝。需要多 ctx 并存的消费方应自己按 ctx 管理,而不是"修"这道保护。
169
+ - **`shrinkJsonlTail` 不归一预算。** `NaN` 与 `+Infinity` 会整段关掉收缩;`0`、负数与 `-Infinity` 仍会收缩到最多两行。触发策略(包括"0 表示永不裁剪")与错误处理都归调用方。
170
+ - **`jobOutcomeOf` 抄的是宿主 bash 工具的文案。** 测试检查的是 `exit code: N` / `signal: NAME` 这些字面量,不是与上游函数逐字相等——那个函数无法从已发布包里 import。上游哪天改措辞,本包不会报错,显示文本仍是本包自己的。
171
+ - **`editPathOf` 只认一种只读写法。** 只有 `str_replace_editor` 且 `command: "view"` 才是 `read-view`;其余带 `file_path` 的工具一律按 `write` 处理,不再做其他判断。
169
172
 
170
173
  ## <img src="https://api.iconify.design/lucide/lock.svg?color=%23658EFF&height=22" height="22" width="22" align="absmiddle" alt="" /> 数据与隐私
171
174
 
172
175
  本包被 import 进宿主进程;下表描述的是运行期。
173
176
 
174
- | 口径 | 内容 |
177
+ | 项目 | 内容 |
175
178
  | --- | --- |
176
179
  | 文件访问 | `deriveProjectKey` 只对你交给它的那条路径调用 `realpathSync`。失败(`ENOENT` / `EACCES`)退回 `path.resolve`,不外抛。`lib/**` 里没有别的文件访问 |
177
- | 写盘 | 没有。本包不写文件、不写缓存、不写日志 |
180
+ | 磁盘写入 | 没有。本包不写文件、不写缓存、不写日志 |
178
181
  | 环境变量 | 不读任何环境变量 |
179
182
  | 网络 | 没有出站请求,没有 HTTP 服务,没有子进程 |
180
183
  | 动态代码 | 没有 `eval`,没有 `new Function`,没有动态 `import()` |
181
- | 进程内状态 | 唯一的跨实例可变状态是 `claimApply` 在 `globalThis` 上的占位,effect 卸载或占位失败时恢复 |
182
- | 日志 | 只有 `lesson-bus` 里的 `onFailure` 自己抛错时才会 `console.error`;测试 setup 会把每条 `[shared/*]` 日志拿去和模板账本对账 |
184
+ | 进程内状态 | 唯一的跨实例可变状态是 `claimApply` 在 `globalThis` 上的标记,effect 卸载或标记失败时恢复 |
185
+ | 日志 | 只有 `lesson-bus` 里的 `onFailure` 自己抛错时才会 `console.error`;测试 setup 会把每条 `[shared/*]` 日志与模板清单比对 |
183
186
 
184
187
  ## <img src="https://api.iconify.design/lucide/layers.svg?color=%23658EFF&height=22" height="22" width="22" align="absmiddle" alt="" /> 版本与兼容
185
188
 
186
- - **公共面一律按语义化版本**:子路径、导出的值符号、导出的类型、错误/原因枚举。`0.x` 的区间只放行 patch 位,所以动这些就是消费方区间里的破坏性变更。
187
- - **`deriveProjectKey` 的哈希算法、8 位切片与尾段空白清洗是冻结的。** 动一位等于把存量教训与记忆从原桶里搬走。
188
- - **Windows 路径归一改变了桶键形态。** 同一目录无论写成 `\` 还是 `/` 都落到 `尾目录名-<哈希>`,盘符根与 UNC 共享根落进 `default`。归一之前写在 Windows 上的桶键不再匹配;POSIX 的桶键逐字节不变,并由字面量断言钉住。
189
+ - **公共接口一律按语义化版本**:子路径、导出的值符号、导出的类型、错误/原因枚举。`0.x` 的区间只放行 patch 位,所以动这些就是消费方区间里的破坏性变更。
190
+ - **`deriveProjectKey` 的哈希算法、8 位切片与尾段空白清洗是冻结的。** 改动任何一位都会让既有的教训与记忆数据在新键下找不到。
191
+ - **Windows 路径归一改变了键的形态。** 同一目录无论写成 `\` 还是 `/` 都得到 `尾目录名-<哈希>`,盘符根与 UNC 共享根归入 `default`。归一之前在 Windows 上生成的键不再匹配;POSIX 的键逐字节不变,并有字面量断言固定。
189
192
  - **`Locale` 是官方 `BuiltInLocaleId` 的别名**,因此跟着宿主的 `LOCALE_IDS` 走,不再是手写的字面量联合。
190
- - **`errorText` 返回的是展示文本,不是可解析格式。** 它对齐官方 `errorChain` 的语义:cause 链拼成 `外: 内`、`AggregateError` 附 `[e1; e2]`、空 message 回退 `Error.name`、跨 realm 的 Error 取其自带 `message` 而不是 `String()` 的 `"Error: ..."`。拿它做正则匹配的调用方在契约之外。
191
- - **`editPathOf` 返回按 `kind` 判别的 `EditTarget`,只有 `write` / `read-view` 两支。** `"skip"` 从来不会被产出,留在类型里只会逼调用方写永不执行的分支。路径取不到时仍返回所属那一支,`path` 为 `undefined`。
192
- - **`./lib/http` 与 `./lib/trust` 已删除。** 插件家族的设置卡数据面跑在官方 Typert Remote 上,认证、Host / DNS 重绑定 / Origin 判据与 body 上限由宿主的 `/api` 网关承担。还要自建 `webServer` 路由的插件得自己写闸门——发布面不再提供第二套"这个请求准不准"的答案。
193
- - **声明文件与宿主钉版是耦合的。** 升级宿主意味着同步移动本包里精确的 `@deepseek-ai/*` 钉版并重新发布;放宽成范围会让消费方载入一份本包没有对着它构建过的类型面。
193
+ - **`errorText` 返回的是展示文本,不是可解析格式。** 它对齐官方 `errorChain` 的语义:cause 链拼成 `外: 内`、`AggregateError` 附 `[e1; e2]`、空 message 回退 `Error.name`、跨 realm 的 Error 取其自带 `message` 而不是 `String()` 的 `"Error: ..."`。拿它做正则匹配的调用方超出了受支持的用法。
194
+ - **`editPathOf` 返回按 `kind` 判别的 `EditTarget`,只有 `write` / `read-view` 两个分支。** `"skip"` 从来不会被产出,留在类型里只会迫使调用方写永不执行的分支。路径取不到时仍返回所属分支,`path` 为 `undefined`。
195
+ - **`./lib/http` 与 `./lib/trust` 已删除。** 插件家族的设置卡数据通道运行在官方 Typert Remote 上,认证、Host / DNS 重绑定 / Origin 校验与 body 上限由宿主的 `/api` 网关承担。还要自建 `webServer` 路由的插件得自己写这些检查——发布内容不再提供第二套"这个请求准不准"的答案。
196
+ - **声明文件与宿主版本锁定是耦合的。** 升级宿主意味着同步更新本包里精确的 `@deepseek-ai/*` 版本并重新发布;放宽成范围会让消费方载入一份本包没有对着它构建过的类型声明。
194
197
 
195
198
  ## <img src="https://api.iconify.design/lucide/circle-help.svg?color=%23658EFF&height=22" height="22" width="22" align="absmiddle" alt="" /> 常见问题
196
199
 
197
200
  | 问题 | 回答 |
198
201
  | --- | --- |
199
- | 要不要 `dsh plugin add` 它? | 不要。它没有配置层也没有 client 半,启用不了任何东西。正确姿势是声明成插件包的依赖。 |
200
- | 消费方启动时报 `does not provide an export named ...` 与 `N entries did not activate`。 | profile 提供的那份本包比消费方 import 的版本旧。把 profile 的 `overrides` 钉版抬到包含该子路径的版本;对照本包的 `exports` 与 CHANGELOG。任何单仓 CI 都看不见这个状态。 |
201
- | 改了 `lib/*.ts`,消费方还是旧行为。 | 消费方载入的是 `dist/`。先跑 `pnpm run build`;`test/build-host.test.ts` 会在运行期产物过期时判红。workspace link 不会绕过产物。 |
202
- | 为什么 `@deepseek-ai/dsh-output-retention` 是必需 peer,其余都是 optional? | 它是唯一按值导入的宿主包(`truncateEnd`)。其余都是类型面,唯一的例外是裸 `@deepseek-ai/dsh` peer——它只声明宿主版本线。 |
203
- | 为什么 `oxlint` 不写成 `peerDependency`? | npm 7+ 会去满足它,而 `oxlint` 的 optional `vite-plus` peer 把 `vitest` 钉在基线所需范围之外,普通安装会得到 `ERESOLVE`。这条要求因此写在文档里。 |
204
- | 想把某个 helper 收进来? | 先确认那份函数体在两个以上包里逐字重复。带领域判定的部分留在插件里。 |
202
+ | 要不要 `dsh plugin add` 它? | 不要。它没有配置层也没有客户端部分,启用不了任何东西。正确做法是声明成插件包的依赖。 |
203
+ | 消费方启动时报 `does not provide an export named ...` 与 `N entries did not activate`。 | profile 提供的那份本包比消费方 import 的版本旧。把 profile 的 `overrides` 版本调高到包含该子路径的版本;对照本包的 `exports` 与 CHANGELOG。任何单仓 CI 都看不见这个状态。 |
204
+ | 改了 `lib/*.ts`,消费方还是旧行为。 | 消费方载入的是 `dist/`。先运行 `pnpm run build`;`test/build-host.test.ts` 会在运行期产物过期时判定失败。workspace link 不会绕过产物。 |
205
+ | 为什么 `@deepseek-ai/dsh-output-retention` 是必需 peer,其余都是 optional? | 它是唯一按值导入的宿主包(`truncateEnd`)。其余都是类型声明,唯一的例外是裸 `@deepseek-ai/dsh` peer——它只声明宿主版本线。 |
206
+ | 为什么 `oxlint` 不写成 `peerDependency`? | npm 7+ 会去满足它,而 `oxlint` 的 optional `vite-plus` peer 把 `vitest` 锁定在基线所需范围之外,普通安装会得到 `ERESOLVE`。这条要求因此写在文档里。 |
207
+ | 想把某个 helper 收录进来? | 先确认那份函数体在两个以上包里逐字重复。带领域判断的部分留在插件里。 |
205
208
 
206
209
  ## <img src="https://api.iconify.design/lucide/wrench.svg?color=%23658EFF&height=22" height="22" width="22" align="absmiddle" alt="" /> 开发
207
210
 
@@ -209,18 +212,18 @@ import { isRecord, fieldOf } from "@jayyuen66/dsh-plugin-shared/lib/record";
209
212
  pnpm install
210
213
  pnpm run check # typecheck → lint → build → test → 格式检查
211
214
  pnpm test # vitest,带覆盖率
212
- pnpm run build # 运行期产物、配置面产物、声明文件
215
+ pnpm run build # 运行期产物、配置产物、声明文件
213
216
  ```
214
217
 
215
- - 测试按「桩看不见的那类缺陷」分层:
216
- - **切面行为**:每个运行期切面一份 `test/<模块>.test.ts`。
217
- - **surface parity**:`test/index.test.ts`(barrel ↔ `lib/` 的值导出)、`test/publish-manifest.test.ts`(`exports` ↔ `lib/` 双向)与 `test/build-host.test.ts`(`build-host.mjs` 的 `facets` ↔ `lib/`)。三条各抓一种另外两条看不见的漏项。
218
- - **产物门**:`test/build-host.test.ts` 把磁盘上的运行期产物与内存构建逐字节比对;`test/dist-size.test.ts` 把运行期面与配置面各自守在字节预算内;同一份测试还钉住官方值导入保持裸说明符,并拒绝为 `dsh-jobs` / `dsh-shell` 留下运行期说明符。
219
- - **配置门**:`test/config-oxlint.test.ts` 把 vitest 规则快照与 `oxlint --rules` 现量对照,并要求每条 `off` 都有依据;`test/vitest-base.test.ts` 钉住合并契约,保证四项阈值与统计面不会被局部改配抹掉。
220
- - **日志账本**:`test/setup-logs.ts` 接管 `console.*`;每条 `[shared/*]` 日志都要对得上 `test/log-templates.ts` 里的模板片段,否则当场红。`test/log-templates.test.ts` 双向校验,增删日志却不改账本都会失败。
221
- - **发布形态**:`test/publish-manifest.test.ts` 要求入口目标都在 `files` 覆盖内、入口指向产物而不是源码、`publishConfig` 不带入口字段、每条 peer 都在 `peerDependenciesMeta` 里表态、`dsh-*` 钉版精确且统一,并要求源码、文档与产物里没有日期、绝对路径、家目录指涉或只在协作当时成立的措辞。
222
- - 覆盖率四项阈值都是 **100%**(lines / statements / functions / branches),所以新增分支必须有能走到它的用例。`test/vitest-base.test.ts` 拒绝空 `coverageInclude`,因为那会让四项阈值对着空统计面全绿。
223
- - CI 在 Linux、Windows 与 macOS 上跑同一道门,Node 版本矩阵覆盖 22 与 24,并有一周一次的定时运行抓上游漂移。安装矩阵会分别用 npm 与 pnpm 打包,在干净目录里不带 peer 解析旗标安装,逐子路径 import,并在两种 `skipLibCheck` 档位下对消费方探针做类型检查。
218
+ - 测试按「stub 覆盖不到的那类缺陷」分层:
219
+ - **模块行为**:每个运行期模块一份 `test/<模块>.test.ts`。
220
+ - **接口一致性(surface parity)**:`test/index.test.ts`(barrel ↔ `lib/` 的值导出)、`test/publish-manifest.test.ts`(`exports` ↔ `lib/` 双向)与 `test/build-host.test.ts`(`build-host.mjs` 的 `facets` ↔ `lib/`)。三条检查各抓一种另外两条发现不了的漏项。
221
+ - **产物检查**:`test/build-host.test.ts` 把磁盘上的运行期产物与内存构建逐字节比对;`test/dist-size.test.ts` 把运行期产物与配置产物各自限制在字节预算内;同一份测试还固定官方值导入必须保持裸说明符,并拒绝为 `dsh-jobs` / `dsh-shell` 留下运行期说明符。
222
+ - **配置检查**:`test/config-oxlint.test.ts` 把 vitest 规则快照与 `oxlint --rules` 的实际输出对照,并要求每条 `off` 都有依据;`test/vitest-base.test.ts` 固定合并行为,保证四项阈值与统计范围不会被局部改配抹掉。
223
+ - **日志记录**:`test/setup-logs.ts` 接管 `console.*`;每条 `[shared/*]` 日志都要匹配 `test/log-templates.ts` 里的模板片段,否则测试立即失败。`test/log-templates.test.ts` 双向校验,增删日志却不改记录清单都会失败。
224
+ - **发布形态**:`test/publish-manifest.test.ts` 要求入口目标都在 `files` 覆盖内、入口指向产物而不是源码、`publishConfig` 不带入口字段、每条 peer 都在 `peerDependenciesMeta` 里明确标注、`dsh-*` 版本精确且统一,并要求源码、文档与产物里没有日期、绝对路径、家目录指涉或只在协作当时成立的措辞。
225
+ - 覆盖率四项阈值都是 **100%**(lines / statements / functions / branches),所以新增分支必须有能走到它的用例。`test/vitest-base.test.ts` 拒绝空 `coverageInclude`,因为那会让四项阈值在空统计范围上全部通过。
226
+ - CI 在 Linux、Windows 与 macOS 上运行同一组检查,Node 版本矩阵覆盖 22 与 24,并有一周一次的定时运行来发现上游变化。安装矩阵会分别用 npm 与 pnpm 打包,在干净目录里不带 peer 解析选项安装,逐子路径 import,并在两种 `skipLibCheck` 设置下对一小段消费方导入代码做类型检查。
224
227
 
225
228
  ## <img src="https://api.iconify.design/lucide/scale.svg?color=%23658EFF&height=22" height="22" width="22" align="absmiddle" alt="" /> 许可
226
229
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "//": "target 不覆盖:与官方一致(deepseek-harness/tsconfig.base.json 是 es2024,client 层只加 lib)。本仓的 client 产物由 rolldown 打,构建脚本里没有 target,也没有读 tsconfig ⇒ 这里的 target 只影响类型面,不影响产物语法。旧基线把它钉在 ES2020 是继承来的写法,实测无产物后果,只把 Promise.withResolvers 一类 ES2024 API 判成 TS2550,逼出包级 lint 例外。types 置空刻意关掉父基线的 node @types 自动加载:纯浏览器 client 是对的;下游 client 源码若用到 Node 全局或需要 vite/client 的声明,须在自己的 tsconfig 显式补 types,报错点(TS2588/TS2304 一类)离这里很远,别把 node 加回这份基线。",
2
+ "//": "target 不覆盖:与官方一致(deepseek-harness/tsconfig.base.json 是 es2024,client 层只加 lib)。本仓的 client 产物由 rolldown 打,构建脚本里没有 target,也没有读 tsconfig,因此这里的 target 只影响类型面,不影响产物语法。旧基线把它固定在 ES2020 是继承来的写法,实测无产物后果,只会把 Promise.withResolvers 一类 ES2024 API 判成 TS2550,迫使包级添加 lint 例外。types 置空刻意关掉父基线的 node @types 自动加载:纯浏览器 client 是对的;下游 client 源码若用到 Node 全局或需要 vite/client 的声明,须在自己的 tsconfig 显式补 types,报错点(TS2588/TS2304 一类)离这里很远,别把 node 加回这份基线。",
3
3
  "extends": "./tsconfig.base.json",
4
4
  "compilerOptions": {
5
5
  "module": "ESNext",
@@ -1,2 +1,2 @@
1
- import{asRecord as e,closeQuietly as t,packageNameOf as n,readManifest as r,singleEntryChunk as i}from"./build-artifacts.js";import{t as a}from"./canonicalize-region-paths-BxladpX3.js";import{readFile as o}from"node:fs/promises";import s from"node:path";const c=[`react`,`react/jsx-runtime`,`react-dom`,`react-dom/client`];function l(e){let t=e.replaceAll(/^\/\/#region .*$/gmu,e=>e.replaceAll(`\\`,`/`).replace(/^\/\/#region (?:(?:\.\.?\/)+)?node_modules\/\.pnpm\/[^/]+\/node_modules\//u,`//#region node_modules/`)),n=/node_modules\/\.pnpm\//u.exec(t);if(n!==null){let e=Math.max(0,n.index-40);throw Error(`产物里仍有 pnpm store 路径未折叠(出现在 …${JSON.stringify(t.slice(e,e+100))}…)。折叠判据与 rolldown 写下的形式不再匹配,client 产物的字节会随安装布局变化。`)}return t}async function u(e){return o(e,`utf8`)}function d(e){let t=e.name;if(typeof t!=`string`||t===``)throw Error(`package.json 的 name 缺失或非字符串。client 产物的 id 必须等于 dsh client 模块表的键(bundle 包名)。`);if(!/^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/u.test(t))throw Error(`package.json 的 name ${JSON.stringify(t)} 不符合 npm 包名字符集。`);return t}function f(e){let t=new Set(e);return e=>{if(t.has(e))return!0;let r=n(e);if(r===`react`||r===`react-dom`)throw Error(`${e} 是 react 家族说明符但不在外置清单里。外置会得到宿主模块表里没有的键(加载期报错),内联会得到第二份 React(hooks 静默失效);真要引入子路径,先把它加进外置清单并确认宿主模块表里有同名项。`);return!1}}async function p(n){let o=d(e(await r(n.root))),p=n.entry??s.join(`src`,`client-entry.ts`),m=n.minify??!1,h=n.comments??!m,g={format:`cjs`,...m?{minify:!0}:{},...h?{}:{comments:!1}},_=await n.rolldown({input:s.join(n.root,p),external:f(n.externals??c),platform:`browser`,cwd:n.root,resolve:{extensions:[`.tsx`,`.ts`,`.jsx`,`.js`,`.mjs`,`.json`]}}),v;try{let{output:e}=await _.generate(g),t=i(e,p);v=l(a(t.code.trimEnd(),n.root,process.cwd()))}catch(e){throw await t(_),e}return await _.close(),await n.verifyName?.({pkgName:o,body:v,readSource:e=>u(s.join(n.root,e))}),[n.header,`window.__ModuleLoader__.load({`,` id: ${JSON.stringify(o)},`,` factory: (require) => {`,` 'use strict';`,` const module = { exports: {} };`,` let exports = module.exports;`,v,` return module.exports;`,` },`,`});`,``].join(`
1
+ import{asRecord as e,closeQuietly as t,packageNameOf as n,readManifest as r,singleEntryChunk as i}from"./build-artifacts.js";import{t as a}from"./canonicalize-region-paths-BxladpX3.js";import{readFile as o}from"node:fs/promises";import s from"node:path";const c=[`react`,`react/jsx-runtime`,`react-dom`,`react-dom/client`];function l(e){let t=e.replaceAll(/^\/\/(?:#region .*| .*)$/gmu,e=>e.replaceAll(`\\`,`/`).replace(/^\/\/(?:#region | )?(?:(?:\.\.?\/)+)?node_modules\/\.pnpm\/[^/]+\/node_modules\//u,e=>e.startsWith(`//#region`)?`//#region node_modules/`:`// node_modules/`)),n=/node_modules\/\.pnpm\//u.exec(t);if(n!==null){let e=Math.max(0,n.index-40);throw Error(`产物里仍有 pnpm store 路径未折叠(出现在 …${JSON.stringify(t.slice(e,e+100))}…)。折叠判据与 rolldown 写下的形式不再匹配,client 产物的字节会随安装布局变化。`)}return t}async function u(e){return o(e,`utf8`)}function d(e){let t=e.name;if(typeof t!=`string`||t===``)throw Error(`package.json 的 name 缺失或非字符串。client 产物的 id 必须等于 dsh client 模块表的键(bundle 包名)。`);if(!/^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/u.test(t))throw Error(`package.json 的 name ${JSON.stringify(t)} 不符合 npm 包名字符集。`);return t}function f(e){let t=new Set(e);return e=>{if(t.has(e))return!0;let r=n(e);if(r===`react`||r===`react-dom`)throw Error(`${e} 是 react 家族说明符但不在外置清单里。外置会得到宿主模块表里没有的键(加载期报错),内联会得到第二份 React(hooks 静默失效);真要引入子路径,先把它加进外置清单并确认宿主模块表里有同名项。`);return!1}}async function p(n){let o=d(e(await r(n.root))),p=n.entry??s.join(`src`,`client-entry.ts`),m=n.minify??!1,h=n.comments??!m,g={format:`cjs`,...m?{minify:!0}:{},...h?{}:{comments:!1}},_=await n.rolldown({input:s.join(n.root,p),external:f(n.externals??c),platform:`browser`,cwd:n.root,resolve:{extensions:[`.tsx`,`.ts`,`.jsx`,`.js`,`.mjs`,`.json`]}}),v;try{let{output:e}=await _.generate(g),t=i(e,p);v=l(a(t.code.trimEnd(),n.root,process.cwd()))}catch(e){throw await t(_),e}return await _.close(),await n.verifyName?.({pkgName:o,body:v,readSource:e=>u(s.join(n.root,e))}),[n.header,`window.__ModuleLoader__.load({`,` id: ${JSON.stringify(o)},`,` factory: (require) => {`,` 'use strict';`,` const module = { exports: {} };`,` let exports = module.exports;`,v,` return module.exports;`,` },`,`});`,``].join(`
2
2
  `)}export{c as REACT_RUNTIME_MODULES,p as buildClientBundle,f as clientExternalPredicate,l as collapseStorePaths};
@@ -0,0 +1,105 @@
1
+ import{createElement as e,useCallback as t,useEffect as n,useId as r,useLayoutEffect as i,useMemo as a,useRef as o,useState as s}from"react";const c=`
2
+ .dsu-form{display:flex;flex-direction:column}
3
+ .dsu-section{margin-top:24px;padding-top:24px;border-top:.5px solid var(--dsw-alias-border-l1)}
4
+ .dsu-form>.dsu-section:first-child{margin-top:0;padding-top:0;border-top:0}
5
+ .dsu-sectionTitle{margin:0;font-size:15px;font-weight:600;line-height:1.4;color:var(--dsw-alias-label-primary)}
6
+ .dsu-sectionHint{margin:4px 0 0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}
7
+ .dsu-statusbar{display:flex;flex-wrap:wrap;gap:4px 8px;margin-top:8px;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}
8
+ .dsu-field{display:grid;grid-template-columns:minmax(0,1fr) minmax(240px,360px);column-gap:32px;align-items:center;padding:12px 0;border-top:.5px solid var(--dsw-alias-border-l2)}
9
+ .dsu-fieldMeta{display:flex;flex-direction:column;gap:4px;min-width:0}
10
+ .dsu-labelRow{display:flex;align-items:center;gap:6px;min-width:0}
11
+ .dsu-label{font-size:13px;font-weight:500;line-height:1.5;color:var(--dsw-alias-label-primary)}
12
+ .dsu-labelMono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px}
13
+ .dsu-hint{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}
14
+ .dsu-control{min-width:0;display:flex;align-items:center;gap:8px}
15
+ .dsu-fieldWide{grid-template-columns:minmax(0,1fr);align-items:start}
16
+ .dsu-fieldWide .dsu-control{width:100%;max-width:720px;margin-top:2px}
17
+ .dsu-help{display:inline-flex;align-items:center;gap:6px;min-width:0}
18
+ .dsu-helpBtn{appearance:none;border:0;background:transparent;padding:0;width:16px;height:16px;display:inline-flex;align-items:center;justify-content:center;color:var(--dsw-alias-label-tertiary);cursor:help;border-radius:50%;flex:none}
19
+ .dsu-help:hover .dsu-helpBtn,.dsu-helpBtn:focus-visible{color:var(--dsw-alias-label-primary)}
20
+ .dsu-helpBtn:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:1px}
21
+ .dsu-tip{position:fixed;z-index:60;box-sizing:border-box;max-width:min(340px,calc(100vw - 16px));max-height:calc(100vh - 16px);overflow:auto;overflow-wrap:anywhere;padding:10px 12px;border-radius:10px;background:var(--dsw-alias-bg-layer-2);border:.5px solid var(--dsw-alias-border-l3);box-shadow:0 12px 32px rgb(0 0 0 / 45%);font-size:12px;line-height:1.6;font-weight:400;color:var(--dsw-alias-label-secondary)}
22
+ .dsu-input{width:100%;height:34px;padding:0 12px;border:.5px solid var(--dsw-alias-border-l4);border-radius:var(--dsw-radius-md);background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);font:inherit;font-size:13px}
23
+ .dsu-input:focus-visible{outline:none;border-color:var(--dsw-alias-state-business-primary)}
24
+ .dsu-input[aria-invalid="true"]{border-color:var(--dsw-alias-state-error-primary)}
25
+ .dsu-input:disabled{color:var(--dsw-alias-label-tertiary);cursor:default}
26
+ .dsu-input::placeholder{color:var(--dsw-alias-label-tertiary)}
27
+ .dsu-textarea{height:auto;min-height:96px;padding:8px 12px;line-height:1.6;resize:vertical}
28
+ .dsu-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
29
+ .dsu-numberWrap{display:flex;flex-direction:column;gap:4px;width:100%;min-width:0}
30
+ .dsu-number{display:flex;align-items:center;gap:8px;min-width:0}
31
+ .dsu-number .dsu-input{min-width:0}
32
+ .dsu-unit{flex:none;font-size:12px;color:var(--dsw-alias-label-tertiary)}
33
+ .dsu-error{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-state-error-primary)}
34
+ .dsu-switch{appearance:none;width:36px;height:20px;border-radius:999px;border:0;padding:2px;background:var(--dsw-alias-border-l3);display:flex;align-items:center;cursor:pointer;flex:none;transition:background-color .16s}
35
+ .dsu-knob{width:16px;height:16px;border-radius:50%;background:var(--dsw-alias-label-primary);transition:transform .16s,background-color .16s}
36
+ .dsu-switch[aria-checked="true"]{background:var(--dsw-alias-label-primary)}
37
+ .dsu-switch[aria-checked="true"] .dsu-knob{transform:translateX(16px);background:var(--dsw-alias-label-primary-foreground)}
38
+ .dsu-switch:disabled{opacity:.4;cursor:not-allowed}
39
+ .dsu-switch:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:2px}
40
+ .dsu-selectWrap{position:relative;width:100%;min-width:0}
41
+ .dsu-select{display:flex;align-items:center;justify-content:space-between;gap:8px;text-align:left;cursor:pointer}
42
+ .dsu-select[aria-expanded="true"]{border-color:var(--dsw-alias-state-business-primary)}
43
+ .dsu-selectValue{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
44
+ .dsu-chevron{flex:none;color:var(--dsw-alias-label-tertiary);transition:transform .16s}
45
+ .dsu-select[aria-expanded="true"] .dsu-chevron{transform:rotate(180deg)}
46
+ .dsu-menu{position:fixed;z-index:70;padding:4px;background:var(--dsw-alias-bg-layer-2);border:.5px solid var(--dsw-alias-border-l3);border-radius:12px;box-shadow:0 12px 32px rgb(0 0 0 / 50%);max-height:280px;overflow:auto}
47
+ .dsu-option{appearance:none;border:0;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;line-height:1.4;width:100%;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:8px 10px;border-radius:8px;cursor:pointer;text-align:left}
48
+ .dsu-option:hover,.dsu-option[data-active="true"]{background:var(--dsw-alias-bg-layer-3)}
49
+ .dsu-option:disabled{color:var(--dsw-alias-label-tertiary);cursor:not-allowed}
50
+ .dsu-option:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:-2px}
51
+ .dsu-optionGroup{padding:6px 10px 2px;font-size:11px;font-weight:600;color:var(--dsw-alias-label-tertiary)}
52
+ .dsu-check{opacity:0;flex:none}
53
+ .dsu-option[aria-selected="true"] .dsu-check{opacity:1}
54
+ .dsu-savebar{display:flex;align-items:center;gap:8px;padding:12px 0;margin-top:24px;border-top:.5px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-base)}
55
+ .dsu-savebar[data-dirty="true"]{position:sticky;bottom:0;z-index:2;box-shadow:0 -12px 24px rgb(0 0 0 / 35%)}
56
+ .dsu-btn{appearance:none;border:.5px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;line-height:1.5;border-radius:var(--dsw-radius-md);padding:5px 14px;cursor:pointer;transition:background-color .12s,border-color .12s,color .12s,filter .12s}
57
+ .dsu-btn:hover:not(:disabled){background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-border-l3)}
58
+ .dsu-btn:active:not(:disabled){background:var(--dsw-alias-bg-layer-1)}
59
+ .dsu-btn:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:1px}
60
+ .dsu-btn:disabled{opacity:.4;cursor:not-allowed}
61
+ .dsu-btnPrimary{background:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}
62
+ .dsu-btnPrimary:hover:not(:disabled){background:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-primary);filter:brightness(.9)}
63
+ .dsu-btnPrimary:active:not(:disabled){background:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-primary);filter:brightness(.84)}
64
+ .dsu-btnDanger{color:var(--dsw-alias-state-error-primary)}
65
+ .dsu-btnDanger:hover:not(:disabled){color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary);background:var(--dsw-alias-bg-layer-2)}
66
+ .dsu-status{margin-left:auto;font-size:12px;color:var(--dsw-alias-label-tertiary)}
67
+ .dsu-statusError{color:var(--dsw-alias-state-error-primary)}
68
+ .dsu-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(200px,100%),1fr));gap:0 24px}
69
+ .dsu-grid>.dsu-field{display:flex;flex-direction:column;align-items:stretch;gap:6px;padding:10px 0;border-top:.5px solid var(--dsw-alias-border-l1)}
70
+ .dsu-grid>.dsu-field .dsu-control{width:100%}
71
+ @media (max-width:640px){
72
+ .dsu-field{grid-template-columns:minmax(0,1fr);align-items:start}
73
+ .dsu-control{width:100%;max-width:720px}
74
+ .dsu-section{margin-top:20px;padding-top:20px}
75
+ }
76
+ @media (prefers-reduced-motion:reduce){
77
+ .dsu-switch,.dsu-knob,.dsu-chevron,.dsu-btn{transition:none}
78
+ }
79
+ `;function l(e,t=30){return e.length<=t?{hint:e}:{detail:e}}function u(t){return e(`div`,{className:`dsu-form`},t.children)}function d(t){return e(`section`,{className:`dsu-section`},e(`h2`,{className:`dsu-sectionTitle`},t.title),t.hint===void 0?null:e(`p`,{className:`dsu-sectionHint`},t.hint),t.children)}function f(t){return e(`div`,{className:`dsu-grid`},t.children)}function p(t){if(t.items.length===0)return null;let n=t.items.map((t,n)=>e(`span`,{key:n},n===0?t:`· ${t}`));return e(`div`,{className:`dsu-statusbar`},...n)}function m(a){let c=r(),[l,u]=s(!1),[d,f]=s(!1),p=o(!1),m=o(!1),h=o(!1),g=o(null),_=o(null),v=o(null),y=t(()=>{v.current!==null&&(clearTimeout(v.current),v.current=null)},[]),b=t(()=>{let e=g.current,t=_.current;
80
+ /* v8 ignore next 3 -- place 只在 open 的 layout effect 与挂载期监听器里调用;refs 为空只可能是卸载竞态 */
81
+ if(e===null||t===null)return;let n=e.getBoundingClientRect(),r=t.offsetWidth,i=t.offsetHeight,{left:a}=n,o=n.bottom+8;o+i>window.innerHeight-8&&(o=n.top-i-8),a+r>window.innerWidth-8&&(a=window.innerWidth-r-8);let s=Math.max(8,window.innerWidth-r-8),c=Math.max(8,window.innerHeight-i-8);t.style.left=`${Math.min(Math.max(8,a),s)}px`,t.style.top=`${Math.min(Math.max(8,o),c)}px`},[]),x=t(()=>{y(),h.current=!1,f(!1),u(!1)},[y]),S=t(()=>{y(),u(!0)},[y]),C=t(()=>{y(),!(p.current||m.current||h.current)&&(v.current=setTimeout(()=>{
82
+ /* v8 ignore next 3 -- 任何重新进入/钉住都会 cancelHide;这次复查只防未来的异步路径 */
83
+ v.current=null,p.current||m.current||h.current||u(!1)},120))},[y]),w=t(e=>{h.current=e,f(e)},[]);i(()=>{if(!l)return()=>{};b();let e=e=>{let{target:t}=e;t instanceof Node&&_.current?.contains(t)===!0||(h.current?b():x())},t=()=>{b()};return window.addEventListener(`scroll`,e,!0),window.addEventListener(`resize`,t),()=>{window.removeEventListener(`scroll`,e,!0),window.removeEventListener(`resize`,t)}},[l,a.detail,b,x]),n(()=>{if(!l)return()=>{};let e=e=>{e.key===`Escape`&&x()};return document.addEventListener(`keydown`,e),()=>{document.removeEventListener(`keydown`,e)}},[l,x]),n(()=>y,[y]);let T=e(`button`,{ref:g,type:`button`,className:`dsu-helpBtn`,"aria-label":`${a.label} 说明`,"aria-describedby":c,"aria-pressed":d,onClick:()=>{h.current?x():(w(!0),S())}},e(`svg`,{width:12,height:12,viewBox:`0 0 14 14`,"aria-hidden":!0},e(`circle`,{cx:7,cy:7,r:6,fill:`none`,stroke:`currentColor`,strokeWidth:1.2}),e(`path`,{d:`M7 6.2v4`,stroke:`currentColor`,strokeWidth:1.3,strokeLinecap:`round`}),e(`circle`,{cx:7,cy:4.3,r:.8,fill:`currentColor`}))),E=e(`div`,{ref:_,id:c,role:`tooltip`,className:`dsu-tip`,hidden:!l},a.detail),D=a.showLabel===!1?null:e(`span`,{className:`dsu-label${a.compact===!0?` dsu-labelMono`:``}`},a.label);return e(`span`,{className:`dsu-help`,onMouseEnter:()=>{p.current=!0,S()},onMouseLeave:()=>{p.current=!1,C()},onFocus:()=>{m.current=!0,S()},onBlur:e=>{let t=e.relatedTarget;t instanceof Node&&e.currentTarget.contains(t)||(m.current=!1,C())}},D,T,E)}function h(t){let n=`dsu-label${t.compact===!0?` dsu-labelMono`:``}`,r=t.controlId===void 0?e(`span`,{className:n},t.label):e(`label`,{className:n,htmlFor:t.controlId},t.label);return e(`div`,{className:`dsu-labelRow`},r,t.detail===void 0?null:e(m,{label:t.label,detail:t.detail,showLabel:!1}))}function g(t){let n=t.controlId===void 0?void 0:`${t.controlId}-hint`;return e(`div`,{className:`dsu-field${t.wide===!0?` dsu-fieldWide`:``}`},e(`div`,{className:`dsu-fieldMeta`},e(h,{label:t.label,detail:t.detail,compact:t.compact,controlId:t.controlId}),t.hint===void 0?null:e(`p`,{className:`dsu-hint`,id:n},t.hint)),e(`div`,{className:`dsu-control`},t.children))}function _(t){let n=r(),i=t.hint===void 0?void 0:`${n}-hint`;return e(g,{...t,controlId:n},e(`button`,{type:`button`,role:`switch`,id:n,className:`dsu-switch`,"aria-checked":t.checked,"aria-describedby":i,"data-field":t.field,disabled:t.disabled===!0,onClick:()=>{t.onChange(!t.checked)}},e(`span`,{className:`dsu-knob`})))}function v(t){let i=typeof t.value==`number`&&Number.isFinite(t.value)?String(t.value):``,[a,c]=s(i),[l,u]=s(!1),d=o(void 0),f=r(),p=r(),m=[t.hint===void 0?void 0:`${f}-hint`,l?p:void 0].filter(e=>e!==void 0),h=m.length===0?void 0:m.join(` `);n(()=>{t.value!==d.current&&(d.current=t.value,c(i),u(!1))},[t.value,i]);let _=e=>{let n=t.min??-1/0,r=t.max??1/0;if(!Number.isFinite(e)||e<n||e>r)return!1;let{step:i}=t;if(i===void 0||i<=0)return!0;let a=(e-(t.min??0))/i;return Math.abs(a-Math.round(a))<1e-9},v=e=>{if(c(e),e===``){u(!1),d.current=void 0,t.onChange(void 0);return}let n=Number(e);_(n)?(u(!1),d.current=n,t.onChange(n)):u(!0)},y=()=>{a===``||l||c(String(Number(a)))};return e(g,{...t,controlId:f},e(`div`,{className:`dsu-numberWrap`},e(`div`,{className:`dsu-number`},e(`input`,{className:`dsu-input`,type:`number`,inputMode:`numeric`,id:f,value:a,min:t.min,max:t.max,step:t.step,placeholder:t.placeholder,"aria-invalid":l||void 0,"aria-describedby":h,"data-field":t.field,disabled:t.disabled===!0,onChange:e=>{v(e.target.value)},onBlur:y}),t.unit===void 0?null:e(`span`,{className:`dsu-unit`},t.unit)),l?e(`p`,{className:`dsu-error`,id:p},t.invalidHint):null))}function y(t){let[i,a]=s(t.value),[o,c]=s(!1),l=r(),u=t.hint===void 0?void 0:`${l}-hint`;n(()=>{o||a(t.value)},[t.value,o]);let d={id:l,className:`dsu-input${t.multiline===!0?` dsu-textarea`:``}${t.monospace===!0?` dsu-mono`:``}`,value:i,placeholder:t.placeholder,"aria-describedby":u,"data-field":t.field,disabled:t.disabled===!0,onFocus:()=>{c(!0)},onBlur:()=>{c(!1)},onChange:e=>{a(e.target.value),t.onChange(e.target.value)}};return e(g,{...t,controlId:l},t.multiline===!0?e(`textarea`,d):e(`input`,d))}function b(t){let i=t.separator??`comma`,a=t.value.join(i===`newline`?`
84
+ `:`, `),[o,c]=s(a),[l,u]=s(!1),d=r(),f=t.hint===void 0?void 0:`${d}-hint`;n(()=>{l||c(a)},[a,l]);let p=e=>e.split(i===`newline`?`
85
+ `:`,`).map(e=>e.trim()).filter(e=>e.length>0),m={id:d,className:`dsu-input${i===`newline`?` dsu-textarea dsu-mono`:``}`,value:o,placeholder:t.placeholder,"aria-describedby":f,"data-field":t.field,disabled:t.disabled===!0,onFocus:()=>{u(!0)},onBlur:()=>{u(!1)},onChange:e=>{c(e.target.value),t.onChange(p(e.target.value))}};return e(g,{...t,controlId:d},e(i===`newline`?`textarea`:`input`,m))}function x(c){let[l,u]=s(!1),[d,f]=s(0),p=o(null),m=o(null),h=r(),_=r(),v=c.hint===void 0?void 0:`${_}-hint`,{disabled:y,value:b,options:x,onChange:S}=c,C=a(()=>x.map((e,t)=>({option:e,index:t})).filter(e=>e.option.disabled!==!0),[x]),w=x.find(e=>e.value===b)?.label??b,T=x[d],E=T!==void 0&&T.disabled!==!0,D=t(()=>{let e=p.current,t=m.current;
86
+ /* v8 ignore next 3 -- place 只在 open 的 layout effect 里调用;refs 为空只可能是卸载竞态 */
87
+ if(e===null||t===null)return;let n=e.getBoundingClientRect(),r=Math.min(n.width,window.innerWidth-16),{left:i}=n;i+r>window.innerWidth-8&&(i=window.innerWidth-r-8);let a=t.offsetHeight,o=n.bottom+6;o+a>window.innerHeight-8&&(o=n.top-a-6);let s=Math.max(8,window.innerWidth-r-8),c=Math.max(8,window.innerHeight-a-8);t.style.width=`${r}px`,t.style.left=`${Math.min(Math.max(8,i),s)}px`,t.style.top=`${Math.min(Math.max(8,o),c)}px`},[]),O=t(()=>{u(!1)},[]),k=t(()=>{if(y===!0)return;let e=C.find(e=>e.option.value===b)??C.at(0);e!==void 0&&(f(e.index),u(!0))},[y,b,C]),A=t(e=>{
88
+ /* v8 ignore next 3 -- 浮层只能经由 openMenu 打开;组件禁用时打不开 */
89
+ if(y===!0)return;let t=x[e];
90
+ /* v8 ignore next 3 -- disabled 选项在浏览器里不派发 click;越界下标由调用面保证 */
91
+ t!==void 0&&t.disabled!==!0&&(S(t.value),O(),p.current?.focus())},[y,x,S,O]),j=t(e=>{let t=C.findIndex(e=>e.index===d);
92
+ /* v8 ignore next 5 -- open 时归一 effect 已保证 enabled 非空且 active 落在可用项上 */
93
+ if(t===-1||C.length===0)return;let n=(t+e+C.length)%C.length,r=C[n];
94
+ /* v8 ignore next 3 -- next 由取模保证在界内 */
95
+ r!==void 0&&f(r.index)},[d,C]);i(()=>{if(!l)return()=>{};D();let e=e=>{let t=e.target;m.current?.contains(t)!==!0&&p.current?.contains(t)!==!0&&O()},t=e=>{let{target:t}=e;t instanceof Node&&m.current?.contains(t)===!0||O()},n=()=>{D()};return document.addEventListener(`pointerdown`,e,!0),window.addEventListener(`scroll`,t,!0),window.addEventListener(`resize`,n),()=>{document.removeEventListener(`pointerdown`,e,!0),window.removeEventListener(`scroll`,t,!0),window.removeEventListener(`resize`,n)}},[l,c.options,D,O]),n(()=>{if(!l)return;if(y===!0||C.length===0){O();return}if(C.some(e=>e.index===d))return;let e=C.find(e=>e.option.value===b)??C.at(0);
96
+ /* v8 ignore next 3 -- enabled 非空在上一分支已保证 */
97
+ e!==void 0&&f(e.index)},[l,y,b,x,d,O,C]),i(()=>{if(!l)return;let e=m.current;
98
+ /* v8 ignore next 2 -- 同上,menu 在 open 的 commit 之后必然已挂上 */
99
+ if(e===null)return;let t=e.querySelector(`[data-active="true"]`);
100
+ /* v8 ignore next 2 -- active 已由归一 effect 保证落在可用项上 */
101
+ t!==null&&(t.offsetTop<e.scrollTop?e.scrollTop=t.offsetTop:t.offsetTop+t.offsetHeight>e.scrollTop+e.clientHeight&&(e.scrollTop=t.offsetTop+t.offsetHeight-e.clientHeight))},[l,d,x]);let M=e=>{if(e.key===`ArrowDown`||e.key===`ArrowUp`){if(e.preventDefault(),!l){k();return}j(e.key===`ArrowDown`?1:-1)}else if(e.key===`Home`||e.key===`End`){if(e.preventDefault(),l||k(),C.length===0)return;let t=e.key===`Home`?C.at(0):C.at(-1);
102
+ /* v8 ignore next 3 -- enabled 为空已在上一条返回 */
103
+ if(t===void 0)return;f(t.index)}else if(e.key===`Enter`||e.key===` `){if(e.preventDefault(),!l){k();return}A(d)}else(e.key===`Tab`||e.key===`Escape`)&&O()},N=[],P;for(let[t,n]of x.entries())n.group!==P&&(n.group!==void 0&&N.push(e(`div`,{key:`group-${t}-${n.group}`,className:`dsu-optionGroup`,role:`presentation`},n.group)),P=n.group),N.push(e(`button`,{key:`option-${n.value}`,type:`button`,role:`option`,id:`${h}-${t}`,className:`dsu-option`,tabIndex:-1,"aria-selected":n.value===c.value,"data-active":t===d,disabled:n.disabled===!0,onMouseDown:e=>{e.preventDefault()},onClick:()=>{A(t)},onMouseEnter:()=>{
104
+ /* v8 ignore next -- happy-dom 对 disabled 元素不派发 mouse 事件;浏览器实现各有差异,保留禁用检查 */
105
+ n.disabled!==!0&&f(t)}},e(`span`,null,n.label),e(`svg`,{className:`dsu-check`,width:14,height:14,viewBox:`0 0 14 14`,"aria-hidden":!0},e(`path`,{d:`M2.5 7.5l3 3 6-7`,fill:`none`,stroke:`currentColor`,strokeWidth:1.5,strokeLinecap:`round`,strokeLinejoin:`round`}))));return e(g,{...c,controlId:_},e(`div`,{className:`dsu-selectWrap`,onBlur:e=>{let t=e.relatedTarget;t instanceof Node&&e.currentTarget.contains(t)||O()}},e(`button`,{ref:p,type:`button`,id:_,className:`dsu-select dsu-input`,role:`combobox`,"aria-haspopup":`listbox`,"aria-expanded":l,"aria-controls":`${h}-list`,"aria-activedescendant":l&&E?`${h}-${d}`:void 0,"aria-describedby":v,"data-field":c.field,disabled:y===!0||x.length===0,onClick:()=>{l?O():k()},onKeyDown:M},e(`span`,{className:`dsu-selectValue`},w),e(`svg`,{className:`dsu-chevron`,width:14,height:14,viewBox:`0 0 14 14`,"aria-hidden":!0},e(`path`,{d:`M3 5l4 4 4-4`,fill:`none`,stroke:`currentColor`,strokeWidth:1.5,strokeLinecap:`round`,strokeLinejoin:`round`}))),e(`div`,{ref:m,id:`${h}-list`,role:`listbox`,className:`dsu-menu`,"aria-label":c.label,hidden:!l},...N)))}function S(t){let n=!t.writable||t.busy||!t.dirty,r;return r=t.error===null?t.writable?t.dirty?t.dirtyLabel:t.cleanLabel:t.readOnlyLabel:t.error,e(`div`,{className:`dsu-savebar`,"data-dirty":t.dirty},e(`button`,{type:`button`,className:`dsu-btn dsu-btnPrimary`,"data-field":`save`,disabled:n,onClick:t.onSave},t.busy?t.savingLabel:t.saveLabel),e(`button`,{type:`button`,className:`dsu-btn`,"data-field":`discard`,disabled:n,onClick:t.onDiscard},t.revertLabel),e(`span`,{className:t.error===null?`dsu-status`:`dsu-status dsu-statusError`},r))}export{c as CARD_UI_CSS,u as CardForm,g as Field,f as FieldGrid,h as FieldLabel,m as InfoTip,b as ListField,v as NumberField,S as SaveBar,d as Section,x as SelectField,p as StatusBar,y as TextField,_ as ToggleField,l as splitHint};