@dsh-workbench/module-tools 0.3.4 → 0.3.6

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.md CHANGED
@@ -30,7 +30,7 @@ dsh-wb-module publish # 发货前的清单:门禁 + 三张表 +
30
30
  | `cards` | 卡片渲染:**八态 × zh/en 各渲一遍**(外壳侧输入 idle / loading / loading-刷新保留旧值(含旧时间戳 `at`)/ ready / ready-空容器 / unreadable-error / unreadable-能力闸缺插件 / unreadable-能力闸缺命令;`useApiResource` 与 `useCapabilityGate` 由判据喂,`useUnitBoard` 用模块仓里装的那份**真壳**)—— 不许抛、不许空白、文字里不许出现 `undefined` / `NaN` / `[object Object]` / `null`、不许裸键名、要套外壳的卡壳、清单 `titleKey` 在两语字典里都有落点。**不断言"画的是哪一态那一屏"**(那归模块自己的逐态测试与 `design.json` 状态矩阵);模块没装外壳时逐态那半跳过、单次全桩渲染照旧跑 |
31
31
  | `standalone` | 独立构建:产物不依赖工坊里跑 |
32
32
  | `markers` | 公开包无内部标识(内网域名、企业内部名) |
33
- | `style` | 样式归属:模块不许定义外壳类(`wb-*`)的规则(判据看**复合选择器**,`:global(.wb-x)` 也算,只有同一个复合选择器里带模块自己的类才放行);**裸 `<a>` 与裸交互控件(`<button>` / `<input>` / `<select>`)都判红** —— 前者用 kit 的 `Link`,后者用 `Button` / `TextField` / `SelectField` / `ChoiceGroup`;只有 `<textarea>` 与 `input[type=hidden\|file]` 例外(两处都没有那种形态);**手写的「标签 + 控件」行(标签元素紧邻 `SelectField` / `TextField` / `ChoiceGroup` / `Input`)与模块自己给 `field`/`form` 容器设的行距(`gap` / 上下 `margin`)也判红** —— 这一层归 kit 的 `FormRow(s)`(面 → 行 → 控件);现存违规收进 `FORM_ROW_EXEMPT`(棘轮:迁一处删一行,且表里已经不再命中的行会当场报红);**手写的操作区(借了 `wb-formActions` 槽类手摆按钮)也判红** —— 操作区归 kit 的 `FormActions`(顺序 / 一个区一颗 `primary` / `danger` 的位置 / 间距 / 窄容器换行都由它定,调用点只给数据与语义),现存违规收进 `FORM_ACTIONS_EXEMPT`(同一套棘轮:迁一处删一行,表里不再命中的行当场报红) |
33
+ | `style` | 样式归属:模块不许定义外壳类(`wb-*`)的规则(判据看**复合选择器**,`:global(.wb-x)` 也算,只有同一个复合选择器里带模块自己的类才放行);**裸 `<a>` 与裸交互控件(`<button>` / `<input>` / `<select>`)都判红** —— 前者用 kit 的 `Link`(**只判「正文里的文字链」那一种**;三种 `Link` 表达不了的形态不判、逐文件收在 `ANCHOR_EXEMPT` 里:**图标链**(`<a className="wb-iconButton">` 包一枚图标 —— `Link` 会多画一颗箭头)· **按钮皮的链**(`.anchorButton` 那类"看起来像按钮、实际是链接",ADR-0054 §11 判过:必须是链接的动作留真 `<a>`,不许塞成 `window.open` 按钮)· **整行链**(`.issueLink` 那类 `flex: 1` / `color: inherit` 的行的布局槽)。`<a` **写在行尾**(属性在下一行)也算 —— 这条是**全文**匹配、不逐行,别改回去),后者用 `Button` / `TextField` / `SelectField` / `ChoiceGroup`;只有 `<textarea>` 与 `input[type=hidden\|file]` 例外(两处都没有那种形态);**手写的「标签 + 控件」行(标签元素紧邻 `SelectField` / `TextField` / `ChoiceGroup` / `Input`)与模块自己给 `field`/`form` 容器设的行距(`gap` / 上下 `margin`)也判红** —— 这一层归 kit 的 `FormRow(s)`(面 → 行 → 控件);现存违规收进 `FORM_ROW_EXEMPT`(棘轮:迁一处删一行,且表里已经不再命中的行会当场报红);**手写的操作区(借了 `wb-formActions` 槽类手摆按钮)也判红** —— 操作区归 kit 的 `FormActions`(顺序 / 一个区一颗 `primary` / `danger` 的位置 / 间距 / 窄容器换行都由它定,调用点只给数据与语义),现存违规收进 `FORM_ACTIONS_EXEMPT`(同一套棘轮:迁一处删一行,表里不再命中的行当场报红);**手写的整张表单也判红** —— 模块源码(`.tsx` / `.ts`,测试文件除外)里的**裸 `<form>` 元素**与**手写行的槽类 `wb-formRow` / `wb-formRows` / `wb-field`** 都算,整张表单的面(行的排布 / 校验的时机 / 错误摆哪 / 提交怎么被挡住 / 第一处错误怎么找 / 操作区怎么排)归 kit 的 `Form`(`fields` 进、其余全归它,调用点只给数据与语义),现存违规收进 `FORM_EXEMPT`(同一套棘轮:迁一处删一行,表里不再命中的行当场报红);那一族判据**故意窄**(`wb-field` 后面跟着 `Label`/`Hint` 这些**兄弟**类的不算,测试文件也算在射程外),模块自己用 div 拼一张既没有 `<form>`、也没借槽类的表单**不判** —— 判宽了就是"用户每次改界面都先撞门禁",已知漏报见 `style.mjs` 里那一段注释 |
34
34
  | `alias` | 模块不指外壳:源码里没有指向外壳 `@/` 的引用 |
35
35
  | `arch` | 模块自己的**分层形状**(下面单开一节):层序表外的目录 / 反向依赖 / 跨运行时 / 巨文件 / 模型层没测试 / 样式里手搓数值与 `!important`。**只跑带 `arch.config.mjs` 的模块** —— 没有这份文件就是 `○ 不适用`,不是红 |
36
36
  | `lint` | 模块自己的**语法卫生**(下面单开一节):`as any` / 残留 `console` / `debugger` / 内联颜色字面量 / 字符串里的 `!important` / react-hooks。**只跑带 `eslint.config.mjs` 的模块** —— 没有那份文件就是 `○ 不适用`,不是红;规则住在 module-tools 的方言里、随工具链同代 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dsh-workbench/module-tools",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "模块包的构建与门禁工具:build(单文件 ESM 宿主侧 + 套 loader 壳的浏览器侧)、check(契约/产物边界/源码边界/卡片渲染)。模块包不再各带一份构建脚本。",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -29,7 +29,7 @@
29
29
  "eslint-plugin-react": "^7.37.5",
30
30
  "eslint-plugin-react-hooks": "^7.1.1",
31
31
  "typescript-eslint": "^8.71.0",
32
- "@dsh-workbench/shared": "0.3.4"
32
+ "@dsh-workbench/shared": "0.3.6"
33
33
  },
34
34
  "scripts": {
35
35
  "selfcheck": "node scripts/selfcheck/index.mjs"
@@ -251,11 +251,14 @@ function fakeLongTasks(created) {
251
251
  return {
252
252
  create(options) {
253
253
  created.push(options);
254
+ // 形状跟着真外壳那份注册表走(`createLongTaskRegistry`):假的那份少一个方法,
255
+ // 把注册表原样返回的模块就会被这条判据冤枉成"缺方法"——所以 `stop` 也在这里。
254
256
  return {
255
257
  start: () => ({}),
256
258
  snapshot: () => null,
257
259
  active: () => null,
258
260
  current: () => [],
261
+ stop: () => false,
259
262
  onChange: () => () => {},
260
263
  };
261
264
  },
@@ -347,7 +350,7 @@ function mountOf(face, created, manifest, tools) {
347
350
  }
348
351
  }
349
352
 
350
- const LONG_TASK_METHODS = ['start', 'snapshot', 'active', 'current', 'onChange'];
353
+ const LONG_TASK_METHODS = ['start', 'snapshot', 'active', 'current', 'stop', 'onChange'];
351
354
 
352
355
  const LONG_TASK_RENDERERS = ['useLongTask', 'LongTaskRow', 'longTaskDot'];
353
356
 
@@ -788,12 +791,19 @@ export async function run() {
788
791
  }
789
792
  }
790
793
 
791
- // 长任务:跑一会儿的事,注册了就得让用户看得见那四盏灯。
794
+ // 长任务:跑一会儿的事,注册了就得让用户看得见那一行。
792
795
  //
793
796
  // 外壳按 mount 结果里的 longTasks 投影出 `${unit}/runs`(kit 的 useLongTask 读的就是它)。
794
- // 契约里的状态只有四种:running / needs-login / ok / failed(`longTaskDot` 把它们映成
795
- // 进行中 / 要登录 / 完成 / 出错)——**没有"停止"**,所以这里不查停止,只查"能不能画出来":
796
- // · 注册表形状要全(少了方法,外壳那条 runs 路由读不动);
797
+ // 契约里的状态是**五种**:running / needs-login / stopped / ok / failed(`longTaskDot` 把它们映成
798
+ // 进行中 / 要登录 / 灰点 / 完成 / 出错 —— `stopped` 落进默认分支,是"用户按停的",不是成功也不是出错)。
799
+ // 契约里也**有"停止"**:注册表多一个 `stop(id)`,`run` 多收一个 `signal`。
800
+ // `start` 还可以带一个**可选**的 `session`(起这一趟的那个会话 id,模块从 `sessionIdOf(exec)` 拿):
801
+ // 不记 = 用户在界面上按的 —— 外壳那两把 `workbench_long_tasks_*` 按它判"谁的"(自己起的免审批)。
802
+ // 判据**不查**这一条(它只查形状;"起的时候记没记来源"是形状之外的事)。
803
+ // 但"停止"在注册表这一侧的射程只有两件:外壳把 `signal` abort 掉、把这一趟标成 `stopped`;
804
+ // **真停不停得了看生产方认不认这个 signal** —— 那是模块的知识,判据查不了这一条
805
+ // (内部是 opencli 子进程那类编排,今天最多做到"我们不再等它")。判据能查的是形状与"画得出来":
806
+ // · 注册表形状要全(少了方法,外壳那条 runs 路由 / 那颗停止钮就按不动);
797
807
  // · create 的 key 非空、run 与 view 都是函数(没有 view,卡片上那行就没有可渲染的快照);
798
808
  // · 真的注册了长任务,客户端产物里就得出现 kit 那套(useLongTask / LongTaskRow / longTaskDot),
799
809
  // 否则用户点完只看到"没反应"。
@@ -803,7 +813,7 @@ export async function run() {
803
813
  const missing = LONG_TASK_METHODS.filter((method) => typeof registry[method] !== 'function');
804
814
  if (missing.length > 0) {
805
815
  problems.push(
806
- `${name}: 长任务注册表缺方法 ${missing.join(' / ')} —— 外壳按它给这个单元投影出 /runs 路由,` +
816
+ `${name}: 长任务注册表缺方法 ${missing.join(' / ')} —— 外壳按它给这个单元投影出 /runs 路由(停止那颗钮也按它发),` +
807
817
  `kit 的 useLongTask 读的就是那条(注册表用 @dsh-workbench/shared/host 的 createLongTaskRegistry 建)`,
808
818
  );
809
819
  }
@@ -827,7 +837,7 @@ export async function run() {
827
837
  if (!LONG_TASK_RENDERERS.some((one) => clientText.includes(one))) {
828
838
  problems.push(
829
839
  `${name}: 注册了长任务,但客户端产物里没有 ${LONG_TASK_RENDERERS.join(' / ')}(kit 里那套)—— ` +
830
- `用户点完只看得到"没反应",看不到进行中 / 要登录 / 完成 / 出错`,
840
+ `用户点完只看得到"没反应",看不到进行中 / 要登录 / 停下的 / 完成 / 出错`,
831
841
  );
832
842
  }
833
843
  }
@@ -5,7 +5,7 @@
5
5
  // CSS Modules 的哈希类名靠"同一份文件、同一个导出"配对,调用点拿到 undefined 时真机渲染成
6
6
  // `class="undefined"` —— 元素整片没样式,而且控制台一声不响。
7
7
  //
8
- // ④ 表单的「行」归 `FormRow(s)`([ADR-0052](../../../docs/adr/0052-the-form-row-is-one-component.md)):
8
+ // ④ 表单的「行」归 `FormRow(s)`([ADR-0052](../../../../docs/adr/0052-the-form-row-is-one-component.md)):
9
9
  // 控件有主(`TextField` / `SelectField` / `ChoiceGroup`)、面有主(`SettingsSection` / `SaveBar`),
10
10
  // 中间那层"标签 + 控件这一行怎么排"从前没有主 ⇒ 每人都自己写一遍:cmdb 一套 `.cmdbField`
11
11
  // (`display:flex` + `.cmdbFieldLabel{width:72px}`)+ 三档 gap(`.cmdbForm` 8 / `.cmdbEnvs` 8 /
@@ -25,7 +25,38 @@ import { auditFontSizes, fontScaleNotes, fontScaleOf } from '../font-scale.mjs';
25
25
 
26
26
  // 外链一律走 kit 的 `Link`:它自带的样式(正文色 / hover 下划线 / 12px / 那个外链小箭头)是唯一一份。
27
27
  // 手写 `<a>` / `h('a', …)` 拿到的是浏览器默认样式,而且每个人写的都不一样(这正是"同一个东西样式不一样"的来源)。
28
- const BARE_ANCHOR = /(h\(\s*['"]a['"]|jsx\(\s*['"]a['"]|<a[\s>])/;
28
+ //
29
+ // **这条是全局匹配(`g`),按全文找再回推行号 —— 与 `bareControlsIn` / `bareFormsIn` 同形,别改回逐行。**
30
+ // 为什么(2026-10-21 实测踩到,`.scratch/uiux-ownership/` 候选 1):JSX 里 `<a` 习惯写在**行尾**
31
+ // (属性在下一行),而 `<a(?=[\s>/])` 要的那个 `[\s>/]` 在**下一行**;逐行 `.test(line)` 里
32
+ // 既没有那个字符、也没有换行本身 ⇒ **行尾写的 `<a` 一处都抓不到**。实测:pduiv2 5 个真模块包里
33
+ // 有 **8 处**裸 `<a>`,**8 处全部**写在行尾,逐行那条的命中是 **0** —— 判据与它自己的文档
34
+ //(`PANEL.md` §6「手写 `<a>` 会被「样式归属」判据直接判红」)打架了半年。全文匹配后正好 **8 命中**。
35
+ // 「一个元素会跨行写属性」这条理由与 `bareFormsIn` 上一行那句是**同一条**,别只给表单那一条用。
36
+ //
37
+ // **哪种 `<a>` 不算 `Link`(所以不算"绕过",收在 ANCHOR_EXEMPT 里)**:`Link` 的形态只有一种 ——
38
+ // **正文里的文字链**(文字 + 行尾那颗 ↗ 小箭头,12px、hover 下划线)。三种是它表达不了的:
39
+ // ① **图标链**:`<a className="wb-iconButton">` 里包一枚图标(平台跳转 / 看日志那些)——
40
+ // `Link` 一定会多画一颗箭头,套进去就是两颗;
41
+ // ② **按钮皮的链**:`.anchorButton` 那种 28 高 / 描边的"看起来像按钮、实际是链接"——
42
+ // [ADR-0054](docs/adr/0054-the-action-area-is-one-component.md) §11 判过:**必须是链接的动作留真 `<a>`**,
43
+ // 不许塞成 `onClick: () => window.open(…)` 的按钮(那一次会把中键新开 / 右键复制链接 / 悬停看 URL 一起丢掉);
44
+ // ③ **整行链**:`.issueLink` 那种 `flex: 1` / `gap` / `color: inherit` 的**行的布局槽**——
45
+ // 它是一条行的皮,不是正文里的字。
46
+ // 这三类今天在真模块包里有 6 处(argocd 的 EnvRows 两处图标链、coding-analysis 的 AnalysisBody 一处图标链、
47
+ // EnvSettings / AnalysisStates 两处按钮皮的链、coding-issues 的 IssueRow 一处整行链),逐文件收在
48
+ // `ANCHOR_EXEMPT` 里;**该迁 `Link` 的是另外 2 处**(argocd 的 EnvRows / IdentityFields 那两条「去登录」文字链)。
49
+ //
50
+ // **两端边界**(与 `CLASS_TOKEN` / `FORM_SLOT_CLASS` 同一套口径,2026-10-21 复核者提醒后补的):
51
+ // · **右**:`<a(?=[\s>/])` —— `<abbr>` 不算(`<a` 后面是 `b`),`<aside>` 也不算;
52
+ // · **左**:`(?<![\w$])` —— **`<` 前面不许是标识符字符**。挡的是 JS 里那个真实形态:
53
+ // `if (count<a ) …`(`count < a` 写掉了空格)今天会命中 `<a ` ⇒ 报一个跟界面无关的红。
54
+ // 用 lookbehind(非消费)而不是 `(^|[^\w$])` 那种前缀,是因为这一条要按 `match.index`
55
+ // **回推行号**(`bareAnchorsIn`):消费式前缀落在上一行的换行上时,行号会算到上一行去。
56
+ // **`h('a'` / `jsx('a'` 那两支也吃同一个左边界**(整组一起套),所以 `myh('a', …)` 不算。
57
+ // **仍然红的、如实记**:`<a` 写在**字符串**里(例如提示文案里那句 `<a href=…>`)—— 与 `BARE_FORM` 那条
58
+ // 一样是**文本级**匹配,这是取舍不是漏报(要收紧先改口径,判据不自己长)。
59
+ const BARE_ANCHOR = /(?<![\w$])(?:h\(\s*['"]a['"]|jsx\(\s*['"]a['"]|<a(?=[\s>/]))/g;
29
60
 
30
61
  // 交互控件同一条理由:按钮 / 单行输入 / 下拉在 kit 与 dsh 原语里都有对应形态
31
62
  // (`Button` · `TextField` · `SelectField` / `ChoiceGroup`),手写的那一份拿到的是浏览器默认样式 ——
@@ -76,7 +107,7 @@ const ZERO_LENGTH = /^(?:0|0px|0rem|0em|0%|none|auto)$/i;
76
107
 
77
108
  // ── F3「手写的操作区」:该走 `FormActions`(ADR-0054) ─────────────────────────────
78
109
  //
79
- // 口径([ADR-0054](../../../docs/adr/0054-the-action-area-is-one-component.md)):操作区那一层
110
+ // 口径([ADR-0054](../../../../docs/adr/0054-the-action-area-is-one-component.md)):操作区那一层
80
111
  // (主操作在右 / 一颗 primary / 危险动作紧挨它左侧 / 间距 / 窄容器换行)从前没有主,只有一条
81
112
  // 借来的槽类 `.wb-formActions`(它只管右对齐 + gap)。于是顺序各摆各的 —— 2026-10-17 用户在
82
113
  // argocd 绑定身份那屏说「这两个按钮的位置换一下」就是现场(pduiv2 `b7977862`)。
@@ -92,6 +123,39 @@ const ZERO_LENGTH = /^(?:0|0px|0rem|0em|0%|none|auto)$/i;
92
123
  // 若自己写 `gap` / 上下 `margin`,还会被 F2 顺带抓到。
93
124
  const ACTION_AREA_CLASS = /wb-formActions/;
94
125
 
126
+ // ── F4「手写的整张表单」:该走 `Form` ─────────────────────────────────────────────
127
+ //
128
+ // 口径照抄 [ADR-0054](../../../../docs/adr/0054-the-action-area-is-one-component.md) §5 与 §9:判据**只**回答
129
+ // "这一层是不是绕过组件自己搭的",**不钉"看起来对不对"**。表单的**面**(行怎么排、错误摆哪、什么时候
130
+ // 校验、提交怎么被挡住、第一处错误怎么找、操作区怎么排)从前没有主 ⇒ 每个调用点自己拼。
131
+ // 用户看到的就是按钮"一会在左一会在右":`.wb-formActions` 的 `justify-content: flex-end` 只在它的盒子
132
+ // 有富余宽度时才有效,放进横向 flex / 网格格子里(子项按内容宽收缩)就顶到左边 —— 同一个组件两个位置两种观感。
133
+ //
134
+ // 判据:模块源码(`.tsx` / `.ts`,测试文件除外)里出现下面任一个 ⇒ 红,改用 kit 的 `Form`:
135
+ // ① 裸 `<form>` 元素(`<form` …`>`,含 `h('form', …)` / `jsx('form', …)`)—— 表单这一层归 `Form`;
136
+ // ② 手写行的槽类 `wb-formRow` / `wb-formRows` / `wb-field`。
137
+ // **为什么只钉这两条(宁可漏报)**:与 F3 同一套口径 —— 这三个槽类只为"行/字段"这一层存在
138
+ //(`shared/src/components/FormRows.css` 的 `.wb-formRow(s)`、`Field.css` 的 `.wb-field`),
139
+ // 出现它们 = 明确在绕过 `FormRow(s)` 手摆行,**零误报**;`<form>` 是浏览器元素,这一层的唯一出处是 kit 的 `Form`。
140
+ // **窄在哪**:`wb-field` 后面跟着标识符或 `-` 的不算 —— `wb-fieldLabel` / `wb-fieldHint` 是那一层的
141
+ // **兄弟**类(`Field` 自己渲染,借用它们不等于"手写了一行"),归属见 F1/F2,别在这里一并判红。
142
+ // **已知漏报(如实记)**:模块自己用 div 拼一张既没有 `<form>`、也没借槽类的表单,这一版不判 ——
143
+ // 判宽了就是"用户每次改界面都先撞门禁"(ADR-0054 §5 那条代价照旧)。这一条靠 KIT.md 与 `Form` 的
144
+ // JSDoc 顶上(ADR-0067:用法住在声明里)。`Form` 这一层的决定、四条行为口径与**完整的漏报清单**
145
+ // (含"借 `wb-formLabel` 自己拼行 F4 不判"那条)见
146
+ // [ADR-0068](../../../../docs/adr/0068-the-form-is-one-component.md)。
147
+ const BARE_FORM = /(?:h\(\s*['"]form['"]|jsx\(\s*['"]form['"]|<form(?=[\s>/]))/g;
148
+ // 这三个槽类的名字:**左右两端都要有边界**(与 `CLASS_TOKEN` 同一套口径,别只写右边那一个)。
149
+ // 只写右边(原来的 `wb-field(?![\w-])`)时,**粘在前缀上的根本不是槽类**却会被判红 ——
150
+ // `data-wb-field="x"`(属性名;独立复核者 2026-10-21 报的那一处)· `xwb-field` · `my-wb-formRows`,全中。
151
+ // 左边用 `(?<![-\w])`:`-` 与标识符字符都不许紧贴在 `wb-` 前面(`my-wb-field` 不算,`className="wb-field"` 算)。
152
+ // **为什么用 lookbehind、不用 `CLASS_TOKEN` 那种消费式前缀 `(^|[^-\w])`**:这一条匹配出来就 `match[0]`,
153
+ // 报错话里印的是 `${slot[0]}` —— 消费式前缀会把前缀一起印进那句人话;用 lookbehind 既印得准,
154
+ // 也不用像 `CLASS_TOKEN` 那样回头按偏移找名字(少一处偏移算术就少一处错的可能)。
155
+ // **今天 0 现实命中**(本仓 + pduiv2 五个模块包都 grep 过:`data-wb-*` 只有 `data-wb-dot` /
156
+ // `data-wb-problem` / `data-wb-select-reveal` 这类,一个都不是槽类)—— 收的是**潜在**误报,不是现实伤。
157
+ const FORM_SLOT_CLASS = /(?<![-\w])(?:wb-formRows?(?![\w-])|wb-field(?![\w-]))/;
158
+
95
159
  /** 类名里有没有 `field(s)` / `form(s)` 这个词(按 camelCase 切词,`platform` 不算)。 */
96
160
  function isFormContainer(token) {
97
161
  return token.split(/(?=[A-Z])/).some((word) => /^(?:fields?|forms?)$/i.test(word))
@@ -198,6 +262,63 @@ const FORM_ROW_EXEMPT = new Map([]);
198
262
  // 直接报红(不再有豁免这一层)。
199
263
  const FORM_ACTIONS_EXEMPT = new Map([]);
200
264
 
265
+ // 历史豁免:**已知**的手写表单(裸 `<form>` / 借了 `wb-formRow(s)` / `wb-field` 槽类手摆行)。
266
+ // 它跟上面三张又是**另一件事、另一个迁移时点**:等的是外壳把 `Form` 这一层发出来(含 `Form` 的那一版)。
267
+ // 理由都指向 ADR-0054 §5(判据边界:只钉"有没有绕过组件")与 §9(为什么只钉槽类、宁可漏报)。
268
+ // 这是**棘轮**:迁一处删一行;某个包在眼前却一处都不再命中时,那条豁免当场报红
269
+ //(见 run() 里的 staleFormRows)。键同形:`<包名>:<相对模块根的文件路径>`。
270
+ // 2026-10-21 实测(只读扫眼前这几个真模块包):**0 命中** ——
271
+ // pduiv2 `dsh-workbench-{argocd,cmdb,coding-analysis,coding-issues,gitops}`(`<form` 一处没有,
272
+ // 三个槽类一处没有)、本仓 `module-hello`(`platform-coding` 不是模块包,没有 `dsh.workbench`)。
273
+ // 所以这里留成**空表**,不许为了"看起来有事做"把规则收宽或写成"全部":
274
+ // 它就是这条判据的棘轮位 —— 谁开始手写表单,直接报红(不再有豁免这一层)。
275
+ const FORM_EXEMPT = new Map([]);
276
+
277
+ // 历史豁免:**已知**的手写裸 `<a>`。它跟上面四张**又是另一件事、另一个迁移时点**:
278
+ // 那一张等的是外壳把 `Link` 发出来(`Link` 从 0.2.x 起就有 ⇒ **这一张没有"等发版"这回事**,
279
+ // 它等的是**逐处判断"这一条到底该不该用 Link"**)。
280
+ // 为什么单开一张、不混进 `CONTROL_EXEMPT`:那一张等的是 kit 成员(`Button` / `TextField`…),
281
+ // 这一张要记的是"三种 `Link` 表达不了的形态"(图标链 / 按钮皮的链 / 整行链)——
282
+ // 口径写在 `BARE_ANCHOR` 那一段注释里,README 的 `style` 那一行也有一份。
283
+ // 这是**棘轮**:迁一处删一行;某个包在眼前却一处都不再命中时,那条豁免当场报红
284
+ //(见 run() 里的 staleAnchorRows)。键同形:`<包名>:<相对模块根的文件路径>`。
285
+ //
286
+ // 2026-10-21 实测(只读 pduiv2):**8 处 / 6 个文件**,全部收在这里 ——
287
+ // 这一天之前它们**一处都没被判据抓到**(`BARE_ANCHOR` 还是逐行匹配,而 8 处 `<a` 全写在行尾)。
288
+ // 理由分两类,逐条写在下面:**「该迁 `Link`」2 处**(两条「去登录」文字链,是 `Link` 的形状)、
289
+ // **「有意的不算」6 处**(图标链 / 按钮皮的链 / 整行链)。哪一处是迁、哪一处是留,别照抄文件名判断 ——
290
+ // **一个文件里可以两种都有**(`module-argocd: src/views/EnvRows.tsx` 就是:两条图标链该留、一条文字链该迁)。
291
+ const ANCHOR_EXEMPT = new Map([
292
+ [
293
+ '@pagoda-dsh-workbench/module-argocd:src/views/EnvRows.tsx',
294
+ '3 处::212 / :298 是**图标链**(`<a className="wb-iconButton">` + 一枚 ↗,`Link` 会多画一颗箭头)⇒ 有意保留;' +
295
+ ':401 是 `.inlineLink` + 文案「去登录」+ ↗12 ⇒ **该迁 `Link`**(这一处是"迁一处删一行"里要删的那一格,不是这一行)',
296
+ ],
297
+ [
298
+ '@pagoda-dsh-workbench/module-argocd:src/views/IdentityFields.tsx',
299
+ '1 处::50 是 `.loginLink` + 文案「去登录」+ ↗12 ⇒ **该迁 `Link`**(迁完把这一行删掉;迁的时候会**有意**变两处观感:' +
300
+ '字号 11 → 12(`Link` 是 `--dsw-font-xxs-12`)、多一条 hover 下划线)',
301
+ ],
302
+ [
303
+ '@pagoda-dsh-workbench/module-coding-analysis:src/views/AnalysisBody.tsx',
304
+ '1 处::42 是**图标链**(`wb-iconButton` 槽 + 一枚 ↗ + `title` / `aria-label`)⇒ 有意保留',
305
+ ],
306
+ [
307
+ '@pagoda-dsh-workbench/module-coding-analysis:src/views/EnvSettings.tsx',
308
+ '1 处::166 是**按钮皮的链**(`.anchorButton`:28 高 / 描边 / 12px)—— ADR-0054 §11 逐字判过' +
309
+ '「必须是链接的动作留真 `<a>`,不许塞成 `window.open` 的按钮」⇒ 有意保留',
310
+ ],
311
+ [
312
+ '@pagoda-dsh-workbench/module-coding-analysis:src/views/AnalysisStates.tsx',
313
+ '1 处::122 同 `EnvSettings.tsx:166`(同一个 `.anchorButton` 皮、同一屏的兄弟)⇒ 有意保留',
314
+ ],
315
+ [
316
+ '@pagoda-dsh-workbench/module-coding-issues:src/components/IssueRow.tsx',
317
+ '1 处::194 是**整行链**(`.issueLink`:`flex: 1` / `gap: 8` / `color: inherit`)—— 它是那条行的布局槽,' +
318
+ '不是正文里的文字链 ⇒ 有意保留',
319
+ ],
320
+ ]);
321
+
201
322
  /** 裸控件出现的位置 —— 逐行看不了(一个元素会跨行),所以在全文里按标签找,再回推行号。 */
202
323
  function bareControlsIn(text) {
203
324
  const hits = [];
@@ -214,6 +335,25 @@ function bareControlsIn(text) {
214
335
  return hits;
215
336
  }
216
337
 
338
+ /** 裸 `<form>` 出现的位置 —— 一个元素会跨行写属性,所以按全文找、再回推行号。 */
339
+ function bareFormsIn(text) {
340
+ return Array.from(text.matchAll(BARE_FORM), (match) => ({
341
+ line: text.slice(0, match.index).split('\n').length,
342
+ }));
343
+ }
344
+
345
+ /** 裸 `<a>` 出现的位置 —— 与 `bareControlsIn` / `bareFormsIn` 同一条理由:一个元素会跨行写属性
346
+ * (`<a` 常常就落在行尾),所以按**全文**找、再回推行号,不逐行看。
347
+ * 开头那一句 `lastIndex` 归零是**防呆**:`matchAll` 会照抄原正则的 `lastIndex`,
348
+ * 而这个正则是带 `g` 的共享常量 —— 谁哪天在别处对它 `.test(...)` 一次(旧版就是逐行 `.test`),
349
+ * 下一次 `matchAll` 就从那个位置接着找,**静默漏掉前半份命中**。实测过这个坑。 */
350
+ function bareAnchorsIn(text) {
351
+ BARE_ANCHOR.lastIndex = 0;
352
+ return Array.from(text.matchAll(BARE_ANCHOR), (match) => ({
353
+ line: text.slice(0, match.index).split('\n').length,
354
+ }));
355
+ }
356
+
217
357
  const CLASS_TOKEN = /(^|[^-\w])\.?(wb-[a-zA-Z0-9-]+)/g;
218
358
  // 契约 = 外壳**所有**声明了 `wb-*` 的样式表,不只是那份全局 `board.css`:
219
359
  // ADR-0029 要求组件的样式跟组件走,所以 `wb-card` / `wb-rows` / `wb-note` 这些已经搬进
@@ -340,9 +480,12 @@ export async function run() {
340
480
  const controls = [];
341
481
  const rows = [];
342
482
  const actionAreas = [];
483
+ const forms = [];
343
484
  // 这一轮"命中过"的 `包:文件`(含被豁免的)—— 豁免表里那些不再命中的行靠它发现(棘轮)。
344
485
  const formRowHits = new Set();
345
486
  const actionAreaHits = new Set();
487
+ const formHits = new Set();
488
+ const anchorHits = new Set();
346
489
  const modules = discoverModulePackages({ root: process.cwd() });
347
490
  const contracts = new Map(modules.map((pkg) => [pkg.dir, contractOf(pkg.dir)]));
348
491
  for (const pkg of modules) {
@@ -383,13 +526,17 @@ export async function run() {
383
526
  if (isTestSource(file)) continue;
384
527
  if (!/\.tsx?$/.test(file)) continue;
385
528
  const exemptControls = CONTROL_EXEMPT.has(`${pkg.manifest.name}:${relative(pkg.dir, file)}`);
386
- lines.forEach((line, index) => {
387
- if (!BARE_ANCHOR.test(line)) return;
529
+ const anchorKey = `${pkg.manifest.name}:${relative(pkg.dir, file)}`;
530
+ const exemptAnchors = ANCHOR_EXEMPT.has(anchorKey);
531
+ for (const hit of bareAnchorsIn(text)) {
532
+ anchorHits.add(anchorKey);
533
+ if (exemptAnchors) continue;
388
534
  anchors.push(
389
- `${pkg.manifest.name}: ${relative(pkg.dir, file)}:${index + 1} 手写了裸 <a> —— ` +
390
- `外链一律用 kit 的 Link(新窗口、noopener、自带那个外链小箭头;样式只有这一份):${line.trim().slice(0, 80)}`,
535
+ `${pkg.manifest.name}: ${relative(pkg.dir, file)}:${hit.line} 手写了裸 <a> —— ` +
536
+ `正文里的文字链一律用 kit 的 Link(新窗口、noopener、自带那个外链小箭头;样式只有这一份):` +
537
+ `${(lines[hit.line - 1] ?? '').trim().slice(0, 80)}`,
391
538
  );
392
- });
539
+ }
393
540
  if (!exemptControls) {
394
541
  for (const hit of bareControlsIn(text)) {
395
542
  controls.push(
@@ -425,6 +572,29 @@ export async function run() {
425
572
  `${line.trim().slice(0, 80)}`,
426
573
  );
427
574
  });
575
+ const formKey = `${pkg.manifest.name}:${relative(pkg.dir, file)}`;
576
+ const exemptForms = FORM_EXEMPT.has(formKey);
577
+ for (const hit of bareFormsIn(text)) {
578
+ formHits.add(formKey);
579
+ if (exemptForms) continue;
580
+ forms.push(
581
+ `${pkg.manifest.name}: ${relative(pkg.dir, file)}:${hit.line} 手写了裸 <form> —— ` +
582
+ '改用 kit 的 Form(fields 进、其余全归它:行的排布 / 校验时机 / 错误摆哪 / 提交怎么被挡住 / ' +
583
+ '第一处错误怎么找 / 操作区怎么排):' +
584
+ `${(lines[hit.line - 1] ?? '').trim().slice(0, 80)}`,
585
+ );
586
+ }
587
+ lines.forEach((line, index) => {
588
+ const slot = line.match(FORM_SLOT_CLASS);
589
+ if (slot === null) return;
590
+ formHits.add(formKey);
591
+ if (exemptForms) return;
592
+ forms.push(
593
+ `${pkg.manifest.name}: ${relative(pkg.dir, file)}:${index + 1} 手写了表单的行(借了外壳的槽类 ${slot[0]})—— ` +
594
+ '改用 kit 的 Form(面 → 行 → 控件:标签 + 控件这一行归 FormRow(s),整张表单的面归 Form):' +
595
+ `${line.trim().slice(0, 80)}`,
596
+ );
597
+ });
428
598
  if (contract === null) continue;
429
599
  lines.forEach((line, index) => {
430
600
  for (const match of line.matchAll(CLASS_TOKEN)) {
@@ -485,9 +655,26 @@ export async function run() {
485
655
  `${key} 还在 FORM_ACTIONS_EXEMPT 里,但一处都不再命中 —— 按棘轮删掉这一行(原由:${reason})`,
486
656
  );
487
657
  }
658
+ const staleFormRows = [];
659
+ for (const [key, reason] of FORM_EXEMPT) {
660
+ const owner = key.slice(0, key.indexOf(':'));
661
+ if (!present.has(owner) || formHits.has(key)) continue;
662
+ staleFormRows.push(
663
+ `${key} 还在 FORM_EXEMPT 里,但一处都不再命中 —— 按棘轮删掉这一行(原由:${reason})`,
664
+ );
665
+ }
666
+ const staleAnchorRows = [];
667
+ for (const [key, reason] of ANCHOR_EXEMPT) {
668
+ const owner = key.slice(0, key.indexOf(':'));
669
+ if (!present.has(owner) || anchorHits.has(key)) continue;
670
+ staleAnchorRows.push(
671
+ `${key} 还在 ANCHOR_EXEMPT 里,但一处都不再命中 —— 按棘轮删掉这一行(原由:${reason})`,
672
+ );
673
+ }
488
674
 
489
675
  if (bindings.length + problems.length + borrowed.length + anchors.length + controls.length
490
- + rows.length + actionAreas.length + staleRows.length + staleActionRows.length > 0) {
676
+ + rows.length + actionAreas.length + forms.length + staleRows.length + staleActionRows.length
677
+ + staleFormRows.length + staleAnchorRows.length > 0) {
491
678
  const notes = [];
492
679
  if (bindings.length > 0) {
493
680
  notes.push(
@@ -511,8 +698,10 @@ export async function run() {
511
698
  }
512
699
  if (anchors.length > 0) {
513
700
  notes.push(
514
- `${anchors.length} 处是「手写了裸 <a>」:(外链用 kit 的 Link —— 样式只有一份,改一处全局一致;` +
515
- '裸 <a> 拿到的是浏览器默认样式,看着和卡片其它地方不是一套)',
701
+ `${anchors.length} 处是「手写了裸 <a>」:(**正文里的文字链**用 kit 的 Link —— 样式只有一份,` +
702
+ '改一处全局一致;裸 <a> 拿到的是浏览器默认样式,看着和卡片其它地方不是一套。' +
703
+ '三种不算"绕过"的形态 —— 图标链 / 按钮皮的链 / 整行链 —— 逐文件收在 `ANCHOR_EXEMPT` 里,' +
704
+ '口径见 style.mjs 里 `BARE_ANCHOR` 那一段注释与 README 的 `style` 那一行)',
516
705
  );
517
706
  }
518
707
  if (controls.length > 0) {
@@ -540,6 +729,16 @@ export async function run() {
540
729
  '窄口径与已知漏报见 style.mjs 里那一段注释)',
541
730
  );
542
731
  }
732
+ if (forms.length > 0) {
733
+ notes.push(
734
+ `${forms.length} 处是「手写了整张表单(裸 <form> 元素,或借了外壳的槽类 wb-formRow / wb-formRows / wb-field)」:(` +
735
+ '表单那一层归 `Form` —— fields 进、其余全归它:行的排布、校验的时机(还没提交过时一格错都不显示)、' +
736
+ '错误摆哪、提交怎么被挡住、第一处错误怎么带进视口、操作区怎么排都由组件定,调用点只给数据与语义。' +
737
+ '`wb-formRow` / `wb-formRows` / `wb-field` 这三个槽类只为"行 / 字段"这一层存在,出现它们就是自己摆了一套:' +
738
+ '改用 kit 的 `Form`(`<Form fields={…} onSubmit={…} submit={…} />`)。' +
739
+ '窄口径与已知漏报见 style.mjs 里那一段注释)',
740
+ );
741
+ }
543
742
  if (staleRows.length > 0) {
544
743
  notes.push(
545
744
  `${staleRows.length} 条 FORM_ROW_EXEMPT 已经不命中了:(棘轮:迁完一处删一行 —— ` +
@@ -552,7 +751,19 @@ export async function run() {
552
751
  '一个包迁完 FormActions 之后,它那几行豁免要跟着删,别留着让表长起来)',
553
752
  );
554
753
  }
555
- // 各类问题各自成串,按原来的输出顺序(绑定 → 规则 → 借用 → 裸 <a> → 裸控件 → 表单行 → 操作区 → 过期豁免)拼在一起。
754
+ if (staleFormRows.length > 0) {
755
+ notes.push(
756
+ `${staleFormRows.length} 条 FORM_EXEMPT 已经不命中了:(棘轮:迁完一处删一行 —— ` +
757
+ '一个包迁完 Form 之后,它那几行豁免要跟着删,别留着让表长起来)',
758
+ );
759
+ }
760
+ if (staleAnchorRows.length > 0) {
761
+ notes.push(
762
+ `${staleAnchorRows.length} 条 ANCHOR_EXEMPT 已经不命中了:(棘轮:迁完一处删一行 —— ` +
763
+ '一个文件里的裸 <a> 都迁成 Link(或那个文件没了)之后,它那一行豁免要跟着删,别留着让表长起来)',
764
+ );
765
+ }
766
+ // 各类问题各自成串,按原来的输出顺序(绑定 → 规则 → 借用 → 裸 <a> → 裸控件 → 表单行 → 操作区 → 整张表单 → 过期豁免)拼在一起。
556
767
  return {
557
768
  status: 'fail',
558
769
  notes,
@@ -564,8 +775,11 @@ export async function run() {
564
775
  ...controls,
565
776
  ...rows,
566
777
  ...actionAreas,
778
+ ...forms,
567
779
  ...staleRows,
568
780
  ...staleActionRows,
781
+ ...staleFormRows,
782
+ ...staleAnchorRows,
569
783
  ],
570
784
  };
571
785
  }
@@ -603,6 +817,10 @@ export async function run() {
603
817
  actionAreaHits.size === 0
604
818
  ? '没有手写的操作区'
605
819
  : `操作区这一层还有 ${actionAreaHits.size} 个文件在 FORM_ACTIONS_EXEMPT 里等外壳发版后迁(棘轮:迁一处删一行)`;
820
+ const formNote =
821
+ formHits.size === 0
822
+ ? '没有手写的整张表单(没有裸 <form>,也没有借表单槽类手摆行)'
823
+ : `表单这一层还有 ${formHits.size} 个文件在 FORM_EXEMPT 里等外壳发版后迁(棘轮:迁一处删一行)`;
606
824
  // 黄的那一条:各类条数 + 涉及文件数 + 最多十来行明细 + 指路文档(口径见上面那一段注释)。
607
825
  const fontScale = fontScaleOf(SHELL_FONT_SCALE_FILES);
608
826
  const fontNotes = fontScaleNotes(
@@ -613,7 +831,7 @@ export async function run() {
613
831
  return {
614
832
  status: 'pass',
615
833
  notes: [
616
- `${modules.length} 个模块都没有定义外壳前缀(wb-*)的 CSS 规则;${contractNote};${exemptNote};${actionNote}`,
834
+ `${modules.length} 个模块都没有定义外壳前缀(wb-*)的 CSS 规则;${contractNote};${exemptNote};${actionNote};${formNote}`,
617
835
  ...fontNotes,
618
836
  ],
619
837
  problems: [],
@@ -47,7 +47,7 @@ export function declaredToolsWithoutKey(raw) {
47
47
  .map((tool) => JSON.stringify(tool?.name));
48
48
  }
49
49
 
50
- /** 生成物的正文。**不给注释**:它落在模块的 `src/**`(零注释那一条管到这里),改它无效、过期会红。 */
50
+ /** 生成物的正文。**不给注释**:它落在模块的 `src/**`(那里只许贴导出的 JSDoc,生成这份一行都不加),改它无效、过期会红。 */
51
51
  export function renderDeclaredTools(raw) {
52
52
  const rows = toolsOf(raw).map(
53
53
  (tool) =>
package/src/lint.mjs CHANGED
@@ -221,7 +221,7 @@ export function moduleLint(options = {}) {
221
221
  name: FINGERPRINT,
222
222
  files: SRC_FILES,
223
223
  rules: {
224
- // `catch {}` 是本仓零注释约定下的刻意写法(注释要用 `_` 前缀表达在场),不是漏写。
224
+ // `catch {}` 是"实现里不留注释"这条约定下的刻意写法(要表达在场就 `_` 前缀命名),不是漏写。
225
225
  'no-empty': ['error', { allowEmptyCatch: true }],
226
226
  'prefer-const': 'error',
227
227
  // 上一期的非目标之一:`as any` 把契约绕过去 —— 复杂模块最先烂在这里。
@@ -59,10 +59,26 @@ export function shellTypeRoots(shellClientPath) {
59
59
  return { shellRoot, roots };
60
60
  }
61
61
 
62
- /** 同名多处时挑哪个:组件 > hooks > 库 > 其他,同档挑路径短的。 */
62
+ /**
63
+ * 同名多处时挑哪个:组件 > hooks > 库 > 其他 > **套件内部(`app/` / `modules/`)垫底**,同档挑路径短的。
64
+ *
65
+ * 垫底那一档是 2026-10-20 补的(ADR-0067 的 kit JSDoc 判据撞出来的):kit 的 `readText`(共享包
66
+ * `config/storage.ts`)与外壳内部 `modules/panels/model/diy-report.ts` 里的同名函数撞了,而 `modules/`
67
+ * 那条路径在 `node_modules` 那份类型树旁边**更短**,于是 `--file readText` 一直指到别人的实现上。
68
+ * kit 的成员只从 `client.tsx` 那几处 spread 来(共享包 + 外壳 `shared/**`),套件内部不会是它 ——
69
+ * 所以垫底只影响撞名时的取舍,掉不到"本来该选的那份"。
70
+ */
63
71
  function preferred(candidates) {
64
72
  const rank = (file) =>
65
- file.includes('/components/') ? 0 : file.includes('/hooks/') ? 1 : file.includes('/lib/') ? 2 : 3;
73
+ file.includes('/components/')
74
+ ? 0
75
+ : file.includes('/hooks/')
76
+ ? 1
77
+ : file.includes('/lib/')
78
+ ? 2
79
+ : /\/(app|modules)\//.test(file)
80
+ ? 9
81
+ : 3;
66
82
  return [...candidates].sort((a, b) => rank(a.file) - rank(b.file) || a.file.length - b.file.length)[0];
67
83
  }
68
84
 
@@ -194,7 +194,12 @@ approvalDetail: async (call) => ({ zh: '会覆盖 3 个模块的产物,不碰
194
194
  mount({ longTasks, ... }) {
195
195
  const runs = longTasks?.create({
196
196
  key: 'rebuild',
197
- run: async ({ payload, onStep }) => { onStep({ kind: 'start', text: '开始…' }); /* … */ return {}; },
197
+ run: async ({ payload, onStep, signal }) => {
198
+ onStep({ kind: 'start', text: '开始…' });
199
+ // signal 被 abort = 有人按了停止(用户 / agent):每一轮都看一眼 `signal.aborted`,能收手就收手
200
+ /* … */
201
+ return {};
202
+ },
198
203
  outcome: (result) => ({ why: '…' }),
199
204
  view: (snapshot) => ({ steps: String(snapshot.steps.length) }),
200
205
  });
@@ -202,9 +207,27 @@ mount({ longTasks, ... }) {
202
207
  }
203
208
  ```
204
209
 
205
- 浏览器侧用 kit 那套画(`kit.hooks.useLongTask` + `<LongTaskRow …/>`):状态只有
206
- `running` 进行中 / `needs-login` 要登录 / `ok` 完成 / `failed` 出错 —— **契约里没有"停止"**,
207
- 别在界面上摆一个不存在的按钮。
210
+ 起这一趟的那把工具里(`execute(args, exec)` 拿得到现场)记下来源:
211
+
212
+ ```ts
213
+ runs.start({ workspace, target, payload, session: sessionIdOf(exec) ?? undefined });
214
+ ```
215
+
216
+ `sessionIdOf`(`@dsh-workbench/shared/host`)说这次调用是哪个会话发的。**不记来源 = 用户按的**
217
+ (或这一版之前起的)—— 停它要走审批;外壳自带的那两把 `workbench_long_tasks_list` /
218
+ `workbench_long_tasks_stop` 就是这么判"谁的"(自己起的免审批,别人的 / 用户按的要审批,
219
+ 不在当前工作区的直接拒绝)。
220
+
221
+ 浏览器侧用 kit 那套画(`kit.hooks.useLongTask` + `<LongTaskRow …/>`):状态有五种 ——
222
+ `running` 进行中 / `needs-login` 要登录 / `stopped` 停下的(用户按停)/ `ok` 完成 / `failed` 出错。
223
+
224
+ 要给用户那颗停止钮,就在卡片上给那一行的 `LongTaskRow` 传 `stop`
225
+ (词与取值照 dsh 任务列表那行:aria `停止任务 {label}` → 上膛 → 可见字 `确认停止`、
226
+ aria `再次点击确认停止`,3 秒落膛;**失败那半是我们自己的** —— 不往钮里写「停止失败」,
227
+ 而是把这次请求失败的原话画在那一行下面,`failed` 给什么画什么);**要停就认 `signal`,不认就别画** ——
228
+ 画了就是骗人:外壳只会把这一趟标成 `stopped`,你的活儿还在跑(内部是 opencli 子进程那类编排,
229
+ 今天最多做到"我们不再等它")。要用这个新 prop 就得装带这次改动的那份外壳 ——
230
+ 把自己 `peerDependencies['@dsh-workbench/core']` 的下限抬到那一版。
208
231
 
209
232
  ### 存起来:他能自己配什么(`prefs`)
210
233