@dimina-kit/compiler 0.0.1-dev.20260702182435 → 0.0.1-dev.20260703101348

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/README.md CHANGED
@@ -73,7 +73,7 @@ pool.dispose() // 用完终止 worker
73
73
 
74
74
  > **上面是最小接入代码,但浏览器环境有前置要求**(不满足会在别处报错,不像示例不完整):页面与 worker 开 COOP/COEP;`toolchainSetupURL` 能被 worker 运行时 import;esbuild browser ESM 与 `esbuild.wasm` 可作静态资源访问;npm 用户需显式装 `@oxc-parser/binding-wasm32-wasi`(`cpu:wasm32` 会被跳过);本包经 `file:`/link 引用时要配 Vite `server.fs.allow`。逐条见本节末尾的「故障排查」。
75
75
 
76
- `compile()` 内部:三个常驻 worker **各跑一个完整 stage**(logic / view / style),各自 seed 私有 memfs、各自 `setupCompile` + 编译该 stage,产物取并集回传;跨 `compile()` 复用同一批 worker(每次编译前自动 `resetCompilerState`)。多个 `compile()` 会自动串行(共享常驻 realm 不能并发)。
76
+ `compile()` 内部分两阶段:**先由一个 worker 跑一次 `setupCompile`**(分配 scope-hash id、构建 miniprogram_npm / app-config scaffold),把可序列化的 `{ pages, storeInfo, … }` bundle **广播给三个常驻 stage worker**(logic / view / style),各自 seed 私有 memfs、只跑 `compileStage`;产物 = scaffold ∪ 各 stage partial 回传。setup 只跑一次是**正确性要求**,不是优化:scope-hash(`data-v-XXXXX`)在 setup 阶段随机分配,若每个 stage realm 各自 setup,CSS 选择器与 render 产物的 id 会各摇一套、WXSS 整体失效(回归由 `test:pool-scopehash` 守护)。跨 `compile()` 复用同一批 worker(每次编译前自动 `resetCompilerState`)。多个 `compile()` 会自动串行(共享常驻 realm 不能并发)。
77
77
 
78
78
  > **连续触发(watch / 保存即编)要自己收敛:** pool 只保证串行,**不合并**——编译进行中再调 N 次 `compile()` 就排队 N 个全量编译,一个不少地跑完(正确性没问题,但白烧算力、垫高最后一次的延迟)。源码快照在**轮到该次编译派发时**才结构化克隆,不是调用时。触发侧应收敛成"至多一个在跑 + 一个待跑"(dirty-flag / debounce)。另外**整个应用共用一个 pool 单例**:多实例之间 realm 全隔离、不会互相污染,但结果没有跨实例顺序保证(旧编译可能晚于新编译返回,按到达顺序应用产物会拿旧盖新),还多付一份工具链常驻内存。
79
79
 
@@ -206,22 +206,27 @@ initToolchain(): Promise<void> // no-op,
206
206
  | `collectOutputs({ fs, targetPath })` | 遍历 fs,把 `targetPath` 前缀下的**所有**产物读成 `{ 相对路径: 内容 }`(无 stage 过滤;含 setup 写的公共产物如 `app-config.json`) |
207
207
  | `resetCompilerState()` | 清编译器模块级缓存;**常驻 realm/worker 复用前必调**,否则第二次编译被污染。它清缓存但**不**重新 seed fs |
208
208
 
209
- > **stage 并行的正确姿势**(pool 内部就是这么做):每个 stage worker **各自** `setupCompile`——因为它会往 fs 写 dist scaffold、`main/app-config.json`、`miniprogram_npm` 构建产物,只把 `storeInfo`/`pages` 发给 worker 而不重跑 setup 会丢这些文件。各 worker 用**私有 fs**,`collectOutputs` 收到的 partial 会含公共 setup 产物(内容相同,`Object.assign` 合并时相互覆盖无害)+ stage 产物。**同一 realm 内不要并发调用接缝**(它们改全局 fs shim 与 compiler env)——并行必须落在不同 worker/realm;`compileMiniApp` 已内部串行化,接缝由调用方自己串行。view stage 不要再按页/分包细拆,必须让单个 view stage 看完整 `pages`(app 级模块去重才成立)。
209
+ > **stage 并行的正确姿势**(pool 内部就是这么做):`setupCompile` **整个编译只跑一次**(一个 realm),把返回的 `{ pages, storeInfo }` 发给每个 stage worker `compileStage`——**绝不能每个 stage realm 各自 setup**:scope-hash(页面/组件的 `data-v-XXXXX` id)在 setup 阶段随机分配,各 realm 各摇一套会让 style 写进 CSS 的选择器与 view 写进 render 产物的 id 完全不相交,WXSS 全部落空(`test:pool-scopehash` 守护此契约)。setup 往它自己的 fs 写 dist scaffold、`main/app-config.json`、`miniprogram_npm` 构建产物——在 setup realm 用 `collectOutputs` 收走这份 scaffold 并入最终并集;各 stage worker 用**私有 fs**、不需要 scaffold(三 stage 只从 `workPath` 读源码,产物写方向才用 `targetPath`),其 partial 只含本 stage 产物。**同一 realm 内不要并发调用接缝**(它们改全局 fs shim 与 compiler env)——并行必须落在不同 worker/realm;`compileMiniApp` 已内部串行化,接缝由调用方自己串行。view stage 不要再按页/分包细拆,必须让单个 view stage 看完整 `pages`(app 级模块去重才成立)。
210
210
 
211
- **pool 的线程边界与编译流程**:主线程**只**派发 + 合并(不加载 wasm、不跑编译);`setupCompile compileStage collectOutputs` 整条流程都在**每个 stage worker(Web Worker 线程)内**跑。
211
+ **pool 的线程边界与编译流程**:主线程**只**派发 + 合并(不加载 wasm、不跑编译);编译分两阶段全在 worker 内跑——Phase 1 一个 worker `setupCompile`(分配 scope-hash id + 写 scaffold,收走 scaffold 与可序列化 bundle);Phase 2 三个 stage worker 各拿**同一份** bundle 跑 `compileStage → collectOutputs`。
212
212
 
213
213
  ```mermaid
214
214
  flowchart TD
215
215
  DISP["主线程 · pool.compile(files)<br/>只派发 + 合并,不加载 wasm、不跑编译"]
216
- DISP -->|"同一份 files 发给 3 个 worker"| WK
217
- subgraph WK["每个常驻 stage worker(Web Worker 线程) · 各跑一个 stage · 编译全在这里"]
216
+ DISP -->|"Phase 1 · files 发给 1 个 worker"| SETUP
217
+ subgraph SETUP["setup worker(Web Worker 线程) · 整个编译只跑一次"]
218
218
  direction TB
219
- SRC["seed 私有 memfs"] --> SU["setupCompile<br/>解析配置 + miniprogram_npm → storeInfo/pages,写 scaffold/app-config"]
220
- SU --> ST["compileStage(本 worker 的 stage)<br/>logic→main/logic.js · view→main/*.js · style→真实 cssnano+autoprefixer→*.css"]
219
+ SSRC["seed 私有 memfs"] --> SU["setupCompile<br/>解析配置 + miniprogram_npm → 分配 scope-hash id,写 scaffold/app-config"]
220
+ SU --> SC["collectOutputs scaffold<br/>+ 可序列化 bundle { pages, storeInfo, targetPath, appId, name }"]
221
+ end
222
+ SETUP -->|"Phase 2 · files + 同一份 bundle 广播给 3 个 worker"| WK
223
+ subgraph WK["每个常驻 stage worker(Web Worker 线程) · 各跑一个 stage"]
224
+ direction TB
225
+ SRC["seed 私有 memfs"] --> ST["compileStage(本 worker 的 stage,用 bundle 的 pages/storeInfo)<br/>logic→main/logic.js · view→main/*.js · style→真实 cssnano+autoprefixer→*.css"]
221
226
  ST --> CO["collectOutputs<br/>遍历 fs 收 targetPath 下产物 = 本 stage partial"]
222
- RS["resetCompilerState 清模块级缓存"] -.->|"每次编译前"| SU
227
+ RS["resetCompilerState 清模块级缓存"] -.->|"每次编译前"| ST
223
228
  end
224
- WK -->|"3 份 partial"| MERGE["主线程 · Object.assign 并集<br/>→ { appId, name, files }"]
229
+ WK -->|"3 份 partial"| MERGE["主线程 · scaffold ∪ 3 份 partial<br/>→ { appId, name, files }"]
225
230
  ```
226
231
 
227
232
  > 用 **core 的 `compileMiniApp`**(单线程)时,是同一条 `setupCompile → 三个 stage → collectOutputs`,只是在**一个 realm、你调用它的那个线程**里跑完——你在主线程调就阻塞主线程,放进自己的 worker 就在那个 worker 跑。
@@ -348,6 +353,7 @@ pnpm --filter @dimina-kit/compiler test:appid # appId fallback 守卫
348
353
  pnpm --filter @dimina-kit/compiler test:decompose # stage 接缝各自独立、产物互不相交
349
354
  pnpm --filter @dimina-kit/compiler test:realm-reuse # resetCompilerState 后复用 realm 与全新 realm 一致
350
355
  pnpm --filter @dimina-kit/compiler test:pool-node # Node pool 与 dmcc 冷+热逐字节等价(含 sourcemap 与返回值)
356
+ pnpm --filter @dimina-kit/compiler test:pool-scopehash # 浏览器 pool 跨 stage scope-hash 内容级一致(CSS data-v-* ⊆ render Module id;文件集校验看不见这层)
351
357
  ```
352
358
 
353
359
  `pool` 的浏览器端到端验证在 `dimina-web-client`(`npm run test:pool`,Playwright 驱动,产物与单线程逐结构一致)。`pool-node` 的宿主端验证在 `@dimina-kit/devkit` 测试套件(真实 fork + `openProject`)。
@@ -32179,9 +32179,9 @@ var require_agents4 = __commonJS({
32179
32179
  }
32180
32180
  });
32181
32181
 
32182
- // ../../dimina/fe/node_modules/.pnpm/electron-to-chromium@1.5.384/node_modules/electron-to-chromium/versions.js
32182
+ // ../../dimina/fe/node_modules/.pnpm/electron-to-chromium@1.5.385/node_modules/electron-to-chromium/versions.js
32183
32183
  var require_versions2 = __commonJS({
32184
- "../../dimina/fe/node_modules/.pnpm/electron-to-chromium@1.5.384/node_modules/electron-to-chromium/versions.js"(exports, module) {
32184
+ "../../dimina/fe/node_modules/.pnpm/electron-to-chromium@1.5.385/node_modules/electron-to-chromium/versions.js"(exports, module) {
32185
32185
  module.exports = {
32186
32186
  "0.20": "39",
32187
32187
  "0.21": "41",
@@ -32438,7 +32438,8 @@ var require_versions2 = __commonJS({
32438
32438
  "42.3": "148",
32439
32439
  "42.4": "148",
32440
32440
  "42.5": "148",
32441
- "43.0": "150"
32441
+ "43.0": "150",
32442
+ "44.0": "151"
32442
32443
  };
32443
32444
  }
32444
32445
  });
@@ -67,17 +67,19 @@ function createCompilerPool(options = {}) {
67
67
  throw new Error("[compiler] pool.compile expects { files: { relPath: content }, workPath? } (or a non-empty files map)");
68
68
  }
69
69
  const workPath = input.workPath || defaultWorkPath;
70
- const parts = await Promise.all(workers.map((x) => x.send({ type: "compile-subset", files, workPath, stages: [x.stage] })));
71
- const merged = {};
72
- let appId, name;
70
+ const s = await workers[0].send({ type: "setup", files, workPath });
71
+ if (!s || s.type === "error") {
72
+ throw new Error(s && s.error ? s.error : `[compiler] setup phase failed in stage '${workers[0].stage}' worker`);
73
+ }
74
+ const { bundle, scaffold } = s;
75
+ const parts = await Promise.all(workers.map((x) => x.send({ type: "compile-subset", files, workPath, stages: [x.stage], bundle })));
76
+ const merged = { ...scaffold || {} };
73
77
  for (let i = 0; i < parts.length; i++) {
74
78
  const pr = parts[i];
75
79
  if (!pr || pr.type === "error") throw new Error(pr && pr.error ? pr.error : `[compiler] stage '${workers[i].stage}' worker error`);
76
- appId = pr.result.appId;
77
- name = pr.result.name;
78
80
  Object.assign(merged, pr.result.files);
79
81
  }
80
- return { appId, name, files: merged };
82
+ return { appId: bundle.appId, name: bundle.name, files: merged };
81
83
  });
82
84
  chain = run.then(() => {
83
85
  }, () => {
@@ -46145,9 +46145,9 @@ var require_agents4 = __commonJS({
46145
46145
  }
46146
46146
  });
46147
46147
 
46148
- // ../../dimina/fe/node_modules/.pnpm/electron-to-chromium@1.5.384/node_modules/electron-to-chromium/versions.js
46148
+ // ../../dimina/fe/node_modules/.pnpm/electron-to-chromium@1.5.385/node_modules/electron-to-chromium/versions.js
46149
46149
  var require_versions2 = __commonJS({
46150
- "../../dimina/fe/node_modules/.pnpm/electron-to-chromium@1.5.384/node_modules/electron-to-chromium/versions.js"(exports, module) {
46150
+ "../../dimina/fe/node_modules/.pnpm/electron-to-chromium@1.5.385/node_modules/electron-to-chromium/versions.js"(exports, module) {
46151
46151
  module.exports = {
46152
46152
  "0.20": "39",
46153
46153
  "0.21": "41",
@@ -46404,7 +46404,8 @@ var require_versions2 = __commonJS({
46404
46404
  "42.3": "148",
46405
46405
  "42.4": "148",
46406
46406
  "42.5": "148",
46407
- "43.0": "150"
46407
+ "43.0": "150",
46408
+ "44.0": "151"
46408
46409
  };
46409
46410
  }
46410
46411
  });
@@ -290972,17 +290973,44 @@ function needsToolchain(stages) {
290972
290973
  function freshFs(files, workPath) {
290973
290974
  return (0, import_memfs.createFsFromVolume)(import_memfs.Volume.fromJSON(files, workPath));
290974
290975
  }
290975
- async function compileSubset(files, workPath, stages) {
290976
+ async function runSetup(files, workPath) {
290976
290977
  const fs2 = freshFs(files, workPath);
290977
290978
  resetCompilerState();
290978
290979
  const ctx = await setupCompile({ fs: fs2, workPath });
290979
- for (const stage of stages) {
290980
- await compileStage({ stage, pages: ctx.pages, storeInfo: ctx.storeInfo, fs: fs2 });
290981
- }
290982
290980
  const map4 = collectOutputs({ fs: fs2, targetPath: ctx.targetPath });
290981
+ const scaffold = {};
290982
+ for (const k of Object.keys(map4)) if (map4[k] != null) scaffold[k] = map4[k];
290983
+ const bundle = {
290984
+ pages: ctx.pages,
290985
+ storeInfo: ctx.storeInfo,
290986
+ targetPath: ctx.targetPath,
290987
+ appId: ctx.appId,
290988
+ name: ctx.name
290989
+ };
290990
+ return { bundle, scaffold };
290991
+ }
290992
+ async function compileSubset(files, workPath, stages, bundle) {
290993
+ const fs2 = freshFs(files, workPath);
290994
+ resetCompilerState();
290995
+ let appId, name, targetPath;
290996
+ if (bundle) {
290997
+ for (const stage of stages) {
290998
+ await compileStage({ stage, pages: bundle.pages, storeInfo: bundle.storeInfo, fs: fs2 });
290999
+ }
291000
+ ;
291001
+ ({ appId, name, targetPath } = bundle);
291002
+ } else {
291003
+ const ctx = await setupCompile({ fs: fs2, workPath });
291004
+ for (const stage of stages) {
291005
+ await compileStage({ stage, pages: ctx.pages, storeInfo: ctx.storeInfo, fs: fs2 });
291006
+ }
291007
+ ;
291008
+ ({ appId, name, targetPath } = ctx);
291009
+ }
291010
+ const map4 = collectOutputs({ fs: fs2, targetPath });
290983
291011
  const out = {};
290984
291012
  for (const k of Object.keys(map4)) if (map4[k] != null) out[k] = map4[k];
290985
- return { appId: ctx.appId, name: ctx.name, files: out };
291013
+ return { appId, name, files: out };
290986
291014
  }
290987
291015
  self.onmessage = async (e) => {
290988
291016
  const { type } = e.data || {};
@@ -290994,13 +291022,22 @@ self.onmessage = async (e) => {
290994
291022
  self.postMessage({ type: "ready", ms: Math.round(performance.now() - t0) });
290995
291023
  return;
290996
291024
  }
291025
+ if (type === "setup") {
291026
+ const { files, workPath = "/work", toolchainSetupURL } = e.data;
291027
+ if (toolchainSetupURL) toolchainURL = toolchainSetupURL;
291028
+ await ensureToolchain();
291029
+ const t = performance.now();
291030
+ const { bundle, scaffold } = await runSetup(files, workPath);
291031
+ self.postMessage({ type: "setup-done", bundle, scaffold, ms: Math.round(performance.now() - t) });
291032
+ return;
291033
+ }
290997
291034
  if (type === "compile-subset") {
290998
- const { files, workPath = "/work", stages = ["logic", "view", "style"], toolchainSetupURL } = e.data;
291035
+ const { files, workPath = "/work", stages = ["logic", "view", "style"], bundle, toolchainSetupURL } = e.data;
290999
291036
  if (toolchainSetupURL) toolchainURL = toolchainSetupURL;
291000
291037
  if (needsToolchain(stages)) await ensureToolchain();
291001
291038
  const warm = !!toolchainReady;
291002
291039
  const t = performance.now();
291003
- const result2 = await compileSubset(files, workPath, stages);
291040
+ const result2 = await compileSubset(files, workPath, stages, bundle);
291004
291041
  self.postMessage({ type: "done", result: result2, ms: Math.round(performance.now() - t), warm });
291005
291042
  return;
291006
291043
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dimina-kit/compiler",
3
- "version": "0.0.1-dev.20260702182435",
3
+ "version": "0.0.1-dev.20260703101348",
4
4
  "description": "dmcc compiler bundles (browser + node) that drive @dimina/compiler against a caller-injected node:fs replacement (no bundled fs)",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -81,6 +81,7 @@
81
81
  "test:decompose": "node scripts/build-compiler.js node && node --import ./scripts/register-kit.js scripts/test-decompose.js",
82
82
  "test:realm-reuse": "node scripts/build-compiler.js node && node --import ./scripts/register-kit.js scripts/test-realm-reuse.js",
83
83
  "test:pool-node": "node scripts/build-compiler.js node && node --import ./scripts/register-kit.js scripts/test-pool-node.js",
84
+ "test:pool-scopehash": "node scripts/build-compiler.js node && node --import ./scripts/register-kit.js scripts/test-pool-scopehash.js",
84
85
  "test:pool-hardening": "node scripts/build-compiler.js node && node scripts/test-pool-hardening.js",
85
86
  "test:npm-scan": "node scripts/build-compiler.js node && node --import ./scripts/register-kit.js scripts/test-npm-scan.js",
86
87
  "test:stage-toolchain": "node scripts/build-compiler.js browser && node scripts/test-stage-toolchain.js"
@@ -0,0 +1,239 @@
1
+ // Content-level scope-hash consistency check for the stage-parallel pool.
2
+ //
3
+ // The pool's structural-equivalence tests (test:pool-node) only compare file
4
+ // NAME/length multisets, so they cannot see whether the `data-v-XXXXX` scope
5
+ // hashes baked into the CSS (`[data-v-X]` selectors) actually match the ones
6
+ // baked into the compiled render templates (`.js`, `scopeId: data-v-X`). If the
7
+ // view stage and the style stage allocate ids in DIFFERENT realms, their hashes
8
+ // diverge and every WXSS rule targets a selector that never appears in the DOM —
9
+ // styles silently stop working while the file lists still match.
10
+ //
11
+ // This script models the browser pool (src/pool.js + src/stage-worker.js) in
12
+ // Node: each stage compiles in its OWN fresh memfs, exactly as a separate Web
13
+ // Worker realm would. It runs the OLD per-worker-setup path and the FIXED
14
+ // shared-setup path and asserts, at the content level, that every scope hash
15
+ // used in CSS also appears in the render output.
16
+ import { readdirSync, readFileSync, statSync } from 'node:fs'
17
+ import { fileURLToPath } from 'node:url'
18
+ import path from 'node:path'
19
+ import { Volume, createFsFromVolume } from 'memfs'
20
+
21
+ const APP = process.env.APP_DIR
22
+ || fileURLToPath(new URL('../../../dimina/fe/example/vant', import.meta.url))
23
+
24
+ const TEXT_EXT = new Set([
25
+ '.json', '.js', '.ts', '.wxml', '.ddml', '.wxss', '.ddss', '.less',
26
+ '.scss', '.sass', '.wxs', '.dds', '.css',
27
+ ])
28
+ function readDir(dir, baseDir, out) {
29
+ for (const name of readdirSync(dir)) {
30
+ if (name === 'node_modules' || name === '.git') continue
31
+ const full = path.join(dir, name)
32
+ if (statSync(full).isDirectory()) readDir(full, baseDir, out)
33
+ else if (TEXT_EXT.has(path.extname(name).toLowerCase())) {
34
+ out[path.relative(baseDir, full).split(path.sep).join('/')] = readFileSync(full, 'utf8')
35
+ }
36
+ }
37
+ }
38
+
39
+ const seed = {}
40
+ readDir(APP, APP, seed)
41
+ console.log(`[seed] ${Object.keys(seed).length} text files from ${APP}`)
42
+
43
+ const workPath = '/work'
44
+ const core = await import('../dist/compile-core.node.js')
45
+ const { setupCompile, compileStage, collectOutputs, compileMiniApp, resetCompilerState } = core
46
+ // The pool orchestration under test is the real source module (pure ESM, no
47
+ // browser-only imports), driven below through a mock worker backed by node core.
48
+ const { createCompilerPool } = await import('../src/pool.js')
49
+ const fileSet = (m) => Object.keys(m).sort().join('\n')
50
+
51
+ // Every realm in the real pool (setup worker AND each stage worker) calls
52
+ // resetCompilerState() before it works (stage-worker.js). In this single-process
53
+ // model, separate Web Worker realms are simulated by calling it before each
54
+ // realm-equivalent operation so module-level caches never leak between them.
55
+ const realm = () => resetCompilerState()
56
+
57
+ const STAGES = ['logic', 'view', 'style']
58
+ const freshFs = () => createFsFromVolume(Volume.fromJSON(seed, workPath))
59
+ const clean = (m) => { for (const k of Object.keys(m)) if (m[k] == null) delete m[k]; return m }
60
+
61
+ // CSS carries the scope hash as `[data-v-XXXXX]`; the compiled render carries
62
+ // the SAME 5-char id bare in each `Module({ …, id:"XXXXX", … })` (the runtime
63
+ // prepends `data-v-`). Extract each with its own pattern so we compare the
64
+ // cross-file linkage the runtime actually relies on.
65
+ function hashesIn(files, pred, re) {
66
+ const set = new Set()
67
+ for (const k of Object.keys(files)) {
68
+ if (!pred(k)) continue
69
+ const v = files[k]
70
+ if (typeof v !== 'string') continue
71
+ let m
72
+ re.lastIndex = 0
73
+ while ((m = re.exec(v))) set.add(m[1])
74
+ }
75
+ return set
76
+ }
77
+ const CSS_RE = /data-v-([a-z0-9]{5})/g
78
+ const RENDER_RE = /\bid:\s*["']([a-z0-9]{5})["']/g
79
+ const isCss = (k) => k.endsWith('.css')
80
+ const isRender = (k) => k.endsWith('.js') && k !== 'main/logic.js' && !k.endsWith('/logic.js')
81
+
82
+ // Report the CSS-vs-render scope-hash relationship for a merged output map.
83
+ function report(label, files) {
84
+ const css = hashesIn(files, isCss, CSS_RE)
85
+ const js = hashesIn(files, isRender, RENDER_RE)
86
+ const orphanCss = [...css].filter((h) => !js.has(h)) // CSS selectors that target nothing rendered
87
+ const matched = [...css].filter((h) => js.has(h))
88
+ console.log(`\n[${label}] css-scope-hashes=${css.size} render-scope-hashes=${js.size} `
89
+ + `matched=${matched.length} orphanCSS=${orphanCss.length}`)
90
+ if (orphanCss.length) {
91
+ console.log(` orphan CSS hashes (in CSS, absent from render): ${orphanCss.slice(0, 10).join(', ')}${orphanCss.length > 10 ? ' …' : ''}`)
92
+ }
93
+ return { css, js, orphanCss, matched }
94
+ }
95
+
96
+ // --- MODEL A: OLD browser pool — each stage runs setupCompile in its own realm ---
97
+ async function buildPerStageSetup() {
98
+ const merged = {}
99
+ for (const stage of STAGES) {
100
+ realm()
101
+ const fs = freshFs()
102
+ const ctx = await setupCompile({ fs, workPath }) // independent id allocation per stage
103
+ await compileStage({ stage, pages: ctx.pages, storeInfo: ctx.storeInfo, fs })
104
+ Object.assign(merged, clean(collectOutputs({ fs, targetPath: ctx.targetPath })))
105
+ }
106
+ return merged
107
+ }
108
+
109
+ // --- MODEL B: FIXED pool — setup ONCE, broadcast the bundle across stage realms ---
110
+ // This models the fixed browser pool exactly (mirrors the working Node disk pool):
111
+ // one realm runs setupCompile (id allocation + npm/app-config scaffold); the shared
112
+ // `{ pages, storeInfo }` bundle is broadcast to every stage worker, which only runs
113
+ // compileStage against it. Setup scaffold (app-config.json + npm) is merged in.
114
+ async function buildSharedSetup() {
115
+ realm()
116
+ const setupFs = freshFs()
117
+ const ctx = await setupCompile({ fs: setupFs, workPath })
118
+ const bundle = { pages: ctx.pages, storeInfo: ctx.storeInfo, targetPath: ctx.targetPath, appId: ctx.appId, name: ctx.name }
119
+ const merged = clean(collectOutputs({ fs: setupFs, targetPath: ctx.targetPath })) // setup scaffold
120
+ for (const stage of STAGES) {
121
+ realm()
122
+ const fs = freshFs()
123
+ // structuredClone mimics the structured-clone every postMessage bundle undergoes.
124
+ const b = structuredClone(bundle)
125
+ await compileStage({ stage, pages: b.pages, storeInfo: b.storeInfo, fs })
126
+ Object.assign(merged, clean(collectOutputs({ fs, targetPath: b.targetPath })))
127
+ }
128
+ return merged
129
+ }
130
+
131
+ // --- MODEL C: the REAL src/pool.js orchestration, driven by a mock worker ---
132
+ // Exercises the actual pool code (setup phase → broadcast bundle → merge scaffold)
133
+ // that the browser ships, but backs each "worker" with node core so no wasm/Web
134
+ // Worker is needed. Separate Web Worker realms are modeled by serializing every
135
+ // handler through one chain (isolated module state) + resetCompilerState per op —
136
+ // so this proves the pool's WIRING keeps scope hashes consistent, not just the
137
+ // compile-core sequence MODEL B covers.
138
+ // One shared chain across ALL mock workers: a real Web Worker is its own realm with
139
+ // its own compile-core module (fs backend, env singletons), but here every mock
140
+ // worker shares this process's single node core module. Serializing all handlers
141
+ // through one chain models that realm isolation (only one core op runs at a time),
142
+ // so pool.js's parallel compile-subset dispatch can't cross-corrupt the shared fs.
143
+ let mockChain = Promise.resolve()
144
+ function makeMockWorker() {
145
+ const w = { onmessage: null, onerror: null, terminate() {} }
146
+ w.postMessage = (msg) => {
147
+ mockChain = mockChain.then(async () => {
148
+ let reply
149
+ try {
150
+ if (msg.type === 'warmup') {
151
+ reply = { type: 'ready', ms: 0 }
152
+ } else if (msg.type === 'setup') {
153
+ realm()
154
+ const fs = createFsFromVolume(Volume.fromJSON(msg.files, msg.workPath))
155
+ const ctx = await setupCompile({ fs, workPath: msg.workPath })
156
+ reply = {
157
+ type: 'setup-done',
158
+ bundle: { pages: ctx.pages, storeInfo: ctx.storeInfo, targetPath: ctx.targetPath, appId: ctx.appId, name: ctx.name },
159
+ scaffold: clean(collectOutputs({ fs, targetPath: ctx.targetPath })),
160
+ }
161
+ } else if (msg.type === 'compile-subset') {
162
+ realm()
163
+ const fs = createFsFromVolume(Volume.fromJSON(msg.files, msg.workPath))
164
+ const b = structuredClone(msg.bundle) // mimic postMessage structured clone
165
+ for (const stage of msg.stages) await compileStage({ stage, pages: b.pages, storeInfo: b.storeInfo, fs })
166
+ reply = { type: 'done', result: { appId: b.appId, name: b.name, files: clean(collectOutputs({ fs, targetPath: b.targetPath })) } }
167
+ }
168
+ } catch (e) {
169
+ reply = { type: 'error', error: String((e && e.stack) || e) }
170
+ }
171
+ queueMicrotask(() => { if (w.onmessage) w.onmessage({ data: reply }) })
172
+ })
173
+ }
174
+ return w
175
+ }
176
+ async function buildViaPool() {
177
+ const pool = createCompilerPool({ createWorker: makeMockWorker, toolchainSetupURL: 'noop://toolchain' })
178
+ const res = await pool.compile({ files: seed, workPath })
179
+ pool.dispose()
180
+ return res
181
+ }
182
+
183
+ // --- ground truth: single-realm compileMiniApp ---
184
+ realm()
185
+ const inline = clean((await compileMiniApp({ fs: freshFs(), workPath })).files)
186
+ const gt = report('inline (single realm, ground truth)', inline)
187
+
188
+ const modelA = await buildPerStageSetup()
189
+ const rA = report('MODEL A — per-stage independent setup (current browser pool)', modelA)
190
+
191
+ const modelB = await buildSharedSetup()
192
+ const rB = report('MODEL B — shared setup bundle (fix)', modelB)
193
+
194
+ const poolRes = await buildViaPool()
195
+ const modelC = clean(poolRes.files)
196
+ const rC = report('MODEL C — real src/pool.js orchestration (mock worker)', modelC)
197
+
198
+ let failed = false
199
+ const fail = (m) => { failed = true; console.error(`❌ ${m}`) }
200
+ const pass = (m) => console.log(`✅ ${m}`)
201
+
202
+ // Ground truth must be self-consistent (every CSS hash targets a rendered element).
203
+ if (gt.orphanCss.length) fail(`ground truth has ${gt.orphanCss.length} orphan CSS hashes — test harness bug`)
204
+ else pass('ground truth: every CSS scope hash appears in the render output')
205
+
206
+ // Model A is EXPECTED to be broken — assert it reproduces the bug.
207
+ if (rA.orphanCss.length > 0 && rA.matched.length === 0) {
208
+ pass(`REPRODUCED: per-stage setup yields ${rA.orphanCss.length} orphan CSS hashes, 0 matched — WXSS fully broken`)
209
+ } else if (rA.orphanCss.length > 0) {
210
+ pass(`REPRODUCED (partial): per-stage setup yields ${rA.orphanCss.length} orphan CSS hashes`)
211
+ } else {
212
+ fail('per-stage setup did NOT reproduce the mismatch (bug model invalid)')
213
+ }
214
+
215
+ // Model B (fix) must be fully consistent AND structurally complete.
216
+ if (rB.orphanCss.length === 0 && rB.css.size === gt.css.size) {
217
+ pass(`FIX VERIFIED: shared setup yields 0 orphan CSS hashes, ${rB.matched.length} matched (== ground truth ${gt.css.size})`)
218
+ } else {
219
+ fail(`shared setup still has ${rB.orphanCss.length} orphan CSS hashes (css=${rB.css.size} gt=${gt.css.size})`)
220
+ }
221
+ // Both models must emit the SAME file set as the single-realm ground truth — this is
222
+ // what the structural test already checks, and why it stays green while WXSS is broken.
223
+ if (fileSet(modelA) !== fileSet(inline)) fail('MODEL A file set differs from ground truth (unexpected)')
224
+ else pass('MODEL A file set == ground truth (this is exactly why the name/length structural test passes while WXSS is broken)')
225
+ if (fileSet(modelB) !== fileSet(inline)) fail('MODEL B (fix) file set differs from ground truth')
226
+ else pass('MODEL B (fix) file set == ground truth')
227
+
228
+ // Model C (real pool.js code) must be consistent + complete, and report the right appId.
229
+ if (rC.orphanCss.length === 0 && rC.css.size === gt.css.size) {
230
+ pass(`POOL WIRING VERIFIED: src/pool.js yields 0 orphan CSS hashes, ${rC.matched.length} matched (== ground truth ${gt.css.size})`)
231
+ } else {
232
+ fail(`src/pool.js still has ${rC.orphanCss.length} orphan CSS hashes (css=${rC.css.size} gt=${gt.css.size})`)
233
+ }
234
+ if (fileSet(modelC) !== fileSet(inline)) fail('MODEL C (pool.js) file set differs from ground truth')
235
+ else pass('MODEL C (pool.js) file set == ground truth')
236
+ if (!poolRes.appId) fail('MODEL C (pool.js) returned no appId')
237
+
238
+ console.log(failed ? '\n❌ FAIL' : '\n✅ PASS')
239
+ process.exit(failed ? 1 : 0)
package/src/pool.js CHANGED
@@ -97,19 +97,33 @@ export function createCompilerPool(options = {}) {
97
97
  throw new Error('[compiler] pool.compile expects { files: { relPath: content }, workPath? } (or a non-empty files map)')
98
98
  }
99
99
  const workPath = input.workPath || defaultWorkPath
100
+
101
+ // Phase 1 — one worker runs setup ONCE: it allocates the scope-hash ids
102
+ // (page + component data-v-XXXXX) and builds miniprogram_npm/app-config.json.
103
+ // Broadcasting this single bundle to every stage is REQUIRED for correctness:
104
+ // each stage runs in its own realm, and if each ran its own setup it would roll
105
+ // independent random uuids, so the CSS `[data-v-X]` selectors would never match
106
+ // the render `Module id` and every WXSS rule would target nothing (regression
107
+ // guarded by scripts/test-pool-scopehash.js). This mirrors the Node disk pool,
108
+ // which likewise sets up once and fans the same { pages, storeInfo } out.
109
+ const s = await workers[0].send({ type: 'setup', files, workPath })
110
+ if (!s || s.type === 'error') {
111
+ throw new Error(s && s.error ? s.error : `[compiler] setup phase failed in stage '${workers[0].stage}' worker`)
112
+ }
113
+ const { bundle, scaffold } = s
114
+
115
+ // Phase 2 — every stage compiles in parallel against the SHARED bundle. The
116
+ // non-stage scaffold (app-config.json + npm, produced once) seeds the union.
100
117
  const parts = await Promise.all(workers.map((x) =>
101
- x.send({ type: 'compile-subset', files, workPath, stages: [x.stage] })))
102
- const merged = {}
103
- let appId, name
118
+ x.send({ type: 'compile-subset', files, workPath, stages: [x.stage], bundle })))
119
+ const merged = { ...(scaffold || {}) }
104
120
  for (let i = 0; i < parts.length; i++) {
105
121
  const pr = parts[i]
106
122
  // pr.error carries the worker's real error string (message + stack) — surface it.
107
123
  if (!pr || pr.type === 'error') throw new Error(pr && pr.error ? pr.error : `[compiler] stage '${workers[i].stage}' worker error`)
108
- appId = pr.result.appId
109
- name = pr.result.name
110
124
  Object.assign(merged, pr.result.files) // stages write disjoint files -> clean union
111
125
  }
112
- return { appId, name, files: merged }
126
+ return { appId: bundle.appId, name: bundle.name, files: merged }
113
127
  })
114
128
  // keep the chain alive regardless of this compile's outcome
115
129
  chain = run.then(() => {}, () => {})
@@ -58,21 +58,61 @@ function freshFs(files, workPath) {
58
58
  return createFsFromVolume(Volume.fromJSON(files, workPath))
59
59
  }
60
60
 
61
+ // Run setupCompile ONCE for a compile: parse config, allocate the scope-hash ids
62
+ // (page + component `data-v-XXXXX`), scaffold app-config.json + miniprogram_npm.
63
+ // Returns the SERIALIZABLE id bundle every stage worker must share, plus the
64
+ // non-stage scaffold files. Sharing this one bundle across the per-stage realms is
65
+ // what keeps the CSS `[data-v-X]` selectors and the render `Module id` in agreement:
66
+ // if each stage ran its own setupCompile it would roll its own random uuids and every
67
+ // WXSS rule would target a selector that never renders (see scripts/test-pool-scopehash.js).
68
+ async function runSetup(files, workPath) {
69
+ const fs = freshFs(files, workPath)
70
+ resetCompilerState()
71
+ const ctx = await setupCompile({ fs, workPath })
72
+ const map = collectOutputs({ fs, targetPath: ctx.targetPath })
73
+ const scaffold = {}
74
+ for (const k of Object.keys(map)) if (map[k] != null) scaffold[k] = map[k]
75
+ const bundle = {
76
+ pages: ctx.pages,
77
+ storeInfo: ctx.storeInfo,
78
+ targetPath: ctx.targetPath,
79
+ appId: ctx.appId,
80
+ name: ctx.name,
81
+ }
82
+ return { bundle, scaffold }
83
+ }
84
+
61
85
  // Compile only the requested stages against a fresh memfs seeded with the source.
62
86
  // resetCompilerState() clears the compiler's module-level caches so this warm realm
63
87
  // stays correct across compiles. Stages write disjoint products; we return this
64
88
  // worker's subset and the pool unions them.
65
- async function compileSubset(files, workPath, stages) {
89
+ //
90
+ // With a `bundle` (from runSetup), the stage reuses the coordinator's shared ids
91
+ // (mirroring the Node disk pool, which broadcasts the same { pages, storeInfo } to
92
+ // every stage worker) instead of running its own setupCompile — the fix for the
93
+ // cross-stage scope-hash mismatch. Stages read source from `workPath` and write
94
+ // disjoint products; they never read the setup scaffold, so it is not seeded here.
95
+ // Without a bundle the worker stays self-contained (single-worker / legacy callers).
96
+ async function compileSubset(files, workPath, stages, bundle) {
66
97
  const fs = freshFs(files, workPath)
67
98
  resetCompilerState()
68
- const ctx = await setupCompile({ fs, workPath })
69
- for (const stage of stages) {
70
- await compileStage({ stage, pages: ctx.pages, storeInfo: ctx.storeInfo, fs })
99
+ let appId, name, targetPath
100
+ if (bundle) {
101
+ for (const stage of stages) {
102
+ await compileStage({ stage, pages: bundle.pages, storeInfo: bundle.storeInfo, fs })
103
+ }
104
+ ;({ appId, name, targetPath } = bundle)
105
+ } else {
106
+ const ctx = await setupCompile({ fs, workPath })
107
+ for (const stage of stages) {
108
+ await compileStage({ stage, pages: ctx.pages, storeInfo: ctx.storeInfo, fs })
109
+ }
110
+ ;({ appId, name, targetPath } = ctx)
71
111
  }
72
- const map = collectOutputs({ fs, targetPath: ctx.targetPath })
112
+ const map = collectOutputs({ fs, targetPath })
73
113
  const out = {}
74
114
  for (const k of Object.keys(map)) if (map[k] != null) out[k] = map[k]
75
- return { appId: ctx.appId, name: ctx.name, files: out }
115
+ return { appId, name, files: out }
76
116
  }
77
117
 
78
118
  self.onmessage = async (e) => {
@@ -88,13 +128,25 @@ self.onmessage = async (e) => {
88
128
  self.postMessage({ type: 'ready', ms: Math.round(performance.now() - t0) })
89
129
  return
90
130
  }
131
+ if (type === 'setup') {
132
+ // Coordinator phase: one worker parses config, allocates the shared scope-hash
133
+ // ids and builds miniprogram_npm once. setupCompile's npm build can invoke the
134
+ // wasm toolchain, so ensure it's loaded regardless of this worker's own stage.
135
+ const { files, workPath = '/work', toolchainSetupURL } = e.data
136
+ if (toolchainSetupURL) toolchainURL = toolchainSetupURL
137
+ await ensureToolchain()
138
+ const t = performance.now()
139
+ const { bundle, scaffold } = await runSetup(files, workPath)
140
+ self.postMessage({ type: 'setup-done', bundle, scaffold, ms: Math.round(performance.now() - t) })
141
+ return
142
+ }
91
143
  if (type === 'compile-subset') {
92
- const { files, workPath = '/work', stages = ['logic', 'view', 'style'], toolchainSetupURL } = e.data
144
+ const { files, workPath = '/work', stages = ['logic', 'view', 'style'], bundle, toolchainSetupURL } = e.data
93
145
  if (toolchainSetupURL) toolchainURL = toolchainSetupURL
94
146
  if (needsToolchain(stages)) await ensureToolchain()
95
147
  const warm = !!toolchainReady
96
148
  const t = performance.now()
97
- const result = await compileSubset(files, workPath, stages)
149
+ const result = await compileSubset(files, workPath, stages, bundle)
98
150
  self.postMessage({ type: 'done', result, ms: Math.round(performance.now() - t), warm })
99
151
  return
100
152
  }