@dsh-workbench/module-tools 0.2.13 → 0.2.15
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 +2 -2
- package/package.json +2 -2
- package/src/checks/cards.mjs +436 -91
- package/src/checks/kit.mjs +2 -40
- package/src/checks/shell-artifact.mjs +51 -0
- package/src/checks/style.mjs +142 -17
- package/src/font-scale.mjs +213 -0
- package/template-diy/README.md.tpl +3 -3
package/README.md
CHANGED
|
@@ -26,10 +26,10 @@ dsh-wb-module publish # 发货前的清单:门禁 + 三张表 +
|
|
|
26
26
|
| `source` | 源码边界:不直接引 dsh 的包、不引不是种子词的 `@deepseek-ai/*` |
|
|
27
27
|
| `primitives` | dsh 原语只从模块表(`kit`)拿 |
|
|
28
28
|
| `kit` | 这份外壳的 kit 成员到底有哪些(缺什么会当场点名) |
|
|
29
|
-
| `cards` |
|
|
29
|
+
| `cards` | 卡片渲染:**八态 × zh/en 各渲一遍**(外壳侧输入 idle / loading / loading-刷新保留旧值(含旧时间戳 `at`)/ ready / ready-空容器 / unreadable-error / unreadable-能力闸缺插件 / unreadable-能力闸缺命令;`useApiResource` 与 `useCapabilityGate` 由判据喂,`useUnitBoard` 用模块仓里装的那份**真壳**)—— 不许抛、不许空白、文字里不许出现 `undefined` / `NaN` / `[object Object]` / `null`、不许裸键名、要套外壳的卡壳、清单 `titleKey` 在两语字典里都有落点。**不断言"画的是哪一态那一屏"**(那归模块自己的逐态测试与 `design.json` 状态矩阵);模块没装外壳时逐态那半跳过、单次全桩渲染照旧跑 |
|
|
30
30
|
| `standalone` | 独立构建:产物不依赖工坊里跑 |
|
|
31
31
|
| `markers` | 公开包无内部标识(内网域名、企业内部名) |
|
|
32
|
-
| `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)`([ADR-0052](../../docs/adr/0052-the-form-row-is-one-component.md):面 → 行 → 控件);现存违规收进 `FORM_ROW_EXEMPT
|
|
32
|
+
| `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)`([ADR-0052](../../docs/adr/0052-the-form-row-is-one-component.md):面 → 行 → 控件);现存违规收进 `FORM_ROW_EXEMPT`(棘轮:迁一处删一行,且表里已经不再命中的行会当场报红);**手写的操作区(借了 `wb-formActions` 槽类手摆按钮)也判红** —— 操作区归 kit 的 `FormActions`([ADR-0054](../../docs/adr/0054-the-action-area-is-one-component.md):顺序 / 一个区一颗 `primary` / `danger` 的位置 / 间距 / 窄容器换行都由它定,调用点只给数据与语义),现存违规收进 `FORM_ACTIONS_EXEMPT`(同一套棘轮:迁一处删一行,表里不再命中的行当场报红) |
|
|
33
33
|
| `alias` | 模块不指外壳:源码里没有指向外壳 `@/` 的引用 |
|
|
34
34
|
|
|
35
35
|
三态输出:`✔ 通过` / `○ 不适用` / `✖ 不过`;有任何不过就退出 1。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dsh-workbench/module-tools",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.15",
|
|
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.
|
|
24
|
+
"@dsh-workbench/shared": "0.2.15"
|
|
25
25
|
},
|
|
26
26
|
"scripts": {
|
|
27
27
|
"selfcheck": "node scripts/selfcheck.mjs"
|
package/src/checks/cards.mjs
CHANGED
|
@@ -1,28 +1,29 @@
|
|
|
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 {
|
|
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
|
+
|
|
22
|
+
// 刷新那一帧的旧时间戳(`at`)。真壳在 `ready` 那一步写 `Date.now()`,同一个 identity 重读时
|
|
23
|
+
// 把它原样带进 loading 相(`use-api-resource.ts:102`);这里给一个固定的过去时刻,判据不依赖
|
|
24
|
+
// "现在几点"(同一个值每次跑都一样,负例才好断言)。
|
|
25
|
+
const STALE_AT = 1_700_000_000_000;
|
|
26
|
+
|
|
26
27
|
function anyStub(path = 'stub') {
|
|
27
28
|
const target = function () {
|
|
28
29
|
return anyStub(`${path}()`);
|
|
@@ -42,12 +43,126 @@ function anyStub(path = 'stub') {
|
|
|
42
43
|
});
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
function containerStub(path, size) {
|
|
47
|
+
const elementAt = (index) => containerStub(`${path}[${index}]`, size);
|
|
48
|
+
const asArray = () => Array.from({ length: size }, (_unused, index) => elementAt(index));
|
|
49
|
+
const scalar = size > 0 ? 1 : 0;
|
|
50
|
+
const methods = {
|
|
51
|
+
map: (fn) => asArray().map(fn),
|
|
52
|
+
filter: (fn) => asArray().filter(fn),
|
|
53
|
+
forEach: (fn) => asArray().forEach(fn),
|
|
54
|
+
find: (fn) => asArray().find(fn),
|
|
55
|
+
findIndex: (fn) => asArray().findIndex(fn),
|
|
56
|
+
some: (fn) => asArray().some(fn),
|
|
57
|
+
every: (fn) => asArray().every(fn),
|
|
58
|
+
reduce: (fn, initial) => asArray().reduce(fn, initial),
|
|
59
|
+
flatMap: (fn) => asArray().flatMap(fn),
|
|
60
|
+
slice: (from, to) => asArray().slice(from, to),
|
|
61
|
+
at: (index) => asArray().at(index),
|
|
62
|
+
indexOf: (value) => asArray().indexOf(value),
|
|
63
|
+
includes: (value) => asArray().includes(value),
|
|
64
|
+
join: (separator) => asArray().join(separator),
|
|
65
|
+
concat: (other) => asArray().concat(other),
|
|
66
|
+
reverse: () => asArray().reverse(),
|
|
67
|
+
sort: () => asArray(),
|
|
68
|
+
keys: () => asArray().keys(),
|
|
69
|
+
values: () => asArray().values(),
|
|
70
|
+
entries: () => asArray().entries(),
|
|
71
|
+
toArray: () => asArray(),
|
|
72
|
+
};
|
|
73
|
+
return new Proxy(function () {}, {
|
|
74
|
+
get(_target, key) {
|
|
75
|
+
if (key === 'ok') return true;
|
|
76
|
+
if (key === Symbol.toPrimitive) return () => scalar;
|
|
77
|
+
if (key === Symbol.iterator) return () => asArray()[Symbol.iterator]();
|
|
78
|
+
if (key === 'then') return undefined;
|
|
79
|
+
if (key === 'length') return size;
|
|
80
|
+
if (key === 'toString' || key === 'valueOf') return () => scalar;
|
|
81
|
+
if (key === 'constructor') return Object;
|
|
82
|
+
if (key in methods) return methods[key];
|
|
83
|
+
if (typeof key === 'symbol') return undefined;
|
|
84
|
+
return containerStub(`${path}.${String(key)}`, size);
|
|
85
|
+
},
|
|
86
|
+
apply() {
|
|
87
|
+
return containerStub(`${path}()`, size);
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const SNAPSHOTS = [
|
|
93
|
+
{ name: 'idle', resource: { phase: 'idle' }, workspace: null },
|
|
94
|
+
{ name: 'loading', resource: { phase: 'loading' }, workspace: WORKSPACE },
|
|
95
|
+
{ name: 'ready', resource: { phase: 'ready', value: containerStub('api', 2) }, workspace: WORKSPACE },
|
|
96
|
+
{
|
|
97
|
+
name: 'empty(有值但容器空)',
|
|
98
|
+
resource: { phase: 'ready', value: containerStub('api', 0) },
|
|
99
|
+
workspace: WORKSPACE,
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: 'unreadable',
|
|
103
|
+
resource: { phase: 'error', error: { code: 'network', message: '宿主没有响应' } },
|
|
104
|
+
workspace: WORKSPACE,
|
|
105
|
+
},
|
|
106
|
+
// P2 第一条:刷新形态(ADR-0022「刷新保留旧值」)。载荷与 `loading` 只差两格 —— `value` 与
|
|
107
|
+
// `at` 都在,而且是旧那份。`use-api-resource.ts:100-104` 在同一个 identity 重读时给的就是这一相
|
|
108
|
+
// (`{ phase: 'loading', value: prev.value, ...(prev.at === undefined ? {} : { at: prev.at }) }`,
|
|
109
|
+
// 而 `prev.at` 是 `ready` 那一步写的 `Date.now()`,所以真实这一相两格一起在)。卡片若把
|
|
110
|
+
// "loading 就没有值"写死,脏就出在这一帧;若把旧值当新值、或拿旧 `at` 当"刚读到"去算,脏也出在这一帧。
|
|
111
|
+
{
|
|
112
|
+
name: 'loading(刷新:保留旧值)',
|
|
113
|
+
resource: { phase: 'loading', value: containerStub('api', 2), at: STALE_AT },
|
|
114
|
+
workspace: WORKSPACE,
|
|
115
|
+
},
|
|
116
|
+
// P2 第二条:能力闸。`useCapabilityGate` 给一个**明确的** `missing-plugin`(不是 `null`):
|
|
117
|
+
// `use-unit-board.ts:47` 对 `capability === null` 直接不算 `blockerOfCapability`,喂 null 就退化成
|
|
118
|
+
// P1 的 idle 那一屏、一格新代码都走不到。`gated` 由能力状态按 `gateOf`(`unit-board.ts:13-17`)
|
|
119
|
+
// 算出来(missing-plugin ⇒ false),`resource` 照 `ready` 一份 —— 于是它跟 `ready` 只差闸这一格,
|
|
120
|
+
// 阻断屏与正文同时在场时谁优先是模块的事。
|
|
121
|
+
{
|
|
122
|
+
name: 'unreadable(能力闸:缺插件)',
|
|
123
|
+
resource: { phase: 'ready', value: containerStub('api', 2) },
|
|
124
|
+
workspace: WORKSPACE,
|
|
125
|
+
capability: (unit) => ({
|
|
126
|
+
unit,
|
|
127
|
+
status: 'missing-plugin',
|
|
128
|
+
required: ['demo-plugin'],
|
|
129
|
+
missing: ['demo-plugin'],
|
|
130
|
+
detail: '逐态探针:缺 demo-plugin',
|
|
131
|
+
}),
|
|
132
|
+
},
|
|
133
|
+
// P2 补记:能力闸的第二种 status —— `missing-commands`(插件在、命令旧)。它与缺插件那一帧
|
|
134
|
+
// 差的是两格:`status` 换掉,且 `fix` / `command` 都在(真壳 `probe-verdict.ts:109-116`:命令落后
|
|
135
|
+
// 的插件状态是 `outdated`,于是 `fix: 'update'`;`:158-160` 再带上那条升级命令)。这两格正好把
|
|
136
|
+
// `blockerOfCapability` 里 `install` 那一段走通:`fixAction`(`blocker.fix.update`)+ `copyAction`
|
|
137
|
+
// (`blocker.copy` + `command` 原文,`blocker-table.ts:109-123`),加上照旧的 retry —— 缺插件那一帧
|
|
138
|
+
// 两格都没给,install 是空的,只走到 retry。`sourceUnknown` 在 `fix` 在时不成立,所以不开市场那条。
|
|
139
|
+
{
|
|
140
|
+
name: 'unreadable(能力闸:缺命令)',
|
|
141
|
+
resource: { phase: 'ready', value: containerStub('api', 2) },
|
|
142
|
+
workspace: WORKSPACE,
|
|
143
|
+
capability: (unit) => ({
|
|
144
|
+
unit,
|
|
145
|
+
status: 'missing-commands',
|
|
146
|
+
required: ['demo-plugin'],
|
|
147
|
+
missing: ['demo.command'],
|
|
148
|
+
detail: '逐态探针:demo-plugin 装在,缺 demo.command',
|
|
149
|
+
fix: 'update',
|
|
150
|
+
command: 'opencli plugin update demo-plugin',
|
|
151
|
+
}),
|
|
152
|
+
},
|
|
153
|
+
];
|
|
154
|
+
|
|
45
155
|
function fakeReact() {
|
|
46
156
|
const element = (type, props, ...children) => ({ type, props: props ?? {}, children });
|
|
47
|
-
|
|
157
|
+
const passthrough = (props, ...children) => element('stub:passthrough', props, ...children);
|
|
158
|
+
const base = {
|
|
48
159
|
createElement: element,
|
|
49
160
|
createElementNS: (_namespace, type, props, ...children) => element(type, props, ...children),
|
|
50
161
|
Fragment: 'Fragment',
|
|
162
|
+
Component: class {},
|
|
163
|
+
PureComponent: class {},
|
|
164
|
+
createContext: (value) => ({ Provider: passthrough, Consumer: passthrough, _currentValue: value }),
|
|
165
|
+
useContext: () => anyStub('context'),
|
|
51
166
|
useState: (initial) => [typeof initial === 'function' ? initial() : initial, () => {}],
|
|
52
167
|
useReducer: (_reducer, initial) => [initial, () => {}],
|
|
53
168
|
useMemo: (factory) => factory(),
|
|
@@ -55,20 +170,52 @@ function fakeReact() {
|
|
|
55
170
|
useRef: (initial) => ({ current: initial ?? null }),
|
|
56
171
|
useEffect: () => {},
|
|
57
172
|
useLayoutEffect: () => {},
|
|
173
|
+
useInsertionEffect: () => {},
|
|
58
174
|
useSyncExternalStore: (_subscribe, snapshot) => snapshot(),
|
|
59
175
|
useId: () => 'wb-id',
|
|
176
|
+
useTransition: () => [false, (fn) => fn()],
|
|
177
|
+
useDeferredValue: (value) => value,
|
|
60
178
|
isValidElement: (value) => typeof value === 'object' && value !== null && 'type' in value,
|
|
61
|
-
|
|
179
|
+
cloneElement: (node, props, ...children) =>
|
|
180
|
+
element(node.type, { ...(node.props ?? {}), ...(props ?? {}) }, ...children),
|
|
181
|
+
memo: (fn) => fn,
|
|
182
|
+
forwardRef: (fn) => fn,
|
|
183
|
+
Children: {
|
|
184
|
+
toArray: (value) =>
|
|
185
|
+
Array.isArray(value) ? value : value === undefined || value === null ? [] : [value],
|
|
186
|
+
map: (value, fn) =>
|
|
187
|
+
(Array.isArray(value) ? value : value === undefined || value === null ? [] : [value]).map(fn),
|
|
188
|
+
count: (value) =>
|
|
189
|
+
Array.isArray(value) ? value.length : value === undefined || value === null ? 0 : 1,
|
|
190
|
+
},
|
|
62
191
|
};
|
|
192
|
+
return new Proxy(base, { get: (target, key) => (key in target ? target[key] : () => null) });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function fakeJsxRuntime() {
|
|
196
|
+
const react = fakeReact();
|
|
197
|
+
return {
|
|
198
|
+
Fragment: react.Fragment,
|
|
199
|
+
jsx: (type, props) => react.createElement(type, props, ...(props?.children ?? [])),
|
|
200
|
+
jsxs: (type, props) => react.createElement(type, props, ...(props?.children ?? [])),
|
|
201
|
+
jsxDEV: (type, props) => react.createElement(type, props, ...(props?.children ?? [])),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function fakeDshPackage() {
|
|
206
|
+
const stub = (props) => ({
|
|
207
|
+
type: 'stub:dsh',
|
|
208
|
+
props: props ?? {},
|
|
209
|
+
children:
|
|
210
|
+
props?.children === undefined ? [] : Array.isArray(props.children) ? props.children : [props.children],
|
|
211
|
+
});
|
|
212
|
+
return new Proxy({}, { get: () => stub });
|
|
63
213
|
}
|
|
64
214
|
|
|
65
215
|
function fakeKit() {
|
|
66
216
|
const callable = () => anyStub('result');
|
|
67
217
|
return {
|
|
68
218
|
hooks: new Proxy({}, { get: () => callable }),
|
|
69
|
-
// 组件替身**带上自己的名字**(`stub:Card`):卡片壳现在是 `kit.components.Card` 这个组件,
|
|
70
|
-
// 产物里不会再出现 `wb-card` 这个类名 —— 判据要能认出"用了外壳给的卡壳"。
|
|
71
|
-
// children 要**往下传**:吞掉的话,卡片体内用 kit 组件写的内容就永远渲染不到(节点数会假少)。
|
|
72
219
|
components: new Proxy(
|
|
73
220
|
{},
|
|
74
221
|
{
|
|
@@ -87,23 +234,9 @@ function fakeKit() {
|
|
|
87
234
|
};
|
|
88
235
|
}
|
|
89
236
|
|
|
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) {
|
|
237
|
+
function loadFace(clientFile, makeKit) {
|
|
104
238
|
let captured;
|
|
105
239
|
const fakeWindow = {
|
|
106
|
-
// 浏览器里 `window.location` 永远在;缺了它,读它的客户端代码(含 `?wbMock=1` 那个开关)会先抛。
|
|
107
240
|
location: { search: '', href: 'http://127.0.0.1:3081/' },
|
|
108
241
|
__ModuleLoader__: {
|
|
109
242
|
load: (entry) => {
|
|
@@ -121,7 +254,8 @@ function loadFace(clientFile) {
|
|
|
121
254
|
expose.exports = module.exports;
|
|
122
255
|
const fakeRequire = (id) => {
|
|
123
256
|
if (id === 'react') return fakeReact();
|
|
124
|
-
if (id === '
|
|
257
|
+
if (id === 'react/jsx-runtime' || id === 'react/jsx-dev-runtime') return fakeJsxRuntime();
|
|
258
|
+
if (id === '@dsh-workbench/core/client') return { kit: makeKit() };
|
|
125
259
|
if (id.startsWith('@deepseek-ai/')) return fakeDshPackage();
|
|
126
260
|
throw new Error(`产物 require 了没桩的外部:${id}`);
|
|
127
261
|
};
|
|
@@ -129,23 +263,29 @@ function loadFace(clientFile) {
|
|
|
129
263
|
return { id: captured.id, exports: produced ?? module.exports };
|
|
130
264
|
}
|
|
131
265
|
|
|
132
|
-
// 递归**调用**函数组件 —— 不调用的话,树里只有最外层那个 `type`,
|
|
133
|
-
// 卡片体内的代码一行都没跑(第一次写漏了这步,4 张卡片都只数出 3 个节点)。
|
|
134
|
-
//
|
|
135
|
-
// 顺手把**用到的类名**收集起来:卡片得自己套外壳的卡片契约类(见文件尾那条判据)。
|
|
136
266
|
function render(node, depth, budget) {
|
|
137
267
|
if (node === null || node === undefined || typeof node === 'boolean') return 0;
|
|
138
|
-
if (typeof node === 'string' || typeof node === 'number')
|
|
268
|
+
if (typeof node === 'string' || typeof node === 'number') {
|
|
269
|
+
budget.texts.push(String(node));
|
|
270
|
+
return 1;
|
|
271
|
+
}
|
|
139
272
|
if (Array.isArray(node)) {
|
|
140
273
|
let count = 0;
|
|
141
274
|
for (const one of node) count += render(one, depth, budget);
|
|
142
275
|
return count;
|
|
143
276
|
}
|
|
144
|
-
if (typeof node !== 'object')
|
|
277
|
+
if (typeof node !== 'object') {
|
|
278
|
+
budget.texts.push(String(node));
|
|
279
|
+
return 1;
|
|
280
|
+
}
|
|
145
281
|
if (budget.left <= 0) return 0;
|
|
146
282
|
budget.left -= 1;
|
|
147
283
|
budget.maxDepth = Math.max(budget.maxDepth, depth);
|
|
148
|
-
|
|
284
|
+
for (const [key, value] of Object.entries(node.props ?? {})) {
|
|
285
|
+
if (key === 'key' || key === 'ref') continue;
|
|
286
|
+
if (typeof value === 'string') budget.texts.push(value);
|
|
287
|
+
else if (typeof value === 'number' && Number.isNaN(value)) budget.texts.push('NaN');
|
|
288
|
+
}
|
|
149
289
|
const classes = node.props?.className;
|
|
150
290
|
if (typeof classes === 'string') {
|
|
151
291
|
for (const one of classes.split(/\s+/)) if (one !== '') budget.classNames.add(one);
|
|
@@ -153,7 +293,6 @@ function render(node, depth, budget) {
|
|
|
153
293
|
if (typeof node.type === 'string' && node.type.startsWith('stub:')) {
|
|
154
294
|
budget.stubs.add(node.type.slice('stub:'.length));
|
|
155
295
|
}
|
|
156
|
-
|
|
157
296
|
const children = [...(node.children ?? [])];
|
|
158
297
|
if (typeof node.type === 'function') {
|
|
159
298
|
if (depth > 40) throw new Error('组件递归超过 40 层(自引用?)');
|
|
@@ -165,10 +304,75 @@ function render(node, depth, budget) {
|
|
|
165
304
|
return count;
|
|
166
305
|
}
|
|
167
306
|
|
|
307
|
+
async function shellDictionariesOf() {
|
|
308
|
+
const require = createRequire(import.meta.url);
|
|
309
|
+
const entry = require.resolve('@dsh-workbench/shared');
|
|
310
|
+
const module = await import(pathToFileURL(join(dirname(entry), 'i18n/index.js')).href);
|
|
311
|
+
return module.dictionaries;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function ownOf(dictionaries, language) {
|
|
315
|
+
const one = dictionaries?.[language];
|
|
316
|
+
return typeof one === 'object' && one !== null ? one : {};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function translatorOf(language, messages, shellDictionaries, missing, snapshot) {
|
|
320
|
+
const own = ownOf(messages, language);
|
|
321
|
+
const shell = ownOf(shellDictionaries, language);
|
|
322
|
+
return (key, params) => {
|
|
323
|
+
const template = typeof own[key] === 'string' ? own[key] : shell[key];
|
|
324
|
+
if (typeof template !== 'string') {
|
|
325
|
+
missing.push({ language, key, snapshot });
|
|
326
|
+
return `\u0000missing:${key}\u0000`;
|
|
327
|
+
}
|
|
328
|
+
if (params === undefined || params === null) return template;
|
|
329
|
+
return template.replace(/\{(\w+)\}/g, (whole, name) =>
|
|
330
|
+
name in params ? String(params[name]) : whole,
|
|
331
|
+
);
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function forbiddenHitsOf(texts) {
|
|
336
|
+
const hits = [];
|
|
337
|
+
const seen = new Set();
|
|
338
|
+
for (const text of texts) {
|
|
339
|
+
for (const { token, pattern } of FORBIDDEN) {
|
|
340
|
+
if (!pattern.test(text)) continue;
|
|
341
|
+
const key = `${token}\u0000${text}`;
|
|
342
|
+
if (seen.has(key)) continue;
|
|
343
|
+
seen.add(key);
|
|
344
|
+
hits.push({ token, text });
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return hits;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function shellClientsOf(modulePackages) {
|
|
351
|
+
const found = new Set();
|
|
352
|
+
for (const pkg of modulePackages) {
|
|
353
|
+
const client = shellClientPathOf(pkg.dir);
|
|
354
|
+
if (client !== undefined && existsSync(client)) found.add(client);
|
|
355
|
+
}
|
|
356
|
+
return [...found];
|
|
357
|
+
}
|
|
358
|
+
|
|
168
359
|
export async function run() {
|
|
169
360
|
const modulePackages = discoverModulePackages({ root: process.cwd() });
|
|
170
361
|
const problems = [];
|
|
171
362
|
const rows = [];
|
|
363
|
+
const footnotes = [];
|
|
364
|
+
let degraded = 0;
|
|
365
|
+
|
|
366
|
+
let shellDictionaries;
|
|
367
|
+
try {
|
|
368
|
+
shellDictionaries = await shellDictionariesOf();
|
|
369
|
+
} catch (error) {
|
|
370
|
+
shellDictionaries = undefined;
|
|
371
|
+
footnotes.push(
|
|
372
|
+
`(取不出 @dsh-workbench/shared 的字典:${error instanceof Error ? error.message : String(error)})` +
|
|
373
|
+
' —— 逐态那半跳过(单次全桩渲染照旧跑),因为裸键名判定要模块 ∪ 外壳两份字典',
|
|
374
|
+
);
|
|
375
|
+
}
|
|
172
376
|
|
|
173
377
|
for (const pkg of modulePackages) {
|
|
174
378
|
const name = pkg.manifest.name;
|
|
@@ -178,9 +382,44 @@ export async function run() {
|
|
|
178
382
|
continue;
|
|
179
383
|
}
|
|
180
384
|
|
|
385
|
+
const declared = pkg.manifest.dsh?.workbench?.card ?? null;
|
|
386
|
+
const declaresCard =
|
|
387
|
+
declared !== null && typeof declared.titleKey === 'string' && declared.titleKey !== '';
|
|
388
|
+
|
|
389
|
+
const shellClient = shellClientPathOf(pkg.dir);
|
|
390
|
+
const hasShell = shellClient !== undefined && existsSync(shellClient);
|
|
391
|
+
const current = { snapshot: SNAPSHOTS[0] };
|
|
392
|
+
let realKit;
|
|
393
|
+
let shellProblem;
|
|
394
|
+
if (hasShell && shellDictionaries !== undefined) {
|
|
395
|
+
try {
|
|
396
|
+
realKit = shellKitOf(shellClient, { react: fakeReact(), dshPackage: fakeDshPackage() });
|
|
397
|
+
} catch (error) {
|
|
398
|
+
shellProblem = error instanceof Error ? error.message : String(error);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const makeKit = () => {
|
|
403
|
+
if (realKit === undefined) return fakeKit();
|
|
404
|
+
const hooks = { ...(realKit.hooks ?? {}) };
|
|
405
|
+
hooks.useApiResource = () => current.snapshot.resource;
|
|
406
|
+
// 闸这一格的默认(P1 五态都是它):没查过 ⇒ capability null、gated true。快照带了
|
|
407
|
+
// `capability` 才换成那一份,`gated` 照 `gateOf(capability, 'capability')` 算 —— 判据不当
|
|
408
|
+
// 第二份真相:状态的语义归外壳,这里只是把"外壳会给的那一格"摆出来。
|
|
409
|
+
hooks.useCapabilityGate = (unit) => {
|
|
410
|
+
const capability = current.snapshot.capability?.(unit) ?? null;
|
|
411
|
+
return {
|
|
412
|
+
capability,
|
|
413
|
+
gated: capability === null || capability.status === 'ok' || capability.status === 'unchecked',
|
|
414
|
+
forceProbe: () => {},
|
|
415
|
+
};
|
|
416
|
+
};
|
|
417
|
+
return { ...realKit, hooks };
|
|
418
|
+
};
|
|
419
|
+
|
|
181
420
|
let face;
|
|
182
421
|
try {
|
|
183
|
-
const loaded = loadFace(clientFile);
|
|
422
|
+
const loaded = loadFace(clientFile, makeKit);
|
|
184
423
|
face = loaded.exports;
|
|
185
424
|
} catch (error) {
|
|
186
425
|
problems.push(`${name}: 产物加载不起来 —— ${error.message}`);
|
|
@@ -218,12 +457,6 @@ export async function run() {
|
|
|
218
457
|
}
|
|
219
458
|
|
|
220
459
|
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
460
|
if (!declaresCard) {
|
|
228
461
|
if (typeof card === 'function') {
|
|
229
462
|
problems.push(
|
|
@@ -240,9 +473,6 @@ export async function run() {
|
|
|
240
473
|
continue;
|
|
241
474
|
}
|
|
242
475
|
|
|
243
|
-
// 清单的 `card.titleKey` 是**面板那一格的标题**,它的文案住在模块自己交的字典里
|
|
244
|
-
// (`registerUnit` 那个面的 `messages`,`zh` / `en` 都必填)。键拼错、或字典漏了那一条 ⇒
|
|
245
|
-
// 面板上显示的就是原始键名(`demo.cardTitle`),而且一声不响 —— 这条判据盯它。
|
|
246
476
|
const titleKey = pkg.manifest.dsh?.workbench?.card?.titleKey;
|
|
247
477
|
if (typeof titleKey === 'string' && titleKey !== '') {
|
|
248
478
|
const messages = registered?.messages;
|
|
@@ -263,7 +493,7 @@ export async function run() {
|
|
|
263
493
|
}
|
|
264
494
|
|
|
265
495
|
const props = {
|
|
266
|
-
world: { current: {} },
|
|
496
|
+
world: { current: { sessionId: null, workspace: null } },
|
|
267
497
|
t: (key) => key,
|
|
268
498
|
refreshToken: 0,
|
|
269
499
|
onRefresh: () => {},
|
|
@@ -272,49 +502,154 @@ export async function run() {
|
|
|
272
502
|
onSummary: () => {},
|
|
273
503
|
};
|
|
274
504
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
505
|
+
if (realKit === undefined) {
|
|
506
|
+
const why =
|
|
507
|
+
shellProblem !== undefined
|
|
508
|
+
? `取不出这份外壳的 kit(${shellClient})—— ${shellProblem}`
|
|
509
|
+
: shellDictionaries === undefined
|
|
510
|
+
? '取不出 @dsh-workbench/shared 的字典'
|
|
511
|
+
: '没装外壳(@dsh-workbench/core)';
|
|
512
|
+
let tree;
|
|
513
|
+
try {
|
|
514
|
+
tree = card(props);
|
|
515
|
+
} catch (error) {
|
|
516
|
+
problems.push(`${name}: 空数据下卡片抛了 —— ${error.message}`);
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
if (tree === null || tree === undefined) {
|
|
520
|
+
problems.push(`${name}: 空数据下卡片返回空(界面上就是一片空白)`);
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
const budget = { left: 20000, maxDepth: 0, classNames: new Set(), stubs: new Set(), texts: [] };
|
|
524
|
+
let count;
|
|
525
|
+
try {
|
|
526
|
+
count = render(tree, 0, budget);
|
|
527
|
+
} catch (error) {
|
|
528
|
+
const where = (error.stack ?? '').split('\n').slice(1, 4).join(' | ');
|
|
529
|
+
problems.push(`${name}: 卡片体内的组件抛了 —— ${error.message}\n ${where}`);
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
if (count <= 1) {
|
|
533
|
+
problems.push(`${name}: 卡片只画出一个节点 —— 卡片体没跑(或什么都没画)`);
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
if (!(budget.classNames.has('wb-card') || budget.stubs.has('Card'))) {
|
|
537
|
+
problems.push(
|
|
538
|
+
`${name}: 卡片没套外壳的卡壳 —— 用 kit.components.Card(推荐),或自己写契约类 wb-card + ` +
|
|
539
|
+
`wb-cardBody;裸内容在面板里看起来就"不是一张卡片"`,
|
|
540
|
+
);
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
footnotes.push(`${name}: ${why} —— 逐态渲染跳过,只跑了单次全桩渲染`);
|
|
544
|
+
rows.push(`${name}: 空数据下画得出 ${count} 个节点(最深 ${budget.maxDepth} 层)`);
|
|
545
|
+
degraded += 1;
|
|
280
546
|
continue;
|
|
281
547
|
}
|
|
282
548
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
549
|
+
const missing = [];
|
|
550
|
+
const counts = new Map();
|
|
551
|
+
const textsBySnapshot = new Map();
|
|
552
|
+
const classNames = new Set();
|
|
553
|
+
const stubs = new Set();
|
|
287
554
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
555
|
+
for (const snapshot of SNAPSHOTS) {
|
|
556
|
+
current.snapshot = snapshot;
|
|
557
|
+
counts.set(snapshot.name, []);
|
|
558
|
+
for (const language of LANGUAGES) {
|
|
559
|
+
const budget = { left: 20000, maxDepth: 0, classNames: new Set(), stubs: new Set(), texts: [] };
|
|
560
|
+
const where = `${snapshot.name}(${language})`;
|
|
561
|
+
props.t = translatorOf(
|
|
562
|
+
language,
|
|
563
|
+
registered?.messages,
|
|
564
|
+
shellDictionaries,
|
|
565
|
+
missing,
|
|
566
|
+
snapshot.name,
|
|
567
|
+
);
|
|
568
|
+
props.world = { current: { sessionId: null, workspace: snapshot.workspace } };
|
|
569
|
+
|
|
570
|
+
let tree;
|
|
571
|
+
try {
|
|
572
|
+
tree = card(props);
|
|
573
|
+
} catch (error) {
|
|
574
|
+
problems.push(`${name}: ${where} 卡片抛了 —— ${error.message}`);
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
if (tree === null || tree === undefined) {
|
|
578
|
+
problems.push(`${name}: ${where} 卡片返回空 —— 这一态下卡片没画出来(界面上就是一片空白)`);
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
let count;
|
|
582
|
+
try {
|
|
583
|
+
count = render(tree, 0, budget);
|
|
584
|
+
} catch (error) {
|
|
585
|
+
const stack = (error.stack ?? '').split('\n').slice(1, 4).join(' | ');
|
|
586
|
+
problems.push(`${name}: ${where} 卡片体内的组件抛了 —— ${error.message}\n ${stack}`);
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
if (count <= 1) {
|
|
590
|
+
problems.push(
|
|
591
|
+
`${name}: ${where} 卡片只画出 ${count} 个节点 —— 这一态下卡片体没跑(或只剩一层空壳)`,
|
|
592
|
+
);
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
counts.get(snapshot.name).push(count);
|
|
596
|
+
for (const one of budget.classNames) classNames.add(one);
|
|
597
|
+
for (const one of budget.stubs) stubs.add(one);
|
|
598
|
+
const bucket = textsBySnapshot.get(snapshot.name) ?? [];
|
|
599
|
+
bucket.push(...budget.texts);
|
|
600
|
+
textsBySnapshot.set(snapshot.name, bucket);
|
|
601
|
+
for (const hit of forbiddenHitsOf(budget.texts)) {
|
|
602
|
+
problems.push(
|
|
603
|
+
`${name}: ${where} 屏上出现了 ${hit.token} —— 这一态下卡片把它摆到了文字上。` +
|
|
604
|
+
`命中的那串:${hit.text.slice(0, 120)}`,
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
296
608
|
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
609
|
+
|
|
610
|
+
if (missing.length > 0) {
|
|
611
|
+
const grouped = new Map();
|
|
612
|
+
for (const one of missing) {
|
|
613
|
+
const key = `${one.language}\u0000${one.key}`;
|
|
614
|
+
if (!grouped.has(key)) grouped.set(key, { key: one.key, language: one.language, snapshots: [] });
|
|
615
|
+
const entry = grouped.get(key);
|
|
616
|
+
if (!entry.snapshots.includes(one.snapshot)) entry.snapshots.push(one.snapshot);
|
|
617
|
+
}
|
|
618
|
+
for (const entry of grouped.values()) {
|
|
619
|
+
problems.push(
|
|
620
|
+
`${name}: t('${entry.key}') 在 ${entry.language} 里没有落点(问它的快照:` +
|
|
621
|
+
`${entry.snapshots.join('、')})—— 屏上会显示原始键名。` +
|
|
622
|
+
'命中判定 = 模块 messages ∪ 外壳 dictionaries',
|
|
623
|
+
);
|
|
624
|
+
}
|
|
300
625
|
}
|
|
301
|
-
|
|
302
|
-
|
|
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) {
|
|
626
|
+
|
|
627
|
+
if (!(classNames.has('wb-card') || stubs.has('Card'))) {
|
|
309
628
|
problems.push(
|
|
310
629
|
`${name}: 卡片没套外壳的卡壳 —— 用 kit.components.Card(推荐),或自己写契约类 wb-card + ` +
|
|
311
630
|
`wb-cardBody;裸内容在面板里看起来就"不是一张卡片"`,
|
|
312
631
|
);
|
|
313
632
|
continue;
|
|
314
633
|
}
|
|
315
|
-
|
|
634
|
+
|
|
635
|
+
const per = SNAPSHOTS.map(
|
|
636
|
+
(snapshot) => `${snapshot.name} ${counts.get(snapshot.name).join('/') || '—'}`,
|
|
637
|
+
).join(' · ');
|
|
638
|
+
rows.push(`${name}: ${SNAPSHOTS.length} 态 × zh/en 都画得出 —— ${per} 个节点`);
|
|
639
|
+
|
|
640
|
+
const shapes = new Set(
|
|
641
|
+
SNAPSHOTS.map((snapshot) => [...new Set(textsBySnapshot.get(snapshot.name) ?? [])].sort().join('\n')),
|
|
642
|
+
);
|
|
643
|
+
if (shapes.size === 1) {
|
|
644
|
+
footnotes.push(
|
|
645
|
+
`${name}: ${SNAPSHOTS.length} 个快照渲染出来的文本完全一样 —— 不判红,只当线索:它的取数可能没走 ` +
|
|
646
|
+
'kit.hooks.useApiResource,或这张卡片本来就不随态分叉',
|
|
647
|
+
);
|
|
648
|
+
}
|
|
316
649
|
}
|
|
317
650
|
|
|
651
|
+
const shellClients = shellDictionaries === undefined ? [] : shellClientsOf(modulePackages);
|
|
652
|
+
|
|
318
653
|
if (problems.length > 0) {
|
|
319
654
|
return { status: 'fail', notes: [], problems };
|
|
320
655
|
}
|
|
@@ -328,9 +663,19 @@ export async function run() {
|
|
|
328
663
|
return {
|
|
329
664
|
status: 'pass',
|
|
330
665
|
notes: [
|
|
331
|
-
`${rows.length}
|
|
332
|
-
|
|
666
|
+
`${rows.length} 个模块过了卡片这一关:` +
|
|
667
|
+
(degraded === 0
|
|
668
|
+
? `${SNAPSHOTS.length} 态 × zh/en 各渲染一遍`
|
|
669
|
+
: `逐态那半这次有 ${degraded} 个模块跳过(没装外壳 / 取不出外壳,见下),其余 ${SNAPSHOTS.length} 态 × zh/en 各渲染一遍`) +
|
|
670
|
+
' —— 不抛、非空白、文字里没有 ' +
|
|
671
|
+
'undefined / NaN / [object Object] / null、没有裸键名、套了外壳的卡壳、清单的 titleKey 在两语字典里都有落点。' +
|
|
672
|
+
`(断言的是"${SNAPSHOTS.length} 种外壳侧输入下都退化得漂亮",不是"画的是哪一态那一屏" —— 屏的对错归模块自己的 ` +
|
|
673
|
+
'card-states.test.ts 与 design.json 状态矩阵)',
|
|
674
|
+
...(shellClients.length === 0
|
|
675
|
+
? []
|
|
676
|
+
: [`逐态用的真壳(模块仓里装的那份 client.js):${shellClients.join('、')}`]),
|
|
333
677
|
...rows,
|
|
678
|
+
...footnotes,
|
|
334
679
|
],
|
|
335
680
|
problems: [],
|
|
336
681
|
};
|
package/src/checks/kit.mjs
CHANGED
|
@@ -21,9 +21,9 @@
|
|
|
21
21
|
// 打印与退出码归 `../check.mjs` 那一层。
|
|
22
22
|
|
|
23
23
|
import { existsSync, readFileSync } from 'node:fs';
|
|
24
|
-
import {
|
|
25
|
-
import { dirname, join } from 'node:path';
|
|
24
|
+
import { join } from 'node:path';
|
|
26
25
|
import { discoverModulePackages } from '../discover.mjs';
|
|
26
|
+
import { shellClientPathOf, shellKitOf } from './shell-artifact.mjs';
|
|
27
27
|
|
|
28
28
|
const SHELL = '@dsh-workbench/core';
|
|
29
29
|
const KIT_SPECIFIER = '@dsh-workbench/core/client';
|
|
@@ -59,44 +59,6 @@ function kitUsageOf(text) {
|
|
|
59
59
|
return used;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
function shellKitOf(clientPath) {
|
|
63
|
-
const stub = () => null;
|
|
64
|
-
const seed = new Proxy(
|
|
65
|
-
{ default: stub },
|
|
66
|
-
{ get: (target, key) => (key in target ? target[key] : stub) },
|
|
67
|
-
);
|
|
68
|
-
// react 得给一个**能 extends 的类**:外壳自己那段 `class CardBoundary extends Component`
|
|
69
|
-
// 在工厂体里就跑(错误边界只能这么写),换成箭头函数会当场 "not a constructor"。
|
|
70
|
-
const react = new Proxy(
|
|
71
|
-
{ default: stub, Component: class {} },
|
|
72
|
-
{ get: (target, key) => (key in target ? target[key] : stub) },
|
|
73
|
-
);
|
|
74
|
-
let exports;
|
|
75
|
-
const load = (registration) => {
|
|
76
|
-
exports = registration.factory((specifier) => {
|
|
77
|
-
if (specifier === 'react' || specifier === 'react/jsx-runtime' || specifier === 'react-dom') return react;
|
|
78
|
-
if (specifier.startsWith('@deepseek-ai/')) return seed;
|
|
79
|
-
if (specifier === SHELL) return exports;
|
|
80
|
-
throw new Error(`外壳产物里出现了一个不认识的 require("${specifier}")`);
|
|
81
|
-
});
|
|
82
|
-
};
|
|
83
|
-
const window = { __ModuleLoader__: { load } };
|
|
84
|
-
// 要的就是"按浏览器加载器那样把这份产物跑一遍",静态解析会骗人(no-new-func 在本仓的 eslint 配置里没开)
|
|
85
|
-
new Function('window', readFileSync(clientPath, 'utf8'))(window);
|
|
86
|
-
return exports?.kit ?? {};
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function shellClientPathOf(pkgDir) {
|
|
90
|
-
const override = process.env.DSH_WB_CORE_CLIENT;
|
|
91
|
-
if (override !== undefined && override.trim() !== '') return override.trim();
|
|
92
|
-
try {
|
|
93
|
-
const require = createRequire(join(pkgDir, 'package.json'));
|
|
94
|
-
return join(dirname(require.resolve(`${SHELL}/package.json`)), 'client.js');
|
|
95
|
-
} catch {
|
|
96
|
-
return undefined;
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
62
|
function floorOf(range) {
|
|
101
63
|
if (typeof range !== 'string') return undefined;
|
|
102
64
|
const match = /(\d+)\.(\d+)\.(\d+)/.exec(range);
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const SHELL = '@dsh-workbench/core';
|
|
6
|
+
|
|
7
|
+
function defaultReact() {
|
|
8
|
+
const stub = () => null;
|
|
9
|
+
return new Proxy(
|
|
10
|
+
{ default: stub, Component: class {} },
|
|
11
|
+
{ get: (target, key) => (key in target ? target[key] : stub) },
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function defaultSeed() {
|
|
16
|
+
const stub = () => null;
|
|
17
|
+
return new Proxy(
|
|
18
|
+
{ default: stub },
|
|
19
|
+
{ get: (target, key) => (key in target ? target[key] : stub) },
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function shellClientPathOf(pkgDir) {
|
|
24
|
+
const override = process.env.DSH_WB_CORE_CLIENT;
|
|
25
|
+
if (override !== undefined && override.trim() !== '') return override.trim();
|
|
26
|
+
try {
|
|
27
|
+
const require = createRequire(join(pkgDir, 'package.json'));
|
|
28
|
+
return join(dirname(require.resolve(`${SHELL}/package.json`)), 'client.js');
|
|
29
|
+
} catch {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function shellKitOf(clientPath, options = {}) {
|
|
35
|
+
const react = options.react ?? defaultReact();
|
|
36
|
+
const seed = options.dshPackage ?? defaultSeed();
|
|
37
|
+
let exports;
|
|
38
|
+
const load = (registration) => {
|
|
39
|
+
exports = registration.factory((specifier) => {
|
|
40
|
+
if (specifier === 'react' || specifier === 'react/jsx-runtime' || specifier === 'react-dom') {
|
|
41
|
+
return react;
|
|
42
|
+
}
|
|
43
|
+
if (specifier.startsWith('@deepseek-ai/')) return seed;
|
|
44
|
+
if (specifier === SHELL) return exports;
|
|
45
|
+
throw new Error(`外壳产物里出现了一个不认识的 require("${specifier}")`);
|
|
46
|
+
});
|
|
47
|
+
};
|
|
48
|
+
const window = { __ModuleLoader__: { load } };
|
|
49
|
+
new Function('window', readFileSync(clientPath, 'utf8'))(window);
|
|
50
|
+
return exports?.kit ?? {};
|
|
51
|
+
}
|
package/src/checks/style.mjs
CHANGED
|
@@ -21,6 +21,7 @@ import { dirname, join, relative, resolve } from 'node:path';
|
|
|
21
21
|
import { fileURLToPath } from 'node:url';
|
|
22
22
|
import { shellRulesIn } from '../css-shell.mjs';
|
|
23
23
|
import { discoverModulePackages } from '../discover.mjs';
|
|
24
|
+
import { auditFontSizes, fontScaleNotes, fontScaleOf } from '../font-scale.mjs';
|
|
24
25
|
|
|
25
26
|
// 外链一律走 kit 的 `Link`:它自带的样式(正文色 / hover 下划线 / 12px / 那个外链小箭头)是唯一一份。
|
|
26
27
|
// 手写 `<a>` / `h('a', …)` 拿到的是浏览器默认样式,而且每个人写的都不一样(这正是"同一个东西样式不一样"的来源)。
|
|
@@ -39,17 +40,14 @@ const BARE_CONTROLS = [
|
|
|
39
40
|
const NATIVE_ONLY_INPUT = /type\s*[:=]\s*['"](?:hidden|file)['"]/;
|
|
40
41
|
|
|
41
42
|
// 历史豁免:**已知**的手写控件,各有各的理由,跟着各自的迁移走 ——
|
|
42
|
-
// · `module-cmdb/src/views/ChooseEnvsBody.tsx`(一处:`role="radio"` 那一列选项行)
|
|
43
|
-
// = 对应 kit 的 `ChoiceGroup`(不是 `SelectField`);换不换是那个模块自己的一次改动
|
|
44
|
-
// (分组标题与标记是它的排版)。
|
|
45
43
|
// ①(ADR-0051 §4 那次)`module-cmdb/src/views/MenuField.tsx` 的两处手写钮已经迁完
|
|
46
44
|
// (2026-10-17,外壳发到 0.2.12 之后;cmdb `a1a37e60` / argocd `d85627a7`),那一行按棘轮删掉了 ——
|
|
47
45
|
// 留着这段是因为它就是这条纪律的由来:模块的 peer 下限得先有 `SelectField`,否则装上就崩。
|
|
46
|
+
// ②(ADR-0052 §10 那次)`module-cmdb/src/views/ChooseEnvsBody.tsx` 的 `role="radio"` 选项行
|
|
47
|
+
// 已经迁 `ChoiceGroup`(2026-10-17,外壳 0.2.13 之后),这一行同样按棘轮删掉了。
|
|
48
48
|
// 这是**棘轮**:迁完一处就删一行,别只加不减(名单长起来等于这条判据没有)。
|
|
49
49
|
// 键是 `<包名>:<相对模块根的文件路径>`。
|
|
50
|
-
const CONTROL_EXEMPT = new Set([
|
|
51
|
-
'@pagoda-dsh-workbench/module-cmdb:src/views/ChooseEnvsBody.tsx',
|
|
52
|
-
]);
|
|
50
|
+
const CONTROL_EXEMPT = new Set([]);
|
|
53
51
|
|
|
54
52
|
// ── F1「手写的标签+控件行」与 F2「自己管行距」:都该走 FormRow(s)(ADR-0052) ──────────
|
|
55
53
|
//
|
|
@@ -76,6 +74,24 @@ const FORM_LAYER = /<(FormRows?|Field)\b/;
|
|
|
76
74
|
const NOT_A_FORM = /card|list|item|issue|env|dot|sub|chip|badge|tag|tab|nav|menu/i;
|
|
77
75
|
const ZERO_LENGTH = /^(?:0|0px|0rem|0em|0%|none|auto)$/i;
|
|
78
76
|
|
|
77
|
+
// ── F3「手写的操作区」:该走 `FormActions`(ADR-0054) ─────────────────────────────
|
|
78
|
+
//
|
|
79
|
+
// 口径([ADR-0054](../../../docs/adr/0054-the-action-area-is-one-component.md)):操作区那一层
|
|
80
|
+
// (主操作在右 / 一颗 primary / 危险动作紧挨它左侧 / 间距 / 窄容器换行)从前没有主,只有一条
|
|
81
|
+
// 借来的槽类 `.wb-formActions`(它只管右对齐 + gap)。于是顺序各摆各的 —— 2026-10-17 用户在
|
|
82
|
+
// argocd 绑定身份那屏说「这两个按钮的位置换一下」就是现场(pduiv2 `b7977862`)。
|
|
83
|
+
//
|
|
84
|
+
// 判据:模块源码(`.tsx`,测试文件除外)里出现 **`wb-formActions`** ⇒ 红,改用 kit 的 `FormActions`。
|
|
85
|
+
// **为什么只钉这一条(宁可漏报)**:`wb-formActions` 只为操作区存在,出现它 = 明确在手写操作区,
|
|
86
|
+
// 零误报。更宽的启发式("同一容器里两颗以上 `Button`")会把**合法的并排小钮**判红 —— 实测
|
|
87
|
+
// `module-argocd:src/views/EnvRows.tsx` 有三处 `.actions` 容器装的是平台跳转图标钮 + 转会话 chip +
|
|
88
|
+
// 同步钮(行级图标簇,没有 primary,不归 `FormActions`),`confirmActions` / `releaseConfirmActions` /
|
|
89
|
+
// `envBlockActions` 这些类名也都不该判红。
|
|
90
|
+
// **已知漏报**:模块自己搭容器(`<div className={styles.actions}>` 里两颗 `Button`)而不借槽类时,
|
|
91
|
+
// 这一版不判 —— 只能靠评审 / SKILL 抓;`FormActions` 里那些类名带 `form` 的(如 `.formActions`)
|
|
92
|
+
// 若自己写 `gap` / 上下 `margin`,还会被 F2 顺带抓到。
|
|
93
|
+
const ACTION_AREA_CLASS = /wb-formActions/;
|
|
94
|
+
|
|
79
95
|
/** 类名里有没有 `field(s)` / `form(s)` 这个词(按 camelCase 切词,`platform` 不算)。 */
|
|
80
96
|
function isFormContainer(token) {
|
|
81
97
|
return token.split(/(?=[A-Z])/).some((word) => /^(?:fields?|forms?)$/i.test(word))
|
|
@@ -169,13 +185,18 @@ function handwrittenRowsIn(text) {
|
|
|
169
185
|
// 这是**棘轮**:迁一处删一行。而且这一版会**替你发现哪一行已经不用留了** ——
|
|
170
186
|
// 某个包在眼前(被判据扫到)却一处都不再命中时,那条豁免当场报红(见 run() 里的 staleRows)。
|
|
171
187
|
// 键与 `CONTROL_EXEMPT` 同形:`<包名>:<相对模块根的文件路径>`。
|
|
172
|
-
const FORM_ROW_EXEMPT = new Map([
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
188
|
+
const FORM_ROW_EXEMPT = new Map([]);
|
|
189
|
+
|
|
190
|
+
// 历史豁免:**已知**的手写操作区(借了 `wb-formActions` 槽类手摆按钮)。它跟上面两张又是
|
|
191
|
+
// **另一件事、另一个迁移时点**:等的是外壳把 `FormActions` 这一层发出来(ADR-0054 §12)。
|
|
192
|
+
// 理由都指向 ADR-0054:§2 是四条规则(顺序 / 颗数 / 危险 / 窄容器)、§9 是判据为什么只钉这一条、
|
|
193
|
+
// §12 是发版后的迁移顺序。这是**棘轮**:迁一处删一行;某个包在眼前却一处都不再命中时,
|
|
194
|
+
// 那条豁免当场报红(见 run() 里的 staleActionRows)。键同形:`<包名>:<相对模块根的文件路径>`。
|
|
195
|
+
// 2026-10-17 实测(只读 pduiv2):8 处 / 6 个文件 —— 全部收在这里,一条都不许写成"全部"。
|
|
196
|
+
// 2026-10-17 清空:外壳 0.2.14(含 `FormActions`)发出后,那 8 处逐条迁完(ADR-0054 §12 的 runbook),
|
|
197
|
+
// 按棘轮一行一行删到空。留着这张空表是这条判据的棘轮位:谁再借 `wb-formActions` 手摆操作区,
|
|
198
|
+
// 直接报红(不再有豁免这一层)。
|
|
199
|
+
const FORM_ACTIONS_EXEMPT = new Map([]);
|
|
179
200
|
|
|
180
201
|
/** 裸控件出现的位置 —— 逐行看不了(一个元素会跨行),所以在全文里按标签找,再回推行号。 */
|
|
181
202
|
function bareControlsIn(text) {
|
|
@@ -210,6 +231,48 @@ const SOURCE_COMPONENTS = fileURLToPath(new URL('../../../shared/src/components'
|
|
|
210
231
|
const INSTALLED_BOARD = join('node_modules', '@dsh-workbench', 'core', 'dist', 'board.css');
|
|
211
232
|
const INSTALLED_COMPONENTS = join('node_modules', '@dsh-workbench', 'shared', 'src', 'components');
|
|
212
233
|
|
|
234
|
+
// ── 字号口径(S5,用户 2026-10-17 定的那条):只报「刻度外手搓字面值」,**黄,不判红** ─────────
|
|
235
|
+
//
|
|
236
|
+
// 口径、刻度出处与"将来怎么变红"写在 `.scratch/font-scale/spec.md`(这份 note 的指路目标)。
|
|
237
|
+
// 扫三个来源:外壳自己的 `src`、shared 的 `src`、本次发现的模块包;外壳两处与上面那份契约
|
|
238
|
+
// (`SOURCE_BOARD` / `SOURCE_COMPONENTS`)走同一条路 —— 源码在隔壁时读源码,仓外的模块工作区
|
|
239
|
+
// 读不到就少扫那一半(note 里只报实际扫到的)。测试文件不扫(用例里那些是断言字符串,不是界面字号)。
|
|
240
|
+
//
|
|
241
|
+
// 为什么落在这一条上:`check:style` 今天就有"通过时也打 note"的做法(豁免表那两行),
|
|
242
|
+
// 这里只是又一条同类 note;**不新增判据、不改退出码、不加开关**。外壳 CSS 那边本来就有红判据
|
|
243
|
+
// (arch-guard 的 D12:`font-size` 字面值必须写 `var(...)`),所以那半边的 0 命中是"红已经守住"
|
|
244
|
+
// 的结果;这份黄清单补的是 D12 看不到的地方:`.tsx` 内联 `fontSize`、shared、以及模块包。
|
|
245
|
+
const SOURCE_SHELL_SRC = fileURLToPath(new URL('../../../workbench/src', import.meta.url));
|
|
246
|
+
const SOURCE_SHARED_SRC = fileURLToPath(new URL('../../../shared/src', import.meta.url));
|
|
247
|
+
const SHELL_FONT_SCALE_FILES = [
|
|
248
|
+
fileURLToPath(new URL('../../../workbench/src/shared/styles/tokens/scale.css', import.meta.url)),
|
|
249
|
+
fileURLToPath(new URL('../../../workbench/src/shared/styles/tokens.css', import.meta.url)),
|
|
250
|
+
];
|
|
251
|
+
const FONT_DOC = '.scratch/font-scale/spec.md';
|
|
252
|
+
|
|
253
|
+
/** 包名(读 package.json;读不到就用兜底名,别让一条 note 把判据带崩)。 */
|
|
254
|
+
function packageNameOf(dir, fallback) {
|
|
255
|
+
try {
|
|
256
|
+
const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
|
|
257
|
+
return typeof manifest.name === 'string' ? manifest.name : fallback;
|
|
258
|
+
} catch {
|
|
259
|
+
return fallback;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** 字号口径要扫的根:外壳 + shared(源码在隔壁才有)+ 本次发现的模块包。 */
|
|
264
|
+
function fontRootsIn(modules) {
|
|
265
|
+
const roots = [];
|
|
266
|
+
for (const [dir, fallback] of [
|
|
267
|
+
[SOURCE_SHELL_SRC, '@dsh-workbench/core'],
|
|
268
|
+
[SOURCE_SHARED_SRC, '@dsh-workbench/shared'],
|
|
269
|
+
]) {
|
|
270
|
+
if (existsSync(dir)) roots.push({ label: packageNameOf(join(dir, '..'), fallback), dir });
|
|
271
|
+
}
|
|
272
|
+
for (const pkg of modules) roots.push({ label: pkg.manifest.name, dir: pkg.dir });
|
|
273
|
+
return roots;
|
|
274
|
+
}
|
|
275
|
+
|
|
213
276
|
function installedUnder(dir, tail) {
|
|
214
277
|
let at = resolve(dir);
|
|
215
278
|
for (;;) {
|
|
@@ -274,8 +337,10 @@ export async function run() {
|
|
|
274
337
|
const anchors = [];
|
|
275
338
|
const controls = [];
|
|
276
339
|
const rows = [];
|
|
340
|
+
const actionAreas = [];
|
|
277
341
|
// 这一轮"命中过"的 `包:文件`(含被豁免的)—— 豁免表里那些不再命中的行靠它发现(棘轮)。
|
|
278
342
|
const formRowHits = new Set();
|
|
343
|
+
const actionAreaHits = new Set();
|
|
279
344
|
const modules = discoverModulePackages({ root: process.cwd() });
|
|
280
345
|
const contracts = new Map(modules.map((pkg) => [pkg.dir, contractOf(pkg.dir)]));
|
|
281
346
|
for (const pkg of modules) {
|
|
@@ -345,6 +410,19 @@ export async function run() {
|
|
|
345
410
|
);
|
|
346
411
|
}
|
|
347
412
|
}
|
|
413
|
+
const actionKey = `${pkg.manifest.name}:${relative(pkg.dir, file)}`;
|
|
414
|
+
const exemptActions = FORM_ACTIONS_EXEMPT.has(actionKey);
|
|
415
|
+
lines.forEach((line, index) => {
|
|
416
|
+
if (!ACTION_AREA_CLASS.test(line)) return;
|
|
417
|
+
actionAreaHits.add(actionKey);
|
|
418
|
+
if (exemptActions) return;
|
|
419
|
+
actionAreas.push(
|
|
420
|
+
`${pkg.manifest.name}: ${relative(pkg.dir, file)}:${index + 1} 手写了操作区(借了外壳的槽类 wb-formActions)—— ` +
|
|
421
|
+
`用 kit 的 FormActions(ADR-0054 §2:主操作在右、一个区一颗 primary、danger 紧挨它左侧且有二次确认、` +
|
|
422
|
+
'间距与窄容器换行都归它):' +
|
|
423
|
+
`${line.trim().slice(0, 80)}`,
|
|
424
|
+
);
|
|
425
|
+
});
|
|
348
426
|
if (contract === null) continue;
|
|
349
427
|
lines.forEach((line, index) => {
|
|
350
428
|
for (const match of line.matchAll(CLASS_TOKEN)) {
|
|
@@ -397,9 +475,17 @@ export async function run() {
|
|
|
397
475
|
`${key} 还在 FORM_ROW_EXEMPT 里,但一处都不再命中 —— 按棘轮删掉这一行(原由:${reason})`,
|
|
398
476
|
);
|
|
399
477
|
}
|
|
478
|
+
const staleActionRows = [];
|
|
479
|
+
for (const [key, reason] of FORM_ACTIONS_EXEMPT) {
|
|
480
|
+
const owner = key.slice(0, key.indexOf(':'));
|
|
481
|
+
if (!present.has(owner) || actionAreaHits.has(key)) continue;
|
|
482
|
+
staleActionRows.push(
|
|
483
|
+
`${key} 还在 FORM_ACTIONS_EXEMPT 里,但一处都不再命中 —— 按棘轮删掉这一行(原由:${reason})`,
|
|
484
|
+
);
|
|
485
|
+
}
|
|
400
486
|
|
|
401
487
|
if (bindings.length + problems.length + borrowed.length + anchors.length + controls.length
|
|
402
|
-
+ rows.length + staleRows.length > 0) {
|
|
488
|
+
+ rows.length + actionAreas.length + staleRows.length + staleActionRows.length > 0) {
|
|
403
489
|
const notes = [];
|
|
404
490
|
if (bindings.length > 0) {
|
|
405
491
|
notes.push(
|
|
@@ -443,17 +529,42 @@ export async function run() {
|
|
|
443
529
|
'换 `<FormRow label={…}>{控件}</FormRow>`,行距一起归它。窄口径与已知漏报见 style.mjs 里那两段注释)',
|
|
444
530
|
);
|
|
445
531
|
}
|
|
532
|
+
if (actionAreas.length > 0) {
|
|
533
|
+
notes.push(
|
|
534
|
+
`${actionAreas.length} 处是「手写了操作区(借了外壳的槽类 wb-formActions)」:(ADR-0054:` +
|
|
535
|
+
'操作区那一层归 `FormActions` —— 顺序(主操作在右、danger 紧挨它左侧)、一个区一颗 `primary`、' +
|
|
536
|
+
'间距、窄容器换行都由组件定;调用点只给"有哪些动作、哪个是主要、点了做什么"。' +
|
|
537
|
+
'`wb-formActions` 这个槽类只为操作区存在,出现它就是自己摆了一套:换 `<FormActions actions={…} />`。' +
|
|
538
|
+
'窄口径与已知漏报见 style.mjs 里那一段注释)',
|
|
539
|
+
);
|
|
540
|
+
}
|
|
446
541
|
if (staleRows.length > 0) {
|
|
447
542
|
notes.push(
|
|
448
543
|
`${staleRows.length} 条 FORM_ROW_EXEMPT 已经不命中了:(棘轮:迁完一处删一行 —— ` +
|
|
449
544
|
'一个包迁完 FormRow 之后,它那几行豁免要跟着删,别留着让表长起来)',
|
|
450
545
|
);
|
|
451
546
|
}
|
|
452
|
-
|
|
547
|
+
if (staleActionRows.length > 0) {
|
|
548
|
+
notes.push(
|
|
549
|
+
`${staleActionRows.length} 条 FORM_ACTIONS_EXEMPT 已经不命中了:(棘轮:迁完一处删一行 —— ` +
|
|
550
|
+
'一个包迁完 FormActions 之后,它那几行豁免要跟着删,别留着让表长起来)',
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
// 各类问题各自成串,按原来的输出顺序(绑定 → 规则 → 借用 → 裸 <a> → 裸控件 → 表单行 → 操作区 → 过期豁免)拼在一起。
|
|
453
554
|
return {
|
|
454
555
|
status: 'fail',
|
|
455
556
|
notes,
|
|
456
|
-
problems: [
|
|
557
|
+
problems: [
|
|
558
|
+
...bindings,
|
|
559
|
+
...problems,
|
|
560
|
+
...borrowed,
|
|
561
|
+
...anchors,
|
|
562
|
+
...controls,
|
|
563
|
+
...rows,
|
|
564
|
+
...actionAreas,
|
|
565
|
+
...staleRows,
|
|
566
|
+
...staleActionRows,
|
|
567
|
+
],
|
|
457
568
|
};
|
|
458
569
|
}
|
|
459
570
|
|
|
@@ -486,9 +597,23 @@ export async function run() {
|
|
|
486
597
|
formRowHits.size === 0
|
|
487
598
|
? '没有手写的「标签+控件」行,也没有模块自己管的行距'
|
|
488
599
|
: `表单「行」这一层还有 ${formRowHits.size} 个文件在 FORM_ROW_EXEMPT 里等外壳发版后迁(棘轮:迁一处删一行)`;
|
|
600
|
+
const actionNote =
|
|
601
|
+
actionAreaHits.size === 0
|
|
602
|
+
? '没有手写的操作区'
|
|
603
|
+
: `操作区这一层还有 ${actionAreaHits.size} 个文件在 FORM_ACTIONS_EXEMPT 里等外壳发版后迁(棘轮:迁一处删一行)`;
|
|
604
|
+
// 黄的那一条:各类条数 + 涉及文件数 + 最多十来行明细 + 指路文档(口径见上面那一段注释)。
|
|
605
|
+
const fontScale = fontScaleOf(SHELL_FONT_SCALE_FILES);
|
|
606
|
+
const fontNotes = fontScaleNotes(
|
|
607
|
+
auditFontSizes({ roots: fontRootsIn(modules), scale: fontScale }),
|
|
608
|
+
fontScale,
|
|
609
|
+
{ doc: FONT_DOC },
|
|
610
|
+
);
|
|
489
611
|
return {
|
|
490
612
|
status: 'pass',
|
|
491
|
-
notes: [
|
|
613
|
+
notes: [
|
|
614
|
+
`${modules.length} 个模块都没有定义外壳前缀(wb-*)的 CSS 规则;${contractNote};${exemptNote};${actionNote}`,
|
|
615
|
+
...fontNotes,
|
|
616
|
+
],
|
|
492
617
|
problems: [],
|
|
493
618
|
};
|
|
494
619
|
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// 字号口径(S5,用户 2026-10-17 定的那条):**不禁大字号,只禁"刻度外手搓"**。
|
|
2
|
+
//
|
|
3
|
+
// 口径原文:「某个卡片的重点数字大是合法的(例如套餐余额那个 28px),只禁刻度外手搓。
|
|
4
|
+
// 所以:先把**刻度**从令牌定义里读出来(那是唯一真相),再列出不在刻度上、且不是引用令牌的字面值。」
|
|
5
|
+
//
|
|
6
|
+
// 这个模块只**读**、只**归类**、只**拼 note**:它不判红、不改退出码、没有开关、也不写文件。
|
|
7
|
+
// 今天这份清单落在 `checks/style.mjs` 通过时的那几行 note 上(黄);真做成红要等棘轮那一半
|
|
8
|
+
// 想清楚(口径与将来的形态写在 `.scratch/font-scale/spec.md`)。
|
|
9
|
+
//
|
|
10
|
+
// 三类的口径(同名同义,全程只用这三个词):
|
|
11
|
+
// a) 引用令牌 —— `var(--dsw-font-*)` / `var(--wb-font-*)`,合规,只报总数;
|
|
12
|
+
// b) 刻度外字面值 —— 数值不在刻度上,**这份就是要的清单**;
|
|
13
|
+
// c) 刻度上字面值 —— 数值在刻度上但没写成 `var(...)`:最容易收进令牌的一批,但**不是错**。
|
|
14
|
+
//
|
|
15
|
+
// 为什么刻度要"读"而不是写死:令牌定义是唯一真相。外壳侧现读 `tokens/scale.css` 与
|
|
16
|
+
// `tokens.css`;dsh 侧在外壳仓里解析不到(见下面 DSH_ALIAS_FONT_SIZES 那段),那一半是照抄的常量。
|
|
17
|
+
// 两边都不发明档位 —— dsh 没有的档写在外壳自己的 token 里才算数。
|
|
18
|
+
|
|
19
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
20
|
+
import { join, relative } from 'node:path';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* dsh 别名刻度 —— `--dsw-font-*-font-size` 的**值**,7 档。
|
|
24
|
+
*
|
|
25
|
+
* 出处:装出来的 dsh 那份 `@deepseek-ai/dsh-client-ui-theme/lib/client.js:1425`
|
|
26
|
+
* (2026-10-17 实测,dsh 0.2.1;那一整行是 CSS 变量声明),逐条抄的值:
|
|
27
|
+
* `--dsw-font-xxxs-11-font-size: 11px` · `--dsw-font-xxs-12-font-size: 12px` ·
|
|
28
|
+
* `--dsw-font-xs-13-font-size: 13px` · `--dsw-font-s-14-font-size: 14px` ·
|
|
29
|
+
* `--dsw-font-base-16-font-size: 16px` · `--dsw-font-l-20-font-size: 20px` ·
|
|
30
|
+
* `--dsw-font-xl-24-font-size: 24px`
|
|
31
|
+
* 两个坑,抄的时候都按**值**收、不按名字:
|
|
32
|
+
* · `--dsw-font-m-18` 的值其实是 **16px**(名字与值不一致),所以刻度里没有 18 这一档;
|
|
33
|
+
* · markdown 那几个(h1 `calc(21px + delta)` / h2 19 / h3 18 / small 12 …)是**内容字号**,
|
|
34
|
+
* 跟着用户的 `--dsh-content-font-size` 走,不是面板排版的档,不收。
|
|
35
|
+
*
|
|
36
|
+
* 为什么是常量而不是现读:外壳仓里根本没有 dsh 的 theme 包(只有 `dsh-client-ui-primitives`),
|
|
37
|
+
* `require.resolve` 直接 MODULE_NOT_FOUND;`--dsw-*` 是 dsh 实例在**运行时**注入的,源码树里没有定义。
|
|
38
|
+
* 哪天真要它现读(在装齐 dsh 的环境里):resolve 到 theme 之后解析同一批 `-font-size` 的值,
|
|
39
|
+
* 再把这份常量删掉 —— 别让它长成第二份真相。
|
|
40
|
+
*/
|
|
41
|
+
export const DSH_ALIAS_FONT_SIZES = [11, 12, 13, 14, 16, 20, 24];
|
|
42
|
+
|
|
43
|
+
/** 外壳自己的字号令牌(`--wb-font-*: <n>px`)—— 刻度定义,不是手搓值。 */
|
|
44
|
+
const SHELL_FONT_TOKEN = /--wb-font-[a-z0-9-]+\s*:\s*([0-9]+(?:\.[0-9]+)?)px/g;
|
|
45
|
+
|
|
46
|
+
/** 从外壳的令牌文件里读出它自己的那几档(文件不在场就跳过:仓外的工作区读不到)。 */
|
|
47
|
+
export function shellFontScaleIn(files) {
|
|
48
|
+
const sizes = new Set();
|
|
49
|
+
for (const file of files) {
|
|
50
|
+
if (!existsSync(file)) continue;
|
|
51
|
+
for (const hit of readFileSync(file, 'utf8').matchAll(SHELL_FONT_TOKEN)) {
|
|
52
|
+
sizes.add(Number(hit[1]));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return sizes;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 合起来的刻度:dsh 别名那 7 档 + 外壳自己 token 里的档(现读)。 */
|
|
59
|
+
export function fontScaleOf(shellTokenFiles = []) {
|
|
60
|
+
return new Set([...DSH_ALIAS_FONT_SIZES, ...shellFontScaleIn(shellTokenFiles)]);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const CSS_FONT_SIZE = /(?:^|[;{\s])font-size\s*:\s*([^;{}]+)/g;
|
|
64
|
+
const CSS_FONT_SHORTHAND = /(?:^|[;{\s])font\s*:\s*([^;{}]+)/g;
|
|
65
|
+
const JS_FONT_SIZE = /\bfontSize\s*[:=]\s*\{?\s*(['"]?)([^,}'"\n]+)\1\s*\}?/g;
|
|
66
|
+
const FONT_TOKEN_REF = /var\(\s*(--[a-z0-9-]*font[a-z0-9-]*)/g;
|
|
67
|
+
// `--dsw-font-family` / `--dsw-font-mono` 也带 `font`,但它们是字族不是字号 ——
|
|
68
|
+
// `font: 11px/18px var(--dsw-font-family)` 那行的字号是字面的 11px,不能算"引用令牌"。
|
|
69
|
+
const FONT_FAMILY_TOKEN = /font-(?:family|mono)/;
|
|
70
|
+
const NUMERIC_VALUE = /^([0-9]*\.?[0-9]+)(px|rem|em|%)?$/;
|
|
71
|
+
|
|
72
|
+
/** 值里引用的**字号**令牌名(排掉字族/等宽那两族)。 */
|
|
73
|
+
function sizeTokenRefs(value) {
|
|
74
|
+
const names = [];
|
|
75
|
+
for (const hit of value.matchAll(FONT_TOKEN_REF)) {
|
|
76
|
+
if (!FONT_FAMILY_TOKEN.test(hit[1])) names.push(hit[1]);
|
|
77
|
+
}
|
|
78
|
+
return names;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 把一个字号声明的值分成 ref / size / relative / keyword。
|
|
83
|
+
* `relative` 是 `em` / `%`(跟着父级走,判不了刻度外)、`keyword` 是 `inherit` / `unset` 这类。
|
|
84
|
+
* `rem` 按 1rem = 16px 折(本仓没有根字号覆盖;dsh 也没改 `html` 的 font-size)。
|
|
85
|
+
*/
|
|
86
|
+
export function classifyFontValue(value) {
|
|
87
|
+
const raw = String(value).trim();
|
|
88
|
+
const refs = sizeTokenRefs(raw);
|
|
89
|
+
if (refs.length > 0) return { kind: 'ref', refs };
|
|
90
|
+
const first = (raw.split(/\s+/)[0] ?? '').replace(/\/.*$/, '');
|
|
91
|
+
const hit = NUMERIC_VALUE.exec(first);
|
|
92
|
+
if (hit === null) return { kind: 'keyword', value: first };
|
|
93
|
+
const unit = hit[2] ?? 'px';
|
|
94
|
+
if (unit === 'em' || unit === '%') return { kind: 'relative', value: first };
|
|
95
|
+
const px = unit === 'rem' ? Number(hit[1]) * 16 : Number(hit[1]);
|
|
96
|
+
return { kind: 'size', px, value: first };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function isTestSource(file) {
|
|
100
|
+
return /(^|[\\/])__tests__[\\/]/.test(file) || /\.test\.[cm]?tsx?$/.test(file);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function walk(dir, acc = []) {
|
|
104
|
+
let entries;
|
|
105
|
+
try {
|
|
106
|
+
entries = readdirSync(dir);
|
|
107
|
+
} catch {
|
|
108
|
+
return acc;
|
|
109
|
+
}
|
|
110
|
+
for (const entry of entries) {
|
|
111
|
+
if (entry === 'node_modules' || entry === 'dist') continue;
|
|
112
|
+
const full = join(dir, entry);
|
|
113
|
+
if (statSync(full).isDirectory()) walk(full, acc);
|
|
114
|
+
else if (/\.(css|ts|tsx)$/.test(full)) acc.push(full);
|
|
115
|
+
}
|
|
116
|
+
return acc;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 扫一组根下的字号声明并归类。`roots` 是 `{ label, dir }`:label 是报出来的包名。
|
|
121
|
+
* 测试文件不扫(用例里那几处是断言用的字符串,不是界面字号);`node_modules` / `dist` 不扫。
|
|
122
|
+
* 返回里 `refFiles` / `litFiles` 是**涉及文件数**(同一个文件只算一次)。
|
|
123
|
+
*/
|
|
124
|
+
export function auditFontSizes({ roots, scale }) {
|
|
125
|
+
const refs = [];
|
|
126
|
+
const offScale = [];
|
|
127
|
+
const onScale = [];
|
|
128
|
+
const relativeSizes = [];
|
|
129
|
+
const refFiles = new Set();
|
|
130
|
+
const litFiles = new Set();
|
|
131
|
+
for (const root of roots) {
|
|
132
|
+
for (const file of walk(root.dir)) {
|
|
133
|
+
if (!/\.css$/.test(file) && isTestSource(file)) continue;
|
|
134
|
+
let text;
|
|
135
|
+
try {
|
|
136
|
+
text = readFileSync(file, 'utf8');
|
|
137
|
+
} catch {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const rel = relative(root.dir, file);
|
|
141
|
+
text.split('\n').forEach((line, at) => {
|
|
142
|
+
const values = [];
|
|
143
|
+
for (const hit of line.matchAll(CSS_FONT_SIZE)) values.push({ value: hit[1], decl: hit[0] });
|
|
144
|
+
for (const hit of line.matchAll(CSS_FONT_SHORTHAND)) values.push({ value: hit[1], decl: hit[0] });
|
|
145
|
+
for (const hit of line.matchAll(JS_FONT_SIZE)) values.push({ value: hit[2], decl: hit[0] });
|
|
146
|
+
for (const one of values) {
|
|
147
|
+
const verdict = classifyFontValue(one.value);
|
|
148
|
+
if (verdict.kind === 'keyword') continue;
|
|
149
|
+
const where = {
|
|
150
|
+
pkg: root.label,
|
|
151
|
+
file: rel,
|
|
152
|
+
line: at + 1,
|
|
153
|
+
decl: one.decl.replace(/^[;{\s]+/, '').trim().slice(0, 56),
|
|
154
|
+
};
|
|
155
|
+
if (verdict.kind === 'ref') {
|
|
156
|
+
refFiles.add(`${root.label}:${rel}`);
|
|
157
|
+
refs.push(where);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
litFiles.add(`${root.label}:${rel}`);
|
|
161
|
+
if (verdict.kind === 'relative') {
|
|
162
|
+
relativeSizes.push({ ...where, px: verdict.value });
|
|
163
|
+
} else if (scale.has(verdict.px)) {
|
|
164
|
+
onScale.push({ ...where, px: verdict.px });
|
|
165
|
+
} else {
|
|
166
|
+
offScale.push({ ...where, px: verdict.px });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return { refs, offScale, onScale, relative: relativeSizes, refFiles, litFiles };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const formatSize = (value) => String(value);
|
|
176
|
+
|
|
177
|
+
/** 最多印十来行明细:刻度外的先印(那是这份清单要的东西),再印刻度上的。 */
|
|
178
|
+
const DETAIL_LIMIT = 12;
|
|
179
|
+
|
|
180
|
+
function detailOf(hit, verdict) {
|
|
181
|
+
const head = `${hit.pkg}: ${hit.file}:${hit.line} ${hit.decl}`;
|
|
182
|
+
return verdict === 'off'
|
|
183
|
+
? `· ${head} —— ${formatSize(hit.px)} 不在刻度上(刻度外)`
|
|
184
|
+
: `· ${head} —— ${formatSize(hit.px)} 在刻度上,可收进令牌(但不是错)`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* 拼成 `checks/style.mjs` 通过时的那几行 note:第一行汇总(各类条数 + 涉及文件数 + 指路),
|
|
189
|
+
* 后面最多 `DETAIL_LIMIT` 行明细。零命中时只留一行"都是 0"。
|
|
190
|
+
*/
|
|
191
|
+
export function fontScaleNotes(report, scale, { doc = '.scratch/font-scale/spec.md' } = {}) {
|
|
192
|
+
const sizes = [...scale].sort((a, b) => a - b).map(formatSize).join(' / ');
|
|
193
|
+
const head =
|
|
194
|
+
`字号口径(黄,不判红):刻度 ${sizes}(从 --dsw-font-* 与 --wb-font-* 的**定义**读出来,` +
|
|
195
|
+
`出处见 ${doc});引用令牌 ${report.refs.length} 处 / ${report.refFiles.size} 文件(合规,不列);` +
|
|
196
|
+
`刻度外字面值 ${report.offScale.length} 处 / ${report.litFiles.size} 文件;` +
|
|
197
|
+
`刻度上字面值 ${report.onScale.length} 处;相对单位(em/%)判不了 ${report.relative.length} 处`;
|
|
198
|
+
const all = [
|
|
199
|
+
...report.offScale.map((hit) => detailOf(hit, 'off')),
|
|
200
|
+
...report.onScale.map((hit) => detailOf(hit, 'on')),
|
|
201
|
+
];
|
|
202
|
+
if (all.length === 0) {
|
|
203
|
+
return [`${head} —— 面板的字号全走令牌,一处刻度外的字面值都没有`];
|
|
204
|
+
}
|
|
205
|
+
const notes = [head, ...all.slice(0, DETAIL_LIMIT)];
|
|
206
|
+
if (all.length > DETAIL_LIMIT) {
|
|
207
|
+
notes.push(
|
|
208
|
+
`…另有 ${all.length - DETAIL_LIMIT} 处(刻度外 ${report.offScale.length} / 刻度上 ${report.onScale.length}),` +
|
|
209
|
+
`完整清单见 ${doc}`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
return notes;
|
|
213
|
+
}
|
|
@@ -208,9 +208,9 @@ prefs: {
|
|
|
208
208
|
}
|
|
209
209
|
```
|
|
210
210
|
|
|
211
|
-
浏览器半侧:`useHostWrite({ channel: 'prefs', resource: 'your-module' })` + `
|
|
212
|
-
`SettingsSection`,保存用 `SaveBar
|
|
213
|
-
|
|
211
|
+
浏览器半侧:`useHostWrite({ channel: 'prefs', resource: 'your-module' })` + `FormRows` / `FormRow` /
|
|
212
|
+
`TextField`,外面套 `SettingsSection`,保存用 `SaveBar`;存失败留在原地。(标签、行距与输入框内边距都是
|
|
213
|
+
外壳给的默认,不用为这个写 CSS。)语义:没写过 ≠ 选空、清空要把文件收掉、不认识的值报错并列出还认的候选。
|
|
214
214
|
|
|
215
215
|
### 靠什么跑起来:`env`
|
|
216
216
|
|