@dsh-workbench/module-tools 0.2.10 → 0.2.11

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
@@ -22,14 +22,14 @@ dsh-wb-module publish # 发货前的清单:门禁 + 三张表 +
22
22
  |---|---|
23
23
  | `contracts` | 模块契约:单元 id 唯一、两个面、四个脚本、`files`、peer 下限;以及**清单 ↔ 代码**(真正注册的工具)与**底稿 ↔ 清单**(`design.json` 的能力名)两处对账(见下) |
24
24
  | `protocol` | 模块声明协议:`dsh.workbench` 的 `apiVersion` 要在、静态半形状要对、不许有协议不认识的键(`scope` 现在**就是**不认识的键之一 —— 见下) |
25
- | `bundles` | 产物边界:宿主面里不许出现浏览器侧的东西 |
25
+ | `bundles` | 产物边界:宿主面里不许出现浏览器侧的东西;浏览器产物里不许出现**内联的外壳 CSS**(模块从 `@dsh-workbench/shared` 根入口取运行期值就会这样) |
26
26
  | `source` | 源码边界:不直接引 dsh 的包、不引不是种子词的 `@deepseek-ai/*` |
27
27
  | `primitives` | dsh 原语只从模块表(`kit`)拿 |
28
28
  | `kit` | 这份外壳的 kit 成员到底有哪些(缺什么会当场点名) |
29
29
  | `cards` | 卡片渲染:五态都要画得出来,空数据不许抛 |
30
30
  | `standalone` | 独立构建:产物不依赖工坊里跑 |
31
31
  | `markers` | 公开包无内部标识(内网域名、企业内部名) |
32
- | `style` | 样式归属:模块不许定义外壳前缀(`wb-*`)的规则 |
32
+ | `style` | 样式归属:模块不许定义外壳类(`wb-*`)的规则(判据看**复合选择器**,`:global(.wb-x)` 也算,只有同一个复合选择器里带模块自己的类才放行);**裸 `<a>` 与裸交互控件(`<button>` / `<input>` / `<select>`)都判红** —— 前者用 kit 的 `Link`,后者用 `Button` / `TextField` / `SelectField` / `ChoiceGroup`;只有 `<textarea>` 与 `input[type=hidden\|file]` 例外(两处都没有那种形态) |
33
33
  | `alias` | 模块不指外壳:源码里没有指向外壳 `@/` 的引用 |
34
34
 
35
35
  三态输出:`✔ 通过` / `○ 不适用` / `✖ 不过`;有任何不过就退出 1。
@@ -103,7 +103,7 @@ dsh-wb-module design accept # 用户逐条点头之后,把验收清单打勾
103
103
  | 模板 | 是什么 |
104
104
  |---|---|
105
105
  | `minimal`(默认) | 空壳:卡壳、文案、测试接好了,没有数据源 |
106
- | `diy` | 一份真能跑的样板:宿主侧一条只读路由真读盘、卡片五态、`?wbMock=1` 三档夹具,**三张表也是填好的** |
106
+ | `diy` | 一份真能跑的样板:宿主侧一条只读路由真读盘、卡片五态,**三张表也是填好的** |
107
107
 
108
108
  `init` 会连模板里的 `*.tpl` 一起拷过去、把 `__UNIT__` / `__NAME__` / `__DESC__` 填上、剥掉 `.tpl` 后缀,
109
109
  再用 `design.json` 生成 README 那一段。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dsh-workbench/module-tools",
3
- "version": "0.2.10",
3
+ "version": "0.2.11",
4
4
  "description": "模块包的构建与门禁工具:build(单文件 ESM 宿主侧 + 套 loader 壳的浏览器侧)、check(契约/产物边界/源码边界/卡片渲染)。模块包不再各带一份构建脚本。",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -21,7 +21,7 @@
21
21
  },
22
22
  "dependencies": {
23
23
  "esbuild": "^0.28.2",
24
- "@dsh-workbench/shared": "0.2.10"
24
+ "@dsh-workbench/shared": "0.2.11"
25
25
  },
26
26
  "scripts": {
27
27
  "selfcheck": "node scripts/selfcheck.mjs"
@@ -19,6 +19,7 @@
19
19
 
20
20
  import { existsSync, readFileSync } from 'node:fs';
21
21
  import { join } from 'node:path';
22
+ import { inlinedShellRules } from '../css-shell.mjs';
22
23
  import { discoverModulePackages } from '../discover.mjs';
23
24
 
24
25
  const CLIENT_FORBIDDEN = [
@@ -63,6 +64,21 @@ export async function run() {
63
64
  );
64
65
  }
65
66
  }
67
+ // 外壳的组件样式**只有一份**(外壳注入那一次)。模块从 `@dsh-workbench/shared` 的**根入口**
68
+ // 取运行期值(`h` / `Fragment` / `useState` / 纯函数…)就会把整份组件 barrel 连 CSS 一起打进
69
+ // 自己的产物,注入后按顺序覆盖外壳那一份 —— 用户看到的是旧样式,而且外壳改了也不生效。
70
+ // 判断看复合选择器:`._hash_row.wb-iconButton` 这种「自己的类 + 外壳的槽类」是合规的,不算。
71
+ if (artifact.file === 'client.js') {
72
+ const inlined = inlinedShellRules(source);
73
+ if (inlined.length > 0) {
74
+ problems.push(
75
+ `${name}: ${artifact.what} ${artifact.file} 里内联了 ${inlined.length} 条外壳的 CSS 规则` +
76
+ `(${inlined.slice(0, 3).join(' · ')})—— 外壳的组件样式只有一份:` +
77
+ 'React 原语从 \'react\' 拿、外壳给的东西从 kit 拿(`@dsh-workbench/shared` 根入口是浏览器 barrel,' +
78
+ '它会急切地把 components/** 连样式一起拉进来)。见 ADR-0028 §5.1',
79
+ );
80
+ }
81
+ }
66
82
  }
67
83
  }
68
84
 
@@ -78,7 +94,7 @@ export async function run() {
78
94
  }
79
95
  return {
80
96
  status: 'pass',
81
- notes: [`${checked} 份产物 · 浏览器侧无 node 内置 · 宿主侧无 DOM/react`],
97
+ notes: [`${checked} 份产物 · 浏览器侧无 node 内置 · 宿主侧无 DOM/react · 浏览器侧没有内联的外壳 CSS`],
82
98
  problems: [],
83
99
  };
84
100
  }
@@ -103,7 +103,7 @@ function fakeDshPackage() {
103
103
  function loadFace(clientFile) {
104
104
  let captured;
105
105
  const fakeWindow = {
106
- // 浏览器里 `window.location` 永远在;缺了它,夹具模式的开关会先抛(第一次跑就踩到)。
106
+ // 浏览器里 `window.location` 永远在;缺了它,读它的客户端代码(含 `?wbMock=1` 那个开关)会先抛。
107
107
  location: { search: '', href: 'http://127.0.0.1:3081/' },
108
108
  __ModuleLoader__: {
109
109
  load: (entry) => {
@@ -11,13 +11,54 @@
11
11
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
12
12
  import { dirname, join, relative, resolve } from 'node:path';
13
13
  import { fileURLToPath } from 'node:url';
14
+ import { shellRulesIn } from '../css-shell.mjs';
14
15
  import { discoverModulePackages } from '../discover.mjs';
15
16
 
16
- const SHELL_SELECTOR = /(^|[\s,>+~])\.wb-[a-z0-9-]+/;
17
- const RULE = /\.wb-[a-z0-9-]+[^{}]*\{/;
18
17
  // 外链一律走 kit 的 `Link`:它自带的样式(正文色 / hover 下划线 / 12px / 那个外链小箭头)是唯一一份。
19
18
  // 手写 `<a>` / `h('a', …)` 拿到的是浏览器默认样式,而且每个人写的都不一样(这正是"同一个东西样式不一样"的来源)。
20
19
  const BARE_ANCHOR = /(h\(\s*['"]a['"]|jsx\(\s*['"]a['"]|<a[\s>])/;
20
+
21
+ // 交互控件同一条理由:按钮 / 单行输入 / 下拉在 kit 与 dsh 原语里都有对应形态
22
+ // (`Button` · `TextField` · `SelectField` / `ChoiceGroup`),手写的那一份拿到的是浏览器默认样式 ——
23
+ // 悬停、禁用、焦点环、色板、字号各写一遍,不跟主题也不跟语言。
24
+ // 例外只有两个,都是"两处都没有的形态":多行输入(`<textarea>`,dsh 与 kit 都没有)与
25
+ // `input` 的 `type="hidden"` / `type="file"`(不是标准控件,拿不到样式)。这两个放行。
26
+ const BARE_CONTROLS = [
27
+ { tag: 'button', what: '按钮', use: 'dsh 的 Button(variant / size),或 kit 的 SaveBar / BlockerState' },
28
+ { tag: 'input', what: '单行输入', use: 'kit 的 TextField' },
29
+ { tag: 'select', what: '下拉', use: 'kit 的 SelectField(搜索 / 分组 / 空态 / 加载态都自带)' },
30
+ ];
31
+ const NATIVE_ONLY_INPUT = /type\s*[:=]\s*['"](?:hidden|file)['"]/;
32
+
33
+ // 历史豁免:这三处是**已知**的手写控件,各有各的理由,跟着各自的迁移走 ——
34
+ // ① `module-cmdb/src/views/MenuField.tsx`(两处:那个下拉的触发钮与它的等待态)
35
+ // = ADR-0051 §4 迁移清单里那两处:两件都归 `SelectField`(等待态就是它的 `loading`),
36
+ // 但**要等外壳先发一版** —— 模块的 peer 下限得先有 `SelectField`,否则装上就崩。
37
+ // ② `module-cmdb/src/views/ChooseEnvsBody.tsx`(一处:`role="radio"` 那一列选项行)
38
+ // = 对应 kit 的 `ChoiceGroup`;换不换是那个模块自己的一次改动(分组标题与标记是它的排版)。
39
+ // 这是**棘轮**:迁完一处就删一行,别只加不减(名单长起来等于这条判据没有)。
40
+ // 键是 `<包名>:<相对模块根的文件路径>`。
41
+ const CONTROL_EXEMPT = new Set([
42
+ '@pagoda-dsh-workbench/module-cmdb:src/views/MenuField.tsx',
43
+ '@pagoda-dsh-workbench/module-cmdb:src/views/ChooseEnvsBody.tsx',
44
+ ]);
45
+
46
+ /** 裸控件出现的位置 —— 逐行看不了(一个元素会跨行),所以在全文里按标签找,再回推行号。 */
47
+ function bareControlsIn(text) {
48
+ const hits = [];
49
+ for (const one of BARE_CONTROLS) {
50
+ const pattern = new RegExp(
51
+ `(?:h\\(\\s*['"]${one.tag}['"]|jsx\\(\\s*['"]${one.tag}['"]|<${one.tag}(?=[\\s>/]))`,
52
+ 'g',
53
+ );
54
+ for (const match of text.matchAll(pattern)) {
55
+ if (one.tag === 'input' && NATIVE_ONLY_INPUT.test(text.slice(match.index, match.index + 220))) continue;
56
+ hits.push({ ...one, line: text.slice(0, match.index).split('\n').length });
57
+ }
58
+ }
59
+ return hits;
60
+ }
61
+
21
62
  const CLASS_TOKEN = /(^|[^-\w])\.?(wb-[a-zA-Z0-9-]+)/g;
22
63
  // 契约 = 外壳**所有**声明了 `wb-*` 的样式表,不只是那份全局 `board.css`:
23
64
  // ADR-0029 要求组件的样式跟组件走,所以 `wb-card` / `wb-rows` / `wb-note` 这些已经搬进
@@ -97,6 +138,7 @@ export async function run() {
97
138
  const problems = [];
98
139
  const borrowed = [];
99
140
  const anchors = [];
141
+ const controls = [];
100
142
  const modules = discoverModulePackages({ root: process.cwd() });
101
143
  const contracts = new Map(modules.map((pkg) => [pkg.dir, contractOf(pkg.dir)]));
102
144
  for (const pkg of modules) {
@@ -111,16 +153,18 @@ export async function run() {
111
153
  }
112
154
  const lines = text.split('\n');
113
155
  lines.forEach((line, index) => {
114
- if (!RULE.test(line)) return;
115
- const selector = line.slice(0, line.indexOf('{'));
116
- if (!SHELL_SELECTOR.test(selector)) return;
117
- problems.push(
118
- `${pkg.manifest.name}: ${relative(pkg.dir, file)}:${index + 1} 定义了外壳前缀的 CSS 规则 —— ${line.trim().slice(0, 100)}`,
119
- );
156
+ for (const offender of shellRulesIn(line)) {
157
+ problems.push(
158
+ `${pkg.manifest.name}: ${relative(pkg.dir, file)}:${index + 1} 定义了外壳类的 CSS 规则 —— ` +
159
+ `${offender}(这个复合选择器里没有模块自己的类:外壳的类谁渲染谁拥有,` +
160
+ '要改就跟外壳那位主人说,别在后面覆盖它)',
161
+ );
162
+ }
120
163
  });
121
164
 
122
165
  if (isTestSource(file)) continue;
123
166
  if (!/\.tsx?$/.test(file)) continue;
167
+ const exemptControls = CONTROL_EXEMPT.has(`${pkg.manifest.name}:${relative(pkg.dir, file)}`);
124
168
  lines.forEach((line, index) => {
125
169
  if (!BARE_ANCHOR.test(line)) return;
126
170
  anchors.push(
@@ -128,6 +172,15 @@ export async function run() {
128
172
  `外链一律用 kit 的 Link(新窗口、noopener、自带那个外链小箭头;样式只有这一份):${line.trim().slice(0, 80)}`,
129
173
  );
130
174
  });
175
+ if (!exemptControls) {
176
+ for (const hit of bareControlsIn(text)) {
177
+ controls.push(
178
+ `${pkg.manifest.name}: ${relative(pkg.dir, file)}:${hit.line} 手写了裸 <${hit.tag}>(${hit.what})—— ` +
179
+ `用 ${hit.use}(手写的拿到的是浏览器默认样式:悬停、禁用、焦点环、色板都得自己抄,还不跟主题):` +
180
+ `${(lines[hit.line - 1] ?? '').trim().slice(0, 80)}`,
181
+ );
182
+ }
183
+ }
131
184
  if (contract === null) continue;
132
185
  lines.forEach((line, index) => {
133
186
  for (const match of line.matchAll(CLASS_TOKEN)) {
@@ -169,7 +222,7 @@ export async function run() {
169
222
  }
170
223
  }
171
224
 
172
- if (bindings.length + problems.length + borrowed.length + anchors.length > 0) {
225
+ if (bindings.length + problems.length + borrowed.length + anchors.length + controls.length > 0) {
173
226
  const notes = [];
174
227
  if (bindings.length > 0) {
175
228
  notes.push(
@@ -179,7 +232,9 @@ export async function run() {
179
232
  }
180
233
  if (problems.length > 0) {
181
234
  notes.push(
182
- `${problems.length} 处是「模块不许定义外壳前缀的 CSS 规则」:(wb-* 是全局类名:两份同名规则按注入顺序互相覆盖,用户看到的是随机的那个)`,
235
+ `${problems.length} 处是「模块不许定义外壳类(wb-*)的 CSS 规则」:(wb-* 是外壳的命名空间:` +
236
+ '两份同名规则按注入顺序互相覆盖,用户看到的是随机的那个。判据看**复合选择器** —— ' +
237
+ '`:global(.wb-x)` 与 `.row :global(.wb-x)` 都算,只有同一个复合选择器里带了模块自己的类才放行)',
183
238
  );
184
239
  }
185
240
  if (borrowed.length > 0) {
@@ -195,8 +250,20 @@ export async function run() {
195
250
  '裸 <a> 拿到的是浏览器默认样式,看着和卡片其它地方不是一套)',
196
251
  );
197
252
  }
198
- // 四类问题各自成串,按原来的输出顺序(绑定 → 规则 → 借用 → 裸 <a>)拼在一起。
199
- return { status: 'fail', notes, problems: [...bindings, ...problems, ...borrowed, ...anchors] };
253
+ if (controls.length > 0) {
254
+ notes.push(
255
+ `${controls.length} 处是「手写了裸 <button>/<input>/<select>」:(kit 与 dsh 原语里都有对应形态 —— ` +
256
+ 'Button / TextField / SelectField / ChoiceGroup;手写的那份拿到浏览器默认样式,' +
257
+ '悬停、禁用、焦点环、字号都得自己抄一遍,还不跟主题。只有多行输入 <textarea> 与 ' +
258
+ '`type="hidden"` / `type="file"` 例外:那两种形态两处都没有)',
259
+ );
260
+ }
261
+ // 各类问题各自成串,按原来的输出顺序(绑定 → 规则 → 借用 → 裸 <a> → 裸控件)拼在一起。
262
+ return {
263
+ status: 'fail',
264
+ notes,
265
+ problems: [...bindings, ...problems, ...borrowed, ...anchors, ...controls],
266
+ };
200
267
  }
201
268
 
202
269
  if (modules.length === 0) {
@@ -0,0 +1,80 @@
1
+ // 「模块碰了外壳的类」这套判断的**唯一一份**算法:源码判据(`checks/style.mjs`)与产物判据
2
+ // (`checks/bundles.mjs`)都从这里取。两处各写一份必然漂,而漂了的那一份会让另一侧漏报。
3
+ //
4
+ // `wb-*` 是外壳的命名空间(全局类名,两份同名规则按注入顺序互相覆盖,用户看到的是随机的那个)。
5
+ // 判据落在**复合选择器**上:一个复合选择器里只要有 `wb-*` 类、又没有任何模块自己的类
6
+ // (CSS Modules 的哈希类,或模块自己起的名字),这条规则就是在替外壳那位主人定样子。两种写法都要认:
7
+ //
8
+ // :global(.wb-approvalWarn) { … } ← 主体就是外壳类
9
+ // .row :global(.wb-cmdbField) { … } ← 后代里挂了外壳类
10
+ //
11
+ // 旧判据只看「`.wb-` 前面是不是分隔符」,于是 `:global(` 那个 `(` 把它挡住了 —— 这两种写法
12
+ // 一个都看不见(实测:cmdb 的 `:global(.wb-approvalWarn)` 把外壳的告警色整页改掉了,判据全绿)。
13
+ //
14
+ // 反过来,「自己的节点 + 外壳的槽类」是**合规**的(外壳 `.scratch/dsh-ui-alignment/spec.md` §16 的
15
+ // 条带钮就是这个形状:`Button variant="ghost"` + 调用点给的槽类),所以
16
+ //
17
+ // .envRefresh:global(.wb-iconButton) { … }
18
+ //
19
+ // 不算 —— 同一个复合选择器里有模块自己的类,它改的是自己那颗钮。
20
+
21
+ // 外壳的类名是 camelCase(`wb-cardBody` / `wb-iconButton`)—— 只写 `[a-z0-9-]` 会把它们全漏掉,
22
+ // 而漏掉的方向是"判据以为自己在守、其实一条都没看"(旧判据只匹配前缀 `.wb-c`,所以没踩到)。
23
+ const SHELL_CLASS = /^wb-[A-Za-z0-9-]+$/;
24
+ const CLASS_TOKEN = /\.([A-Za-z_-][\w-]*)/g;
25
+ // 只看**像选择器**的那段文本:JSX / JS 那几行(`h("div", {`、`const x = {`、`<div className=`)带引号、
26
+ // 等号或尖括号,一律不认(源码文件与产物都要一份能同时读 CSS 与 JS 的判断)。
27
+ const SELECTOR_TEXT = /^[\s,>+~.:#*()&[\]\w-]*$/;
28
+
29
+ function compoundsOf(selector) {
30
+ return selector
31
+ .split(',')
32
+ .flatMap((part) => part.trim().split(/[\s>+~]+/))
33
+ .filter((compound) => compound !== '');
34
+ }
35
+
36
+ function classesIn(compound) {
37
+ return Array.from(compound.matchAll(CLASS_TOKEN), (match) => match[1]);
38
+ }
39
+
40
+ /** 选择器里第一个「只由外壳类构成」的复合选择器;一个都没有就是 undefined。 */
41
+ export function shellOnlyCompound(selector) {
42
+ if (!SELECTOR_TEXT.test(selector)) return undefined;
43
+ return compoundsOf(selector).find((compound) => {
44
+ const classes = classesIn(compound);
45
+ return classes.length > 0 && classes.every((name) => SHELL_CLASS.test(name));
46
+ });
47
+ }
48
+
49
+ /** 一份样式表里被定义/覆盖的外壳规则(选择器,含外壳命名空间的 keyframes)。 */
50
+ export function shellRulesIn(cssText) {
51
+ const found = [];
52
+ for (const line of cssText.split('\n')) {
53
+ const brace = line.indexOf('{');
54
+ if (brace >= 0 && shellOnlyCompound(line.slice(0, brace)) !== undefined) {
55
+ found.push(line.slice(0, brace).trim());
56
+ }
57
+ const keyframes = /@keyframes\s+(wb-[a-z0-9-]+)/.exec(line);
58
+ if (keyframes !== null) found.push(`@keyframes ${keyframes[1]}`);
59
+ }
60
+ return found;
61
+ }
62
+
63
+ /** 模块**产物**里被内联进来的外壳规则(第二份实例的物证)。 */
64
+ export function inlinedShellRules(artifactText) {
65
+ // 产物里的 CSS 是 JS 字符串:换行是字面两字符 `\n`,先还原成真换行才谈得上按规则切。
66
+ const text = artifactText.replace(/\\n/g, '\n').replace(/\\t/g, '\t');
67
+ const found = [];
68
+ // 规则的起点不一定在行首 —— CSS 就住在 `var css = "…"` 里(`".wb-cardBody {`),所以引号、
69
+ // 分号、花括号都算边界。捕获的字符集与选择器同宽(不含引号/等号/尖括号):否则一条 `var x = "…"`
70
+ // 会从它自己的换行处起 match、把后面那个真规则的起点一起吞掉(实测:真规则在同一行后面时漏报)。
71
+ for (const match of text.matchAll(/(?:^|[\n;{}"'])[ \t]*([\s,>+~.:#*()&[\]\w-]*?)[ \t]*\{/gm)) {
72
+ const selector = match[1];
73
+ if (selector.trimStart().startsWith('@')) continue;
74
+ if (shellOnlyCompound(selector) !== undefined) found.push(selector.trim());
75
+ }
76
+ for (const match of text.matchAll(/@keyframes\s+(wb-[a-z0-9-]+)/g)) {
77
+ found.push(`@keyframes ${match[1]}`);
78
+ }
79
+ return [...new Set(found)];
80
+ }
package/src/init.mjs CHANGED
@@ -14,7 +14,7 @@ export async function init({ root, args = [] }) {
14
14
  if (unit === undefined || !UNIT_ID.test(unit)) {
15
15
  console.error('用法:dsh-wb-module init <unit> [--dir <目录>] [--template minimal|diy] [--description <文字>]');
16
16
  console.error(' unit 是小写短横线标识(它同时是单元 id、包名后缀、卡片 class 前缀)');
17
- console.error(' --template diy:带一个真数据源 + 三态 + ?wbMock=1 夹具的样板(默认 minimal 是空壳)');
17
+ console.error(' --template diy:带一个真数据源 + 卡片的样板(默认 minimal 是空壳)');
18
18
  process.exit(2);
19
19
  }
20
20
  const picked = args[args.indexOf('--template') + 1];
@@ -5,7 +5,7 @@
5
5
  // 于是"模块注册的卡片到底进没进外壳"变成一条机器判据,而不再是人的眼睛。
6
6
  //
7
7
  // ★ 这份脚本只验**形状**:它认不出成员名、也不认识你的数据源,所以换数据源、换卡片内容都不用改它。
8
- // 你自己的那条读该不该跑通,属于 `?wbMock=1` 三档夹具与你自己 vitest 的事。
8
+ // 你自己的那条读该不该跑通,属于你自己 vitest 与 `pnpm selfcheck` 的事。
9
9
  //
10
10
  // 负例在最后一段:服务不在的时候必须**报出来**,不许静默通过 —— 静默的模块是最难查的那种坏。
11
11
 
@@ -17,7 +17,7 @@ dsh-wb-module check --only cards # 十条可以单跑
17
17
  ```bash
18
18
  pnpm build # 构建产物
19
19
  pnpm typecheck # tsc --noEmit
20
- pnpm test # vitest:路由真读数 + 卡片五态 + 三种夹具
20
+ pnpm test # vitest:路由真读数 + 卡片五态
21
21
  pnpm selfcheck # 构建 + 把 client.js 当浏览器加载器跑一遍 + 调一次 <unit>/overview 看它真读盘
22
22
  ```
23
23
 
@@ -91,9 +91,9 @@ pnpm selfcheck # 构建 + 把 client.js 当浏览器加载器跑一遍 + 调
91
91
  | `package.json` | 自述清单:`dsh.workbench.unit` = `__UNIT__`、两个面(`src/host.ts` / `src/client.tsx`)、四个脚本、peer 下限 |
92
92
  | `cordis.patch.yml` | 插进 dsh 组合的补丁 |
93
93
  | `src/host.ts` | 宿主面:`readOverview()`(真读目录)+ 一条只读路由 `<unit>/overview` |
94
- | `src/client.tsx` | 卡片:`Card` 壳 + 五态(待机 / 加载 / 就绪 / 出错,其中"就绪但空"单列一态)+ `?wbMock=1` 夹具 |
94
+ | `src/client.tsx` | 卡片:`Card` 壳 + 五态(待机 / 加载 / 就绪 / 出错,其中"就绪但空"单列一态) |
95
95
  | `src/messages.ts` | 卡片那几句文案(zh / en) |
96
- | `src/plugin.test.ts` | 面注册 + 路由真读 + 五态 + 三种夹具的断言 |
96
+ | `src/plugin.test.ts` | 面注册 + 路由真读 + 五态的断言 |
97
97
  | `scripts/smoke.mjs` | 接线自检:卡片进没进外壳、路由真读没真读 |
98
98
  | `tsconfig.json` / `vitest.config.ts` / `types/globals.d.ts` | 经典 JSX 运行时(`jsxFactory: h`)、vitest 的别名、浏览器半侧的运行时垫片 |
99
99
  | `README.md` | 就是本文 |
@@ -222,12 +222,12 @@ env: {
222
222
  }
223
223
  ```
224
224
 
225
- 外壳的隔离运行时按它装、按它探(`workbench_modules list` 里逐条看得到)。**没装好时用户看到的那句话
225
+ 外壳的隔离运行时按它装、按它探(`workbench_modules_list` 里逐条看得到)。**没装好时用户看到的那句话
226
226
  由你写**:`libInstallCommand({ need })` 会连源和缓存目录一起拼好,原样给他,别自己拼 npm 命令。
227
227
 
228
228
  ## 卡片画哪五态
229
229
 
230
- `src/client.tsx` 的 `overviewStateOf({ fixture, phase, value, error })` 是一条纯函数,
230
+ `src/client.tsx` 的 `overviewStateOf({ phase, value, error })` 是一条纯函数,
231
231
  每个分支各对应一屏(`src/plugin.test.ts` 一屏一屏断言):
232
232
 
233
233
  | 态 | 什么时候 | 画什么 |
@@ -236,29 +236,12 @@ env: {
236
236
  | 加载 | 还没有值(首读),或 `phase === 'error'` 之外什么都没来 | `CenteredState`:正在读目录 |
237
237
  | 就绪 | 有值,且文件数 + 目录数 > 0 | `StateBlock`:路径 + 「N 个文件 · M 个目录 · 总字节」+ 最大的几个文件列表 |
238
238
  | 就绪但空 | 有值,但文件数 = 目录数 = 0 | `EmptyState`:这个目录是空的 |
239
- | 出错 | `phase === 'error'`(或夹具给的是错误) | `CenteredState`:读不到这个目录 + 宿主的原话 |
240
-
241
- 待机那一屏要排在夹具**之后**:`?wbMock=1` 且没开项目时,夹具仍然要演得出来(否则
242
- "读不到"这种状态在没有项目时就永远看不到,而那正是干净 profile 下的评审场景)。
239
+ | 出错 | `phase === 'error'` | `CenteredState`:读不到这个目录 + 宿主的原话 |
243
240
 
244
241
  刷新时 `useApiResource` 会把 `phase` 打成 `loading` **同时保留上一次的 `value`** ——
245
242
  这时刻意继续画旧数据(右上角的转圈由 `Card` 的 `refreshing` 表示),不退回骨架:
246
243
  一次刷新不该让整张卡闪一下。
247
244
 
248
- ## `?wbMock=1` 夹具
249
-
250
- 打开 `?wbMock=1`(搜索串里含 `wbMock=1`)时卡片顶上多一条 `MockStrip`,
251
- `kit.hooks.useFixturePick` 提供三个夹具,**只切状态、不发请求**:
252
-
253
- | id | 标签 | 演的是 |
254
- |---|---|---|
255
- | `ready` | 有数据 | 128 个文件 / 12 个目录 / 3 MB,带三个最大的文件 |
256
- | `empty` | 空目录 | 就绪但那一路"空"态 |
257
- | `error` | 读不到 | `EACCES: permission denied, scandir /mock/secret` |
258
-
259
- 夹具优先于真请求:只要 mock 开着,画的就是夹具那一屏。
260
- 改夹具时顺手改一眼 `src/plugin.test.ts` 的「夹具」那一组断言 —— 那组就是守它的。
261
-
262
245
  ## 装进本地工作台
263
246
 
264
247
  ```bash
@@ -7,8 +7,8 @@ const OVERVIEW_ROUTE = `${UNIT_ID}/overview`;
7
7
  const LIST_LIMIT = 5;
8
8
  const KB = 1024;
9
9
 
10
- const { Card, MockStrip, CenteredState, EmptyState, StateBlock, IconPanelScreen } = kit.components;
11
- const { useApiResource, useFixturePick } = kit.hooks;
10
+ const { Card, CenteredState, EmptyState, Row, Rows, StateBlock, IconPanelScreen } = kit.components;
11
+ const { useApiResource } = kit.hooks;
12
12
 
13
13
  type Rendered = ReturnType<typeof h>;
14
14
  type Translate = (key: string, params?: Record<string, unknown>) => string;
@@ -60,13 +60,6 @@ interface Failure {
60
60
  readonly message: string;
61
61
  }
62
62
 
63
- export interface Fixture {
64
- readonly id: string;
65
- readonly label: string;
66
- readonly payload?: OverviewPayload;
67
- readonly error?: Failure;
68
- }
69
-
70
63
  export type OverviewState =
71
64
  | { readonly kind: 'idle' }
72
65
  | { readonly kind: 'loading' }
@@ -75,36 +68,11 @@ export type OverviewState =
75
68
  | { readonly kind: 'error'; readonly detail: string };
76
69
 
77
70
  export interface OverviewInput {
78
- readonly fixture: Fixture | undefined;
79
71
  readonly phase: 'idle' | 'loading' | 'ready' | 'error';
80
72
  readonly value: OverviewReady | undefined;
81
73
  readonly error: Failure | undefined;
82
74
  }
83
75
 
84
- const fixtureFiles: readonly OverviewFile[] = [
85
- { name: 'client.js', bytes: 182400, mtime: '2026-01-05T09:12:44.000Z' },
86
- { name: 'host.js', bytes: 96256, mtime: '2026-01-05T09:12:44.000Z' },
87
- { name: 'pnpm-lock.yaml', bytes: 41984, mtime: '2026-01-04T18:02:10.000Z' },
88
- ];
89
-
90
- export const overviewFixtures: readonly Fixture[] = [
91
- {
92
- id: 'ready',
93
- label: '有数据',
94
- payload: { path: '/mock/workbench', files: 128, dirs: 12, bytes: 3145728, largest: fixtureFiles },
95
- },
96
- {
97
- id: 'empty',
98
- label: '空目录',
99
- payload: { path: '/mock/empty', files: 0, dirs: 0, bytes: 0, largest: [] },
100
- },
101
- {
102
- id: 'error',
103
- label: '读不到',
104
- error: { code: 'overview-unreadable', message: 'EACCES: permission denied, scandir /mock/secret' },
105
- },
106
- ];
107
-
108
76
  export function formatBytes(bytes: number): string {
109
77
  if (bytes < KB) return `${String(bytes)} B`;
110
78
  if (bytes < KB * KB) return `${(bytes / KB).toFixed(1)} KB`;
@@ -118,11 +86,6 @@ function payloadState(payload: OverviewPayload): OverviewState {
118
86
  }
119
87
 
120
88
  export function overviewStateOf(input: OverviewInput): OverviewState {
121
- const fixture = input.fixture;
122
- if (fixture !== undefined) {
123
- if (fixture.error !== undefined) return { kind: 'error', detail: fixture.error.message };
124
- if (fixture.payload !== undefined) return payloadState(fixture.payload);
125
- }
126
89
  if (input.phase === 'idle') return { kind: 'idle' };
127
90
  if (input.phase === 'error') {
128
91
  const error = input.error;
@@ -177,10 +140,14 @@ function bodyOf(state: OverviewState, t: Translate): Rendered {
177
140
  resolving: t(`${UNIT_ID}.largest`),
178
141
  },
179
142
  h(
180
- 'ul',
143
+ Rows,
181
144
  null,
182
145
  ...payload.largest.map((file) =>
183
- h('li', { key: file.name }, `${file.name} · ${formatBytes(file.bytes)} · ${file.mtime}`),
146
+ h(Row, {
147
+ key: file.name,
148
+ label: file.name,
149
+ value: `${formatBytes(file.bytes)} · ${file.mtime}`,
150
+ }),
184
151
  ),
185
152
  ),
186
153
  );
@@ -198,9 +165,7 @@ const face: UnitClientFace = {
198
165
  props.refreshToken,
199
166
  { enabled: path !== '' },
200
167
  );
201
- const mock = useFixturePick<Fixture>({ fixtures: overviewFixtures, initial: 'ready' });
202
168
  const state = overviewStateOf({
203
- fixture: mock.enabled ? mock.current : undefined,
204
169
  phase: resource.phase,
205
170
  value: resource.value,
206
171
  error: resource.error,
@@ -213,14 +178,6 @@ const face: UnitClientFace = {
213
178
  refreshing: resource.phase === 'loading',
214
179
  onRefresh: props.onRefresh,
215
180
  },
216
- mock.enabled
217
- ? h(MockStrip, {
218
- mocks: overviewFixtures,
219
- index: mock.index,
220
- onPick: (next: number) => mock.pick(overviewFixtures[next]?.id ?? null),
221
- note: props.t(`${UNIT_ID}.mockNote`),
222
- })
223
- : null,
224
181
  bodyOf(state, props.t),
225
182
  );
226
183
  },
@@ -10,7 +10,6 @@ export const messages = {
10
10
  '__UNIT__.largest': '最大的几个文件',
11
11
  '__UNIT__.fileUnit': ' 个文件',
12
12
  '__UNIT__.dirUnit': ' 个目录',
13
- '__UNIT__.mockNote': '夹具:只切状态,不发请求',
14
13
  },
15
14
  en: {
16
15
  '__UNIT__.cardTitle': 'Directory overview',
@@ -23,6 +22,5 @@ export const messages = {
23
22
  '__UNIT__.largest': 'Largest files',
24
23
  '__UNIT__.fileUnit': ' files',
25
24
  '__UNIT__.dirUnit': ' dirs',
26
- '__UNIT__.mockNote': 'Fixtures: switches state only, no request',
27
25
  },
28
26
  };
@@ -4,8 +4,6 @@ import { join } from 'node:path';
4
4
  import { afterAll, describe, expect, it, vi } from 'vitest';
5
5
 
6
6
  const shell = vi.hoisted(() => ({
7
- enabled: false,
8
- fixture: undefined as { readonly id: string } | undefined,
9
7
  resource: { phase: 'loading' } as {
10
8
  readonly phase: string;
11
9
  readonly value?: unknown;
@@ -21,18 +19,9 @@ vi.mock('@dsh-workbench/core/client', () => ({
21
19
  shell.readEnabled = options?.enabled;
22
20
  return shell.resource;
23
21
  },
24
- useFixturePick: () => ({
25
- enabled: shell.enabled,
26
- active: shell.fixture?.id ?? null,
27
- current: shell.fixture,
28
- pick: () => {},
29
- nonce: 0,
30
- index: 0,
31
- }),
32
22
  },
33
23
  components: {
34
24
  Card: () => null,
35
- MockStrip: () => null,
36
25
  CenteredState: () => null,
37
26
  EmptyState: () => null,
38
27
  StateBlock: () => null,
@@ -42,13 +31,7 @@ vi.mock('@dsh-workbench/core/client', () => ({
42
31
  }));
43
32
 
44
33
  import { apply, inject, name, OVERVIEW_ROUTE, readOverview } from './host';
45
- import {
46
- apply as applyClient,
47
- formatBytes,
48
- inject as injectClient,
49
- overviewFixtures,
50
- overviewStateOf,
51
- } from './client';
34
+ import { apply as applyClient, formatBytes, inject as injectClient, overviewStateOf } from './client';
52
35
 
53
36
  interface HostFace {
54
37
  readonly unit: string;
@@ -221,8 +204,6 @@ describe('宿主侧的只读路由', () => {
221
204
  describe('卡片:待机 / 加载 / 就绪 / 就绪但空 / 出错', () => {
222
205
  it('没有选中工作区时不发请求,画待机那一屏', () => {
223
206
  const face = clientFace();
224
- shell.enabled = false;
225
- shell.fixture = undefined;
226
207
  shell.resource = { phase: 'idle' };
227
208
  const withoutProject = { ...cardProps, world: { current: { workspace: null } } };
228
209
 
@@ -234,19 +215,8 @@ describe('卡片:待机 / 加载 / 就绪 / 就绪但空 / 出错', () => {
234
215
  expect(shell.readEnabled).toBe(true);
235
216
  });
236
217
 
237
- it('没有工作区时夹具照样能演(夹具优先于待机)', () => {
238
- const face = clientFace();
239
- shell.enabled = true;
240
- shell.fixture = overviewFixtures[0];
241
- shell.resource = { phase: 'idle' };
242
- const withoutProject = { ...cardProps, world: { current: { workspace: null } } };
243
- expect(textOf(face.card(withoutProject)).join(' ')).toContain('/mock/workbench');
244
- });
245
-
246
218
  it('四态各画得出来', () => {
247
219
  const face = clientFace();
248
- shell.enabled = false;
249
- shell.fixture = undefined;
250
220
 
251
221
  shell.resource = { phase: 'loading' };
252
222
  expect(textOf(face.card(cardProps)).join(' ')).toContain('__UNIT__.loading');
@@ -281,8 +251,6 @@ describe('卡片:待机 / 加载 / 就绪 / 就绪但空 / 出错', () => {
281
251
 
282
252
  it('刷新中旧数据还在(不退回加载态)', () => {
283
253
  const face = clientFace();
284
- shell.enabled = false;
285
- shell.fixture = undefined;
286
254
  shell.resource = {
287
255
  phase: 'loading',
288
256
  value: { ok: true, path: '/real/workbench', files: 1, dirs: 0, bytes: 10, largest: [] },
@@ -292,65 +260,33 @@ describe('卡片:待机 / 加载 / 就绪 / 就绪但空 / 出错', () => {
292
260
  expect(text).not.toContain('__UNIT__.loading');
293
261
  });
294
262
 
295
- it('只有 mock 模式才挂出夹具条', () => {
296
- const face = clientFace();
297
- shell.fixture = undefined;
298
- shell.resource = { phase: 'loading' };
299
-
300
- shell.enabled = false;
301
- expect(textOf(face.card(cardProps)).join(' ')).not.toContain('__UNIT__.mockNote');
302
-
303
- shell.enabled = true;
304
- const text = textOf(face.card(cardProps)).join(' ');
305
- expect(text).toContain('__UNIT__.mockNote');
306
- expect(text).toContain('空目录');
307
- });
308
263
  });
309
264
 
310
- describe('夹具:?wbMock=1 下三种状态都演得出来', () => {
311
- it('三个夹具各自解出一个状态(有数据 / 空 / 读不到)', () => {
312
- expect(overviewFixtures.map((fixture) => fixture.id)).toEqual(['ready', 'empty', 'error']);
313
- const kinds = overviewFixtures.map(
314
- (fixture) =>
315
- overviewStateOf({
316
- fixture,
317
- phase: 'error',
318
- value: undefined,
319
- error: { code: 'network', message: '夹具优先于真请求' },
320
- }).kind,
321
- );
322
- expect(kinds).toEqual(['ready', 'empty', 'error']);
323
- });
324
-
325
- it('夹具优先于真请求:三种都画得出来', () => {
326
- const face = clientFace();
327
- shell.enabled = true;
328
- shell.resource = { phase: 'loading' };
329
-
330
- shell.fixture = overviewFixtures[0];
331
- const ready = textOf(face.card(cardProps)).join(' ');
332
- expect(ready).toContain('/mock/workbench');
333
- expect(ready).toContain('client.js');
334
-
335
- shell.fixture = overviewFixtures[1];
336
- expect(textOf(face.card(cardProps)).join(' ')).toContain('__UNIT__.empty');
337
-
338
- shell.fixture = overviewFixtures[2];
339
- expect(textOf(face.card(cardProps)).join(' ')).toContain('EACCES');
340
- });
341
-
342
- it('夹具数据自己的形状也有人守', () => {
343
- const ready = overviewFixtures[0]?.payload;
344
- expect(ready?.files).toBeGreaterThan(0);
345
- expect(ready?.dirs).toBeGreaterThan(0);
346
- expect(ready?.bytes).toBeGreaterThan(0);
347
- expect(ready?.largest).toHaveLength(3);
348
- expect(Number.isNaN(Date.parse(ready?.largest[0]?.mtime ?? ''))).toBe(false);
349
- expect(overviewFixtures[1]?.payload?.files).toBe(0);
350
- expect(overviewFixtures[1]?.payload?.largest).toHaveLength(0);
351
- expect(overviewFixtures[2]?.error?.message).toContain('EACCES');
265
+ describe('状态映射', () => {
266
+ it('phase 与读数一起解出那一屏(待机 / 加载 / 就绪 / 就绪但空 / 出错)', () => {
267
+ const at = (input: Parameters<typeof overviewStateOf>[0]): string => overviewStateOf(input).kind;
268
+
269
+ expect(at({ phase: 'idle', value: undefined, error: undefined })).toBe('idle');
270
+ expect(at({ phase: 'loading', value: undefined, error: undefined })).toBe('loading');
271
+ expect(
272
+ at({
273
+ phase: 'ready',
274
+ value: { ok: true, path: '/p', files: 2, dirs: 1, bytes: 10, largest: [] },
275
+ error: undefined,
276
+ }),
277
+ ).toBe('ready');
278
+ expect(
279
+ at({
280
+ phase: 'ready',
281
+ value: { ok: true, path: '/p', files: 0, dirs: 0, bytes: 0, largest: [] },
282
+ error: undefined,
283
+ }),
284
+ ).toBe('empty');
285
+ expect(at({ phase: 'error', value: undefined, error: { code: 'internal', message: 'boom' } })).toBe('error');
352
286
  });
287
+ });
353
288
 
289
+ describe('格式化', () => {
354
290
  it('字节格式化不炸也不乱', () => {
355
291
  expect(formatBytes(0)).toBe('0 B');
356
292
  expect(formatBytes(2048)).toBe('2.0 KB');