@dsh-workbench/module-tools 0.1.14 → 0.1.16

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.
@@ -13,12 +13,15 @@
13
13
  // - `faces.host` / `faces.client` 指的入口文件真的在;
14
14
  // - 对壳有 peer 依赖(它跑在外壳交出来的服务上);
15
15
  // - 有自己的接线自检脚本(模块侧也必须机器判据,不能只靠人眼)。
16
+ //
17
+ // 形状(十条判据统一):导出 `title` 与 `run()`,`run()` 返回三态结果;
18
+ // 打印与退出码归 `../check.mjs` 那一层。
16
19
 
17
20
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
18
21
  import { createRequire } from 'node:module';
19
22
  import { join, relative } from 'node:path';
20
- import { fileURLToPath } from 'node:url';
21
- import { discoverPackages } from '../discover.mjs';
23
+ import { fileURLToPath, pathToFileURL } from 'node:url';
24
+ import { discoverPackages, workspaceRootOf } from '../discover.mjs';
22
25
  import { SEED_SHORT } from '../seed.mjs';
23
26
 
24
27
  const ROOT = process.env.DSH_WB_ROOT ?? fileURLToPath(new URL('../../../../', import.meta.url));
@@ -32,6 +35,237 @@ const UNIT_ID = /^[a-z][a-z0-9-]*$/;
32
35
 
33
36
  const SHELL = '@dsh-workbench/core';
34
37
 
38
+ export const title = '模块契约';
39
+
40
+ function text(value) {
41
+ return typeof value === 'string' ? value.trim() : '';
42
+ }
43
+
44
+ // 把模块的**宿主产物**挂起来,拿到它的面(清单 + needsApproval / approvalDetail)。
45
+ // 与模块自己的 smoke 用的是同一条路:apply() 要一个 workbenchUnits,register 里把面接住。
46
+ // 挂不起来(产物缺失、apply 抛、mount 需要真的 store)一律返回 undefined —— 那是别的判据的事,
47
+ // 这里只负责"清单说它会写,那它有没有认领审批"。
48
+ async function unitFaceOf(dir) {
49
+ const entry = join(dir, 'dist/host.js');
50
+ if (!existsSync(entry)) return undefined;
51
+ try {
52
+ const mod = await import(pathToFileURL(entry).href);
53
+ if (typeof mod.apply !== 'function') return undefined;
54
+ const faces = [];
55
+ mod.apply({
56
+ get: (key) =>
57
+ key === 'workbenchUnits'
58
+ ? {
59
+ register(face) {
60
+ faces.push(face);
61
+ return () => {};
62
+ },
63
+ }
64
+ : undefined,
65
+ effect: (fn) => {
66
+ fn();
67
+ },
68
+ logger: { warn: () => {}, info: () => {} },
69
+ });
70
+ return faces[0];
71
+ } catch {
72
+ return undefined;
73
+ }
74
+ }
75
+
76
+ function isRegistry(value) {
77
+ return /^(https?|file):\/\//.test(value);
78
+ }
79
+
80
+ function envProblemsOf(name, env) {
81
+ const problems = [];
82
+ const libs = env.libs ?? [];
83
+ const plugins = env.plugins ?? [];
84
+ if (text(env.command) === '' && libs.length === 0 && plugins.length === 0) {
85
+ problems.push(`${name}: env 是空的 —— 要么删掉它,要么写清这个模块靠什么才跑得起来(command / libs / plugins)`);
86
+ }
87
+ libs.forEach((need, index) => {
88
+ const at = `env.libs[${index}]`;
89
+ if (text(need?.name) === '') problems.push(`${name}: ${at} 没写 name`);
90
+ if (text(need?.registryEnv) === '') {
91
+ problems.push(`${name}: ${at} 没写 registryEnv —— 用户的内网源没法覆盖它,换源时只能改代码`);
92
+ }
93
+ if (!isRegistry(text(need?.registryDefault))) {
94
+ problems.push(
95
+ `${name}: ${at}.registryDefault 不是 http(s):// 或 file:// 的源地址 —— ${JSON.stringify(need?.registryDefault)}`,
96
+ );
97
+ }
98
+ if (text(need?.purpose) === '') {
99
+ problems.push(`${name}: ${at} 没写 purpose —— "装它干嘛"就是给用户看的那半句,不能空`);
100
+ }
101
+ });
102
+ plugins.forEach((need, index) => {
103
+ const at = `env.plugins[${index}]`;
104
+ if (text(need?.name) === '') problems.push(`${name}: ${at} 没写 name`);
105
+ for (const command of need?.commands ?? []) {
106
+ if (text(command) === '') problems.push(`${name}: ${at}.commands 里有空字符串`);
107
+ }
108
+ const source = need?.installSource;
109
+ if (source === undefined) return;
110
+ if (source.kind === 'npm') {
111
+ if (text(source.package) === '') problems.push(`${name}: ${at}.installSource 是 npm 却没写 package`);
112
+ if (text(source.registryEnv) === '') problems.push(`${name}: ${at}.installSource 没写 registryEnv`);
113
+ } else if (source.kind === 'git') {
114
+ if (text(source.url) === '') problems.push(`${name}: ${at}.installSource 是 git 却没写 url`);
115
+ } else if (source.kind === 'local') {
116
+ if (text(source.env) === '') problems.push(`${name}: ${at}.installSource 是 local 却没写 env`);
117
+ } else {
118
+ problems.push(
119
+ `${name}: ${at}.installSource.kind 不认识 —— ${JSON.stringify(source.kind)}(只有 npm / git / local)`,
120
+ );
121
+ }
122
+ });
123
+ return problems;
124
+ }
125
+
126
+ // opencli 的隔离运行时(唯一真相在 packages/shared/src/host/opencli-runtime.ts;module-tools 不依赖
127
+ // shared,所以这里按同一套约定重算一遍 —— 认同一组环境变量,测试与 selfcheck 靠它注入 fixture)。
128
+ const OPENCLI_CATALOG_ENV = 'DSH_WB_OPENCLI_CATALOG';
129
+ const OPENCLI_NPM_PACKAGE = '@jackwener/opencli';
130
+
131
+ function isolatedRootOf() {
132
+ const home = process.env.DSH_HOME?.trim();
133
+ return `${home === undefined || home === '' ? `${process.env.HOME ?? ''}/.dsh` : home}/workbench/opencli`;
134
+ }
135
+
136
+ function catalogPathOf() {
137
+ const override = process.env[OPENCLI_CATALOG_ENV]?.trim();
138
+ return override === undefined || override === ''
139
+ ? `${isolatedRootOf()}/toolchain/node_modules/${OPENCLI_NPM_PACKAGE}/cli-manifest.json`
140
+ : override;
141
+ }
142
+
143
+ function pluginDirOf(plugin) {
144
+ return `${isolatedRootOf()}/plugins-npm/node_modules/opencli-plugin-${plugin}`;
145
+ }
146
+
147
+ function pluginCommandsIn(dir) {
148
+ try {
149
+ return readdirSync(dir)
150
+ .filter((one) => one.endsWith('.js'))
151
+ .map((one) => one.slice(0, -3))
152
+ .filter((one) => one !== 'index' && !one.startsWith('_'));
153
+ } catch {
154
+ return undefined;
155
+ }
156
+ }
157
+
158
+ function catalogBySite() {
159
+ let parsed;
160
+ try {
161
+ parsed = JSON.parse(readFileSync(catalogPathOf(), 'utf8'));
162
+ } catch {
163
+ return undefined;
164
+ }
165
+ if (!Array.isArray(parsed)) return undefined;
166
+ const bySite = new Map();
167
+ for (const one of parsed) {
168
+ if (typeof one !== 'object' || one === null) continue;
169
+ const site = text(one['site']);
170
+ const name = text(one['name']);
171
+ if (site === '' || name === '') continue;
172
+ const set = bySite.get(site) ?? new Set();
173
+ set.add(name);
174
+ for (const alias of Array.isArray(one['aliases']) ? one['aliases'] : []) {
175
+ if (typeof alias === 'string' && alias !== '') set.add(alias);
176
+ }
177
+ bySite.set(site, set);
178
+ }
179
+ return bySite;
180
+ }
181
+
182
+ function productsTextOf(dir) {
183
+ let text = '';
184
+ for (const rel of ['dist/host.js', 'client.js']) {
185
+ const full = join(dir, rel);
186
+ if (existsSync(full)) text += `\n${readFileSync(full, 'utf8')}`;
187
+ }
188
+ return text;
189
+ }
190
+
191
+ function hostProductOf(dir) {
192
+ try {
193
+ return readFileSync(join(dir, 'dist/host.js'), 'utf8');
194
+ } catch {
195
+ return '';
196
+ }
197
+ }
198
+
199
+ function designRowsOf(dir, key) {
200
+ let parsed;
201
+ try {
202
+ parsed = JSON.parse(readFileSync(join(dir, 'design.json'), 'utf8'));
203
+ } catch {
204
+ return undefined;
205
+ }
206
+ return Array.isArray(parsed?.[key]) ? parsed[key] : [];
207
+ }
208
+
209
+ function fakeLongTasks(created) {
210
+ return {
211
+ create(options) {
212
+ created.push(options);
213
+ return {
214
+ start: () => ({}),
215
+ snapshot: () => null,
216
+ active: () => null,
217
+ current: () => [],
218
+ onChange: () => () => {},
219
+ };
220
+ },
221
+ };
222
+ }
223
+
224
+ // 契约里 mount 一定拿得到 deps.store(外壳给的是 WorkspaceStore)。假的 deps 少给这一样,
225
+ // 用到 store 的模块(存偏好是最常见的用法)就会在这里抛、被当成"mount 坏了",
226
+ // 然后在审批那条判据上被冤枉成"没给出合法口径"。所以假 store 也给它一个:
227
+ // for / machine / scan 都回同一个内存文件区,写进去的东西后面读得回来。
228
+ function fakeStore() {
229
+ const files = new Map();
230
+ const one = () => ({
231
+ dir: 'memory',
232
+ read: (name) => (files.has(name) ? files.get(name) : null),
233
+ write: (name, value) => {
234
+ files.set(name, value);
235
+ },
236
+ remove: (name) => {
237
+ files.delete(name);
238
+ },
239
+ list: () => [...files.keys()],
240
+ });
241
+ return { for: () => one(), machine: () => one(), scan: () => [one()] };
242
+ }
243
+
244
+ function mountOf(face, created) {
245
+ if (face === undefined || typeof face.mount !== 'function') return undefined;
246
+ try {
247
+ return face.mount({
248
+ ctx: {
249
+ get: () => undefined,
250
+ effect: (fn) => {
251
+ fn();
252
+ },
253
+ logger: { warn: () => {}, info: () => {} },
254
+ },
255
+ manifests: () => [],
256
+ opencliCommand: 'opencli',
257
+ store: fakeStore(),
258
+ longTasks: fakeLongTasks(created),
259
+ });
260
+ } catch {
261
+ return undefined;
262
+ }
263
+ }
264
+
265
+ const LONG_TASK_METHODS = ['start', 'snapshot', 'active', 'current', 'onChange'];
266
+
267
+ const LONG_TASK_RENDERERS = ['useLongTask', 'LongTaskRow', 'longTaskDot'];
268
+
35
269
  function installed(specifier, from) {
36
270
  try {
37
271
  createRequire(join(from, 'package.json')).resolve(`${packageOf(specifier)}/package.json`);
@@ -41,151 +275,422 @@ function installed(specifier, from) {
41
275
  }
42
276
  }
43
277
 
278
+ /**
279
+ * 这个包是不是"裸"的:上面没有任何 workspace(`pnpm-workspace.yaml` 或 `packages/`),
280
+ * 自己也没装依赖。裸目录里 `init` 出来的骨架就是这样 —— 它报的 inject/external 落不到图里
281
+ * **不是声明写错**,而是"它压根不在一个 workspace 里",而原文把这句话说成了"声明有问题"。
282
+ */
283
+ function barePackage(pkg) {
284
+ return workspaceRootOf(pkg.dir) === pkg.dir && !existsSync(join(pkg.dir, 'node_modules'));
285
+ }
286
+
44
287
  function packageOf(specifier) {
45
288
  const parts = specifier.split('/');
46
289
  return specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0];
47
290
  }
48
291
 
49
- const readPackages = () => discoverPackages({ root: process.cwd() });
50
-
51
- const packages = readPackages();
52
- const known = new Set(packages.map((pkg) => pkg.manifest.name));
53
- const problems = [];
54
- const units = new Map();
55
- let declared = 0;
56
- let modules = 0;
292
+ export async function run() {
293
+ const packages = discoverPackages({ root: process.cwd() });
294
+ const known = new Set(packages.map((pkg) => pkg.manifest.name));
295
+ const problems = [];
296
+ const unresolvedIn = new Set();
297
+ const units = new Map();
298
+ let declared = 0;
299
+ let modules = 0;
57
300
 
58
- for (const pkg of packages) {
59
- const name = pkg.manifest.name;
60
- const dsh = pkg.manifest.dsh;
61
- if (dsh === undefined) continue;
62
- declared += 1;
301
+ for (const pkg of packages) {
302
+ const name = pkg.manifest.name;
303
+ const dsh = pkg.manifest.dsh;
304
+ if (dsh === undefined) continue;
305
+ declared += 1;
63
306
 
64
- // `dsh.bundle.patch` 允许是**一个字符串,也允许是一个数组**(dsh 自己的 dsh-web-app 就是数组:
65
- // 它的 cordis.patch.yml + 四个 preset 文件全挂在这一处)。这里原来只按字符串处理,
66
- // 于是第一个用数组的包会把判据**打崩**(ERR_INVALID_ARG_TYPE,堆栈指在 path.join 上,
67
- // 而真正的问题是"声明形状没接住")—— 外壳加 DIY 的 preset 时踩到。
68
- const patches = dsh.bundle?.patch;
69
- for (const patch of patches === undefined ? [] : Array.isArray(patches) ? patches : [patches]) {
70
- if (!existsSync(join(pkg.dir, patch))) {
71
- problems.push(`${name}: dsh.bundle.patch 指的 ${patch} 不存在`);
307
+ // `dsh.bundle.patch` 允许是**一个字符串,也允许是一个数组**(dsh 自己的 dsh-web-app 就是数组:
308
+ // 它的 cordis.patch.yml + 四个 preset 文件全挂在这一处)。这里原来只按字符串处理,
309
+ // 于是第一个用数组的包会把判据**打崩**(ERR_INVALID_ARG_TYPE,堆栈指在 path.join 上,
310
+ // 而真正的问题是"声明形状没接住")—— 外壳加 DIY 的 preset 时踩到。
311
+ const patches = dsh.bundle?.patch;
312
+ for (const patch of patches === undefined ? [] : Array.isArray(patches) ? patches : [patches]) {
313
+ if (!existsSync(join(pkg.dir, patch))) {
314
+ problems.push(`${name}: dsh.bundle.patch 指的 ${patch} 不存在`);
315
+ }
72
316
  }
73
- }
74
317
 
75
- if (dsh.client !== undefined) {
76
- if (!PLATFORMS.has(dsh.client.platform)) {
77
- problems.push(`${name}: dsh.client.platform 不认识 —— ${String(dsh.client.platform)}`);
78
- }
79
- for (const field of ['inject', 'external']) {
80
- for (const specifier of dsh.client[field] ?? []) {
81
- const resolvable =
82
- SEED.has(specifier) ||
83
- packageOf(specifier).startsWith('@deepseek-ai/') ||
84
- known.has(packageOf(specifier)) ||
85
- installed(specifier, pkg.dir);
86
- if (!resolvable) {
87
- problems.push(
88
- `${name}: dsh.client.${field} 里的 ${JSON.stringify(specifier)} 落不到图里任何一行 —— ` +
89
- `既不是 seed 词、也不是平台包、也不在 workspace 里、也不是装进来的依赖(dsh 自己不校验这条,错的声明要等浏览器 require 才炸)`,
90
- );
318
+ if (dsh.client !== undefined) {
319
+ if (!PLATFORMS.has(dsh.client.platform)) {
320
+ problems.push(`${name}: dsh.client.platform 不认识 —— ${String(dsh.client.platform)}`);
321
+ }
322
+ for (const field of ['inject', 'external']) {
323
+ for (const specifier of dsh.client[field] ?? []) {
324
+ const resolvable =
325
+ SEED.has(specifier) ||
326
+ packageOf(specifier).startsWith('@deepseek-ai/') ||
327
+ known.has(packageOf(specifier)) ||
328
+ installed(specifier, pkg.dir);
329
+ if (!resolvable) {
330
+ unresolvedIn.add(pkg);
331
+ problems.push(
332
+ `${name}: dsh.client.${field} 里的 ${JSON.stringify(specifier)} 落不到图里任何一行 —— ` +
333
+ `既不是 seed 词、也不是平台包、也不在 workspace 里、也不是装进来的依赖(dsh 自己不校验这条,错的声明要等浏览器 require 才炸)`,
334
+ );
335
+ }
91
336
  }
92
337
  }
93
338
  }
94
- }
95
339
 
96
- if (dsh.workbench === undefined) continue;
97
- modules += 1;
98
-
99
- const unit = dsh.workbench.unit;
100
- if (typeof unit !== 'string' || !UNIT_ID.test(unit)) {
101
- problems.push(`${name}: dsh.workbench.unit 形状不对 —— ${JSON.stringify(unit)}(要小写连字符)`);
102
- } else if (units.has(unit)) {
103
- problems.push(
104
- `${name}: 单元 id ${JSON.stringify(unit)} 与 ${units.get(unit)} 撞了 —— ` +
105
- `两个包都声明同一个单元,装上去注册表当场抛(id 会进持久化配置)`,
106
- );
107
- } else {
108
- units.set(unit, name);
109
- const shellPkg = packages.find((item) => item.manifest.name === SHELL);
110
- if (shellPkg !== undefined && existsSync(join(shellPkg.dir, 'src/modules', unit, 'unit.ts'))) {
340
+ if (dsh.workbench === undefined) continue;
341
+ modules += 1;
342
+
343
+ const unit = dsh.workbench.unit;
344
+ if (typeof unit !== 'string' || !UNIT_ID.test(unit)) {
345
+ problems.push(`${name}: dsh.workbench.unit 形状不对 —— ${JSON.stringify(unit)}(要小写连字符)`);
346
+ } else if (units.has(unit)) {
111
347
  problems.push(
112
- `${name}: 单元 ${JSON.stringify(unit)} 在外壳里还有一份(${SHELL}/src/modules/${unit}/)—— ` +
113
- `同一个单元不许两边都住:两份真相,装上模块注册表当场抛`,
348
+ `${name}: 单元 id ${JSON.stringify(unit)} 与 ${units.get(unit)} 撞了 —— ` +
349
+ `两个包都声明同一个单元,装上去注册表当场抛(id 会进持久化配置)`,
114
350
  );
351
+ } else {
352
+ units.set(unit, name);
353
+ const shellPkg = packages.find((item) => item.manifest.name === SHELL);
354
+ if (shellPkg !== undefined && existsSync(join(shellPkg.dir, 'src/modules', unit, 'unit.ts'))) {
355
+ problems.push(
356
+ `${name}: 单元 ${JSON.stringify(unit)} 在外壳里还有一份(${SHELL}/src/modules/${unit}/)—— ` +
357
+ `同一个单元不许两边都住:两份真相,装上模块注册表当场抛`,
358
+ );
359
+ }
115
360
  }
116
- }
117
361
 
118
- for (const face of ['host', 'client']) {
119
- const rel = dsh.workbench.faces?.[face];
120
- if (typeof rel !== 'string') {
121
- problems.push(`${name}: dsh.workbench.faces.${face} 没声明`);
122
- } else if (!existsSync(join(pkg.dir, rel))) {
123
- problems.push(`${name}: dsh.workbench.faces.${face} 指的文件不存在 —— ${rel}`);
362
+ for (const face of ['host', 'client']) {
363
+ const rel = dsh.workbench.faces?.[face];
364
+ if (typeof rel !== 'string') {
365
+ problems.push(`${name}: dsh.workbench.faces.${face} 没声明`);
366
+ } else if (!existsSync(join(pkg.dir, rel))) {
367
+ problems.push(`${name}: dsh.workbench.faces.${face} 指的文件不存在 —— ${rel}`);
368
+ }
124
369
  }
125
- }
126
370
 
127
- const publishable = pkg.manifest.private !== true;
128
- const registry = pkg.manifest.publishConfig?.registry;
129
- const isPublic = pkg.manifest.publishConfig?.access === 'public';
130
- if (publishable && !isPublic && (typeof registry !== 'string' || registry.includes('registry.npmjs.org'))) {
131
- problems.push(
132
- `${name}: 它不是 private,却既没声明 publishConfig.access=public、也没有指向内网源的 registry —— ` +
133
- `发布目标必须显式写出来(公网 / 内网),否则等于把私有模块发到公共源`,
134
- );
135
- }
371
+ const publishable = pkg.manifest.private !== true;
372
+ const registry = pkg.manifest.publishConfig?.registry;
373
+ const isPublic = pkg.manifest.publishConfig?.access === 'public';
374
+ if (publishable && !isPublic && (typeof registry !== 'string' || registry.includes('registry.npmjs.org'))) {
375
+ problems.push(
376
+ `${name}: 它不是 private,却既没声明 publishConfig.access=public、也没有指向内网源的 registry —— ` +
377
+ `发布目标必须显式写出来(公网 / 内网),否则等于把私有模块发到公共源`,
378
+ );
379
+ }
136
380
 
137
- if (pkg.manifest.peerDependencies?.[SHELL] === undefined) {
138
- problems.push(
139
- `${name}: 没有对 ${SHELL} 的 peer 依赖 —— 模块跑在外壳交出来的服务上,装的时候必须能说清版本`,
140
- );
141
- }
381
+ if (pkg.manifest.peerDependencies?.[SHELL] === undefined) {
382
+ problems.push(
383
+ `${name}: 没有对 ${SHELL} 的 peer 依赖 —— 模块跑在外壳交出来的服务上,装的时候必须能说清版本`,
384
+ );
385
+ }
386
+
387
+ for (const script of ['build', 'typecheck', 'test', 'selfcheck']) {
388
+ if (pkg.manifest.scripts?.[script] === undefined) {
389
+ problems.push(
390
+ `${name}: 没有 ${script} 脚本 —— 模块包必须自带整套门禁:` +
391
+ `外壳的 arch / 覆盖率 / 运行时边界都只扫自己的 src/,模块出包之后没人替它跑`,
392
+ );
393
+ }
394
+ }
395
+
396
+ // 外部供给:清单里声明了 env,就要把"靠什么跑起来"和"没装好时怎么办"都说清。
397
+ // 外壳只按 env 去装(app/host.ts 把 plugins/libs 交给隔离运行时跑 probe/install),
398
+ // 它不知道这个能力叫什么 —— 所以"照做"那句安装命令必须由模块自己给。
399
+ const created = [];
400
+ const face = await unitFaceOf(pkg.dir);
401
+ const mounted = face === undefined ? undefined : mountOf(face, created);
402
+ const env = face?.manifest?.env;
403
+ if (env !== undefined) {
404
+ problems.push(...envProblemsOf(name, env));
405
+ const products = productsTextOf(pkg.dir);
406
+
407
+ // 声明的插件命令必须真的存在:拿 opencli 自带目录(按 site 归组,别名也算)与这台机器上
408
+ // 已装的插件包对一遍。**插件两头都找不到的就不管**(那说明这台机器还没装它,没得对,别误伤)——
409
+ // 拦的是"插件明明在,命令名却写错了":那样用户那边的"缺命令"永远补不上。
410
+ const catalog = catalogBySite();
411
+ (env.plugins ?? []).forEach((plugin, index) => {
412
+ const pluginName = text(plugin?.name);
413
+ const declared = (plugin?.commands ?? []).map((one) => text(one)).filter((one) => one !== '');
414
+ if (pluginName === '' || declared.length === 0) return;
415
+ const builtIn = catalog?.get(pluginName);
416
+ const installed = pluginCommandsIn(pluginDirOf(pluginName));
417
+ if (builtIn === undefined && installed === undefined) return;
418
+ const known = new Set([...(builtIn ?? []), ...(installed ?? [])]);
419
+ const missing = declared.filter((one) => !known.has(one));
420
+ if (missing.length === 0) return;
421
+ const knownList = [...known].sort();
422
+ problems.push(
423
+ `${name}: env.plugins[${index}](${pluginName})声明的命令 ${missing.join(' / ')} 在插件里找不到 —— ` +
424
+ `它真有这些:${knownList.slice(0, 8).join(' / ')}${knownList.length > 8 ? ' …' : ''}。` +
425
+ `命令名写错的话,用户那边的"缺命令"永远补不上(用 \`opencli ${pluginName} --help\` 看原名)`,
426
+ );
427
+ });
428
+
429
+ if ((env.libs ?? []).length > 0 && !products.includes('libInstallCommand')) {
430
+ problems.push(
431
+ `${name}: 声明了 env.libs 却没用 libInstallCommand(@dsh-workbench/shared/host 里现成的)—— ` +
432
+ `lib 没装好时用户只看到"没装",没有能照做的那条安装命令`,
433
+ );
434
+ }
435
+ }
436
+
437
+ // 「他能自己配什么」有两种:视图内选择(route 参数,刷新回默认)与偏好(prefs + store,刷新还在)。
438
+ // 设计表里写了哪一种就得真的接上哪一种 —— 否则用户改完刷新回到默认,而表上写着"能配"。
439
+ // (**与 env 无关**:没有 env 的模块一样要查,所以放在 env 块外面。)
440
+ const configRows = designRowsOf(pkg.dir, 'config');
441
+ const wiredPrefs = /\bprefs\s*:/.test(hostProductOf(pkg.dir));
442
+ const declaredPrefs = (configRows ?? []).some((one) => text(one?.['persistence']) === 'prefs');
443
+ if (declaredPrefs && !wiredPrefs) {
444
+ problems.push(
445
+ `${name}: 设计表里有一行写着"存起来"(config[].persistence = 'prefs'),产物里却没有接 prefs —— ` +
446
+ '用户改完刷新就回到默认,等于没存。接法:mount 返回 `prefs: { resource, handle }`,handle 里用 deps.store 落盘',
447
+ );
448
+ }
449
+ if (wiredPrefs && !declaredPrefs) {
450
+ problems.push(
451
+ `${name}: 产物里接了 prefs(能给用户配东西),设计表的 config[] 里却没有一行 persistence = 'prefs' —— ` +
452
+ '代码里能配、表上没写,下一个读表的人就不知道它刷新还在',
453
+ );
454
+ }
142
455
 
143
- for (const script of ['build', 'typecheck', 'test', 'selfcheck']) {
144
- if (pkg.manifest.scripts?.[script] === undefined) {
456
+ // 「这把能力在哪些项目里出现」:表里写了 workspace(按项目收窄),产物里就得真的实现 gate.deniedTools ——
457
+ // 否则每个项目里它都出现在工具清单里,agent 调了才发现不行(两次调用白花,而且它还会再试)。
458
+ // 反向**不查**:`deniedTools` 也用来"按自己的开关收窄"(比如能力没配好先藏起来),那不是项目作用域 ——
459
+ // 查反向会把这种合法用法判红。
460
+ const capabilityRows = designRowsOf(pkg.dir, 'capabilities');
461
+ const declaredWorkspaceScope = (capabilityRows ?? []).some((one) => text(one?.['scope']) === 'workspace');
462
+ if (declaredWorkspaceScope && !/\bdeniedTools\b/.test(hostProductOf(pkg.dir))) {
145
463
  problems.push(
146
- `${name}: 没有 ${script} 脚本 —— 模块包必须自带整套门禁:` +
147
- `外壳的 arch / 覆盖率 / 运行时边界都只扫自己的 src/,模块出包之后没人替它跑`,
464
+ `${name}: 设计表里有一条能力写着"只在某些项目里出现"(capabilities[].scope = 'workspace'),` +
465
+ '产物里却没有实现 gate.deniedTools —— 每个项目的工具清单里都有它,agent 调了才知道不行。' +
466
+ '接法:mount 返回 `gate: { deniedTools(workspace) { … }, subscribe }`(见 TOOLS.md §12)',
148
467
  );
149
468
  }
469
+
470
+ // 长任务:跑一会儿的事,注册了就得让用户看得见那四盏灯。
471
+ //
472
+ // 外壳按 mount 结果里的 longTasks 投影出 `${unit}/runs`(kit 的 useLongTask 读的就是它)。
473
+ // 契约里的状态只有四种:running / needs-login / ok / failed(`longTaskDot` 把它们映成
474
+ // 进行中 / 要登录 / 完成 / 出错)——**没有"停止"**,所以这里不查停止,只查"能不能画出来":
475
+ // · 注册表形状要全(少了方法,外壳那条 runs 路由读不动);
476
+ // · create 的 key 非空、run 与 view 都是函数(没有 view,卡片上那行就没有可渲染的快照);
477
+ // · 真的注册了长任务,客户端产物里就得出现 kit 那套(useLongTask / LongTaskRow / longTaskDot),
478
+ // 否则用户点完只看到"没反应"。
479
+ if (created.length > 0 || mounted?.longTasks !== undefined) {
480
+ const registry = mounted?.longTasks;
481
+ if (registry !== undefined) {
482
+ const missing = LONG_TASK_METHODS.filter((method) => typeof registry[method] !== 'function');
483
+ if (missing.length > 0) {
484
+ problems.push(
485
+ `${name}: 长任务注册表缺方法 ${missing.join(' / ')} —— 外壳按它给这个单元投影出 /runs 路由,` +
486
+ `kit 的 useLongTask 读的就是那条(注册表用 @dsh-workbench/shared/host 的 createLongTaskRegistry 建)`,
487
+ );
488
+ }
489
+ }
490
+ for (const options of created) {
491
+ if (text(options?.key) === '') {
492
+ problems.push(`${name}: longTasks.create 没给 key —— 一个单元可以有好几件跑一会儿的事,key 是它们的名字`);
493
+ }
494
+ if (typeof options?.run !== 'function') {
495
+ problems.push(`${name}: longTasks.create({key:'${String(options?.key ?? '')}'}) 没给 run —— 那它跑什么`);
496
+ }
497
+ if (typeof options?.view !== 'function') {
498
+ problems.push(
499
+ `${name}: longTasks.create({key:'${String(options?.key ?? '')}'}) 没给 view —— 卡片上那行要渲染的快照没处来`,
500
+ );
501
+ }
502
+ }
503
+ const clientText = existsSync(join(pkg.dir, 'client.js'))
504
+ ? readFileSync(join(pkg.dir, 'client.js'), 'utf8')
505
+ : '';
506
+ if (!LONG_TASK_RENDERERS.some((one) => clientText.includes(one))) {
507
+ problems.push(
508
+ `${name}: 注册了长任务,但客户端产物里没有 ${LONG_TASK_RENDERERS.join(' / ')}(kit 里那套)—— ` +
509
+ `用户点完只看得到"没反应",看不到进行中 / 要登录 / 完成 / 出错`,
510
+ );
511
+ }
512
+ }
513
+
514
+ // 联动层:agent 动完 / 读完,面板那一格怎么知道要重读。
515
+ //
516
+ // 只查**形状**:`context.render` 不能拿假参数去调 —— 成熟模块在参数形状不对时是**故意 throw** 的
517
+ // (coding-issues 的 render 就是"缺陷引用的形状不对 → throw"),拿假参数调会误伤正确的模块。
518
+ // `factsOf` / `warmReads` 可以安全地拿空参数调一次,看它回的东西形状对不对。
519
+ if (mounted !== undefined) {
520
+ const factsOf = mounted.factsOf;
521
+ if (factsOf !== undefined && typeof factsOf !== 'function') {
522
+ problems.push(`${name}: factsOf 不是函数 —— 外壳调它的时候会当场抛`);
523
+ } else if (typeof factsOf === 'function') {
524
+ let claimed;
525
+ try {
526
+ claimed = factsOf({ source: '__contract_probe__', args: {}, result: undefined });
527
+ } catch {
528
+ claimed = undefined;
529
+ }
530
+ const ok =
531
+ claimed === undefined ||
532
+ (Array.isArray(claimed) && claimed.every((one) => text(one) !== ''));
533
+ if (!ok) {
534
+ problems.push(
535
+ `${name}: factsOf 要么回 undefined(这次跟面板无关)、要么回一串非空的事实名 —— ` +
536
+ `现在回的是 ${JSON.stringify(claimed)}(事实名用 factOfRoute('<unit>/<路由>') 起,别自己拼)`,
537
+ );
538
+ }
539
+ }
540
+
541
+ const context = mounted.context;
542
+ if (context !== undefined) {
543
+ const kinds = context.kinds;
544
+ if (!Array.isArray(kinds) || kinds.length === 0 || kinds.some((one) => text(one) === '')) {
545
+ problems.push(
546
+ `${name}: context.kinds 要是非空的一串类别名 —— agent 引用你这一格时,认哪几类由它决定`,
547
+ );
548
+ }
549
+ if (typeof context.render !== 'function') {
550
+ problems.push(`${name}: context.render 不是函数 —— agent 引用你这一格时没人把它渲染成人话`);
551
+ }
552
+ }
553
+
554
+ const warmReads = mounted.warmReads;
555
+ if (warmReads !== undefined) {
556
+ if (typeof warmReads !== 'function') {
557
+ problems.push(`${name}: warmReads 不是函数`);
558
+ } else {
559
+ let reads;
560
+ try {
561
+ reads = warmReads();
562
+ } catch {
563
+ reads = undefined;
564
+ }
565
+ const ok =
566
+ Array.isArray(reads) &&
567
+ reads.every(
568
+ (one) => Array.isArray(one) && one.length > 0 && one.every((part) => text(part) !== ''),
569
+ );
570
+ if (!ok) {
571
+ problems.push(
572
+ `${name}: warmReads() 要回一串 [['<unit>', '<target>']] —— 现在回的是 ${JSON.stringify(reads)}`,
573
+ );
574
+ }
575
+ }
576
+ }
577
+ }
578
+
579
+ // 声明会写的工具,必须同时认领"这次要不要点头"。
580
+ //
581
+ // 外壳每次调用前都会问套件:writes:true 的工具,needsApproval 不给 verdict.kind==='ask'
582
+ // 就**当场 deny**(原文:"在套件的 needsApproval 里补一条审批(含"这次会发生什么"),别让写静默过去")——
583
+ // 那条错误只有真跑一次才看得见,用户看到的是一次莫名其妙的拒绝。所以门禁在出包前先看一遍:
584
+ // · needsApproval 要给完整的 ask 口径(理由 + 问句 + 兜底文案,zh/en 都要);
585
+ // · approvalDetail 要说清"这次会发生什么"(审批屏上就靠它,缺了只剩一句通用问句)。
586
+ const writeTools = (face?.manifest?.tools ?? []).filter((one) => one?.writes === true);
587
+ if (writeTools.length > 0) {
588
+ if (mounted === undefined) {
589
+ problems.push(
590
+ `${name}: 清单里有会写的工具,但宿主面挂不起来(apply / mount 抛了)—— ` +
591
+ `这条审批口径验不了,先把挂载依赖修好(模块自己的 smoke 也是这么挂的)`,
592
+ );
593
+ continue;
594
+ }
595
+ for (const tool of writeTools) {
596
+ const call = { name: tool.name, arguments: {}, workspace: null };
597
+ let verdict;
598
+ try {
599
+ verdict = mounted?.needsApproval?.(call);
600
+ } catch {
601
+ verdict = undefined;
602
+ }
603
+ // 合法口径有两种:`{ kind: 'read' }`(这件事不用问 —— 例:没什么可做的)与
604
+ // `{ kind: 'ask', reason, question{zh,en}, fallback{zh,en} }`(要问,且问得清)。
605
+ // 别的返回值(**包括 undefined**)外壳会当场 deny —— 那才是要拦的。
606
+ const read = verdict?.kind === 'read';
607
+ const ask =
608
+ verdict?.kind === 'ask' &&
609
+ text(verdict.reason) !== '' &&
610
+ text(verdict.question?.zh) !== '' &&
611
+ text(verdict.question?.en) !== '' &&
612
+ text(verdict.fallback?.zh) !== '' &&
613
+ text(verdict.fallback?.en) !== '';
614
+ if (!read && !ask) {
615
+ problems.push(
616
+ `${name}: 工具 ${tool.name} 的清单里写着 writes:true,但 needsApproval 没给出合法口径 —— ` +
617
+ `要么 { kind: 'read' }(这件事不用问),要么 { kind: 'ask', reason, question: {zh,en}, ` +
618
+ `fallback: {zh,en} }(要问,且问得清)。别的返回值外壳会当场 deny`,
619
+ );
620
+ continue;
621
+ }
622
+ let facts;
623
+ try {
624
+ facts = await mounted?.approvalDetail?.(call);
625
+ } catch {
626
+ facts = undefined;
627
+ }
628
+ const described =
629
+ facts !== undefined &&
630
+ ('blocked' in facts
631
+ ? text(facts.blocked) !== ''
632
+ : text(facts.zh) !== '' && text(facts.en) !== '');
633
+ if (!described) {
634
+ problems.push(
635
+ `${name}: 写工具 ${tool.name} 没给 approvalDetail("这次会发生什么",zh/en 或 {blocked})—— ` +
636
+ `审批屏上用户只能看到一句通用问句,看不到会动什么`,
637
+ );
638
+ }
639
+ }
640
+ }
150
641
  }
151
- }
152
642
 
153
- const unitList = [...units.entries()].map(([unit, name]) => `${unit} ← ${name}`).join(' · ');
643
+ const unitList = [...units.entries()].map(([unit, name]) => `${unit} ← ${name}`).join(' · ');
154
644
 
155
- const shellPkg = packages.find((pkg) => pkg.manifest.name === SHELL);
156
- if (shellPkg !== undefined) {
157
- const suspects = [join(shellPkg.dir, 'package.json')];
158
- const srcDir = join(shellPkg.dir, 'src');
159
- if (existsSync(srcDir)) {
160
- const walk = (dir) => {
161
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
162
- const full = join(dir, entry.name);
163
- if (entry.isDirectory()) walk(full);
164
- else if (/\.tsx?$/.test(entry.name)) suspects.push(full);
645
+ const shellPkg = packages.find((pkg) => pkg.manifest.name === SHELL);
646
+ if (shellPkg !== undefined) {
647
+ const suspects = [join(shellPkg.dir, 'package.json')];
648
+ const srcDir = join(shellPkg.dir, 'src');
649
+ if (existsSync(srcDir)) {
650
+ const walk = (dir) => {
651
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
652
+ const full = join(dir, entry.name);
653
+ if (entry.isDirectory()) walk(full);
654
+ else if (/\.tsx?$/.test(entry.name)) suspects.push(full);
655
+ }
656
+ };
657
+ walk(srcDir);
658
+ }
659
+ for (const file of suspects) {
660
+ const text = readFileSync(file, 'utf8');
661
+ const hit = /@pagoda\/dsh-workbench-module-[a-z0-9-]+/.exec(text);
662
+ if (hit !== null) {
663
+ problems.push(
664
+ `${SHELL}: ${relative(ROOT, file)} 里点名了模块包 ${hit[0]} —— ` +
665
+ `外壳不许认识任何模块(那等于把"中央清单"搬进外壳);模块靠服务名挂进来`,
666
+ );
165
667
  }
166
- };
167
- walk(srcDir);
668
+ }
168
669
  }
169
- for (const file of suspects) {
170
- const text = readFileSync(file, 'utf8');
171
- const hit = /@pagoda\/dsh-workbench-module-[a-z0-9-]+/.exec(text);
172
- if (hit !== null) {
173
- problems.push(
174
- `${SHELL}: ${relative(ROOT, file)} 里点名了模块包 ${hit[0]} —— ` +
175
- `外壳不许认识任何模块(那等于把"中央清单"搬进外壳);模块靠服务名挂进来`,
670
+
671
+ if (problems.length > 0) {
672
+ const notes = [`${declared} 个带 dsh 声明的包,其中 ${modules} 个模块`];
673
+ // 落不到图里最常见的成因不是"声明写错",而是"这个包不在任何 workspace 里"(裸目录里 init
674
+ // 出来的骨架)。不点出来的话,屏幕上的话会把责任推给那两条声明,而真正该做的是换到工坊里跑。
675
+ const bare = [...unresolvedIn].filter(barePackage);
676
+ if (bare.length > 0) {
677
+ notes.push(
678
+ `上面有 ${bare.length} 个包是**裸**的:它上面没有 pnpm-workspace.yaml、自己也没有 node_modules —— ` +
679
+ `那几条 inject/external 正是因为找不到任何同伴包才落不到图里。` +
680
+ `做法:在工坊里造($DSH_HOME/workbench/diy/packages/module-<名字>),或者先在这个工作区里 pnpm install。`,
176
681
  );
177
682
  }
683
+ return { status: 'fail', notes, problems };
178
684
  }
179
- }
180
685
 
181
- if (problems.length > 0) {
182
- console.error(`✖ 模块契约 ${problems.length} 条不过(${declared} 个带 dsh 声明的包,其中 ${modules} 个模块):`);
183
- for (const problem of problems) console.error(` · ${problem}`);
184
- process.exit(1);
686
+ return {
687
+ status: 'pass',
688
+ notes: [
689
+ `${declared} 个带 dsh 声明的包(${modules} 个模块)· bundle 落点存在 · ` +
690
+ `inject/external 都能落到图里 · 单元 id 唯一 · 面文件与 peer 都在 · 各有接线自检`,
691
+ '(test 不在这儿跑:状态级的东西在模块自己的 vitest 里 —— 四个脚本自己跑一遍)',
692
+ `单元:${unitList}`,
693
+ ],
694
+ problems: [],
695
+ };
185
696
  }
186
-
187
- console.log(
188
- `✔ 模块契约通过:${declared} 个带 dsh 声明的包(${modules} 个模块)· bundle 落点存在 · ` +
189
- `inject/external 都能落到图里 · 单元 id 唯一 · 面文件与 peer 都在 · 各有接线自检`,
190
- );
191
- console.log(` 单元:${unitList}`);