@dimina-kit/compiler 0.0.1-dev.20260702173719

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 (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +363 -0
  3. package/dist/compile-core.browser.js +281945 -0
  4. package/dist/compile-core.node.js +3702 -0
  5. package/dist/pool.browser.js +99 -0
  6. package/dist/pool.node.js +3743 -0
  7. package/dist/stage-worker.browser.js +291085 -0
  8. package/dist/stage-worker.node.js +3097 -0
  9. package/dist/toolchain.browser.js +30 -0
  10. package/package.json +87 -0
  11. package/scripts/build-compiler.js +207 -0
  12. package/scripts/gen-bench-fixture.js +24 -0
  13. package/scripts/kit-resolve-hook.js +35 -0
  14. package/scripts/register-kit.js +2 -0
  15. package/scripts/test-appid-fallback.js +114 -0
  16. package/scripts/test-decompose.js +90 -0
  17. package/scripts/test-hardening.js +78 -0
  18. package/scripts/test-node.js +69 -0
  19. package/scripts/test-npm-scan.js +164 -0
  20. package/scripts/test-pool-hardening.js +65 -0
  21. package/scripts/test-pool-node.js +117 -0
  22. package/scripts/test-realm-reuse.js +77 -0
  23. package/src/browser-entry.js +25 -0
  24. package/src/compile-core.js +428 -0
  25. package/src/pool-node.js +170 -0
  26. package/src/pool.js +122 -0
  27. package/src/shims/esbuild-wasm.js +19 -0
  28. package/src/shims/fs-promises.js +20 -0
  29. package/src/shims/fs.js +59 -0
  30. package/src/shims/less.js +9 -0
  31. package/src/shims/os.js +11 -0
  32. package/src/shims/oxc-parser.js +21 -0
  33. package/src/shims/oxc-walker.js +29 -0
  34. package/src/shims/postcss-noop-plugin.js +9 -0
  35. package/src/shims/process.js +20 -0
  36. package/src/shims/url.js +4 -0
  37. package/src/shims/worker_threads.js +10 -0
  38. package/src/stage-worker-node.js +41 -0
  39. package/src/stage-worker.js +88 -0
  40. package/src/toolchain.js +49 -0
@@ -0,0 +1,78 @@
1
+ // Regression tests for the adversarial-review hardening:
2
+ // #1 concurrent compiles in one realm must not cross fs / env singletons
3
+ // #2 ensureAppIdFs must not clobber malformed JSON, must reject bad appid
4
+ // #5 the fs contract is validated up-front with a clear error
5
+ // memfs stands in as the downstream fs.
6
+ import assert from 'node:assert/strict'
7
+ import { Volume, createFsFromVolume } from 'memfs'
8
+ import { compileMiniApp } from '../dist/compile-core.node.js'
9
+
10
+ const WP = '/work'
11
+ const BASE = {
12
+ 'app.js': 'App({})',
13
+ 'app.json': JSON.stringify({ pages: ['pages/index'] }),
14
+ 'pages/index.js': 'Page({})',
15
+ 'pages/index.wxml': '<view>hi</view>',
16
+ }
17
+ const volOf = (files) => Volume.fromJSON(files, WP)
18
+ const fsOf = (files) => createFsFromVolume(volOf(files))
19
+
20
+ let pass = 0
21
+ let fail = 0
22
+ const check = async (label, fn) => {
23
+ try { await fn(); console.log(` ✅ ${label}`); pass++ }
24
+ catch (e) { console.error(` ❌ ${label}\n ${e.message}`); fail++ }
25
+ }
26
+
27
+ // #5 — a partial fs is rejected up front, naming the missing method.
28
+ await check('missing fs method throws up front, naming it', async () => {
29
+ const partial = { readFileSync() {}, readdirSync() {} } // lacks writeFileSync etc.
30
+ await assert.rejects(
31
+ () => compileMiniApp({ fs: partial, workPath: WP }),
32
+ /missing required method\(s\):.*writeFileSync/,
33
+ )
34
+ })
35
+
36
+ // #2a — malformed project.config.json is NOT overwritten; a clear error is thrown.
37
+ await check('malformed project.config.json throws and is preserved', async () => {
38
+ const vol = volOf({ ...BASE, 'project.config.json': '{bad json' })
39
+ await assert.rejects(
40
+ () => compileMiniApp({ fs: createFsFromVolume(vol), workPath: WP }),
41
+ /not valid JSON/,
42
+ )
43
+ assert.equal(vol.readFileSync(`${WP}/project.config.json`, 'utf8'), '{bad json', 'original file must be untouched')
44
+ })
45
+
46
+ // #2b — a non-string appid is rejected (would otherwise corrupt output paths).
47
+ await check('non-string appid throws', async () => {
48
+ const files = { ...BASE, 'project.config.json': JSON.stringify({ appid: { x: 1 } }) }
49
+ await assert.rejects(
50
+ () => compileMiniApp({ fs: fsOf(files), workPath: WP }),
51
+ /"appid" must be a non-empty string/,
52
+ )
53
+ })
54
+
55
+ // #1 — two compiles fired concurrently must keep distinct appIds (serialization
56
+ // prevents the module-level fs backend / env singletons from crossing).
57
+ await check('concurrent compiles keep distinct appIds', async () => {
58
+ const A = { ...BASE, 'project.config.json': JSON.stringify({ appid: 'wxAAAAAAAAAAAAAAAA' }) }
59
+ const B = { ...BASE, 'project.config.json': JSON.stringify({ appid: 'wxBBBBBBBBBBBBBBBB' }) }
60
+ const [ra, rb] = await Promise.all([
61
+ compileMiniApp({ fs: fsOf(A), workPath: WP }),
62
+ compileMiniApp({ fs: fsOf(B), workPath: WP }),
63
+ ])
64
+ assert.equal(ra.appId, 'wxAAAAAAAAAAAAAAAA', 'compile A got the wrong appId (state crossed)')
65
+ assert.equal(rb.appId, 'wxBBBBBBBBBBBBBBBB', 'compile B got the wrong appId (state crossed)')
66
+ assert.ok(Object.keys(ra.files).length > 0 && Object.keys(rb.files).length > 0, 'both must produce files')
67
+ })
68
+
69
+ // sanity — the happy path still works after all the guards.
70
+ await check('happy path still compiles', async () => {
71
+ const r = await compileMiniApp({ fs: fsOf(BASE), workPath: WP })
72
+ assert.equal(typeof r.appId, 'string')
73
+ assert.ok(r.appId.length > 0)
74
+ })
75
+
76
+ console.log(`\n${'─'.repeat(56)}\nResults: ${pass} passed, ${fail} failed`)
77
+ if (fail) process.exit(1)
78
+ console.log('All hardening assertions passed.')
@@ -0,0 +1,69 @@
1
+ // Layer 1: run the bundled in-memory compiler in Node against the `base` example,
2
+ // reading source from the real FS into a plain {path: content} map.
3
+ import { readdirSync, readFileSync, statSync } from 'node:fs'
4
+ import { fileURLToPath } from 'node:url'
5
+ import path from 'node:path'
6
+ // memfs stands in as the "downstream" fs here — web-compiler carries no fs of
7
+ // its own; the host injects one via { fs }.
8
+ import { Volume, createFsFromVolume } from 'memfs'
9
+
10
+ // Default to the example shipped in dimina-kit's `dimina` submodule.
11
+ const APP = process.env.APP_DIR
12
+ || fileURLToPath(new URL('../../../dimina/fe/example/base', import.meta.url))
13
+
14
+ const TEXT_EXT = new Set([
15
+ '.json', '.js', '.ts', '.wxml', '.ddml', '.wxss', '.ddss', '.less',
16
+ '.scss', '.sass', '.wxs', '.dds', '.css',
17
+ ])
18
+
19
+ function readDir(dir, baseDir, out) {
20
+ for (const name of readdirSync(dir)) {
21
+ if (name === 'node_modules' || name === '.git') continue
22
+ const full = path.join(dir, name)
23
+ const st = statSync(full)
24
+ if (st.isDirectory()) {
25
+ readDir(full, baseDir, out)
26
+ } else {
27
+ const rel = path.relative(baseDir, full).split(path.sep).join('/')
28
+ const ext = path.extname(name).toLowerCase()
29
+ if (TEXT_EXT.has(ext)) out[rel] = readFileSync(full, 'utf8')
30
+ // binary assets (png etc.) skipped for this text-only smoke test
31
+ }
32
+ }
33
+ }
34
+
35
+ const files = {}
36
+ readDir(APP, APP, files)
37
+ console.log(`[seed] ${Object.keys(files).length} text files from ${APP}`)
38
+
39
+ const { compileMiniApp } = await import('../dist/compile-core.node.js')
40
+
41
+ // Downstream seeds a memfs volume at workPath and injects its fs.
42
+ const workPath = '/work'
43
+ const vol = Volume.fromJSON(files, workPath)
44
+ const t0 = Date.now()
45
+ const result = await compileMiniApp({ fs: createFsFromVolume(vol), workPath })
46
+ const dt = Date.now() - t0
47
+
48
+ console.log(`\n[result] appId=${result.appId} name=${result.name} in ${dt}ms`)
49
+ // memfs toJSON yields null for directory entries; keep only real files
50
+ for (const k of Object.keys(result.files)) {
51
+ if (result.files[k] == null) delete result.files[k]
52
+ }
53
+ const outNames = Object.keys(result.files).sort()
54
+ console.log(`[output] ${outNames.length} files`)
55
+ const top = outNames.filter((n) => /^main\/(logic\.js|app-config\.json|app\.css|pages_index\.(js|css))$/.test(n))
56
+ for (const n of top) console.log(` ${n} (${result.files[n].length} bytes)`)
57
+
58
+ // sanity: must have main/logic.js, main/app-config.json, main/app.css and at least one page view
59
+ const need = ['main/logic.js', 'main/app-config.json']
60
+ const missing = need.filter((n) => !(n in result.files))
61
+ if (missing.length) {
62
+ console.error(`\n❌ MISSING expected outputs: ${missing.join(', ')}`)
63
+ process.exit(1)
64
+ }
65
+ console.log('\n--- main/app-config.json (head) ---')
66
+ console.log(result.files['main/app-config.json'].slice(0, 600))
67
+ console.log('\n--- main/logic.js (head) ---')
68
+ console.log(result.files['main/logic.js'].slice(0, 400))
69
+ console.log('\n✅ Layer1 compile produced expected artifacts')
@@ -0,0 +1,164 @@
1
+ // Regression tests: the miniprogram_npm scan that feeds compiled output must mirror
2
+ // the npm addressing authority (NpmResolver), which only ever walks UP from a compiled
3
+ // source file's own directory looking for a miniprogram_npm sibling. A miniprogram_npm
4
+ // dir that sits inside node_modules/, inside a dot-directory, or inside an output dir
5
+ // nested within the project is never reachable that way and must not appear in the
6
+ // compiled product — while one at the project root, a subpackage root, or anywhere on
7
+ // a page's ancestor chain (all real addressing positions) must still come through.
8
+ import assert from 'node:assert/strict'
9
+ import fs from 'node:fs'
10
+ import os from 'node:os'
11
+ import path from 'node:path'
12
+ import { Volume, createFsFromVolume } from 'memfs'
13
+ import { compileMiniApp } from '../dist/compile-core.node.js'
14
+
15
+ const WP = '/work'
16
+ const BASE = {
17
+ 'app.js': 'App({})',
18
+ 'app.json': JSON.stringify({ pages: ['pages/index'] }),
19
+ 'pages/index.js': 'Page({})',
20
+ 'pages/index.wxml': '<view>hi</view>',
21
+ }
22
+ const volOf = (files) => Volume.fromJSON(files, WP)
23
+ const fsOf = (files) => createFsFromVolume(volOf(files))
24
+
25
+ // A minimal npm package under `${npmDirPath}/${pkgName}` — just enough for
26
+ // findMiniprogramNpmDirs/buildPackage to see a real package.
27
+ const npmPackage = (npmDirPath, pkgName) => ({
28
+ [`${npmDirPath}/${pkgName}/index.js`]: 'module.exports = 1',
29
+ [`${npmDirPath}/${pkgName}/package.json`]: JSON.stringify({ name: pkgName, version: '1.0.0' }),
30
+ })
31
+
32
+ const hasPrefixKey = (files, prefix) => Object.keys(files).some((k) => k.startsWith(prefix))
33
+ const hasSubstringKey = (files, needle) => Object.keys(files).some((k) => k.includes(needle))
34
+
35
+ let pass = 0
36
+ let fail = 0
37
+ const check = async (label, fn) => {
38
+ try { await fn(); console.log(` ✅ ${label}`); pass++ }
39
+ catch (e) { console.error(` ❌ ${label}\n ${e.message}`); fail++ }
40
+ }
41
+
42
+ // #1 — the project-root miniprogram_npm is a real addressing position (the fallback
43
+ // every compiled source file resolves to once it walks past its own package roots).
44
+ await check('root miniprogram_npm package reaches the output', async () => {
45
+ const files = { ...BASE, ...npmPackage('miniprogram_npm', 'foo') }
46
+ const r = await compileMiniApp({ fs: fsOf(files), workPath: WP })
47
+ assert.ok(
48
+ hasPrefixKey(r.files, 'miniprogram_npm/foo/'),
49
+ `expected a miniprogram_npm/foo/ file in the output, got: ${Object.keys(r.files).join(', ')}`,
50
+ )
51
+ })
52
+
53
+ // #2 — a subpackage root's own miniprogram_npm is a real addressing position for
54
+ // source files compiled under that subpackage.
55
+ await check('subpackage-root miniprogram_npm package reaches the output', async () => {
56
+ const files = {
57
+ ...BASE,
58
+ 'app.json': JSON.stringify({
59
+ pages: ['pages/index'],
60
+ subPackages: [{ root: 'sub', pages: ['pages/a'] }],
61
+ }),
62
+ 'sub/pages/a.js': 'Page({})',
63
+ 'sub/pages/a.wxml': '<view>a</view>',
64
+ ...npmPackage('sub/miniprogram_npm', 'bar'),
65
+ }
66
+ const r = await compileMiniApp({ fs: fsOf(files), workPath: WP })
67
+ assert.ok(
68
+ hasPrefixKey(r.files, 'sub/miniprogram_npm/bar/'),
69
+ `expected a sub/miniprogram_npm/bar/ file in the output, got: ${Object.keys(r.files).join(', ')}`,
70
+ )
71
+ })
72
+
73
+ // #3 — any layer on a page's ancestor chain (here pages/miniprogram_npm, an ancestor
74
+ // of pages/index.js) is a real addressing position too.
75
+ await check('page-ancestor-chain miniprogram_npm package reaches the output', async () => {
76
+ const files = { ...BASE, ...npmPackage('pages/miniprogram_npm', 'baz') }
77
+ const r = await compileMiniApp({ fs: fsOf(files), workPath: WP })
78
+ assert.ok(
79
+ hasPrefixKey(r.files, 'pages/miniprogram_npm/baz/'),
80
+ `expected a pages/miniprogram_npm/baz/ file in the output, got: ${Object.keys(r.files).join(', ')}`,
81
+ )
82
+ })
83
+
84
+ // #4 — no compiled source file's upward walk ever passes through node_modules, so a
85
+ // miniprogram_npm nested inside it can never be a real addressing position.
86
+ await check('miniprogram_npm nested under node_modules is excluded from the output', async () => {
87
+ const files = { ...BASE, ...npmPackage('node_modules/some-pkg/miniprogram_npm', 'junk') }
88
+ const r = await compileMiniApp({ fs: fsOf(files), workPath: WP })
89
+ assert.ok(
90
+ !hasSubstringKey(r.files, 'node_modules/'),
91
+ `output leaked a node_modules/ path: ${Object.keys(r.files).filter((k) => k.includes('node_modules/')).join(', ')}`,
92
+ )
93
+ })
94
+
95
+ // #5 — dot-directories (.git, e2e snapshot dirs, …) are never on a compiled source
96
+ // file's own path, so a miniprogram_npm nested inside one is unaddressable.
97
+ await check('miniprogram_npm nested under a dot-directory is excluded from the output', async () => {
98
+ const files = { ...BASE, ...npmPackage('.snapshots/deep/miniprogram_npm', 'junk') }
99
+ const r = await compileMiniApp({ fs: fsOf(files), workPath: WP })
100
+ const leaked = Object.keys(r.files).filter((k) => k.startsWith('.snapshots/') || k.includes('/.snapshots/'))
101
+ assert.equal(leaked.length, 0, `output leaked dot-directory path(s): ${leaked.join(', ')}`)
102
+ })
103
+
104
+ // --- #6: an in-project output dir must not feed back into the next build's scan -----
105
+ // Real-disk helpers (createNodeCompilerPool writes to native fs, not memfs).
106
+ function writeProjectFile(root, relPath, content) {
107
+ const full = path.join(root, ...relPath.split('/'))
108
+ fs.mkdirSync(path.dirname(full), { recursive: true })
109
+ fs.writeFileSync(full, content)
110
+ }
111
+ function writeProject(root, files) {
112
+ for (const [rel, content] of Object.entries(files)) writeProjectFile(root, rel, content)
113
+ }
114
+ function readTree(root) {
115
+ const out = {}
116
+ const walk = (dir, rel) => {
117
+ for (const name of fs.readdirSync(dir)) {
118
+ const full = path.join(dir, name)
119
+ const r = rel ? `${rel}/${name}` : name
120
+ if (fs.statSync(full).isDirectory()) walk(full, r)
121
+ else out[r] = fs.readFileSync(full, 'utf8')
122
+ }
123
+ }
124
+ walk(root, '')
125
+ return out
126
+ }
127
+
128
+ await check('a project-internal output dir is not re-scanned as npm input on the next build (no compounding nesting)', async () => {
129
+ const { createNodeCompilerPool } = await import('../dist/pool.node.js')
130
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'dmcc-npm-scan-'))
131
+ const pool = createNodeCompilerPool()
132
+ try {
133
+ writeProject(tmpRoot, { ...BASE, ...npmPackage('miniprogram_npm', 'foo') })
134
+ // outputDir lives INSIDE the project — exactly the "caller set outputDir in the
135
+ // project" case that must not let build N's own output feed build N+1's npm scan.
136
+ const outputDir = path.join(tmpRoot, 'dist')
137
+
138
+ const info1 = await pool.build(outputDir, tmpRoot, true, {})
139
+ const tree1 = readTree(path.join(outputDir, info1.appId))
140
+
141
+ const info2 = await pool.build(outputDir, tmpRoot, true, {})
142
+ const tree2 = readTree(path.join(outputDir, info2.appId))
143
+
144
+ const nestedDist = Object.keys(tree2).filter((k) => k.split('/').includes('dist'))
145
+ assert.equal(
146
+ nestedDist.length,
147
+ 0,
148
+ `second build's output contains a nested dist/ subtree — the first build's own `
149
+ + `output was re-scanned as npm input: ${nestedDist.slice(0, 5).join(', ')}`,
150
+ )
151
+ assert.deepEqual(
152
+ Object.keys(tree1).sort(),
153
+ Object.keys(tree2).sort(),
154
+ 'output file set must be identical across repeated builds of an unchanged project',
155
+ )
156
+ } finally {
157
+ await pool.dispose()
158
+ fs.rmSync(tmpRoot, { recursive: true, force: true })
159
+ }
160
+ })
161
+
162
+ console.log(`\n${'─'.repeat(56)}\nResults: ${pass} passed, ${fail} failed`)
163
+ if (fail) process.exit(1)
164
+ console.log('All npm-scan assertions passed.')
@@ -0,0 +1,65 @@
1
+ // Robustness tests for the resident Node disk pool (dist/pool.node.js):
2
+ // • a dead stage worker must NOT wedge later builds — postMessage to an exited
3
+ // worker neither throws nor answers, so without respawn the next build hangs
4
+ // forever (idle death AND mid-build death both covered)
5
+ // • builds from TWO pool instances must serialize — setupCompile/publishToDist
6
+ // go through dmcc's process-global env singletons, so cross-instance overlap
7
+ // would publish one pool's staging dir under the other's appId
8
+ // • build() after dispose() rejects instead of silently respawning workers
9
+ // Worker termination reaches the live Worker via the `_slots` test hook.
10
+ import fs from 'node:fs'
11
+ import { fileURLToPath } from 'node:url'
12
+ import path from 'node:path'
13
+
14
+ const APP = process.env.APP_DIR
15
+ || fileURLToPath(new URL('../../../dimina/fe/example/base', import.meta.url))
16
+ const TMP = fileURLToPath(new URL('../.tmp-pool-hardening/', import.meta.url))
17
+ fs.rmSync(TMP, { recursive: true, force: true })
18
+ fs.mkdirSync(TMP, { recursive: true })
19
+ const dir = (n) => path.join(TMP, n)
20
+
21
+ const { createNodeCompilerPool } = await import('../dist/pool.node.js')
22
+
23
+ let failed = false
24
+ const chk = (cond, msg) => { if (!cond) { failed = true; console.error(`❌ ${msg}`) } else console.log(`✅ ${msg}`) }
25
+ const withTimeout = (p, ms, label) => Promise.race([
26
+ p,
27
+ new Promise((_, rej) => setTimeout(() => rej(new Error(`TIMEOUT: ${label} did not settle in ${ms}ms`)), ms).unref()),
28
+ ])
29
+
30
+ // --- idle-death recovery: kill a warm worker, next build must succeed --------
31
+ const pool = createNodeCompilerPool()
32
+ const info1 = await pool.build(dir('a1'), APP, true, {})
33
+ chk(!!info1.appId, `baseline build ok (appId ${info1.appId}, path ${info1.path})`)
34
+ await pool._slots[1].w.terminate() // kill the view worker while idle
35
+ const info2 = await withTimeout(pool.build(dir('a2'), APP, true, {}), 120000, 'build after idle worker death')
36
+ chk(info2.appId === info1.appId && fs.existsSync(path.join(dir('a2'), info2.appId, 'main', 'logic.js')),
37
+ 'build after idle worker death succeeds via respawn (no hang)')
38
+
39
+ // --- mid-build death: in-flight build fails fast, NEXT build recovers --------
40
+ const midBuild = pool.build(dir('b1'), APP, true, {})
41
+ setTimeout(() => { pool._slots[0].w?.terminate() }, 30) // kill logic worker mid-flight
42
+ const midResult = await withTimeout(midBuild.then(() => 'resolved', (e) => e.message), 120000, 'mid-flight-death build')
43
+ chk(/exited/.test(String(midResult)), `mid-flight worker death rejects the build with the stage error: ${midResult}`)
44
+ const info3 = await withTimeout(pool.build(dir('b2'), APP, true, {}), 120000, 'build after mid-flight death')
45
+ chk(info3.appId === info1.appId, 'next build after mid-flight death succeeds via respawn')
46
+
47
+ // --- two pool instances, concurrent builds → module-level serialization ------
48
+ const poolB = createNodeCompilerPool()
49
+ const [x, y] = await withTimeout(
50
+ Promise.all([pool.build(dir('c1'), APP, true, {}), poolB.build(dir('c2'), APP, true, {})]),
51
+ 240000, 'cross-pool concurrent builds',
52
+ )
53
+ const treeOk = (d, i) => fs.existsSync(path.join(d, i.appId, 'main', 'logic.js')) && fs.existsSync(path.join(d, i.appId, 'main', 'app.css'))
54
+ chk(x.appId === y.appId && treeOk(dir('c1'), x) && treeOk(dir('c2'), y),
55
+ 'concurrent builds from two pool instances both publish intact output (serialized)')
56
+
57
+ // --- dispose semantics --------------------------------------------------------
58
+ await poolB.dispose()
59
+ const afterDispose = await poolB.build(dir('d1'), APP, true, {}).then(() => 'resolved', (e) => e.message)
60
+ chk(/disposed/.test(String(afterDispose)), `build after dispose rejects: ${afterDispose}`)
61
+ await pool.dispose()
62
+
63
+ fs.rmSync(TMP, { recursive: true, force: true })
64
+ console.log(failed ? '\n❌ FAIL' : '\n✅ PASS: respawn-on-death, cross-pool serialization, dispose guard')
65
+ process.exit(failed ? 1 : 0)
@@ -0,0 +1,117 @@
1
+ // Equivalence test for the resident Node disk pool (dist/pool.node.js) vs real dmcc.
2
+ //
3
+ // dmcc output is not byte-deterministic: scoped data-v ids, esbuild variable naming, and
4
+ // uuid-prefixed asset file names all vary run-to-run — dmcc's own two runs of the same
5
+ // project differ on those. So we run dmcc TWICE to learn:
6
+ // • which files are deterministically NAMED (present under the same name in both runs)
7
+ // • which of those are deterministic in CONTENT (byte-identical across the two runs)
8
+ // and require our pool to match dmcc only where dmcc matches itself. Assets (uuid-named)
9
+ // are matched by CONTENT hash multiset instead of name. Sourcemap is asserted against
10
+ // dmcc's own map (parity), since that is the format devtools already consumes.
11
+ import { readdirSync, readFileSync, statSync, rmSync, mkdirSync } from 'node:fs'
12
+ import { fileURLToPath } from 'node:url'
13
+ import { createHash } from 'node:crypto'
14
+ import path from 'node:path'
15
+
16
+ const APP = process.env.APP_DIR
17
+ || fileURLToPath(new URL('../../../dimina/fe/example/base', import.meta.url))
18
+
19
+ const TMP = fileURLToPath(new URL('../.tmp-pool-node/', import.meta.url))
20
+ rmSync(TMP, { recursive: true, force: true })
21
+ mkdirSync(TMP, { recursive: true })
22
+ const dir = (n) => path.join(TMP, n)
23
+ const sha = (buf) => createHash('sha256').update(buf).digest('hex')
24
+ const multiset = (arr) => { const m = {}; for (const x of arr) m[x] = (m[x] || 0) + 1; return m }
25
+ const eqMultiset = (a, b) => {
26
+ const ka = Object.keys(a); const kb = Object.keys(b)
27
+ return ka.length === kb.length && ka.every((k) => a[k] === b[k])
28
+ }
29
+
30
+ function readTree(root) {
31
+ const out = {}
32
+ const walk = (d, rel) => {
33
+ for (const name of readdirSync(d)) {
34
+ const full = path.join(d, name)
35
+ const r = rel ? `${rel}/${name}` : name
36
+ if (statSync(full).isDirectory()) walk(full, r)
37
+ else out[r] = readFileSync(full) // Buffer — byte-exact, binary-safe
38
+ }
39
+ }
40
+ walk(root, '')
41
+ return out
42
+ }
43
+
44
+ const dmccBuild = (await import('../../../dimina/fe/packages/compiler/src/index.js')).default
45
+ const { createNodeCompilerPool } = await import('../dist/pool.node.js')
46
+
47
+ // --- reference: dmcc twice ------------------------------------------------
48
+ console.log(`[ref] dmcc build ×2 (sourcemap) from ${APP}`)
49
+ const appInfoRefA = await dmccBuild(dir('refA'), APP, true, { sourcemap: true })
50
+ await dmccBuild(dir('refB'), APP, true, { sourcemap: true })
51
+ const appId = appInfoRefA.appId
52
+ const refA = readTree(path.join(dir('refA'), appId))
53
+ const refB = readTree(path.join(dir('refB'), appId))
54
+
55
+ // Classify dmcc's files. detName: same name in both runs. detContent: byte-identical too.
56
+ const detName = Object.keys(refA).filter((f) => f in refB).sort()
57
+ const detContent = detName.filter((f) => refA[f].equals(refB[f]))
58
+ const nondetNamed = Object.keys(refA).filter((f) => !(f in refB)) // uuid-named assets
59
+ const refAssetHashes = multiset(nondetNamed.map((f) => sha(refA[f])))
60
+ if (!(refA['main/logic.js.map'])) throw new Error('test setup: dmcc reference did not emit logic.js.map')
61
+ const refMap = JSON.parse(refA['main/logic.js.map'].toString('utf8'))
62
+ console.log(`[ref] ${Object.keys(refA).length} files — ${detName.length} deterministically named, `
63
+ + `${detContent.length} byte-deterministic, ${nondetNamed.length} uuid-named assets`)
64
+
65
+ let failed = false
66
+ const fail = (m) => { failed = true; console.error(`❌ ${m}`) }
67
+
68
+ // The full dmcc-equivalence bar, applied to any of our builds.
69
+ function checkAgainstDmcc(tree, label) {
70
+ const total = Object.keys(tree).length
71
+ if (total !== Object.keys(refA).length) fail(`[${label}] file count ${total} != dmcc ${Object.keys(refA).length}`)
72
+ // every deterministically-named dmcc file must exist
73
+ const miss = detName.filter((f) => !(f in tree))
74
+ if (miss.length) fail(`[${label}] missing dmcc files: ${miss.slice(0, 8).join(', ')}`)
75
+ // byte parity on files dmcc itself is deterministic about
76
+ const bad = detContent.filter((f) => f in tree && !tree[f].equals(refA[f]))
77
+ if (bad.length) fail(`[${label}] REAL divergence on ${bad.length} byte-deterministic file(s): ${bad.slice(0, 8).join(', ')}`)
78
+ // assets: same content, just renamed → compare hash multiset
79
+ const oursNondet = Object.keys(tree).filter((f) => !detName.includes(f))
80
+ if (!eqMultiset(multiset(oursNondet.map((f) => sha(tree[f]))), refAssetHashes)) {
81
+ fail(`[${label}] asset content multiset differs from dmcc (renamed is fine, corrupted is not)`)
82
+ }
83
+ // sourcemap parity with dmcc
84
+ const js = (tree['main/logic.js'] || Buffer.alloc(0)).toString('utf8')
85
+ if (!/\/\/# sourceMappingURL=logic\.js\.map\s*$/.test(js)) fail(`[${label}] logic.js missing trailing sourceMappingURL`)
86
+ if (!tree['main/logic.js.map']) { fail(`[${label}] logic.js.map missing`); return }
87
+ let map
88
+ try { map = JSON.parse(tree['main/logic.js.map'].toString('utf8')) } catch (e) { fail(`[${label}] logic.js.map invalid JSON: ${e.message}`); return }
89
+ const s = (map.sources || []).slice().sort()
90
+ const rs = (refMap.sources || []).slice().sort()
91
+ if (s.join('|') !== rs.join('|')) fail(`[${label}] logic.js.map sources differ from dmcc\n ours: ${s.slice(0, 4).join(', ')}\n dmcc: ${rs.slice(0, 4).join(', ')}`)
92
+ if (!(Array.isArray(map.sourcesContent) && map.sourcesContent.some((c) => c && c.length))) fail(`[${label}] logic.js.map has no sourcesContent`)
93
+ if (!failed) console.log(`[${label}] ✅ dmcc-equivalent (count+bytes+assets+sourcemap sources match dmcc)`)
94
+ }
95
+
96
+ // --- ours: pool, twice (warm reuse) ---------------------------------------
97
+ console.log('[ours] resident pool build ×2 (warm)')
98
+ const pool = createNodeCompilerPool()
99
+ const appInfo1 = await pool.build(dir('ours1'), APP, true, { sourcemap: true })
100
+ const appInfo2 = await pool.build(dir('ours2'), APP, true, { sourcemap: true })
101
+ await pool.dispose()
102
+
103
+ // The whole return value must match dmcc's, `path` included — dmcc's mainPages[1]
104
+ // read happens AFTER its style task unshifted `app` into the shared array, i.e. it
105
+ // is the original first page (the pool reads mainPages[0] of its unmutated array).
106
+ for (const key of ['appId', 'name', 'path']) {
107
+ if (appInfo1[key] !== appInfoRefA[key]) fail(`appInfo.${key} mismatch: pool ${JSON.stringify(appInfo1[key])} vs dmcc ${JSON.stringify(appInfoRefA[key])}`)
108
+ }
109
+ if (appInfo1.appId !== appInfo2.appId) fail('appId not stable across warm rebuilds')
110
+ if (appInfo1.path !== appInfo2.path) fail('path not stable across warm rebuilds')
111
+
112
+ checkAgainstDmcc(readTree(path.join(dir('ours1'), appInfo1.appId)), 'cold')
113
+ checkAgainstDmcc(readTree(path.join(dir('ours2'), appInfo2.appId)), 'warm')
114
+
115
+ console.log(`[sourcemap] logic.js.map: ${refMap.sources.length} sources, sourcesContent present, matches dmcc ✅`)
116
+ console.log(failed ? '\n❌ FAIL' : '\n✅ PASS: resident Node pool is dmcc-equivalent (incl. sourcemap) on cold+warm builds')
117
+ process.exit(failed ? 1 : 0)
@@ -0,0 +1,77 @@
1
+ // Realm-reuse test: a pooled worker kept warm to amortize wasm init must be able
2
+ // to compile a SECOND project in the SAME realm without the first compile's
3
+ // module-level caches leaking into it. This proves resetCompilerState() is both
4
+ // necessary — a repeat compile WITHOUT reset diverges from a fresh one, and
5
+ // sufficient — a repeat compile WITH reset is byte-identical to a fresh one.
6
+ // Each compile uses its OWN fs (fresh memfs), so any divergence comes purely from
7
+ // leaked compiler module state, not from a shared fs.
8
+ import { readdirSync, readFileSync, statSync } from 'node:fs'
9
+ import { fileURLToPath } from 'node:url'
10
+ import path from 'node:path'
11
+ import { Volume, createFsFromVolume } from 'memfs'
12
+
13
+ const APP = process.env.APP_DIR
14
+ || fileURLToPath(new URL('../../../dimina/fe/example/base', import.meta.url))
15
+
16
+ const TEXT_EXT = new Set([
17
+ '.json', '.js', '.ts', '.wxml', '.ddml', '.wxss', '.ddss', '.less',
18
+ '.scss', '.sass', '.wxs', '.dds', '.css',
19
+ ])
20
+
21
+ function readDir(dir, baseDir, out) {
22
+ for (const name of readdirSync(dir)) {
23
+ if (name === 'node_modules' || name === '.git') continue
24
+ const full = path.join(dir, name)
25
+ if (statSync(full).isDirectory()) readDir(full, baseDir, out)
26
+ else if (TEXT_EXT.has(path.extname(name).toLowerCase())) {
27
+ out[path.relative(baseDir, full).split(path.sep).join('/')] = readFileSync(full, 'utf8')
28
+ }
29
+ }
30
+ }
31
+
32
+ const seed = {}
33
+ readDir(APP, APP, seed)
34
+ console.log(`[seed] ${Object.keys(seed).length} text files from ${APP}`)
35
+
36
+ const workPath = '/work'
37
+ const { compileMiniApp, resetCompilerState } = await import('../dist/compile-core.node.js')
38
+
39
+ // Each run gets a pristine fs so the ONLY thing shared between runs is the realm.
40
+ async function run() {
41
+ const fs = createFsFromVolume(Volume.fromJSON(seed, workPath))
42
+ const r = await compileMiniApp({ fs, workPath })
43
+ for (const k of Object.keys(r.files)) if (r.files[k] == null) delete r.files[k]
44
+ return r.files
45
+ }
46
+
47
+ // Stable signature of an output map: sorted "path\tlength" lines.
48
+ const sig = (m) => Object.keys(m).sort().map((k) => `${k}\t${m[k].length}`).join('\n')
49
+
50
+ let failed = 0
51
+ const check = (cond, msg) => { console.log(` ${cond ? '✅' : '❌'} ${msg}`); if (!cond) failed++ }
52
+
53
+ // 1) fresh realm baseline
54
+ const fresh = await run()
55
+ console.log(`[fresh] ${Object.keys(fresh).length} files`)
56
+
57
+ // 2) repeat WITHOUT reset — informational: shows whether caches leak for this
58
+ // project. Divergence proves reset is load-bearing; a match just means this
59
+ // project's caches happen to be benign. Either way sufficiency (below) is the
60
+ // property that matters, so this is NOT a hard assertion.
61
+ const noReset = await run()
62
+ console.log(`[no-reset] ${Object.keys(noReset).length} files`)
63
+ if (sig(noReset) !== sig(fresh)) {
64
+ console.log(' ℹ️ repeat WITHOUT reset DIVERGED — cache leak confirmed, resetCompilerState() is load-bearing')
65
+ } else {
66
+ console.log(' ℹ️ repeat WITHOUT reset matched — no observable divergence for this project')
67
+ }
68
+
69
+ // 3) repeat WITH reset — MUST be byte-identical to the fresh realm (sufficiency).
70
+ resetCompilerState()
71
+ const withReset = await run()
72
+ console.log(`[with-reset] ${Object.keys(withReset).length} files`)
73
+ check(sig(withReset) === sig(fresh), 'a repeat compile WITH resetCompilerState() matches the fresh realm')
74
+
75
+ console.log(`\n────────────────────────────────────────────────────────`)
76
+ if (failed) { console.error(`❌ ${failed} realm-reuse assertion(s) failed.`); process.exit(1) }
77
+ console.log('✅ resetCompilerState() makes a warm realm safe to reuse across compiles.')
@@ -0,0 +1,25 @@
1
+ // Browser entry: bundles the in-memory dmcc compiler for the browser.
2
+ // The wasm toolchain is NOT bundled here — the host worker loads it and installs
3
+ // globalThis.__esbuildTransform + globalThis.__oxcParseSync (both have relative
4
+ // wasm assets / nested workers that can't survive being inlined into one bundle).
5
+ // `esbuild` -> src/shims/esbuild-wasm.js, `oxc-parser` -> src/shims/oxc-parser.js.
6
+ //
7
+ // This bundle carries NO fs implementation: the host injects its own node:fs
8
+ // replacement (memfs, or anything meeting the contract) via compileMiniApp({ fs }).
9
+ // setupCompile/compileStage/collectOutputs are the decomposed seams the parallel
10
+ // pipeline drives: the host runs setupCompile once, fans the stages out to workers
11
+ // over a shared fs, then collectOutputs merges. compileMiniApp is the single-realm
12
+ // convenience wrapper over the same seams.
13
+ import {
14
+ compileMiniApp, setupCompile, compileStage, collectOutputs, resetCompilerState, STAGE_NAMES,
15
+ } from './compile-core.js'
16
+
17
+ export {
18
+ compileMiniApp, setupCompile, compileStage, collectOutputs, resetCompilerState, STAGE_NAMES,
19
+ }
20
+
21
+ /**
22
+ * Kept for API stability. The host worker injects the wasm toolchain hooks
23
+ * before calling compileMiniApp, so there is nothing to initialize here.
24
+ */
25
+ export async function initToolchain() {}