@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.
@@ -8,6 +8,9 @@
8
8
  // 关于 dsh 的包:「哪一半」**不靠目录名猜**,而是从两个入口(`faces.host` / `faces.client`)
9
9
  // 做一次本包内可达性分析。种子词(模块表里天生就有的那几个)在**只被客户端入口可达**的文件里
10
10
  // 可以用(ADR-0028);宿主侧一个都不许碰 —— 那些包只在浏览器侧存在。
11
+ //
12
+ // 形状(十条判据统一):导出 `title` 与 `run()`,`run()` 返回三态结果;
13
+ // 打印与退出码归 `../check.mjs` 那一层。
11
14
 
12
15
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
13
16
  import { dirname, join, relative, resolve } from 'node:path';
@@ -88,107 +91,118 @@ function hasPlatformPackage(manifest) {
88
91
  return names.some((name) => /(^|\/)platform-/.test(name));
89
92
  }
90
93
 
91
- const modulePackages = () => discoverModulePackages({ root: process.cwd() });
92
-
93
- const problems = [];
94
- let scanned = 0;
95
- let seedUsers = 0;
96
-
97
- for (const pkg of modulePackages()) {
98
- const name = pkg.manifest.name;
99
- const unit = pkg.manifest.dsh.workbench.unit;
100
- const faces = pkg.manifest.dsh.workbench.faces ?? {};
101
- const srcDir = join(pkg.dir, 'src');
102
- const viaPlatform = hasPlatformPackage(pkg.manifest);
103
-
104
- if (viaPlatform && existsSync(join(srcDir, 'platforms'))) {
105
- problems.push(
106
- `${name}: 已经改用平台包了,src/platforms/ 就别再留一份 —— 平台知识两处都有,改平台要改两处`,
107
- );
108
- }
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
-
121
- for (const file of sourceFiles(srcDir)) {
122
- scanned += 1;
123
- const rel = relative(pkg.dir, file);
124
- const text = readFileSync(file, 'utf8');
125
-
126
- for (const match of text.matchAll(IMPORT)) {
127
- const specifier = match[1] ?? match[2];
128
- if (specifier === undefined) continue;
94
+ export const title = '模块源码边界';
95
+
96
+ export async function run() {
97
+ const modulePackages = discoverModulePackages({ root: process.cwd() });
98
+ const problems = [];
99
+ let scanned = 0;
100
+ let seedUsers = 0;
101
+
102
+ for (const pkg of modulePackages) {
103
+ const name = pkg.manifest.name;
104
+ const unit = pkg.manifest.dsh.workbench.unit;
105
+ const faces = pkg.manifest.dsh.workbench.faces ?? {};
106
+ const srcDir = join(pkg.dir, 'src');
107
+ const viaPlatform = hasPlatformPackage(pkg.manifest);
108
+
109
+ if (viaPlatform && existsSync(join(srcDir, 'platforms'))) {
110
+ problems.push(
111
+ `${name}: 已经改用平台包了,src/platforms/ 就别再留一份 —— 平台知识两处都有,改平台要改两处`,
112
+ );
113
+ }
129
114
 
130
- if (specifier.startsWith('@deepseek-ai/')) {
131
- if (isSeedSpecifier(specifier) && sideOf(file) === 'client') {
132
- seedUsers += 1;
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);
117
+ const sideOf = (file) => {
118
+ const host = hostReach.has(file);
119
+ const client = clientReach.has(file);
120
+ if (host && client) return 'both';
121
+ if (host) return 'host';
122
+ if (client) return 'client';
123
+ return 'orphan';
124
+ };
125
+
126
+ for (const file of sourceFiles(srcDir)) {
127
+ scanned += 1;
128
+ const rel = relative(pkg.dir, file);
129
+ const text = readFileSync(file, 'utf8');
130
+
131
+ for (const match of text.matchAll(IMPORT)) {
132
+ const specifier = match[1] ?? match[2];
133
+ if (specifier === undefined) continue;
134
+
135
+ if (specifier.startsWith('@deepseek-ai/')) {
136
+ if (isSeedSpecifier(specifier) && sideOf(file) === 'client') {
137
+ seedUsers += 1;
138
+ continue;
139
+ }
140
+ if (isSeedSpecifier(specifier)) {
141
+ const side = sideOf(file);
142
+ const why =
143
+ side === 'both'
144
+ ? '这个文件**两个入口都到得了** —— 它会被拖进宿主产物'
145
+ : side === 'orphan'
146
+ ? '这个文件两个入口都到不了(可能是死代码),证明不了它只在客户端侧'
147
+ : '这个文件在宿主侧可达';
148
+ problems.push(
149
+ `${name}: ${rel} 里 import 了 ${specifier}(dsh 的浏览器包,是平台种子词)—— ${why},` +
150
+ `而它只在浏览器侧存在。把它挪到只被 faces.client 可达的文件里`,
151
+ );
152
+ continue;
153
+ }
154
+ problems.push(
155
+ `${name}: ${rel} 里 import 了 ${specifier} —— 它不是平台种子词,模块表里没有它,` +
156
+ `浏览器侧要用 dsh 的组件只能 import 种子词那几个(ADR-0028);其余平台契约按需自己声明`,
157
+ );
133
158
  continue;
134
159
  }
135
- if (isSeedSpecifier(specifier)) {
136
- const side = sideOf(file);
137
- const why =
138
- side === 'both'
139
- ? '这个文件**两个入口都到得了** —— 它会被拖进宿主产物'
140
- : side === 'orphan'
141
- ? '这个文件两个入口都到不了(可能是死代码),证明不了它只在客户端侧'
142
- : '这个文件在宿主侧可达';
160
+ if (specifier === '@/app' || specifier.startsWith('@/app/')) {
143
161
  problems.push(
144
- `${name}: ${rel} 里 import 了 ${specifier}(dsh 的浏览器包,是平台种子词)—— ${why},` +
145
- `而它只在浏览器侧存在。把它挪到只被 faces.client 可达的文件里`,
162
+ `${name}: ${rel} 里 import 了 ${specifier} —— 模块不许伸进外壳的入口/外壳层,只能走 kit 与 mount(deps)`,
146
163
  );
147
164
  continue;
148
165
  }
149
- problems.push(
150
- `${name}: ${rel} 里 import 了 ${specifier} —— 它不是平台种子词,模块表里没有它,` +
151
- `浏览器侧要用 dsh 的组件只能 import 种子词那几个(ADR-0028);其余平台契约按需自己声明`,
152
- );
153
- continue;
154
- }
155
- if (specifier === '@/app' || specifier.startsWith('@/app/')) {
156
- problems.push(
157
- `${name}: ${rel} 里 import 了 ${specifier} —— 模块不许伸进外壳的入口/外壳层,只能走 kit 与 mount(deps)`,
158
- );
159
- continue;
160
- }
161
- const cross = /^@\/modules\/([^/]+)\//.exec(specifier);
162
- if (cross !== null && cross[1] !== unit) {
163
- problems.push(
164
- `${name}: ${rel} 里 import 了 ${specifier} —— 模块之间不许互相 import(另一个套件没装就没有这份源码)`,
165
- );
166
- continue;
167
- }
168
- const platform = /^@\/platforms\/([^/]+)\/(.+)$/.exec(specifier);
169
- if (platform !== null && viaPlatform) {
170
- problems.push(
171
- `${name}: ${rel} 里 import 了 ${specifier} —— 这个模块已经改用平台包了,平台知识走包名` +
172
- `(比如 @pagoda-dsh-workbench/platform-coding),不许再用 @/platforms/*`,
173
- );
174
- } else if (platform !== null && !existsSync(join(srcDir, 'platforms', `${platform[2]}.ts`))) {
175
- problems.push(
176
- `${name}: ${rel} 里 import 了 ${specifier},但本包 src/platforms/${platform[2]}.ts 不存在 —— ` +
177
- `平台知识要么放在自己包里的 src/platforms/,要么整个走平台包,两条只能选一条`,
178
- );
166
+ const cross = /^@\/modules\/([^/]+)\//.exec(specifier);
167
+ if (cross !== null && cross[1] !== unit) {
168
+ problems.push(
169
+ `${name}: ${rel} 里 import 了 ${specifier} —— 模块之间不许互相 import(另一个套件没装就没有这份源码)`,
170
+ );
171
+ continue;
172
+ }
173
+ const platform = /^@\/platforms\/([^/]+)\/(.+)$/.exec(specifier);
174
+ if (platform !== null && viaPlatform) {
175
+ problems.push(
176
+ `${name}: ${rel} 里 import 了 ${specifier} —— 这个模块已经改用平台包了,平台知识走包名` +
177
+ `(比如 @pagoda-dsh-workbench/platform-coding),不许再用 @/platforms/*`,
178
+ );
179
+ } else if (platform !== null && !existsSync(join(srcDir, 'platforms', `${platform[2]}.ts`))) {
180
+ problems.push(
181
+ `${name}: ${rel} 里 import 了 ${specifier},但本包 src/platforms/${platform[2]}.ts 不存在 —— ` +
182
+ `平台知识要么放在自己包里的 src/platforms/,要么整个走平台包,两条只能选一条`,
183
+ );
184
+ }
179
185
  }
180
186
  }
181
187
  }
182
- }
183
188
 
184
- if (problems.length > 0) {
185
- console.error(`✖ 模块源码边界 ${problems.length} 条不过:`);
186
- for (const problem of problems) console.error(` · ${problem}`);
187
- process.exit(1);
189
+ if (problems.length > 0) {
190
+ return { status: 'fail', notes: [], problems };
191
+ }
192
+ if (modulePackages.length === 0) {
193
+ return {
194
+ status: 'skip',
195
+ notes: ['没有模块包(没有声明 dsh.workbench 的包)—— 没有源码要查边界'],
196
+ problems: [],
197
+ };
198
+ }
199
+ return {
200
+ status: 'pass',
201
+ notes: [
202
+ `${scanned} 个源文件 · 不伸进外壳 app/ · 不跨模块 · ` +
203
+ `平台知识要么在自己包里、要么走平台包(不许两样都有)· ` +
204
+ `dsh 的浏览器包只在客户端侧可用(${seedUsers} 处)`,
205
+ ],
206
+ problems: [],
207
+ };
188
208
  }
189
-
190
- console.log(
191
- `✔ 模块源码边界通过:${scanned} 个源文件 · 不伸进外壳 app/ · 不跨模块 · ` +
192
- `平台知识要么在自己包里、要么走平台包(不许两样都有)· ` +
193
- `dsh 的浏览器包只在客户端侧可用(${seedUsers} 处)`,
194
- );
@@ -1,3 +1,13 @@
1
+ // 模块**离开外壳源码**也得能 build + typecheck(发布出去的是模块,用户手上没有本仓的 src)。
2
+ //
3
+ // 手法:把外壳的 `src/` 暂时改名藏起来,再对每个模块包跑它自己的 build 与 typecheck,
4
+ // 最后无论成败都还原。藏起来还过不了 = 这条模块在用"只有本仓才有"的相对路径 / 别名 ——
5
+ // 发布出去会散架。
6
+ //
7
+ // 形状(十条判据统一):导出 `title` 与 `run()`,`run()` 返回三态结果;
8
+ // 打印与退出码归 `../check.mjs` 那一层。所以这里**不许**自己退出:
9
+ // 仓外工作区(没有外壳源码)以前就是"提前成功退出"(退出码 0)—— 那会把排在后面的判据静默吞掉。
10
+
1
11
  import { execFileSync } from 'node:child_process';
2
12
  import { existsSync, renameSync } from 'node:fs';
3
13
  import { join } from 'node:path';
@@ -5,33 +15,49 @@ import { discoverModulePackages, discoverPackages } from '../discover.mjs';
5
15
 
6
16
  const SHELL = '@dsh-workbench/core';
7
17
 
8
- const shell = discoverPackages({ root: process.cwd() }).find((pkg) => pkg.manifest.name === SHELL);
9
- const visible = shell === undefined ? undefined : join(shell.dir, 'src');
18
+ export const title = '独立构建';
10
19
 
11
- if (visible === undefined || !existsSync(visible)) {
12
- console.log(`✔ 独立构建:跳过 —— 本仓没有 ${SHELL} 的源码("不许 @/ 指外壳"那条判据照旧在守)`);
13
- } else {
14
- runStandalone(visible);
15
- }
20
+ export async function run() {
21
+ const shell = discoverPackages({ root: process.cwd() }).find((pkg) => pkg.manifest.name === SHELL);
22
+ const visible = shell === undefined ? undefined : join(shell.dir, 'src');
23
+
24
+ if (visible === undefined || !existsSync(visible)) {
25
+ return {
26
+ status: 'skip',
27
+ notes: [`本仓没有 ${SHELL} 的源码("不许 @/ 指外壳"那条判据照旧在守)`],
28
+ problems: [],
29
+ };
30
+ }
16
31
 
17
- function runStandalone(visible) {
18
32
  const modules = discoverModulePackages({ root: process.cwd() });
33
+ if (modules.length === 0) {
34
+ return {
35
+ status: 'skip',
36
+ notes: ['没有模块包(没有声明 dsh.workbench 的包)—— 没有可独立构建的对象'],
37
+ problems: [],
38
+ };
39
+ }
40
+
19
41
  const hidden = join(shell.dir, 'src.hidden');
42
+ if (existsSync(hidden)) {
43
+ return {
44
+ status: 'fail',
45
+ notes: [],
46
+ problems: [`上次没还原干净:${hidden} 还在,手动改名回去再跑。`],
47
+ };
48
+ }
20
49
 
21
50
  const restore = () => {
22
51
  if (existsSync(hidden) && !existsSync(visible)) renameSync(hidden, visible);
23
52
  };
24
- process.on('SIGINT', () => {
53
+ // Ctrl-C 时也必须还原(进程被杀时 finally 不跑):还原完把信号还回去,让默认行为收场。
54
+ const onInterrupt = () => {
25
55
  restore();
26
- process.exit(130);
27
- });
28
-
29
- if (existsSync(hidden)) {
30
- console.error(`上次没还原干净:${hidden} 还在,手动改名回去再跑。`);
31
- process.exit(1);
32
- }
56
+ process.removeListener('SIGINT', onInterrupt);
57
+ process.kill(process.pid, 'SIGINT');
58
+ };
59
+ process.on('SIGINT', onInterrupt);
33
60
 
34
- const rows = [];
35
61
  const problems = [];
36
62
  renameSync(visible, hidden);
37
63
  try {
@@ -41,7 +67,6 @@ function runStandalone(visible) {
41
67
  if (pkg.manifest.scripts?.[script] === undefined) continue;
42
68
  try {
43
69
  execFileSync('pnpm', ['run', script], { cwd: pkg.dir, stdio: 'pipe' });
44
- rows.push(`${name} ${script}: ✅`);
45
70
  } catch (error) {
46
71
  const output = `${error.stdout ?? ''}${error.stderr ?? ''}`;
47
72
  const first = output
@@ -50,20 +75,25 @@ function runStandalone(visible) {
50
75
  .slice(0, 2)
51
76
  .map((line) => line.trim())
52
77
  .join(' | ');
53
- rows.push(`${name} ${script}: ❌`);
54
78
  problems.push(`${name} ${script} 在外壳源码不在场时失败 —— ${first.slice(0, 220)}`);
55
79
  }
56
80
  }
57
81
  }
58
82
  } finally {
59
83
  restore();
84
+ process.removeListener('SIGINT', onInterrupt);
60
85
  }
61
86
 
62
87
  if (problems.length > 0) {
63
- console.error(`✖ 独立构建 ${problems.length} 条不过:`);
64
- for (const problem of problems) console.error(` · ${problem}`);
65
- console.error(' (上面的清单就是"发布出去会散架"的地方:模块只许依赖已发布的接口)');
66
- process.exit(1);
88
+ return {
89
+ status: 'fail',
90
+ notes: ['(上面的清单就是"发布出去会散架"的地方:模块只许依赖已发布的接口)'],
91
+ problems,
92
+ };
67
93
  }
68
- console.log(`✔ 独立构建通过:${modules.length} 个模块包在外壳源码不在场时都能 build + typecheck`);
94
+ return {
95
+ status: 'pass',
96
+ notes: [`${modules.length} 个模块包在外壳源码不在场时都能 build + typecheck`],
97
+ problems: [],
98
+ };
69
99
  }
@@ -1,3 +1,13 @@
1
+ // 模块的样式归属:① 不许定义外壳前缀(`wb-*`)的规则;② 借用的 `wb-*` 类必须在契约里;
2
+ // ③ `*.module.css` 的绑定不许指向不存在的类。
3
+ //
4
+ // 为什么:`wb-*` 是**全局**类名,两份同名规则按注入顺序互相覆盖,用户看到的是随机的那个;
5
+ // CSS Modules 的哈希类名靠"同一份文件、同一个导出"配对,调用点拿到 undefined 时真机渲染成
6
+ // `class="undefined"` —— 元素整片没样式,而且控制台一声不响。
7
+ //
8
+ // 形状(十条判据统一):导出 `title` 与 `run()`,`run()` 返回三态结果;
9
+ // 打印与退出码归 `../check.mjs` 那一层。
10
+
1
11
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
12
  import { dirname, join, relative, resolve } from 'node:path';
3
13
  import { fileURLToPath } from 'node:url';
@@ -5,6 +15,9 @@ import { discoverModulePackages } from '../discover.mjs';
5
15
 
6
16
  const SHELL_SELECTOR = /(^|[\s,>+~])\.wb-[a-z0-9-]+/;
7
17
  const RULE = /\.wb-[a-z0-9-]+[^{}]*\{/;
18
+ // 外链一律走 kit 的 `Link`:它自带的样式(正文色 / hover 下划线 / 12px / 那个外链小箭头)是唯一一份。
19
+ // 手写 `<a>` / `h('a', …)` 拿到的是浏览器默认样式,而且每个人写的都不一样(这正是"同一个东西样式不一样"的来源)。
20
+ const BARE_ANCHOR = /(h\(\s*['"]a['"]|jsx\(\s*['"]a['"]|<a[\s>])/;
8
21
  const CLASS_TOKEN = /(^|[^-\w])\.?(wb-[a-zA-Z0-9-]+)/g;
9
22
  // 契约 = 外壳**所有**声明了 `wb-*` 的样式表,不只是那份全局 `board.css`:
10
23
  // ADR-0029 要求组件的样式跟组件走,所以 `wb-card` / `wb-rows` / `wb-note` 这些已经搬进
@@ -21,6 +34,8 @@ const CONTRACT_FILES = [
21
34
  })(),
22
35
  ];
23
36
 
37
+ export const title = '样式归属';
38
+
24
39
  function walk(dir, acc = []) {
25
40
  for (const entry of readdirSync(dir)) {
26
41
  if (entry === 'node_modules' || entry === 'dist') continue;
@@ -35,109 +50,133 @@ function isTestSource(file) {
35
50
  return /(^|[\\/])__tests__[\\/]/.test(file) || /\.test\.[cm]?tsx?$/.test(file);
36
51
  }
37
52
 
38
- const contract = CONTRACT_FILES.some((file) => existsSync(file))
39
- ? new Set(
40
- CONTRACT_FILES.filter((file) => existsSync(file)).flatMap((file) =>
41
- Array.from(readFileSync(file, 'utf8').matchAll(CLASS_TOKEN), (match) => match[2]),
42
- ),
43
- )
44
- : null;
53
+ export async function run() {
54
+ const contract = CONTRACT_FILES.some((file) => existsSync(file))
55
+ ? new Set(
56
+ CONTRACT_FILES.filter((file) => existsSync(file)).flatMap((file) =>
57
+ Array.from(readFileSync(file, 'utf8').matchAll(CLASS_TOKEN), (match) => match[2]),
58
+ ),
59
+ )
60
+ : null;
45
61
 
46
- const problems = [];
47
- const borrowed = [];
48
- const modules = discoverModulePackages({ root: process.cwd() });
49
- for (const pkg of modules) {
50
- for (const file of walk(pkg.dir)) {
51
- if (!/\.(css|ts|tsx)$/.test(file)) continue;
52
- let text;
53
- try {
54
- text = readFileSync(file, 'utf8');
55
- } catch {
56
- continue;
57
- }
58
- const lines = text.split('\n');
59
- lines.forEach((line, index) => {
60
- if (!RULE.test(line)) return;
61
- const selector = line.slice(0, line.indexOf('{'));
62
- if (!SHELL_SELECTOR.test(selector)) return;
63
- problems.push(
64
- `${pkg.manifest.name}: ${relative(pkg.dir, file)}:${index + 1} 定义了外壳前缀的 CSS 规则 —— ${line.trim().slice(0, 100)}`,
65
- );
66
- });
67
-
68
- if (contract === null || isTestSource(file)) continue;
69
- if (!/\.tsx?$/.test(file)) continue;
70
- lines.forEach((line, index) => {
71
- for (const match of line.matchAll(CLASS_TOKEN)) {
72
- const name = match[2];
73
- if (line[match.index + match[0].indexOf(name) - 1] === '-') continue;
74
- if (name.endsWith('-')) continue;
75
- if (contract.has(name)) continue;
76
- borrowed.push(
77
- `${pkg.manifest.name}: ${relative(pkg.dir, file)}:${index + 1} 借用了 ${name},契约里没有它 —— ${line.trim().slice(0, 100)}`,
62
+ const problems = [];
63
+ const borrowed = [];
64
+ const anchors = [];
65
+ const modules = discoverModulePackages({ root: process.cwd() });
66
+ for (const pkg of modules) {
67
+ for (const file of walk(pkg.dir)) {
68
+ if (!/\.(css|ts|tsx)$/.test(file)) continue;
69
+ let text;
70
+ try {
71
+ text = readFileSync(file, 'utf8');
72
+ } catch {
73
+ continue;
74
+ }
75
+ const lines = text.split('\n');
76
+ lines.forEach((line, index) => {
77
+ if (!RULE.test(line)) return;
78
+ const selector = line.slice(0, line.indexOf('{'));
79
+ if (!SHELL_SELECTOR.test(selector)) return;
80
+ problems.push(
81
+ `${pkg.manifest.name}: ${relative(pkg.dir, file)}:${index + 1} 定义了外壳前缀的 CSS 规则 —— ${line.trim().slice(0, 100)}`,
78
82
  );
83
+ });
84
+
85
+ if (isTestSource(file)) continue;
86
+ if (!/\.tsx?$/.test(file)) continue;
87
+ if (contract !== null) {
88
+ lines.forEach((line, index) => {
89
+ if (!BARE_ANCHOR.test(line)) return;
90
+ anchors.push(
91
+ `${pkg.manifest.name}: ${relative(pkg.dir, file)}:${index + 1} 手写了裸 <a> —— ` +
92
+ `外链一律用 kit 的 Link(新窗口、noopener、自带那个外链小箭头;样式只有这一份):${line.trim().slice(0, 80)}`,
93
+ );
94
+ });
79
95
  }
80
- });
96
+ lines.forEach((line, index) => {
97
+ for (const match of line.matchAll(CLASS_TOKEN)) {
98
+ const name = match[2];
99
+ if (line[match.index + match[0].indexOf(name) - 1] === '-') continue;
100
+ if (name.endsWith('-')) continue;
101
+ if (contract.has(name)) continue;
102
+ borrowed.push(
103
+ `${pkg.manifest.name}: ${relative(pkg.dir, file)}:${index + 1} 借用了 ${name},契约里没有它 —— ${line.trim().slice(0, 100)}`,
104
+ );
105
+ }
106
+ });
107
+ }
81
108
  }
82
- }
83
109
 
84
- const bindings = [];
85
- for (const pkg of modules) {
86
- const defined = new Map();
87
- for (const file of walk(pkg.dir)) {
88
- if (!/\.module\.css$/.test(file)) continue;
89
- const text = readFileSync(file, 'utf8').replace(/:global\([^)]*\)/g, '');
90
- defined.set(file, new Set(Array.from(text.matchAll(/\.([A-Za-z][\w-]*)/g), (m) => m[1])));
91
- }
92
- for (const file of walk(pkg.dir)) {
93
- if (!/\.tsx?$/.test(file) || isTestSource(file)) continue;
94
- const text = readFileSync(file, 'utf8');
95
- for (const hit of text.matchAll(/import\s+([A-Za-z_$][\w$]*)\s+from\s+'([^']*\.module\.css)'/g)) {
96
- const bind = hit[1];
97
- const names = defined.get(resolve(dirname(file), hit[2]));
98
- if (names === undefined) continue;
99
- for (const use of text.matchAll(new RegExp(`\\b${bind}\\.([A-Za-z_$][\\w$]*)`, 'g'))) {
100
- if (names.has(use[1])) continue;
101
- bindings.push(
102
- `${pkg.manifest.name}: ${relative(pkg.dir, file)} 用了 ${bind}.${use[1]},` +
103
- `${relative(pkg.dir, resolve(dirname(file), hit[2]))} 里没有这个类`,
104
- );
110
+ const bindings = [];
111
+ for (const pkg of modules) {
112
+ const defined = new Map();
113
+ for (const file of walk(pkg.dir)) {
114
+ if (!/\.module\.css$/.test(file)) continue;
115
+ const text = readFileSync(file, 'utf8').replace(/:global\([^)]*\)/g, '');
116
+ defined.set(file, new Set(Array.from(text.matchAll(/\.([A-Za-z][\w-]*)/g), (m) => m[1])));
117
+ }
118
+ for (const file of walk(pkg.dir)) {
119
+ if (!/\.tsx?$/.test(file) || isTestSource(file)) continue;
120
+ const text = readFileSync(file, 'utf8');
121
+ for (const hit of text.matchAll(/import\s+([A-Za-z_$][\w$]*)\s+from\s+'([^']*\.module\.css)'/g)) {
122
+ const bind = hit[1];
123
+ const names = defined.get(resolve(dirname(file), hit[2]));
124
+ if (names === undefined) continue;
125
+ for (const use of text.matchAll(new RegExp(`\\b${bind}\\.([A-Za-z_$][\\w$]*)`, 'g'))) {
126
+ if (names.has(use[1])) continue;
127
+ bindings.push(
128
+ `${pkg.manifest.name}: ${relative(pkg.dir, file)} 用了 ${bind}.${use[1]},` +
129
+ `${relative(pkg.dir, resolve(dirname(file), hit[2]))} 里没有这个类`,
130
+ );
131
+ }
105
132
  }
106
133
  }
107
134
  }
108
- }
109
135
 
110
- if (bindings.length > 0) {
111
- console.error(`✖ 样式绑定指向了 CSS 里不存在的类:${bindings.length} 处`);
112
- for (const one of bindings) console.error(` · ${one}`);
113
- console.error(
114
- ' (CSS Modules 的哈希类名靠"同一份文件、同一个导出"配对:调用点拿到的会是 undefined,' +
115
- '真机上渲染成 class="undefined" —— 元素整片没样式,而且控制台一声不响)',
116
- );
117
- process.exit(1);
118
- }
136
+ if (bindings.length + problems.length + borrowed.length + anchors.length > 0) {
137
+ const notes = [];
138
+ if (bindings.length > 0) {
139
+ notes.push(
140
+ `${bindings.length} 处是「样式绑定指向了 CSS 里不存在的类」:(CSS Modules 的哈希类名靠"同一份文件、同一个导出"配对:` +
141
+ '调用点拿到的会是 undefined,真机上渲染成 class="undefined" —— 元素整片没样式,而且控制台一声不响)',
142
+ );
143
+ }
144
+ if (problems.length > 0) {
145
+ notes.push(
146
+ `${problems.length} 处是「模块不许定义外壳前缀的 CSS 规则」:(wb-* 是全局类名:两份同名规则按注入顺序互相覆盖,用户看到的是随机的那个)`,
147
+ );
148
+ }
149
+ if (borrowed.length > 0) {
150
+ notes.push(
151
+ `${borrowed.length} 处是「模块借用了契约里没有的 wb-* 类」:(契约是本仓 board.css:模块只许借它里面有的类。` +
152
+ '没有的那个要么是拼错/改了名漏改,要么本该进 board.css —— 空类名在真机上就是没样式,而且没人会报错)',
153
+ );
154
+ }
155
+ if (anchors.length > 0) {
156
+ notes.push(
157
+ `${anchors.length} 处是「手写了裸 <a>」:(外链用 kit 的 Link —— 样式只有一份,改一处全局一致;` +
158
+ '裸 <a> 拿到的是浏览器默认样式,看着和卡片其它地方不是一套)',
159
+ );
160
+ }
161
+ // 四类问题各自成串,按原来的输出顺序(绑定 → 规则 → 借用 → 裸 <a>)拼在一起。
162
+ return { status: 'fail', notes, problems: [...bindings, ...problems, ...borrowed, ...anchors] };
163
+ }
119
164
 
120
- if (problems.length > 0) {
121
- console.error(`✖ 模块不许定义外壳前缀的 CSS 规则:${problems.length} 处`);
122
- for (const problem of problems) console.error(` · ${problem}`);
123
- console.error(' (wb-* 是全局类名:两份同名规则按注入顺序互相覆盖,用户看到的是随机的那个)');
124
- process.exit(1);
125
- }
165
+ if (modules.length === 0) {
166
+ return {
167
+ status: 'skip',
168
+ notes: ['没有模块包(没有声明 dsh.workbench 的包)—— 没有样式要查归属'],
169
+ problems: [],
170
+ };
171
+ }
126
172
 
127
- if (borrowed.length > 0) {
128
- console.error(`✖ 模块借用了契约里没有的 wb-* 类:${borrowed.length} 处`);
129
- for (const one of borrowed) console.error(` · ${one}`);
130
- console.error(
131
- ' (契约是本仓 board.css:模块只许借它里面有的类。没有的那个要么是拼错/改了名漏改,' +
132
- '要么本该进 board.css —— 空类名在真机上就是没样式,而且没人会报错)',
133
- );
134
- process.exit(1);
173
+ const contractNote =
174
+ contract === null
175
+ ? '契约文件不在(这次只查了"模块不许定义 wb-*")'
176
+ : `借用的类都在契约里(board.css + 外壳组件自己的样式表)`;
177
+ return {
178
+ status: 'pass',
179
+ notes: [`${modules.length} 个模块都没有定义外壳前缀(wb-*)的 CSS 规则;${contractNote}`],
180
+ problems: [],
181
+ };
135
182
  }
136
-
137
- const contractNote =
138
- contract === null
139
- ? '契约文件不在(这次只查了"模块不许定义 wb-*")'
140
- : `借用的类都在契约里(board.css + 外壳组件自己的样式表)`;
141
- console.log(
142
- `✔ 样式归属清楚:${modules.length} 个模块都没有定义外壳前缀(wb-*)的 CSS 规则;${contractNote}`,
143
- );
@@ -0,0 +1,27 @@
1
+ // `*.module.css` / `.css` 落到页面上的那一小段代码:构建时生成,随模块的 client.js 发出去。
2
+ //
3
+ // 为什么是"有就改写"、而不是"没有才插入"(2026-10-11 实测):
4
+ // 去重的 key(`<文件名>:<绝对路径>`)与类名哈希(`sha1(相对路径)`)都只跟**文件路径**有关,
5
+ // 跟 CSS 内容无关。于是热更新重新执行这段代码时,页面里**已经有一份同 key 的 <style>**:
6
+ // · 旧写法 `if (querySelector(...) === null) { 新建 + append }` → 直接跳过 → 新 CSS 写不进去
7
+ // (症状一:"改了没变");而**新增 / 改名的类**在新 bundle 里被引用、旧表里没有规则 →
8
+ // 那个元素掉样式(症状二);外壳重新挂载时它自己的 <style> 会挪到后面,同特异性的规则
9
+ // 谁赢也跟着变(症状三)。合起来就是用户嘴里的"有时候热更新样式会乱"。
10
+ // · 现在:有就改写 textContent,没有才插入 —— 幂等,且节点位置不动(顺序不漂),
11
+ // 顺手用"内容不同才写"避开每次热更新的空改写。
12
+ export function cssInjectionCode(key, css) {
13
+ return [
14
+ `const css = ${JSON.stringify(css)};`,
15
+ `const key = ${JSON.stringify(key)};`,
16
+ "if (typeof document !== 'undefined') {",
17
+ " const selector = 'style[data-dsh-wb-css=\"' + key + '\"]';",
18
+ " const applied = document.querySelector(selector);",
19
+ " const style = applied === null ? document.createElement('style') : applied;",
20
+ " if (applied === null) {",
21
+ " style.setAttribute('data-dsh-wb-css', key);",
22
+ " document.head.appendChild(style);",
23
+ " }",
24
+ " if (style.textContent !== css) style.textContent = css;",
25
+ "}",
26
+ ].join('\n');
27
+ }