@dsh-workbench/module-tools 0.1.2 → 0.1.4

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dsh-workbench/module-tools",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "模块包的构建与门禁工具:build(单文件 ESM 宿主侧 + 套 loader 壳的浏览器侧)、check(契约/产物边界/源码边界/卡片渲染)。模块包不再各带一份构建脚本。",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/build.mjs CHANGED
@@ -3,11 +3,17 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { builtinModules } from 'node:module';
4
4
  import { join } from 'node:path';
5
5
  import { build as esbuild } from 'esbuild';
6
+ import { SEED_SPECIFIERS } from './seed.mjs';
6
7
 
7
8
  const SHELL_CLIENT = '@dsh-workbench/core/client';
8
9
  const RESOLVE_EXTENSIONS = ['.ts', '.tsx', '.js', '.json'];
9
10
  const SINGLETON = /^@\/shared\/(hooks|components)\//;
10
11
 
12
+ // dsh 的客户端模块表里"天生就有"的包(seed 词)。模块的浏览器产物**默认**把它们标成外部:
13
+ // 这些是外壳那一份实例,内联进来就是第二份 —— 两份 React 组件树、两套样式、hover 与浮层失联。
14
+ // 名单在 `../seed.mjs`(唯一真相);判据侧由 `checks/source.mjs` 与 `checks/primitives.mjs` 守。
15
+ const SEED_EXTERNAL = SEED_SPECIFIERS;
16
+
11
17
  function pickEntry(root, names) {
12
18
  for (const name of names) {
13
19
  const full = join(root, name);
@@ -84,7 +90,8 @@ export async function build({ root }) {
84
90
  platform: 'browser',
85
91
  format: 'cjs',
86
92
  target: 'es2022',
87
- external: ['react', 'react-dom', SHELL_CLIENT, ...declared],
93
+ external: [SHELL_CLIENT, ...SEED_EXTERNAL, ...declared],
94
+ metafile: true,
88
95
  jsx: 'transform',
89
96
  jsxFactory: 'h',
90
97
  jsxFragment: 'Fragment',
@@ -94,6 +101,17 @@ export async function build({ root }) {
94
101
  logLevel: 'warning',
95
102
  });
96
103
 
104
+ // 物证:metafile 列出**这次真的被打进包里**的每个输入文件。dsh 的包只要出现,
105
+ // 就说明它没走外部化(声明漏了 / 名字写错 / 从 shared 内联进来)—— 那正是"第二份实例"。
106
+ // 不靠产物里的字符串去猜(tree-shaking 之后标记可能根本不进包),也不靠它自己报错。
107
+ const inlinedDsh = Object.keys(client.metafile.inputs).filter((path) => /@deepseek-ai[\\/]/.test(path));
108
+ if (inlinedDsh.length > 0) {
109
+ throw new Error(
110
+ `${id}: dsh 的包被打进了浏览器产物 —— ${inlinedDsh.join('、')}\n` +
111
+ '这些应当由外壳的模块表提供(标成 external 才是"用外壳那一份")。见 ADR-0028。',
112
+ );
113
+ }
114
+
97
115
  const indented = client.outputFiles[0].text
98
116
  .split('\n')
99
117
  .map((line) => (line === '' ? line : ` ${line}`))
package/src/check.mjs CHANGED
@@ -3,6 +3,7 @@ const CHECKS = {
3
3
  contracts: { title: '模块契约', load: () => import('./checks/contracts.mjs') },
4
4
  bundles: { title: '模块产物边界', load: () => import('./checks/bundles.mjs') },
5
5
  source: { title: '模块源码边界', load: () => import('./checks/source.mjs') },
6
+ primitives: { title: 'dsh 原语只从模块表拿', load: () => import('./checks/primitives.mjs') },
6
7
  cards: { title: '卡片渲染', load: () => import('./checks/cards.mjs') },
7
8
  standalone: { title: '独立构建', load: () => import('./checks/standalone.mjs') },
8
9
  markers: { title: '公开包无内部标识', load: () => import('./checks/markers.mjs') },
@@ -70,6 +70,19 @@ function fakeKit() {
70
70
  };
71
71
  }
72
72
 
73
+ /**
74
+ * dsh 的客户端包(平台种子词)在**卡片渲染**这里的替身。
75
+ *
76
+ * 模块的产物里 `require("@deepseek-ai/dsh-client-ui-primitives")` 是**规范**(ADR-0028:
77
+ * 标成 external 才是"用外壳那一份")。判据在 node 里跑产物,就得像外壳的模块表那样把这些
78
+ * 种子词交出去 —— 不然合规的模块反而卡在"没桩的外部"(实际上就是这么发现的)。
79
+ * 组件与 hooks 都长成函数:卡片判据只问"画不画得出非空元素树"。
80
+ */
81
+ function fakeDshPackage() {
82
+ const stub = (props) => ({ type: 'stub', props: props ?? {}, children: [] });
83
+ return new Proxy({}, { get: (_target, key) => (key === 'default' ? stub : stub) });
84
+ }
85
+
73
86
  function loadFace(clientFile) {
74
87
  let captured;
75
88
  const fakeWindow = {
@@ -92,6 +105,7 @@ function loadFace(clientFile) {
92
105
  const fakeRequire = (id) => {
93
106
  if (id === 'react') return fakeReact();
94
107
  if (id === '@dsh-workbench/core/client') return { kit: fakeKit() };
108
+ if (id.startsWith('@deepseek-ai/')) return fakeDshPackage();
95
109
  throw new Error(`产物 require 了没桩的外部:${id}`);
96
110
  };
97
111
  const produced = captured.factory(fakeRequire);
@@ -19,11 +19,12 @@ import { createRequire } from 'node:module';
19
19
  import { join, relative } from 'node:path';
20
20
  import { fileURLToPath } from 'node:url';
21
21
  import { discoverPackages } from '../discover.mjs';
22
+ import { SEED_SHORT } from '../seed.mjs';
22
23
 
23
24
  const ROOT = process.env.DSH_WB_ROOT ?? fileURLToPath(new URL('../../../../', import.meta.url));
24
25
 
25
- /** dsh 浏览器侧的固定 seed 词表(模块表里天生就有的那几个)。 */
26
- const SEED = new Set(['react', 'react-dom', 'cordis', 'dsh-client-store', 'ui-slots', 'ui-primitives', 'ui-dockkit']);
26
+ /** dsh 浏览器侧的固定 seed 词表(模块表里天生就有的那几个)—— 唯一真相在 `../seed.mjs`。 */
27
+ const SEED = SEED_SHORT;
27
28
 
28
29
  const PLATFORMS = new Set(['web']);
29
30
 
@@ -0,0 +1,116 @@
1
+ // 模块用 dsh 的 UI 原语时,**只从模块表拿** —— 这条判据守的是"声明"与"产物"对不对得上。
2
+ //
3
+ // ADR-0028 决定:浏览器半侧直接用 dsh 的客户端 UI 原语(它们是平台种子词)。但"直接用"要付
4
+ // 两笔账,任何一笔错了都不会当场报错,只会**悄悄多一份实例**或**等浏览器 require 那一刻才炸**:
5
+ //
6
+ // ① 源码 import 了某个种子词 ⇒ 它必须出现在 `dsh.client.external` 里
7
+ // (漏声明 ⇒ 构建把那个包**内联**进模块产物 = 第二份 React 组件树、两套样式);
8
+ // ② 同一条 ⇒ 产物 `client.js` 里必须有 `require("<说明符>")`
9
+ // (内联了就不会有 require —— 这是"到底外部化没有"唯一不靠猜的物证);
10
+ // ③ 宿主产物里不许出现种子词的引用(那些包只在浏览器侧存在;宿主侧源码 import 它们
11
+ // 由 `checks/source.mjs` 拦,这里对着产物再确认一次)。
12
+ //
13
+ // 判据跟着 `dsh.workbench` 自述清单走:没用 dsh 原语的模块直接跳过(0 命中 = 通过)。
14
+
15
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
16
+ import { join, relative } from 'node:path';
17
+ import { discoverModulePackages } from '../discover.mjs';
18
+ import { BROWSER_SEED_SPECIFIERS, SEED_SPECIFIERS } from '../seed.mjs';
19
+
20
+ const IMPORT = /from\s*'([^']+)'|import\s*\(?\s*'([^']+)'/g;
21
+ const SEED = new Set(SEED_SPECIFIERS);
22
+
23
+ /** 短名写法(`ui-primitives`)也算声明过 —— 老包里有这种写法,不该因为写法短被判红。 */
24
+ function declares(declared, specifier) {
25
+ if (declared.has(specifier)) return true;
26
+ const short = specifier.replace(/^@deepseek-ai\/(dsh-client-)?/, '');
27
+ return declared.has(short);
28
+ }
29
+
30
+ function sourceFiles(dir, acc = []) {
31
+ if (!existsSync(dir)) return acc;
32
+ for (const entry of readdirSync(dir)) {
33
+ const full = join(dir, entry);
34
+ if (statSync(full).isDirectory()) {
35
+ if (entry === '__tests__' || entry === 'node_modules') continue;
36
+ sourceFiles(full, acc);
37
+ } else if (/\.tsx?$/.test(entry) && !/\.test\.tsx?$/.test(entry)) {
38
+ acc.push(full);
39
+ }
40
+ }
41
+ return acc;
42
+ }
43
+
44
+ const problems = [];
45
+ let modulesUsingPrimitives = 0;
46
+ let modulesUsingReactOnly = 0;
47
+
48
+ for (const pkg of discoverModulePackages({ root: process.cwd() })) {
49
+ const name = pkg.manifest.name;
50
+ const declared = new Set(pkg.manifest.dsh?.client?.external ?? []);
51
+ const used = new Map();
52
+
53
+ for (const file of sourceFiles(join(pkg.dir, 'src'))) {
54
+ const text = readFileSync(file, 'utf8');
55
+ for (const match of text.matchAll(IMPORT)) {
56
+ const specifier = match[1] ?? match[2];
57
+ if (specifier === undefined || !SEED.has(specifier)) continue;
58
+ if (!used.has(specifier)) used.set(specifier, relative(pkg.dir, file));
59
+ }
60
+ }
61
+ if (used.size === 0) continue;
62
+ const dshSeeds = [...used.keys()].filter((specifier) => BROWSER_SEED_SPECIFIERS.includes(specifier));
63
+ if (dshSeeds.length === 0) modulesUsingReactOnly += 1;
64
+ else modulesUsingPrimitives += 1;
65
+
66
+ // ① 只管 **dsh 的那些浏览器包**:react / react-dom 是构建一直默认外部化的(从第一天就是),
67
+ // 要求每个模块去声明它只会制造噪音。dsh 的包不一样 —— 旧版 module-tools 的默认表里没有它们。
68
+ const needsDeclaration = new Set(BROWSER_SEED_SPECIFIERS);
69
+ for (const [specifier, where] of used) {
70
+ if (needsDeclaration.has(specifier) && !declares(declared, specifier)) {
71
+ problems.push(
72
+ `${name}: ${where} 用了 ${specifier},但 package.json 的 dsh.client.external 里没声明它 —— ` +
73
+ `漏声明会被构建内联进来(第二份实例)。加上 "${specifier}"`,
74
+ );
75
+ }
76
+ }
77
+
78
+ const client = join(pkg.dir, 'client.js');
79
+ if (!existsSync(client)) {
80
+ problems.push(`${name}: 源码用了 dsh 的种子词,但没有 client.js —— 先跑 dsh-wb-module build`);
81
+ } else {
82
+ const text = readFileSync(client, 'utf8');
83
+ for (const [specifier, where] of used) {
84
+ if (!text.includes(`require(${JSON.stringify(specifier)})`)) {
85
+ problems.push(
86
+ `${name}: ${where} 用了 ${specifier},但产物 client.js 里没有 require("${specifier}") —— ` +
87
+ `它被**内联**进产物了(第二份实例)。检查 dsh.client.external 与构建用的 module-tools 版本`,
88
+ );
89
+ }
90
+ }
91
+ }
92
+
93
+ const host = join(pkg.dir, 'dist', 'host.js');
94
+ if (existsSync(host)) {
95
+ const text = readFileSync(host, 'utf8');
96
+ for (const specifier of BROWSER_SEED_SPECIFIERS) {
97
+ if (text.includes(`"${specifier}"`)) {
98
+ problems.push(
99
+ `${name}: 宿主产物 dist/host.js 里出现了 ${specifier} —— 那些包只在浏览器侧存在,` +
100
+ `宿主侧引用它就是起来那一刻炸`,
101
+ );
102
+ }
103
+ }
104
+ }
105
+ }
106
+
107
+ if (problems.length > 0) {
108
+ console.error(`✖ dsh 原语 ${problems.length} 条不过:`);
109
+ for (const problem of problems) console.error(` · ${problem}`);
110
+ process.exit(1);
111
+ }
112
+
113
+ console.log(
114
+ `✔ dsh 原语通过:${modulesUsingPrimitives} 个模块在用 dsh 的包(都声明了 external、产物里都是 ` +
115
+ `require、宿主产物里没有它们)· 另有 ${modulesUsingReactOnly} 个模块只用到 react/react-dom`,
116
+ );
@@ -2,15 +2,21 @@
2
2
  //
3
3
  // 外壳那份 `arch/rules/units.mjs` 是按**路径**发现套件的(`src/modules/<id>/unit.ts`),
4
4
  // 模块搬出 `src/` 之后它照跑但不报。那几条规则守的多是「外壳不许认识套件」——对模块包没意义;
5
- // 模块包自己要守的是另外四条,写在下面。判据全部来自源码里的 import 说明符,覆盖范围跟着
5
+ // 模块包自己要守的是另外几条,写在下面。判据全部来自源码里的 import 说明符,覆盖范围跟着
6
6
  // `dsh.workbench` 自述清单走(新加模块不用改这里)。
7
+ //
8
+ // 关于 dsh 的包:「哪一半」**不靠目录名猜**,而是从两个入口(`faces.host` / `faces.client`)
9
+ // 做一次本包内可达性分析。种子词(模块表里天生就有的那几个)在**只被客户端入口可达**的文件里
10
+ // 可以用(ADR-0028);宿主侧一个都不许碰 —— 那些包只在浏览器侧存在。
7
11
 
8
12
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
9
- import { join, relative } from 'node:path';
13
+ import { dirname, join, relative, resolve } from 'node:path';
10
14
  import { discoverModulePackages } from '../discover.mjs';
15
+ import { isSeedSpecifier } from '../seed.mjs';
11
16
 
12
-
13
- const IMPORT = /from\s+'([^']+)'|import\s+'([^']+)'/g;
17
+ // 静态 `from '…'`、副作用 `import '…'`、动态 `import('…')` —— 动态那条不能漏,
18
+ // 模块入口常见写法是 `await import('./views/…')`,漏了就会把可达文件判成"死代码"。
19
+ const IMPORT = /from\s*'([^']+)'|import\s*\(?\s*'([^']+)'/g;
14
20
 
15
21
  function sourceFiles(dir, acc = []) {
16
22
  for (const entry of readdirSync(dir)) {
@@ -25,6 +31,58 @@ function sourceFiles(dir, acc = []) {
25
31
  return acc;
26
32
  }
27
33
 
34
+ /**
35
+ * 把一条 import 说明符解析成本包内的文件;解析不到就是包说明符(不外追)。
36
+ * 别名两条(与 `build.mjs` 的 aliasResolver 同一条约定):
37
+ * `@/modules/<unit>/X` → `src/X` · `@/platforms/X` → `src/platforms/X`
38
+ */
39
+ function resolveLocal(pkgDir, unit, fromFile, specifier) {
40
+ let base;
41
+ if (specifier.startsWith('@/')) {
42
+ const own = `@/modules/${unit}/`;
43
+ base = specifier.startsWith(own)
44
+ ? join(pkgDir, 'src', specifier.slice(own.length))
45
+ : join(pkgDir, 'src', specifier.slice(2));
46
+ } else if (specifier.startsWith('.')) {
47
+ base = resolve(dirname(fromFile), specifier);
48
+ } else {
49
+ return undefined;
50
+ }
51
+ const stems = [base, base.replace(/\.js$/, '')];
52
+ for (const stem of stems) {
53
+ for (const candidate of [
54
+ `${stem}.ts`,
55
+ `${stem}.tsx`,
56
+ join(stem, 'index.ts'),
57
+ join(stem, 'index.tsx'),
58
+ ]) {
59
+ if (existsSync(candidate)) return candidate;
60
+ }
61
+ }
62
+ return undefined;
63
+ }
64
+
65
+ /** 从某个入口出发,本包内可达的源文件。 */
66
+ function reachableFrom(pkgDir, unit, entry) {
67
+ const seen = new Set();
68
+ const start = join(pkgDir, entry);
69
+ if (!existsSync(start)) return seen;
70
+ const queue = [start];
71
+ while (queue.length > 0) {
72
+ const file = queue.pop();
73
+ if (seen.has(file)) continue;
74
+ seen.add(file);
75
+ const text = readFileSync(file, 'utf8');
76
+ for (const match of text.matchAll(IMPORT)) {
77
+ const specifier = match[1] ?? match[2];
78
+ if (specifier === undefined) continue;
79
+ const next = resolveLocal(pkgDir, unit, file, specifier);
80
+ if (next !== undefined && !seen.has(next)) queue.push(next);
81
+ }
82
+ }
83
+ return seen;
84
+ }
85
+
28
86
  function hasPlatformPackage(manifest) {
29
87
  const names = Object.keys({ ...(manifest.dependencies ?? {}), ...(manifest.devDependencies ?? {}) });
30
88
  return names.some((name) => /(^|\/)platform-/.test(name));
@@ -34,10 +92,12 @@ const modulePackages = () => discoverModulePackages({ root: process.cwd() });
34
92
 
35
93
  const problems = [];
36
94
  let scanned = 0;
95
+ let seedUsers = 0;
37
96
 
38
97
  for (const pkg of modulePackages()) {
39
98
  const name = pkg.manifest.name;
40
99
  const unit = pkg.manifest.dsh.workbench.unit;
100
+ const faces = pkg.manifest.dsh.workbench.faces ?? {};
41
101
  const srcDir = join(pkg.dir, 'src');
42
102
  const viaPlatform = hasPlatformPackage(pkg.manifest);
43
103
 
@@ -47,6 +107,17 @@ for (const pkg of modulePackages()) {
47
107
  );
48
108
  }
49
109
 
110
+ const hostReach = faces.host === undefined ? new Set() : reachableFrom(pkg.dir, unit, faces.host);
111
+ const clientReach = faces.client === undefined ? new Set() : reachableFrom(pkg.dir, unit, faces.client);
112
+ const sideOf = (file) => {
113
+ const host = hostReach.has(file);
114
+ const client = clientReach.has(file);
115
+ if (host && client) return 'both';
116
+ if (host) return 'host';
117
+ if (client) return 'client';
118
+ return 'orphan';
119
+ };
120
+
50
121
  for (const file of sourceFiles(srcDir)) {
51
122
  scanned += 1;
52
123
  const rel = relative(pkg.dir, file);
@@ -57,8 +128,27 @@ for (const pkg of modulePackages()) {
57
128
  if (specifier === undefined) continue;
58
129
 
59
130
  if (specifier.startsWith('@deepseek-ai/')) {
131
+ if (isSeedSpecifier(specifier) && sideOf(file) === 'client') {
132
+ seedUsers += 1;
133
+ continue;
134
+ }
135
+ if (isSeedSpecifier(specifier)) {
136
+ const side = sideOf(file);
137
+ const why =
138
+ side === 'both'
139
+ ? '这个文件**两个入口都到得了** —— 它会被拖进宿主产物'
140
+ : side === 'orphan'
141
+ ? '这个文件两个入口都到不了(可能是死代码),证明不了它只在客户端侧'
142
+ : '这个文件在宿主侧可达';
143
+ problems.push(
144
+ `${name}: ${rel} 里 import 了 ${specifier}(dsh 的浏览器包,是平台种子词)—— ${why},` +
145
+ `而它只在浏览器侧存在。把它挪到只被 faces.client 可达的文件里`,
146
+ );
147
+ continue;
148
+ }
60
149
  problems.push(
61
- `${name}: ${rel} 里 import 了 ${specifier} —— 平台契约要按需自己声明,不许直接吃 dsh 的包`,
150
+ `${name}: ${rel} 里 import 了 ${specifier} —— 它不是平台种子词,模块表里没有它,` +
151
+ `浏览器侧要用 dsh 的组件只能 import 种子词那几个(ADR-0028);其余平台契约按需自己声明`,
62
152
  );
63
153
  continue;
64
154
  }
@@ -98,5 +188,7 @@ if (problems.length > 0) {
98
188
  }
99
189
 
100
190
  console.log(
101
- `✔ 模块源码边界通过:${scanned} 个源文件 · 不吃平台包 · 不伸进外壳 app/ · 不跨模块 · 平台知识要么在自己包里、要么走平台包(不许两样都有)`,
191
+ `✔ 模块源码边界通过:${scanned} 个源文件 · 不伸进外壳 app/ · 不跨模块 · ` +
192
+ `平台知识要么在自己包里、要么走平台包(不许两样都有)· ` +
193
+ `dsh 的浏览器包只在客户端侧可用(${seedUsers} 处)`,
102
194
  );
@@ -1,9 +1,14 @@
1
- import { readdirSync, readFileSync, statSync } from 'node:fs';
1
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
2
  import { join, relative } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
3
4
  import { discoverModulePackages } from '../discover.mjs';
4
5
 
5
6
  const SHELL_SELECTOR = /(^|[\s,>+~])\.wb-[a-z0-9-]+/;
6
7
  const RULE = /\.wb-[a-z0-9-]+[^{}]*\{/;
8
+ const CLASS_TOKEN = /(^|[^-\w])\.?(wb-[a-zA-Z0-9-]+)/g;
9
+ const CONTRACT = fileURLToPath(
10
+ new URL('../../../workbench/src/shared/styles/board.css', import.meta.url),
11
+ );
7
12
 
8
13
  function walk(dir, acc = []) {
9
14
  for (const entry of readdirSync(dir)) {
@@ -15,7 +20,18 @@ function walk(dir, acc = []) {
15
20
  return acc;
16
21
  }
17
22
 
23
+ function isTestSource(file) {
24
+ return /(^|[\\/])__tests__[\\/]/.test(file) || /\.test\.[cm]?tsx?$/.test(file);
25
+ }
26
+
27
+ const contract = existsSync(CONTRACT)
28
+ ? new Set(
29
+ Array.from(readFileSync(CONTRACT, 'utf8').matchAll(CLASS_TOKEN), (match) => match[2]),
30
+ )
31
+ : null;
32
+
18
33
  const problems = [];
34
+ const borrowed = [];
19
35
  const modules = discoverModulePackages({ root: process.cwd() });
20
36
  for (const pkg of modules) {
21
37
  for (const file of walk(pkg.dir)) {
@@ -26,7 +42,8 @@ for (const pkg of modules) {
26
42
  } catch {
27
43
  continue;
28
44
  }
29
- text.split('\n').forEach((line, index) => {
45
+ const lines = text.split('\n');
46
+ lines.forEach((line, index) => {
30
47
  if (!RULE.test(line)) return;
31
48
  const selector = line.slice(0, line.indexOf('{'));
32
49
  if (!SHELL_SELECTOR.test(selector)) return;
@@ -34,6 +51,20 @@ for (const pkg of modules) {
34
51
  `${pkg.manifest.name}: ${relative(pkg.dir, file)}:${index + 1} 定义了外壳前缀的 CSS 规则 —— ${line.trim().slice(0, 100)}`,
35
52
  );
36
53
  });
54
+
55
+ if (contract === null || isTestSource(file)) continue;
56
+ if (!/\.tsx?$/.test(file)) continue;
57
+ lines.forEach((line, index) => {
58
+ for (const match of line.matchAll(CLASS_TOKEN)) {
59
+ const name = match[2];
60
+ if (line[match.index + match[0].indexOf(name) - 1] === '-') continue;
61
+ if (name.endsWith('-')) continue;
62
+ if (contract.has(name)) continue;
63
+ borrowed.push(
64
+ `${pkg.manifest.name}: ${relative(pkg.dir, file)}:${index + 1} 借用了 ${name},契约里没有它 —— ${line.trim().slice(0, 100)}`,
65
+ );
66
+ }
67
+ });
37
68
  }
38
69
  }
39
70
 
@@ -43,4 +74,21 @@ if (problems.length > 0) {
43
74
  console.error(' (wb-* 是全局类名:两份同名规则按注入顺序互相覆盖,用户看到的是随机的那个)');
44
75
  process.exit(1);
45
76
  }
46
- console.log(`✔ 样式归属清楚:${modules.length} 个模块都没有定义外壳前缀(wb-*)的 CSS 规则`);
77
+
78
+ if (borrowed.length > 0) {
79
+ console.error(`✖ 模块借用了契约里没有的 wb-* 类:${borrowed.length} 处`);
80
+ for (const one of borrowed) console.error(` · ${one}`);
81
+ console.error(
82
+ ' (契约是本仓 board.css:模块只许借它里面有的类。没有的那个要么是拼错/改了名漏改,' +
83
+ '要么本该进 board.css —— 空类名在真机上就是没样式,而且没人会报错)',
84
+ );
85
+ process.exit(1);
86
+ }
87
+
88
+ const contractNote =
89
+ contract === null
90
+ ? '契约文件不在(这次只查了"模块不许定义 wb-*")'
91
+ : `借用的类都在 board.css 契约里`;
92
+ console.log(
93
+ `✔ 样式归属清楚:${modules.length} 个模块都没有定义外壳前缀(wb-*)的 CSS 规则;${contractNote}`,
94
+ );
package/src/discover.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
 
2
- import { existsSync, readFileSync, readdirSync } from 'node:fs';
2
+ import { existsSync, readFileSync, readdirSync, realpathSync } from 'node:fs';
3
3
  import { dirname, join } from 'node:path';
4
4
 
5
5
  function manifestOf(dir) {
@@ -36,8 +36,11 @@ export function discoverPackages({ root = process.cwd() } = {}) {
36
36
  const seen = new Set();
37
37
  const found = [];
38
38
  for (const dir of dirs) {
39
- if (seen.has(dir)) continue;
40
- seen.add(dir);
39
+ // 按**真实路径**去重:`process.cwd()` 与 workspace 拼出来的路径可能是同一条目录的两种写法
40
+ // (软链,典型是 macOS 的 /tmp → /private/tmp),不规范化就会把同一个包扫两遍、报两遍。
41
+ const key = existsSync(dir) ? realpathSync(dir) : dir;
42
+ if (seen.has(key)) continue;
43
+ seen.add(key);
41
44
  const manifest = manifestOf(dir);
42
45
  if (manifest === undefined || typeof manifest.name !== 'string') continue;
43
46
  found.push({ dir, manifest });
package/src/seed.mjs ADDED
@@ -0,0 +1,45 @@
1
+ // dsh 客户端模块表的**平台种子词** —— 一个模块的浏览器产物**天生**能 require 到它们。
2
+ //
3
+ // 名单真值出处:dsh 外壳构造种子表的地方(`@deepseek-ai/dsh-web-frontend` 的 dist,函数 WS()),
4
+ // 证据与推理见 `.scratch/dsh-ui-alignment/spec.md` §0;「模块可以直接用它」的决定见 ADR-0028。
5
+ //
6
+ // 这份表是**唯一真相**:构建的 external 默认表(`build.mjs`)、契约判据(`contracts.mjs`)、
7
+ // 源码边界(`checks/source.mjs`)与新增的原语判据(`checks/primitives.mjs`)都从这里取。
8
+ // dsh 升版改了种子表,只改这一处。
9
+
10
+ export const SEED_SPECIFIERS = [
11
+ 'react',
12
+ 'react/jsx-runtime',
13
+ 'react-dom',
14
+ 'react-dom/client',
15
+ '@deepseek-ai/cordis',
16
+ '@deepseek-ai/dsh-client-store',
17
+ '@deepseek-ai/dsh-client-ui-slots',
18
+ '@deepseek-ai/dsh-client-ui-primitives',
19
+ '@deepseek-ai/dsh-client-ui-dockkit',
20
+ ];
21
+
22
+ /**
23
+ * 外部声明里允许的**短名**(历史写法:`"ui-primitives"` 这类)。
24
+ * 契约判据按它放行,免得老包只因为写法短就被判红。
25
+ */
26
+ export const SEED_SHORT = new Set([
27
+ 'react',
28
+ 'react-dom',
29
+ 'cordis',
30
+ 'dsh-client-store',
31
+ 'ui-slots',
32
+ 'ui-primitives',
33
+ 'ui-dockkit',
34
+ ...SEED_SPECIFIERS.filter((name) => name.startsWith('@deepseek-ai/dsh-client')),
35
+ ]);
36
+
37
+ const SEED_LONG = new Set(SEED_SPECIFIERS);
38
+
39
+ /** 是不是种子词(按完整说明符判断)。 */
40
+ export const isSeedSpecifier = (specifier) => SEED_LONG.has(specifier);
41
+
42
+ /** 种子词里那些 **dsh 的** 包 —— 它们只在浏览器侧存在,宿主侧一个都不许碰。 */
43
+ export const BROWSER_SEED_SPECIFIERS = SEED_SPECIFIERS.filter(
44
+ (name) => name.startsWith('@deepseek-ai/'),
45
+ );