@dsh-workbench/module-tools 0.2.12 → 0.2.14

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.
@@ -1,28 +1,24 @@
1
- // 模块卡片能不能**画出来**(S1 视觉验收里唯一能机器化的那一半)。
2
- //
3
- // 「卡片出现」这句话里有两件事:
4
- // ① 结构画得出来 —— 卡片的代码跑得通、返回一棵非空元素树。**这条能机器验**;
5
- // ② 画成什么样(间距、颜色、两栏还是三栏)—— 只有人眼能判,这里不碰。
6
- //
7
- // 为什么用「空数据」当输入:外壳渲染卡片时,数据往往还没回来(第一次读、卡在审批、
8
- // 平台没登录)。所以**卡片在拿到空数据时不许抛** —— 这正是"卡片一直不出来 / 点一下白屏"
9
- // 那类事故的机器形态。给了数据才跑得通的卡片,等于把崩溃留到用户面前。
10
- //
11
- // 手法与 packages/workbench/scripts/selfcheck.mjs 里那套一致:产物是
12
- // `window.__ModuleLoader__.load({id, factory})`,用假的 loader / require 把它拉起来。
13
- // 覆盖范围跟着 `dsh.workbench` 自述清单走 —— 新模块不用改这里。
14
- //
15
- // 形状(所有判据统一):导出 `title` 与 `run()`,`run()` 返回三态结果;
16
- // 打印与退出码归 `../check.mjs` 那一层。
17
-
18
1
  import { existsSync, readFileSync } from 'node:fs';
19
- import { join } from 'node:path';
2
+ import { createRequire } from 'node:module';
3
+ import { dirname, join } from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+
20
6
  import { discoverModulePackages } from '../discover.mjs';
7
+ import { shellClientPathOf, shellKitOf } from './shell-artifact.mjs';
21
8
 
22
9
  export const title = '卡片渲染';
23
10
 
24
- // 任何属性都拿得到、任何调用都返回自己的替身 —— 这样「外壳还没给数据」的各种形状
25
- // 都不会让卡片因为我们**没桩到某个字段**而假红。真红只留给卡片自己的代码。
11
+ const LANGUAGES = ['zh', 'en'];
12
+
13
+ const FORBIDDEN = [
14
+ { token: 'undefined', pattern: /\bundefined\b/ },
15
+ { token: 'NaN', pattern: /\bNaN\b/ },
16
+ { token: '[object Object]', pattern: /\[object Object\]/ },
17
+ { token: 'null', pattern: /\bnull\b/ },
18
+ ];
19
+
20
+ const WORKSPACE = { path: '/tmp/repo', workspaceId: 'wb-card-probe' };
21
+
26
22
  function anyStub(path = 'stub') {
27
23
  const target = function () {
28
24
  return anyStub(`${path}()`);
@@ -42,12 +38,105 @@ function anyStub(path = 'stub') {
42
38
  });
43
39
  }
44
40
 
41
+ function containerStub(path, size) {
42
+ const elementAt = (index) => containerStub(`${path}[${index}]`, size);
43
+ const asArray = () => Array.from({ length: size }, (_unused, index) => elementAt(index));
44
+ const scalar = size > 0 ? 1 : 0;
45
+ const methods = {
46
+ map: (fn) => asArray().map(fn),
47
+ filter: (fn) => asArray().filter(fn),
48
+ forEach: (fn) => asArray().forEach(fn),
49
+ find: (fn) => asArray().find(fn),
50
+ findIndex: (fn) => asArray().findIndex(fn),
51
+ some: (fn) => asArray().some(fn),
52
+ every: (fn) => asArray().every(fn),
53
+ reduce: (fn, initial) => asArray().reduce(fn, initial),
54
+ flatMap: (fn) => asArray().flatMap(fn),
55
+ slice: (from, to) => asArray().slice(from, to),
56
+ at: (index) => asArray().at(index),
57
+ indexOf: (value) => asArray().indexOf(value),
58
+ includes: (value) => asArray().includes(value),
59
+ join: (separator) => asArray().join(separator),
60
+ concat: (other) => asArray().concat(other),
61
+ reverse: () => asArray().reverse(),
62
+ sort: () => asArray(),
63
+ keys: () => asArray().keys(),
64
+ values: () => asArray().values(),
65
+ entries: () => asArray().entries(),
66
+ toArray: () => asArray(),
67
+ };
68
+ return new Proxy(function () {}, {
69
+ get(_target, key) {
70
+ if (key === 'ok') return true;
71
+ if (key === Symbol.toPrimitive) return () => scalar;
72
+ if (key === Symbol.iterator) return () => asArray()[Symbol.iterator]();
73
+ if (key === 'then') return undefined;
74
+ if (key === 'length') return size;
75
+ if (key === 'toString' || key === 'valueOf') return () => scalar;
76
+ if (key === 'constructor') return Object;
77
+ if (key in methods) return methods[key];
78
+ if (typeof key === 'symbol') return undefined;
79
+ return containerStub(`${path}.${String(key)}`, size);
80
+ },
81
+ apply() {
82
+ return containerStub(`${path}()`, size);
83
+ },
84
+ });
85
+ }
86
+
87
+ const SNAPSHOTS = [
88
+ { name: 'idle', resource: { phase: 'idle' }, workspace: null },
89
+ { name: 'loading', resource: { phase: 'loading' }, workspace: WORKSPACE },
90
+ { name: 'ready', resource: { phase: 'ready', value: containerStub('api', 2) }, workspace: WORKSPACE },
91
+ {
92
+ name: 'empty(有值但容器空)',
93
+ resource: { phase: 'ready', value: containerStub('api', 0) },
94
+ workspace: WORKSPACE,
95
+ },
96
+ {
97
+ name: 'unreadable',
98
+ resource: { phase: 'error', error: { code: 'network', message: '宿主没有响应' } },
99
+ workspace: WORKSPACE,
100
+ },
101
+ // P2 第一条:刷新形态(ADR-0022「刷新保留旧值」)。载荷与 `loading` 只差一格 —— `value` 在,
102
+ // 而且是旧那份。`use-api-resource.ts:100-104` 在同一个 identity 重读时给的就是这一相,卡片
103
+ // 若把"loading 就没有值"写死,脏就出在这一帧。注意它按 spec 只带 `value`、不带 `at`
104
+ // (真实那一相还会带 `prev.at`)—— 这是与真壳的一处已知出入,记在 spec 的 §7.3。
105
+ {
106
+ name: 'loading(刷新:保留旧值)',
107
+ resource: { phase: 'loading', value: containerStub('api', 2) },
108
+ workspace: WORKSPACE,
109
+ },
110
+ // P2 第二条:能力闸。`useCapabilityGate` 给一个**明确的** `missing-plugin`(不是 `null`):
111
+ // `use-unit-board.ts:47` 对 `capability === null` 直接不算 `blockerOfCapability`,喂 null 就退化成
112
+ // P1 的 idle 那一屏、一格新代码都走不到。`gated` 由能力状态按 `gateOf`(`unit-board.ts:13-17`)
113
+ // 算出来(missing-plugin ⇒ false),`resource` 照 `ready` 一份 —— 于是它跟 `ready` 只差闸这一格,
114
+ // 阻断屏与正文同时在场时谁优先是模块的事。
115
+ {
116
+ name: 'unreadable(能力闸:缺插件)',
117
+ resource: { phase: 'ready', value: containerStub('api', 2) },
118
+ workspace: WORKSPACE,
119
+ capability: (unit) => ({
120
+ unit,
121
+ status: 'missing-plugin',
122
+ required: ['demo-plugin'],
123
+ missing: ['demo-plugin'],
124
+ detail: '逐态探针:缺 demo-plugin',
125
+ }),
126
+ },
127
+ ];
128
+
45
129
  function fakeReact() {
46
130
  const element = (type, props, ...children) => ({ type, props: props ?? {}, children });
47
- return {
131
+ const passthrough = (props, ...children) => element('stub:passthrough', props, ...children);
132
+ const base = {
48
133
  createElement: element,
49
134
  createElementNS: (_namespace, type, props, ...children) => element(type, props, ...children),
50
135
  Fragment: 'Fragment',
136
+ Component: class {},
137
+ PureComponent: class {},
138
+ createContext: (value) => ({ Provider: passthrough, Consumer: passthrough, _currentValue: value }),
139
+ useContext: () => anyStub('context'),
51
140
  useState: (initial) => [typeof initial === 'function' ? initial() : initial, () => {}],
52
141
  useReducer: (_reducer, initial) => [initial, () => {}],
53
142
  useMemo: (factory) => factory(),
@@ -55,20 +144,52 @@ function fakeReact() {
55
144
  useRef: (initial) => ({ current: initial ?? null }),
56
145
  useEffect: () => {},
57
146
  useLayoutEffect: () => {},
147
+ useInsertionEffect: () => {},
58
148
  useSyncExternalStore: (_subscribe, snapshot) => snapshot(),
59
149
  useId: () => 'wb-id',
150
+ useTransition: () => [false, (fn) => fn()],
151
+ useDeferredValue: (value) => value,
60
152
  isValidElement: (value) => typeof value === 'object' && value !== null && 'type' in value,
61
- Children: { toArray: (value) => (Array.isArray(value) ? value : [value]) },
153
+ cloneElement: (node, props, ...children) =>
154
+ element(node.type, { ...(node.props ?? {}), ...(props ?? {}) }, ...children),
155
+ memo: (fn) => fn,
156
+ forwardRef: (fn) => fn,
157
+ Children: {
158
+ toArray: (value) =>
159
+ Array.isArray(value) ? value : value === undefined || value === null ? [] : [value],
160
+ map: (value, fn) =>
161
+ (Array.isArray(value) ? value : value === undefined || value === null ? [] : [value]).map(fn),
162
+ count: (value) =>
163
+ Array.isArray(value) ? value.length : value === undefined || value === null ? 0 : 1,
164
+ },
165
+ };
166
+ return new Proxy(base, { get: (target, key) => (key in target ? target[key] : () => null) });
167
+ }
168
+
169
+ function fakeJsxRuntime() {
170
+ const react = fakeReact();
171
+ return {
172
+ Fragment: react.Fragment,
173
+ jsx: (type, props) => react.createElement(type, props, ...(props?.children ?? [])),
174
+ jsxs: (type, props) => react.createElement(type, props, ...(props?.children ?? [])),
175
+ jsxDEV: (type, props) => react.createElement(type, props, ...(props?.children ?? [])),
62
176
  };
63
177
  }
64
178
 
179
+ function fakeDshPackage() {
180
+ const stub = (props) => ({
181
+ type: 'stub:dsh',
182
+ props: props ?? {},
183
+ children:
184
+ props?.children === undefined ? [] : Array.isArray(props.children) ? props.children : [props.children],
185
+ });
186
+ return new Proxy({}, { get: () => stub });
187
+ }
188
+
65
189
  function fakeKit() {
66
190
  const callable = () => anyStub('result');
67
191
  return {
68
192
  hooks: new Proxy({}, { get: () => callable }),
69
- // 组件替身**带上自己的名字**(`stub:Card`):卡片壳现在是 `kit.components.Card` 这个组件,
70
- // 产物里不会再出现 `wb-card` 这个类名 —— 判据要能认出"用了外壳给的卡壳"。
71
- // children 要**往下传**:吞掉的话,卡片体内用 kit 组件写的内容就永远渲染不到(节点数会假少)。
72
193
  components: new Proxy(
73
194
  {},
74
195
  {
@@ -87,23 +208,9 @@ function fakeKit() {
87
208
  };
88
209
  }
89
210
 
90
- /**
91
- * dsh 的客户端包(平台种子词)在**卡片渲染**这里的替身。
92
- *
93
- * 模块的产物里 `require("@deepseek-ai/dsh-client-ui-primitives")` 是**规范**(ADR-0028:
94
- * 标成 external 才是"用外壳那一份")。判据在 node 里跑产物,就得像外壳的模块表那样把这些
95
- * 种子词交出去 —— 不然合规的模块反而卡在"没桩的外部"(实际上就是这么发现的)。
96
- * 组件与 hooks 都长成函数:卡片判据只问"画不画得出非空元素树"。
97
- */
98
- function fakeDshPackage() {
99
- const stub = (props) => ({ type: 'stub', props: props ?? {}, children: [] });
100
- return new Proxy({}, { get: (_target, key) => (key === 'default' ? stub : stub) });
101
- }
102
-
103
- function loadFace(clientFile) {
211
+ function loadFace(clientFile, makeKit) {
104
212
  let captured;
105
213
  const fakeWindow = {
106
- // 浏览器里 `window.location` 永远在;缺了它,读它的客户端代码(含 `?wbMock=1` 那个开关)会先抛。
107
214
  location: { search: '', href: 'http://127.0.0.1:3081/' },
108
215
  __ModuleLoader__: {
109
216
  load: (entry) => {
@@ -121,7 +228,8 @@ function loadFace(clientFile) {
121
228
  expose.exports = module.exports;
122
229
  const fakeRequire = (id) => {
123
230
  if (id === 'react') return fakeReact();
124
- if (id === '@dsh-workbench/core/client') return { kit: fakeKit() };
231
+ if (id === 'react/jsx-runtime' || id === 'react/jsx-dev-runtime') return fakeJsxRuntime();
232
+ if (id === '@dsh-workbench/core/client') return { kit: makeKit() };
125
233
  if (id.startsWith('@deepseek-ai/')) return fakeDshPackage();
126
234
  throw new Error(`产物 require 了没桩的外部:${id}`);
127
235
  };
@@ -129,23 +237,29 @@ function loadFace(clientFile) {
129
237
  return { id: captured.id, exports: produced ?? module.exports };
130
238
  }
131
239
 
132
- // 递归**调用**函数组件 —— 不调用的话,树里只有最外层那个 `type`,
133
- // 卡片体内的代码一行都没跑(第一次写漏了这步,4 张卡片都只数出 3 个节点)。
134
- //
135
- // 顺手把**用到的类名**收集起来:卡片得自己套外壳的卡片契约类(见文件尾那条判据)。
136
240
  function render(node, depth, budget) {
137
241
  if (node === null || node === undefined || typeof node === 'boolean') return 0;
138
- if (typeof node === 'string' || typeof node === 'number') return 1;
242
+ if (typeof node === 'string' || typeof node === 'number') {
243
+ budget.texts.push(String(node));
244
+ return 1;
245
+ }
139
246
  if (Array.isArray(node)) {
140
247
  let count = 0;
141
248
  for (const one of node) count += render(one, depth, budget);
142
249
  return count;
143
250
  }
144
- if (typeof node !== 'object') return 1;
251
+ if (typeof node !== 'object') {
252
+ budget.texts.push(String(node));
253
+ return 1;
254
+ }
145
255
  if (budget.left <= 0) return 0;
146
256
  budget.left -= 1;
147
257
  budget.maxDepth = Math.max(budget.maxDepth, depth);
148
-
258
+ for (const [key, value] of Object.entries(node.props ?? {})) {
259
+ if (key === 'key' || key === 'ref') continue;
260
+ if (typeof value === 'string') budget.texts.push(value);
261
+ else if (typeof value === 'number' && Number.isNaN(value)) budget.texts.push('NaN');
262
+ }
149
263
  const classes = node.props?.className;
150
264
  if (typeof classes === 'string') {
151
265
  for (const one of classes.split(/\s+/)) if (one !== '') budget.classNames.add(one);
@@ -153,7 +267,6 @@ function render(node, depth, budget) {
153
267
  if (typeof node.type === 'string' && node.type.startsWith('stub:')) {
154
268
  budget.stubs.add(node.type.slice('stub:'.length));
155
269
  }
156
-
157
270
  const children = [...(node.children ?? [])];
158
271
  if (typeof node.type === 'function') {
159
272
  if (depth > 40) throw new Error('组件递归超过 40 层(自引用?)');
@@ -165,10 +278,75 @@ function render(node, depth, budget) {
165
278
  return count;
166
279
  }
167
280
 
281
+ async function shellDictionariesOf() {
282
+ const require = createRequire(import.meta.url);
283
+ const entry = require.resolve('@dsh-workbench/shared');
284
+ const module = await import(pathToFileURL(join(dirname(entry), 'i18n/index.js')).href);
285
+ return module.dictionaries;
286
+ }
287
+
288
+ function ownOf(dictionaries, language) {
289
+ const one = dictionaries?.[language];
290
+ return typeof one === 'object' && one !== null ? one : {};
291
+ }
292
+
293
+ function translatorOf(language, messages, shellDictionaries, missing, snapshot) {
294
+ const own = ownOf(messages, language);
295
+ const shell = ownOf(shellDictionaries, language);
296
+ return (key, params) => {
297
+ const template = typeof own[key] === 'string' ? own[key] : shell[key];
298
+ if (typeof template !== 'string') {
299
+ missing.push({ language, key, snapshot });
300
+ return `\u0000missing:${key}\u0000`;
301
+ }
302
+ if (params === undefined || params === null) return template;
303
+ return template.replace(/\{(\w+)\}/g, (whole, name) =>
304
+ name in params ? String(params[name]) : whole,
305
+ );
306
+ };
307
+ }
308
+
309
+ function forbiddenHitsOf(texts) {
310
+ const hits = [];
311
+ const seen = new Set();
312
+ for (const text of texts) {
313
+ for (const { token, pattern } of FORBIDDEN) {
314
+ if (!pattern.test(text)) continue;
315
+ const key = `${token}\u0000${text}`;
316
+ if (seen.has(key)) continue;
317
+ seen.add(key);
318
+ hits.push({ token, text });
319
+ }
320
+ }
321
+ return hits;
322
+ }
323
+
324
+ function shellClientsOf(modulePackages) {
325
+ const found = new Set();
326
+ for (const pkg of modulePackages) {
327
+ const client = shellClientPathOf(pkg.dir);
328
+ if (client !== undefined && existsSync(client)) found.add(client);
329
+ }
330
+ return [...found];
331
+ }
332
+
168
333
  export async function run() {
169
334
  const modulePackages = discoverModulePackages({ root: process.cwd() });
170
335
  const problems = [];
171
336
  const rows = [];
337
+ const footnotes = [];
338
+ let degraded = 0;
339
+
340
+ let shellDictionaries;
341
+ try {
342
+ shellDictionaries = await shellDictionariesOf();
343
+ } catch (error) {
344
+ shellDictionaries = undefined;
345
+ footnotes.push(
346
+ `(取不出 @dsh-workbench/shared 的字典:${error instanceof Error ? error.message : String(error)})` +
347
+ ' —— 逐态那半跳过(单次全桩渲染照旧跑),因为裸键名判定要模块 ∪ 外壳两份字典',
348
+ );
349
+ }
172
350
 
173
351
  for (const pkg of modulePackages) {
174
352
  const name = pkg.manifest.name;
@@ -178,9 +356,44 @@ export async function run() {
178
356
  continue;
179
357
  }
180
358
 
359
+ const declared = pkg.manifest.dsh?.workbench?.card ?? null;
360
+ const declaresCard =
361
+ declared !== null && typeof declared.titleKey === 'string' && declared.titleKey !== '';
362
+
363
+ const shellClient = shellClientPathOf(pkg.dir);
364
+ const hasShell = shellClient !== undefined && existsSync(shellClient);
365
+ const current = { snapshot: SNAPSHOTS[0] };
366
+ let realKit;
367
+ let shellProblem;
368
+ if (hasShell && shellDictionaries !== undefined) {
369
+ try {
370
+ realKit = shellKitOf(shellClient, { react: fakeReact(), dshPackage: fakeDshPackage() });
371
+ } catch (error) {
372
+ shellProblem = error instanceof Error ? error.message : String(error);
373
+ }
374
+ }
375
+
376
+ const makeKit = () => {
377
+ if (realKit === undefined) return fakeKit();
378
+ const hooks = { ...(realKit.hooks ?? {}) };
379
+ hooks.useApiResource = () => current.snapshot.resource;
380
+ // 闸这一格的默认(P1 五态都是它):没查过 ⇒ capability null、gated true。快照带了
381
+ // `capability` 才换成那一份,`gated` 照 `gateOf(capability, 'capability')` 算 —— 判据不当
382
+ // 第二份真相:状态的语义归外壳,这里只是把"外壳会给的那一格"摆出来。
383
+ hooks.useCapabilityGate = (unit) => {
384
+ const capability = current.snapshot.capability?.(unit) ?? null;
385
+ return {
386
+ capability,
387
+ gated: capability === null || capability.status === 'ok' || capability.status === 'unchecked',
388
+ forceProbe: () => {},
389
+ };
390
+ };
391
+ return { ...realKit, hooks };
392
+ };
393
+
181
394
  let face;
182
395
  try {
183
- const loaded = loadFace(clientFile);
396
+ const loaded = loadFace(clientFile, makeKit);
184
397
  face = loaded.exports;
185
398
  } catch (error) {
186
399
  problems.push(`${name}: 产物加载不起来 —— ${error.message}`);
@@ -218,12 +431,6 @@ export async function run() {
218
431
  }
219
432
 
220
433
  const card = registered?.card;
221
- // 清单说"没有卡片"(`"card": null`)与产物说"没有卡片"必须是同一件事 —— 两处说法不一致就是
222
- // 声明与代码打架:登记了的那张卡没人会画(外壳只认清单),它只会一直烂在产物里。
223
- // 这条判据的名字就是它防的事:gitops 当初为了哄旧判据挂了一张"我这张卡片就是说我没有卡片"的占位卡。
224
- const declared = pkg.manifest.dsh?.workbench?.card ?? null;
225
- const declaresCard =
226
- declared !== null && typeof declared.titleKey === 'string' && declared.titleKey !== '';
227
434
  if (!declaresCard) {
228
435
  if (typeof card === 'function') {
229
436
  problems.push(
@@ -240,9 +447,6 @@ export async function run() {
240
447
  continue;
241
448
  }
242
449
 
243
- // 清单的 `card.titleKey` 是**面板那一格的标题**,它的文案住在模块自己交的字典里
244
- // (`registerUnit` 那个面的 `messages`,`zh` / `en` 都必填)。键拼错、或字典漏了那一条 ⇒
245
- // 面板上显示的就是原始键名(`demo.cardTitle`),而且一声不响 —— 这条判据盯它。
246
450
  const titleKey = pkg.manifest.dsh?.workbench?.card?.titleKey;
247
451
  if (typeof titleKey === 'string' && titleKey !== '') {
248
452
  const messages = registered?.messages;
@@ -263,7 +467,7 @@ export async function run() {
263
467
  }
264
468
 
265
469
  const props = {
266
- world: { current: {} },
470
+ world: { current: { sessionId: null, workspace: null } },
267
471
  t: (key) => key,
268
472
  refreshToken: 0,
269
473
  onRefresh: () => {},
@@ -272,49 +476,154 @@ export async function run() {
272
476
  onSummary: () => {},
273
477
  };
274
478
 
275
- let tree;
276
- try {
277
- tree = card(props);
278
- } catch (error) {
279
- problems.push(`${name}: 空数据下卡片抛了 —— ${error.message}`);
479
+ if (realKit === undefined) {
480
+ const why =
481
+ shellProblem !== undefined
482
+ ? `取不出这份外壳的 kit(${shellClient})—— ${shellProblem}`
483
+ : shellDictionaries === undefined
484
+ ? '取不出 @dsh-workbench/shared 的字典'
485
+ : '没装外壳(@dsh-workbench/core)';
486
+ let tree;
487
+ try {
488
+ tree = card(props);
489
+ } catch (error) {
490
+ problems.push(`${name}: 空数据下卡片抛了 —— ${error.message}`);
491
+ continue;
492
+ }
493
+ if (tree === null || tree === undefined) {
494
+ problems.push(`${name}: 空数据下卡片返回空(界面上就是一片空白)`);
495
+ continue;
496
+ }
497
+ const budget = { left: 20000, maxDepth: 0, classNames: new Set(), stubs: new Set(), texts: [] };
498
+ let count;
499
+ try {
500
+ count = render(tree, 0, budget);
501
+ } catch (error) {
502
+ const where = (error.stack ?? '').split('\n').slice(1, 4).join(' | ');
503
+ problems.push(`${name}: 卡片体内的组件抛了 —— ${error.message}\n ${where}`);
504
+ continue;
505
+ }
506
+ if (count <= 1) {
507
+ problems.push(`${name}: 卡片只画出一个节点 —— 卡片体没跑(或什么都没画)`);
508
+ continue;
509
+ }
510
+ if (!(budget.classNames.has('wb-card') || budget.stubs.has('Card'))) {
511
+ problems.push(
512
+ `${name}: 卡片没套外壳的卡壳 —— 用 kit.components.Card(推荐),或自己写契约类 wb-card + ` +
513
+ `wb-cardBody;裸内容在面板里看起来就"不是一张卡片"`,
514
+ );
515
+ continue;
516
+ }
517
+ footnotes.push(`${name}: ${why} —— 逐态渲染跳过,只跑了单次全桩渲染`);
518
+ rows.push(`${name}: 空数据下画得出 ${count} 个节点(最深 ${budget.maxDepth} 层)`);
519
+ degraded += 1;
280
520
  continue;
281
521
  }
282
522
 
283
- if (tree === null || tree === undefined) {
284
- problems.push(`${name}: 空数据下卡片返回空(界面上就是一片空白)`);
285
- continue;
286
- }
523
+ const missing = [];
524
+ const counts = new Map();
525
+ const textsBySnapshot = new Map();
526
+ const classNames = new Set();
527
+ const stubs = new Set();
287
528
 
288
- const budget = { left: 20000, maxDepth: 0, classNames: new Set(), stubs: new Set() };
289
- let count;
290
- try {
291
- count = render(tree, 0, budget);
292
- } catch (error) {
293
- const where = (error.stack ?? '').split('\n').slice(1, 4).join(' | ');
294
- problems.push(`${name}: 卡片体内的组件抛了 —— ${error.message}\n ${where}`);
295
- continue;
529
+ for (const snapshot of SNAPSHOTS) {
530
+ current.snapshot = snapshot;
531
+ counts.set(snapshot.name, []);
532
+ for (const language of LANGUAGES) {
533
+ const budget = { left: 20000, maxDepth: 0, classNames: new Set(), stubs: new Set(), texts: [] };
534
+ const where = `${snapshot.name}(${language})`;
535
+ props.t = translatorOf(
536
+ language,
537
+ registered?.messages,
538
+ shellDictionaries,
539
+ missing,
540
+ snapshot.name,
541
+ );
542
+ props.world = { current: { sessionId: null, workspace: snapshot.workspace } };
543
+
544
+ let tree;
545
+ try {
546
+ tree = card(props);
547
+ } catch (error) {
548
+ problems.push(`${name}: ${where} 卡片抛了 —— ${error.message}`);
549
+ continue;
550
+ }
551
+ if (tree === null || tree === undefined) {
552
+ problems.push(`${name}: ${where} 卡片返回空 —— 这一态下卡片没画出来(界面上就是一片空白)`);
553
+ continue;
554
+ }
555
+ let count;
556
+ try {
557
+ count = render(tree, 0, budget);
558
+ } catch (error) {
559
+ const stack = (error.stack ?? '').split('\n').slice(1, 4).join(' | ');
560
+ problems.push(`${name}: ${where} 卡片体内的组件抛了 —— ${error.message}\n ${stack}`);
561
+ continue;
562
+ }
563
+ if (count <= 1) {
564
+ problems.push(
565
+ `${name}: ${where} 卡片只画出 ${count} 个节点 —— 这一态下卡片体没跑(或只剩一层空壳)`,
566
+ );
567
+ continue;
568
+ }
569
+ counts.get(snapshot.name).push(count);
570
+ for (const one of budget.classNames) classNames.add(one);
571
+ for (const one of budget.stubs) stubs.add(one);
572
+ const bucket = textsBySnapshot.get(snapshot.name) ?? [];
573
+ bucket.push(...budget.texts);
574
+ textsBySnapshot.set(snapshot.name, bucket);
575
+ for (const hit of forbiddenHitsOf(budget.texts)) {
576
+ problems.push(
577
+ `${name}: ${where} 屏上出现了 ${hit.token} —— 这一态下卡片把它摆到了文字上。` +
578
+ `命中的那串:${hit.text.slice(0, 120)}`,
579
+ );
580
+ }
581
+ }
296
582
  }
297
- if (count <= 1) {
298
- problems.push(`${name}: 卡片只画出一个节点 —— 卡片体没跑(或什么都没画)`);
299
- continue;
583
+
584
+ if (missing.length > 0) {
585
+ const grouped = new Map();
586
+ for (const one of missing) {
587
+ const key = `${one.language}\u0000${one.key}`;
588
+ if (!grouped.has(key)) grouped.set(key, { key: one.key, language: one.language, snapshots: [] });
589
+ const entry = grouped.get(key);
590
+ if (!entry.snapshots.includes(one.snapshot)) entry.snapshots.push(one.snapshot);
591
+ }
592
+ for (const entry of grouped.values()) {
593
+ problems.push(
594
+ `${name}: t('${entry.key}') 在 ${entry.language} 里没有落点(问它的快照:` +
595
+ `${entry.snapshots.join('、')})—— 屏上会显示原始键名。` +
596
+ '命中判定 = 模块 messages ∪ 外壳 dictionaries',
597
+ );
598
+ }
300
599
  }
301
- // 卡片得**套上外壳给的卡壳**:要么用 `kit.components.Card` 这个组件(推荐、也是 hello 现在的做法),
302
- // 要么自己写契约类 `wb-card`(`board.css` 里那条,兄弟模块现在还是这么写的)。
303
- //
304
- // 用户实测(2026-10-09):"我们的 hello module 怎么不是卡片" —— 面板里那块内容既没有底色也没有
305
- // 描边,看着就是标题加一段话。原因是卡片函数没套壳:当时用的是 `wb-view`(那是面板列布局),
306
- // 更早那份装进 profile 的产物还自己搓过 `wb-hello*`。两种都查得出来:组件走 stub 名,类名走 classNames。
307
- const shelled = budget.classNames.has('wb-card') || budget.stubs.has('Card');
308
- if (!shelled) {
600
+
601
+ if (!(classNames.has('wb-card') || stubs.has('Card'))) {
309
602
  problems.push(
310
603
  `${name}: 卡片没套外壳的卡壳 —— 用 kit.components.Card(推荐),或自己写契约类 wb-card + ` +
311
604
  `wb-cardBody;裸内容在面板里看起来就"不是一张卡片"`,
312
605
  );
313
606
  continue;
314
607
  }
315
- rows.push(`${name}: 空数据下画得出 ${count} 个节点(最深 ${budget.maxDepth} 层)`);
608
+
609
+ const per = SNAPSHOTS.map(
610
+ (snapshot) => `${snapshot.name} ${counts.get(snapshot.name).join('/') || '—'}`,
611
+ ).join(' · ');
612
+ rows.push(`${name}: ${SNAPSHOTS.length} 态 × zh/en 都画得出 —— ${per} 个节点`);
613
+
614
+ const shapes = new Set(
615
+ SNAPSHOTS.map((snapshot) => [...new Set(textsBySnapshot.get(snapshot.name) ?? [])].sort().join('\n')),
616
+ );
617
+ if (shapes.size === 1) {
618
+ footnotes.push(
619
+ `${name}: ${SNAPSHOTS.length} 个快照渲染出来的文本完全一样 —— 不判红,只当线索:它的取数可能没走 ` +
620
+ 'kit.hooks.useApiResource,或这张卡片本来就不随态分叉',
621
+ );
622
+ }
316
623
  }
317
624
 
625
+ const shellClients = shellDictionaries === undefined ? [] : shellClientsOf(modulePackages);
626
+
318
627
  if (problems.length > 0) {
319
628
  return { status: 'fail', notes: [], problems };
320
629
  }
@@ -328,9 +637,19 @@ export async function run() {
328
637
  return {
329
638
  status: 'pass',
330
639
  notes: [
331
- `${rows.length} 个模块过了卡片这一关:有卡片的都在**空数据**下画得出来(不许抛)、清单的 titleKey 在字典里都有落点;` +
332
- '清单写 "card": null 的,产物里也确实没登记卡片(声明与代码同一句话)',
640
+ `${rows.length} 个模块过了卡片这一关:` +
641
+ (degraded === 0
642
+ ? `${SNAPSHOTS.length} 态 × zh/en 各渲染一遍`
643
+ : `逐态那半这次有 ${degraded} 个模块跳过(没装外壳 / 取不出外壳,见下),其余 ${SNAPSHOTS.length} 态 × zh/en 各渲染一遍`) +
644
+ ' —— 不抛、非空白、文字里没有 ' +
645
+ 'undefined / NaN / [object Object] / null、没有裸键名、套了外壳的卡壳、清单的 titleKey 在两语字典里都有落点。' +
646
+ `(断言的是"${SNAPSHOTS.length} 种外壳侧输入下都退化得漂亮",不是"画的是哪一态那一屏" —— 屏的对错归模块自己的 ` +
647
+ 'card-states.test.ts 与 design.json 状态矩阵)',
648
+ ...(shellClients.length === 0
649
+ ? []
650
+ : [`逐态用的真壳(模块仓里装的那份 client.js):${shellClients.join('、')}`]),
333
651
  ...rows,
652
+ ...footnotes,
334
653
  ],
335
654
  problems: [],
336
655
  };