@dsh-workbench/module-tools 0.3.11 → 1.0.0-beta.1

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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +223 -177
  3. package/bin/dsh-wb-module.mjs +89 -34
  4. package/package.json +22 -3
  5. package/src/arch.mjs +158 -129
  6. package/src/build.mjs +6 -30
  7. package/src/changelog.mjs +166 -0
  8. package/src/check-flags.mjs +0 -7
  9. package/src/check.mjs +115 -53
  10. package/src/checks/alias.mjs +0 -9
  11. package/src/checks/arch.mjs +3 -25
  12. package/src/checks/bundles.mjs +2 -34
  13. package/src/checks/cards.mjs +26 -30
  14. package/src/checks/contracts.mjs +72 -219
  15. package/src/checks/kit.mjs +2 -42
  16. package/src/checks/lint.mjs +9 -82
  17. package/src/checks/markers.mjs +48 -17
  18. package/src/checks/primitives.mjs +0 -39
  19. package/src/checks/protocol.mjs +0 -18
  20. package/src/checks/source.mjs +103 -51
  21. package/src/checks/standalone.mjs +4 -14
  22. package/src/checks/style.mjs +84 -230
  23. package/src/css-injection.mjs +0 -11
  24. package/src/css-shell.mjs +3 -31
  25. package/src/datasource-file.mjs +104 -0
  26. package/src/declared-tools.mjs +0 -24
  27. package/src/design.mjs +116 -38
  28. package/src/discover.mjs +2 -10
  29. package/src/font-scale.mjs +1 -61
  30. package/src/init.mjs +2 -17
  31. package/src/lint.mjs +24 -144
  32. package/src/market-index.mjs +569 -0
  33. package/src/market-init.mjs +80 -0
  34. package/src/primitives-surface.mjs +2 -28
  35. package/src/publish.mjs +11 -25
  36. package/src/release-changelog.mjs +29 -0
  37. package/src/release-journal.mjs +131 -0
  38. package/src/release-report.mjs +99 -0
  39. package/src/release-util.mjs +87 -0
  40. package/src/release.mjs +1250 -0
  41. package/src/seed.mjs +0 -25
  42. package/src/shell-type-files.mjs +3 -30
  43. package/src/sync.mjs +41 -24
  44. package/template/AGENTS.md.tpl +33 -0
  45. package/template/CHANGELOG.md.tpl +7 -0
  46. package/template/README.md.tpl +6 -33
  47. package/template/package.json.tpl +2 -2
  48. package/template/src/client.tsx.tpl +1 -18
  49. package/template/src/host.ts.tpl +2 -15
  50. package/template/types/globals.d.ts +1 -14
  51. package/template-diy/AGENTS.md.tpl +33 -0
  52. package/template-diy/CHANGELOG.md.tpl +7 -0
  53. package/template-diy/README.md.tpl +4 -133
  54. package/template-diy/package.json.tpl +2 -2
  55. package/template-diy/src/client.tsx.tpl +1 -29
  56. package/template-diy/src/host.ts.tpl +1 -15
  57. package/template-diy/src/plugin.test.ts.tpl +1 -1
  58. package/template-diy/types/globals.d.ts +1 -14
  59. package/template-diy/types/styles.d.ts +0 -10
@@ -0,0 +1,104 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ export const DATASOURCE_FILE = 'src/datasource.ts';
5
+
6
+ function text(value) {
7
+ return typeof value === 'string' ? value.trim() : '';
8
+ }
9
+
10
+ function readsOf(design) {
11
+ const list = design?.reads;
12
+ if (Array.isArray(list) && list.length > 0) return list;
13
+ const one = design?.datasource;
14
+ return one === undefined || one === null || typeof one !== 'object' ? [] : [one];
15
+ }
16
+
17
+ function writesOf(design) {
18
+ const list = design?.writes;
19
+ if (Array.isArray(list) && list.length > 0) return list;
20
+ const one = design?.write;
21
+ return one === undefined || one === null || typeof one !== 'object' ? [] : [one];
22
+ }
23
+
24
+ export function datasourceEntriesOf(design) {
25
+ return [
26
+ ...readsOf(design).map((read) => ({ key: text(read?.key), recipe: read?.recipe, calls: read?.calls })),
27
+ ...writesOf(design).map((write) => ({ key: text(write?.key), recipe: write?.recipe })),
28
+ ].filter((one) => one.key !== '' && one.recipe !== undefined && one.recipe !== null);
29
+ }
30
+
31
+ function stringLiteral(value) {
32
+ return `'${value
33
+ .replace(/\\/g, '\\\\')
34
+ .replace(/'/g, "\\'")
35
+ .replace(/\n/g, '\\n')
36
+ .replace(/\r/g, '\\r')
37
+ .replace(/\t/g, '\\t')}'`;
38
+ }
39
+
40
+ function keyLiteral(key) {
41
+ return /^[A-Za-z_$][\w$]*$/.test(key) ? key : stringLiteral(key);
42
+ }
43
+
44
+ function tsLiteral(value, indent) {
45
+ if (typeof value === 'string') return stringLiteral(value);
46
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
47
+ if (value === null || value === undefined) return 'null';
48
+ if (Array.isArray(value)) {
49
+ if (value.length === 0) return '[]';
50
+ return `[${value.map((one) => tsLiteral(one, indent + 1)).join(', ')}]`;
51
+ }
52
+ if (typeof value === 'object') {
53
+ const entries = Object.entries(value).filter(([, one]) => one !== undefined);
54
+ if (entries.length === 0) return '{}';
55
+ const pad = ' '.repeat(indent + 1);
56
+ const close = ' '.repeat(indent);
57
+ return `{\n${entries
58
+ .map(([key, one]) => `${pad}${keyLiteral(key)}: ${tsLiteral(one, indent + 1)},`)
59
+ .join('\n')}\n${close}}`;
60
+ }
61
+ return 'null';
62
+ }
63
+
64
+ export function renderDatasource(design) {
65
+ const entries = datasourceEntriesOf(design);
66
+ const lines = ['export const DATASOURCE = {'];
67
+ for (const entry of entries) {
68
+ lines.push(` ${keyLiteral(entry.key)}: ${tsLiteral(entry.recipe, 1)},`);
69
+ }
70
+ lines.push('} as const;');
71
+ const batched = entries.filter((entry) => entry.calls !== undefined && entry.calls !== null);
72
+ if (batched.length > 0) {
73
+ lines.push('', 'export const DATASOURCE_CALLS = {');
74
+ for (const entry of batched) {
75
+ lines.push(` ${keyLiteral(entry.key)}: ${tsLiteral(entry.calls, 1)},`);
76
+ }
77
+ lines.push('} as const;');
78
+ }
79
+ lines.push('');
80
+ return lines.join('\n');
81
+ }
82
+
83
+ export function datasourceProblemOf(dir, design) {
84
+ const entries = datasourceEntriesOf(design);
85
+ const at = join(dir, DATASOURCE_FILE);
86
+ if (entries.length === 0) {
87
+ if (!existsSync(at)) return undefined;
88
+ return (
89
+ `${DATASOURCE_FILE} 在,但 design.json 里没有 key + recipe 的读 / 写 —— ` +
90
+ '补上配方(跑 `dsh-wb-module sync` 刷新),或者删掉这个生成物'
91
+ );
92
+ }
93
+ if (!existsSync(at)) {
94
+ return `${DATASOURCE_FILE} 不在 —— design.json 里有 key + recipe 的读 / 写,就该有生成物,跑 \`dsh-wb-module sync\``;
95
+ }
96
+ const strip = (source) => source.replace(/\s+/g, '').replace(/"/g, "'");
97
+ if (strip(readFileSync(at, 'utf8')) !== strip(renderDatasource(design))) {
98
+ return (
99
+ `${DATASOURCE_FILE} 与 design.json 对不上(生成物过期,或有人手改了它)—— ` +
100
+ '跑 `dsh-wb-module sync` 刷新。别手改:它是生成的'
101
+ );
102
+ }
103
+ return undefined;
104
+ }
@@ -1,23 +1,3 @@
1
- // 工具名只有**一处写者**:清单(`dsh.workbench.tools[].name`)。
2
- //
3
- // 代码里注册一个工具必须报它的名字,所以从前那份字面量在清单与代码里各写一遍 —— 只能靠判据对账
4
- // (`check:contracts` 的 reconcileTools 就是这么干的)。`tools[].key` + `dsh-wb-module sync` 把
5
- // 代码那一半**生成**出来:
6
- //
7
- // dsh-wb-module sync → src/declared-tools.ts
8
- // export const DECLARED_TOOLS = {
9
- // sync: { name: 'workbench_argocd_sync', writes: true },
10
- // } as const;
11
- //
12
- // 于是:改名只动清单一处;代码引用的是**键**(`DECLARED_TOOLS.sync.name`),键写错是编译错;
13
- // 生成物落后于清单由判据当场判红。
14
- //
15
- // 方向是**单向**的:绝不回写 `package.json`(ADR-0038 §2.5 已经拒了"生成式回写":`package.json`
16
- // 有两个写者(人与生成器)就会静默覆盖)。
17
- //
18
- // 输出与比对都按"各家 prettier 都可能重排"来设计:字面量用单引号(本生态的 TS 风格),
19
- // 比对时**忽略空白、把两种引号当同一件事** —— 重排不算过期,值变了才算。
20
-
21
1
  import { existsSync, readFileSync } from 'node:fs';
22
2
  import { join } from 'node:path';
23
3
 
@@ -35,19 +15,16 @@ function keyOf(tool) {
35
15
  return typeof tool?.key === 'string' && tool.key.trim() !== '' ? tool.key.trim() : '';
36
16
  }
37
17
 
38
- /** 一个 key 都没写的模块不适用(那是不用生成物那一档,判据也不找它)。 */
39
18
  export function declaredToolsApplies(raw) {
40
19
  return toolsOf(raw).some((tool) => keyOf(tool) !== '');
41
20
  }
42
21
 
43
- /** 还没写 key 的那些工具(有 key 就得每个都有:半写会让生成物少几条,而代码里还在写字面量)。 */
44
22
  export function declaredToolsWithoutKey(raw) {
45
23
  return toolsOf(raw)
46
24
  .filter((tool) => keyOf(tool) === '')
47
25
  .map((tool) => JSON.stringify(tool?.name));
48
26
  }
49
27
 
50
- /** 生成物的正文。**不给注释**:它落在模块的 `src/**`(那里只许贴导出的 JSDoc,生成这份一行都不加),改它无效、过期会红。 */
51
28
  export function renderDeclaredTools(raw) {
52
29
  const rows = toolsOf(raw).map(
53
30
  (tool) =>
@@ -63,7 +40,6 @@ export function renderDeclaredTools(raw) {
63
40
  ].join('\n');
64
41
  }
65
42
 
66
- /** 生成物与清单对不上的原因(`undefined` = 新鲜,或不适用)。 */
67
43
  export function declaredToolsProblemOf(dir, raw) {
68
44
  if (!declaredToolsApplies(raw)) return undefined;
69
45
  const missing = declaredToolsWithoutKey(raw);
package/src/design.mjs CHANGED
@@ -1,26 +1,11 @@
1
- // 三张表的**唯一真相**:`design.json`。
2
- //
3
- // ── 为什么要有这个文件 ────────────────────────────────────────────────────────
4
- //
5
- // 在这之前,三张表只活在 README 的散文里:SKILL.md 说"空着的格就是还没定的事,别带着空格往下做",
6
- // 可**没有一条判据看得见那些格**。于是它靠 agent 自觉:忘了填、填了一半、只留在对话里 —— 都没人拦。
7
- // README 是给人读的落点,不是给机器判的形状。
8
- //
9
- // 现在的分工:
10
- // · `design.json` —— 机器可读的需求底稿(三张表 + 状态矩阵 + 数据源记录 + 写通道记录 + 验收清单);
11
- // · `README.md` —— 由它**生成**的那一段(夹在 `<!-- dsh-wb:design:start/end -->` 之间);
12
- // · `dsh-wb-module design check` —— 把**空着的格**列出来(红),并核对 README 没落后于 design.json。
13
- //
14
- // 这样"空格"第一次有了形状:填空 → 再往下做;写完 → 自己在真卡片上逐条走通,`design accept` 打勾。
15
-
16
1
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
17
2
  import { join } from 'node:path';
3
+ import { callsProblemOf, describeRecipe, recipeProblemOf } from '@dsh-workbench/shared/host';
18
4
 
19
5
  export const DESIGN_FILE = 'design.json';
20
6
  export const START_MARK = '<!-- dsh-wb:design:start -->';
21
7
  export const END_MARK = '<!-- dsh-wb:design:end -->';
22
8
 
23
- /** 卡片五态 —— 与指引 PANEL.md 的状态矩阵、`check:cards` 的渲染判据同一套。 */
24
9
  export const STATE_KEYS = ['idle', 'loading', 'ready', 'empty', 'unreadable'];
25
10
  const STATE_LABELS = {
26
11
  idle: '待机(还没选东西)',
@@ -32,7 +17,6 @@ const STATE_LABELS = {
32
17
  const LETTERS = ['A', 'B', 'C', 'D', 'E'];
33
18
  const PENDING = '(待填)';
34
19
 
35
- /** 必填的行字段;`optional` 的那些留空不算空。 */
36
20
  const ROWS = {
37
21
  panel: { fields: ['cell', 'conclusion'], optional: ['link'] },
38
22
  config: {
@@ -51,16 +35,8 @@ const ROWS = {
51
35
  const DATASOURCE_FIELDS = ['read', 'params', 'credentials', 'notLoggedIn', 'timeout'];
52
36
  const WRITE_FIELDS = ['command', 'params', 'credentials', 'preview', 'readback', 'undo', 'failure'];
53
37
 
54
- /** `params` 里写了"必填",就得说清凭什么是必填 —— 来源只能是这五个词之一
55
- * (`已证` 那句话的来源是**录下来的图**上那个红 `*`,或者**契约**里 `--help -f json` 的 `required`)。**气味判据不是解析器**:
56
- * 只看来源有没有写出来,不看句式、不数个数、不认字段名,也不校验来源是不是真的 ——
57
- * 真话假话只有人和复核能判(照 `undo` 那条的原话:逼作者想过,不逼他造假)。 */
58
38
  const SOURCE_TAGS = ['已证', '声明', '人证', '契约', '未定'];
59
39
 
60
- /** `params` 里有没有一处「必填」**没被否掉** —— `非必填` / `不必填`(只看紧邻的那个字符)才算被否掉。
61
- * 这是**字符级守卫**,只挡这一种写法:不认"必填项""必填字段"以外的语法、不做否定句理解。
62
- * 为什么非要有它:`design check` 是模块作者天天跑的门禁,`非必填` 这种**明确说不要**的写法
63
- * 被误咬一次,他下次就把这条判据当噪音 —— 误红的代价比漏一两次大得多。 */
64
40
  function hasBareRequired(value) {
65
41
  let at = value.indexOf('必填');
66
42
  while (at !== -1) {
@@ -71,8 +47,6 @@ function hasBareRequired(value) {
71
47
  return false;
72
48
  }
73
49
 
74
- /** 一条卡背后可以有多条读(甚至跨站点):规范形状 `reads: [ … ]`,一条一行;
75
- * `datasource: { … }` 是只有一条读时的写法(老模块都长这样,继续认)。 */
76
50
  function readsOf(design) {
77
51
  const list = design?.reads;
78
52
  if (Array.isArray(list) && list.length > 0) return list;
@@ -80,8 +54,6 @@ function readsOf(design) {
80
54
  return one === undefined || one === null || typeof one !== 'object' ? [] : [one];
81
55
  }
82
56
 
83
- /** 写侧与读侧对称:一个动作可以打几条写(先建后发布那种),规范形状 `writes: [ … ]`;
84
- * `write: { … }` 是只有一条写时的写法。**没有写通道的模块不写就行** —— 这里不判"一条都没有"。 */
85
57
  function writesOf(design) {
86
58
  const list = design?.writes;
87
59
  if (Array.isArray(list) && list.length > 0) return list;
@@ -89,7 +61,6 @@ function writesOf(design) {
89
61
  return one === undefined || one === null || typeof one !== 'object' ? [] : [one];
90
62
  }
91
63
 
92
- /** 「他能自己配什么」有两种,分不清就会写成一个不落盘的选择还自称配置: */
93
64
  function persistenceLabel(value) {
94
65
  const raw = text(value);
95
66
  if (raw === 'prefs') return '存起来(按工作区记,刷新还在)';
@@ -97,11 +68,39 @@ function persistenceLabel(value) {
97
68
  return cell(raw);
98
69
  }
99
70
 
71
+ function recipeSummaryOf(recipe) {
72
+ try {
73
+ const described = describeRecipe(recipe);
74
+ const auth =
75
+ described.auth === undefined
76
+ ? 'cookie'
77
+ : `${described.auth.source} → ${described.auth.place}${described.auth.name === undefined ? '' : `(${described.auth.name})`}`;
78
+ return `${described.method} ${described.url} · ${auth}`;
79
+ } catch (error) {
80
+ return `(配方不合法:${error.message})`;
81
+ }
82
+ }
83
+
84
+ function callsSummaryOf(recipe, calls) {
85
+ try {
86
+ return Object.keys(calls)
87
+ .map((name) => {
88
+ const described = describeRecipe(recipe, calls[name]);
89
+ return `${name}: ${described.url}`;
90
+ })
91
+ .join(';');
92
+ } catch (error) {
93
+ return `(批量不合法:${error.message})`;
94
+ }
95
+ }
96
+
100
97
  function datasourceRowsOf(read) {
101
98
  return [
102
99
  '| 项 | 值(照抄,别转述) |',
103
100
  '|---|---|',
104
101
  `| 哪条读(命令 / URL) | ${cell(read?.read)} |`,
102
+ ...(read?.recipe === undefined ? [] : [`| 调用形状(配方) | ${cell(recipeSummaryOf(read.recipe))} |`]),
103
+ ...(read?.calls === undefined ? [] : [`| 一次打几条(批量) | ${cell(callsSummaryOf(read.recipe, read.calls))} |`]),
105
104
  `| 参数(缺省 / 范围 / 上限) | ${cell(read?.params)} |`,
106
105
  `| 凭据从哪来 | ${cell(read?.credentials)} |`,
107
106
  `| 没登录 / 凭据过期时是什么样 | ${cell(read?.notLoggedIn)} |`,
@@ -115,6 +114,7 @@ function writeRowsOf(write) {
115
114
  '| 项 | 值(照抄,别转述) |',
116
115
  '|---|---|',
117
116
  `| 哪条写(命令 / URL + 方法) | ${cell(write?.command)} |`,
117
+ ...(write?.recipe === undefined ? [] : [`| 调用形状(配方) | ${cell(recipeSummaryOf(write.recipe))} |`]),
118
118
  `| 参数(必填 / 取值枚举 / 上限) | ${cell(write?.params)} |`,
119
119
  `| 凭据从哪来 | ${cell(write?.credentials)} |`,
120
120
  `| 有没有预览档(没有就写"没有,一次就是真的") | ${cell(write?.preview)} |`,
@@ -148,6 +148,16 @@ export function readDesign(root) {
148
148
  }
149
149
  }
150
150
 
151
+ export function acceptanceOf(root) {
152
+ const design = readDesign(root);
153
+ const rows = asRows(design?.acceptance);
154
+ return {
155
+ total: rows.length,
156
+ checked: rows.filter((one) => one?.checked === true).length,
157
+ acceptedAt: typeof design?.acceptedAt === 'string' ? design.acceptedAt : undefined,
158
+ };
159
+ }
160
+
151
161
  function skeleton(manifest = {}) {
152
162
  return {
153
163
  unit: text(manifest?.dsh?.workbench?.unit) || text(manifest.name).replace(/^.*\/module-/, ''),
@@ -179,7 +189,6 @@ function need(cells, value, path) {
179
189
  if (pending(value)) cells.push(path);
180
190
  }
181
191
 
182
- /** 空着的格 —— 这就是"别带着空格往下做"那条纪律的机器形状。 */
183
192
  export function emptyCells(design) {
184
193
  const cells = [];
185
194
  for (const field of ['unit', 'name', 'oneLine']) need(cells, design?.[field], field);
@@ -209,11 +218,10 @@ export function emptyCells(design) {
209
218
  cells.push(`states[${index}](缺 ${key} 这一态)`);
210
219
  continue;
211
220
  }
212
- // 这一态对这份模块确实不适用时,写一句**为什么**就行(例:账号级的卡没有"还没选项目"这一态)。
213
- // 行仍然要占着 —— 五态是矩阵的行,不是"能省则省"的清单;省掉的只是那三个格子。
221
+
214
222
  if (text(row.na) !== '') continue;
215
223
  for (const field of ['sees', 'canDo']) need(cells, row[field], `states.${key}.${field}`);
216
- // 这份模块故意没有 agent 能力时,`agentCan` 没有内容可写 —— 那几格不必硬填一句同义反复。
224
+
217
225
  if (!noCapabilities) need(cells, row.agentCan, `states.${key}.agentCan`);
218
226
  }
219
227
 
@@ -222,21 +230,71 @@ export function emptyCells(design) {
222
230
  reads.forEach((read, index) => {
223
231
  const label = reads.length === 1 ? 'datasource' : `reads[${index}]`;
224
232
  for (const field of DATASOURCE_FIELDS) need(cells, read?.[field], `${label}.${field}`);
233
+
234
+ const recipe = read?.recipe;
235
+ const custom = read?.custom;
236
+ if (recipe !== undefined && custom !== undefined) {
237
+ cells.push(`${label}.recipe(recipe 与 custom 只能二选一)`);
238
+ }
239
+ if (recipe !== undefined) {
240
+ const problem = recipeProblemOf(recipe);
241
+ if (problem !== undefined) cells.push(`${label}.recipe(${problem})`);
242
+ }
243
+ if (read?.calls !== undefined) {
244
+ if (recipe === undefined) cells.push(`${label}.calls(calls 要有 recipe)`);
245
+ else {
246
+ const problem = callsProblemOf(recipe, read.calls);
247
+ if (problem !== undefined) cells.push(`${label}.calls(${problem})`);
248
+ }
249
+ }
250
+ if (custom !== undefined && text(custom?.why) === '') cells.push(`${label}.custom.why`);
251
+
252
+ const key = text(read?.key);
253
+ if (key !== '' && !/^[a-z][A-Za-z0-9]*$/.test(key)) cells.push(`${label}.key(lowerCamelCase)`);
225
254
  });
226
255
 
227
256
  const writes = writesOf(design);
228
257
  writes.forEach((write, index) => {
229
258
  const label = writes.length === 1 ? 'write' : `writes[${index}]`;
230
259
  for (const field of WRITE_FIELDS) need(cells, write?.[field], `${label}.${field}`);
231
- // 写了"必填"却不带来源词 ⇒ 点名(`非必填` / `不必填` 那种**明确说不要**的不算,见 hasBareRequired)。
232
- // 空着 / `(待填)` 是上一轮那条 `pending` 的事,两条不叠。
233
- // 判的是**值**不是表头:渲染出来的行名「参数(必填 / 取值枚举 / 上限)」不在这条路上。
260
+
234
261
  const params = text(write?.params);
235
262
  if (!pending(params) && hasBareRequired(params) && !SOURCE_TAGS.some((tag) => params.includes(tag))) {
236
263
  cells.push(`${label}.params.source`);
237
264
  }
265
+
266
+ const recipe = write?.recipe;
267
+ const custom = write?.custom;
268
+ if (recipe !== undefined && custom !== undefined) {
269
+ cells.push(`${label}.recipe(recipe 与 custom 只能二选一)`);
270
+ }
271
+ if (recipe !== undefined) {
272
+ const problem = recipeProblemOf(recipe);
273
+ if (problem !== undefined) cells.push(`${label}.recipe(${problem})`);
274
+ }
275
+ if (custom !== undefined && text(custom?.why) === '') cells.push(`${label}.custom.why`);
276
+
277
+ const key = text(write?.key);
278
+ if (key !== '' && !/^[a-z][A-Za-z0-9]*$/.test(key)) cells.push(`${label}.key(lowerCamelCase)`);
238
279
  });
239
280
 
281
+ const keys = new Set();
282
+ const seen = new Map();
283
+ for (const [readIndex, read] of reads.entries()) {
284
+ const key = text(read?.key);
285
+ if (key === '') continue;
286
+ if (keys.has(key)) cells.push(`数据源 key 重复:${key}(${seen.get(key)} 与 读 ${String(readIndex + 1)})`);
287
+ else seen.set(key, `读 ${String(readIndex + 1)}`);
288
+ keys.add(key);
289
+ }
290
+ for (const [writeIndex, write] of writes.entries()) {
291
+ const key = text(write?.key);
292
+ if (key === '') continue;
293
+ if (keys.has(key)) cells.push(`数据源 key 重复:${key}(${seen.get(key)} 与 写 ${String(writeIndex + 1)})`);
294
+ else seen.set(key, `写 ${String(writeIndex + 1)}`);
295
+ keys.add(key);
296
+ }
297
+
240
298
  return cells;
241
299
  }
242
300
 
@@ -245,7 +303,6 @@ function cell(value) {
245
303
  return raw === '' ? PENDING : raw.replace(/\|/g, '\\|').replace(/\s*\n\s*/g, ' ');
246
304
  }
247
305
 
248
- /** 可空的格:空着是**合法**的(例:这张卡没有外链),渲染成 `—` 而不是"待填"。 */
249
306
  function soft(value) {
250
307
  const raw = text(value);
251
308
  return raw === '' || raw === PENDING ? '—' : cell(raw);
@@ -257,6 +314,25 @@ function tableOf(header, rows) {
257
314
  return [`| ${header} |`, divider, ...rows.map((row) => `| ${row.join(' | ')} |`)].join('\n');
258
315
  }
259
316
 
317
+ export function designWarningsOf(design) {
318
+ const reads = readsOf(design);
319
+ const writes = writesOf(design);
320
+ const warnings = [];
321
+ reads.forEach((read, index) => {
322
+ if (read?.custom === undefined) return;
323
+ const label = reads.length === 1 ? 'datasource' : `reads[${index}]`;
324
+ const why = text(read.custom?.why);
325
+ warnings.push(`${label}.custom(${why === '' ? '没写 why' : why})—— 配方盖不住的读,理由要留在底稿里`);
326
+ });
327
+ writes.forEach((write, index) => {
328
+ if (write?.custom === undefined) return;
329
+ const label = writes.length === 1 ? 'write' : `writes[${index}]`;
330
+ const why = text(write.custom?.why);
331
+ warnings.push(`${label}.custom(${why === '' ? '没写 why' : why})—— 配方盖不住的写,理由要留在底稿里`);
332
+ });
333
+ return warnings;
334
+ }
335
+
260
336
  export function renderDesign(design) {
261
337
  const panel = asRows(design?.panel);
262
338
  const config = asRows(design?.config);
@@ -403,6 +479,7 @@ function checkDesign(root) {
403
479
  process.exit(1);
404
480
  }
405
481
  const cells = emptyCells(design);
482
+ const warnings = designWarningsOf(design);
406
483
  const rows = (key) => asRows(design[key]).length;
407
484
  const acceptance = asRows(design.acceptance);
408
485
  const accepted = acceptance.filter((one) => one?.checked === true).length;
@@ -412,6 +489,7 @@ function checkDesign(root) {
412
489
  const fresh = at === undefined ? false : readme.slice(at.start, at.end) === `${START_MARK}\n${renderDesign(design)}\n`;
413
490
 
414
491
  console.log(`design.json:面板 ${rows('panel')} 行 · 状态 ${STATE_KEYS.length} 态 · 配置 ${rows('config')} 项 · 能力 ${rows('capabilities')} 条 · 联动 ${rows('linkage')} 行 · 验收 ${accepted}/${acceptance.length}`);
492
+ for (const one of warnings) console.log(`⚠ ${one}`);
415
493
  if (cells.length > 0) {
416
494
  console.error(`✖ 还空着 ${cells.length} 格("空着的格就是还没定的事",别带着空格往下做):`);
417
495
  for (const one of cells) console.error(` · ${one}`);
package/src/discover.mjs CHANGED
@@ -1,4 +1,3 @@
1
-
2
1
  import { existsSync, readFileSync, readdirSync, realpathSync } from 'node:fs';
3
2
  import { dirname, join } from 'node:path';
4
3
 
@@ -29,13 +28,7 @@ export function discoverPackages({ root = process.cwd() } = {}) {
29
28
  const packagesDir = join(workspace, 'packages');
30
29
  if (existsSync(packagesDir)) {
31
30
  for (const entry of readdirSync(packagesDir, { withFileTypes: true })) {
32
- // 点开头的目录一律不认:那是**自检的临时探针**(mkdtemp 造、跑完删;名字前那一点就是为了
33
- // 不跟真包混)。不排除它们有个很坑的后果 —— 别的链/别的进程正在写一个探针时,这条链会把
34
- // 它当成真模块包算进契约 / 卡片 / kit 那几条,于是红得跟你改的东西毫无关系:
35
- // 2026-10-19 实测:一条链留下的 `.writes-*` 让另一条链的 lint 阶段
36
- // ERR_MODULE_NOT_FOUND(探针里的 eslint.config.mjs 解析不到 module-tools);
37
- // 另一条链正在写的 `.init-*` 让四条判据集体红("它们会被当成真的模块包参与后面的每一条判据")。
38
- // 残留探针本身仍由自检那条「探针清理干净」点名叫出来 —— 这里只是**不让它参与判据**。
31
+
39
32
  if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
40
33
  dirs.push(join(packagesDir, entry.name));
41
34
  }
@@ -44,8 +37,7 @@ export function discoverPackages({ root = process.cwd() } = {}) {
44
37
  const seen = new Set();
45
38
  const found = [];
46
39
  for (const dir of dirs) {
47
- // 按**真实路径**去重:`process.cwd()` 与 workspace 拼出来的路径可能是同一条目录的两种写法
48
- // (软链,典型是 macOS 的 /tmp → /private/tmp),不规范化就会把同一个包扫两遍、报两遍。
40
+
49
41
  const key = existsSync(dir) ? realpathSync(dir) : dir;
50
42
  if (seen.has(key)) continue;
51
43
  seen.add(key);
@@ -1,49 +1,10 @@
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
1
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
20
2
  import { join, relative } from 'node:path';
21
3
 
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
4
  export const DSH_ALIAS_FONT_SIZES = [11, 12, 13, 14, 16, 20, 24];
42
5
 
43
- /** 外壳自己的字号令牌(`--wb-font-*: <n>px`)—— 刻度定义,不是手搓值。 */
44
6
  const SHELL_FONT_TOKEN = /--wb-font-[a-z0-9-]+\s*:\s*([0-9]+(?:\.[0-9]+)?)px/g;
45
7
 
46
- /** 从外壳的令牌文件里读出它自己的那几档(文件不在场就跳过:仓外的工作区读不到)。 */
47
8
  export function shellFontScaleIn(files) {
48
9
  const sizes = new Set();
49
10
  for (const file of files) {
@@ -55,7 +16,6 @@ export function shellFontScaleIn(files) {
55
16
  return sizes;
56
17
  }
57
18
 
58
- /** 合起来的刻度:dsh 别名那 7 档 + 外壳自己 token 里的档(现读)。 */
59
19
  export function fontScaleOf(shellTokenFiles = []) {
60
20
  return new Set([...DSH_ALIAS_FONT_SIZES, ...shellFontScaleIn(shellTokenFiles)]);
61
21
  }
@@ -64,12 +24,10 @@ const CSS_FONT_SIZE = /(?:^|[;{\s])font-size\s*:\s*([^;{}]+)/g;
64
24
  const CSS_FONT_SHORTHAND = /(?:^|[;{\s])font\s*:\s*([^;{}]+)/g;
65
25
  const JS_FONT_SIZE = /\bfontSize\s*[:=]\s*\{?\s*(['"]?)([^,}'"\n]+)\1\s*\}?/g;
66
26
  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,不能算"引用令牌"。
27
+
69
28
  const FONT_FAMILY_TOKEN = /font-(?:family|mono)/;
70
29
  const NUMERIC_VALUE = /^([0-9]*\.?[0-9]+)(px|rem|em|%)?$/;
71
30
 
72
- /** 值里引用的**字号**令牌名(排掉字族/等宽那两族)。 */
73
31
  function sizeTokenRefs(value) {
74
32
  const names = [];
75
33
  for (const hit of value.matchAll(FONT_TOKEN_REF)) {
@@ -78,11 +36,6 @@ function sizeTokenRefs(value) {
78
36
  return names;
79
37
  }
80
38
 
81
- /**
82
- * 把一个字号声明的值分成 ref / size / relative / keyword。
83
- * `relative` 是 `em` / `%`(跟着父级走,判不了刻度外)、`keyword` 是 `inherit` / `unset` 这类。
84
- * `rem` 按 1rem = 16px 折(本仓没有根字号覆盖;dsh 也没改 `html` 的 font-size)。
85
- */
86
39
  export function classifyFontValue(value) {
87
40
  const raw = String(value).trim();
88
41
  const refs = sizeTokenRefs(raw);
@@ -116,11 +69,6 @@ function walk(dir, acc = []) {
116
69
  return acc;
117
70
  }
118
71
 
119
- /**
120
- * 扫一组根下的字号声明并归类。`roots` 是 `{ label, dir }`:label 是报出来的包名。
121
- * 测试文件不扫(用例里那几处是断言用的字符串,不是界面字号);`node_modules` / `dist` 不扫。
122
- * 返回里 `refFiles` / `litFiles` 是**涉及文件数**(同一个文件只算一次)。
123
- */
124
72
  export function auditFontSizes({ roots, scale }) {
125
73
  const refs = [];
126
74
  const offScale = [];
@@ -174,7 +122,6 @@ export function auditFontSizes({ roots, scale }) {
174
122
 
175
123
  const formatSize = (value) => String(value);
176
124
 
177
- /** 最多印十来行明细:刻度外的先印(那是这份清单要的东西),再印刻度上的。 */
178
125
  const DETAIL_LIMIT = 12;
179
126
 
180
127
  function detailOf(hit, verdict) {
@@ -184,13 +131,6 @@ function detailOf(hit, verdict) {
184
131
  : `· ${head} —— ${formatSize(hit.px)} 在刻度上,可收进令牌(但不是错)`;
185
132
  }
186
133
 
187
- /**
188
- * 拼成 `checks/style.mjs` 通过时的那几行 note:第一行汇总(各类条数 + 涉及文件数 + 指路),
189
- * 后面最多 `DETAIL_LIMIT` 行明细。零命中时只留一行"都是 0"。
190
- *
191
- * `doc` 是**必填**的:这几行 note 打印给模块作者,指路必须是他打得到的地方(调用方给,见
192
- * `checks/style.mjs` 的 `FONT_DOC`)—— 不留默认值,免得默认值哪天又变成一条跨仓路径印出去。
193
- */
194
134
  export function fontScaleNotes(report, scale, { doc }) {
195
135
  const sizes = [...scale].sort((a, b) => a - b).map(formatSize).join(' / ');
196
136
  const head =
package/src/init.mjs CHANGED
@@ -9,21 +9,8 @@ const TEMPLATES = {
9
9
  };
10
10
  const UNIT_ID = /^[a-z][a-z0-9-]*$/;
11
11
 
12
- // 模板里那三处 `@dsh-workbench/*` 声明的不是"某个死数",而是**盖章那一刻的工具链范围**:
13
- // 优先 init 所在工程里实际装着的那份外壳(用户手上真正会跑的那个版本),退回 module-tools
14
- // 自己的版本(同一次发布同版本,`^0.3.0` 这类范围对得上),两者都读不到时保持旧模板的
15
- // `^0.1.0` 并说一句人话 —— 静默盖个空范围会让生成的 package.json 声明一个谁也不认的依赖。
16
- // 为什么两个 token 共用一个值:`check:kit` 的口径是 peer 下限不许低于 devDependency 下限。
17
12
  const FALLBACK_RANGE = '^0.1.0';
18
13
 
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
14
  const FALLBACK_DSH_UI = '0.2.1-rc.2';
28
15
  const DSH_UI = '@deepseek-ai/dsh-client-ui-primitives';
29
16
 
@@ -36,7 +23,7 @@ const dshUiVersion = (root) => {
36
23
  return { version: pinned, source: '外壳 manifest 里钉的(对账过的那版)' };
37
24
  }
38
25
  } catch {
39
- // 读不到就走下面的兜底
26
+
40
27
  }
41
28
  console.error(
42
29
  `读不到外壳 manifest 里钉的 ${DSH_UI}:模板保持 ${FALLBACK_DSH_UI}(与外壳对账过的那版);在装好依赖的工程里 init 就能盖上当下那版`,
@@ -95,9 +82,7 @@ export async function init({ root, args = [] }) {
95
82
  __SHELL_RANGE__: shellRange(root),
96
83
  __DSH_UI_VERSION__: dshUi.version,
97
84
  };
98
- // 按**已知 token 名**逐个盖章,而不是按 `__[A-Z]+__` 的形状:名字里带下划线的 token
99
- // (`__SHELL_RANGE__`)也要认;同时 DIY 模板 README 里真写着的 `workbench___UNIT___rebuild`
100
- // 仍然只认内层那一个(按形状宽匹配会把三重下划线整段吞掉,工具名就盖不成 `workbench_demo_rebuild`)。
85
+
101
86
  const fill = (text) =>
102
87
  text.replace(new RegExp(Object.keys(tokens).join('|'), 'g'), (token) => tokens[token]);
103
88
  const templateDir = fileURLToPath(new URL(template, import.meta.url));