jz 0.3.1 → 0.5.0

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,13 +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
- import jzifyFn, { codegen } from './src/jzify.js'
12
+ import jzifyFn from './src/jzify.js'
13
+ import { codegen } from './src/codegen.js'
14
+ import { resolveModuleGraph } from './src/resolve.js'
14
15
  import { createRequire } from 'module'
15
16
 
16
17
  const jzRequire = createRequire(import.meta.url)
@@ -148,53 +149,10 @@ async function handleCompile(args) {
148
149
  if (!outputFile) outputFile = inputFile.replace(/\.(js|jz)$/, wat ? '.wat' : '.wasm')
149
150
  if (outputFile.endsWith('.wat')) wat = true
150
151
 
151
- const code = readFileSync(inputFile, 'utf8')
152
-
153
- // Resolve imports
154
- const dir = dirname(resolve(inputFile))
155
- const modules = {}
156
-
157
- const pkgFile = join(dir, 'package.json')
158
- if (existsSync(pkgFile)) {
159
- try {
160
- const pkg = JSON.parse(readFileSync(pkgFile, 'utf8'))
161
- if (pkg.imports) for (const [spec, path] of Object.entries(pkg.imports)) {
162
- const full = resolve(dir, path)
163
- try { modules[spec] = readFileSync(full, 'utf8') } catch {}
164
- }
165
- } catch {}
166
- }
167
-
168
- // Recursively resolve relative imports from entry file and all discovered modules
169
- const importRe = /import\s+.*?\s+from\s+['"]([^'"]+)['"]/g
170
- const resolveBareModule = (specifier, fromDir) => execFileSync(
171
- process.execPath,
172
- ['--input-type=module', '-e', 'process.stdout.write(import.meta.resolve(process.argv[1]))', specifier],
173
- { cwd: fromDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
174
- ).trim()
175
- const resolveModule = (specifier, fromDir) => {
176
- if (modules[specifier]) return
177
- // Relative imports: resolve from filesystem
178
- if (specifier.startsWith('./') || specifier.startsWith('../')) {
179
- const full = resolve(fromDir, specifier)
180
- let src
181
- try { src = readFileSync(full, 'utf8') }
182
- catch { try { src = readFileSync(full + '.js', 'utf8') } catch { return } }
183
- modules[specifier] = src
184
- let m; importRe.lastIndex = 0
185
- while ((m = importRe.exec(src)) !== null) resolveModule(m[1], dirname(full))
186
- return
187
- }
188
- // Bare specifiers: opt-in Node.js resolution
189
- if (resolveNode) {
190
- try {
191
- const resolved = resolveBareModule(specifier, fromDir)
192
- if (resolved.startsWith('file:')) modules[specifier] = readFileSync(new URL(resolved), 'utf8')
193
- } catch {}
194
- }
195
- }
196
- let m; importRe.lastIndex = 0
197
- 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))
198
156
 
199
157
  // .jz = strict (no auto-transform), .js = auto-jzify
200
158
  // --strict forces strict for any extension
@@ -215,7 +173,7 @@ async function handleCompile(args) {
215
173
  opts.imports = JSON.parse(readFileSync(importsPath, 'utf8'))
216
174
  }
217
175
 
218
- const result = compile(code, opts)
176
+ const result = compile(codeRewritten, opts)
219
177
 
220
178
  if (outputFile === '-') {
221
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,25 +46,17 @@ 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
- const importSpecMayReturnExternal = (spec) => {
55
- if (typeof spec === 'function') return true
56
- return false
57
- }
58
-
59
- const importsMayReturnExternal = (imports) => {
60
- if (!imports) return false
61
- for (const mod of Object.values(imports))
62
- for (const spec of Object.values(mod || {}))
63
- if (importSpecMayReturnExternal(spec)) return true
64
- return false
65
- }
55
+ // A host import that's a JS function may hand back any value, including a host
56
+ // object which arrives in wasm as a PTR.EXTERNAL ref. Constants/typed specs can't.
57
+ const importsMayReturnExternal = (imports) =>
58
+ !!imports && Object.values(imports).some(mod =>
59
+ Object.values(mod || {}).some(spec => typeof spec === 'function'))
66
60
 
67
61
  const nowMs = () => globalThis.performance?.now ? globalThis.performance.now() : Date.now()
68
62
 
@@ -171,8 +165,11 @@ jz.memory = enhanceMemory
171
165
  * @param {boolean|number|object} [opts.optimize] - Optimization level/config.
172
166
  * - `false` / `0`: nothing. Fastest compile, largest output (live coding).
173
167
  * - `1`: encoding-compactness only (treeshake + sortLocalsByUse + fusedRewrite-inline).
174
- * - `true` / `2` (default): all current passes (watr CSE/DCE/inline + every jz pass).
175
- * - `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.
176
173
  * - `{ level?: 0|1|2|3, watr?: bool, hoistAddrBase?: bool, ... }`: per-pass
177
174
  * overrides on top of the chosen level. See PASS_NAMES in src/optimize.js.
178
175
  * @param {object} [opts.profile] - Optional mutable profile sink populated with
@@ -182,9 +179,120 @@ jz.memory = enhanceMemory
182
179
  * @param {boolean} [opts.profileNames] - Legacy alias for `profile.names`.
183
180
  * @param {string} [opts.importMetaUrl] - Module URL used to lower `import.meta.url`
184
181
  * and static `import.meta.resolve("...")` expressions.
185
- * @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}}
186
188
  */
187
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 = {}) => {
188
296
  const profiler = compileProfiler(opts.profile)
189
297
  const time = (name, fn) => profiler ? profiler.time(name, fn) : fn()
190
298
 
@@ -204,10 +312,12 @@ jz.compile = (code, opts = {}) => {
204
312
  if (opts.noTailCall) ctx.transform.noTailCall = true
205
313
  if (opts.strict) ctx.transform.strict = true
206
314
  if (opts.host) {
207
- 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'.`)
208
317
  ctx.transform.host = opts.host
209
318
  }
210
319
  if (opts.alloc === false) ctx.transform.alloc = false
320
+ if (opts.inspect) ctx.transform.inspect = true
211
321
  if (opts.importMetaUrl) ctx.transform.importMetaUrl = String(opts.importMetaUrl)
212
322
  if (opts.nativeTimers) ctx.features.blockingTimers = true // wasmtime CLI: include __timer_loop in _start
213
323
  ctx.transform.optimize = resolveOptimize(opts.optimize)
@@ -227,8 +337,13 @@ jz.compile = (code, opts = {}) => {
227
337
  const ast = time('prepare', () => prepare(parsed))
228
338
 
229
339
  // Auto-detect optimization tuning from source characteristics when the user
230
- // hasn't provided any optimize option.
231
- if (opts.optimize == null) {
340
+ // hasn't provided any optimize option. At the default level its only *live*
341
+ // output is the typed-array scalarization thresholds (every other override
342
+ // watr off, sortStrPool/hoistPtr on — already matches the level-2 preset), so
343
+ // skip the AST scan entirely unless the program touches typed arrays.
344
+ // NOTE: if the level-2 default ever flips watr on, drop this guard so the
345
+ // machine-generated-code heuristic in detectOptimizeConfig can take effect.
346
+ if (opts.optimize == null && ctx.module.modules.typedarray) {
232
347
  const autoCfg = detectOptimizeConfig(ast, code)
233
348
  if (Object.keys(autoCfg).length) {
234
349
  ctx.transform.optimize = resolveOptimize(autoCfg)
@@ -253,7 +368,12 @@ jz.compile = (code, opts = {}) => {
253
368
  }
254
369
 
255
370
  const cfg = ctx.transform.optimize
256
- const optimized = cfg.watr ? time('watrOptimize', () => watrOptimize(module)) : module
371
+ // watr's `loopify` collapses the `(block $brk (loop … (br_if $brk !cond) (br $loop)))`
372
+ // idiom into `(loop … (if cond …))` — sound, but destroys the exact shape jz's
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
376
+ const optimized = cfg.watr ? time('watrOptimize', () => watrOptimize(module, watrOpts)) : module
257
377
  // Final peephole pass: watrOptimize's inliner can re-introduce rebox/unbox at boundaries
258
378
  // (e.g. inlined closure body's `i32.wrap_i64 (i64.reinterpret_f64 __env)` next to caller's
259
379
  // `boxPtrIR(g)` rebox). Our fusedRewrite folds these, watr's peephole doesn't.
@@ -263,12 +383,35 @@ jz.compile = (code, opts = {}) => {
263
383
  // Build global name→type map from ctx.scope.globalTypes for promoteGlobals
264
384
  const globalTypesMap = ctx.scope.globalTypes ? new Map([...ctx.scope.globalTypes].map(([k, v]) => [`$${k}`, v])) : null
265
385
  time('watrReopt', () => {
266
- 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)
267
389
  })
268
390
  }
269
- if (opts.wat) return time('watrPrint', () => watrPrint(optimized))
270
- const wasm = time('watrCompile', () => watrCompile(optimized))
271
- 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
+ }
272
415
  }
273
416
 
274
417
  /**
@@ -327,7 +470,8 @@ export default function jz(code, ...args) {
327
470
  }
328
471
  if (hoisted.length) src = hoisted.join('; ') + '; ' + src
329
472
  const hasInterp = Object.keys(interp).length
330
- 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)
331
475
  // Patch data getters: allocate values in WASM memory, update closure refs
332
476
  for (const [, { val, ref }] of Object.entries(data)) {
333
477
  if (typeof val === 'string') ref.ptr = result.memory.String(val)
@@ -338,7 +482,15 @@ export default function jz(code, ...args) {
338
482
  }
339
483
 
340
484
  // String call: jz('code', opts?) — compile + instantiate + wrap
341
- 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)
342
494
  }
343
495
 
344
496
  export { jz }