@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.
- package/README.md +3 -3
- package/package.json +2 -2
- package/src/checks/bundles.mjs +36 -0
- package/src/checks/cards.mjs +410 -91
- package/src/checks/kit.mjs +2 -40
- package/src/checks/shell-artifact.mjs +51 -0
- package/src/checks/style.mjs +355 -13
- package/src/font-scale.mjs +213 -0
- package/template-diy/README.md.tpl +3 -3
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
|
@@ -5,6 +5,14 @@
|
|
|
5
5
|
// CSS Modules 的哈希类名靠"同一份文件、同一个导出"配对,调用点拿到 undefined 时真机渲染成
|
|
6
6
|
// `class="undefined"` —— 元素整片没样式,而且控制台一声不响。
|
|
7
7
|
//
|
|
8
|
+
// ④ 表单的「行」归 `FormRow(s)`([ADR-0052](../../../docs/adr/0052-the-form-row-is-one-component.md)):
|
|
9
|
+
// 控件有主(`TextField` / `SelectField` / `ChoiceGroup`)、面有主(`SettingsSection` / `SaveBar`),
|
|
10
|
+
// 中间那层"标签 + 控件这一行怎么排"从前没有主 ⇒ 每人都自己写一遍:cmdb 一套 `.cmdbField`
|
|
11
|
+
// (`display:flex` + `.cmdbFieldLabel{width:72px}`)+ 三档 gap(`.cmdbForm` 8 / `.cmdbEnvs` 8 /
|
|
12
|
+
// `.cmdbEnvGroup` 2),argocd 又一套自己的两列 grid。现场就是控件尺寸漂、行距贴住、标签在上还是
|
|
13
|
+
// 在下各写各的(ADR-0052 §1 / §7 第 ② 条)。两族判据(F1 手写的标签+控件行 / F2 自己管行距)
|
|
14
|
+
// 都很窄 —— **宁可漏报,别把合法的网格/卡片布局判红**;判据的窄法与已知漏报写在各自那一段。
|
|
15
|
+
//
|
|
8
16
|
// 形状(所有判据统一):导出 `title` 与 `run()`,`run()` 返回三态结果;
|
|
9
17
|
// 打印与退出码归 `../check.mjs` 那一层。
|
|
10
18
|
|
|
@@ -13,6 +21,7 @@ import { dirname, join, relative, resolve } from 'node:path';
|
|
|
13
21
|
import { fileURLToPath } from 'node:url';
|
|
14
22
|
import { shellRulesIn } from '../css-shell.mjs';
|
|
15
23
|
import { discoverModulePackages } from '../discover.mjs';
|
|
24
|
+
import { auditFontSizes, fontScaleNotes, fontScaleOf } from '../font-scale.mjs';
|
|
16
25
|
|
|
17
26
|
// 外链一律走 kit 的 `Link`:它自带的样式(正文色 / hover 下划线 / 12px / 那个外链小箭头)是唯一一份。
|
|
18
27
|
// 手写 `<a>` / `h('a', …)` 拿到的是浏览器默认样式,而且每个人写的都不一样(这正是"同一个东西样式不一样"的来源)。
|
|
@@ -30,17 +39,185 @@ const BARE_CONTROLS = [
|
|
|
30
39
|
];
|
|
31
40
|
const NATIVE_ONLY_INPUT = /type\s*[:=]\s*['"](?:hidden|file)['"]/;
|
|
32
41
|
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
42
|
+
// 历史豁免:**已知**的手写控件,各有各的理由,跟着各自的迁移走 ——
|
|
43
|
+
// ①(ADR-0051 §4 那次)`module-cmdb/src/views/MenuField.tsx` 的两处手写钮已经迁完
|
|
44
|
+
// (2026-10-17,外壳发到 0.2.12 之后;cmdb `a1a37e60` / argocd `d85627a7`),那一行按棘轮删掉了 ——
|
|
45
|
+
// 留着这段是因为它就是这条纪律的由来:模块的 peer 下限得先有 `SelectField`,否则装上就崩。
|
|
46
|
+
// ②(ADR-0052 §10 那次)`module-cmdb/src/views/ChooseEnvsBody.tsx` 的 `role="radio"` 选项行
|
|
47
|
+
// 已经迁 `ChoiceGroup`(2026-10-17,外壳 0.2.13 之后),这一行同样按棘轮删掉了。
|
|
39
48
|
// 这是**棘轮**:迁完一处就删一行,别只加不减(名单长起来等于这条判据没有)。
|
|
40
49
|
// 键是 `<包名>:<相对模块根的文件路径>`。
|
|
41
|
-
const CONTROL_EXEMPT = new Set([
|
|
42
|
-
|
|
43
|
-
|
|
50
|
+
const CONTROL_EXEMPT = new Set([]);
|
|
51
|
+
|
|
52
|
+
// ── F1「手写的标签+控件行」与 F2「自己管行距」:都该走 FormRow(s)(ADR-0052) ──────────
|
|
53
|
+
//
|
|
54
|
+
// F1:模块源码里一个**标签样子**的元素(`<span>` / `<label>` / `<div>`,className 含 `label`/`Label`,
|
|
55
|
+
// 或它的内容是 i18n 的 `*.field.*` / `*.label` 键)紧挨着一个 kit 控件(`SelectField` / `TextField` /
|
|
56
|
+
// `ChoiceGroup`)或 dsh 原语 `Input` ⇒ 报"这一行该用 `FormRow`(标签归它)"。
|
|
57
|
+
// **窄在哪**:标签元素必须**短**(开、闭标签 5 行以内 —— 排掉大容器被当成标签)、控件必须在它后面
|
|
58
|
+
// 12 行内、中间撞见 `FormRow(s)` / `Field`(已经走那一层了)就放行、同一个控件只报一次。
|
|
59
|
+
// **已知漏报**(判宽了就会误伤,故意不收):没有 className 也没有 i18n 键的裸 `<label>名字<Input/></label>`;
|
|
60
|
+
// `h('span', …)` / `jsx(…)` 写的行;"标签在一个容器、控件在隔壁容器"那种松散排版。
|
|
61
|
+
const FORM_LABEL_ELEMENT = /<(span|label|div)\b[^>]*className\s*=\s*(?:\{[^}]*\}|"[^"]*")[^>]*>/;
|
|
62
|
+
const LABEL_CLASS = /label/i;
|
|
63
|
+
const I18N_LABEL_KEY = /['"][^'"]*\.(?:field|label)(?:['".])/;
|
|
64
|
+
const FORM_CONTROL = /<(SelectField|TextField|ChoiceGroup|Input)\b/;
|
|
65
|
+
const FORM_LAYER = /<(FormRows?|Field)\b/;
|
|
66
|
+
|
|
67
|
+
// F2:模块 CSS 里给"字段/表单"容器设了**行距**(`gap` / `row-gap` / 上下 `margin`,值为 0 的重置不算)。
|
|
68
|
+
// **窄在哪**:类名按 camelCase 切词,只认 `field(s)` / `form(s)` 这个词(`platform` / `performance` /
|
|
69
|
+
// `transform` 里的 form 不算);`row` 单独出现太歧义(列表行 / 卡片行 / 摘要行都叫 `row`),
|
|
70
|
+
// 要写就得写成 `formRow` / `fieldRow`;带 `card|list|item|issue|env|dot|…` 的 token 放行
|
|
71
|
+
// (那是卡片/清单的网格,不是表单行)。横向 margin(`margin-left/right/inline*`)与 `column-gap` 不算。
|
|
72
|
+
// **已知漏报**:ADR-0052 §7 点名的 `.cmdbEnvs` / `.cmdbEnvGroup`、只叫 `.row` / `.switchRow` 的容器 ——
|
|
73
|
+
// 名字里没有 field/form,这一版不判(判宽了会把 argocd 的 `.row` 清单行、cmdb 的 `.releaseConfirmRow` 一起误伤)。
|
|
74
|
+
const NOT_A_FORM = /card|list|item|issue|env|dot|sub|chip|badge|tag|tab|nav|menu/i;
|
|
75
|
+
const ZERO_LENGTH = /^(?:0|0px|0rem|0em|0%|none|auto)$/i;
|
|
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
|
+
|
|
95
|
+
/** 类名里有没有 `field(s)` / `form(s)` 这个词(按 camelCase 切词,`platform` 不算)。 */
|
|
96
|
+
function isFormContainer(token) {
|
|
97
|
+
return token.split(/(?=[A-Z])/).some((word) => /^(?:fields?|forms?)$/i.test(word))
|
|
98
|
+
|| /^(?:fields?|forms?)$/i.test(token);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 一个 CSS 声明的值算不算"行距"(垂直方向、非零)—— 不算返回 null。 */
|
|
102
|
+
function rowSpacingOf(property, value) {
|
|
103
|
+
const prop = property.toLowerCase();
|
|
104
|
+
const first = value.trim().split(/\s+/)[0] ?? '';
|
|
105
|
+
if (prop === 'gap' || prop === 'row-gap') return ZERO_LENGTH.test(first) ? null : prop;
|
|
106
|
+
if (prop === 'margin-top' || prop === 'margin-bottom' || prop === 'margin-block'
|
|
107
|
+
|| prop === 'margin-block-start' || prop === 'margin-block-end') {
|
|
108
|
+
return ZERO_LENGTH.test(first) ? null : prop;
|
|
109
|
+
}
|
|
110
|
+
if (prop === 'margin') {
|
|
111
|
+
const parts = value.trim().split(/\s+/);
|
|
112
|
+
const vertical = [parts[0] ?? '', parts.length >= 3 ? (parts[2] ?? '') : (parts[0] ?? '')];
|
|
113
|
+
return vertical.some((one) => !ZERO_LENGTH.test(one)) ? prop : null;
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 模块自己管的行距出现的位置(值 0 的重置、横向 margin、卡片/清单的网格都不算)。 */
|
|
119
|
+
function selfManagedRowsIn(css) {
|
|
120
|
+
const hits = [];
|
|
121
|
+
for (const block of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
|
|
122
|
+
const selector = block[1].replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/\s+/g, ' ').trim();
|
|
123
|
+
const form = Array.from(selector.matchAll(/\.([A-Za-z][\w-]*)/g), (one) => one[1])
|
|
124
|
+
.filter((token) => isFormContainer(token) && !NOT_A_FORM.test(token));
|
|
125
|
+
if (form.length === 0) continue;
|
|
126
|
+
// 注释换成等长空格:既不让注释里一句 `gap: …` 混进来,又保住下面按偏移回推行号的算法。
|
|
127
|
+
const body = block[2].replace(/\/\*[\s\S]*?\*\//g, (one) => ' '.repeat(one.length));
|
|
128
|
+
const bodyAt = block.index + block[1].length + 1;
|
|
129
|
+
for (const decl of body.matchAll(/([-\w]+)\s*:\s*([^;]*)/g)) {
|
|
130
|
+
const prop = rowSpacingOf(decl[1], decl[2]);
|
|
131
|
+
if (prop === null) continue;
|
|
132
|
+
hits.push({
|
|
133
|
+
line: css.slice(0, bodyAt + decl.index).split('\n').length,
|
|
134
|
+
selector: selector.slice(0, 60),
|
|
135
|
+
prop,
|
|
136
|
+
decl: `${decl[1]}: ${decl[2].trim()}`.slice(0, 60),
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return hits;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** 手写的「标签 + 控件」行出现的位置 —— 元素跨行,所以按行扫、给控件那一行。 */
|
|
144
|
+
function handwrittenRowsIn(text) {
|
|
145
|
+
const lines = text.split('\n');
|
|
146
|
+
const hits = [];
|
|
147
|
+
const claimed = new Set();
|
|
148
|
+
for (let at = 0; at < lines.length; at += 1) {
|
|
149
|
+
const open = lines[at].match(FORM_LABEL_ELEMENT);
|
|
150
|
+
if (open === null) continue;
|
|
151
|
+
const tag = open[1];
|
|
152
|
+
const named = (() => {
|
|
153
|
+
const found = lines[at].match(/className\s*=\s*(?:\{([^}]*)\}|"([^"]*)")/);
|
|
154
|
+
return LABEL_CLASS.test(found?.[1] ?? found?.[2] ?? '');
|
|
155
|
+
})();
|
|
156
|
+
const close = new RegExp(`</${tag}>`);
|
|
157
|
+
let end = -1;
|
|
158
|
+
let keyed = false;
|
|
159
|
+
for (let scan = at; scan < Math.min(lines.length, at + 5); scan += 1) {
|
|
160
|
+
if (I18N_LABEL_KEY.test(lines[scan])) keyed = true;
|
|
161
|
+
if (close.test(lines[scan])) {
|
|
162
|
+
end = scan;
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (end < 0 || (!named && !keyed)) continue;
|
|
167
|
+
for (let scan = end + 1; scan < Math.min(lines.length, end + 13); scan += 1) {
|
|
168
|
+
if (FORM_LAYER.test(lines[scan])) break;
|
|
169
|
+
const control = lines[scan].match(FORM_CONTROL);
|
|
170
|
+
if (control === null) continue;
|
|
171
|
+
if (!claimed.has(scan)) {
|
|
172
|
+
claimed.add(scan);
|
|
173
|
+
hits.push({ line: scan + 1, labelLine: at + 1, control: control[1] });
|
|
174
|
+
}
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return hits;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// 历史豁免:**已知**的手写「标签+控件」行与模块自己管的行距。它们跟 `CONTROL_EXEMPT` 是
|
|
182
|
+
// **两件事、两个迁移时点**(那一张等 kit 成员,这一张等外壳把 `FormRow(s)` 这一层发出来),
|
|
183
|
+
// 所以单开一张表,别混。理由都指向 ADR-0052:§5 是顺序(共享面先发版、模块后迁)、
|
|
184
|
+
// §7 第 ② 条是"行距归 `FormRow(s)`、模块不许自己写"。
|
|
185
|
+
// 这是**棘轮**:迁一处删一行。而且这一版会**替你发现哪一行已经不用留了** ——
|
|
186
|
+
// 某个包在眼前(被判据扫到)却一处都不再命中时,那条豁免当场报红(见 run() 里的 staleRows)。
|
|
187
|
+
// 键与 `CONTROL_EXEMPT` 同形:`<包名>:<相对模块根的文件路径>`。
|
|
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
|
+
const FORM_ACTIONS_EXEMPT = new Map([
|
|
197
|
+
[
|
|
198
|
+
'@pagoda-dsh-workbench/module-argocd:src/views/BindIdentity.tsx',
|
|
199
|
+
':112(无候选那屏的 重试 / 去设置)· :165(绑定确认那屏的 重试 / 确认,就是这次换按钮那屏)—— 等外壳发出 FormActions 后随它迁(ADR-0054 §2/§12)',
|
|
200
|
+
],
|
|
201
|
+
[
|
|
202
|
+
'@pagoda-dsh-workbench/module-argocd:src/views/ChooseEnvs.tsx',
|
|
203
|
+
':39(选择环境那屏)—— 等外壳发出 FormActions 后随它迁(ADR-0054 §12)',
|
|
204
|
+
],
|
|
205
|
+
[
|
|
206
|
+
'@pagoda-dsh-workbench/module-argocd:src/views/ArgocdCard.tsx',
|
|
207
|
+
':105(卡片上那组 取消 / 重试 / 确认)—— 等外壳发出 FormActions 后随它迁(ADR-0054 §12)',
|
|
208
|
+
],
|
|
209
|
+
[
|
|
210
|
+
'@pagoda-dsh-workbench/module-cmdb:src/views/CmdbSettingsPanes.tsx',
|
|
211
|
+
':55(清除确认那一对 取消 / 确认清除)· :76(编辑 / 清除入口)—— 等外壳发出 FormActions 后随它迁(ADR-0054 §12)',
|
|
212
|
+
],
|
|
213
|
+
[
|
|
214
|
+
'@pagoda-dsh-workbench/module-cmdb:src/views/ChooseEnvsBody.tsx',
|
|
215
|
+
':77(选环境那一屏)—— 等外壳发出 FormActions 后随它迁(ADR-0054 §12)',
|
|
216
|
+
],
|
|
217
|
+
[
|
|
218
|
+
'@pagoda-dsh-workbench/module-coding-analysis:src/views/EnvSettings.tsx',
|
|
219
|
+
':150(环境设置那一屏)—— 等外壳发出 FormActions 后随它迁(ADR-0054 §12)',
|
|
220
|
+
],
|
|
44
221
|
]);
|
|
45
222
|
|
|
46
223
|
/** 裸控件出现的位置 —— 逐行看不了(一个元素会跨行),所以在全文里按标签找,再回推行号。 */
|
|
@@ -76,6 +253,48 @@ const SOURCE_COMPONENTS = fileURLToPath(new URL('../../../shared/src/components'
|
|
|
76
253
|
const INSTALLED_BOARD = join('node_modules', '@dsh-workbench', 'core', 'dist', 'board.css');
|
|
77
254
|
const INSTALLED_COMPONENTS = join('node_modules', '@dsh-workbench', 'shared', 'src', 'components');
|
|
78
255
|
|
|
256
|
+
// ── 字号口径(S5,用户 2026-10-17 定的那条):只报「刻度外手搓字面值」,**黄,不判红** ─────────
|
|
257
|
+
//
|
|
258
|
+
// 口径、刻度出处与"将来怎么变红"写在 `.scratch/font-scale/spec.md`(这份 note 的指路目标)。
|
|
259
|
+
// 扫三个来源:外壳自己的 `src`、shared 的 `src`、本次发现的模块包;外壳两处与上面那份契约
|
|
260
|
+
// (`SOURCE_BOARD` / `SOURCE_COMPONENTS`)走同一条路 —— 源码在隔壁时读源码,仓外的模块工作区
|
|
261
|
+
// 读不到就少扫那一半(note 里只报实际扫到的)。测试文件不扫(用例里那些是断言字符串,不是界面字号)。
|
|
262
|
+
//
|
|
263
|
+
// 为什么落在这一条上:`check:style` 今天就有"通过时也打 note"的做法(豁免表那两行),
|
|
264
|
+
// 这里只是又一条同类 note;**不新增判据、不改退出码、不加开关**。外壳 CSS 那边本来就有红判据
|
|
265
|
+
// (arch-guard 的 D12:`font-size` 字面值必须写 `var(...)`),所以那半边的 0 命中是"红已经守住"
|
|
266
|
+
// 的结果;这份黄清单补的是 D12 看不到的地方:`.tsx` 内联 `fontSize`、shared、以及模块包。
|
|
267
|
+
const SOURCE_SHELL_SRC = fileURLToPath(new URL('../../../workbench/src', import.meta.url));
|
|
268
|
+
const SOURCE_SHARED_SRC = fileURLToPath(new URL('../../../shared/src', import.meta.url));
|
|
269
|
+
const SHELL_FONT_SCALE_FILES = [
|
|
270
|
+
fileURLToPath(new URL('../../../workbench/src/shared/styles/tokens/scale.css', import.meta.url)),
|
|
271
|
+
fileURLToPath(new URL('../../../workbench/src/shared/styles/tokens.css', import.meta.url)),
|
|
272
|
+
];
|
|
273
|
+
const FONT_DOC = '.scratch/font-scale/spec.md';
|
|
274
|
+
|
|
275
|
+
/** 包名(读 package.json;读不到就用兜底名,别让一条 note 把判据带崩)。 */
|
|
276
|
+
function packageNameOf(dir, fallback) {
|
|
277
|
+
try {
|
|
278
|
+
const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
|
|
279
|
+
return typeof manifest.name === 'string' ? manifest.name : fallback;
|
|
280
|
+
} catch {
|
|
281
|
+
return fallback;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** 字号口径要扫的根:外壳 + shared(源码在隔壁才有)+ 本次发现的模块包。 */
|
|
286
|
+
function fontRootsIn(modules) {
|
|
287
|
+
const roots = [];
|
|
288
|
+
for (const [dir, fallback] of [
|
|
289
|
+
[SOURCE_SHELL_SRC, '@dsh-workbench/core'],
|
|
290
|
+
[SOURCE_SHARED_SRC, '@dsh-workbench/shared'],
|
|
291
|
+
]) {
|
|
292
|
+
if (existsSync(dir)) roots.push({ label: packageNameOf(join(dir, '..'), fallback), dir });
|
|
293
|
+
}
|
|
294
|
+
for (const pkg of modules) roots.push({ label: pkg.manifest.name, dir: pkg.dir });
|
|
295
|
+
return roots;
|
|
296
|
+
}
|
|
297
|
+
|
|
79
298
|
function installedUnder(dir, tail) {
|
|
80
299
|
let at = resolve(dir);
|
|
81
300
|
for (;;) {
|
|
@@ -139,6 +358,11 @@ export async function run() {
|
|
|
139
358
|
const borrowed = [];
|
|
140
359
|
const anchors = [];
|
|
141
360
|
const controls = [];
|
|
361
|
+
const rows = [];
|
|
362
|
+
const actionAreas = [];
|
|
363
|
+
// 这一轮"命中过"的 `包:文件`(含被豁免的)—— 豁免表里那些不再命中的行靠它发现(棘轮)。
|
|
364
|
+
const formRowHits = new Set();
|
|
365
|
+
const actionAreaHits = new Set();
|
|
142
366
|
const modules = discoverModulePackages({ root: process.cwd() });
|
|
143
367
|
const contracts = new Map(modules.map((pkg) => [pkg.dir, contractOf(pkg.dir)]));
|
|
144
368
|
for (const pkg of modules) {
|
|
@@ -162,6 +386,20 @@ export async function run() {
|
|
|
162
386
|
}
|
|
163
387
|
});
|
|
164
388
|
|
|
389
|
+
if (/\.css$/.test(file)) {
|
|
390
|
+
const key = `${pkg.manifest.name}:${relative(pkg.dir, file)}`;
|
|
391
|
+
const exemptRows = FORM_ROW_EXEMPT.has(key);
|
|
392
|
+
for (const hit of selfManagedRowsIn(text)) {
|
|
393
|
+
formRowHits.add(key);
|
|
394
|
+
if (exemptRows) continue;
|
|
395
|
+
rows.push(
|
|
396
|
+
`${pkg.manifest.name}: ${relative(pkg.dir, file)}:${hit.line} 模块自己管了行距(${hit.selector} 的 ${hit.decl})—— ` +
|
|
397
|
+
`行距归 FormRow(s)(ADR-0052 §7 第 ② 条:行距只有这一档,模块不许自己写):` +
|
|
398
|
+
`${(lines[hit.line - 1] ?? '').trim().slice(0, 80)}`,
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
165
403
|
if (isTestSource(file)) continue;
|
|
166
404
|
if (!/\.tsx?$/.test(file)) continue;
|
|
167
405
|
const exemptControls = CONTROL_EXEMPT.has(`${pkg.manifest.name}:${relative(pkg.dir, file)}`);
|
|
@@ -181,6 +419,32 @@ export async function run() {
|
|
|
181
419
|
);
|
|
182
420
|
}
|
|
183
421
|
}
|
|
422
|
+
if (/\.tsx$/.test(file)) {
|
|
423
|
+
const key = `${pkg.manifest.name}:${relative(pkg.dir, file)}`;
|
|
424
|
+
const exemptRows = FORM_ROW_EXEMPT.has(key);
|
|
425
|
+
for (const hit of handwrittenRowsIn(text)) {
|
|
426
|
+
formRowHits.add(key);
|
|
427
|
+
if (exemptRows) continue;
|
|
428
|
+
rows.push(
|
|
429
|
+
`${pkg.manifest.name}: ${relative(pkg.dir, file)}:${hit.line} 手写的「标签 + 控件」行(标签在 ${hit.labelLine} 行,控件是 ${hit.control})—— ` +
|
|
430
|
+
`用 FormRow:标签归它(ADR-0052 §2:面 → 行 → 控件,中间那层归 FormRow(s)):` +
|
|
431
|
+
`${(lines[hit.line - 1] ?? '').trim().slice(0, 80)}`,
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
const actionKey = `${pkg.manifest.name}:${relative(pkg.dir, file)}`;
|
|
436
|
+
const exemptActions = FORM_ACTIONS_EXEMPT.has(actionKey);
|
|
437
|
+
lines.forEach((line, index) => {
|
|
438
|
+
if (!ACTION_AREA_CLASS.test(line)) return;
|
|
439
|
+
actionAreaHits.add(actionKey);
|
|
440
|
+
if (exemptActions) return;
|
|
441
|
+
actionAreas.push(
|
|
442
|
+
`${pkg.manifest.name}: ${relative(pkg.dir, file)}:${index + 1} 手写了操作区(借了外壳的槽类 wb-formActions)—— ` +
|
|
443
|
+
`用 kit 的 FormActions(ADR-0054 §2:主操作在右、一个区一颗 primary、danger 紧挨它左侧且有二次确认、` +
|
|
444
|
+
'间距与窄容器换行都归它):' +
|
|
445
|
+
`${line.trim().slice(0, 80)}`,
|
|
446
|
+
);
|
|
447
|
+
});
|
|
184
448
|
if (contract === null) continue;
|
|
185
449
|
lines.forEach((line, index) => {
|
|
186
450
|
for (const match of line.matchAll(CLASS_TOKEN)) {
|
|
@@ -222,7 +486,28 @@ export async function run() {
|
|
|
222
486
|
}
|
|
223
487
|
}
|
|
224
488
|
|
|
225
|
-
|
|
489
|
+
// 棘轮的另一半:豁免表里的行如果**已经不再命中**(那个包在眼前,却一处都不报了),
|
|
490
|
+
// 说明它已经迁完/文件没了 —— 这一行该删。包不在眼前(别的仓库里)时不说它,免得误报。
|
|
491
|
+
const present = new Set(modules.map((pkg) => pkg.manifest.name));
|
|
492
|
+
const staleRows = [];
|
|
493
|
+
for (const [key, reason] of FORM_ROW_EXEMPT) {
|
|
494
|
+
const owner = key.slice(0, key.indexOf(':'));
|
|
495
|
+
if (!present.has(owner) || formRowHits.has(key)) continue;
|
|
496
|
+
staleRows.push(
|
|
497
|
+
`${key} 还在 FORM_ROW_EXEMPT 里,但一处都不再命中 —— 按棘轮删掉这一行(原由:${reason})`,
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
const staleActionRows = [];
|
|
501
|
+
for (const [key, reason] of FORM_ACTIONS_EXEMPT) {
|
|
502
|
+
const owner = key.slice(0, key.indexOf(':'));
|
|
503
|
+
if (!present.has(owner) || actionAreaHits.has(key)) continue;
|
|
504
|
+
staleActionRows.push(
|
|
505
|
+
`${key} 还在 FORM_ACTIONS_EXEMPT 里,但一处都不再命中 —— 按棘轮删掉这一行(原由:${reason})`,
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (bindings.length + problems.length + borrowed.length + anchors.length + controls.length
|
|
510
|
+
+ rows.length + actionAreas.length + staleRows.length + staleActionRows.length > 0) {
|
|
226
511
|
const notes = [];
|
|
227
512
|
if (bindings.length > 0) {
|
|
228
513
|
notes.push(
|
|
@@ -258,11 +543,50 @@ export async function run() {
|
|
|
258
543
|
'`type="hidden"` / `type="file"` 例外:那两种形态两处都没有)',
|
|
259
544
|
);
|
|
260
545
|
}
|
|
261
|
-
|
|
546
|
+
if (rows.length > 0) {
|
|
547
|
+
notes.push(
|
|
548
|
+
`${rows.length} 处是「手写的标签+控件行 或 模块自己管的行距」:(ADR-0052:面 → 行 → 控件,` +
|
|
549
|
+
'中间那层归 `FormRow(s)`。行里自己写 `<span className=…label>` 配 `SelectField` / `TextField` / ' +
|
|
550
|
+
'`ChoiceGroup` / `Input`、或给 field/form 容器设 `gap` / 上下 `margin`,就是这一层没有主人的信号 —— ' +
|
|
551
|
+
'换 `<FormRow label={…}>{控件}</FormRow>`,行距一起归它。窄口径与已知漏报见 style.mjs 里那两段注释)',
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
if (actionAreas.length > 0) {
|
|
555
|
+
notes.push(
|
|
556
|
+
`${actionAreas.length} 处是「手写了操作区(借了外壳的槽类 wb-formActions)」:(ADR-0054:` +
|
|
557
|
+
'操作区那一层归 `FormActions` —— 顺序(主操作在右、danger 紧挨它左侧)、一个区一颗 `primary`、' +
|
|
558
|
+
'间距、窄容器换行都由组件定;调用点只给"有哪些动作、哪个是主要、点了做什么"。' +
|
|
559
|
+
'`wb-formActions` 这个槽类只为操作区存在,出现它就是自己摆了一套:换 `<FormActions actions={…} />`。' +
|
|
560
|
+
'窄口径与已知漏报见 style.mjs 里那一段注释)',
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
if (staleRows.length > 0) {
|
|
564
|
+
notes.push(
|
|
565
|
+
`${staleRows.length} 条 FORM_ROW_EXEMPT 已经不命中了:(棘轮:迁完一处删一行 —— ` +
|
|
566
|
+
'一个包迁完 FormRow 之后,它那几行豁免要跟着删,别留着让表长起来)',
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
if (staleActionRows.length > 0) {
|
|
570
|
+
notes.push(
|
|
571
|
+
`${staleActionRows.length} 条 FORM_ACTIONS_EXEMPT 已经不命中了:(棘轮:迁完一处删一行 —— ` +
|
|
572
|
+
'一个包迁完 FormActions 之后,它那几行豁免要跟着删,别留着让表长起来)',
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
// 各类问题各自成串,按原来的输出顺序(绑定 → 规则 → 借用 → 裸 <a> → 裸控件 → 表单行 → 操作区 → 过期豁免)拼在一起。
|
|
262
576
|
return {
|
|
263
577
|
status: 'fail',
|
|
264
578
|
notes,
|
|
265
|
-
problems: [
|
|
579
|
+
problems: [
|
|
580
|
+
...bindings,
|
|
581
|
+
...problems,
|
|
582
|
+
...borrowed,
|
|
583
|
+
...anchors,
|
|
584
|
+
...controls,
|
|
585
|
+
...rows,
|
|
586
|
+
...actionAreas,
|
|
587
|
+
...staleRows,
|
|
588
|
+
...staleActionRows,
|
|
589
|
+
],
|
|
266
590
|
};
|
|
267
591
|
}
|
|
268
592
|
|
|
@@ -291,9 +615,27 @@ export async function run() {
|
|
|
291
615
|
noContract.length === 0
|
|
292
616
|
? '借用的类都在契约里(外壳出货的 board.css + 外壳组件自己的样式表)'
|
|
293
617
|
: `${noContract.join(' / ')} 那一边找不到外壳的契约,借用这一项没查`;
|
|
618
|
+
const exemptNote =
|
|
619
|
+
formRowHits.size === 0
|
|
620
|
+
? '没有手写的「标签+控件」行,也没有模块自己管的行距'
|
|
621
|
+
: `表单「行」这一层还有 ${formRowHits.size} 个文件在 FORM_ROW_EXEMPT 里等外壳发版后迁(棘轮:迁一处删一行)`;
|
|
622
|
+
const actionNote =
|
|
623
|
+
actionAreaHits.size === 0
|
|
624
|
+
? '没有手写的操作区'
|
|
625
|
+
: `操作区这一层还有 ${actionAreaHits.size} 个文件在 FORM_ACTIONS_EXEMPT 里等外壳发版后迁(棘轮:迁一处删一行)`;
|
|
626
|
+
// 黄的那一条:各类条数 + 涉及文件数 + 最多十来行明细 + 指路文档(口径见上面那一段注释)。
|
|
627
|
+
const fontScale = fontScaleOf(SHELL_FONT_SCALE_FILES);
|
|
628
|
+
const fontNotes = fontScaleNotes(
|
|
629
|
+
auditFontSizes({ roots: fontRootsIn(modules), scale: fontScale }),
|
|
630
|
+
fontScale,
|
|
631
|
+
{ doc: FONT_DOC },
|
|
632
|
+
);
|
|
294
633
|
return {
|
|
295
634
|
status: 'pass',
|
|
296
|
-
notes: [
|
|
635
|
+
notes: [
|
|
636
|
+
`${modules.length} 个模块都没有定义外壳前缀(wb-*)的 CSS 规则;${contractNote};${exemptNote};${actionNote}`,
|
|
637
|
+
...fontNotes,
|
|
638
|
+
],
|
|
297
639
|
problems: [],
|
|
298
640
|
};
|
|
299
641
|
}
|