jz 0.4.0 → 0.5.1

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/cli.js CHANGED
@@ -4,14 +4,14 @@
4
4
  * JZ CLI - Command-line interface for JZ compiler
5
5
  */
6
6
 
7
- import { readFileSync, writeFileSync, existsSync } from 'fs'
8
- import { dirname, resolve, join } from 'path'
7
+ import { readFileSync, writeFileSync } from 'fs'
8
+ import { resolve } from 'path'
9
9
  import { pathToFileURL } from 'url'
10
- import { execFileSync } from 'child_process'
11
10
  import { parse } from 'subscript/feature/jessie'
12
11
  import jz, { compile } from './index.js'
13
12
  import jzifyFn from './src/jzify.js'
14
13
  import { codegen } from './src/codegen.js'
14
+ import { resolveModuleGraph } from './src/resolve.js'
15
15
  import { createRequire } from 'module'
16
16
 
17
17
  const jzRequire = createRequire(import.meta.url)
@@ -149,53 +149,10 @@ async function handleCompile(args) {
149
149
  if (!outputFile) outputFile = inputFile.replace(/\.(js|jz)$/, wat ? '.wat' : '.wasm')
150
150
  if (outputFile.endsWith('.wat')) wat = true
151
151
 
152
- const code = readFileSync(inputFile, 'utf8')
153
-
154
- // Resolve imports
155
- const dir = dirname(resolve(inputFile))
156
- const modules = {}
157
-
158
- const pkgFile = join(dir, 'package.json')
159
- if (existsSync(pkgFile)) {
160
- try {
161
- const pkg = JSON.parse(readFileSync(pkgFile, 'utf8'))
162
- if (pkg.imports) for (const [spec, path] of Object.entries(pkg.imports)) {
163
- const full = resolve(dir, path)
164
- try { modules[spec] = readFileSync(full, 'utf8') } catch {}
165
- }
166
- } catch {}
167
- }
168
-
169
- // Recursively resolve relative imports from entry file and all discovered modules
170
- const importRe = /import\s+.*?\s+from\s+['"]([^'"]+)['"]/g
171
- const resolveBareModule = (specifier, fromDir) => execFileSync(
172
- process.execPath,
173
- ['--input-type=module', '-e', 'process.stdout.write(import.meta.resolve(process.argv[1]))', specifier],
174
- { cwd: fromDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
175
- ).trim()
176
- const resolveModule = (specifier, fromDir) => {
177
- if (modules[specifier]) return
178
- // Relative imports: resolve from filesystem
179
- if (specifier.startsWith('./') || specifier.startsWith('../')) {
180
- const full = resolve(fromDir, specifier)
181
- let src
182
- try { src = readFileSync(full, 'utf8') }
183
- catch { try { src = readFileSync(full + '.js', 'utf8') } catch { return } }
184
- modules[specifier] = src
185
- let m; importRe.lastIndex = 0
186
- while ((m = importRe.exec(src)) !== null) resolveModule(m[1], dirname(full))
187
- return
188
- }
189
- // Bare specifiers: opt-in Node.js resolution
190
- if (resolveNode) {
191
- try {
192
- const resolved = resolveBareModule(specifier, fromDir)
193
- if (resolved.startsWith('file:')) modules[specifier] = readFileSync(new URL(resolved), 'utf8')
194
- } catch {}
195
- }
196
- }
197
- let m; importRe.lastIndex = 0
198
- while ((m = importRe.exec(code)) !== null) resolveModule(m[1], dir)
152
+ // Resolve imports canonicalize every specifier to an absolute path so the
153
+ // same physical file always produces one module instance (see src/resolve.js).
154
+ const { code: codeRewritten, modules } = resolveModuleGraph(inputFile, { resolveNode })
155
+ if (process.env.JZ_DEBUG_MODULES === '1') console.error('modules:', Object.keys(modules))
199
156
 
200
157
  // .jz = strict (no auto-transform), .js = auto-jzify
201
158
  // --strict forces strict for any extension
@@ -216,7 +173,7 @@ async function handleCompile(args) {
216
173
  opts.imports = JSON.parse(readFileSync(importsPath, 'utf8'))
217
174
  }
218
175
 
219
- const result = compile(code, opts)
176
+ const result = compile(codeRewritten, opts)
220
177
 
221
178
  if (outputFile === '-') {
222
179
  process.stdout.write(result)
package/index.js CHANGED
@@ -33,7 +33,9 @@
33
33
  * Feature flags (ctx.features.*) gate conditional stdlib branches for dead-code elimination.
34
34
  * Capability hooks (ctx.schema.register, ctx.closure.make) are installed by capability modules.
35
35
  *
36
- * Interop host layer (memory marshaling, wrap, instantiate) lives in src/host.js.
36
+ * Interop host layer (memory marshaling, wrap, instantiate) lives in
37
+ * interop.js — also exported as the standalone `jz/interop` subpath for
38
+ * hosts that want to run prebuilt jz wasm without pulling the compiler.
37
39
  *
38
40
  * @module jz
39
41
  */
@@ -44,12 +46,11 @@ import { ctx, reset, err } from './src/ctx.js'
44
46
  import prepare, { GLOBALS } from './src/prepare.js'
45
47
  import compile from './src/compile.js'
46
48
  import { emitter } from './src/emit.js'
47
- import { optimizeFunc, resolveOptimize } from './src/optimize.js'
48
- import { detectOptimizeConfig } from './src/auto-config.js'
49
+ import { optimizeFunc, collectVolatileGlobals, resolveOptimize } from './src/optimize.js'
49
50
  import jzify from './src/jzify.js'
50
51
  import {
51
52
  memory as enhanceMemory, instantiate as instantiateRuntime,
52
- } from './src/host.js'
53
+ } from './interop.js'
53
54
 
54
55
  // A host import that's a JS function may hand back any value, including a host
55
56
  // object — which arrives in wasm as a PTR.EXTERNAL ref. Constants/typed specs can't.
@@ -164,8 +165,11 @@ jz.memory = enhanceMemory
164
165
  * @param {boolean|number|object} [opts.optimize] - Optimization level/config.
165
166
  * - `false` / `0`: nothing. Fastest compile, largest output (live coding).
166
167
  * - `1`: encoding-compactness only (treeshake + sortLocalsByUse + fusedRewrite-inline).
167
- * - `true` / `2` (default): all current passes (watr CSE/DCE/inline + every jz pass).
168
- * - `3`: reserved for future aggressive passes (currently == 2).
168
+ * - `true` / `2` (default): every stable jz pass + full watr (inlineOnce +
169
+ * coalesce on; `inline` stays off per watr's own default).
170
+ * - `3` / `'speed'`: level 2 + larger array/hash initial caps (`arrayMinCap`,
171
+ * `hashSmallInitCap`) + `hoistConstantPool` off (inline `f64.const` over
172
+ * mutable globals); trades size for speed.
169
173
  * - `{ level?: 0|1|2|3, watr?: bool, hoistAddrBase?: bool, ... }`: per-pass
170
174
  * overrides on top of the chosen level. See PASS_NAMES in src/optimize.js.
171
175
  * @param {object} [opts.profile] - Optional mutable profile sink populated with
@@ -175,9 +179,120 @@ jz.memory = enhanceMemory
175
179
  * @param {boolean} [opts.profileNames] - Legacy alias for `profile.names`.
176
180
  * @param {string} [opts.importMetaUrl] - Module URL used to lower `import.meta.url`
177
181
  * and static `import.meta.resolve("...")` expressions.
178
- * @returns {Uint8Array|string}
182
+ * @param {boolean} [opts.inspect] - When true, return `{ wasm, inspect }`
183
+ * (or `{ wat, inspect }` with `opts.wat`) instead of the bare output.
184
+ * `inspect` carries per-function inferred shapes (params, locals, JSON shapes,
185
+ * cross-call paramReps) for editor hosts to drive inlay hints / hover types
186
+ * without re-running the analyzer. Pays a small serialization cost; off by default.
187
+ * @returns {Uint8Array|string|{wasm: Uint8Array, inspect: object}|{wat: string, inspect: object}}
179
188
  */
180
189
  jz.compile = (code, opts = {}) => {
190
+ try {
191
+ return jzCompileInner(code, opts)
192
+ } catch (e) {
193
+ // Any uncaught native exception (TypeError, ReferenceError, etc.) is a jz
194
+ // codegen leak — surface it as an internal compile error with the source
195
+ // location we were standing on. err()-thrown errors already pass through.
196
+ if (e?.name === 'TypeError' || e?.name === 'ReferenceError' || e?.name === 'RangeError') {
197
+ err(`internal: ${e.message} (jz hit an unsupported case while compiling${ctx.error.node ? '; the AST node above shows the trigger' : ''}). This is a jz bug — please report.`)
198
+ }
199
+ throw e
200
+ }
201
+ }
202
+
203
+ // =============================================================================
204
+ // Optimization auto-tuning: scan prepared AST + ctx.func.list to infer program
205
+ // properties, then emit per-pass overrides. When the user does not explicitly
206
+ // configure individual passes, the result is merged in before resolveOptimize()
207
+ // so the compiler self-tunes.
208
+ // =============================================================================
209
+
210
+ const AUTO_CFG_LOOP_OPS = new Set(['for', 'while', 'do', 'do-while'])
211
+ const AUTO_CFG_TYPED_CTORS = new Set([
212
+ 'new.Float32Array', 'new.Float64Array', 'new.Int8Array', 'new.Int16Array',
213
+ 'new.Int32Array', 'new.Uint8Array', 'new.Uint16Array', 'new.Uint32Array',
214
+ 'new.Uint8ClampedArray',
215
+ ])
216
+
217
+ const autoCfgNodeSize = (node) => {
218
+ if (!Array.isArray(node)) return 1
219
+ let n = 1
220
+ for (let i = 1; i < node.length; i++) n += autoCfgNodeSize(node[i])
221
+ return n
222
+ }
223
+
224
+ const autoCfgScanNode = (node, stats, loopDepth) => {
225
+ if (!Array.isArray(node)) return
226
+ const op = node[0]
227
+ if (AUTO_CFG_LOOP_OPS.has(op)) {
228
+ stats.loopCount++
229
+ const d = loopDepth + 1
230
+ if (d > stats.maxLoopDepth) stats.maxLoopDepth = d
231
+ for (let i = 1; i < node.length; i++) autoCfgScanNode(node[i], stats, d)
232
+ return
233
+ }
234
+ if (op === '()') {
235
+ stats.callSites++
236
+ const callee = node[1]
237
+ if (typeof callee === 'string' && AUTO_CFG_TYPED_CTORS.has(callee)) {
238
+ stats.typedArrayCount++
239
+ const args = node[2]
240
+ const argList = args == null ? [] : (Array.isArray(args) && args[0] === ',') ? args.slice(1) : [args]
241
+ const lenLit = typeof argList[0] === 'number' ? argList[0] : null
242
+ if (lenLit != null && lenLit > stats.maxTypedArrayLen) stats.maxTypedArrayLen = lenLit
243
+ }
244
+ }
245
+ if (op === 'str') stats.stringLiteralCount++
246
+ if (op === '=>') stats.closureCount++
247
+ for (let i = 1; i < node.length; i++) autoCfgScanNode(node[i], stats, loopDepth)
248
+ }
249
+
250
+ /** Detect optimization config from source characteristics.
251
+ * Returns an object of pass overrides; empty object means "use defaults". */
252
+ const detectOptimizeConfig = (ast, code) => {
253
+ const s = {
254
+ sourceChars: code?.length || 0,
255
+ funcCount: 0, maxFuncBodySize: 0,
256
+ loopCount: 0, maxLoopDepth: 0,
257
+ typedArrayCount: 0, maxTypedArrayLen: 0,
258
+ stringLiteralCount: 0, closureCount: 0, callSites: 0,
259
+ }
260
+ if (ctx.func?.list) {
261
+ s.funcCount = ctx.func.list.length
262
+ for (const f of ctx.func.list) {
263
+ if (f.body) {
264
+ const sz = autoCfgNodeSize(f.body)
265
+ if (sz > s.maxFuncBodySize) s.maxFuncBodySize = sz
266
+ autoCfgScanNode(f.body, s, 0)
267
+ }
268
+ }
269
+ }
270
+ if (ast) autoCfgScanNode(ast, s, 0)
271
+
272
+ const cfg = {}
273
+ // Machine-generated or large code: watr's WAT-level CSE/DCE/inline fights
274
+ // jz's already-optimized IR and inflates output. Disable it automatically.
275
+ const isLarge = s.sourceChars > 4000 || s.funcCount > 40 || s.maxFuncBodySize > 300
276
+ const isMachineLike = s.callSites > 300 && s.stringLiteralCount < 10
277
+ if (isLarge || isMachineLike) cfg.watr = false
278
+ // Typed-array heavy: tighten scalarization thresholds when we see large
279
+ // fixed-size arrays; keep defaults for small/dynamic ones.
280
+ if (s.typedArrayCount > 0 && s.maxTypedArrayLen > 0) {
281
+ cfg.scalarTypedArrayLen = Math.min(32, Math.max(8, s.maxTypedArrayLen + 4))
282
+ cfg.scalarTypedLoopUnroll = s.maxLoopDepth > 1 ? 8 : 16
283
+ cfg.scalarTypedNestedUnroll = s.maxLoopDepth > 1 ? 32 : 128
284
+ }
285
+ // String-heavy: ensure pool sorting is on (already default, but explicit).
286
+ if (s.stringLiteralCount > 30) cfg.sortStrPoolByFreq = true
287
+ // Closure-heavy: ptr hoists pay off.
288
+ if (s.closureCount > 4) {
289
+ cfg.hoistPtrType = true
290
+ cfg.hoistInvariantPtrOffset = true
291
+ }
292
+ return cfg
293
+ }
294
+
295
+ const jzCompileInner = (code, opts = {}) => {
181
296
  const profiler = compileProfiler(opts.profile)
182
297
  const time = (name, fn) => profiler ? profiler.time(name, fn) : fn()
183
298
 
@@ -197,10 +312,12 @@ jz.compile = (code, opts = {}) => {
197
312
  if (opts.noTailCall) ctx.transform.noTailCall = true
198
313
  if (opts.strict) ctx.transform.strict = true
199
314
  if (opts.host) {
200
- if (opts.host !== 'js' && opts.host !== 'wasi') err(`Invalid host '${opts.host}'. Expected 'js' or 'wasi'.`)
315
+ if (opts.host === 'gc') err(`host:'gc' is reserved for a planned wasm-gc backend, not yet implemented. Use 'js' (default JS host with externref/js-string interop) or 'wasi' (standalone runtimes — no env imports).`)
316
+ if (opts.host !== 'js' && opts.host !== 'wasi') err(`Invalid host '${opts.host}'. Expected 'js' (default) or 'wasi'.`)
201
317
  ctx.transform.host = opts.host
202
318
  }
203
319
  if (opts.alloc === false) ctx.transform.alloc = false
320
+ if (opts.inspect) ctx.transform.inspect = true
204
321
  if (opts.importMetaUrl) ctx.transform.importMetaUrl = String(opts.importMetaUrl)
205
322
  if (opts.nativeTimers) ctx.features.blockingTimers = true // wasmtime CLI: include __timer_loop in _start
206
323
  ctx.transform.optimize = resolveOptimize(opts.optimize)
@@ -253,23 +370,9 @@ jz.compile = (code, opts = {}) => {
253
370
  const cfg = ctx.transform.optimize
254
371
  // watr's `loopify` collapses the `(block $brk (loop … (br_if $brk !cond) … (br $loop)))`
255
372
  // idiom into `(loop … (if cond …))` — sound, but destroys the exact shape jz's
256
- // post-watr vectorizer scans for. Disable loopify when vectorize is going to run.
257
- //
258
- // watr config:
259
- // true / 'full' → all default passes (includes `inlineOnce` / `inline` / `coalesce`)
260
- // 'light' → everything except inlining + coalesce. Default at level 2.
261
- // `inlineOnce` reshapes codegen the way slot-type / LICM tests scan for,
262
- // and miscompiles regex `split(/\s+/)`. `coalesceLocals` on its own (i.e.
263
- // without the cleanup that follows inlining) breaks `/a.+b/.test("ab")`
264
- // — likely a stale alias the post-inline propagate sweep normally tidies
265
- // up at L3. Dropping both still keeps treeshake / dedupe / dedupTypes /
266
- // propagate / packData / fold / peephole / vacuum / mergeBlocks / brif /
267
- // loopify / offset / unbranch / identity / strength / globals etc.
268
- // false → skip watr entirely (level 0/1).
269
- let watrOpts
270
- if (cfg.watr === 'light') watrOpts = { inline: false, inlineOnce: false, coalesce: false, loopify: !cfg.vectorizeLaneLocal }
271
- else if (cfg.vectorizeLaneLocal) watrOpts = { loopify: false }
272
- else watrOpts = true
373
+ // post-watr vectorizer scans for. Disable loopify when vectorize is going to run;
374
+ // otherwise hand watr its full default pass set (inlineOnce + coalesce on, inline off).
375
+ const watrOpts = cfg.vectorizeLaneLocal ? { loopify: false } : true
273
376
  const optimized = cfg.watr ? time('watrOptimize', () => watrOptimize(module, watrOpts)) : module
274
377
  // Final peephole pass: watrOptimize's inliner can re-introduce rebox/unbox at boundaries
275
378
  // (e.g. inlined closure body's `i32.wrap_i64 (i64.reinterpret_f64 __env)` next to caller's
@@ -280,12 +383,35 @@ jz.compile = (code, opts = {}) => {
280
383
  // Build global name→type map from ctx.scope.globalTypes for promoteGlobals
281
384
  const globalTypesMap = ctx.scope.globalTypes ? new Map([...ctx.scope.globalTypes].map(([k, v]) => [`$${k}`, v])) : null
282
385
  time('watrReopt', () => {
283
- for (const node of optimized) if (Array.isArray(node) && node[0] === 'func') optimizeFunc(node, postCfg, globalTypesMap)
386
+ const funcs = optimized.filter(node => Array.isArray(node) && node[0] === 'func')
387
+ const volatileGlobals = collectVolatileGlobals(funcs)
388
+ for (const node of funcs) optimizeFunc(node, postCfg, globalTypesMap, volatileGlobals)
284
389
  })
285
390
  }
286
- if (opts.wat) return time('watrPrint', () => watrPrint(optimized))
287
- const wasm = time('watrCompile', () => watrCompile(optimized))
288
- return (opts.profileNames || opts.profile?.names) ? appendFunctionNames(wasm, optimized) : wasm
391
+ try {
392
+ if (opts.wat) {
393
+ const wat = time('watrPrint', () => watrPrint(optimized))
394
+ return opts.inspect ? { wat, inspect: ctx.inspect } : wat
395
+ }
396
+ const wasm = time('watrCompile', () => watrCompile(optimized))
397
+ let bytes = wasm
398
+ if (opts.profileNames || opts.profile?.names) bytes = appendFunctionNames(bytes, optimized)
399
+ return opts.inspect ? { wasm: bytes, inspect: ctx.inspect } : bytes
400
+ } catch (e) {
401
+ // watr surfaces dangling identifiers as "Unknown local|func|global|table|memory $X".
402
+ // That's always a jz codegen leak — we emitted IR that references something never
403
+ // declared (typically: a built-in / stdlib we don't implement). Rewrite to a clean
404
+ // user-facing message instead of leaking watr internals.
405
+ const m = /Unknown (local|func|global|table|memory|type) \$?(\S+)/.exec(e?.message || '')
406
+ if (m) {
407
+ const [, kind, name] = m
408
+ const friendly = kind === 'func' ? `'${name}' is not a known function or built-in`
409
+ : kind === 'global' ? `'${name}' is not a known global or imported binding`
410
+ : `'${name}' is not in scope`
411
+ err(`${friendly} — jz emitted a reference it cannot resolve (likely an unsupported built-in or missing import).`)
412
+ }
413
+ throw e
414
+ }
289
415
  }
290
416
 
291
417
  /**
@@ -344,7 +470,8 @@ export default function jz(code, ...args) {
344
470
  }
345
471
  if (hoisted.length) src = hoisted.join('; ') + '; ' + src
346
472
  const hasInterp = Object.keys(interp).length
347
- const result = instantiateRuntime(jz.compile, src, { _interp: hasInterp ? interp : null })
473
+ const tplOpts = { _interp: hasInterp ? interp : null }
474
+ const result = instantiateRuntime(jz.compile(src, tplOpts), tplOpts)
348
475
  // Patch data getters: allocate values in WASM memory, update closure refs
349
476
  for (const [, { val, ref }] of Object.entries(data)) {
350
477
  if (typeof val === 'string') ref.ptr = result.memory.String(val)
@@ -355,7 +482,15 @@ export default function jz(code, ...args) {
355
482
  }
356
483
 
357
484
  // String call: jz('code', opts?) — compile + instantiate + wrap
358
- return instantiateRuntime(jz.compile, code, args[0] || {})
485
+ const callOpts = args[0] || {}
486
+ const out = jz.compile(code, callOpts)
487
+ // inspect:true returns { wasm, inspect } from jz.compile — unwrap the bytes for
488
+ // instantiation and surface inspect on the runtime result alongside exports/memory.
489
+ if (callOpts.inspect && out && typeof out === 'object' && 'wasm' in out) {
490
+ const result = instantiateRuntime(out.wasm, callOpts)
491
+ return Object.assign(result, { inspect: out.inspect })
492
+ }
493
+ return instantiateRuntime(out, callOpts)
359
494
  }
360
495
 
361
496
  export { jz }