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

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 +18 -3
  5. package/src/arch.mjs +158 -129
  6. package/src/build.mjs +6 -30
  7. package/src/changelog.mjs +167 -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 +54 -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 +71 -241
  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 +6 -24
  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 +80 -0
  40. package/src/release.mjs +1251 -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
@@ -19,9 +19,6 @@ const FORBIDDEN = [
19
19
 
20
20
  const WORKSPACE = { path: '/tmp/repo', workspaceId: 'wb-card-probe' };
21
21
 
22
- // 刷新那一帧的旧时间戳(`at`)。真壳在 `ready` 那一步写 `Date.now()`,同一个 identity 重读时
23
- // 把它原样带进 loading 相(`use-api-resource.ts:102`);这里给一个固定的过去时刻,判据不依赖
24
- // "现在几点"(同一个值每次跑都一样,负例才好断言)。
25
22
  const STALE_AT = 1_700_000_000_000;
26
23
 
27
24
  function anyStub(path = 'stub') {
@@ -103,51 +100,52 @@ const SNAPSHOTS = [
103
100
  resource: { phase: 'error', error: { code: 'network', message: '宿主没有响应' } },
104
101
  workspace: WORKSPACE,
105
102
  },
106
- // P2 第一条:刷新形态(ADR-0022「刷新保留旧值」)。载荷与 `loading` 只差两格 —— `value` 与
107
- // `at` 都在,而且是旧那份。`use-api-resource.ts:100-104` 在同一个 identity 重读时给的就是这一相
108
- // (`{ phase: 'loading', value: prev.value, ...(prev.at === undefined ? {} : { at: prev.at }) }`,
109
- // 而 `prev.at` 是 `ready` 那一步写的 `Date.now()`,所以真实这一相两格一起在)。卡片若把
110
- // "loading 就没有值"写死,脏就出在这一帧;若把旧值当新值、或拿旧 `at` 当"刚读到"去算,脏也出在这一帧。
103
+
111
104
  {
112
105
  name: 'loading(刷新:保留旧值)',
113
106
  resource: { phase: 'loading', value: containerStub('api', 2), at: STALE_AT },
114
107
  workspace: WORKSPACE,
115
108
  },
116
- // P2 第二条:能力闸。`useCapabilityGate` 给一个**明确的** `missing-plugin`(不是 `null`):
117
- // `use-unit-board.ts:47` 对 `capability === null` 直接不算 `blockerOfCapability`,喂 null 就退化成
118
- // P1 的 idle 那一屏、一格新代码都走不到。`gated` 由能力状态按 `gateOf`(`unit-board.ts:13-17`)
119
- // 算出来(missing-plugin ⇒ false),`resource` 照 `ready` 一份 —— 于是它跟 `ready` 只差闸这一格,
120
- // 阻断屏与正文同时在场时谁优先是模块的事。
109
+
121
110
  {
122
111
  name: 'unreadable(能力闸:缺插件)',
123
112
  resource: { phase: 'ready', value: containerStub('api', 2) },
124
113
  workspace: WORKSPACE,
125
114
  capability: (unit) => ({
126
115
  unit,
127
- status: 'missing-plugin',
128
- required: ['demo-plugin'],
129
- missing: ['demo-plugin'],
116
+ status: 'missing',
117
+ needs: [
118
+ {
119
+ id: 'extension:demo-plugin',
120
+ kind: 'extension',
121
+ name: 'demo-plugin',
122
+ status: 'missing',
123
+ detail: '逐态探针:缺 demo-plugin',
124
+ },
125
+ ],
130
126
  detail: '逐态探针:缺 demo-plugin',
131
127
  }),
132
128
  },
133
- // P2 补记:能力闸的第二种 status —— `missing-commands`(插件在、命令旧)。它与缺插件那一帧
134
- // 差的是两格:`status` 换掉,且 `fix` / `command` 都在(真壳 `probe-verdict.ts:109-116`:命令落后
135
- // 的插件状态是 `outdated`,于是 `fix: 'update'`;`:158-160` 再带上那条升级命令)。这两格正好把
136
- // `blockerOfCapability` 里 `install` 那一段走通:`fixAction`(`blocker.fix.update`)+ `copyAction`
137
- // (`blocker.copy` + `command` 原文,`blocker-table.ts:109-123`),加上照旧的 retry —— 缺插件那一帧
138
- // 两格都没给,install 是空的,只走到 retry。`sourceUnknown` 在 `fix` 在时不成立,所以不开市场那条。
129
+
139
130
  {
140
131
  name: 'unreadable(能力闸:缺命令)',
141
132
  resource: { phase: 'ready', value: containerStub('api', 2) },
142
133
  workspace: WORKSPACE,
143
134
  capability: (unit) => ({
144
135
  unit,
145
- status: 'missing-commands',
146
- required: ['demo-plugin'],
147
- missing: ['demo.command'],
136
+ status: 'outdated',
137
+ needs: [
138
+ {
139
+ id: 'extension:demo-plugin',
140
+ kind: 'extension',
141
+ name: 'demo-plugin',
142
+ status: 'outdated',
143
+ missing: ['demo.command'],
144
+ detail: '逐态探针:demo-plugin 装在,缺 demo.command',
145
+ command: "'opencli' plugin update 'demo-plugin'",
146
+ },
147
+ ],
148
148
  detail: '逐态探针:demo-plugin 装在,缺 demo.command',
149
- fix: 'update',
150
- command: 'opencli plugin update demo-plugin',
151
149
  }),
152
150
  },
153
151
  ];
@@ -403,9 +401,7 @@ export async function run() {
403
401
  if (realKit === undefined) return fakeKit();
404
402
  const hooks = { ...(realKit.hooks ?? {}) };
405
403
  hooks.useApiResource = () => current.snapshot.resource;
406
- // 闸这一格的默认(P1 五态都是它):没查过 ⇒ capability null、gated true。快照带了
407
- // `capability` 才换成那一份,`gated` 照 `gateOf(capability, 'capability')` 算 —— 判据不当
408
- // 第二份真相:状态的语义归外壳,这里只是把"外壳会给的那一格"摆出来。
404
+
409
405
  hooks.useCapabilityGate = (unit) => {
410
406
  const capability = current.snapshot.capability?.(unit) ?? null;
411
407
  return {
@@ -1,29 +1,8 @@
1
- // 跨包的**模块契约**检查(S2 的第一块:门禁读包的自述清单,不扫源码)。
2
- //
3
- // 为什么需要它:dsh 的组合阶段**不校验** `dsh.client.external` —— 实测把一个不存在的
4
- // 外部声明(`@dsh-workbench/core-nope/client`)写进模块包,它被原样收进启动图、
5
- // 宿主侧一声不吭、程序照起;错的声明要等浏览器 `require` 那一刻才炸。
6
- //
7
- // 判据全部来自各包 package.json 里的 `dsh` 字段(**自述清单**):
8
- // · `dsh.bundle.patch` 指的补丁文件真的在;
9
- // · `dsh.client.platform` 是认识的平台;
10
- // · `dsh.client` 的 inject / external 每一条都能落到图里的一行(seed 词 / 平台包 / workspace 包);
11
- // · `dsh.workbench`(**这是个模块**):
12
- // - `unit` 形状合法、全局唯一(撞了就是装上去当场抛);
13
- // - `dsh.workbenchFaces.host` / `.client` 指的源码入口文件真的在 ——
14
- // 它是**工具链字段**,不在 `dsh.workbench` 协议里(ADR-0038 §7 第 7 条):
15
- // `source` 判据靠这两个入口做可达性分析,所以它必须是源码路径、必须声明;
16
- // - 对壳有 peer 依赖(它跑在外壳交出来的服务上);
17
- // - 有自己的接线自检脚本(模块侧也必须机器判据,不能只靠人眼)。
18
- //
19
- // 形状(所有判据统一):导出 `title` 与 `run()`,`run()` 返回三态结果;
20
- // 打印与退出码归 `../check.mjs` 那一层。
21
-
22
1
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
23
2
  import { createRequire } from 'node:module';
24
3
  import { join, relative } from 'node:path';
25
4
  import { fileURLToPath, pathToFileURL } from 'node:url';
26
- import { readDeclaration } from '@dsh-workbench/shared/host';
5
+ import { buildSupplyIndex, readDeclaration, recipeProblemOf } from '@dsh-workbench/shared/host';
27
6
  import {
28
7
  DECLARED_TOOLS_FILE,
29
8
  declaredToolsProblemOf,
@@ -31,11 +10,12 @@ import {
31
10
  declaredToolsApplies,
32
11
  } from '../declared-tools.mjs';
33
12
  import { discoverPackages, workspaceRootOf } from '../discover.mjs';
13
+ import { readDesign } from '../design.mjs';
14
+ import { datasourceProblemOf } from '../datasource-file.mjs';
34
15
  import { SEED_SHORT } from '../seed.mjs';
35
16
 
36
17
  const ROOT = process.env.DSH_WB_ROOT ?? fileURLToPath(new URL('../../../../', import.meta.url));
37
18
 
38
- /** dsh 浏览器侧的固定 seed 词表(模块表里天生就有的那几个)—— 唯一真相在 `../seed.mjs`。 */
39
19
  const SEED = SEED_SHORT;
40
20
 
41
21
  const PLATFORMS = new Set(['web']);
@@ -50,10 +30,6 @@ function text(value) {
50
30
  return typeof value === 'string' ? value.trim() : '';
51
31
  }
52
32
 
53
- // 把模块的**宿主产物**挂起来,拿到它的面(清单 + needsApproval / approvalDetail)。
54
- // 与模块自己的 smoke 用的是同一条路:apply() 要一个 workbenchUnits,register 里把面接住。
55
- // 挂不起来(产物缺失、apply 抛、mount 需要真的 store)一律返回 undefined —— 那是别的判据的事,
56
- // 这里只负责"清单说它会写,那它有没有认领审批"。
57
33
  async function unitFaceOf(dir) {
58
34
  const entry = join(dir, 'dist/host.js');
59
35
  if (!existsSync(entry)) return undefined;
@@ -82,58 +58,6 @@ async function unitFaceOf(dir) {
82
58
  }
83
59
  }
84
60
 
85
- function isRegistry(value) {
86
- return /^(https?|file):\/\//.test(value);
87
- }
88
-
89
- function envProblemsOf(name, env) {
90
- const problems = [];
91
- const libs = env.libs ?? [];
92
- const plugins = env.plugins ?? [];
93
- if (text(env.command) === '' && libs.length === 0 && plugins.length === 0) {
94
- problems.push(`${name}: env 是空的 —— 要么删掉它,要么写清这个模块靠什么才跑得起来(command / libs / plugins)`);
95
- }
96
- libs.forEach((need, index) => {
97
- const at = `env.libs[${index}]`;
98
- if (text(need?.name) === '') problems.push(`${name}: ${at} 没写 name`);
99
- if (text(need?.registryEnv) === '') {
100
- problems.push(`${name}: ${at} 没写 registryEnv —— 用户的内网源没法覆盖它,换源时只能改代码`);
101
- }
102
- if (!isRegistry(text(need?.registryDefault))) {
103
- problems.push(
104
- `${name}: ${at}.registryDefault 不是 http(s):// 或 file:// 的源地址 —— ${JSON.stringify(need?.registryDefault)}`,
105
- );
106
- }
107
- if (text(need?.purpose) === '') {
108
- problems.push(`${name}: ${at} 没写 purpose —— "装它干嘛"就是给用户看的那半句,不能空`);
109
- }
110
- });
111
- plugins.forEach((need, index) => {
112
- const at = `env.plugins[${index}]`;
113
- if (text(need?.name) === '') problems.push(`${name}: ${at} 没写 name`);
114
- for (const command of need?.commands ?? []) {
115
- if (text(command) === '') problems.push(`${name}: ${at}.commands 里有空字符串`);
116
- }
117
- const source = need?.installSource;
118
- if (source === undefined) return;
119
- if (source.kind === 'npm') {
120
- if (text(source.package) === '') problems.push(`${name}: ${at}.installSource 是 npm 却没写 package`);
121
- if (text(source.registryEnv) === '') problems.push(`${name}: ${at}.installSource 没写 registryEnv`);
122
- } else if (source.kind === 'git') {
123
- if (text(source.url) === '') problems.push(`${name}: ${at}.installSource 是 git 却没写 url`);
124
- } else if (source.kind === 'local') {
125
- if (text(source.env) === '') problems.push(`${name}: ${at}.installSource 是 local 却没写 env`);
126
- } else {
127
- problems.push(
128
- `${name}: ${at}.installSource.kind 不认识 —— ${JSON.stringify(source.kind)}(只有 npm / git / local)`,
129
- );
130
- }
131
- });
132
- return problems;
133
- }
134
-
135
- // opencli 的隔离运行时(唯一真相在 packages/shared/src/host/opencli-runtime.ts;module-tools 不依赖
136
- // shared,所以这里按同一套约定重算一遍 —— 认同一组环境变量,测试与 selfcheck 靠它注入 fixture)。
137
61
  const OPENCLI_CATALOG_ENV = 'DSH_WB_OPENCLI_CATALOG';
138
62
  const OPENCLI_NPM_PACKAGE = '@jackwener/opencli';
139
63
 
@@ -216,10 +140,6 @@ function sourceFilesOf(dir) {
216
140
  return found;
217
141
  }
218
142
 
219
- // `plugin: '<字面量>'` 与同一行里 `?? '<字面量>'` 的兜底:都该是**清单里声明过**的那个插件名。
220
- // 插件名只该有一处真相(能拿到 `deps.manifest` 的地方就用它),但模块级代码拿不到 manifest ⇒ 只能写字面量。
221
- // 这条判据拦的是"改了清单没改代码"(或反过来):那样外壳会按错的名字去装 / 去探那个插件,而用户只看到
222
- // "缺命令"这种没头没尾的状态。只查 `plugin:` 那几行,别的字面量不管(不猜)。
223
143
  function pluginLiteralsOf(line) {
224
144
  if (!/\bplugin\s*:/.test(line)) return [];
225
145
  const found = [];
@@ -251,8 +171,7 @@ function fakeLongTasks(created) {
251
171
  return {
252
172
  create(options) {
253
173
  created.push(options);
254
- // 形状跟着真外壳那份注册表走(`createLongTaskRegistry`):假的那份少一个方法,
255
- // 把注册表原样返回的模块就会被这条判据冤枉成"缺方法"——所以 `stop` / `live` 也在这里。
174
+
256
175
  return {
257
176
  start: () => ({}),
258
177
  snapshot: () => null,
@@ -266,10 +185,6 @@ function fakeLongTasks(created) {
266
185
  };
267
186
  }
268
187
 
269
- // 契约里 mount 一定拿得到 deps.store(外壳给的是 WorkspaceStore)。假的 deps 少给这一样,
270
- // 用到 store 的模块(存偏好是最常见的用法)就会在这里抛、被当成"mount 坏了",
271
- // 然后在审批那条判据上被冤枉成"没给出合法口径"。所以假 store 也给它一个:
272
- // for / machine / scan 都回同一个内存文件区,写进去的东西后面读得回来。
273
188
  function fakeStore() {
274
189
  const files = new Map();
275
190
  const one = () => ({
@@ -286,11 +201,6 @@ function fakeStore() {
286
201
  return { for: () => one(), machine: () => one(), scan: () => [one()] };
287
202
  }
288
203
 
289
- // mount 也一定拿得到 shell 与 workspaceRegistry(外壳给的是 dsh 交出来的服务)。
290
- // 少给这两样,模块的**降级写法**就会生效 —— 而"拿不到 shell 就只交 routes + registerTools"
291
- // 是合法写法(实测 pduiv2 的 coding-issues / coding-analysis 就这么写),降级后的面本来就不带
292
- // 审批钩子,判据却会把这读成"没认领审批":红的是判据自己的环境,不是模块。
293
- // (与上面 `store` 那笔账同一条纪律:假 deps 少给一样,模块就在这条判据上被冤枉。)
294
204
  function fakeShell() {
295
205
  return {
296
206
  resolve: () => undefined,
@@ -316,7 +226,7 @@ function fakeWorkspaceRegistry() {
316
226
 
317
227
  function mountOf(face, created, manifest, tools) {
318
228
  if (face === undefined || typeof face.mount !== 'function') return undefined;
319
- // 假服务**一次 mount 内是同一个对象**(真外壳给的就是同一个)—— 有的模块会拿两次再比。
229
+
320
230
  const shell = fakeShell();
321
231
  const workspaceRegistry = fakeWorkspaceRegistry();
322
232
  const toolRegistry = {
@@ -355,16 +265,6 @@ const LONG_TASK_METHODS = ['start', 'snapshot', 'active', 'current', 'live', 'st
355
265
 
356
266
  const LONG_TASK_RENDERERS = ['useLongTask', 'LongTaskRow', 'longTaskDot'];
357
267
 
358
- // 审批探针要用的**合成调用**。
359
- //
360
- // 为什么不是 `{ arguments: {} }`:外壳调 needsApproval 时拿到的是 agent 真传的参数,
361
- // 而"这次要不要点头"本来就允许依赖参数 —— "带 confirm: true 才真的写"是最常见的合法口径。
362
- // 空参数探会把这类模块判成"没认领审批"(实测 pduiv2 那 4 条审批报错就是这么来的,
363
- // ADR-0038 §6.15)。所以能拿到工具的 `parameters`(JSON Schema)时,按**必填项**造一个
364
- // 形状合法的调用:枚举取第一个值,string / number / boolean / array / object 各给一个占位值。
365
- //
366
- // 拿不到(registerTools() 没注册出这把工具、或注册时没带 parameters)就退回空参数,
367
- // 并在判红时把这件事写进那句话 —— 那种情况下判据只能验到"钩子在不在"这一层。
368
268
  function placeholderOf(schema) {
369
269
  if (typeof schema !== 'object' || schema === null) return null;
370
270
  if (Array.isArray(schema.enum) && schema.enum.length > 0) return schema.enum[0];
@@ -389,36 +289,12 @@ function probeCallOf(tool) {
389
289
  return { arguments: args, shaped: true };
390
290
  }
391
291
 
392
- // 审批头行(dsh 那张卡的 `.headline`)的预算。
393
- //
394
- // 为什么要有这条:`.headline` 是**纯文本的一格**,没有 `white-space` —— 换行会被塌成空格,
395
- // 长句就不是"多几行"而是"一大坨"。三段都会落进那一格(外壳按 `明细 —— 问句 —— 附注` 拼):
396
- // · `approvalDetail` 的 zh/en = 明细(模块写的"这次要动什么",本该是一句短标题)
397
- // · `question` 的 zh/en = 问句(外壳拼在明细后面的那句"允许吗")
398
- // · `fallback` 的 zh/en = 兜底整句(明细缺失时顶上来)
399
- // 长内容(文件路径 / diff / 列表 / 正文 / 多字段)该走模块的 `approval.render`:
400
- // 那是卡上**另一格**(`conversation.approval.detail`),有排版、能换行、能画 diff。
401
- // 实测(2026-10-16 一条真实审批卡):明细里塞了一整条 commit message、问句 77 字、附注 75 字,
402
- // 屏幕上 5 行 300 字。
403
292
  export const HEAD_BUDGETS = { detail: 20, question: 40, fallback: 40 };
404
293
 
405
- // 英文那一侧只用来抓"真正的一坨":按中文的 3 倍算。长度主要看中文那侧(用户读的是它),
406
- // 实测同一句的英文是中文的 2.2–2.8 倍(machine 直译的风格),按 2 倍算会把正常英文问句全判红。
407
294
  const EN_RATIO = 3;
408
295
 
409
- // 长度预算是**两轮**的:
410
- // · 第 1 轮(占位短值,见 probeCallOf):紧预算 —— 头行本来就该是一句短句;
411
- // · 第 2 轮(长值,见 budgetProbeOf):宽 3 倍 —— 专抓"把可变长字段塞进头行"。
412
- // 宽 3 倍是故意的:对象名、文件名这类标识符本来就有长有短,不该因为"名字长"判红;
413
- // 而一条 commit message、一段正文(60 字)塞进标题,3 倍也照样兜不住 —— 那才是要抓的。
414
296
  const LONG_SCALE = 3;
415
297
 
416
- // 只按必填项造的探针(`probeCallOf`)给的是**占位短值**(`probe` / `1` / `true`)——
417
- // 于是"把可变长字段塞进头行"这件事它是**瞎的**:实测那条真实审批卡的明细是
418
- // `改对象文件:对象「diep-dify」的「tencent-test-values.yaml」 仓库:helm 提交信息:<一整条 commit message>`(157 字),
419
- // 而探针上只有 20 字(`probe`)⇒ 判据照绿。所以第 2 轮把**自由文本字段**换成真长度(60 字):
420
- // 名字里带 message / note / reason / body / text / content / comment / title / description / summary
421
- // 的字段(这些就是"人可以写一段话"的地方),其余 string(标识符)给 12 字的正常长度。
422
298
  const FREE_TEXT = /message|note|reason|body|text|content|comment|title|description|summary/i;
423
299
  const LONG_TEXT = '这里是一段真实长度的正文用来量头行预算不要把这种内容放进标题里'.repeat(2);
424
300
  const LONG_ID = '真实长度的名字十二个字';
@@ -488,11 +364,6 @@ function installed(specifier, from) {
488
364
  }
489
365
  }
490
366
 
491
- /**
492
- * 这个包是不是"裸"的:上面没有任何 workspace(`pnpm-workspace.yaml` 或 `packages/`),
493
- * 自己也没装依赖。裸目录里 `init` 出来的骨架就是这样 —— 它报的 inject/external 落不到图里
494
- * **不是声明写错**,而是"它压根不在一个 workspace 里",而原文把这句话说成了"声明有问题"。
495
- */
496
367
  function barePackage(pkg) {
497
368
  return workspaceRootOf(pkg.dir) === pkg.dir && !existsSync(join(pkg.dir, 'node_modules'));
498
369
  }
@@ -502,7 +373,6 @@ function packageOf(specifier) {
502
373
  return specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0];
503
374
  }
504
375
 
505
- /** 深比较两份 JSON:对象**键序无关**,数组**顺序有关**(工具那一串的顺序是声明的一部分)。 */
506
376
  function reconcileTools(name, manifest, toolNames) {
507
377
  const declared = manifest.tools.map((tool) => tool.name).sort();
508
378
  const actual = [...new Set(toolNames.filter((one) => one !== ''))].sort();
@@ -519,10 +389,11 @@ export async function run() {
519
389
  const packages = discoverPackages({ root: process.cwd() });
520
390
  const known = new Set(packages.map((pkg) => pkg.manifest.name));
521
391
  const problems = [];
392
+ const warnings = [];
522
393
  const unresolvedIn = new Set();
523
394
  const units = new Map();
524
- // 判据"看到了但下不了结论"的那些,写进 notes(红绿两边都印)——
525
- // 与其给一个撑不住的 verdict,不如把作者该自己确认的那一件事说清楚(ADR-0038 §6.15)。
395
+ const supplyDeclarations = [];
396
+
526
397
  const probeNotes = [];
527
398
  const headlineNotes = [];
528
399
  let declared = 0;
@@ -534,10 +405,6 @@ export async function run() {
534
405
  if (dsh === undefined) continue;
535
406
  declared += 1;
536
407
 
537
- // `dsh.bundle.patch` 允许是**一个字符串,也允许是一个数组**(dsh 自己的 dsh-web-app 就是数组:
538
- // 它的 cordis.patch.yml + 四个 preset 文件全挂在这一处)。这里原来只按字符串处理,
539
- // 于是第一个用数组的包会把判据**打崩**(ERR_INVALID_ARG_TYPE,堆栈指在 path.join 上,
540
- // 而真正的问题是"声明形状没接住")—— 外壳加 DIY 的 preset 时踩到。
541
408
  const patches = dsh.bundle?.patch;
542
409
  for (const patch of patches === undefined ? [] : Array.isArray(patches) ? patches : [patches]) {
543
410
  if (!existsSync(join(pkg.dir, patch))) {
@@ -589,10 +456,6 @@ export async function run() {
589
456
  }
590
457
  }
591
458
 
592
- // 清单里的工具名与「代码里那份名字」的关系(ADR-0038 的 `tools[].key` 一节):
593
- // 给了 key 就走生成物那一档 —— `src/declared-tools.ts` 必须存在、且与清单一致(过期 = 红),
594
- // 并且源码里不许再有工具名的字面量(名字只写在清单里,代码引用 `DECLARED_TOOLS.<key>.name`,
595
- // 键写错是编译错)。一个 key 都没写的模块不适用(那是不用生成物那一档)。
596
459
  const declaredProblem = declaredToolsProblemOf(pkg.dir, dsh.workbench);
597
460
  if (declaredProblem !== undefined) {
598
461
  problems.push(`${name}: ${declaredProblem}`);
@@ -660,17 +523,21 @@ export async function run() {
660
523
  }
661
524
  }
662
525
 
663
- // 外部供给:清单里声明了 env,就要把"靠什么跑起来"和"没装好时怎么办"都说清。
664
- // 外壳只按 env 去装(app/host.ts 把 plugins/libs 交给隔离运行时跑 probe/install),
665
- // 它不知道这个能力叫什么 —— 所以"照做"那句安装命令必须由模块自己给。
666
526
  const read = readDeclaration(name, dsh.workbench);
667
527
  const manifest = read.manifest;
668
528
  const created = [];
669
- // 注册出来的**工具对象**(不只是名字):审批探针要读它的 `parameters` 造形状合法的调用。
529
+
670
530
  const tools = [];
671
531
  const face = await unitFaceOf(pkg.dir);
672
532
  const mounted = face === undefined ? undefined : mountOf(face, created, manifest, tools);
673
- const env = manifest?.env;
533
+ const needs = manifest?.needs;
534
+ warnings.push(...read.warnings);
535
+ if (manifest === undefined) {
536
+ for (const one of read.problems) problems.push(one);
537
+ }
538
+ if (manifest !== undefined) {
539
+ supplyDeclarations.push({ id: manifest.id, needs: needs ?? [] });
540
+ }
674
541
 
675
542
  if (manifest !== undefined) {
676
543
  if (face === undefined) {
@@ -694,15 +561,12 @@ export async function run() {
694
561
  }
695
562
  }
696
563
 
697
- if (env !== undefined) {
698
- problems.push(...envProblemsOf(name, env));
564
+ if (needs !== undefined) {
699
565
  const products = productsTextOf(pkg.dir);
566
+ const extensions = needs.filter((need) => need?.kind === 'extension');
700
567
 
701
- // 声明的插件命令必须真的存在:拿 opencli 自带目录(按 site 归组,别名也算)与这台机器上
702
- // 已装的插件包对一遍。**插件两头都找不到的就不管**(那说明这台机器还没装它,没得对,别误伤)——
703
- // 拦的是"插件明明在,命令名却写错了":那样用户那边的"缺命令"永远补不上。
704
568
  const catalog = catalogBySite();
705
- (env.plugins ?? []).forEach((plugin, index) => {
569
+ extensions.forEach((plugin, index) => {
706
570
  const pluginName = text(plugin?.name);
707
571
  const declared = (plugin?.commands ?? []).map((one) => text(one)).filter((one) => one !== '');
708
572
  if (pluginName === '' || declared.length === 0) return;
@@ -714,15 +578,13 @@ export async function run() {
714
578
  if (missing.length === 0) return;
715
579
  const knownList = [...known].sort();
716
580
  problems.push(
717
- `${name}: env.plugins[${index}](${pluginName})声明的命令 ${missing.join(' / ')} 在插件里找不到 —— ` +
581
+ `${name}: needs 里第 ${index} 条 extension(${pluginName})声明的命令 ${missing.join(' / ')} 在插件里找不到 —— ` +
718
582
  `它真有这些:${knownList.slice(0, 8).join(' / ')}${knownList.length > 8 ? ' …' : ''}。` +
719
583
  `命令名写错的话,用户那边的"缺命令"永远补不上(用 \`opencli ${pluginName} --help\` 看原名)`,
720
584
  );
721
585
  });
722
586
 
723
- const declaredPlugins = new Set(
724
- (env.plugins ?? []).map((one) => text(one?.name)).filter((one) => one !== ''),
725
- );
587
+ const declaredPlugins = new Set(extensions.map((one) => text(one?.name)).filter((one) => one !== ''));
726
588
  if (declaredPlugins.size > 0) {
727
589
  for (const file of sourceFilesOf(pkg.dir)) {
728
590
  readFileSync(file, 'utf8')
@@ -732,7 +594,7 @@ export async function run() {
732
594
  if (declaredPlugins.has(literal)) continue;
733
595
  problems.push(
734
596
  `${name}: ${relative(pkg.dir, file)}:${index + 1} 里的插件名「${literal}」不在清单的 ` +
735
- `env.plugins[] 里(清单里是 ${[...declaredPlugins].join(' / ')})—— 改了清单没改代码` +
597
+ `needs(kind=extension)里(清单里是 ${[...declaredPlugins].join(' / ')})—— 改了清单没改代码` +
736
598
  '(或反过来)会让外壳按错的名字去装 / 去探那个插件。插件名只有一处真相:' +
737
599
  '能拿到 deps.manifest 的地方就用它,拿不到的地方至少与清单保持一致',
738
600
  );
@@ -741,17 +603,14 @@ export async function run() {
741
603
  }
742
604
  }
743
605
 
744
- if ((env.libs ?? []).length > 0 && !products.includes('libInstallCommand')) {
606
+ if (needs.some((need) => need?.kind === 'lib') && !products.includes('libInstallCommand')) {
745
607
  problems.push(
746
- `${name}: 声明了 env.libs 却没用 libInstallCommand(@dsh-workbench/shared/host 里现成的)—— ` +
608
+ `${name}: 声明了 lib 类 need 却没用 libInstallCommand(@dsh-workbench/shared/host 里现成的)—— ` +
747
609
  `lib 没装好时用户只看到"没装",没有能照做的那条安装命令`,
748
610
  );
749
611
  }
750
612
  }
751
613
 
752
- // 「他能自己配什么」有两种:视图内选择(route 参数,刷新回默认)与偏好(prefs + store,刷新还在)。
753
- // 设计表里写了哪一种就得真的接上哪一种 —— 否则用户改完刷新回到默认,而表上写着"能配"。
754
- // (**与 env 无关**:没有 env 的模块一样要查,所以放在 env 块外面。)
755
614
  const configRows = designRowsOf(pkg.dir, 'config');
756
615
  const hostProduct = hostProductOf(pkg.dir);
757
616
  const wiredPrefs = /\bprefs\s*:/.test(hostProduct);
@@ -769,9 +628,25 @@ export async function run() {
769
628
  );
770
629
  }
771
630
 
772
- // 底稿与清单只对账**键集合**:`design.json` 的 `capabilities[].name` 与清单的 `tools[].name`
773
- // 不重不漏(ADR-0038 §2.5)。**只对名字**:内容各写一处(人话在底稿、机器字段在清单),没有可比的东西。
774
- // 没有 `design.json` 的模块不查(底稿是可选的,契约不要求它);清单形状坏了的也不查(protocol 已经说话了)。
631
+ const design = readDesign(pkg.dir);
632
+ const datasourceProblem = datasourceProblemOf(pkg.dir, design);
633
+ if (datasourceProblem !== undefined) problems.push(`${name}: ${datasourceProblem}`);
634
+
635
+ const reads =
636
+ design !== undefined && Array.isArray(design.reads) && design.reads.length > 0
637
+ ? design.reads
638
+ : design !== undefined && design.datasource !== undefined
639
+ ? [design.datasource]
640
+ : [];
641
+ for (const read of reads) {
642
+ const recipe = read?.recipe;
643
+ if (recipe === undefined) continue;
644
+ const problem = recipeProblemOf(recipe);
645
+ if (problem !== undefined) {
646
+ problems.push(`${name}: design.json 的配方不合法(${problem})—— 跑 \`dsh-wb-module design check\` 看点名`);
647
+ }
648
+ }
649
+
775
650
  const capabilityRows = designRowsOf(pkg.dir, 'capabilities');
776
651
  if (capabilityRows !== undefined && manifest !== undefined) {
777
652
  const inDesign = capabilityRows.map((one) => text(one?.name)).filter((one) => one !== '');
@@ -792,22 +667,6 @@ export async function run() {
792
667
  }
793
668
  }
794
669
 
795
- // 长任务:跑一会儿的事,注册了就得让用户看得见那一行。
796
- //
797
- // 外壳按 mount 结果里的 longTasks 投影出 `${unit}/runs`(kit 的 useLongTask 读的就是它)。
798
- // 契约里的状态是**五种**:running / needs-login / stopped / ok / failed(`longTaskDot` 把它们映成
799
- // 进行中 / 要登录 / 灰点 / 完成 / 出错 —— `stopped` 落进默认分支,是"用户按停的",不是成功也不是出错)。
800
- // 契约里也**有"停止"**:注册表多一个 `stop(id)`,`run` 多收一个 `signal`。
801
- // `start` 还可以带一个**可选**的 `session`(起这一趟的那个会话 id,模块从 `sessionIdOf(exec)` 拿):
802
- // 不记 = 用户在界面上按的 —— 外壳那两把 `workbench_long_tasks_*` 按它判"谁的"(自己起的免审批)。
803
- // 判据**不查**这一条(它只查形状;"起的时候记没记来源"是形状之外的事)。
804
- // 但"停止"在注册表这一侧的射程只有两件:外壳把 `signal` abort 掉、把这一趟标成 `stopped`;
805
- // **真停不停得了看生产方认不认这个 signal** —— 那是模块的知识,判据查不了这一条
806
- // (内部是 opencli 子进程那类编排,今天最多做到"我们不再等它")。判据能查的是形状与"画得出来":
807
- // · 注册表形状要全(少了方法,外壳那条 runs 路由 / 那颗停止钮就按不动);
808
- // · create 的 key 非空、run 与 view 都是函数(没有 view,卡片上那行就没有可渲染的快照);
809
- // · 真的注册了长任务,客户端产物里就得出现 kit 那套(useLongTask / LongTaskRow / longTaskDot),
810
- // 否则用户点完只看到"没反应"。
811
670
  if (created.length > 0 || mounted?.longTasks !== undefined) {
812
671
  const registry = mounted?.longTasks;
813
672
  if (registry !== undefined) {
@@ -844,11 +703,6 @@ export async function run() {
844
703
  }
845
704
  }
846
705
 
847
- // 联动层:agent 动完 / 读完,面板那一格怎么知道要重读。
848
- //
849
- // 只查**形状**:`context.render` 不能拿假参数去调 —— 成熟模块在参数形状不对时是**故意 throw** 的
850
- // (coding-issues 的 render 就是"缺陷引用的形状不对 → throw"),拿假参数调会误伤正确的模块。
851
- // `factsOf` / `warmReads` 可以安全地拿空参数调一次,看它回的东西形状对不对。
852
706
  if (mounted !== undefined) {
853
707
  const factsOf = mounted.factsOf;
854
708
  if (factsOf !== undefined && typeof factsOf !== 'function') {
@@ -909,21 +763,6 @@ export async function run() {
909
763
  }
910
764
  }
911
765
 
912
- // 声明会写的工具,必须同时认领"这次要不要点头"。
913
- //
914
- // 外壳每次调用前都会问套件:writes:true 的工具,needsApproval 不给 verdict.kind==='ask'
915
- // 就**当场 deny**(原文:"在套件的 needsApproval 里补一条审批(含"这次会发生什么"),别让写静默过去")——
916
- // 那条错误只有真跑一次才看得见,用户看到的是一次莫名其妙的拒绝。所以门禁在出包前先看一遍。
917
- //
918
- // **这条判据只能验到哪一层**(原样写给以后改它的人):
919
- // · 硬事实:一个 writes:true 的工具,mount() 有没有把 needsApproval 交出来;
920
- // · 判据能造出合成调用时:对那次调用给的 verdict 形状对不对;verdict 是 ask 时
921
- // approvalDetail 说没说清"这次会发生什么";
922
- // · **验不了的**:真实写入调用会不会真的 ask。答案依赖参数的模块("带 confirm: true 才真的写")
923
- // 在合成调用上依法回 `{ kind: 'read' }` —— 外壳那条路(shared/host/tool-approval.ts)在 read
924
- // 时**根本不会问 approvalDetail**,所以这里也不再逼它给。这类模块列进 notes 让作者自己确认。
925
- // · 空参数探会误报:合成调用按声明里的 `parameters` 必填项造(见 probeCallOf),
926
- // 造不出来时退回空参数并把这件事写进判红那句。
927
766
  const writeTools = (manifest?.tools ?? []).filter((one) => one?.writes === true);
928
767
  if (writeTools.length > 0) {
929
768
  if (mounted === undefined) {
@@ -960,12 +799,9 @@ export async function run() {
960
799
  );
961
800
  continue;
962
801
  }
963
- // 合法口径有两种:`{ kind: 'read' }`(这件事不用问 —— 例:没什么可做的)与
964
- // `{ kind: 'ask', reason, question{zh,en}, fallback{zh,en} }`(要问,且问得清)。
965
- // 别的返回值(**包括 undefined**)外壳会当场 deny —— 那才是要拦的。
802
+
966
803
  const read = verdict?.kind === 'read';
967
- // deny(ADR-0032 第五态):套件说"这一通根本跑不成" —— 外壳在备料之前就拒,连问都不问。
968
- // 合成调用上它合法,但和 read 一样验不到"真的调用会不会问",进 notes 让作者自己确认。
804
+
969
805
  const deny = verdict?.kind === 'deny' && text(verdict.reason) !== '';
970
806
  const ask =
971
807
  verdict?.kind === 'ask' &&
@@ -984,9 +820,7 @@ export async function run() {
984
820
  );
985
821
  continue;
986
822
  }
987
- // read / deny:这次调用不用点头。**不查 approvalDetail** —— 外壳在这两档都不会去问它
988
- // (`approvalFor` 只在 verdict 是 ask 时才调 detailOf)。合成调用本来就不是一次真实写入,
989
- // 拿它逼模块给"这次会发生什么"就是让判据替外壳问一个外壳不会问的问题。
823
+
990
824
  if (read || deny) {
991
825
  readOnProbe.push(tool.name);
992
826
  continue;
@@ -1003,8 +837,7 @@ export async function run() {
1003
837
  ? text(facts.blocked) !== ''
1004
838
  : text(facts.zh) !== '' && text(facts.en) !== '');
1005
839
  problems.push(...headlineProblemsOf(name, tool.name, verdict, facts));
1006
- // 第二轮:**长值**探(把声明里的 string 都填成 40 字正文、可选字段也填上),
1007
- // 专抓"标题里带了可变长字段"—— 第一轮用的是占位短值,对这件事是瞎的(见 budgetProbeOf)。
840
+
1008
841
  const longArgs = budgetProbeOf(registered.get(tool.name));
1009
842
  if (longArgs !== undefined) {
1010
843
  const longCall = { ...call, arguments: longArgs };
@@ -1077,7 +910,7 @@ export async function run() {
1077
910
  }
1078
911
  for (const file of suspects) {
1079
912
  const text = readFileSync(file, 'utf8');
1080
- const hit = /@pagoda\/dsh-workbench-module-[a-z0-9-]+/.exec(text);
913
+ const hit = /(?:@[a-z0-9-]+\/)?dsh-workbench-module-[a-z0-9-]+/.exec(text);
1081
914
  if (hit !== null) {
1082
915
  problems.push(
1083
916
  `${SHELL}: ${relative(ROOT, file)} 里点名了模块包 ${hit[0]} —— ` +
@@ -1087,10 +920,11 @@ export async function run() {
1087
920
  }
1088
921
  }
1089
922
 
923
+ problems.push(...buildSupplyIndex(supplyDeclarations).problems);
924
+
1090
925
  if (problems.length > 0) {
1091
926
  const notes = [`${declared} 个带 dsh 声明的包,其中 ${modules} 个模块`, ...probeNotes, ...headlineNotes];
1092
- // 落不到图里最常见的成因不是"声明写错",而是"这个包不在任何 workspace 里"(裸目录里 init
1093
- // 出来的骨架)。不点出来的话,屏幕上的话会把责任推给那两条声明,而真正该做的是换到工坊里跑。
927
+
1094
928
  const bare = [...unresolvedIn].filter(barePackage);
1095
929
  if (bare.length > 0) {
1096
930
  notes.push(
@@ -1099,7 +933,7 @@ export async function run() {
1099
933
  `做法:在工坊里造($DSH_HOME/workbench/diy/packages/module-<名字>),或者先在这个工作区里 pnpm install。`,
1100
934
  );
1101
935
  }
1102
- return { status: 'fail', notes, problems };
936
+ return { status: 'fail', notes: [...notes, ...warnings.map((one) => `(警告)${one}`)], problems };
1103
937
  }
1104
938
 
1105
939
  return {
@@ -1111,6 +945,7 @@ export async function run() {
1111
945
  `单元:${unitList}`,
1112
946
  ...probeNotes,
1113
947
  ...headlineNotes,
948
+ ...warnings.map((one) => `(警告)${one}`),
1114
949
  ],
1115
950
  problems: [],
1116
951
  };