@dsh-workbench/module-tools 0.1.16 → 0.1.18

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.
@@ -10,17 +10,26 @@
10
10
  // · `dsh.client` 的 inject / external 每一条都能落到图里的一行(seed 词 / 平台包 / workspace 包);
11
11
  // · `dsh.workbench`(**这是个模块**):
12
12
  // - `unit` 形状合法、全局唯一(撞了就是装上去当场抛);
13
- // - `faces.host` / `faces.client` 指的入口文件真的在;
13
+ // - `dsh.workbenchFaces.host` / `.client` 指的源码入口文件真的在 ——
14
+ // 它是**工具链字段**,不在 `dsh.workbench` 协议里(ADR-0038 §7 第 7 条):
15
+ // `source` 判据靠这两个入口做可达性分析,所以它必须是源码路径、必须声明;
14
16
  // - 对壳有 peer 依赖(它跑在外壳交出来的服务上);
15
17
  // - 有自己的接线自检脚本(模块侧也必须机器判据,不能只靠人眼)。
16
18
  //
17
- // 形状(十条判据统一):导出 `title` 与 `run()`,`run()` 返回三态结果;
19
+ // 形状(所有判据统一):导出 `title` 与 `run()`,`run()` 返回三态结果;
18
20
  // 打印与退出码归 `../check.mjs` 那一层。
19
21
 
20
22
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
21
23
  import { createRequire } from 'node:module';
22
24
  import { join, relative } from 'node:path';
23
25
  import { fileURLToPath, pathToFileURL } from 'node:url';
26
+ import { readDeclaration } from '@dsh-workbench/shared/host';
27
+ import {
28
+ DECLARED_TOOLS_FILE,
29
+ declaredToolsProblemOf,
30
+ declaredToolsWithoutKey,
31
+ declaredToolsApplies,
32
+ } from '../declared-tools.mjs';
24
33
  import { discoverPackages, workspaceRootOf } from '../discover.mjs';
25
34
  import { SEED_SHORT } from '../seed.mjs';
26
35
 
@@ -188,6 +197,38 @@ function productsTextOf(dir) {
188
197
  return text;
189
198
  }
190
199
 
200
+ function sourceFilesOf(dir) {
201
+ const srcDir = join(dir, 'src');
202
+ if (!existsSync(srcDir)) return [];
203
+ const found = [];
204
+ const walk = (at) => {
205
+ for (const entry of readdirSync(at, { withFileTypes: true })) {
206
+ const full = join(at, entry.name);
207
+ if (entry.isDirectory()) {
208
+ if (entry.name === '__tests__' || entry.name === 'node_modules') continue;
209
+ walk(full);
210
+ } else if (/\.tsx?$/.test(entry.name) && !/\.test\.tsx?$/.test(entry.name)) {
211
+ found.push(full);
212
+ }
213
+ }
214
+ };
215
+ walk(srcDir);
216
+ return found;
217
+ }
218
+
219
+ // `plugin: '<字面量>'` 与同一行里 `?? '<字面量>'` 的兜底:都该是**清单里声明过**的那个插件名。
220
+ // 插件名只该有一处真相(能拿到 `deps.manifest` 的地方就用它),但模块级代码拿不到 manifest ⇒ 只能写字面量。
221
+ // 这条判据拦的是"改了清单没改代码"(或反过来):那样外壳会按错的名字去装 / 去探那个插件,而用户只看到
222
+ // "缺命令"这种没头没尾的状态。只查 `plugin:` 那几行,别的字面量不管(不猜)。
223
+ function pluginLiteralsOf(line) {
224
+ if (!/\bplugin\s*:/.test(line)) return [];
225
+ const found = [];
226
+ const direct = /plugin\s*:\s*['"]([^'"]+)['"]/.exec(line);
227
+ if (direct !== null) found.push(direct[1]);
228
+ for (const match of line.matchAll(/\?\?\s*['"]([^'"]+)['"]/g)) found.push(match[1]);
229
+ return found;
230
+ }
231
+
191
232
  function hostProductOf(dir) {
192
233
  try {
193
234
  return readFileSync(join(dir, 'dist/host.js'), 'utf8');
@@ -241,15 +282,59 @@ function fakeStore() {
241
282
  return { for: () => one(), machine: () => one(), scan: () => [one()] };
242
283
  }
243
284
 
244
- function mountOf(face, created) {
285
+ // mount 也一定拿得到 shell 与 workspaceRegistry(外壳给的是 dsh 交出来的服务)。
286
+ // 少给这两样,模块的**降级写法**就会生效 —— 而"拿不到 shell 就只交 routes + registerTools"
287
+ // 是合法写法(实测 pduiv2 的 coding-issues / coding-analysis 就这么写),降级后的面本来就不带
288
+ // 审批钩子,判据却会把这读成"没认领审批":红的是判据自己的环境,不是模块。
289
+ // (与上面 `store` 那笔账同一条纪律:假 deps 少给一样,模块就在这条判据上被冤枉。)
290
+ function fakeShell() {
291
+ return {
292
+ resolve: () => undefined,
293
+ execute: async () => ({
294
+ result: async () => ({
295
+ exitCode: null,
296
+ timedOut: false,
297
+ aborted: false,
298
+ stdout: { text: '', truncated: false },
299
+ stderr: { text: '', truncated: false },
300
+ }),
301
+ }),
302
+ };
303
+ }
304
+
305
+ function fakeWorkspaceRegistry() {
306
+ return {
307
+ get: () => undefined,
308
+ resolveByPath: async () => undefined,
309
+ create: async () => undefined,
310
+ };
311
+ }
312
+
313
+ function mountOf(face, created, manifest, tools) {
245
314
  if (face === undefined || typeof face.mount !== 'function') return undefined;
315
+ // 假服务**一次 mount 内是同一个对象**(真外壳给的就是同一个)—— 有的模块会拿两次再比。
316
+ const shell = fakeShell();
317
+ const workspaceRegistry = fakeWorkspaceRegistry();
318
+ const toolRegistry = {
319
+ register(tool) {
320
+ tools.push(tool);
321
+ return () => {};
322
+ },
323
+ };
246
324
  try {
247
325
  return face.mount({
326
+ manifest,
248
327
  ctx: {
249
- get: () => undefined,
328
+ get: (key) => {
329
+ if (key === 'tools') return toolRegistry;
330
+ if (key === 'shell') return shell;
331
+ if (key === 'workspaceRegistry') return workspaceRegistry;
332
+ return undefined;
333
+ },
250
334
  effect: (fn) => {
251
335
  fn();
252
336
  },
337
+ provide: () => {},
253
338
  logger: { warn: () => {}, info: () => {} },
254
339
  },
255
340
  manifests: () => [],
@@ -266,6 +351,39 @@ const LONG_TASK_METHODS = ['start', 'snapshot', 'active', 'current', 'onChange']
266
351
 
267
352
  const LONG_TASK_RENDERERS = ['useLongTask', 'LongTaskRow', 'longTaskDot'];
268
353
 
354
+ // 审批探针要用的**合成调用**。
355
+ //
356
+ // 为什么不是 `{ arguments: {} }`:外壳调 needsApproval 时拿到的是 agent 真传的参数,
357
+ // 而"这次要不要点头"本来就允许依赖参数 —— "带 confirm: true 才真的写"是最常见的合法口径。
358
+ // 空参数探会把这类模块判成"没认领审批"(实测 pduiv2 那 4 条审批报错就是这么来的,
359
+ // ADR-0038 §6.15)。所以能拿到工具的 `parameters`(JSON Schema)时,按**必填项**造一个
360
+ // 形状合法的调用:枚举取第一个值,string / number / boolean / array / object 各给一个占位值。
361
+ //
362
+ // 拿不到(registerTools() 没注册出这把工具、或注册时没带 parameters)就退回空参数,
363
+ // 并在判红时把这件事写进那句话 —— 那种情况下判据只能验到"钩子在不在"这一层。
364
+ function placeholderOf(schema) {
365
+ if (typeof schema !== 'object' || schema === null) return null;
366
+ if (Array.isArray(schema.enum) && schema.enum.length > 0) return schema.enum[0];
367
+ if (schema.type === 'string') return 'probe';
368
+ if (schema.type === 'number' || schema.type === 'integer') return 1;
369
+ if (schema.type === 'boolean') return true;
370
+ if (schema.type === 'array') return [];
371
+ if (schema.type === 'object') return {};
372
+ return null;
373
+ }
374
+
375
+ function probeCallOf(tool) {
376
+ const schema = tool?.parameters;
377
+ if (typeof schema !== 'object' || schema === null) {
378
+ return { arguments: {}, shaped: false };
379
+ }
380
+ const properties = typeof schema.properties === 'object' && schema.properties !== null ? schema.properties : {};
381
+ const required = Array.isArray(schema.required) ? schema.required : [];
382
+ const args = {};
383
+ for (const key of required) args[key] = placeholderOf(properties[key]);
384
+ return { arguments: args, shaped: true };
385
+ }
386
+
269
387
  function installed(specifier, from) {
270
388
  try {
271
389
  createRequire(join(from, 'package.json')).resolve(`${packageOf(specifier)}/package.json`);
@@ -289,12 +407,28 @@ function packageOf(specifier) {
289
407
  return specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0];
290
408
  }
291
409
 
410
+ /** 深比较两份 JSON:对象**键序无关**,数组**顺序有关**(工具那一串的顺序是声明的一部分)。 */
411
+ function reconcileTools(name, manifest, toolNames) {
412
+ const declared = manifest.tools.map((tool) => tool.name).sort();
413
+ const actual = [...new Set(toolNames.filter((one) => one !== ''))].sort();
414
+ if (declared.join('\n') === actual.join('\n')) return [];
415
+ return [
416
+ `${name}: dsh.workbench 声明的能力与代码真正注册的对不上 —— ` +
417
+ `清单 [${declared.join(', ')}]、代码 [${actual.join(', ')}]。` +
418
+ `清单是工具面的**唯一**来源(外壳按它算收窄、市场按它画花名册):` +
419
+ `漏声明 = 那个工具不会被收起也不会进花名册;多声明 = 花名册里有个永远不存在的工具`,
420
+ ];
421
+ }
422
+
292
423
  export async function run() {
293
424
  const packages = discoverPackages({ root: process.cwd() });
294
425
  const known = new Set(packages.map((pkg) => pkg.manifest.name));
295
426
  const problems = [];
296
427
  const unresolvedIn = new Set();
297
428
  const units = new Map();
429
+ // 判据"看到了但下不了结论"的那些,写进 notes(红绿两边都印)——
430
+ // 与其给一个撑不住的 verdict,不如把作者该自己确认的那一件事说清楚(ADR-0038 §6.15)。
431
+ const probeNotes = [];
298
432
  let declared = 0;
299
433
  let modules = 0;
300
434
 
@@ -359,12 +493,49 @@ export async function run() {
359
493
  }
360
494
  }
361
495
 
496
+ // 清单里的工具名与「代码里那份名字」的关系(ADR-0038 的 `tools[].key` 一节):
497
+ // 给了 key 就走生成物那一档 —— `src/declared-tools.ts` 必须存在、且与清单一致(过期 = 红),
498
+ // 并且源码里不许再有工具名的字面量(名字只写在清单里,代码引用 `DECLARED_TOOLS.<key>.name`,
499
+ // 键写错是编译错)。一个 key 都没写的模块不适用(那是不用生成物那一档)。
500
+ const declaredProblem = declaredToolsProblemOf(pkg.dir, dsh.workbench);
501
+ if (declaredProblem !== undefined) {
502
+ problems.push(`${name}: ${declaredProblem}`);
503
+ } else if (declaredToolsApplies(dsh.workbench)) {
504
+ const bare = declaredToolsWithoutKey(dsh.workbench);
505
+ const literals = [];
506
+ const generated = join(pkg.dir, DECLARED_TOOLS_FILE);
507
+ for (const file of sourceFilesOf(pkg.dir)) {
508
+ if (file === generated) continue;
509
+ const text = readFileSync(file, 'utf8');
510
+ for (const tool of dsh.workbench.tools) {
511
+ const one = `'${tool.name}'`;
512
+ const two = `"${tool.name}"`;
513
+ if (text.includes(one) || text.includes(two)) {
514
+ literals.push(`${relative(pkg.dir, file)} 里的 ${tool.name}`);
515
+ }
516
+ }
517
+ }
518
+ if (literals.length > 0) {
519
+ problems.push(
520
+ `${name}: 工具名的字面量又写回了源码 —— ${literals.join(' / ')}。` +
521
+ `清单里已经有 key,代码该引用生成物(\`DECLARED_TOOLS.<key>.name\`):` +
522
+ `名字只有清单一处写者,写两遍就会漂,而漂了没有任何编译错`,
523
+ );
524
+ }
525
+ if (bare.length > 0) {
526
+ problems.push(`${name}: 清单里 ${bare.join(' / ')} 没有 key`);
527
+ }
528
+ }
529
+
362
530
  for (const face of ['host', 'client']) {
363
- const rel = dsh.workbench.faces?.[face];
531
+ const rel = dsh.workbenchFaces?.[face];
364
532
  if (typeof rel !== 'string') {
365
- problems.push(`${name}: dsh.workbench.faces.${face} 没声明`);
533
+ problems.push(
534
+ `${name}: dsh.workbenchFaces.${face} 没声明 —— 这两个源码入口是工具链字段` +
535
+ `(不在 dsh.workbench 协议里):source 判据靠它们判"哪一半",缺了它判不了`,
536
+ );
366
537
  } else if (!existsSync(join(pkg.dir, rel))) {
367
- problems.push(`${name}: dsh.workbench.faces.${face} 指的文件不存在 —— ${rel}`);
538
+ problems.push(`${name}: dsh.workbenchFaces.${face} 指的文件不存在 —— ${rel}`);
368
539
  }
369
540
  }
370
541
 
@@ -396,10 +567,37 @@ export async function run() {
396
567
  // 外部供给:清单里声明了 env,就要把"靠什么跑起来"和"没装好时怎么办"都说清。
397
568
  // 外壳只按 env 去装(app/host.ts 把 plugins/libs 交给隔离运行时跑 probe/install),
398
569
  // 它不知道这个能力叫什么 —— 所以"照做"那句安装命令必须由模块自己给。
570
+ const read = readDeclaration(name, dsh.workbench);
571
+ const manifest = read.manifest;
399
572
  const created = [];
573
+ // 注册出来的**工具对象**(不只是名字):审批探针要读它的 `parameters` 造形状合法的调用。
574
+ const tools = [];
400
575
  const face = await unitFaceOf(pkg.dir);
401
- const mounted = face === undefined ? undefined : mountOf(face, created);
402
- const env = face?.manifest?.env;
576
+ const mounted = face === undefined ? undefined : mountOf(face, created, manifest, tools);
577
+ const env = manifest?.env;
578
+
579
+ if (manifest !== undefined) {
580
+ if (face === undefined) {
581
+ problems.push(
582
+ `${name}: 声明了 ${manifest.tools.length} 个能力,但宿主面挂不起来(dist/host.js 缺、或 apply 抛了)` +
583
+ `—— 声明的能力对不了账,先把面修好`,
584
+ );
585
+ } else {
586
+ try {
587
+ mounted?.registerTools();
588
+ } catch (error) {
589
+ problems.push(`${name}: registerTools() 抛了 —— ${String(error)}`);
590
+ }
591
+ problems.push(
592
+ ...reconcileTools(
593
+ name,
594
+ manifest,
595
+ tools.map((tool) => (typeof tool?.name === 'string' ? tool.name : '')),
596
+ ),
597
+ );
598
+ }
599
+ }
600
+
403
601
  if (env !== undefined) {
404
602
  problems.push(...envProblemsOf(name, env));
405
603
  const products = productsTextOf(pkg.dir);
@@ -426,6 +624,27 @@ export async function run() {
426
624
  );
427
625
  });
428
626
 
627
+ const declaredPlugins = new Set(
628
+ (env.plugins ?? []).map((one) => text(one?.name)).filter((one) => one !== ''),
629
+ );
630
+ if (declaredPlugins.size > 0) {
631
+ for (const file of sourceFilesOf(pkg.dir)) {
632
+ readFileSync(file, 'utf8')
633
+ .split('\n')
634
+ .forEach((line, index) => {
635
+ for (const literal of pluginLiteralsOf(line)) {
636
+ if (declaredPlugins.has(literal)) continue;
637
+ problems.push(
638
+ `${name}: ${relative(pkg.dir, file)}:${index + 1} 里的插件名「${literal}」不在清单的 ` +
639
+ `env.plugins[] 里(清单里是 ${[...declaredPlugins].join(' / ')})—— 改了清单没改代码` +
640
+ '(或反过来)会让外壳按错的名字去装 / 去探那个插件。插件名只有一处真相:' +
641
+ '能拿到 deps.manifest 的地方就用它,拿不到的地方至少与清单保持一致',
642
+ );
643
+ }
644
+ });
645
+ }
646
+ }
647
+
429
648
  if ((env.libs ?? []).length > 0 && !products.includes('libInstallCommand')) {
430
649
  problems.push(
431
650
  `${name}: 声明了 env.libs 却没用 libInstallCommand(@dsh-workbench/shared/host 里现成的)—— ` +
@@ -438,7 +657,8 @@ export async function run() {
438
657
  // 设计表里写了哪一种就得真的接上哪一种 —— 否则用户改完刷新回到默认,而表上写着"能配"。
439
658
  // (**与 env 无关**:没有 env 的模块一样要查,所以放在 env 块外面。)
440
659
  const configRows = designRowsOf(pkg.dir, 'config');
441
- const wiredPrefs = /\bprefs\s*:/.test(hostProductOf(pkg.dir));
660
+ const hostProduct = hostProductOf(pkg.dir);
661
+ const wiredPrefs = /\bprefs\s*:/.test(hostProduct);
442
662
  const declaredPrefs = (configRows ?? []).some((one) => text(one?.['persistence']) === 'prefs');
443
663
  if (declaredPrefs && !wiredPrefs) {
444
664
  problems.push(
@@ -453,18 +673,27 @@ export async function run() {
453
673
  );
454
674
  }
455
675
 
456
- // 「这把能力在哪些项目里出现」:表里写了 workspace(按项目收窄),产物里就得真的实现 gate.deniedTools ——
457
- // 否则每个项目里它都出现在工具清单里,agent 调了才发现不行(两次调用白花,而且它还会再试)。
458
- // 反向**不查**:`deniedTools` 也用来"按自己的开关收窄"(比如能力没配好先藏起来),那不是项目作用域 ——
459
- // 查反向会把这种合法用法判红。
676
+ // 底稿与清单只对账**键集合**:`design.json` 的 `capabilities[].name` 与清单的 `tools[].name`
677
+ // 不重不漏(ADR-0038 §2.5)。**只对名字**:内容各写一处(人话在底稿、机器字段在清单),没有可比的东西。
678
+ // 没有 `design.json` 的模块不查(底稿是可选的,契约不要求它);清单形状坏了的也不查(protocol 已经说话了)。
460
679
  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))) {
463
- problems.push(
464
- `${name}: 设计表里有一条能力写着"只在某些项目里出现"(capabilities[].scope = 'workspace'),` +
465
- '产物里却没有实现 gate.deniedTools —— 每个项目的工具清单里都有它,agent 调了才知道不行。' +
466
- '接法:mount 返回 `gate: { deniedTools(workspace) { … }, subscribe }`(见 TOOLS.md §12)',
467
- );
680
+ if (capabilityRows !== undefined && manifest !== undefined) {
681
+ const inDesign = capabilityRows.map((one) => text(one?.name)).filter((one) => one !== '');
682
+ const inDeclaration = manifest.tools.map((tool) => tool.name);
683
+ for (const name0 of inDesign.filter((one) => !inDeclaration.includes(one))) {
684
+ problems.push(
685
+ `${name}: design.json 的能力行里有「${name0}」,清单的 tools[] 里却没有它 —— ` +
686
+ '能力只有清单那一处能出货(装机预检 / 市场 / 外壳都读它),底稿写了也是一句空话:' +
687
+ `要么把它补进 \`dsh.workbench.tools\`,要么从底稿里删掉`,
688
+ );
689
+ }
690
+ for (const name0 of inDeclaration.filter((one) => !inDesign.includes(one))) {
691
+ problems.push(
692
+ `${name}: 清单的 tools[] 里声明了「${name0}」,design.json 的能力行里却没有它 —— ` +
693
+ '底稿是给人读的那半(它干嘛、要不要点头,`design render` 拿它出 README):' +
694
+ `要么在 design.json 的 capabilities[] 里为「${name0}」补一行,要么把它从清单里删掉`,
695
+ );
696
+ }
468
697
  }
469
698
 
470
699
  // 长任务:跑一会儿的事,注册了就得让用户看得见那四盏灯。
@@ -580,10 +809,18 @@ export async function run() {
580
809
  //
581
810
  // 外壳每次调用前都会问套件:writes:true 的工具,needsApproval 不给 verdict.kind==='ask'
582
811
  // 就**当场 deny**(原文:"在套件的 needsApproval 里补一条审批(含"这次会发生什么"),别让写静默过去")——
583
- // 那条错误只有真跑一次才看得见,用户看到的是一次莫名其妙的拒绝。所以门禁在出包前先看一遍:
584
- // · needsApproval 要给完整的 ask 口径(理由 + 问句 + 兜底文案,zh/en 都要);
585
- // · approvalDetail 要说清"这次会发生什么"(审批屏上就靠它,缺了只剩一句通用问句)。
586
- const writeTools = (face?.manifest?.tools ?? []).filter((one) => one?.writes === true);
812
+ // 那条错误只有真跑一次才看得见,用户看到的是一次莫名其妙的拒绝。所以门禁在出包前先看一遍。
813
+ //
814
+ // **这条判据只能验到哪一层**(原样写给以后改它的人):
815
+ // · 硬事实:一个 writes:true 的工具,mount() 有没有把 needsApproval 交出来;
816
+ // · 判据能造出合成调用时:对那次调用给的 verdict 形状对不对;verdict 是 ask 时
817
+ // approvalDetail 说没说清"这次会发生什么";
818
+ // · **验不了的**:真实写入调用会不会真的 ask。答案依赖参数的模块("带 confirm: true 才真的写")
819
+ // 在合成调用上依法回 `{ kind: 'read' }` —— 外壳那条路(shared/host/tool-approval.ts)在 read
820
+ // 时**根本不会问 approvalDetail**,所以这里也不再逼它给。这类模块列进 notes 让作者自己确认。
821
+ // · 空参数探会误报:合成调用按声明里的 `parameters` 必填项造(见 probeCallOf),
822
+ // 造不出来时退回空参数并把这件事写进判红那句。
823
+ const writeTools = (manifest?.tools ?? []).filter((one) => one?.writes === true);
587
824
  if (writeTools.length > 0) {
588
825
  if (mounted === undefined) {
589
826
  problems.push(
@@ -592,13 +829,32 @@ export async function run() {
592
829
  );
593
830
  continue;
594
831
  }
832
+ const registered = new Map(tools.map((tool) => [text(tool?.name), tool]));
833
+ const readOnProbe = [];
595
834
  for (const tool of writeTools) {
596
- const call = { name: tool.name, arguments: {}, workspace: null };
835
+ const probe = probeCallOf(registered.get(tool.name));
836
+ const call = { name: tool.name, arguments: probe.arguments, workspace: null };
837
+ const cannotShape = probe.shaped
838
+ ? ''
839
+ : `(registerTools() 没注册出 ${tool.name} 的 parameters,判据只能用空参数探 —— ` +
840
+ `你的判断依赖参数的话,注册时把 parameters 那个 JSON Schema 一起交出来)`;
841
+ if (typeof mounted.needsApproval !== 'function') {
842
+ problems.push(
843
+ `${name}: 工具 ${tool.name} 的清单里写着 writes:true,但 mount() 的返回值里没有 needsApproval —— ` +
844
+ `外壳每次调用前都问它"这次要不要点头",没有它就只有一句"没认领",**每一次调用都当场被拒**。` +
845
+ `做法:mount() 返回的对象里加 needsApproval(call),认出这把工具再给 verdict:` +
846
+ `{ kind: 'read' }(这次不用问)或 { kind: 'ask', reason, question: {zh,en}, fallback: {zh,en} }(要问,且问得清)`,
847
+ );
848
+ continue;
849
+ }
597
850
  let verdict;
598
851
  try {
599
- verdict = mounted?.needsApproval?.(call);
600
- } catch {
601
- verdict = undefined;
852
+ verdict = mounted.needsApproval(call);
853
+ } catch (error) {
854
+ problems.push(
855
+ `${name}: 工具 ${tool.name} 的 needsApproval 在合成调用 ${JSON.stringify(call.arguments)} 上抛了 —— ${String(error)}`,
856
+ );
857
+ continue;
602
858
  }
603
859
  // 合法口径有两种:`{ kind: 'read' }`(这件事不用问 —— 例:没什么可做的)与
604
860
  // `{ kind: 'ask', reason, question{zh,en}, fallback{zh,en} }`(要问,且问得清)。
@@ -613,15 +869,23 @@ export async function run() {
613
869
  text(verdict.fallback?.en) !== '';
614
870
  if (!read && !ask) {
615
871
  problems.push(
616
- `${name}: 工具 ${tool.name} 的清单里写着 writes:true,但 needsApproval 没给出合法口径 —— ` +
872
+ `${name}: 工具 ${tool.name} 的清单里写着 writes:true,但 needsApproval 对合成调用 ` +
873
+ `${JSON.stringify(call.arguments)} 没给出合法口径 —— ` +
617
874
  `要么 { kind: 'read' }(这件事不用问),要么 { kind: 'ask', reason, question: {zh,en}, ` +
618
- `fallback: {zh,en} }(要问,且问得清)。别的返回值外壳会当场 deny`,
875
+ `fallback: {zh,en} }(要问,且问得清)。别的返回值外壳会当场 deny${cannotShape}`,
619
876
  );
620
877
  continue;
621
878
  }
879
+ // read:这次调用不用点头。**不查 approvalDetail** —— 外壳在 read 时也不会去问它
880
+ // (`approvalFor` 只在 verdict 是 ask 时才调 detailOf)。合成调用本来就不是一次真实写入,
881
+ // 拿它逼模块给"这次会发生什么"就是让判据替外壳问一个外壳不会问的问题。
882
+ if (read) {
883
+ readOnProbe.push(tool.name);
884
+ continue;
885
+ }
622
886
  let facts;
623
887
  try {
624
- facts = await mounted?.approvalDetail?.(call);
888
+ facts = await mounted.approvalDetail?.(call);
625
889
  } catch {
626
890
  facts = undefined;
627
891
  }
@@ -632,11 +896,20 @@ export async function run() {
632
896
  : text(facts.zh) !== '' && text(facts.en) !== '');
633
897
  if (!described) {
634
898
  problems.push(
635
- `${name}: 写工具 ${tool.name} 没给 approvalDetail("这次会发生什么",zh/en 或 {blocked})—— ` +
636
- `审批屏上用户只能看到一句通用问句,看不到会动什么`,
899
+ `${name}: 写工具 ${tool.name} 的 needsApproval 对合成调用回了 ask(要点头),` +
900
+ `但 approvalDetail 没说清"这次会发生什么"(zh/en 或 {blocked})—— ` +
901
+ `审批屏上用户只能看到一句通用问句,看不到会动什么${cannotShape}`,
637
902
  );
638
903
  }
639
904
  }
905
+ if (readOnProbe.length > 0) {
906
+ probeNotes.push(
907
+ `写工具 ${readOnProbe.join(' / ')} 按合成调用回了"这次不用问"(kind: 'read')—— ` +
908
+ `判据只能验到这一步:它是不是"参数里带 confirm 才真的写"那种**合法**口径,` +
909
+ `合成调用看不出来(必填项给的是占位值,不是真实写入参数)。作者自己确认一次:` +
910
+ `真的调用会不会 ask、ask 时 approvalDetail 说没说清`,
911
+ );
912
+ }
640
913
  }
641
914
  }
642
915
 
@@ -669,7 +942,7 @@ export async function run() {
669
942
  }
670
943
 
671
944
  if (problems.length > 0) {
672
- const notes = [`${declared} 个带 dsh 声明的包,其中 ${modules} 个模块`];
945
+ const notes = [`${declared} 个带 dsh 声明的包,其中 ${modules} 个模块`, ...probeNotes];
673
946
  // 落不到图里最常见的成因不是"声明写错",而是"这个包不在任何 workspace 里"(裸目录里 init
674
947
  // 出来的骨架)。不点出来的话,屏幕上的话会把责任推给那两条声明,而真正该做的是换到工坊里跑。
675
948
  const bare = [...unresolvedIn].filter(barePackage);
@@ -690,6 +963,7 @@ export async function run() {
690
963
  `inject/external 都能落到图里 · 单元 id 唯一 · 面文件与 peer 都在 · 各有接线自检`,
691
964
  '(test 不在这儿跑:状态级的东西在模块自己的 vitest 里 —— 四个脚本自己跑一遍)',
692
965
  `单元:${unitList}`,
966
+ ...probeNotes,
693
967
  ],
694
968
  problems: [],
695
969
  };
@@ -17,7 +17,7 @@
17
17
  //
18
18
  // 判据跟着 `dsh.workbench` 自述清单走;没有 client.js 的包这里没事可查(由 build/契约判据管)。
19
19
  //
20
- // 形状(十条判据统一):导出 `title` 与 `run()`,`run()` 返回三态结果;
20
+ // 形状(所有判据统一):导出 `title` 与 `run()`,`run()` 返回三态结果;
21
21
  // 打印与退出码归 `../check.mjs` 那一层。
22
22
 
23
23
  import { existsSync, readFileSync } from 'node:fs';
@@ -4,7 +4,7 @@
4
4
  // 不能照搬"本 workspace 里所有 access=public 的包" —— 在外壳仓里等价,但把这个工具拿到
5
5
  // 别人的 monorepo(例如 pdv2)里跑时,那条会把人家二十几个自家 SDK 包一起判了。
6
6
  //
7
- // 形状(十条判据统一):导出 `title` 与 `run()`,`run()` 返回三态结果;
7
+ // 形状(所有判据统一):导出 `title` 与 `run()`,`run()` 返回三态结果;
8
8
  // 打印与退出码归 `../check.mjs` 那一层。
9
9
 
10
10
  import { readdirSync, readFileSync, statSync } from 'node:fs';
@@ -12,7 +12,7 @@
12
12
  //
13
13
  // 判据跟着 `dsh.workbench` 自述清单走:没用 dsh 原语的模块直接跳过(0 命中 = 通过)。
14
14
  //
15
- // 形状(十条判据统一):导出 `title` 与 `run()`,`run()` 返回三态结果;
15
+ // 形状(所有判据统一):导出 `title` 与 `run()`,`run()` 返回三态结果;
16
16
  // 打印与退出码归 `../check.mjs` 那一层。
17
17
 
18
18
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
@@ -0,0 +1,57 @@
1
+ // 模块声明协议:`dsh.workbench` 的**版本**与**静态半形状**。
2
+ //
3
+ // 这一条是**薄壳**:真正的形状判定在 `@dsh-workbench/shared` 的 `declarationProblemsOf()`。
4
+ // 三个调用方共用那一份实现,所以协议只有一处定义:
5
+ // · 门禁(本判据)
6
+ // · 外壳(装配期把声明读成运行期清单,ADR-0038 §2.2)
7
+ // · 装机预检(拿 registry 元数据里的 package.json 就能调,ADR-0038 §2.6)
8
+ // 在 U1b 之前本文件自己带了一套形状校验 —— 那意味着外壳与门禁会各判各的,
9
+ // 正是"两份同类真相"。现在它只剩"把当前目录的包扫一遍、把问题倒出来"。
10
+ //
11
+ // 分工:contracts 管"这个包长得对不对"(unit 唯一、`dsh.workbenchFaces` 在、peer 在、审批认领了没),
12
+ // 本判据管"这份声明读不读得懂"(版本认不认、静态半形状对不对、有没有生字段)。
13
+ //
14
+ // `faces` 自 A-2 起**不在协议里**(ADR-0038 §7 第 7 条):它指的是**源码**入口
15
+ // (`src/host.ts` / `src/client.tsx`),而协议是"装的人手上有什么"那份清单 —— 装机预检与外壳
16
+ // 都不用它。它搬到 `dsh.workbench` 的**兄弟键** `dsh.workbenchFaces`,由 contracts / source 读;
17
+ // 于是它既不是协议里的合法键(写回 `dsh.workbench` 里会被判生字段),也不会被协议"顺手校验"。
18
+
19
+ import { declarationProblemsOf } from '@dsh-workbench/shared/host';
20
+
21
+ export const title = '模块声明协议';
22
+
23
+ export async function run() {
24
+ const { discoverPackages } = await import('../discover.mjs');
25
+ const packages = discoverPackages({ root: process.cwd() });
26
+ const problems = [];
27
+ const versions = new Set();
28
+ let declared = 0;
29
+
30
+ for (const pkg of packages) {
31
+ const raw = pkg.manifest.dsh?.workbench;
32
+ if (raw === undefined) continue;
33
+ problems.push(...declarationProblemsOf(pkg.manifest.name, raw));
34
+ if (typeof raw === 'object' && raw !== null && Number.isInteger(raw.apiVersion)) {
35
+ declared += 1;
36
+ versions.add(raw.apiVersion);
37
+ }
38
+ }
39
+
40
+ if (problems.length > 0) {
41
+ return {
42
+ status: 'fail',
43
+ notes: [`${packages.length} 个包里带 dsh.workbench 声明的已逐个读过`],
44
+ problems,
45
+ };
46
+ }
47
+
48
+ return {
49
+ status: 'pass',
50
+ notes: [
51
+ declared === 0
52
+ ? '这个工作区里还没有模块(没有带 dsh.workbench 的包)'
53
+ : `${declared} 个模块都声明了协议 v${[...versions].sort().join(' / v')},静态半形状齐、没有生字段`,
54
+ ],
55
+ problems: [],
56
+ };
57
+ }
@@ -5,11 +5,15 @@
5
5
  // 模块包自己要守的是另外几条,写在下面。判据全部来自源码里的 import 说明符,覆盖范围跟着
6
6
  // `dsh.workbench` 自述清单走(新加模块不用改这里)。
7
7
  //
8
- // 关于 dsh 的包:「哪一半」**不靠目录名猜**,而是从两个入口(`faces.host` / `faces.client`)
9
- // 做一次本包内可达性分析。种子词(模块表里天生就有的那几个)在**只被客户端入口可达**的文件里
10
- // 可以用(ADR-0028);宿主侧一个都不许碰 —— 那些包只在浏览器侧存在。
8
+ // 关于 dsh 的包:「哪一半」**不靠目录名猜**,而是从两个入口(`dsh.workbenchFaces.host` /
9
+ // `dsh.workbenchFaces.client`)做一次本包内可达性分析。种子词(模块表里天生就有的那几个)在
10
+ // **只被客户端入口可达**的文件里可以用(ADR-0028);宿主侧一个都不许碰 —— 那些包只在浏览器侧存在。
11
11
  //
12
- // 形状(十条判据统一):导出 `title` 与 `run()`,`run()` 返回三态结果;
12
+ // 这两个入口是 dsh.workbenchFaces 的**唯一**用途(工具链字段,不是协议的一部分,见 ADR-0038 §7 第 7 条):
13
+ // 装机预检与外壳都不读它。所以这里必须拿**源码**入口 —— 从产物 dist/host.js 反推不出是哪一份源码
14
+ // 把 dsh 的包拖进来的。
15
+ //
16
+ // 形状(所有判据统一):导出 `title` 与 `run()`,`run()` 返回三态结果;
13
17
  // 打印与退出码归 `../check.mjs` 那一层。
14
18
 
15
19
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
@@ -102,7 +106,7 @@ export async function run() {
102
106
  for (const pkg of modulePackages) {
103
107
  const name = pkg.manifest.name;
104
108
  const unit = pkg.manifest.dsh.workbench.unit;
105
- const faces = pkg.manifest.dsh.workbench.faces ?? {};
109
+ const workbenchFaces = pkg.manifest.dsh.workbenchFaces ?? {};
106
110
  const srcDir = join(pkg.dir, 'src');
107
111
  const viaPlatform = hasPlatformPackage(pkg.manifest);
108
112
 
@@ -112,8 +116,10 @@ export async function run() {
112
116
  );
113
117
  }
114
118
 
115
- const hostReach = faces.host === undefined ? new Set() : reachableFrom(pkg.dir, unit, faces.host);
116
- const clientReach = faces.client === undefined ? new Set() : reachableFrom(pkg.dir, unit, faces.client);
119
+ const hostReach =
120
+ workbenchFaces.host === undefined ? new Set() : reachableFrom(pkg.dir, unit, workbenchFaces.host);
121
+ const clientReach =
122
+ workbenchFaces.client === undefined ? new Set() : reachableFrom(pkg.dir, unit, workbenchFaces.client);
117
123
  const sideOf = (file) => {
118
124
  const host = hostReach.has(file);
119
125
  const client = clientReach.has(file);
@@ -147,7 +153,7 @@ export async function run() {
147
153
  : '这个文件在宿主侧可达';
148
154
  problems.push(
149
155
  `${name}: ${rel} 里 import 了 ${specifier}(dsh 的浏览器包,是平台种子词)—— ${why},` +
150
- `而它只在浏览器侧存在。把它挪到只被 faces.client 可达的文件里`,
156
+ `而它只在浏览器侧存在。把它挪到只被 dsh.workbenchFaces.client 可达的文件里`,
151
157
  );
152
158
  continue;
153
159
  }
@@ -4,7 +4,7 @@
4
4
  // 最后无论成败都还原。藏起来还过不了 = 这条模块在用"只有本仓才有"的相对路径 / 别名 ——
5
5
  // 发布出去会散架。
6
6
  //
7
- // 形状(十条判据统一):导出 `title` 与 `run()`,`run()` 返回三态结果;
7
+ // 形状(所有判据统一):导出 `title` 与 `run()`,`run()` 返回三态结果;
8
8
  // 打印与退出码归 `../check.mjs` 那一层。所以这里**不许**自己退出:
9
9
  // 仓外工作区(没有外壳源码)以前就是"提前成功退出"(退出码 0)—— 那会把排在后面的判据静默吞掉。
10
10