@dsh-workbench/module-tools 0.3.10 → 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 (60) 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 +61 -222
  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 +163 -29
  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/messages.ts.tpl +4 -4
  58. package/template-diy/src/plugin.test.ts.tpl +1 -1
  59. package/template-diy/types/globals.d.ts +1 -14
  60. package/template-diy/types/styles.d.ts +0 -10
@@ -1,19 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
  import { existsSync } from 'node:fs';
3
3
  import { join, resolve } from 'node:path';
4
+ import { Command } from 'commander';
4
5
  import { build } from '../src/build.mjs';
5
6
  import { CHECKS, check } from '../src/check.mjs';
7
+ import { changelog } from '../src/changelog.mjs';
6
8
  import { design } from '../src/design.mjs';
7
9
  import { init } from '../src/init.mjs';
10
+ import { marketInit } from '../src/market-init.mjs';
8
11
  import { publish } from '../src/publish.mjs';
12
+ import { release } from '../src/release.mjs';
9
13
  import { sync } from '../src/sync.mjs';
10
14
 
11
- // `dsh-wb-module lint [--fix]`:跑模块**自己那份** `eslint.config.mjs`(方言住在 `src/lint.mjs`)。
12
- // ESLint 的构造只有一处 —— `src/checks/lint.mjs` 的 `lintPackage`,判据与这条命令共用它;
13
- // 这里只管打印与退出码(与 `check` 那条路同一分工)。
14
- //
15
- // 为什么 eslint 是**懒 import**:`build` / `init` 是工坊里的热路径,而 eslint 那一坨加载要几百毫秒 ——
16
- // 静态 import 会让每条命令都替这条 lint 付一次。写成命令里的动态 import,只有真跑 lint 才付。
17
15
  async function lint({ root, args }) {
18
16
  const [{ CONFIG_FILE, lintPackage }, { discoverModulePackages }] = await Promise.all([
19
17
  import('../src/checks/lint.mjs'),
@@ -47,39 +45,96 @@ async function lint({ root, args }) {
47
45
  if (failed > 0) process.exit(1);
48
46
  }
49
47
 
50
- const [command, ...rest] = process.argv.slice(2);
51
- const cwd = process.env.DSH_WB_MODULE_CWD ?? process.cwd();
48
+ const commands = { build, check, init, 'market-init': marketInit, design, publish, release, sync, lint, changelog };
52
49
 
53
- const commands = { build, check, init, design, publish, sync, lint };
50
+ const CHECK_HELP = `
51
+ 查询口:
52
+ check --only kit [--file <名字>] 这份外壳的 kit 里有什么
53
+ check --only primitives [--all|--files|--json] [--file <名字>]
54
+ 这份外壳的 dsh 原语里有什么
55
+ check --list [--json|--markdown] 只列判据元数据`;
54
56
 
55
- if (!command || !commands[command]) {
56
- console.error(`用法:dsh-wb-module <build|check|init|design|publish|sync|lint> [--cwd <目录>]
57
- dsh-wb-module check [--only ${Object.keys(CHECKS).join('|')}]
58
- dsh-wb-module check --only kit # 单跑当查询口:这份外壳的 kit 里有什么(--file <名字> 给类型文件绝对路径)
59
- dsh-wb-module check --only primitives [--all|--files|--json]
60
- # 单跑当查询口:dsh 原语里有什么 + --file <名字> 给类型文件绝对路径
61
- dsh-wb-module init <unit> [--dir <目录>] [--template minimal|diy] [--description <文字>]
62
- dsh-wb-module design <init|check|render|accept> # 三张表:填、查空格、生成 README、自验打勾
63
- dsh-wb-module sync # 按清单生成 src/declared-tools.ts(工具名只有清单一处写者)
64
- dsh-wb-module lint [--fix] # 模块自己那份 eslint.config.mjs;--fix 写回文件
65
- dsh-wb-module publish # 发货前的清单:门禁 + npm pack 清单 + files 漏项(只 dry run)`);
66
- process.exit(2);
67
- }
57
+ const RELEASE_HELP = `
58
+ 选项:
59
+ --target team|public|tarball|git|market|local 出口;省略按配置默认(可写索引优先 market)
60
+ --version patch|minor|major|x.y.z 版本;省略时已存在就自动 patch
61
+ --registry <url> --access restricted|public --npmrc <文件>
62
+ --license <id> --out <目录> --git <remote>
63
+ --index auto|none|<目录|git 地址> 发布后要更新的索引源
64
+ --index-ref <分支> --index-path <索引路径> --index-policy artifacts-in-repo|catalog-only
65
+ --resume 上次中断后接着发(用记录里的出口与版本)
66
+ --dry-run --verify --yes --json`;
67
+
68
+ const MARKET_INIT_HELP = `
69
+ 用法:
70
+ market-init <git 地址|目录> [--ref <分支>] [--path <索引路径>] [--no-readme] [--json]
71
+
72
+ 空仓库:用仓库自己声明的默认分支,读不到时用 --ref 指定(github.com 兜底 main)。
73
+ 已有 commit 的仓库:只补 market.json / README.md,不改已有文件。`;
68
74
 
69
- const value = (flag) => {
70
- const at = rest.indexOf(flag);
71
- return at === -1 ? undefined : rest[at + 1];
75
+ const CHANGELOG_HELP = `
76
+ 用法:
77
+ changelog init
78
+ changelog add [版本] [--type added|changed|fixed|removed|security|deprecated] [--note <文字>] [--date <日期>]
79
+ changelog check [版本] [--json]
80
+
81
+ 版本省略时用 package.json 里的 version。发布时这一版必须在 CHANGELOG.md 里有记录,否则 release 会拦下。`;
82
+
83
+ const COMMANDS = {
84
+ build: '出 dist/host.js(单文件 ESM)与 client.js(套 loader 壳)',
85
+ check: '十三条判据;--only 单跑,也当查询口用;--list 只列判据元数据',
86
+ init: '建模块骨架(--dir / --template minimal|diy / --description)',
87
+ 'market-init': '初始化市场索引仓库(写 market.json + README,推首个 commit)',
88
+ design: '设计底稿:init 填 / check 查空格 / render 生成 README / accept 打勾',
89
+ publish: '发货前清单:门禁 + files + npm pack(只 dry run)',
90
+ release: '真发布:全门禁 + 验收闸 + 预检接管 + 发到出口 + 发后核包 + 更新市场索引(中断可 --resume)',
91
+ sync: '按清单生成 src/declared-tools.ts,按 design.json 生成 src/datasource.ts',
92
+ lint: '用模块自己那份 eslint.config.mjs;--fix 写回文件',
93
+ changelog: '更新日志:init 建 / add 记一版 / check 查这一版有没有(发布前必须有)',
72
94
  };
73
- const args = [];
74
- for (let i = 0; i < rest.length; i += 1) {
75
- if (rest[i] === '--cwd' || rest[i] === '--only') {
76
- i += 1;
77
- continue;
95
+
96
+ function flagValue(raw, flag) {
97
+ const at = raw.indexOf(flag);
98
+ return at === -1 ? undefined : raw[at + 1];
99
+ }
100
+
101
+ async function dispatch(name, raw) {
102
+ const cwd = process.env.DSH_WB_MODULE_CWD ?? process.cwd();
103
+ const args = [];
104
+ for (let index = 0; index < raw.length; index += 1) {
105
+ if (raw[index] === '--cwd' || raw[index] === '--only') {
106
+ index += 1;
107
+ continue;
108
+ }
109
+ args.push(raw[index]);
78
110
  }
79
- args.push(rest[i]);
111
+ const root = resolve(flagValue(raw, '--cwd') ?? cwd);
112
+ if (root !== process.cwd()) process.chdir(root);
113
+ await commands[name]({ root, only: flagValue(raw, '--only'), args });
80
114
  }
81
115
 
82
- const root = resolve(value('--cwd') ?? cwd);
83
- if (root !== process.cwd()) process.chdir(root);
116
+ const program = new Command('dsh-wb-module')
117
+ .description(`模块包的工具链:${Object.keys(COMMANDS).join(' / ')}`)
118
+ .helpOption('-h, --help', '打印帮助')
119
+ .addHelpText(
120
+ 'after',
121
+ '\n --cwd <目录> 指定包目录(默认当前目录;也可用 DSH_WB_MODULE_CWD)\n 任何命令后跟 --help 都只打印帮助。\n',
122
+ );
123
+
124
+ for (const [name, description] of Object.entries(COMMANDS)) {
125
+ const sub = program
126
+ .command(name)
127
+ .description(description)
128
+ .helpOption('-h, --help', '打印帮助')
129
+ .allowUnknownOption(true)
130
+ .allowExcessArguments(true);
131
+ if (name === 'check') sub.addHelpText('after', `\n${CHECK_HELP}\n --only 可选:${Object.keys(CHECKS).join('|')}\n`);
132
+ if (name === 'release') sub.addHelpText('after', `${RELEASE_HELP}\n`);
133
+ if (name === 'market-init') sub.addHelpText('after', `${MARKET_INIT_HELP}\n`);
134
+ if (name === 'changelog') sub.addHelpText('after', `${CHANGELOG_HELP}\n`);
135
+ sub.action(async (_options, command) => {
136
+ await dispatch(name, command.args);
137
+ });
138
+ }
84
139
 
85
- await commands[command]({ root, only: value('--only'), args });
140
+ await program.parseAsync(process.argv);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dsh-workbench/module-tools",
3
- "version": "0.3.10",
3
+ "version": "1.0.0-beta.0",
4
4
  "description": "模块包的构建与门禁工具:build(单文件 ESM 宿主侧 + 套 loader 壳的浏览器侧)、check(契约/产物边界/源码边界/卡片渲染)。模块包不再各带一份构建脚本。",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -22,16 +22,31 @@
22
22
  "access": "public"
23
23
  },
24
24
  "dependencies": {
25
- "@arch-guard/core": "^0.11.0",
25
+ "@arch-guard/core": "^0.11.1",
26
26
  "@eslint/js": "^10.0.1",
27
+ "commander": "^15.0.0",
27
28
  "esbuild": "^0.28.2",
28
29
  "eslint": "^10.11.0",
29
30
  "eslint-plugin-react": "^7.37.5",
30
31
  "eslint-plugin-react-hooks": "^7.1.1",
32
+ "execa": "^10.2.0",
33
+ "typescript": "5.9.3",
31
34
  "typescript-eslint": "^8.71.0",
32
- "@dsh-workbench/shared": "0.3.10"
35
+ "@dsh-workbench/shared": "1.0.0-beta.0"
36
+ },
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/novaeth/dsh-workbench.git",
40
+ "directory": "packages/module-tools"
41
+ },
42
+ "homepage": "https://github.com/novaeth/dsh-workbench#readme",
43
+ "bugs": {
44
+ "url": "https://github.com/novaeth/dsh-workbench/issues"
33
45
  },
34
46
  "scripts": {
47
+ "lint": "eslint src bin scripts",
48
+ "arch": "arch-guard",
49
+ "check": "pnpm run lint && pnpm run arch && pnpm run selfcheck",
35
50
  "selfcheck": "node scripts/selfcheck/index.mjs"
36
51
  }
37
52
  }
package/src/arch.mjs CHANGED
@@ -1,42 +1,4 @@
1
- // 模块包的**架构方言**(arch-guard 的模块版)。
2
- //
3
- // 为什么要有这一份:模块包是"用户自己写"的代码,而模板给的是一个**单文件**起步的形状 ——
4
- // 卡片一复杂(多路取数、多态渲染、几个子件、几条文案)那个文件就会长到几百行,而门禁
5
- // (`check` 原本那十一条)只看产物边界与声明形状,**没有一条在看代码结构**。arch-guard 正好补这一层,
6
- // 但它认识的是"应用 / 库 / FSD"三种范式,模块包(一个插件包、两个面的小库)不在其中:
7
- // 直接把库范式套上去,`src/messages.ts` 这种根文件会报 S01「无处安放」,而两个运行时的
8
- // 互相 import 它也不管。所以这里把模块包的方言**写死一处**:层序表、两个运行时、阈值、
9
- // 允许的依赖、样式与文案的口径。
10
- //
11
- // 为什么不是让每个模板抄一份 arch.config.mjs:那样 ① 每个模块的方言会各自漂,
12
- // ② 方言跟着**工具链**走才对 —— 工坊那三个包本来就"与外壳同代"(seed-workshop.sh 的
13
- // 代际自愈),把方言放在 module-tools 里,它随代际一起更新,用户不用重抄一份配置。
14
- // 模板里那份 `arch.config.mjs` 只留一行 `moduleArch()`,要改(加目录、加依赖、放宽阈值)
15
- // 就在**这一处**改。
16
- //
17
- // 层序(数字越小越底层,S21:只许依赖层号 ≤ 自己的文件):
18
- //
19
- // src/model/** 1 纯逻辑:类型 / 换算 / 状态机。两个运行时都可达,不碰 react / kit / node:fs
20
- // src/messages.ts 1 文案(zh / en)。同样是"谁都能用的数据"
21
- // src/declared-tools.ts 1 `dsh-wb-module sync` 生成的工具名(工具链自己的产物,见下)
22
- // src/components/** 2 浏览器侧的视图件;样式与它同目录(`*.module.css`)
23
- // src/host.ts 10 宿主面(`dsh.workbenchFaces.host`)
24
- // src/client.tsx 10 浏览器面(`dsh.workbenchFaces.client`)
25
- // **/*.test.ts(x) 99 测试(库范式自带这条排他角色,S21 直接跳过;S16 的体积阈值也不管它)
26
- // src/**/__tests__/** 99 测试的另一个家(方言自己声明的,见 TESTS_DIR_ROLE 那段)
27
- //
28
- // 两个运行时**互相隔离**:`structure.runtimes` + `faces` 让 S23 认得出"宿主侧文件 import 了
29
- // 浏览器面"(反过来也一样)。这一条以前没有任何判据管 —— `check:source` 只管 dsh 的包在不在
30
- // 客户端侧可达。
31
- //
32
- // 阈值刻意**严于** arch-guard 的默认(500 行 / 150 行函数):模块包是"一个人写的一块面板",
33
- // 一个文件 200 行、一个函数 80 行就该拆了 —— 这条不是审美,是"复杂模块不会退化成一个巨文件"
34
- // 的可判定下限。要更宽就在自己那份配置里传 `fileLines`。
35
- //
36
- // 三条内置豁免条目 / 两条规则(都要写 reason,arch-guard 的硬要求):D04 的 `--dsw-*` 令牌
37
- // (我们**不拥有**主题,见 STYLE_EXCEPTION 那段)与 S16 的测试文件(脚手架不是产品代码,
38
- // 见 TEST_LINES_EXCEPTIONS 那段 —— 后缀式与 `__tests__/**` 各一条)。
39
-
1
+ import { readFileSync } from 'node:fs';
40
2
  import {
41
3
  cssModulesKit,
42
4
  deps,
@@ -48,41 +10,21 @@ import {
48
10
  noneI18nKit,
49
11
  styles,
50
12
  } from '@arch-guard/core/presets';
13
+ import { createRule } from '@arch-guard/core';
51
14
  import { DECLARED_TOOLS_FILE } from './declared-tools.mjs';
52
15
  import { SEED_SPECIFIERS } from './seed.mjs';
53
16
 
54
- // 两个面**就是** `dsh.workbenchFaces` 指的那两个文件(协议要求,见 ADR-0038)。改这两个名字
55
- // 等于改声明,判据(contracts / source)先红 —— 所以这里是常量,不是选项。
56
17
  const HOST_FACE = 'src/host.ts';
57
18
  const CLIENT_FACE = 'src/client.tsx';
58
19
 
59
- // 内部目录表(`library()` 的 modules)。目录名是约定:模型 / 视图,别的一个都不许有 ——
60
- // 多出来的目录会被 S01 报"不在目录契约内",那正是要的:要加一层就明写在配置里。
61
20
  const LAYERS = { model: 1, components: 2 };
62
21
 
63
- // `src/messages.ts` 是文件不是目录,而 `modules` 只认目录前缀(文件级 key 实测仍报 S01),
64
- // 所以它走 `addRoles`。层号与 model 同层:文案不依赖任何东西,两端都能读。
65
22
  const MESSAGES_ROLE = { id: 'lib:messages', pattern: 'src/messages.ts', layer: 1 };
66
23
 
67
- // `src/declared-tools.ts` 是 `dsh-wb-module sync` 按清单生成的工具名(`tools[].key` 那一档,
68
- // 见 declared-tools.mjs)—— 与 messages.ts 一样是"谁都能读的常量",所以同层、同样走 `addRoles`
69
- // (它也是文件不是目录)。这一条必须内置:那是**工具链自己的产物**,不认它等于"照我们推荐的路走
70
- // 就会撞 S01"(实测:作者得翻源码才知道 roles 的形状,再手写一行补上)。
71
- // 路径直接取 declared-tools 那份常量,不在这里抄一遍字符串(工具名只有一处写者,这条也一样)。
72
24
  const DECLARED_TOOLS_ROLE = { id: 'lib:declared-tools', pattern: DECLARED_TOOLS_FILE, layer: 1 };
73
25
 
74
26
  const DEFAULT_THRESHOLDS = { fileLines: 200, viewLines: 200, functionLines: 80 };
75
27
 
76
- // 测试文件不吃 S16 的体积阈值(类级豁免:arch-guard 的 exceptions 只认"某条规则对某类文件不适用")。
77
- // 它与产品代码不是同一种东西 —— 夹具、逐条断言、一条用例十来行,200 行对它是错的口径;而"拆成两个
78
- // 测试文件"只会把脚手架摞进 src/(作者反馈:一个模块被这条逼出 `src/host/testing.ts`,测试脚手架住进
79
- // 了宿主层目录)。它本来就有一条排他角色(layer 99,S21 直接跳过),这条豁免与那条同一个理由。
80
- // 为什么不给"测试单列一个阈值":S16 只读 thresholds 的 fileLines / viewLines / functionLines
81
- // (arch-guard structure.js),要按角色分档得改上游;先用类级豁免把口径说清楚。
82
- //
83
- // 两条覆盖面合起来 = 方言对"什么算测试"的**全部**声明,一份口径:后缀式的 `.test.` / `.spec.`
84
- // (库范式那条排他角色收的七种扩展,这里一种不落)与 `__tests__/**` 这个家(`metrics.testGlobs`
85
- // 里写着的那一条)。少于这个并集就会冒出第二份口径:同一个方言里"什么算测试"有两套答案。
86
28
  const TEST_LINES_REASON =
87
29
  '测试文件是脚手架,不是产品代码:夹具、逐条断言、一条用例十来行,200 行这个阈值对它是错的口径;' +
88
30
  '按它拆文件只会把测试脚手架摞进 src/ 的层里(作者反馈:一个模块被逼出过 src/host/testing.ts)。' +
@@ -91,16 +33,10 @@ const TEST_LINES_REASON =
91
33
 
92
34
  const TEST_LINES_EXCEPTIONS = [
93
35
  { rule: 'S16', glob: '**/*.{test,spec}.{ts,tsx,mts,cts,js,mjs,cjs}', reason: TEST_LINES_REASON },
94
- // `__tests__/**` 也要豁免:那是方言自己声明过的测试家(`metrics.testGlobs`),
95
- // 但库范式那条排他角色只认后缀式命名 —— 不豁免的话,把测试放进 `__tests__/` 反而吃 S16。
36
+
96
37
  { rule: 'S16', glob: 'src/**/__tests__/**', reason: TEST_LINES_REASON },
97
38
  ];
98
39
 
99
- // `__tests__/**` 里的文件(夹具 / 用例)也要有个角色:方言已经在 `metrics.testGlobs` 里把这里
100
- // 声明成测试的家,而库范式只给了后缀式命名一条角色 —— 于是 `src/__tests__/x.ts` 会撞 S01,
101
- // `src/model/__tests__/x.ts` 会被当成模型层(S16 与 S21 都按产品代码判它)。`exclusive` 是必须的:
102
- // 它排在库范式的目录角色后面,命中即清掉前面的命中,否则 `src/model/__tests__/**` 会"同时命中两个角色"
103
- // 再撞一次 S01。层号跟后缀式测试同一条(99 ⇒ S21 跳过):脚手架能引产品代码,反过来不行。
104
40
  const TESTS_DIR_ROLE = {
105
41
  id: 'lib:tests-dir',
106
42
  pattern: 'src/**/__tests__/**',
@@ -108,9 +44,6 @@ const TESTS_DIR_ROLE = {
108
44
  exclusive: true,
109
45
  };
110
46
 
111
- // 我们**不拥有**主题:`--dsw-*` 那套令牌由 dsh 在运行时给(与外壳同一口径,
112
- // 见 ADR-0028 / .scratch/dsh-ui-alignment/spec.md)。所以这里声明的样式目录里,
113
- // "引用的令牌查不闭合"是正常的 —— 理由就写在下面这条里(与上面那条 S16 一起,是两条内置豁免)。
114
47
  const STYLE_EXCEPTION = {
115
48
  rule: 'D04',
116
49
  glob: 'src/components/**',
@@ -120,32 +53,25 @@ const STYLE_EXCEPTION = {
120
53
  'dsh 改名会由外壳升级暴露,而不是由模块本地的表兜住。',
121
54
  };
122
55
 
123
- // 模块包**运行时**允许的依赖:react(外壳经模块表提供)+ 外壳自己那两个包 + **平台种子词那一整族**。
124
- //
125
- // 种子词为什么要一起放进来(2026-10-19 补,用户口径「arch 没加依赖」):那些包是 dsh 的模块表
126
- // 天生就提供的(`seed.mjs` 是唯一真相),模块引它们走 `dsh.client.external` 是**被允许的路**
127
- // (ADR-0028 §5 / ADR-0063)。但作者把它们写进 `dependencies` 时,P01 会报「未登记的运行时依赖」,
128
- // 而 arch-guard 给的那句修法是「加进 `deps({ allow })`」—— 对平台件这是**误导**:它逼每个模块
129
- // 为一条本来就合法的事开口子。所以这里按 `seed.mjs` 推导(dsh 加词,这张表自己跟着长),
130
- // 表外的包(真正的第三方 SDK)照旧要明着登记。
131
- //
132
- // 推荐写法仍是 **devDependency(类型)+ `dsh.client.external`**:放进 `dependencies` 不会报错,
133
- // 但 pnpm 会给模块装一份**运行时用不到的**副本(产物里是 `require`,实例由外壳给)。
134
- // 那是"浪费"不是"错误",所以不由 arch 判;由 `check:source` / `check:primitives` 守真正的错误
135
- // (宿主侧碰它、漏声明 external、产物被内联)。
136
- // `@dsh-workbench/module-tools` 是构建/门禁工具,是 devDependency,不进这张表。
137
56
  const SEED_PACKAGES = [
138
57
  ...new Set(
139
58
  SEED_SPECIFIERS.map((name) => (name.startsWith('@') ? name.split('/').slice(0, 2).join('/') : name.split('/')[0])),
140
59
  ),
141
60
  ];
142
61
  const ALLOWED_DEPS = [
143
- ...new Set([...SEED_PACKAGES, '@dsh-workbench/core', '@dsh-workbench/shared']),
62
+ ...new Set([
63
+ ...SEED_PACKAGES,
64
+ '@dsh-workbench/core',
65
+ '@dsh-workbench/shared',
66
+ 'execa',
67
+ 'which',
68
+ 'proper-lockfile',
69
+ 'write-file-atomic',
70
+ 'dayjs',
71
+ 'zod',
72
+ ]),
144
73
  ];
145
74
 
146
- // 我们**不拥有**主题:颜色 / 圆角 / 字号 / 时长一律引用 dsh 的 `--dsw-*` 令牌,
147
- // 所以这里没有 tokenDir / palette(声明一个自己的令牌表就是编造一处不存在的真相,
148
- // 与外壳那份 arch.config.mjs 同一个理由)。留白的三族白名单 = 一个手搓数值都不收(`0` 本来就被跳过)。
149
75
  const DESIGN = {
150
76
  styleDir: 'src/components',
151
77
  tokenDir: '',
@@ -159,32 +85,124 @@ const DESIGN = {
159
85
  ],
160
86
  };
161
87
 
162
- /**
163
- * 模块包的 arch-guard 配置。模板里那一行就是默认值:
164
- *
165
- * ```js
166
- * import { moduleArch } from '@dsh-workbench/module-tools/arch';
167
- * export default moduleArch();
168
- * ```
169
- *
170
- * 复杂模块要开口子时**从参数进**(每条口子都有名字,评审时看得见),别改判据:
171
- *
172
- * - `fileLines` / `viewLines` / `functionLines`:放宽体积阈值(默认 200 / 200 / 80)
173
- * - `deps`:追加允许的运行时依赖(比如宿主侧接了某个 SDK)。dsh 的**平台种子词**
174
- * (`seed.mjs` 那一族:`react` / `zod` / `@deepseek-ai/dsh-client-*` …)与 `@dsh-workbench/*`
175
- * 已经按种子表推导进表里,不用为它们开口子
176
- * - `roles`:追加角色(自定义目录 / 文件;`{ id, pattern, layer }`)。
177
- * 内置已有三条**文件级**角色:`src/messages.ts`、`src/declared-tools.ts`,以及 `__tests__/`
178
- * 目录下的文件(`src/` 内任意一层)—— 后两条一条是 `sync` 生成的、一条是方言声明的测试家,
179
- * 工具链自己认,不用你补
180
- * - `layers`:**追加或覆盖**内部目录表(默认 `{ model: 1, components: 2 }`)。宿主侧想住
181
- * `src/host/` 就在自己那份配置里加 `{ host: 10 }`;但**两个面的文件路径不是可调项**
182
- * (`src/host.ts` / `src/client.tsx` 同时钉在 `library({ entry })`、`structure.faces` 与清单的
183
- * `dsh.workbenchFaces` 上,要换面得整体替换 `structure` 并改清单 —— 先红的是 contracts)
184
- * - `structure`:整体替换 `structure` 声明(面在别处、或者只有一个运行时的模块)
185
- * - `exceptions`:结构性豁免,**每条必须写 reason**(arch-guard 的硬要求);
186
- * 内置已有三条(`--dsw-*` 令牌 / 测试文件不吃 S16 的两条覆盖面),不必重复声明
187
- */
88
+ const MODEL_FILE = /^src\/(model\/|messages\.ts$)/;
89
+ const MODEL_BANNED = [
90
+ /^react(?:\/|$)/,
91
+ /^react-dom(?:\/|$)/,
92
+ /^@deepseek-ai\//,
93
+ /^@dsh-workbench\/core(?:\/client)?$/,
94
+ /^@dsh-workbench\/shared\/host$/,
95
+ /^node:/,
96
+ /^opencli(?:\/|$)/,
97
+ /^@\/components(?:\/|$)/,
98
+ /^(?:\.\.?\/)+(?:components|host|client)(?:\/|$)/,
99
+ /^(?:@\/|\.\.?\/)(?:host|client)$/,
100
+ ];
101
+
102
+ const COMPONENT_FILE = /^src\/components\//;
103
+ const COMPONENT_BANNED = [
104
+ /^@dsh-workbench\/shared\/host$/,
105
+ /^@dsh-workbench\/core$/,
106
+ /^node:/,
107
+ /^opencli(?:\/|$)/,
108
+ /^(?:\.\.?\/)+(?:host|client)$/,
109
+ /^(?:@\/)(?:host|client)$/,
110
+ ];
111
+
112
+ const modelStaysPure = createRule({
113
+ id: 'S71',
114
+ domain: 'structure',
115
+ level: 'L2',
116
+ severity: 'error',
117
+ title: '模块核心(model / messages)不碰宿主、UI 与运行时',
118
+ hint:
119
+ 'model / messages 是模块里最底的一层:只放纯逻辑、状态机、文案。' +
120
+ 'React、kit、dsh、opencli、node:*、组件与两个面都不许 import;要用类型就从 core/contract 取 import type。',
121
+ run(ctx) {
122
+ const findings = [];
123
+ for (const record of ctx.records) {
124
+ if (!MODEL_FILE.test(record.rel)) continue;
125
+ for (const fact of ctx.facts.get(record.rel)?.imports ?? []) {
126
+ if (fact.typeOnly) continue;
127
+ if (!MODEL_BANNED.some((pattern) => pattern.test(fact.spec))) continue;
128
+ findings.push({
129
+ rule: 'S71',
130
+ file: record.rel,
131
+ line: fact.line,
132
+ ...(fact.column === undefined ? {} : { column: fact.column }),
133
+ text: `模块核心 import 了 ${fact.spec}`,
134
+ hint: '把宿主 / UI / 运行时的依赖推到 components 或两个面;核心只依赖纯类型与纯函数',
135
+ });
136
+ }
137
+ }
138
+ return findings;
139
+ },
140
+ });
141
+
142
+ const componentsStayBrowser = createRule({
143
+ id: 'S72',
144
+ domain: 'structure',
145
+ level: 'L2',
146
+ severity: 'error',
147
+ title: '模块视图不碰宿主面',
148
+ hint:
149
+ 'components 只消费 props:可以拿 kit 与纯数据,不许 import 宿主入口、shared/host、dsh 原语或 node:*。' +
150
+ '取数、写、路由都在两个面与 model 里。',
151
+ run(ctx) {
152
+ const findings = [];
153
+ for (const record of ctx.records) {
154
+ if (!COMPONENT_FILE.test(record.rel)) continue;
155
+ for (const fact of ctx.facts.get(record.rel)?.imports ?? []) {
156
+ if (fact.typeOnly) continue;
157
+ if (!COMPONENT_BANNED.some((pattern) => pattern.test(fact.spec))) continue;
158
+ findings.push({
159
+ rule: 'S72',
160
+ file: record.rel,
161
+ line: fact.line,
162
+ ...(fact.column === undefined ? {} : { column: fact.column }),
163
+ text: `模块视图 import 了 ${fact.spec}`,
164
+ hint: '组件只接收数据与回调;宿主侧能力放 src/host.ts,浏览器侧取数放面的 hooks',
165
+ });
166
+ }
167
+ }
168
+ return findings;
169
+ },
170
+ });
171
+
172
+ const LOCAL_PROTOCOL =
173
+ /\b(?:interface|type)\s+(UnitModuleFace|UnitMountResult|UnitClientFace|UnitClientApi|UnitModuleRegistry|UnitClientRegistry|UnitRegistry|HostContext|ClientContext|BoardProps)\b/g;
174
+
175
+ const lineOf = (text, index) => text.slice(0, index).split('\n').length;
176
+
177
+ const protocolsStayCanonical = createRule({
178
+ id: 'S73',
179
+ domain: 'structure',
180
+ level: 'L2',
181
+ severity: 'error',
182
+ title: '模块不本地重声明外壳协议',
183
+ hint:
184
+ '协议类型只有一份:`@dsh-workbench/core/contract`。删掉本地 interface / type,改成 `import type`;' +
185
+ '本地重声明会让外壳升级时模块静默对不上。',
186
+ run(ctx) {
187
+ const findings = [];
188
+ for (const record of ctx.records) {
189
+ const text = readFileSync(record.abs, 'utf8');
190
+ for (const match of text.matchAll(LOCAL_PROTOCOL)) {
191
+ findings.push({
192
+ rule: 'S73',
193
+ file: record.rel,
194
+ line: lineOf(text, match.index),
195
+ text: `本地声明 ${match[1]}`,
196
+ hint: '删掉,改 import type from @dsh-workbench/core/contract',
197
+ });
198
+ }
199
+ }
200
+ return findings;
201
+ },
202
+ });
203
+
204
+ const MODULE_RULES = [modelStaysPure, componentsStayBrowser, protocolsStayCanonical];
205
+
188
206
  export function moduleArch(options = {}) {
189
207
  const {
190
208
  fileLines,
@@ -207,20 +225,32 @@ export function moduleArch(options = {}) {
207
225
  return {
208
226
  sourceForm: 'react',
209
227
  presets: [
210
- // 层序表 + 角色完备(S01 未命中任何角色的文件当场报)+ 体积(S16)。
228
+
211
229
  library({ src: 'src', entry: ['host.ts', 'client.tsx'], modules: { ...LAYERS, ...layers } }),
212
- // 运行时依赖 fail-closed:没登记的依赖报 P01。
213
- deps({ allow: [...ALLOWED_DEPS, ...extraDeps] }),
214
- // 手搓数值 / !important / 选择器 / CSS Modules 的绑定闭合。令牌表留白(理由见 DESIGN)。
230
+
231
+ deps({
232
+ allow: [...ALLOWED_DEPS, ...extraDeps],
233
+ deny: ['shell-quote', 'shelljs', 'zx'],
234
+ capabilities: {
235
+ 'unique-id': 'crypto.randomUUID',
236
+ 'number-format': 'Intl.NumberFormat',
237
+ 'deep-equal': 'node:util.isDeepStrictEqual',
238
+ datetime: 'dayjs',
239
+ 'deep-clone': 'structuredClone',
240
+ 'query-string': 'URLSearchParams',
241
+ validation: 'zod',
242
+ },
243
+ fingerprints: [{ capability: 'query-string', allowOwn: false }],
244
+ }),
245
+
215
246
  designSystem({ ...DESIGN }),
216
- // 组件样式走 `*.module.css`(ADR-0029 的模块版:样式跟组件走)。
247
+
217
248
  styles(cssModulesKit()),
218
- // 反退化:TODO / 调试残留 / 空 catch / 未完成标记。
249
+
219
250
  hygiene(),
220
- // 文案走模块自己的 `messages`(zh / en),不用 i18next —— 与外壳同一口径。
251
+
221
252
  i18n(noneI18nKit()),
222
- // 模型层必须有落地的测试(`src/model/**` 命中文件但一份测试都没有就红)。
223
- // 门禁链不在这里查(`check` 不跑 vitest,那是 `pnpm test` 的事,与外壳同一条口径)。
253
+
224
254
  metrics({
225
255
  tests: {
226
256
  requireTestsFor: ['src/model/**'],
@@ -228,14 +258,15 @@ export function moduleArch(options = {}) {
228
258
  checkChain: { script: 'check', require: [] },
229
259
  },
230
260
  }),
261
+
262
+ { enable: ['S71', 'S72', 'S73'] },
231
263
  ],
232
264
  overrides: {
233
265
  include: ['src/**'],
234
266
  addRoles: [MESSAGES_ROLE, DECLARED_TOOLS_ROLE, TESTS_DIR_ROLE, ...roles],
235
267
  thresholds,
236
- // 两个运行时 + 两个面:S23 据此判"跨运行时走了对面那个面"。
237
- // 不写 `value`(面值形状):模块的面导出的是 `apply` / `inject` / `name`,
238
- // 那是 dsh 插件的契约,不是我们在这里能收敛成某个前缀的东西。
268
+ customRules: MODULE_RULES,
269
+
239
270
  structure: structure ?? {
240
271
  runtimes: [
241
272
  { name: 'host', entries: [HOST_FACE] },
@@ -244,9 +275,7 @@ export function moduleArch(options = {}) {
244
275
  faces: [
245
276
  { pattern: HOST_FACE, runtime: 'host' },
246
277
  { pattern: CLIENT_FACE, runtime: 'client' },
247
- // 组件目录整片算"浏览器侧的面":于是**宿主侧的文件 import 一个组件**会被 S23 当场报
248
- // (层号拦不住它 —— components 在第 2 层、host 面在第 10 层,S21 认为"向下依赖"合法)。
249
- // 那正是最典型的一种错:宿主产物里拖进一个只会渲染的东西,`dist/host.js` 在 Node 里起不来。
278
+
250
279
  { pattern: 'src/components/**', runtime: 'client' },
251
280
  ],
252
281
  },