@dsh-workbench/module-tools 0.3.3 → 0.3.5
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 +1 -1
- package/bin/dsh-wb-module.mjs +3 -0
- package/package.json +2 -2
- package/src/arch.mjs +25 -3
- package/src/check-flags.mjs +22 -0
- package/src/check.mjs +21 -2
- package/src/checks/kit.mjs +68 -19
- package/src/checks/primitives.mjs +239 -5
- package/src/checks/style.mjs +231 -13
- package/src/declared-tools.mjs +1 -1
- package/src/design.mjs +2 -1
- package/src/discover.mjs +9 -1
- package/src/init.mjs +33 -0
- package/src/lint.mjs +1 -1
- package/src/primitives-surface.mjs +120 -0
- package/src/shell-type-files.mjs +119 -0
- package/template/package.json.tpl +2 -1
- package/template-diy/package.json.tpl +2 -1
package/src/checks/style.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// CSS Modules 的哈希类名靠"同一份文件、同一个导出"配对,调用点拿到 undefined 时真机渲染成
|
|
6
6
|
// `class="undefined"` —— 元素整片没样式,而且控制台一声不响。
|
|
7
7
|
//
|
|
8
|
-
// ④ 表单的「行」归 `FormRow(s)`([ADR-0052](
|
|
8
|
+
// ④ 表单的「行」归 `FormRow(s)`([ADR-0052](../../../../docs/adr/0052-the-form-row-is-one-component.md)):
|
|
9
9
|
// 控件有主(`TextField` / `SelectField` / `ChoiceGroup`)、面有主(`SettingsSection` / `SaveBar`),
|
|
10
10
|
// 中间那层"标签 + 控件这一行怎么排"从前没有主 ⇒ 每人都自己写一遍:cmdb 一套 `.cmdbField`
|
|
11
11
|
// (`display:flex` + `.cmdbFieldLabel{width:72px}`)+ 三档 gap(`.cmdbForm` 8 / `.cmdbEnvs` 8 /
|
|
@@ -25,7 +25,38 @@ import { auditFontSizes, fontScaleNotes, fontScaleOf } from '../font-scale.mjs';
|
|
|
25
25
|
|
|
26
26
|
// 外链一律走 kit 的 `Link`:它自带的样式(正文色 / hover 下划线 / 12px / 那个外链小箭头)是唯一一份。
|
|
27
27
|
// 手写 `<a>` / `h('a', …)` 拿到的是浏览器默认样式,而且每个人写的都不一样(这正是"同一个东西样式不一样"的来源)。
|
|
28
|
-
|
|
28
|
+
//
|
|
29
|
+
// **这条是全局匹配(`g`),按全文找再回推行号 —— 与 `bareControlsIn` / `bareFormsIn` 同形,别改回逐行。**
|
|
30
|
+
// 为什么(2026-10-21 实测踩到,`.scratch/uiux-ownership/` 候选 1):JSX 里 `<a` 习惯写在**行尾**
|
|
31
|
+
// (属性在下一行),而 `<a(?=[\s>/])` 要的那个 `[\s>/]` 在**下一行**;逐行 `.test(line)` 里
|
|
32
|
+
// 既没有那个字符、也没有换行本身 ⇒ **行尾写的 `<a` 一处都抓不到**。实测:pduiv2 5 个真模块包里
|
|
33
|
+
// 有 **8 处**裸 `<a>`,**8 处全部**写在行尾,逐行那条的命中是 **0** —— 判据与它自己的文档
|
|
34
|
+
//(`PANEL.md` §6「手写 `<a>` 会被「样式归属」判据直接判红」)打架了半年。全文匹配后正好 **8 命中**。
|
|
35
|
+
// 「一个元素会跨行写属性」这条理由与 `bareFormsIn` 上一行那句是**同一条**,别只给表单那一条用。
|
|
36
|
+
//
|
|
37
|
+
// **哪种 `<a>` 不算 `Link`(所以不算"绕过",收在 ANCHOR_EXEMPT 里)**:`Link` 的形态只有一种 ——
|
|
38
|
+
// **正文里的文字链**(文字 + 行尾那颗 ↗ 小箭头,12px、hover 下划线)。三种是它表达不了的:
|
|
39
|
+
// ① **图标链**:`<a className="wb-iconButton">` 里包一枚图标(平台跳转 / 看日志那些)——
|
|
40
|
+
// `Link` 一定会多画一颗箭头,套进去就是两颗;
|
|
41
|
+
// ② **按钮皮的链**:`.anchorButton` 那种 28 高 / 描边的"看起来像按钮、实际是链接"——
|
|
42
|
+
// [ADR-0054](docs/adr/0054-the-action-area-is-one-component.md) §11 判过:**必须是链接的动作留真 `<a>`**,
|
|
43
|
+
// 不许塞成 `onClick: () => window.open(…)` 的按钮(那一次会把中键新开 / 右键复制链接 / 悬停看 URL 一起丢掉);
|
|
44
|
+
// ③ **整行链**:`.issueLink` 那种 `flex: 1` / `gap` / `color: inherit` 的**行的布局槽**——
|
|
45
|
+
// 它是一条行的皮,不是正文里的字。
|
|
46
|
+
// 这三类今天在真模块包里有 6 处(argocd 的 EnvRows 两处图标链、coding-analysis 的 AnalysisBody 一处图标链、
|
|
47
|
+
// EnvSettings / AnalysisStates 两处按钮皮的链、coding-issues 的 IssueRow 一处整行链),逐文件收在
|
|
48
|
+
// `ANCHOR_EXEMPT` 里;**该迁 `Link` 的是另外 2 处**(argocd 的 EnvRows / IdentityFields 那两条「去登录」文字链)。
|
|
49
|
+
//
|
|
50
|
+
// **两端边界**(与 `CLASS_TOKEN` / `FORM_SLOT_CLASS` 同一套口径,2026-10-21 复核者提醒后补的):
|
|
51
|
+
// · **右**:`<a(?=[\s>/])` —— `<abbr>` 不算(`<a` 后面是 `b`),`<aside>` 也不算;
|
|
52
|
+
// · **左**:`(?<![\w$])` —— **`<` 前面不许是标识符字符**。挡的是 JS 里那个真实形态:
|
|
53
|
+
// `if (count<a ) …`(`count < a` 写掉了空格)今天会命中 `<a ` ⇒ 报一个跟界面无关的红。
|
|
54
|
+
// 用 lookbehind(非消费)而不是 `(^|[^\w$])` 那种前缀,是因为这一条要按 `match.index`
|
|
55
|
+
// **回推行号**(`bareAnchorsIn`):消费式前缀落在上一行的换行上时,行号会算到上一行去。
|
|
56
|
+
// **`h('a'` / `jsx('a'` 那两支也吃同一个左边界**(整组一起套),所以 `myh('a', …)` 不算。
|
|
57
|
+
// **仍然红的、如实记**:`<a` 写在**字符串**里(例如提示文案里那句 `<a href=…>`)—— 与 `BARE_FORM` 那条
|
|
58
|
+
// 一样是**文本级**匹配,这是取舍不是漏报(要收紧先改口径,判据不自己长)。
|
|
59
|
+
const BARE_ANCHOR = /(?<![\w$])(?:h\(\s*['"]a['"]|jsx\(\s*['"]a['"]|<a(?=[\s>/]))/g;
|
|
29
60
|
|
|
30
61
|
// 交互控件同一条理由:按钮 / 单行输入 / 下拉在 kit 与 dsh 原语里都有对应形态
|
|
31
62
|
// (`Button` · `TextField` · `SelectField` / `ChoiceGroup`),手写的那一份拿到的是浏览器默认样式 ——
|
|
@@ -76,7 +107,7 @@ const ZERO_LENGTH = /^(?:0|0px|0rem|0em|0%|none|auto)$/i;
|
|
|
76
107
|
|
|
77
108
|
// ── F3「手写的操作区」:该走 `FormActions`(ADR-0054) ─────────────────────────────
|
|
78
109
|
//
|
|
79
|
-
// 口径([ADR-0054](
|
|
110
|
+
// 口径([ADR-0054](../../../../docs/adr/0054-the-action-area-is-one-component.md)):操作区那一层
|
|
80
111
|
// (主操作在右 / 一颗 primary / 危险动作紧挨它左侧 / 间距 / 窄容器换行)从前没有主,只有一条
|
|
81
112
|
// 借来的槽类 `.wb-formActions`(它只管右对齐 + gap)。于是顺序各摆各的 —— 2026-10-17 用户在
|
|
82
113
|
// argocd 绑定身份那屏说「这两个按钮的位置换一下」就是现场(pduiv2 `b7977862`)。
|
|
@@ -92,6 +123,39 @@ const ZERO_LENGTH = /^(?:0|0px|0rem|0em|0%|none|auto)$/i;
|
|
|
92
123
|
// 若自己写 `gap` / 上下 `margin`,还会被 F2 顺带抓到。
|
|
93
124
|
const ACTION_AREA_CLASS = /wb-formActions/;
|
|
94
125
|
|
|
126
|
+
// ── F4「手写的整张表单」:该走 `Form` ─────────────────────────────────────────────
|
|
127
|
+
//
|
|
128
|
+
// 口径照抄 [ADR-0054](../../../../docs/adr/0054-the-action-area-is-one-component.md) §5 与 §9:判据**只**回答
|
|
129
|
+
// "这一层是不是绕过组件自己搭的",**不钉"看起来对不对"**。表单的**面**(行怎么排、错误摆哪、什么时候
|
|
130
|
+
// 校验、提交怎么被挡住、第一处错误怎么找、操作区怎么排)从前没有主 ⇒ 每个调用点自己拼。
|
|
131
|
+
// 用户看到的就是按钮"一会在左一会在右":`.wb-formActions` 的 `justify-content: flex-end` 只在它的盒子
|
|
132
|
+
// 有富余宽度时才有效,放进横向 flex / 网格格子里(子项按内容宽收缩)就顶到左边 —— 同一个组件两个位置两种观感。
|
|
133
|
+
//
|
|
134
|
+
// 判据:模块源码(`.tsx` / `.ts`,测试文件除外)里出现下面任一个 ⇒ 红,改用 kit 的 `Form`:
|
|
135
|
+
// ① 裸 `<form>` 元素(`<form` …`>`,含 `h('form', …)` / `jsx('form', …)`)—— 表单这一层归 `Form`;
|
|
136
|
+
// ② 手写行的槽类 `wb-formRow` / `wb-formRows` / `wb-field`。
|
|
137
|
+
// **为什么只钉这两条(宁可漏报)**:与 F3 同一套口径 —— 这三个槽类只为"行/字段"这一层存在
|
|
138
|
+
//(`shared/src/components/FormRows.css` 的 `.wb-formRow(s)`、`Field.css` 的 `.wb-field`),
|
|
139
|
+
// 出现它们 = 明确在绕过 `FormRow(s)` 手摆行,**零误报**;`<form>` 是浏览器元素,这一层的唯一出处是 kit 的 `Form`。
|
|
140
|
+
// **窄在哪**:`wb-field` 后面跟着标识符或 `-` 的不算 —— `wb-fieldLabel` / `wb-fieldHint` 是那一层的
|
|
141
|
+
// **兄弟**类(`Field` 自己渲染,借用它们不等于"手写了一行"),归属见 F1/F2,别在这里一并判红。
|
|
142
|
+
// **已知漏报(如实记)**:模块自己用 div 拼一张既没有 `<form>`、也没借槽类的表单,这一版不判 ——
|
|
143
|
+
// 判宽了就是"用户每次改界面都先撞门禁"(ADR-0054 §5 那条代价照旧)。这一条靠 KIT.md 与 `Form` 的
|
|
144
|
+
// JSDoc 顶上(ADR-0067:用法住在声明里)。`Form` 这一层的决定、四条行为口径与**完整的漏报清单**
|
|
145
|
+
// (含"借 `wb-formLabel` 自己拼行 F4 不判"那条)见
|
|
146
|
+
// [ADR-0068](../../../../docs/adr/0068-the-form-is-one-component.md)。
|
|
147
|
+
const BARE_FORM = /(?:h\(\s*['"]form['"]|jsx\(\s*['"]form['"]|<form(?=[\s>/]))/g;
|
|
148
|
+
// 这三个槽类的名字:**左右两端都要有边界**(与 `CLASS_TOKEN` 同一套口径,别只写右边那一个)。
|
|
149
|
+
// 只写右边(原来的 `wb-field(?![\w-])`)时,**粘在前缀上的根本不是槽类**却会被判红 ——
|
|
150
|
+
// `data-wb-field="x"`(属性名;独立复核者 2026-10-21 报的那一处)· `xwb-field` · `my-wb-formRows`,全中。
|
|
151
|
+
// 左边用 `(?<![-\w])`:`-` 与标识符字符都不许紧贴在 `wb-` 前面(`my-wb-field` 不算,`className="wb-field"` 算)。
|
|
152
|
+
// **为什么用 lookbehind、不用 `CLASS_TOKEN` 那种消费式前缀 `(^|[^-\w])`**:这一条匹配出来就 `match[0]`,
|
|
153
|
+
// 报错话里印的是 `${slot[0]}` —— 消费式前缀会把前缀一起印进那句人话;用 lookbehind 既印得准,
|
|
154
|
+
// 也不用像 `CLASS_TOKEN` 那样回头按偏移找名字(少一处偏移算术就少一处错的可能)。
|
|
155
|
+
// **今天 0 现实命中**(本仓 + pduiv2 五个模块包都 grep 过:`data-wb-*` 只有 `data-wb-dot` /
|
|
156
|
+
// `data-wb-problem` / `data-wb-select-reveal` 这类,一个都不是槽类)—— 收的是**潜在**误报,不是现实伤。
|
|
157
|
+
const FORM_SLOT_CLASS = /(?<![-\w])(?:wb-formRows?(?![\w-])|wb-field(?![\w-]))/;
|
|
158
|
+
|
|
95
159
|
/** 类名里有没有 `field(s)` / `form(s)` 这个词(按 camelCase 切词,`platform` 不算)。 */
|
|
96
160
|
function isFormContainer(token) {
|
|
97
161
|
return token.split(/(?=[A-Z])/).some((word) => /^(?:fields?|forms?)$/i.test(word))
|
|
@@ -198,6 +262,63 @@ const FORM_ROW_EXEMPT = new Map([]);
|
|
|
198
262
|
// 直接报红(不再有豁免这一层)。
|
|
199
263
|
const FORM_ACTIONS_EXEMPT = new Map([]);
|
|
200
264
|
|
|
265
|
+
// 历史豁免:**已知**的手写表单(裸 `<form>` / 借了 `wb-formRow(s)` / `wb-field` 槽类手摆行)。
|
|
266
|
+
// 它跟上面三张又是**另一件事、另一个迁移时点**:等的是外壳把 `Form` 这一层发出来(含 `Form` 的那一版)。
|
|
267
|
+
// 理由都指向 ADR-0054 §5(判据边界:只钉"有没有绕过组件")与 §9(为什么只钉槽类、宁可漏报)。
|
|
268
|
+
// 这是**棘轮**:迁一处删一行;某个包在眼前却一处都不再命中时,那条豁免当场报红
|
|
269
|
+
//(见 run() 里的 staleFormRows)。键同形:`<包名>:<相对模块根的文件路径>`。
|
|
270
|
+
// 2026-10-21 实测(只读扫眼前这几个真模块包):**0 命中** ——
|
|
271
|
+
// pduiv2 `dsh-workbench-{argocd,cmdb,coding-analysis,coding-issues,gitops}`(`<form` 一处没有,
|
|
272
|
+
// 三个槽类一处没有)、本仓 `module-hello`(`platform-coding` 不是模块包,没有 `dsh.workbench`)。
|
|
273
|
+
// 所以这里留成**空表**,不许为了"看起来有事做"把规则收宽或写成"全部":
|
|
274
|
+
// 它就是这条判据的棘轮位 —— 谁开始手写表单,直接报红(不再有豁免这一层)。
|
|
275
|
+
const FORM_EXEMPT = new Map([]);
|
|
276
|
+
|
|
277
|
+
// 历史豁免:**已知**的手写裸 `<a>`。它跟上面四张**又是另一件事、另一个迁移时点**:
|
|
278
|
+
// 那一张等的是外壳把 `Link` 发出来(`Link` 从 0.2.x 起就有 ⇒ **这一张没有"等发版"这回事**,
|
|
279
|
+
// 它等的是**逐处判断"这一条到底该不该用 Link"**)。
|
|
280
|
+
// 为什么单开一张、不混进 `CONTROL_EXEMPT`:那一张等的是 kit 成员(`Button` / `TextField`…),
|
|
281
|
+
// 这一张要记的是"三种 `Link` 表达不了的形态"(图标链 / 按钮皮的链 / 整行链)——
|
|
282
|
+
// 口径写在 `BARE_ANCHOR` 那一段注释里,README 的 `style` 那一行也有一份。
|
|
283
|
+
// 这是**棘轮**:迁一处删一行;某个包在眼前却一处都不再命中时,那条豁免当场报红
|
|
284
|
+
//(见 run() 里的 staleAnchorRows)。键同形:`<包名>:<相对模块根的文件路径>`。
|
|
285
|
+
//
|
|
286
|
+
// 2026-10-21 实测(只读 pduiv2):**8 处 / 6 个文件**,全部收在这里 ——
|
|
287
|
+
// 这一天之前它们**一处都没被判据抓到**(`BARE_ANCHOR` 还是逐行匹配,而 8 处 `<a` 全写在行尾)。
|
|
288
|
+
// 理由分两类,逐条写在下面:**「该迁 `Link`」2 处**(两条「去登录」文字链,是 `Link` 的形状)、
|
|
289
|
+
// **「有意的不算」6 处**(图标链 / 按钮皮的链 / 整行链)。哪一处是迁、哪一处是留,别照抄文件名判断 ——
|
|
290
|
+
// **一个文件里可以两种都有**(`module-argocd: src/views/EnvRows.tsx` 就是:两条图标链该留、一条文字链该迁)。
|
|
291
|
+
const ANCHOR_EXEMPT = new Map([
|
|
292
|
+
[
|
|
293
|
+
'@pagoda-dsh-workbench/module-argocd:src/views/EnvRows.tsx',
|
|
294
|
+
'3 处::212 / :298 是**图标链**(`<a className="wb-iconButton">` + 一枚 ↗,`Link` 会多画一颗箭头)⇒ 有意保留;' +
|
|
295
|
+
':401 是 `.inlineLink` + 文案「去登录」+ ↗12 ⇒ **该迁 `Link`**(这一处是"迁一处删一行"里要删的那一格,不是这一行)',
|
|
296
|
+
],
|
|
297
|
+
[
|
|
298
|
+
'@pagoda-dsh-workbench/module-argocd:src/views/IdentityFields.tsx',
|
|
299
|
+
'1 处::50 是 `.loginLink` + 文案「去登录」+ ↗12 ⇒ **该迁 `Link`**(迁完把这一行删掉;迁的时候会**有意**变两处观感:' +
|
|
300
|
+
'字号 11 → 12(`Link` 是 `--dsw-font-xxs-12`)、多一条 hover 下划线)',
|
|
301
|
+
],
|
|
302
|
+
[
|
|
303
|
+
'@pagoda-dsh-workbench/module-coding-analysis:src/views/AnalysisBody.tsx',
|
|
304
|
+
'1 处::42 是**图标链**(`wb-iconButton` 槽 + 一枚 ↗ + `title` / `aria-label`)⇒ 有意保留',
|
|
305
|
+
],
|
|
306
|
+
[
|
|
307
|
+
'@pagoda-dsh-workbench/module-coding-analysis:src/views/EnvSettings.tsx',
|
|
308
|
+
'1 处::166 是**按钮皮的链**(`.anchorButton`:28 高 / 描边 / 12px)—— ADR-0054 §11 逐字判过' +
|
|
309
|
+
'「必须是链接的动作留真 `<a>`,不许塞成 `window.open` 的按钮」⇒ 有意保留',
|
|
310
|
+
],
|
|
311
|
+
[
|
|
312
|
+
'@pagoda-dsh-workbench/module-coding-analysis:src/views/AnalysisStates.tsx',
|
|
313
|
+
'1 处::122 同 `EnvSettings.tsx:166`(同一个 `.anchorButton` 皮、同一屏的兄弟)⇒ 有意保留',
|
|
314
|
+
],
|
|
315
|
+
[
|
|
316
|
+
'@pagoda-dsh-workbench/module-coding-issues:src/components/IssueRow.tsx',
|
|
317
|
+
'1 处::194 是**整行链**(`.issueLink`:`flex: 1` / `gap: 8` / `color: inherit`)—— 它是那条行的布局槽,' +
|
|
318
|
+
'不是正文里的文字链 ⇒ 有意保留',
|
|
319
|
+
],
|
|
320
|
+
]);
|
|
321
|
+
|
|
201
322
|
/** 裸控件出现的位置 —— 逐行看不了(一个元素会跨行),所以在全文里按标签找,再回推行号。 */
|
|
202
323
|
function bareControlsIn(text) {
|
|
203
324
|
const hits = [];
|
|
@@ -214,6 +335,25 @@ function bareControlsIn(text) {
|
|
|
214
335
|
return hits;
|
|
215
336
|
}
|
|
216
337
|
|
|
338
|
+
/** 裸 `<form>` 出现的位置 —— 一个元素会跨行写属性,所以按全文找、再回推行号。 */
|
|
339
|
+
function bareFormsIn(text) {
|
|
340
|
+
return Array.from(text.matchAll(BARE_FORM), (match) => ({
|
|
341
|
+
line: text.slice(0, match.index).split('\n').length,
|
|
342
|
+
}));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** 裸 `<a>` 出现的位置 —— 与 `bareControlsIn` / `bareFormsIn` 同一条理由:一个元素会跨行写属性
|
|
346
|
+
* (`<a` 常常就落在行尾),所以按**全文**找、再回推行号,不逐行看。
|
|
347
|
+
* 开头那一句 `lastIndex` 归零是**防呆**:`matchAll` 会照抄原正则的 `lastIndex`,
|
|
348
|
+
* 而这个正则是带 `g` 的共享常量 —— 谁哪天在别处对它 `.test(...)` 一次(旧版就是逐行 `.test`),
|
|
349
|
+
* 下一次 `matchAll` 就从那个位置接着找,**静默漏掉前半份命中**。实测过这个坑。 */
|
|
350
|
+
function bareAnchorsIn(text) {
|
|
351
|
+
BARE_ANCHOR.lastIndex = 0;
|
|
352
|
+
return Array.from(text.matchAll(BARE_ANCHOR), (match) => ({
|
|
353
|
+
line: text.slice(0, match.index).split('\n').length,
|
|
354
|
+
}));
|
|
355
|
+
}
|
|
356
|
+
|
|
217
357
|
const CLASS_TOKEN = /(^|[^-\w])\.?(wb-[a-zA-Z0-9-]+)/g;
|
|
218
358
|
// 契约 = 外壳**所有**声明了 `wb-*` 的样式表,不只是那份全局 `board.css`:
|
|
219
359
|
// ADR-0029 要求组件的样式跟组件走,所以 `wb-card` / `wb-rows` / `wb-note` 这些已经搬进
|
|
@@ -340,9 +480,12 @@ export async function run() {
|
|
|
340
480
|
const controls = [];
|
|
341
481
|
const rows = [];
|
|
342
482
|
const actionAreas = [];
|
|
483
|
+
const forms = [];
|
|
343
484
|
// 这一轮"命中过"的 `包:文件`(含被豁免的)—— 豁免表里那些不再命中的行靠它发现(棘轮)。
|
|
344
485
|
const formRowHits = new Set();
|
|
345
486
|
const actionAreaHits = new Set();
|
|
487
|
+
const formHits = new Set();
|
|
488
|
+
const anchorHits = new Set();
|
|
346
489
|
const modules = discoverModulePackages({ root: process.cwd() });
|
|
347
490
|
const contracts = new Map(modules.map((pkg) => [pkg.dir, contractOf(pkg.dir)]));
|
|
348
491
|
for (const pkg of modules) {
|
|
@@ -383,13 +526,17 @@ export async function run() {
|
|
|
383
526
|
if (isTestSource(file)) continue;
|
|
384
527
|
if (!/\.tsx?$/.test(file)) continue;
|
|
385
528
|
const exemptControls = CONTROL_EXEMPT.has(`${pkg.manifest.name}:${relative(pkg.dir, file)}`);
|
|
386
|
-
|
|
387
|
-
|
|
529
|
+
const anchorKey = `${pkg.manifest.name}:${relative(pkg.dir, file)}`;
|
|
530
|
+
const exemptAnchors = ANCHOR_EXEMPT.has(anchorKey);
|
|
531
|
+
for (const hit of bareAnchorsIn(text)) {
|
|
532
|
+
anchorHits.add(anchorKey);
|
|
533
|
+
if (exemptAnchors) continue;
|
|
388
534
|
anchors.push(
|
|
389
|
-
`${pkg.manifest.name}: ${relative(pkg.dir, file)}:${
|
|
390
|
-
|
|
535
|
+
`${pkg.manifest.name}: ${relative(pkg.dir, file)}:${hit.line} 手写了裸 <a> —— ` +
|
|
536
|
+
`正文里的文字链一律用 kit 的 Link(新窗口、noopener、自带那个外链小箭头;样式只有这一份):` +
|
|
537
|
+
`${(lines[hit.line - 1] ?? '').trim().slice(0, 80)}`,
|
|
391
538
|
);
|
|
392
|
-
}
|
|
539
|
+
}
|
|
393
540
|
if (!exemptControls) {
|
|
394
541
|
for (const hit of bareControlsIn(text)) {
|
|
395
542
|
controls.push(
|
|
@@ -425,6 +572,29 @@ export async function run() {
|
|
|
425
572
|
`${line.trim().slice(0, 80)}`,
|
|
426
573
|
);
|
|
427
574
|
});
|
|
575
|
+
const formKey = `${pkg.manifest.name}:${relative(pkg.dir, file)}`;
|
|
576
|
+
const exemptForms = FORM_EXEMPT.has(formKey);
|
|
577
|
+
for (const hit of bareFormsIn(text)) {
|
|
578
|
+
formHits.add(formKey);
|
|
579
|
+
if (exemptForms) continue;
|
|
580
|
+
forms.push(
|
|
581
|
+
`${pkg.manifest.name}: ${relative(pkg.dir, file)}:${hit.line} 手写了裸 <form> —— ` +
|
|
582
|
+
'改用 kit 的 Form(fields 进、其余全归它:行的排布 / 校验时机 / 错误摆哪 / 提交怎么被挡住 / ' +
|
|
583
|
+
'第一处错误怎么找 / 操作区怎么排):' +
|
|
584
|
+
`${(lines[hit.line - 1] ?? '').trim().slice(0, 80)}`,
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
lines.forEach((line, index) => {
|
|
588
|
+
const slot = line.match(FORM_SLOT_CLASS);
|
|
589
|
+
if (slot === null) return;
|
|
590
|
+
formHits.add(formKey);
|
|
591
|
+
if (exemptForms) return;
|
|
592
|
+
forms.push(
|
|
593
|
+
`${pkg.manifest.name}: ${relative(pkg.dir, file)}:${index + 1} 手写了表单的行(借了外壳的槽类 ${slot[0]})—— ` +
|
|
594
|
+
'改用 kit 的 Form(面 → 行 → 控件:标签 + 控件这一行归 FormRow(s),整张表单的面归 Form):' +
|
|
595
|
+
`${line.trim().slice(0, 80)}`,
|
|
596
|
+
);
|
|
597
|
+
});
|
|
428
598
|
if (contract === null) continue;
|
|
429
599
|
lines.forEach((line, index) => {
|
|
430
600
|
for (const match of line.matchAll(CLASS_TOKEN)) {
|
|
@@ -485,9 +655,26 @@ export async function run() {
|
|
|
485
655
|
`${key} 还在 FORM_ACTIONS_EXEMPT 里,但一处都不再命中 —— 按棘轮删掉这一行(原由:${reason})`,
|
|
486
656
|
);
|
|
487
657
|
}
|
|
658
|
+
const staleFormRows = [];
|
|
659
|
+
for (const [key, reason] of FORM_EXEMPT) {
|
|
660
|
+
const owner = key.slice(0, key.indexOf(':'));
|
|
661
|
+
if (!present.has(owner) || formHits.has(key)) continue;
|
|
662
|
+
staleFormRows.push(
|
|
663
|
+
`${key} 还在 FORM_EXEMPT 里,但一处都不再命中 —— 按棘轮删掉这一行(原由:${reason})`,
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
const staleAnchorRows = [];
|
|
667
|
+
for (const [key, reason] of ANCHOR_EXEMPT) {
|
|
668
|
+
const owner = key.slice(0, key.indexOf(':'));
|
|
669
|
+
if (!present.has(owner) || anchorHits.has(key)) continue;
|
|
670
|
+
staleAnchorRows.push(
|
|
671
|
+
`${key} 还在 ANCHOR_EXEMPT 里,但一处都不再命中 —— 按棘轮删掉这一行(原由:${reason})`,
|
|
672
|
+
);
|
|
673
|
+
}
|
|
488
674
|
|
|
489
675
|
if (bindings.length + problems.length + borrowed.length + anchors.length + controls.length
|
|
490
|
-
+ rows.length + actionAreas.length + staleRows.length + staleActionRows.length
|
|
676
|
+
+ rows.length + actionAreas.length + forms.length + staleRows.length + staleActionRows.length
|
|
677
|
+
+ staleFormRows.length + staleAnchorRows.length > 0) {
|
|
491
678
|
const notes = [];
|
|
492
679
|
if (bindings.length > 0) {
|
|
493
680
|
notes.push(
|
|
@@ -511,8 +698,10 @@ export async function run() {
|
|
|
511
698
|
}
|
|
512
699
|
if (anchors.length > 0) {
|
|
513
700
|
notes.push(
|
|
514
|
-
`${anchors.length} 处是「手写了裸 <a
|
|
515
|
-
'
|
|
701
|
+
`${anchors.length} 处是「手写了裸 <a>」:(**正文里的文字链**用 kit 的 Link —— 样式只有一份,` +
|
|
702
|
+
'改一处全局一致;裸 <a> 拿到的是浏览器默认样式,看着和卡片其它地方不是一套。' +
|
|
703
|
+
'三种不算"绕过"的形态 —— 图标链 / 按钮皮的链 / 整行链 —— 逐文件收在 `ANCHOR_EXEMPT` 里,' +
|
|
704
|
+
'口径见 style.mjs 里 `BARE_ANCHOR` 那一段注释与 README 的 `style` 那一行)',
|
|
516
705
|
);
|
|
517
706
|
}
|
|
518
707
|
if (controls.length > 0) {
|
|
@@ -540,6 +729,16 @@ export async function run() {
|
|
|
540
729
|
'窄口径与已知漏报见 style.mjs 里那一段注释)',
|
|
541
730
|
);
|
|
542
731
|
}
|
|
732
|
+
if (forms.length > 0) {
|
|
733
|
+
notes.push(
|
|
734
|
+
`${forms.length} 处是「手写了整张表单(裸 <form> 元素,或借了外壳的槽类 wb-formRow / wb-formRows / wb-field)」:(` +
|
|
735
|
+
'表单那一层归 `Form` —— fields 进、其余全归它:行的排布、校验的时机(还没提交过时一格错都不显示)、' +
|
|
736
|
+
'错误摆哪、提交怎么被挡住、第一处错误怎么带进视口、操作区怎么排都由组件定,调用点只给数据与语义。' +
|
|
737
|
+
'`wb-formRow` / `wb-formRows` / `wb-field` 这三个槽类只为"行 / 字段"这一层存在,出现它们就是自己摆了一套:' +
|
|
738
|
+
'改用 kit 的 `Form`(`<Form fields={…} onSubmit={…} submit={…} />`)。' +
|
|
739
|
+
'窄口径与已知漏报见 style.mjs 里那一段注释)',
|
|
740
|
+
);
|
|
741
|
+
}
|
|
543
742
|
if (staleRows.length > 0) {
|
|
544
743
|
notes.push(
|
|
545
744
|
`${staleRows.length} 条 FORM_ROW_EXEMPT 已经不命中了:(棘轮:迁完一处删一行 —— ` +
|
|
@@ -552,7 +751,19 @@ export async function run() {
|
|
|
552
751
|
'一个包迁完 FormActions 之后,它那几行豁免要跟着删,别留着让表长起来)',
|
|
553
752
|
);
|
|
554
753
|
}
|
|
555
|
-
|
|
754
|
+
if (staleFormRows.length > 0) {
|
|
755
|
+
notes.push(
|
|
756
|
+
`${staleFormRows.length} 条 FORM_EXEMPT 已经不命中了:(棘轮:迁完一处删一行 —— ` +
|
|
757
|
+
'一个包迁完 Form 之后,它那几行豁免要跟着删,别留着让表长起来)',
|
|
758
|
+
);
|
|
759
|
+
}
|
|
760
|
+
if (staleAnchorRows.length > 0) {
|
|
761
|
+
notes.push(
|
|
762
|
+
`${staleAnchorRows.length} 条 ANCHOR_EXEMPT 已经不命中了:(棘轮:迁完一处删一行 —— ` +
|
|
763
|
+
'一个文件里的裸 <a> 都迁成 Link(或那个文件没了)之后,它那一行豁免要跟着删,别留着让表长起来)',
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
// 各类问题各自成串,按原来的输出顺序(绑定 → 规则 → 借用 → 裸 <a> → 裸控件 → 表单行 → 操作区 → 整张表单 → 过期豁免)拼在一起。
|
|
556
767
|
return {
|
|
557
768
|
status: 'fail',
|
|
558
769
|
notes,
|
|
@@ -564,8 +775,11 @@ export async function run() {
|
|
|
564
775
|
...controls,
|
|
565
776
|
...rows,
|
|
566
777
|
...actionAreas,
|
|
778
|
+
...forms,
|
|
567
779
|
...staleRows,
|
|
568
780
|
...staleActionRows,
|
|
781
|
+
...staleFormRows,
|
|
782
|
+
...staleAnchorRows,
|
|
569
783
|
],
|
|
570
784
|
};
|
|
571
785
|
}
|
|
@@ -603,6 +817,10 @@ export async function run() {
|
|
|
603
817
|
actionAreaHits.size === 0
|
|
604
818
|
? '没有手写的操作区'
|
|
605
819
|
: `操作区这一层还有 ${actionAreaHits.size} 个文件在 FORM_ACTIONS_EXEMPT 里等外壳发版后迁(棘轮:迁一处删一行)`;
|
|
820
|
+
const formNote =
|
|
821
|
+
formHits.size === 0
|
|
822
|
+
? '没有手写的整张表单(没有裸 <form>,也没有借表单槽类手摆行)'
|
|
823
|
+
: `表单这一层还有 ${formHits.size} 个文件在 FORM_EXEMPT 里等外壳发版后迁(棘轮:迁一处删一行)`;
|
|
606
824
|
// 黄的那一条:各类条数 + 涉及文件数 + 最多十来行明细 + 指路文档(口径见上面那一段注释)。
|
|
607
825
|
const fontScale = fontScaleOf(SHELL_FONT_SCALE_FILES);
|
|
608
826
|
const fontNotes = fontScaleNotes(
|
|
@@ -613,7 +831,7 @@ export async function run() {
|
|
|
613
831
|
return {
|
|
614
832
|
status: 'pass',
|
|
615
833
|
notes: [
|
|
616
|
-
`${modules.length} 个模块都没有定义外壳前缀(wb-*)的 CSS 规则;${contractNote};${exemptNote};${actionNote}`,
|
|
834
|
+
`${modules.length} 个模块都没有定义外壳前缀(wb-*)的 CSS 规则;${contractNote};${exemptNote};${actionNote};${formNote}`,
|
|
617
835
|
...fontNotes,
|
|
618
836
|
],
|
|
619
837
|
problems: [],
|
package/src/declared-tools.mjs
CHANGED
|
@@ -47,7 +47,7 @@ export function declaredToolsWithoutKey(raw) {
|
|
|
47
47
|
.map((tool) => JSON.stringify(tool?.name));
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
/** 生成物的正文。**不给注释**:它落在模块的 `src
|
|
50
|
+
/** 生成物的正文。**不给注释**:它落在模块的 `src/**`(那里只许贴导出的 JSDoc,生成这份一行都不加),改它无效、过期会红。 */
|
|
51
51
|
export function renderDeclaredTools(raw) {
|
|
52
52
|
const rows = toolsOf(raw).map(
|
|
53
53
|
(tool) =>
|
package/src/design.mjs
CHANGED
|
@@ -49,7 +49,7 @@ const ROWS = {
|
|
|
49
49
|
acceptance: { fields: ['step'] },
|
|
50
50
|
};
|
|
51
51
|
const DATASOURCE_FIELDS = ['read', 'params', 'credentials', 'notLoggedIn', 'timeout'];
|
|
52
|
-
const WRITE_FIELDS = ['command', 'params', 'credentials', 'preview', 'readback', 'failure'];
|
|
52
|
+
const WRITE_FIELDS = ['command', 'params', 'credentials', 'preview', 'readback', 'undo', 'failure'];
|
|
53
53
|
|
|
54
54
|
/** 一条卡背后可以有多条读(甚至跨站点):规范形状 `reads: [ … ]`,一条一行;
|
|
55
55
|
* `datasource: { … }` 是只有一条读时的写法(老模块都长这样,继续认)。 */
|
|
@@ -99,6 +99,7 @@ function writeRowsOf(write) {
|
|
|
99
99
|
`| 凭据从哪来 | ${cell(write?.credentials)} |`,
|
|
100
100
|
`| 有没有预览档(没有就写"没有,一次就是真的") | ${cell(write?.preview)} |`,
|
|
101
101
|
`| 写完之后怎么回读确认(哪条读 / 出参哪个字段) | ${cell(write?.readback)} |`,
|
|
102
|
+
`| 配对的那个撤销 / 删除(没有就写"没有配对,一次就是真的") | ${cell(write?.undo)} |`,
|
|
102
103
|
`| 失败长什么样(含"可能已经写了") | ${cell(write?.failure)} |`,
|
|
103
104
|
`| 当初探明 / 预览用的那次调用(原文) | ${soft(write?.probeCommand)} |`,
|
|
104
105
|
];
|
package/src/discover.mjs
CHANGED
|
@@ -29,7 +29,15 @@ export function discoverPackages({ root = process.cwd() } = {}) {
|
|
|
29
29
|
const packagesDir = join(workspace, 'packages');
|
|
30
30
|
if (existsSync(packagesDir)) {
|
|
31
31
|
for (const entry of readdirSync(packagesDir, { withFileTypes: true })) {
|
|
32
|
-
|
|
32
|
+
// 点开头的目录一律不认:那是**自检的临时探针**(mkdtemp 造、跑完删;名字前那一点就是为了
|
|
33
|
+
// 不跟真包混)。不排除它们有个很坑的后果 —— 别的链/别的进程正在写一个探针时,这条链会把
|
|
34
|
+
// 它当成真模块包算进契约 / 卡片 / kit 那几条,于是红得跟你改的东西毫无关系:
|
|
35
|
+
// 2026-10-19 实测:一条链留下的 `.writes-*` 让另一条链的 lint 阶段
|
|
36
|
+
// ERR_MODULE_NOT_FOUND(探针里的 eslint.config.mjs 解析不到 module-tools);
|
|
37
|
+
// 另一条链正在写的 `.init-*` 让四条判据集体红("它们会被当成真的模块包参与后面的每一条判据")。
|
|
38
|
+
// 残留探针本身仍由自检那条「探针清理干净」点名叫出来 —— 这里只是**不让它参与判据**。
|
|
39
|
+
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
|
|
40
|
+
dirs.push(join(packagesDir, entry.name));
|
|
33
41
|
}
|
|
34
42
|
}
|
|
35
43
|
dirs.push(root, workspace);
|
package/src/init.mjs
CHANGED
|
@@ -16,6 +16,34 @@ const UNIT_ID = /^[a-z][a-z0-9-]*$/;
|
|
|
16
16
|
// 为什么两个 token 共用一个值:`check:kit` 的口径是 peer 下限不许低于 devDependency 下限。
|
|
17
17
|
const FALLBACK_RANGE = '^0.1.0';
|
|
18
18
|
|
|
19
|
+
// 模板默认带的那份 dsh 原语(`devDependencies` 精确版 + `dsh.client.external` 一行)。
|
|
20
|
+
//
|
|
21
|
+
// 版本取自**装机外壳 manifest 的 `devDependencies`** —— 那是外壳编译时对账过的那版
|
|
22
|
+
// (`packages/workbench/package.json` 的 `@deepseek-ai/dsh-client-ui-primitives`),
|
|
23
|
+
// 不是外壳自己的版本号,两者不是一个号(外壳 0.3.x / 原语 0.2.1-rc.x)。
|
|
24
|
+
// 为什么用精确版:类型面要对齐对账过的那一版;`^` 会把"没验过的下一版"放进来。
|
|
25
|
+
// 读不到时兜底 + 说一句人话(与 FALLBACK_RANGE 同一处理):静默留空会让生成的
|
|
26
|
+
// package.json 声明一个谁也不认的版本。见 `.scratch/dsh-primitives-face/spec.md` §3.1。
|
|
27
|
+
const FALLBACK_DSH_UI = '0.2.1-rc.2';
|
|
28
|
+
const DSH_UI = '@deepseek-ai/dsh-client-ui-primitives';
|
|
29
|
+
|
|
30
|
+
const dshUiVersion = (root) => {
|
|
31
|
+
try {
|
|
32
|
+
const pinned = JSON.parse(
|
|
33
|
+
readFileSync(join(root, 'node_modules/@dsh-workbench/core/package.json'), 'utf8'),
|
|
34
|
+
).devDependencies?.[DSH_UI];
|
|
35
|
+
if (typeof pinned === 'string' && pinned !== '') {
|
|
36
|
+
return { version: pinned, source: '外壳 manifest 里钉的(对账过的那版)' };
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
// 读不到就走下面的兜底
|
|
40
|
+
}
|
|
41
|
+
console.error(
|
|
42
|
+
`读不到外壳 manifest 里钉的 ${DSH_UI}:模板保持 ${FALLBACK_DSH_UI}(与外壳对账过的那版);在装好依赖的工程里 init 就能盖上当下那版`,
|
|
43
|
+
);
|
|
44
|
+
return { version: FALLBACK_DSH_UI, source: '兜底常量(没读到外壳 manifest)' };
|
|
45
|
+
};
|
|
46
|
+
|
|
19
47
|
const versionIn = (file) => {
|
|
20
48
|
try {
|
|
21
49
|
const version = JSON.parse(readFileSync(file, 'utf8')).version;
|
|
@@ -59,11 +87,13 @@ export async function init({ root, args = [] }) {
|
|
|
59
87
|
console.error(`${target} 已经存在且不是空目录,不覆盖`);
|
|
60
88
|
process.exit(2);
|
|
61
89
|
}
|
|
90
|
+
const dshUi = dshUiVersion(root);
|
|
62
91
|
const tokens = {
|
|
63
92
|
__UNIT__: unit,
|
|
64
93
|
__NAME__: `@dsh-workbench/module-${unit}`,
|
|
65
94
|
__DESC__: description,
|
|
66
95
|
__SHELL_RANGE__: shellRange(root),
|
|
96
|
+
__DSH_UI_VERSION__: dshUi.version,
|
|
67
97
|
};
|
|
68
98
|
// 按**已知 token 名**逐个盖章,而不是按 `__[A-Z]+__` 的形状:名字里带下划线的 token
|
|
69
99
|
// (`__SHELL_RANGE__`)也要认;同时 DIY 模板 README 里真写着的 `workbench___UNIT___rebuild`
|
|
@@ -98,4 +128,7 @@ export async function init({ root, args = [] }) {
|
|
|
98
128
|
const tables = existsSync(join(target, DESIGN_FILE)) ? `${DESIGN_FILE} 里那份三张表` : '还没有三张表(dsh-wb-module design init 生成)';
|
|
99
129
|
console.log(`${target} 建好了:${unit} 模块骨架(模板 ${which};build / check / design / selfcheck 已接工具)`);
|
|
100
130
|
console.log(` 需求底稿在 ${tables};空格用 dsh-wb-module design check 查,README 由它生成`);
|
|
131
|
+
console.log(` 模板默认带了 ${DSH_UI}@${dshUi.version}(${dshUi.source})—— 这是 dsh 原语的类型面 + external 声明两处;`);
|
|
132
|
+
console.log(' 装完先在这个 workspace 里跑一次 pnpm install,pnpm typecheck 才认得到它(骨架的判据不依赖它)。');
|
|
133
|
+
console.log(' 确实用不到就连 devDependencies 与 dsh.client.external 两处一起删,判据不拦。');
|
|
101
134
|
}
|
package/src/lint.mjs
CHANGED
|
@@ -221,7 +221,7 @@ export function moduleLint(options = {}) {
|
|
|
221
221
|
name: FINGERPRINT,
|
|
222
222
|
files: SRC_FILES,
|
|
223
223
|
rules: {
|
|
224
|
-
// `catch {}`
|
|
224
|
+
// `catch {}` 是"实现里不留注释"这条约定下的刻意写法(要表达在场就 `_` 前缀命名),不是漏写。
|
|
225
225
|
'no-empty': ['error', { allowEmptyCatch: true }],
|
|
226
226
|
'prefer-const': 'error',
|
|
227
227
|
// 上一期的非目标之一:`as any` 把契约绕过去 —— 复杂模块最先烂在这里。
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// dsh 原语本包的**可见面**:从装机那份类型面(`lib/types/**/*.d.ts`)取出导出名,按名字形状分桶。
|
|
2
|
+
//
|
|
3
|
+
// 为什么读类型面而不是源码:这个包发布出去的 `files` 只有
|
|
4
|
+
// `lib/index.js` / `lib/**/*.css` / `lib/types/**/*.d.ts` —— **源码树不随包发布**
|
|
5
|
+
// (`package.json` 的 `exports['./src/*']` 在装机包里是死条目),类型面是唯一机器可读的清单。
|
|
6
|
+
//
|
|
7
|
+
// 为什么不上 TypeScript API:module-tools 自己没有 `typescript` 依赖(只有 `typescript-eslint`
|
|
8
|
+
// 的 peer),而这份 barrel 是 tsc 生成的、形状稳定:`export { A, B } from './X.tsx'` +
|
|
9
|
+
// `export type { … }`(滤掉)+ 少量 `export declare const X` + 一条 `export *`(要跟着走)。
|
|
10
|
+
// 代价是"上游换了 emit 形状没人知道"—— 由 selfcheck 那条"清单非空 / 含 Button / `Icon*Regular` ≥ 90"
|
|
11
|
+
// 兜住(`.scratch/dsh-primitives-face/spec.md` §3.4 / §8.1)。
|
|
12
|
+
//
|
|
13
|
+
// 分桶**只按名字形状**,不判用途:`Icon*` → 图标;`^use[A-Z]` → hooks;首字母大写 → 组件;
|
|
14
|
+
// 其余(常量、纯函数)→ 工具。"这件是不是该用、该怎么用"是判断,留给 agent / 人(TOOLING.md 那条口径)。
|
|
15
|
+
|
|
16
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
17
|
+
import { createRequire } from 'node:module';
|
|
18
|
+
import { dirname, join } from 'node:path';
|
|
19
|
+
|
|
20
|
+
export const PRIMITIVES_SPECIFIER = '@deepseek-ai/dsh-client-ui-primitives';
|
|
21
|
+
|
|
22
|
+
/** 从 `from`(一个包的目录)解析这个原语包;解析不到返回 undefined。 */
|
|
23
|
+
export function installedPrimitives(from) {
|
|
24
|
+
try {
|
|
25
|
+
const require = createRequire(join(from, 'package.json'));
|
|
26
|
+
const manifest = require.resolve(`${PRIMITIVES_SPECIFIER}/package.json`);
|
|
27
|
+
const parsed = JSON.parse(readFileSync(manifest, 'utf8'));
|
|
28
|
+
const version = typeof parsed.version === 'string' ? parsed.version : undefined;
|
|
29
|
+
// 打印/落点用它**从 node_modules 看**的那条路径(pnpm 把真身放在 `.pnpm/` 哈希目录里,
|
|
30
|
+
// `require.resolve` 给的是 realpath)—— agent 照着能直接打开,也短得多。
|
|
31
|
+
const viaNodeModules = join(from, 'node_modules', ...PRIMITIVES_SPECIFIER.split('/'));
|
|
32
|
+
const dir = existsSync(join(viaNodeModules, 'package.json')) ? viaNodeModules : dirname(manifest);
|
|
33
|
+
return { manifest, dir, version };
|
|
34
|
+
} catch {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 外壳 manifest 里钉的那份原语版本(`devDependencies` 那一格)—— 模板盖章与漂移 note 都用它。 */
|
|
40
|
+
export function pinnedPrimitivesVersion(manifestPath) {
|
|
41
|
+
try {
|
|
42
|
+
const pinned = JSON.parse(readFileSync(manifestPath, 'utf8')).devDependencies?.[PRIMITIVES_SPECIFIER];
|
|
43
|
+
return typeof pinned === 'string' && pinned !== '' ? pinned : undefined;
|
|
44
|
+
} catch {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** `export … from '…'` 一条条看:`export type` 滤掉、`export *` 跟着走、其余按名字拆。 */
|
|
50
|
+
const STATEMENT = /export\s+(type\s+)?(\*|\{[^}]*\})\s*from\s*'([^']+)'/g;
|
|
51
|
+
const DECLARED = /^export declare (?:const|function|class)\s+([A-Za-z_$][\w$]*)/gm;
|
|
52
|
+
const MAX_STAR_DEPTH = 3;
|
|
53
|
+
|
|
54
|
+
/** 说明符落到同目录的 `.d.ts`:`./icons/index.tsx` → `<types>/icons/index.d.ts`。 */
|
|
55
|
+
function dtsTargetOf(fromFile, specifier) {
|
|
56
|
+
const base = join(dirname(fromFile), specifier);
|
|
57
|
+
const stripped = base.replace(/\.(tsx?|jsx?|mjs|cjs)$/, '');
|
|
58
|
+
const candidates = [`${stripped}.d.ts`, join(stripped, 'index.d.ts'), `${base}.d.ts`];
|
|
59
|
+
return candidates.find((one) => existsSync(one));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function namesOf(statement) {
|
|
63
|
+
return statement
|
|
64
|
+
.replace(/^\{/, '')
|
|
65
|
+
.replace(/\}$/, '')
|
|
66
|
+
.split(',')
|
|
67
|
+
.map((raw) => raw.trim().split(/\s+as\s+/).pop().trim())
|
|
68
|
+
.filter((name) => /^[A-Za-z_$][\w$]*$/.test(name));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function collect(file, origin, acc, depth) {
|
|
72
|
+
let text;
|
|
73
|
+
try {
|
|
74
|
+
text = readFileSync(file, 'utf8');
|
|
75
|
+
} catch {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
for (const match of text.matchAll(DECLARED)) acc.set(match[1], { origin, file });
|
|
79
|
+
for (const match of text.matchAll(STATEMENT)) {
|
|
80
|
+
if (match[1] !== undefined) continue;
|
|
81
|
+
if (match[2] === '*') {
|
|
82
|
+
if (depth >= MAX_STAR_DEPTH) continue;
|
|
83
|
+
const target = dtsTargetOf(file, match[3]);
|
|
84
|
+
if (target !== undefined) collect(target, origin, acc, depth + 1);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
// 名字**声明**在哪份 `.d.ts`:`export { Button } from './Button.tsx'` 的答案就是
|
|
88
|
+
// `lib/types/Button.d.ts` —— 这条信息就是"agent 想用哪个直接去看"的落点,别丢。
|
|
89
|
+
const declaredIn = dtsTargetOf(file, match[3]) ?? file;
|
|
90
|
+
for (const name of namesOf(match[2])) acc.set(name, { origin, file: declaredIn });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 装机那份类型面的导出,按名字形状分成四桶。
|
|
96
|
+
* `typesDir` = 原语包的 `lib/types`;读不到入口时抛(调用方给人话,不给空表)。
|
|
97
|
+
* `files` = 名字 → 声明它的那份 `.d.ts` 的绝对路径(188 枚图标同指 `icons/index.d.ts`)。
|
|
98
|
+
*/
|
|
99
|
+
export function surfaceOf(typesDir) {
|
|
100
|
+
const entry = join(typesDir, 'index.d.ts');
|
|
101
|
+
if (!existsSync(entry)) throw new Error(`类型面不在:${entry}`);
|
|
102
|
+
const found = new Map();
|
|
103
|
+
collect(entry, 'root', found, 0);
|
|
104
|
+
const iconsEntry = join(typesDir, 'icons', 'index.d.ts');
|
|
105
|
+
if (existsSync(iconsEntry)) collect(iconsEntry, 'icons', found, 0);
|
|
106
|
+
|
|
107
|
+
const buckets = { components: [], icons: [], hooks: [], lib: [] };
|
|
108
|
+
const files = {};
|
|
109
|
+
for (const [name, { origin, file }] of found) {
|
|
110
|
+
files[name] = file;
|
|
111
|
+
if (origin === 'icons' && !name.startsWith('Icon')) buckets.lib.push(name);
|
|
112
|
+
else if (name.startsWith('Icon')) buckets.icons.push(name);
|
|
113
|
+
else if (/^use[A-Z]/.test(name)) buckets.hooks.push(name);
|
|
114
|
+
else if (/^[A-Z][A-Z0-9_]*$/.test(name)) buckets.lib.push(name);
|
|
115
|
+
else if (/^[A-Z]/.test(name)) buckets.components.push(name);
|
|
116
|
+
else buckets.lib.push(name);
|
|
117
|
+
}
|
|
118
|
+
for (const key of Object.keys(buckets)) buckets[key].sort();
|
|
119
|
+
return { ...buckets, total: found.size, files };
|
|
120
|
+
}
|