@dsh-workbench/module-tools 0.2.12 → 0.2.13
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/bundles.mjs +36 -0
- package/src/checks/style.mjs +206 -11
package/README.md
CHANGED
|
@@ -22,14 +22,14 @@ dsh-wb-module publish # 发货前的清单:门禁 + 三张表 +
|
|
|
22
22
|
|---|---|
|
|
23
23
|
| `contracts` | 模块契约:单元 id 唯一、两个面、四个脚本、`files`、peer 下限;以及**清单 ↔ 代码**(真正注册的工具)与**底稿 ↔ 清单**(`design.json` 的能力名)两处对账(见下) |
|
|
24
24
|
| `protocol` | 模块声明协议:`dsh.workbench` 的 `apiVersion` 要在、静态半形状要对、不许有协议不认识的键(`scope` 现在**就是**不认识的键之一 —— 见下) |
|
|
25
|
-
| `bundles` | 产物边界:宿主面里不许出现浏览器侧的东西;浏览器产物里不许出现**内联的外壳 CSS**(模块从 `@dsh-workbench/shared` 根入口取运行期值就会这样)
|
|
25
|
+
| `bundles` | 产物边界:宿主面里不许出现浏览器侧的东西;浏览器产物里不许出现**内联的外壳 CSS**(模块从 `@dsh-workbench/shared` 根入口取运行期值就会这样) |;**产物里出现夹具标记(`wbMock` / `MockStrip` / `useFixturePick` / `-mock'` / `SCENES`…)判红** —— 模块不带夹具(见 `.scratch/dev-only-fixtures/spec.md`)
|
|
26
26
|
| `source` | 源码边界:不直接引 dsh 的包、不引不是种子词的 `@deepseek-ai/*` |
|
|
27
27
|
| `primitives` | dsh 原语只从模块表(`kit`)拿 |
|
|
28
28
|
| `kit` | 这份外壳的 kit 成员到底有哪些(缺什么会当场点名) |
|
|
29
29
|
| `cards` | 卡片渲染:五态都要画得出来,空数据不许抛 |
|
|
30
30
|
| `standalone` | 独立构建:产物不依赖工坊里跑 |
|
|
31
31
|
| `markers` | 公开包无内部标识(内网域名、企业内部名) |
|
|
32
|
-
| `style` | 样式归属:模块不许定义外壳类(`wb-*`)的规则(判据看**复合选择器**,`:global(.wb-x)` 也算,只有同一个复合选择器里带模块自己的类才放行);**裸 `<a>` 与裸交互控件(`<button>` / `<input>` / `<select>`)都判红** —— 前者用 kit 的 `Link`,后者用 `Button` / `TextField` / `SelectField` / `ChoiceGroup`;只有 `<textarea>` 与 `input[type=hidden\|file]`
|
|
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`(棘轮:迁一处删一行,且表里已经不再命中的行会当场报红) |
|
|
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.13",
|
|
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.13"
|
|
25
25
|
},
|
|
26
26
|
"scripts": {
|
|
27
27
|
"selfcheck": "node scripts/selfcheck.mjs"
|
package/src/checks/bundles.mjs
CHANGED
|
@@ -36,6 +36,30 @@ const HOST_FORBIDDEN = [
|
|
|
36
36
|
|
|
37
37
|
export const title = '模块产物边界';
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* 模块产物里的夹具标记 —— 这几串一出现就说明"夹具又长回模块里了"。
|
|
41
|
+
*
|
|
42
|
+
* 为什么是"串"而不是"符号":模块产物是打包后的 JS,符号名会被内联/保留,用固定串更稳。
|
|
43
|
+
* `-mock'` 那一条故意只匹配**带引号的模块路径尾巴**(`from './cmdb-mock'` 这类),
|
|
44
|
+
* 避免误伤 vitest 的 `vi.mock`(那在测试里,不进产物)。
|
|
45
|
+
*/
|
|
46
|
+
const FIXTURE_MARKERS = [
|
|
47
|
+
'wbMock',
|
|
48
|
+
'wbCrash',
|
|
49
|
+
'MockStrip',
|
|
50
|
+
'useFixturePick',
|
|
51
|
+
'mockModeOn',
|
|
52
|
+
'crashModeOn',
|
|
53
|
+
'-mock\'',
|
|
54
|
+
'"*-mock"',
|
|
55
|
+
'SCENES',
|
|
56
|
+
'fixtureOf',
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
function fixtureMarkersIn(source) {
|
|
60
|
+
return FIXTURE_MARKERS.filter((one) => source.includes(one));
|
|
61
|
+
}
|
|
62
|
+
|
|
39
63
|
export async function run() {
|
|
40
64
|
const modulePackages = discoverModulePackages({ root: process.cwd() });
|
|
41
65
|
const problems = [];
|
|
@@ -64,6 +88,18 @@ export async function run() {
|
|
|
64
88
|
);
|
|
65
89
|
}
|
|
66
90
|
}
|
|
91
|
+
// 夹具只属于**外壳开发者的调试**(.scratch/dev-only-fixtures/spec.md):外壳自 0.2.12 起
|
|
92
|
+
// 把它整棵树挪出发布构建,kit 面也不再提供那几个成员。模块带着夹具只有坏处:
|
|
93
|
+
// 取不到旧成员会当场抛(2026-10-17 实测三个模块无条件渲染的包装组件就是这么崩的),
|
|
94
|
+
// 而且夹具数据会跟真取数抢同一条渲染路径。这条盯**产物**:出现任何一个标记就红。
|
|
95
|
+
if (fixtureMarkersIn(source).length > 0) {
|
|
96
|
+
const hits = fixtureMarkersIn(source);
|
|
97
|
+
problems.push(
|
|
98
|
+
`${name}: ${artifact.what} ${artifact.file} 里有夹具(${hits.slice(0, 4).join(' · ')})—— ` +
|
|
99
|
+
'模块不带夹具:夹具只服务工作台开发者的调试(外壳那边走 `@/dev/surface` 接缝,发布构建里没有它),' +
|
|
100
|
+
'模块自己的 vitest 把几态各喂一遍就够。见 .scratch/dev-only-fixtures/spec.md',
|
|
101
|
+
);
|
|
102
|
+
}
|
|
67
103
|
// 外壳的组件样式**只有一份**(外壳注入那一次)。模块从 `@dsh-workbench/shared` 的**根入口**
|
|
68
104
|
// 取运行期值(`h` / `Fragment` / `useState` / 纯函数…)就会把整份组件 barrel 连 CSS 一起打进
|
|
69
105
|
// 自己的产物,注入后按顺序覆盖外壳那一份 —— 用户看到的是旧样式,而且外壳改了也不生效。
|
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
|
|
|
@@ -30,19 +38,145 @@ const BARE_CONTROLS = [
|
|
|
30
38
|
];
|
|
31
39
|
const NATIVE_ONLY_INPUT = /type\s*[:=]\s*['"](?:hidden|file)['"]/;
|
|
32
40
|
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
41
|
+
// 历史豁免:**已知**的手写控件,各有各的理由,跟着各自的迁移走 ——
|
|
42
|
+
// · `module-cmdb/src/views/ChooseEnvsBody.tsx`(一处:`role="radio"` 那一列选项行)
|
|
43
|
+
// = 对应 kit 的 `ChoiceGroup`(不是 `SelectField`);换不换是那个模块自己的一次改动
|
|
44
|
+
// (分组标题与标记是它的排版)。
|
|
45
|
+
// ①(ADR-0051 §4 那次)`module-cmdb/src/views/MenuField.tsx` 的两处手写钮已经迁完
|
|
46
|
+
// (2026-10-17,外壳发到 0.2.12 之后;cmdb `a1a37e60` / argocd `d85627a7`),那一行按棘轮删掉了 ——
|
|
47
|
+
// 留着这段是因为它就是这条纪律的由来:模块的 peer 下限得先有 `SelectField`,否则装上就崩。
|
|
39
48
|
// 这是**棘轮**:迁完一处就删一行,别只加不减(名单长起来等于这条判据没有)。
|
|
40
49
|
// 键是 `<包名>:<相对模块根的文件路径>`。
|
|
41
50
|
const CONTROL_EXEMPT = new Set([
|
|
42
|
-
'@pagoda-dsh-workbench/module-cmdb:src/views/MenuField.tsx',
|
|
43
51
|
'@pagoda-dsh-workbench/module-cmdb:src/views/ChooseEnvsBody.tsx',
|
|
44
52
|
]);
|
|
45
53
|
|
|
54
|
+
// ── F1「手写的标签+控件行」与 F2「自己管行距」:都该走 FormRow(s)(ADR-0052) ──────────
|
|
55
|
+
//
|
|
56
|
+
// F1:模块源码里一个**标签样子**的元素(`<span>` / `<label>` / `<div>`,className 含 `label`/`Label`,
|
|
57
|
+
// 或它的内容是 i18n 的 `*.field.*` / `*.label` 键)紧挨着一个 kit 控件(`SelectField` / `TextField` /
|
|
58
|
+
// `ChoiceGroup`)或 dsh 原语 `Input` ⇒ 报"这一行该用 `FormRow`(标签归它)"。
|
|
59
|
+
// **窄在哪**:标签元素必须**短**(开、闭标签 5 行以内 —— 排掉大容器被当成标签)、控件必须在它后面
|
|
60
|
+
// 12 行内、中间撞见 `FormRow(s)` / `Field`(已经走那一层了)就放行、同一个控件只报一次。
|
|
61
|
+
// **已知漏报**(判宽了就会误伤,故意不收):没有 className 也没有 i18n 键的裸 `<label>名字<Input/></label>`;
|
|
62
|
+
// `h('span', …)` / `jsx(…)` 写的行;"标签在一个容器、控件在隔壁容器"那种松散排版。
|
|
63
|
+
const FORM_LABEL_ELEMENT = /<(span|label|div)\b[^>]*className\s*=\s*(?:\{[^}]*\}|"[^"]*")[^>]*>/;
|
|
64
|
+
const LABEL_CLASS = /label/i;
|
|
65
|
+
const I18N_LABEL_KEY = /['"][^'"]*\.(?:field|label)(?:['".])/;
|
|
66
|
+
const FORM_CONTROL = /<(SelectField|TextField|ChoiceGroup|Input)\b/;
|
|
67
|
+
const FORM_LAYER = /<(FormRows?|Field)\b/;
|
|
68
|
+
|
|
69
|
+
// F2:模块 CSS 里给"字段/表单"容器设了**行距**(`gap` / `row-gap` / 上下 `margin`,值为 0 的重置不算)。
|
|
70
|
+
// **窄在哪**:类名按 camelCase 切词,只认 `field(s)` / `form(s)` 这个词(`platform` / `performance` /
|
|
71
|
+
// `transform` 里的 form 不算);`row` 单独出现太歧义(列表行 / 卡片行 / 摘要行都叫 `row`),
|
|
72
|
+
// 要写就得写成 `formRow` / `fieldRow`;带 `card|list|item|issue|env|dot|…` 的 token 放行
|
|
73
|
+
// (那是卡片/清单的网格,不是表单行)。横向 margin(`margin-left/right/inline*`)与 `column-gap` 不算。
|
|
74
|
+
// **已知漏报**:ADR-0052 §7 点名的 `.cmdbEnvs` / `.cmdbEnvGroup`、只叫 `.row` / `.switchRow` 的容器 ——
|
|
75
|
+
// 名字里没有 field/form,这一版不判(判宽了会把 argocd 的 `.row` 清单行、cmdb 的 `.releaseConfirmRow` 一起误伤)。
|
|
76
|
+
const NOT_A_FORM = /card|list|item|issue|env|dot|sub|chip|badge|tag|tab|nav|menu/i;
|
|
77
|
+
const ZERO_LENGTH = /^(?:0|0px|0rem|0em|0%|none|auto)$/i;
|
|
78
|
+
|
|
79
|
+
/** 类名里有没有 `field(s)` / `form(s)` 这个词(按 camelCase 切词,`platform` 不算)。 */
|
|
80
|
+
function isFormContainer(token) {
|
|
81
|
+
return token.split(/(?=[A-Z])/).some((word) => /^(?:fields?|forms?)$/i.test(word))
|
|
82
|
+
|| /^(?:fields?|forms?)$/i.test(token);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 一个 CSS 声明的值算不算"行距"(垂直方向、非零)—— 不算返回 null。 */
|
|
86
|
+
function rowSpacingOf(property, value) {
|
|
87
|
+
const prop = property.toLowerCase();
|
|
88
|
+
const first = value.trim().split(/\s+/)[0] ?? '';
|
|
89
|
+
if (prop === 'gap' || prop === 'row-gap') return ZERO_LENGTH.test(first) ? null : prop;
|
|
90
|
+
if (prop === 'margin-top' || prop === 'margin-bottom' || prop === 'margin-block'
|
|
91
|
+
|| prop === 'margin-block-start' || prop === 'margin-block-end') {
|
|
92
|
+
return ZERO_LENGTH.test(first) ? null : prop;
|
|
93
|
+
}
|
|
94
|
+
if (prop === 'margin') {
|
|
95
|
+
const parts = value.trim().split(/\s+/);
|
|
96
|
+
const vertical = [parts[0] ?? '', parts.length >= 3 ? (parts[2] ?? '') : (parts[0] ?? '')];
|
|
97
|
+
return vertical.some((one) => !ZERO_LENGTH.test(one)) ? prop : null;
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 模块自己管的行距出现的位置(值 0 的重置、横向 margin、卡片/清单的网格都不算)。 */
|
|
103
|
+
function selfManagedRowsIn(css) {
|
|
104
|
+
const hits = [];
|
|
105
|
+
for (const block of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
|
|
106
|
+
const selector = block[1].replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/\s+/g, ' ').trim();
|
|
107
|
+
const form = Array.from(selector.matchAll(/\.([A-Za-z][\w-]*)/g), (one) => one[1])
|
|
108
|
+
.filter((token) => isFormContainer(token) && !NOT_A_FORM.test(token));
|
|
109
|
+
if (form.length === 0) continue;
|
|
110
|
+
// 注释换成等长空格:既不让注释里一句 `gap: …` 混进来,又保住下面按偏移回推行号的算法。
|
|
111
|
+
const body = block[2].replace(/\/\*[\s\S]*?\*\//g, (one) => ' '.repeat(one.length));
|
|
112
|
+
const bodyAt = block.index + block[1].length + 1;
|
|
113
|
+
for (const decl of body.matchAll(/([-\w]+)\s*:\s*([^;]*)/g)) {
|
|
114
|
+
const prop = rowSpacingOf(decl[1], decl[2]);
|
|
115
|
+
if (prop === null) continue;
|
|
116
|
+
hits.push({
|
|
117
|
+
line: css.slice(0, bodyAt + decl.index).split('\n').length,
|
|
118
|
+
selector: selector.slice(0, 60),
|
|
119
|
+
prop,
|
|
120
|
+
decl: `${decl[1]}: ${decl[2].trim()}`.slice(0, 60),
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return hits;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** 手写的「标签 + 控件」行出现的位置 —— 元素跨行,所以按行扫、给控件那一行。 */
|
|
128
|
+
function handwrittenRowsIn(text) {
|
|
129
|
+
const lines = text.split('\n');
|
|
130
|
+
const hits = [];
|
|
131
|
+
const claimed = new Set();
|
|
132
|
+
for (let at = 0; at < lines.length; at += 1) {
|
|
133
|
+
const open = lines[at].match(FORM_LABEL_ELEMENT);
|
|
134
|
+
if (open === null) continue;
|
|
135
|
+
const tag = open[1];
|
|
136
|
+
const named = (() => {
|
|
137
|
+
const found = lines[at].match(/className\s*=\s*(?:\{([^}]*)\}|"([^"]*)")/);
|
|
138
|
+
return LABEL_CLASS.test(found?.[1] ?? found?.[2] ?? '');
|
|
139
|
+
})();
|
|
140
|
+
const close = new RegExp(`</${tag}>`);
|
|
141
|
+
let end = -1;
|
|
142
|
+
let keyed = false;
|
|
143
|
+
for (let scan = at; scan < Math.min(lines.length, at + 5); scan += 1) {
|
|
144
|
+
if (I18N_LABEL_KEY.test(lines[scan])) keyed = true;
|
|
145
|
+
if (close.test(lines[scan])) {
|
|
146
|
+
end = scan;
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (end < 0 || (!named && !keyed)) continue;
|
|
151
|
+
for (let scan = end + 1; scan < Math.min(lines.length, end + 13); scan += 1) {
|
|
152
|
+
if (FORM_LAYER.test(lines[scan])) break;
|
|
153
|
+
const control = lines[scan].match(FORM_CONTROL);
|
|
154
|
+
if (control === null) continue;
|
|
155
|
+
if (!claimed.has(scan)) {
|
|
156
|
+
claimed.add(scan);
|
|
157
|
+
hits.push({ line: scan + 1, labelLine: at + 1, control: control[1] });
|
|
158
|
+
}
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return hits;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// 历史豁免:**已知**的手写「标签+控件」行与模块自己管的行距。它们跟 `CONTROL_EXEMPT` 是
|
|
166
|
+
// **两件事、两个迁移时点**(那一张等 kit 成员,这一张等外壳把 `FormRow(s)` 这一层发出来),
|
|
167
|
+
// 所以单开一张表,别混。理由都指向 ADR-0052:§5 是顺序(共享面先发版、模块后迁)、
|
|
168
|
+
// §7 第 ② 条是"行距归 `FormRow(s)`、模块不许自己写"。
|
|
169
|
+
// 这是**棘轮**:迁一处删一行。而且这一版会**替你发现哪一行已经不用留了** ——
|
|
170
|
+
// 某个包在眼前(被判据扫到)却一处都不再命中时,那条豁免当场报红(见 run() 里的 staleRows)。
|
|
171
|
+
// 键与 `CONTROL_EXEMPT` 同形:`<包名>:<相对模块根的文件路径>`。
|
|
172
|
+
const FORM_ROW_EXEMPT = new Map([
|
|
173
|
+
['@pagoda-dsh-workbench/module-cmdb:src/views/CmdbSettings.tsx', 'cmdb 绑定表单 4 处「标签 span + SelectField」;等外壳 0.2.13 发出 FormRow 后随它迁(ADR-0052 §5/§7)'],
|
|
174
|
+
['@pagoda-dsh-workbench/module-cmdb:src/views/EnvChoice.tsx', 'cmdb 环境行 3 处「标签 span + SelectField」;同上,随 FormRow 迁(ADR-0052 §5/§7)'],
|
|
175
|
+
['@pagoda-dsh-workbench/module-cmdb:src/views/Cmdb.module.css', 'cmdb 自己那套 `.cmdbField` 行布局与 `.cmdbForm`/`.cmdbFieldLabel` 三档行距(8/8/2);迁 FormRow 时一并删(ADR-0052 §7 第 ② 条)'],
|
|
176
|
+
['@pagoda-dsh-workbench/module-argocd:src/views/IdentityFields.tsx', 'argocd 自己那套两列 grid 的 2 处「标签 span + TextField/SelectField」;随 FormRow 迁(ADR-0052 §1/§5)'],
|
|
177
|
+
['@pagoda-dsh-workbench/module-argocd:src/views/IdentityFields.module.css', 'argocd `.fields` 网格自己管行距(gap);行距归 FormRow(s)(ADR-0052 §7 第 ② 条)'],
|
|
178
|
+
]);
|
|
179
|
+
|
|
46
180
|
/** 裸控件出现的位置 —— 逐行看不了(一个元素会跨行),所以在全文里按标签找,再回推行号。 */
|
|
47
181
|
function bareControlsIn(text) {
|
|
48
182
|
const hits = [];
|
|
@@ -139,6 +273,9 @@ export async function run() {
|
|
|
139
273
|
const borrowed = [];
|
|
140
274
|
const anchors = [];
|
|
141
275
|
const controls = [];
|
|
276
|
+
const rows = [];
|
|
277
|
+
// 这一轮"命中过"的 `包:文件`(含被豁免的)—— 豁免表里那些不再命中的行靠它发现(棘轮)。
|
|
278
|
+
const formRowHits = new Set();
|
|
142
279
|
const modules = discoverModulePackages({ root: process.cwd() });
|
|
143
280
|
const contracts = new Map(modules.map((pkg) => [pkg.dir, contractOf(pkg.dir)]));
|
|
144
281
|
for (const pkg of modules) {
|
|
@@ -162,6 +299,20 @@ export async function run() {
|
|
|
162
299
|
}
|
|
163
300
|
});
|
|
164
301
|
|
|
302
|
+
if (/\.css$/.test(file)) {
|
|
303
|
+
const key = `${pkg.manifest.name}:${relative(pkg.dir, file)}`;
|
|
304
|
+
const exemptRows = FORM_ROW_EXEMPT.has(key);
|
|
305
|
+
for (const hit of selfManagedRowsIn(text)) {
|
|
306
|
+
formRowHits.add(key);
|
|
307
|
+
if (exemptRows) continue;
|
|
308
|
+
rows.push(
|
|
309
|
+
`${pkg.manifest.name}: ${relative(pkg.dir, file)}:${hit.line} 模块自己管了行距(${hit.selector} 的 ${hit.decl})—— ` +
|
|
310
|
+
`行距归 FormRow(s)(ADR-0052 §7 第 ② 条:行距只有这一档,模块不许自己写):` +
|
|
311
|
+
`${(lines[hit.line - 1] ?? '').trim().slice(0, 80)}`,
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
165
316
|
if (isTestSource(file)) continue;
|
|
166
317
|
if (!/\.tsx?$/.test(file)) continue;
|
|
167
318
|
const exemptControls = CONTROL_EXEMPT.has(`${pkg.manifest.name}:${relative(pkg.dir, file)}`);
|
|
@@ -181,6 +332,19 @@ export async function run() {
|
|
|
181
332
|
);
|
|
182
333
|
}
|
|
183
334
|
}
|
|
335
|
+
if (/\.tsx$/.test(file)) {
|
|
336
|
+
const key = `${pkg.manifest.name}:${relative(pkg.dir, file)}`;
|
|
337
|
+
const exemptRows = FORM_ROW_EXEMPT.has(key);
|
|
338
|
+
for (const hit of handwrittenRowsIn(text)) {
|
|
339
|
+
formRowHits.add(key);
|
|
340
|
+
if (exemptRows) continue;
|
|
341
|
+
rows.push(
|
|
342
|
+
`${pkg.manifest.name}: ${relative(pkg.dir, file)}:${hit.line} 手写的「标签 + 控件」行(标签在 ${hit.labelLine} 行,控件是 ${hit.control})—— ` +
|
|
343
|
+
`用 FormRow:标签归它(ADR-0052 §2:面 → 行 → 控件,中间那层归 FormRow(s)):` +
|
|
344
|
+
`${(lines[hit.line - 1] ?? '').trim().slice(0, 80)}`,
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
184
348
|
if (contract === null) continue;
|
|
185
349
|
lines.forEach((line, index) => {
|
|
186
350
|
for (const match of line.matchAll(CLASS_TOKEN)) {
|
|
@@ -222,7 +386,20 @@ export async function run() {
|
|
|
222
386
|
}
|
|
223
387
|
}
|
|
224
388
|
|
|
225
|
-
|
|
389
|
+
// 棘轮的另一半:豁免表里的行如果**已经不再命中**(那个包在眼前,却一处都不报了),
|
|
390
|
+
// 说明它已经迁完/文件没了 —— 这一行该删。包不在眼前(别的仓库里)时不说它,免得误报。
|
|
391
|
+
const present = new Set(modules.map((pkg) => pkg.manifest.name));
|
|
392
|
+
const staleRows = [];
|
|
393
|
+
for (const [key, reason] of FORM_ROW_EXEMPT) {
|
|
394
|
+
const owner = key.slice(0, key.indexOf(':'));
|
|
395
|
+
if (!present.has(owner) || formRowHits.has(key)) continue;
|
|
396
|
+
staleRows.push(
|
|
397
|
+
`${key} 还在 FORM_ROW_EXEMPT 里,但一处都不再命中 —— 按棘轮删掉这一行(原由:${reason})`,
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (bindings.length + problems.length + borrowed.length + anchors.length + controls.length
|
|
402
|
+
+ rows.length + staleRows.length > 0) {
|
|
226
403
|
const notes = [];
|
|
227
404
|
if (bindings.length > 0) {
|
|
228
405
|
notes.push(
|
|
@@ -258,11 +435,25 @@ export async function run() {
|
|
|
258
435
|
'`type="hidden"` / `type="file"` 例外:那两种形态两处都没有)',
|
|
259
436
|
);
|
|
260
437
|
}
|
|
261
|
-
|
|
438
|
+
if (rows.length > 0) {
|
|
439
|
+
notes.push(
|
|
440
|
+
`${rows.length} 处是「手写的标签+控件行 或 模块自己管的行距」:(ADR-0052:面 → 行 → 控件,` +
|
|
441
|
+
'中间那层归 `FormRow(s)`。行里自己写 `<span className=…label>` 配 `SelectField` / `TextField` / ' +
|
|
442
|
+
'`ChoiceGroup` / `Input`、或给 field/form 容器设 `gap` / 上下 `margin`,就是这一层没有主人的信号 —— ' +
|
|
443
|
+
'换 `<FormRow label={…}>{控件}</FormRow>`,行距一起归它。窄口径与已知漏报见 style.mjs 里那两段注释)',
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
if (staleRows.length > 0) {
|
|
447
|
+
notes.push(
|
|
448
|
+
`${staleRows.length} 条 FORM_ROW_EXEMPT 已经不命中了:(棘轮:迁完一处删一行 —— ` +
|
|
449
|
+
'一个包迁完 FormRow 之后,它那几行豁免要跟着删,别留着让表长起来)',
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
// 各类问题各自成串,按原来的输出顺序(绑定 → 规则 → 借用 → 裸 <a> → 裸控件 → 表单行 → 过期豁免)拼在一起。
|
|
262
453
|
return {
|
|
263
454
|
status: 'fail',
|
|
264
455
|
notes,
|
|
265
|
-
problems: [...bindings, ...problems, ...borrowed, ...anchors, ...controls],
|
|
456
|
+
problems: [...bindings, ...problems, ...borrowed, ...anchors, ...controls, ...rows, ...staleRows],
|
|
266
457
|
};
|
|
267
458
|
}
|
|
268
459
|
|
|
@@ -291,9 +482,13 @@ export async function run() {
|
|
|
291
482
|
noContract.length === 0
|
|
292
483
|
? '借用的类都在契约里(外壳出货的 board.css + 外壳组件自己的样式表)'
|
|
293
484
|
: `${noContract.join(' / ')} 那一边找不到外壳的契约,借用这一项没查`;
|
|
485
|
+
const exemptNote =
|
|
486
|
+
formRowHits.size === 0
|
|
487
|
+
? '没有手写的「标签+控件」行,也没有模块自己管的行距'
|
|
488
|
+
: `表单「行」这一层还有 ${formRowHits.size} 个文件在 FORM_ROW_EXEMPT 里等外壳发版后迁(棘轮:迁一处删一行)`;
|
|
294
489
|
return {
|
|
295
490
|
status: 'pass',
|
|
296
|
-
notes: [`${modules.length} 个模块都没有定义外壳前缀(wb-*)的 CSS 规则;${contractNote}`],
|
|
491
|
+
notes: [`${modules.length} 个模块都没有定义外壳前缀(wb-*)的 CSS 规则;${contractNote};${exemptNote}`],
|
|
297
492
|
problems: [],
|
|
298
493
|
};
|
|
299
494
|
}
|