js-confuser-vm 0.0.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.
@@ -0,0 +1,1617 @@
1
+ import parser from "@babel/parser";
2
+ import traverseImport from "@babel/traverse";
3
+ import { generate } from "@babel/generator";
4
+
5
+ import { readFileSync } from "fs";
6
+ import { join } from "path";
7
+ import { stripTypeScriptTypes } from "module";
8
+ import JSON5 from "json5";
9
+
10
+ const traverse = traverseImport.default;
11
+
12
+ const SHUFFLE_OPCODES = false;
13
+ const PACK = true;
14
+
15
+ // ── Opcodes ──────────────────────────────────────────────────────
16
+ const OP_ORIGINAL = {
17
+ LOAD_CONST: 0,
18
+ LOAD_LOCAL: 1,
19
+ STORE_LOCAL: 2,
20
+ LOAD_GLOBAL: 3,
21
+ STORE_GLOBAL: 4,
22
+ GET_PROP: 5,
23
+ ADD: 6,
24
+ SUB: 7,
25
+ MUL: 8,
26
+ DIV: 9,
27
+ MAKE_CLOSURE: 10,
28
+ CALL: 11,
29
+ CALL_METHOD: 12,
30
+ RETURN: 13,
31
+ POP: 14,
32
+ LT: 15, // pop b, pop a → push (a < b)
33
+ GT: 16,
34
+ EQ: 17,
35
+ JUMP: 18, // unconditional — operand = absolute bytecode index
36
+ JUMP_IF_FALSE: 19, // pop value; jump if falsy
37
+ LTE: 20, // a <= b
38
+ GTE: 21, // a >= b
39
+ NEQ: 22, // a !== b
40
+ LOAD_UPVALUE: 23, // push frame.closure.upvalues[operand].read()
41
+ STORE_UPVALUE: 24, // frame.closure.upvalues[operand].write(pop())
42
+
43
+ // ── Unary ──────────────────────────
44
+ UNARY_NEG: 25, // -x
45
+ UNARY_POS: 26, // +x
46
+ UNARY_NOT: 27, // !x
47
+ UNARY_BITNOT: 28, // ~x
48
+ TYPEOF: 29, // typeof x
49
+ VOID: 30, // void x → always undefined
50
+
51
+ TYPEOF_SAFE: 31, // operand = name constIdx — typeof guard for undeclared globals
52
+ BUILD_ARRAY: 32, // operand = element count — pops N values → pushes array
53
+ BUILD_OBJECT: 33, // operand = pair count — pops N*2 (key,val) → pushes object
54
+ SET_PROP: 34, // pop val, pop key, peek obj → obj[key] = val (obj stays on stack)
55
+ GET_PROP_COMPUTED: 35, // pop key, peek obj → push obj[key] (computed: nums[i])
56
+
57
+ MOD: 36, // a % b
58
+ BAND: 37, // a & b
59
+ BOR: 38, // a | b
60
+ BXOR: 39, // a ^ b
61
+ SHL: 40, // a << b
62
+ SHR: 41, // a >> b
63
+ USHR: 42, // a >>> b
64
+
65
+ JUMP_IF_FALSE_OR_POP: 43, // && — if top falsy: jump (keep), else: pop, eval RHS
66
+ JUMP_IF_TRUE_OR_POP: 44, // || — if top truthy: jump (keep), else: pop, eval RHS
67
+
68
+ DELETE_PROP: 45,
69
+ IN: 46, // a in b
70
+ INSTANCEOF: 47, // a instanceof b
71
+
72
+ // ── NEW ────────────────────────────────────────────
73
+ LOAD_THIS: 48, // push frame.thisVal
74
+ NEW: 49, // operand = argCount — construct a new object
75
+ DUP: 50, // duplicate top of stack
76
+ THROW: 51, // pop value, throw it
77
+ LOOSE_EQ: 52, // a == b (abstract equality)
78
+ LOOSE_NEQ: 53, // a != b (abstract inequality)
79
+
80
+ FOR_IN_SETUP: 54, // pop obj → build enumerable-key iterator → push {keys,i}
81
+ FOR_IN_NEXT: 55, // operand=exit_pc; pop iter; if done→jump; else push next key
82
+
83
+ // ── Self-modifying bytecode ────────────────────────────────
84
+ PATCH: 56, // pop destPc; constants[operand]=word[]; write words into bytecode[destPc..]
85
+ };
86
+
87
+ export let OP: Partial<typeof OP_ORIGINAL> = {};
88
+ // Construct randomized opcode mapping
89
+ if (SHUFFLE_OPCODES) {
90
+ let used = new Set();
91
+ for (const key in OP_ORIGINAL) {
92
+ let val;
93
+ do {
94
+ val = Math.floor(Math.random() * 256);
95
+ } while (used.has(val));
96
+ used.add(val);
97
+ OP[key] = val;
98
+ }
99
+ } else {
100
+ OP = OP_ORIGINAL;
101
+ }
102
+
103
+ // Reverse map for comment generation
104
+ const OP_NAME = Object.fromEntries(Object.entries(OP).map(([k, v]) => [v, k]));
105
+
106
+ const JUMP_OPS = new Set([
107
+ OP.JUMP,
108
+ OP.JUMP_IF_FALSE,
109
+ OP.JUMP_IF_TRUE_OR_POP,
110
+ OP.JUMP_IF_FALSE_OR_POP,
111
+ OP.FOR_IN_NEXT,
112
+ ]);
113
+
114
+ // ─────────────────────────────────────────────────────────────────
115
+ // Constant Pool
116
+ // Primitives (string/number/bool) are interned (deduped).
117
+ // Object entries (fn descriptors) are always appended — no dedup.
118
+ // ─────────────────────────────────────────────────────────────────
119
+ class ConstantPool {
120
+ items: any[];
121
+ _index: Map<string, number>;
122
+
123
+ constructor() {
124
+ this.items = []; // ordered pool entries
125
+ this._index = new Map(); // primitive dedup map
126
+ }
127
+
128
+ intern(val) {
129
+ // Only intern primitives — objects must use addObject()
130
+ const key = `${typeof val}:${val}`;
131
+ if (this._index.has(key)) return this._index.get(key);
132
+ const idx = this.items.length;
133
+ this.items.push(val);
134
+ this._index.set(key, idx);
135
+ return idx;
136
+ }
137
+
138
+ addObject(obj) {
139
+ const idx = this.items.length;
140
+ this.items.push(obj);
141
+ return idx;
142
+ }
143
+ }
144
+
145
+ // ─────────────────────────────────────────────────────────────────
146
+ // Scope
147
+ // Each function call gets its own Scope. Locals are resolved to
148
+ // numeric slots at compile time — zero name lookups at runtime.
149
+ // ─────────────────────────────────────────────────────────────────
150
+ class Scope {
151
+ parent: Scope | null;
152
+ _locals: Map<string, number>;
153
+ _next: number;
154
+
155
+ constructor(parent = null) {
156
+ this.parent = parent;
157
+ this._locals = new Map(); // name → slot index
158
+ this._next = 0;
159
+ }
160
+
161
+ define(name) {
162
+ if (!this._locals.has(name)) {
163
+ this._locals.set(name, this._next++);
164
+ }
165
+ return this._locals.get(name);
166
+ }
167
+
168
+ // Walk up scope chain. If we fall off the top → global.
169
+ resolve(name) {
170
+ if (this._locals.has(name)) {
171
+ return { kind: "local", slot: this._locals.get(name) };
172
+ }
173
+ if (this.parent) return this.parent.resolve(name);
174
+ return { kind: "global" };
175
+ }
176
+
177
+ get localCount() {
178
+ return this._next;
179
+ }
180
+ }
181
+
182
+ // ─────────────────────────────────────────────────────────────────
183
+ // FnContext
184
+ // Compiler-side state for the function currently being compiled.
185
+ // Distinct from runtime Frame — this is compile-time only.
186
+ // ─────────────────────────────────────────────────────────────────
187
+ class FnContext {
188
+ upvalues: { name: string; isLocal: number; index: number }[];
189
+ parentCtx: FnContext | null;
190
+ scope: Scope;
191
+ compiler: Compiler;
192
+ bc: any[];
193
+
194
+ constructor(compiler, parentCtx = null) {
195
+ this.compiler = compiler;
196
+ this.parentCtx = parentCtx;
197
+ this.scope = new Scope();
198
+
199
+ this.bc = [];
200
+ this.upvalues = []; // { name, isLocal, index }
201
+ }
202
+
203
+ // Find or register a captured variable as an upvalue.
204
+ // isLocal=true → captured directly from parent's locals[index]
205
+ // isLocal=false → relayed from parent's own upvalue list[index]
206
+ addUpvalue(name, isLocal, index) {
207
+ const existing = this.upvalues.findIndex((u) => u.name === name);
208
+ if (existing !== -1) return existing;
209
+ const idx = this.upvalues.length;
210
+ this.upvalues.push({ name, isLocal, index: index });
211
+ return idx;
212
+ }
213
+ }
214
+
215
+ // ─────────────────────────────────────────────────────────────────
216
+ // Compiler
217
+ // ─────────────────────────────────────────────────────────────────
218
+ class Compiler {
219
+ constants: ConstantPool;
220
+ fnDescriptors: any[];
221
+ bytecode: any[];
222
+ mainStartPc: number;
223
+
224
+ _currentCtx: FnContext | null;
225
+ _pendingLabel: string | null;
226
+ _forInCount: number;
227
+ _loopStack: {
228
+ type: "loop" | "switch" | "block";
229
+ label: string | null;
230
+ breakJumps: number[];
231
+ continueJumps: number[];
232
+ }[];
233
+
234
+ options: Options;
235
+ serializer: Serializer;
236
+
237
+ constructor(options: Options) {
238
+ this.options = options;
239
+ this.constants = new ConstantPool();
240
+ this.fnDescriptors = []; // populated in pass 1
241
+ this.bytecode = [];
242
+ this.mainStartPc = 0;
243
+ this._currentCtx = null; // FnContext of the function being compiled, null at top-level
244
+ this._loopStack = []; // { breakJumps: number[], continueJumps: number[] } per active loop
245
+ this._pendingLabel = null;
246
+ this._forInCount = 0; // counter for synthetic for-in iterator global names
247
+
248
+ this.serializer = new Serializer(this);
249
+ }
250
+
251
+ // ── Variable resolution ──────────────────────────────────────
252
+ // Walks up the FnContext chain. Crossing a context boundary means
253
+ // we're capturing from an outer function — register an upvalue.
254
+ _resolve(name, ctx) {
255
+ if (!ctx) return { kind: "global" };
256
+
257
+ // 1. Own locals
258
+ if (ctx.scope._locals.has(name)) {
259
+ return { kind: "local", slot: ctx.scope._locals.get(name) };
260
+ }
261
+
262
+ // 2. No parent context → must be global
263
+ if (!ctx.parentCtx) return { kind: "global" };
264
+
265
+ // 3. Ask parent — recurse up the chain
266
+ const parentResult = this._resolve(name, ctx.parentCtx);
267
+ if (parentResult.kind === "global") return { kind: "global" };
268
+
269
+ // 4. Parent has it (as local or upvalue) — register an upvalue here.
270
+ // isLocal=true means "take it straight from parent's locals[index]"
271
+ // isLocal=false means "relay parent's upvalue[index]" (multi-level capture)
272
+ const isLocal = parentResult.kind === "local";
273
+ const index = isLocal ? parentResult.slot : parentResult.index;
274
+ const uvIdx = ctx.addUpvalue(name, isLocal, index);
275
+ return { kind: "upvalue", index: uvIdx };
276
+ }
277
+
278
+ // ── Entry point ──────────────────────────────────────────────
279
+
280
+ compile(source) {
281
+ const ast = parser.parse(source, { sourceType: "script" });
282
+
283
+ return this.compileAST(ast);
284
+ }
285
+
286
+ compileAST(ast) {
287
+ // Pass 1 — compile every FunctionDeclaration into a descriptor.
288
+ // Traverse finds them regardless of nesting depth.
289
+ traverse(ast, {
290
+ FunctionDeclaration: (path) => {
291
+ // Only handle top-level functions for this MVP.
292
+ // (Parent is Program node)
293
+ if (path.parent.type !== "Program") return;
294
+ this._compileFunctionDecl(path.node);
295
+ path.skip(); // don't recurse into nested functions
296
+ },
297
+ });
298
+
299
+ // Pass 2 — compile top-level statements into BYTECODE.
300
+ this._compileMain(ast.program.body);
301
+
302
+ return {
303
+ bytecode: this.bytecode,
304
+ mainStartPc: this.mainStartPc,
305
+ };
306
+ }
307
+
308
+ // ── Function Declaration ──────────────────────────────────────
309
+
310
+ _compileFunctionDecl(node) {
311
+ // Create a context whose parent is whatever we're currently compiling.
312
+ // This is what lets _resolve cross function boundaries correctly.
313
+ const ctx = new FnContext(this, this._currentCtx);
314
+ const savedCtx = this._currentCtx;
315
+ this._currentCtx = ctx;
316
+
317
+ // Params occupy the first N local slots (args are copied in on CALL)
318
+ for (const param of node.params) {
319
+ if (param.type === "AssignmentPattern") {
320
+ ctx.scope.define(param.left.name);
321
+ } else {
322
+ ctx.scope.define(param.name);
323
+ }
324
+ }
325
+
326
+ // Reserve the next slot for the implicit `arguments` object.
327
+ // Slot index will always equal paramCount (params are 0..paramCount-1).
328
+ ctx.scope.define("arguments");
329
+
330
+ // ── Pass 2: emit default-value guards at top of fn body ─────
331
+ // Mirrors what JS engines do: if the caller passed undefined (or
332
+ // nothing), evaluate the default expression and overwrite the slot.
333
+ // Default expressions are full expressions, so f(x = a + b) and
334
+ // f(x = foo()) both work correctly.
335
+ for (const param of node.params) {
336
+ if (param.type !== "AssignmentPattern") continue;
337
+
338
+ const slot = ctx.scope._locals.get(param.left.name);
339
+
340
+ // if (param === undefined) param = <default expr>
341
+ ctx.bc.push([OP.LOAD_LOCAL, slot]);
342
+ ctx.bc.push([OP.LOAD_CONST, this.constants.intern(undefined)]);
343
+ ctx.bc.push([OP.EQ]);
344
+ ctx.bc.push([OP.JUMP_IF_FALSE, 0]);
345
+ const skipIdx = ctx.bc.length - 1;
346
+
347
+ this._compileExpr(param.right, ctx.scope, ctx.bc); // eval default
348
+ ctx.bc.push([OP.STORE_LOCAL, slot]);
349
+
350
+ ctx.bc[skipIdx][1] = ctx.bc.length; // patch skip jump
351
+ }
352
+
353
+ for (const stmt of node.body.body) {
354
+ this._compileStatement(stmt, ctx.scope, ctx.bc);
355
+ }
356
+
357
+ // If we fall off the end of the function, implicitly return undefined.
358
+ ctx.bc.push([OP.LOAD_CONST, this.constants.intern(undefined)]);
359
+ ctx.bc.push([OP.RETURN]);
360
+
361
+ this._currentCtx = savedCtx; // restore before touching fnDescriptors
362
+
363
+ var fnIdx = this.fnDescriptors.length;
364
+ node._fnIdx = fnIdx; // for error messages
365
+
366
+ const desc = {
367
+ name: node.id?.name || "<anonymous>",
368
+ paramCount: node.params.length,
369
+ localCount: ctx.scope.localCount,
370
+ upvalueDescriptors: ctx.upvalues.map((u) => ({
371
+ isLocal: u.isLocal,
372
+ _index: u.index,
373
+ })),
374
+ bytecode: ctx.bc,
375
+ // Indices assigned after pushing into the pool
376
+ _fnIdx: this.fnDescriptors.length,
377
+ _constIdx: null,
378
+ };
379
+
380
+ this.fnDescriptors.push(desc);
381
+ desc._constIdx = this.constants.addObject(desc); // object entry, no dedup
382
+ return desc;
383
+ }
384
+
385
+ // ── Main (top-level) ─────────────────────────────────────────
386
+
387
+ _compileMain(body) {
388
+ this.mainStartPc = 0; // ← record main's entry point
389
+ const bc = this.bytecode;
390
+
391
+ // Hoist all FunctionDeclarations: MAKE_CLOSURE → STORE_GLOBAL
392
+ // (mirrors JS hoisting — functions are available before other code)
393
+ for (const node of body) {
394
+ if (node.type !== "FunctionDeclaration") continue;
395
+ const desc = this.fnDescriptors.find((d) => d._fnIdx === node._fnIdx);
396
+ const nameIdx = this.constants.intern(node.id.name);
397
+ bc.push([OP.MAKE_CLOSURE, desc._constIdx]);
398
+ bc.push([OP.STORE_GLOBAL, nameIdx]);
399
+ }
400
+
401
+ // Compile everything else in order
402
+ for (const node of body) {
403
+ if (node.type === "FunctionDeclaration") continue;
404
+ this._compileStatement(node, null, bc); // null scope → global context
405
+ }
406
+
407
+ bc.push([OP.RETURN]); // end program
408
+
409
+ // Now that main is compiled, we can append all the function bodies at the end of the bytecode.
410
+ for (const descriptor of this.fnDescriptors) {
411
+ descriptor.startPc = this.bytecode.length;
412
+
413
+ descriptor.bytecode.push([OP.RETURN]); // ensure every function ends with RETURN
414
+
415
+ if (this.options.selfModifying) {
416
+ // Preamble is 2 instructions: LOAD_CONST(destPc) + PATCH(bodyConst)
417
+ // Real body starts immediately after the preamble.
418
+ const bodyPc = descriptor.startPc + 2;
419
+
420
+ // Build real body with jump targets resolved from bodyPc as the base.
421
+ const realBodyInstrs = descriptor.bytecode.map((instr) =>
422
+ this._offsetJump(instr, bodyPc),
423
+ );
424
+
425
+ // Pack each instruction into a 32-bit word and store as a constant.
426
+ // The PATCH handler will write these words directly into this.bytecode.
427
+ const realBodyWords =
428
+ this.serializer._serializeBytecode(realBodyInstrs);
429
+ const bodyConstIdx = this.constants.addObject(realBodyWords);
430
+
431
+ // Emit preamble: push destination PC, then PATCH.
432
+ const destPcConstIdx = this.constants.intern(bodyPc);
433
+ this.bytecode.push([OP.LOAD_CONST, destPcConstIdx]);
434
+ this.bytecode.push([OP.PATCH, bodyConstIdx]);
435
+
436
+ // Garbage fill — same length as real body, never executed (PATCH fires first).
437
+ for (let i = 0; i < realBodyInstrs.length; i++) {
438
+ this.bytecode.push([OP.LOAD_CONST, 0]);
439
+ }
440
+ } else {
441
+ for (const instr of descriptor.bytecode) {
442
+ this.bytecode.push(this._offsetJump(instr, descriptor.startPc));
443
+ }
444
+ }
445
+ }
446
+
447
+ if (this.bytecode.length > 0xffffff)
448
+ throw new Error(
449
+ `Program too large: ${this.bytecode.length} instructions, max 16,777,215`,
450
+ );
451
+
452
+ if (this.constants.items.length > 0xffffff)
453
+ throw new Error(
454
+ `Constant pool too large: ${this.constants.items.length} entries, max 16,777,215`,
455
+ );
456
+ }
457
+
458
+ _offsetJump(instr, offset) {
459
+ if (JUMP_OPS.has(instr[0]) && instr[1] !== undefined) {
460
+ return [instr[0], instr[1] + offset];
461
+ }
462
+ return instr;
463
+ }
464
+
465
+ // ── Statements ───────────────────────────────────────────────
466
+
467
+ _compileStatement(node, scope, bc) {
468
+ switch (node.type) {
469
+ case "BlockStatement": {
470
+ for (const stmt of node.body) {
471
+ this._compileStatement(stmt, scope, bc);
472
+ }
473
+ break;
474
+ }
475
+
476
+ case "FunctionDeclaration": {
477
+ // Nested function — compile it into a descriptor, then emit
478
+ // MAKE_CLOSURE so it's captured as a live closure at runtime.
479
+ // (_compileFunctionDecl pushes/pops _currentCtx internally)
480
+ const desc = this._compileFunctionDecl(node);
481
+ bc.push([OP.MAKE_CLOSURE, desc._constIdx]);
482
+ if (scope) {
483
+ const slot = scope.define(node.id.name);
484
+ bc.push([OP.STORE_LOCAL, slot]);
485
+ } else {
486
+ bc.push([OP.STORE_GLOBAL, this.constants.intern(node.id.name)]);
487
+ }
488
+ break;
489
+ }
490
+
491
+ case "ThrowStatement": {
492
+ this._compileExpr(node.argument, scope, bc);
493
+ bc.push([OP.THROW]);
494
+ break;
495
+ }
496
+
497
+ case "ReturnStatement": {
498
+ if (node.argument) {
499
+ this._compileExpr(node.argument, scope, bc);
500
+ } else {
501
+ bc.push([OP.LOAD_CONST, this.constants.intern(undefined)]);
502
+ }
503
+ bc.push([OP.RETURN]);
504
+ break;
505
+ }
506
+
507
+ case "ExpressionStatement": {
508
+ this._compileExpr(node.expression, scope, bc);
509
+ bc.push([OP.POP]); // discard return value of statement-level expressions
510
+ break;
511
+ }
512
+
513
+ case "VariableDeclaration": {
514
+ for (const decl of node.declarations) {
515
+ // Push the initialiser (or undefined if absent)
516
+ if (decl.init) {
517
+ this._compileExpr(decl.init, scope, bc);
518
+ } else {
519
+ bc.push([OP.LOAD_CONST, this.constants.intern(undefined)]);
520
+ }
521
+ // Store: local slot if inside a function, global name otherwise
522
+ if (scope) {
523
+ const slot = scope.define(decl.id.name);
524
+ bc.push([OP.STORE_LOCAL, slot]);
525
+ } else {
526
+ bc.push([OP.STORE_GLOBAL, this.constants.intern(decl.id.name)]);
527
+ }
528
+ }
529
+ break;
530
+ }
531
+
532
+ case "IfStatement": {
533
+ // 1. Compile the test expression → leaves a value on the stack
534
+ this._compileExpr(node.test, scope, bc);
535
+ // 2. Emit JUMP_IF_FALSE with placeholder target
536
+ bc.push([OP.JUMP_IF_FALSE, 0]);
537
+ const jumpIfFalseIdx = bc.length - 1;
538
+ // 3. Compile the consequent block (the "then" branch)
539
+ // Consequent may be a BlockStatement or a bare statement (no braces)
540
+ const consequentBody =
541
+ node.consequent.type === "BlockStatement"
542
+ ? node.consequent.body
543
+ : [node.consequent];
544
+ for (const stmt of consequentBody) {
545
+ this._compileStatement(stmt, scope, bc);
546
+ }
547
+ if (node.alternate) {
548
+ // 4a. Consequent needs to jump OVER the else block when done
549
+ bc.push([OP.JUMP, 0]);
550
+ const jumpOverElseIdx = bc.length - 1;
551
+ // Patch JUMP_IF_FALSE to land here (start of else)
552
+ bc[jumpIfFalseIdx][1] = bc.length;
553
+ // 5. Compile the alternate (else) block
554
+ const altBody =
555
+ node.alternate.type === "BlockStatement"
556
+ ? node.alternate.body
557
+ : [node.alternate]; // handles `else if` — it's just a nested IfStatement
558
+ for (const stmt of altBody) {
559
+ this._compileStatement(stmt, scope, bc);
560
+ }
561
+ // Patch the JUMP to land after the else block
562
+ bc[jumpOverElseIdx][1] = bc.length;
563
+ } else {
564
+ // 4b. No else — patch JUMP_IF_FALSE to land right after the then block
565
+ bc[jumpIfFalseIdx][1] = bc.length;
566
+ }
567
+ break;
568
+ }
569
+
570
+ case "WhileStatement": {
571
+ const _wLabel = this._pendingLabel;
572
+ this._pendingLabel = null;
573
+ this._loopStack.push({
574
+ type: "loop",
575
+ label: _wLabel,
576
+ breakJumps: [],
577
+ continueJumps: [],
578
+ });
579
+ const loopCtxW = this._loopStack[this._loopStack.length - 1];
580
+
581
+ const loopTop = bc.length;
582
+ this._compileExpr(node.test, scope, bc);
583
+ bc.push([OP.JUMP_IF_FALSE, 0]);
584
+ const exitJumpIdx = bc.length - 1;
585
+
586
+ for (const stmt of node.body.body) {
587
+ this._compileStatement(stmt, scope, bc);
588
+ }
589
+
590
+ // continue → re-evaluate the test
591
+ for (const idx of loopCtxW.continueJumps) bc[idx][1] = loopTop;
592
+ bc.push([OP.JUMP, loopTop]);
593
+
594
+ const exitTargetW = bc.length;
595
+ bc[exitJumpIdx][1] = exitTargetW;
596
+ for (const idx of loopCtxW.breakJumps) bc[idx][1] = exitTargetW;
597
+
598
+ this._loopStack.pop();
599
+ break;
600
+ }
601
+
602
+ case "DoWhileStatement": {
603
+ const _dwLabel = this._pendingLabel;
604
+ this._pendingLabel = null;
605
+ this._loopStack.push({
606
+ type: "loop",
607
+ label: _dwLabel,
608
+ breakJumps: [],
609
+ continueJumps: [],
610
+ });
611
+ const loopCtxDW = this._loopStack[this._loopStack.length - 1];
612
+
613
+ const loopTopDW = bc.length;
614
+
615
+ for (const stmt of node.body.body) {
616
+ this._compileStatement(stmt, scope, bc);
617
+ }
618
+
619
+ // continue → skip rest of body, fall through to test
620
+ const continueTargetDW = bc.length;
621
+ for (const idx of loopCtxDW.continueJumps)
622
+ bc[idx][1] = continueTargetDW;
623
+
624
+ this._compileExpr(node.test, scope, bc);
625
+ bc.push([OP.JUMP_IF_FALSE, 0]);
626
+ const exitJumpIdxDW = bc.length - 1;
627
+ bc.push([OP.JUMP, loopTopDW]);
628
+
629
+ const exitTargetDW = bc.length;
630
+ bc[exitJumpIdxDW][1] = exitTargetDW;
631
+ for (const idx of loopCtxDW.breakJumps) bc[idx][1] = exitTargetDW;
632
+
633
+ this._loopStack.pop();
634
+ break;
635
+ }
636
+
637
+ case "ForStatement": {
638
+ const _fLabel = this._pendingLabel;
639
+ this._pendingLabel = null;
640
+ this._loopStack.push({
641
+ type: "loop",
642
+ label: _fLabel,
643
+ breakJumps: [],
644
+ continueJumps: [],
645
+ });
646
+ const loopCtxF = this._loopStack[this._loopStack.length - 1];
647
+
648
+ if (node.init) {
649
+ if (node.init.type === "VariableDeclaration") {
650
+ this._compileStatement(node.init, scope, bc);
651
+ } else {
652
+ this._compileExpr(node.init, scope, bc);
653
+ bc.push([OP.POP]);
654
+ }
655
+ }
656
+
657
+ const loopTopF = bc.length;
658
+ if (node.test) {
659
+ this._compileExpr(node.test, scope, bc);
660
+ bc.push([OP.JUMP_IF_FALSE, 0]);
661
+ }
662
+ const exitJumpIdxF = node.test ? bc.length - 1 : null;
663
+
664
+ for (const stmt of node.body.body) {
665
+ this._compileStatement(stmt, scope, bc);
666
+ }
667
+
668
+ // continue → run update (if any) then back to test
669
+ if (node.update) {
670
+ const continueTargetF = bc.length;
671
+ for (const idx of loopCtxF.continueJumps)
672
+ bc[idx][1] = continueTargetF;
673
+ this._compileExpr(node.update, scope, bc);
674
+ bc.push([OP.POP]);
675
+ } else {
676
+ // No update — continue goes straight to the test
677
+ for (const idx of loopCtxF.continueJumps) bc[idx][1] = loopTopF;
678
+ }
679
+
680
+ bc.push([OP.JUMP, loopTopF]);
681
+
682
+ const exitTargetF = bc.length;
683
+ if (exitJumpIdxF !== null) bc[exitJumpIdxF][1] = exitTargetF;
684
+ for (const idx of loopCtxF.breakJumps) bc[idx][1] = exitTargetF;
685
+
686
+ this._loopStack.pop();
687
+ break;
688
+ }
689
+
690
+ case "BreakStatement": {
691
+ bc.push([OP.JUMP, 0]);
692
+ const _bJumpIdx = bc.length - 1;
693
+ if (node.label) {
694
+ const _bLabelName = node.label.name;
695
+ let _bFound = -1;
696
+ for (let _bi = this._loopStack.length - 1; _bi >= 0; _bi--) {
697
+ if (this._loopStack[_bi].label === _bLabelName) {
698
+ _bFound = _bi;
699
+ break;
700
+ }
701
+ }
702
+ if (_bFound === -1)
703
+ throw new Error(`Label '${_bLabelName}' not found`);
704
+ this._loopStack[_bFound].breakJumps.push(_bJumpIdx);
705
+ } else {
706
+ if (this._loopStack.length === 0)
707
+ throw new Error("break outside loop");
708
+ this._loopStack[this._loopStack.length - 1].breakJumps.push(
709
+ _bJumpIdx,
710
+ );
711
+ }
712
+ break;
713
+ }
714
+
715
+ case "ContinueStatement": {
716
+ bc.push([OP.JUMP, 0]);
717
+ const _cJumpIdx = bc.length - 1;
718
+ if (node.label) {
719
+ const _cLabelName = node.label.name;
720
+ let _cFound = -1;
721
+ for (let _ci = this._loopStack.length - 1; _ci >= 0; _ci--) {
722
+ if (
723
+ this._loopStack[_ci].label === _cLabelName &&
724
+ this._loopStack[_ci].type === "loop"
725
+ ) {
726
+ _cFound = _ci;
727
+ break;
728
+ }
729
+ }
730
+ if (_cFound === -1)
731
+ throw new Error(`Label '${_cLabelName}' not found for continue`);
732
+ this._loopStack[_cFound].continueJumps.push(_cJumpIdx);
733
+ } else {
734
+ if (this._loopStack.length === 0)
735
+ throw new Error("continue outside loop");
736
+ // Find the innermost loop (skip switch and block contexts)
737
+ let loopIdx = -1;
738
+ for (let i = this._loopStack.length - 1; i >= 0; i--) {
739
+ if (this._loopStack[i].type === "loop") {
740
+ loopIdx = i;
741
+ break;
742
+ }
743
+ }
744
+ if (loopIdx === -1) throw new Error("continue outside loop");
745
+ this._loopStack[loopIdx].continueJumps.push(_cJumpIdx);
746
+ }
747
+ break;
748
+ }
749
+
750
+ case "SwitchStatement": {
751
+ const _swLabel = this._pendingLabel;
752
+ this._pendingLabel = null;
753
+ this._loopStack.push({
754
+ type: "switch",
755
+ label: _swLabel,
756
+ breakJumps: [],
757
+ continueJumps: [],
758
+ });
759
+ const switchCtx = this._loopStack[this._loopStack.length - 1];
760
+
761
+ // Compile the discriminant and leave it on the stack
762
+ this._compileExpr(node.discriminant, scope, bc);
763
+
764
+ const cases = node.cases;
765
+ const defaultIdx = cases.findIndex((c) => c.test === null);
766
+
767
+ // Dispatch section: emit case checks
768
+ const bodyJumps = []; // { cas, jumpIdx }
769
+
770
+ for (const cas of cases) {
771
+ if (cas.test === null) continue; // Skip default in dispatch
772
+
773
+ // Check this case: DUP; LOAD_CONST; EQ; JUMP_IF_FALSE
774
+ bc.push([OP.DUP]);
775
+ this._compileExpr(cas.test, scope, bc);
776
+ bc.push([OP.EQ]);
777
+ bc.push([OP.JUMP_IF_FALSE, 0]); // Jump to next check (patched later)
778
+ const skipIdx = bc.length - 1;
779
+
780
+ // If matched, jump to this case's body
781
+ bc.push([OP.JUMP, 0]); // Jump to body (patched later)
782
+ bodyJumps.push({ cas, jumpIdx: bc.length - 1 });
783
+
784
+ // Patch the JUMP_IF_FALSE to the next check
785
+ bc[skipIdx][1] = bc.length;
786
+ }
787
+
788
+ // No match found: jump to default (or exit if no default)
789
+ bc.push([OP.JUMP, 0]);
790
+ const noMatchJumpIdx = bc.length - 1;
791
+
792
+ // Body section: compile all case bodies in source order
793
+ const bodyStart = new Map();
794
+ for (const cas of cases) {
795
+ bodyStart.set(cas, bc.length);
796
+ for (const stmt of cas.consequent) {
797
+ this._compileStatement(stmt, scope, bc);
798
+ }
799
+ }
800
+
801
+ // Patch the no-match jump to default or exit
802
+ const exitTarget = bc.length;
803
+ if (defaultIdx !== -1) {
804
+ bc[noMatchJumpIdx][1] = bodyStart.get(cases[defaultIdx]);
805
+ } else {
806
+ bc[noMatchJumpIdx][1] = exitTarget;
807
+ }
808
+
809
+ // Patch all body jumps
810
+ for (const { cas, jumpIdx } of bodyJumps) {
811
+ bc[jumpIdx][1] = bodyStart.get(cas);
812
+ }
813
+
814
+ // Exit: pop the discriminant and patch break jumps
815
+ bc.push([OP.POP]);
816
+ for (const idx of switchCtx.breakJumps) {
817
+ bc[idx][1] = bc.length - 1; // Point to the POP instruction
818
+ }
819
+
820
+ this._loopStack.pop();
821
+ break;
822
+ }
823
+
824
+ case "LabeledStatement": {
825
+ const _lName = node.label.name;
826
+ const _lBody = node.body;
827
+ const _lIsLoop =
828
+ _lBody.type === "ForStatement" ||
829
+ _lBody.type === "WhileStatement" ||
830
+ _lBody.type === "DoWhileStatement" ||
831
+ _lBody.type === "ForInStatement";
832
+ const _lIsSwitch = _lBody.type === "SwitchStatement";
833
+
834
+ if (_lIsLoop || _lIsSwitch) {
835
+ // Pass label down to the loop/switch handler via _pendingLabel
836
+ this._pendingLabel = _lName;
837
+ this._compileStatement(_lBody, scope, bc);
838
+ this._pendingLabel = null; // safety clear if handler didn't consume it
839
+ } else {
840
+ // Non-loop labeled statement (e.g. labeled block) — only break is valid
841
+ this._loopStack.push({
842
+ type: "block",
843
+ label: _lName,
844
+ breakJumps: [],
845
+ continueJumps: [],
846
+ });
847
+ this._compileStatement(_lBody, scope, bc);
848
+ const _lEntry = this._loopStack.pop()!;
849
+ const _lExit = bc.length;
850
+ for (const _lIdx of _lEntry.breakJumps) bc[_lIdx][1] = _lExit;
851
+ }
852
+ break;
853
+ }
854
+
855
+ case "ForInStatement": {
856
+ const _fiLabel = this._pendingLabel;
857
+ this._pendingLabel = null;
858
+
859
+ // Evaluate the object expression → on stack
860
+ this._compileExpr(node.right, scope, bc);
861
+ // FOR_IN_SETUP: pops obj, pushes iterator {keys, i}
862
+ bc.push([OP.FOR_IN_SETUP]);
863
+
864
+ // Store iterator in a hidden slot so break/continue need no cleanup
865
+ let emitLoadIter: () => void;
866
+ let emitStoreIter: () => void;
867
+ if (scope) {
868
+ // Reserve a hidden local slot (no name mapping needed)
869
+ const iterSlot = scope._next++;
870
+ emitLoadIter = () => bc.push([OP.LOAD_LOCAL, iterSlot]);
871
+ emitStoreIter = () => bc.push([OP.STORE_LOCAL, iterSlot]);
872
+ } else {
873
+ // Top level — use a synthetic global that won't collide with user code
874
+ const iterNameIdx = this.constants.intern(
875
+ "__fi" + this._forInCount++,
876
+ );
877
+ emitLoadIter = () => bc.push([OP.LOAD_GLOBAL, iterNameIdx]);
878
+ emitStoreIter = () => bc.push([OP.STORE_GLOBAL, iterNameIdx]);
879
+ }
880
+ emitStoreIter();
881
+
882
+ this._loopStack.push({
883
+ type: "loop",
884
+ label: _fiLabel,
885
+ breakJumps: [],
886
+ continueJumps: [],
887
+ });
888
+ const loopCtxFI = this._loopStack[this._loopStack.length - 1];
889
+
890
+ const loopTopFI = bc.length;
891
+
892
+ // Load iterator, attempt to get next key
893
+ emitLoadIter();
894
+ bc.push([OP.FOR_IN_NEXT, 0]); // exit target patched below
895
+ const forInNextPatch = bc.length - 1;
896
+
897
+ // Assign the key (now on top of stack) to the loop variable
898
+ if (node.left.type === "VariableDeclaration") {
899
+ const name = node.left.declarations[0].id.name;
900
+ if (scope) {
901
+ const slot = scope.define(name);
902
+ bc.push([OP.STORE_LOCAL, slot]);
903
+ } else {
904
+ bc.push([OP.STORE_GLOBAL, this.constants.intern(name)]);
905
+ }
906
+ } else if (node.left.type === "Identifier") {
907
+ const res = this._resolve(node.left.name, this._currentCtx);
908
+ if (res.kind === "local") {
909
+ bc.push([OP.STORE_LOCAL, res.slot]);
910
+ } else if (res.kind === "upvalue") {
911
+ bc.push([OP.STORE_UPVALUE, res.index]);
912
+ } else {
913
+ bc.push([OP.STORE_GLOBAL, this.constants.intern(node.left.name)]);
914
+ }
915
+ } else {
916
+ const src = generate(node.left).code;
917
+ throw new Error(
918
+ `Unsupported for-in left-hand side: ${node.left.type}\n → ${src}`,
919
+ );
920
+ }
921
+
922
+ // Compile the loop body
923
+ const fiBody =
924
+ node.body.type === "BlockStatement" ? node.body.body : [node.body];
925
+ for (const stmt of fiBody) {
926
+ this._compileStatement(stmt, scope, bc);
927
+ }
928
+
929
+ // continue → re-load iterator and check next key
930
+ for (const idx of loopCtxFI.continueJumps) bc[idx][1] = loopTopFI;
931
+ bc.push([OP.JUMP, loopTopFI]);
932
+
933
+ const exitTargetFI = bc.length;
934
+ bc[forInNextPatch][1] = exitTargetFI;
935
+ for (const idx of loopCtxFI.breakJumps) bc[idx][1] = exitTargetFI;
936
+
937
+ this._loopStack.pop();
938
+ break;
939
+ }
940
+
941
+ default: {
942
+ // Use @babel/generator to reproduce the source of unsupported nodes
943
+ // so we can emit a clear error with context.
944
+ const src = generate(node).code;
945
+ throw new Error(`Unsupported statement: ${node.type}\n → ${src}`);
946
+ }
947
+ }
948
+ }
949
+
950
+ // ── Expressions ──────────────────────────────────────────────
951
+
952
+ _compileExpr(node, scope, bc) {
953
+ switch (node.type) {
954
+ case "NumericLiteral":
955
+ case "StringLiteral": {
956
+ bc.push([OP.LOAD_CONST, this.constants.intern(node.value)]);
957
+ break;
958
+ }
959
+
960
+ case "BooleanLiteral": {
961
+ bc.push([OP.LOAD_CONST, this.constants.intern(node.value)]);
962
+ break;
963
+ }
964
+
965
+ case "NullLiteral": {
966
+ bc.push([OP.LOAD_CONST, this.constants.intern(null)]);
967
+ break;
968
+ }
969
+
970
+ case "Identifier": {
971
+ // scope=null means we're at the top-level → always global
972
+ const res = this._resolve(node.name, this._currentCtx);
973
+ if (res.kind === "local") {
974
+ bc.push([OP.LOAD_LOCAL, res.slot]);
975
+ } else if (res.kind === "upvalue") {
976
+ bc.push([OP.LOAD_UPVALUE, res.index]);
977
+ } else {
978
+ bc.push([OP.LOAD_GLOBAL, this.constants.intern(node.name)]);
979
+ }
980
+ break;
981
+ }
982
+
983
+ case "ThisExpression": {
984
+ bc.push([OP.LOAD_THIS]);
985
+ break;
986
+ }
987
+
988
+ case "NewExpression": {
989
+ // Push callee, then args — identical layout to CALL but uses NEW opcode
990
+ this._compileExpr(node.callee, scope, bc);
991
+ for (const arg of node.arguments) this._compileExpr(arg, scope, bc);
992
+ bc.push([OP.NEW, node.arguments.length]);
993
+ break;
994
+ }
995
+
996
+ case "SequenceExpression": {
997
+ // (a, b, c) → eval a → POP, eval b → POP, eval c → leave on stack
998
+ // Matches CPython's BINARY_OP / POP_TOP pattern for comma expressions.
999
+ for (let i = 0; i < node.expressions.length - 1; i++) {
1000
+ this._compileExpr(node.expressions[i], scope, bc);
1001
+ bc.push([OP.POP]); // discard intermediate result
1002
+ }
1003
+ // Last expression — its value is the result of the whole sequence
1004
+ this._compileExpr(
1005
+ node.expressions[node.expressions.length - 1],
1006
+ scope,
1007
+ bc,
1008
+ );
1009
+ break;
1010
+ }
1011
+
1012
+ case "ConditionalExpression": {
1013
+ // test ? consequent : alternate
1014
+ // Identical to IfStatement codegen, just lives in expression context.
1015
+ this._compileExpr(node.test, scope, bc);
1016
+
1017
+ bc.push([OP.JUMP_IF_FALSE, 0]);
1018
+ const jumpToElse = bc.length - 1;
1019
+
1020
+ this._compileExpr(node.consequent, scope, bc);
1021
+
1022
+ bc.push([OP.JUMP, 0]);
1023
+ const jumpToEnd = bc.length - 1;
1024
+
1025
+ bc[jumpToElse][1] = bc.length; // patch: false → alternate
1026
+ this._compileExpr(node.alternate, scope, bc);
1027
+
1028
+ bc[jumpToEnd][1] = bc.length; // patch: after consequent → end
1029
+ break;
1030
+ }
1031
+
1032
+ case "LogicalExpression": {
1033
+ // Pattern (CPython-style):
1034
+ // eval LHS
1035
+ // JUMP_IF_*_OR_POP → target (past RHS)
1036
+ // eval RHS ← only reached if LHS didn't short-circuit
1037
+ // [target lands here, stack top is the result either way]
1038
+
1039
+ this._compileExpr(node.left, scope, bc);
1040
+
1041
+ if (node.operator === "||") {
1042
+ // Short-circuit if LHS is TRUTHY — keep it, skip RHS
1043
+ bc.push([OP.JUMP_IF_TRUE_OR_POP, 0]);
1044
+ const jumpIdx = bc.length - 1;
1045
+ this._compileExpr(node.right, scope, bc);
1046
+ bc[jumpIdx][1] = bc.length; // patch target to after RHS
1047
+ } else if (node.operator === "&&") {
1048
+ // Short-circuit if LHS is FALSY — keep it, skip RHS
1049
+ bc.push([OP.JUMP_IF_FALSE_OR_POP, 0]);
1050
+ const jumpIdx = bc.length - 1;
1051
+ this._compileExpr(node.right, scope, bc);
1052
+ bc[jumpIdx][1] = bc.length; // patch target to after RHS
1053
+ } else {
1054
+ throw new Error(`Unsupported logical operator: ${node.operator}`);
1055
+ }
1056
+ break;
1057
+ }
1058
+
1059
+ case "BinaryExpression": {
1060
+ this._compileExpr(node.left, scope, bc);
1061
+ this._compileExpr(node.right, scope, bc);
1062
+ const arithOp = {
1063
+ "+": OP.ADD,
1064
+ "-": OP.SUB,
1065
+ "*": OP.MUL,
1066
+ "/": OP.DIV,
1067
+ "%": OP.MOD,
1068
+ "&": OP.BAND,
1069
+ "|": OP.BOR,
1070
+ "^": OP.BXOR,
1071
+ "<<": OP.SHL,
1072
+ ">>": OP.SHR,
1073
+ ">>>": OP.USHR,
1074
+ }[node.operator];
1075
+
1076
+ const cmpOp = {
1077
+ "<": OP.LT,
1078
+ ">": OP.GT,
1079
+ "===": OP.EQ,
1080
+ "==": OP.LOOSE_EQ,
1081
+ "<=": OP.LTE,
1082
+ ">=": OP.GTE,
1083
+ "!==": OP.NEQ,
1084
+ "!=": OP.LOOSE_NEQ,
1085
+ in: OP.IN, // ← add
1086
+ instanceof: OP.INSTANCEOF, // ← add
1087
+ }[node.operator];
1088
+ const resolvedOp = arithOp ?? cmpOp;
1089
+ if (resolvedOp === undefined)
1090
+ throw new Error(`Unsupported operator: ${node.operator}`);
1091
+ bc.push([resolvedOp]);
1092
+
1093
+ break;
1094
+ }
1095
+
1096
+ case "UpdateExpression": {
1097
+ const res = this._resolve(node.argument.name, this._currentCtx);
1098
+ const bumpOp = node.operator === "++" ? OP.ADD : OP.SUB;
1099
+ const one = this.constants.intern(1);
1100
+
1101
+ // Helper closures: emit load / store for whichever resolution kind we have
1102
+ const emitLoad = () => {
1103
+ if (res.kind === "local") bc.push([OP.LOAD_LOCAL, res.slot]);
1104
+ else if (res.kind === "upvalue")
1105
+ bc.push([OP.LOAD_UPVALUE, res.index]);
1106
+ else
1107
+ bc.push([
1108
+ OP.LOAD_GLOBAL,
1109
+ this.constants.intern(node.argument.name),
1110
+ ]);
1111
+ };
1112
+ const emitStore = () => {
1113
+ if (res.kind === "local") bc.push([OP.STORE_LOCAL, res.slot]);
1114
+ else if (res.kind === "upvalue")
1115
+ bc.push([OP.STORE_UPVALUE, res.index]);
1116
+ else
1117
+ bc.push([
1118
+ OP.STORE_GLOBAL,
1119
+ this.constants.intern(node.argument.name),
1120
+ ]);
1121
+ };
1122
+
1123
+ emitLoad();
1124
+ if (!node.prefix) bc.push([OP.DUP]); // post: save old value before mutating
1125
+ bc.push([OP.LOAD_CONST, one]);
1126
+ bc.push([bumpOp]);
1127
+ emitStore();
1128
+ if (node.prefix) emitLoad(); // pre: reload new value as result
1129
+
1130
+ break;
1131
+ }
1132
+
1133
+ case "AssignmentExpression": {
1134
+ const compoundOp = {
1135
+ "+=": OP.ADD,
1136
+ "-=": OP.SUB,
1137
+ "*=": OP.MUL,
1138
+ "/=": OP.DIV,
1139
+ "%=": OP.MOD,
1140
+ "&=": OP.BAND,
1141
+ "|=": OP.BOR,
1142
+ "^=": OP.BXOR,
1143
+ "<<=": OP.SHL,
1144
+ ">>=": OP.SHR,
1145
+ ">>>=": OP.USHR,
1146
+ }[node.operator];
1147
+
1148
+ const isCompound = compoundOp !== undefined;
1149
+
1150
+ if (node.operator !== "=" && !isCompound) {
1151
+ throw new Error(`Unsupported assignment operator: ${node.operator}`);
1152
+ }
1153
+
1154
+ // ── Member assignment: obj.x = val or arr[i] = val ──────
1155
+ if (node.left.type === "MemberExpression") {
1156
+ this._compileExpr(node.left.object, scope, bc); // push obj
1157
+
1158
+ if (node.left.computed) {
1159
+ this._compileExpr(node.left.property, scope, bc); // push key (runtime)
1160
+ } else {
1161
+ bc.push([
1162
+ OP.LOAD_CONST,
1163
+ this.constants.intern(node.left.property.name),
1164
+ ]);
1165
+ }
1166
+
1167
+ if (isCompound) {
1168
+ // Duplicate obj+key on the stack so we can read before we write.
1169
+ // Stack before DUP2: [..., obj, key]
1170
+ // We need: [..., obj, key, obj, key] → GET_PROP_COMPUTED → [..., obj, key, currentVal]
1171
+ // Cheapest approach without a DUP opcode: re-compile the member read.
1172
+ // (emits obj + key again; a future peephole pass could DUP instead)
1173
+ this._compileExpr(node.left.object, scope, bc);
1174
+ if (node.left.computed) {
1175
+ this._compileExpr(node.left.property, scope, bc);
1176
+ } else {
1177
+ bc.push([
1178
+ OP.LOAD_CONST,
1179
+ this.constants.intern(node.left.property.name),
1180
+ ]);
1181
+ }
1182
+ bc.push([OP.GET_PROP_COMPUTED]); // [..., obj, key, currentVal]
1183
+ this._compileExpr(node.right, scope, bc); // [..., obj, key, currentVal, rhs]
1184
+ bc.push([compoundOp]); // [..., obj, key, newVal]
1185
+ } else {
1186
+ this._compileExpr(node.right, scope, bc); // [..., obj, key, val]
1187
+ }
1188
+
1189
+ bc.push([OP.SET_PROP]); // obj[key] = val, leaves val on stack
1190
+ break;
1191
+ }
1192
+
1193
+ // ── Plain identifier assignment ────────────────────────────
1194
+ const res = this._resolve(node.left.name, this._currentCtx);
1195
+
1196
+ if (isCompound) {
1197
+ // Load the current value of the target first
1198
+ if (res.kind === "local") {
1199
+ bc.push([OP.LOAD_LOCAL, res.slot]);
1200
+ } else if (res.kind === "upvalue") {
1201
+ bc.push([OP.LOAD_UPVALUE, res.index]);
1202
+ } else {
1203
+ bc.push([OP.LOAD_GLOBAL, this.constants.intern(node.left.name)]);
1204
+ }
1205
+ }
1206
+
1207
+ this._compileExpr(node.right, scope, bc); // push RHS
1208
+
1209
+ if (isCompound) {
1210
+ bc.push([compoundOp]); // apply binary op → leaves newVal on stack
1211
+ }
1212
+
1213
+ // Store & leave value on stack (assignment is an expression)
1214
+ if (res.kind === "local") {
1215
+ bc.push([OP.STORE_LOCAL, res.slot]);
1216
+ bc.push([OP.LOAD_LOCAL, res.slot]);
1217
+ } else if (res.kind === "upvalue") {
1218
+ bc.push([OP.STORE_UPVALUE, res.index]);
1219
+ bc.push([OP.LOAD_UPVALUE, res.index]);
1220
+ } else {
1221
+ const nameIdx = this.constants.intern(node.left.name);
1222
+ bc.push([OP.STORE_GLOBAL, nameIdx]);
1223
+ bc.push([OP.LOAD_GLOBAL, nameIdx]);
1224
+ }
1225
+ break;
1226
+ }
1227
+
1228
+ case "CallExpression": {
1229
+ if (node.callee.type === "MemberExpression") {
1230
+ // ── Method call: console.log(...)
1231
+ // Push receiver first (GET_PROP leaves it; CALL_METHOD pops it as `this`)
1232
+ this._compileExpr(node.callee.object, scope, bc);
1233
+ const prop = node.callee.property.name;
1234
+ const propIdx = this.constants.intern(prop);
1235
+ bc.push([OP.LOAD_CONST, propIdx]);
1236
+ bc.push([OP.GET_PROP]);
1237
+ for (const arg of node.arguments) this._compileExpr(arg, scope, bc);
1238
+ bc.push([OP.CALL_METHOD, node.arguments.length]);
1239
+ } else {
1240
+ // ── Plain call: add(5, 10)
1241
+ this._compileExpr(node.callee, scope, bc);
1242
+ for (const arg of node.arguments) this._compileExpr(arg, scope, bc);
1243
+ bc.push([OP.CALL, node.arguments.length]);
1244
+ }
1245
+ break;
1246
+ }
1247
+
1248
+ case "UnaryExpression": {
1249
+ // Special case: typeof on a bare identifier must not throw if undeclared.
1250
+ // We emit TYPEOF_SAFE (operand = name constant index) instead of
1251
+ // compiling the argument first. The VM does the guard itself.
1252
+ if (node.operator === "typeof" && node.argument.type === "Identifier") {
1253
+ const res = this._resolve(node.argument.name, this._currentCtx);
1254
+ if (res.kind === "global") {
1255
+ // Potentially undeclared — let VM guard it
1256
+ bc.push([OP.LOAD_CONST, this.constants.intern(node.argument.name)]);
1257
+ bc.push([OP.TYPEOF_SAFE]);
1258
+ break;
1259
+ }
1260
+ // Known local or upvalue — safe to load first, then typeof
1261
+ }
1262
+ // All other unary ops: compile argument first, then apply operator
1263
+ this._compileExpr(node.argument, scope, bc);
1264
+ switch (node.operator) {
1265
+ case "-":
1266
+ bc.push([OP.UNARY_NEG]);
1267
+ break;
1268
+ case "+":
1269
+ bc.push([OP.UNARY_POS]);
1270
+ break;
1271
+ case "!":
1272
+ bc.push([OP.UNARY_NOT]);
1273
+ break;
1274
+ case "~":
1275
+ bc.push([OP.UNARY_BITNOT]);
1276
+ break;
1277
+ case "typeof":
1278
+ bc.push([OP.TYPEOF]);
1279
+ break;
1280
+ case "void":
1281
+ bc.push([OP.VOID]);
1282
+ break;
1283
+
1284
+ case "delete": {
1285
+ const arg = node.argument;
1286
+ if (arg.type === "MemberExpression") {
1287
+ this._compileExpr(arg.object, scope, bc);
1288
+ if (arg.computed) {
1289
+ this._compileExpr(arg.property, scope, bc);
1290
+ } else {
1291
+ bc.push([
1292
+ OP.LOAD_CONST,
1293
+ this.constants.intern(arg.property.name),
1294
+ ]);
1295
+ }
1296
+ bc.push([OP.DELETE_PROP]);
1297
+ } else {
1298
+ // delete x, delete 0, etc. — always true in non-strict, just push true
1299
+ bc.push([OP.LOAD_CONST, this.constants.intern(true)]);
1300
+ }
1301
+ break;
1302
+ }
1303
+
1304
+ default:
1305
+ throw new Error(`Unsupported unary operator: ${node.operator}`);
1306
+ }
1307
+ break;
1308
+ }
1309
+
1310
+ case "FunctionExpression": {
1311
+ // Compile into a descriptor exactly like a declaration,
1312
+ // but leave the resulting closure ON THE STACK — no store.
1313
+ // The surrounding expression (assignment, call arg, return) consumes it.
1314
+ const desc = this._compileFunctionDecl(node);
1315
+ bc.push([OP.MAKE_CLOSURE, desc._constIdx]);
1316
+ break;
1317
+ }
1318
+
1319
+ case "MemberExpression": {
1320
+ this._compileExpr(node.object, scope, bc);
1321
+ if (node.computed) {
1322
+ // nums[i] — key is runtime value
1323
+ this._compileExpr(node.property, scope, bc);
1324
+ } else {
1325
+ // point.x — push key as string, same opcode handles both
1326
+ bc.push([OP.LOAD_CONST, this.constants.intern(node.property.name)]);
1327
+ }
1328
+
1329
+ // GET_PROP_COMPUTED pops the object — correct for value access.
1330
+ // GET_PROP (peek) is only used in CallExpression's method call path
1331
+ // where the receiver must survive on the stack for CALL_METHOD.
1332
+ bc.push([OP.GET_PROP_COMPUTED]);
1333
+ break;
1334
+ }
1335
+
1336
+ case "ArrayExpression": {
1337
+ // Compile each element left→right, then BUILD_ARRAY collapses them.
1338
+ // Sparse arrays (holes) get explicit undefined per slot.
1339
+ for (const el of node.elements) {
1340
+ if (el === null) {
1341
+ // hole: e.g. [1,,3]
1342
+ bc.push([OP.LOAD_CONST, this.constants.intern(undefined)]);
1343
+ } else {
1344
+ this._compileExpr(el, scope, bc);
1345
+ }
1346
+ }
1347
+ bc.push([OP.BUILD_ARRAY, node.elements.length]);
1348
+ break;
1349
+ }
1350
+ case "ObjectExpression": {
1351
+ // For each property: push key (always as string), push value.
1352
+ // BUILD_OBJECT pops pairs right→left and assembles the object.
1353
+ for (const prop of node.properties) {
1354
+ if (prop.type === "SpreadElement") {
1355
+ throw new Error("Object spread not supported");
1356
+ }
1357
+ // Key — identifier shorthand (`{x:1}`) or string/number literal
1358
+ const key = prop.key;
1359
+ let keyStr;
1360
+ if (key.type === "Identifier") {
1361
+ keyStr = key.name; // {x: 1} → key is "x"
1362
+ } else if (
1363
+ key.type === "StringLiteral" ||
1364
+ key.type === "NumericLiteral"
1365
+ ) {
1366
+ keyStr = String(key.value); // {"x": 1} or {0: 1}
1367
+ } else {
1368
+ throw new Error(`Unsupported object key type: ${key.type}`);
1369
+ }
1370
+ bc.push([OP.LOAD_CONST, this.constants.intern(keyStr)]);
1371
+ // Value — any expression, including FunctionExpression
1372
+ this._compileExpr(prop.value, scope, bc);
1373
+ }
1374
+ bc.push([OP.BUILD_OBJECT, node.properties.length]);
1375
+ break;
1376
+ }
1377
+
1378
+ default: {
1379
+ const src = generate(node).code;
1380
+ throw new Error(`Unsupported expression: ${node.type}\n → ${src}`);
1381
+ }
1382
+ }
1383
+ }
1384
+ }
1385
+
1386
+ // ─────────────────────────────────────────────────────────────────
1387
+ // Serializer
1388
+ // Turns the compiled output into a commented JS source string.
1389
+ // ─────────────────────────────────────────────────────────────────
1390
+ class Serializer {
1391
+ compiler: Compiler;
1392
+
1393
+ constructor(compiler: Compiler) {
1394
+ this.compiler = compiler;
1395
+ }
1396
+
1397
+ get constants() {
1398
+ return this.compiler.constants.items;
1399
+ }
1400
+
1401
+ get fnDescriptors() {
1402
+ return this.compiler.fnDescriptors;
1403
+ }
1404
+
1405
+ // Produce a JS literal for a constant pool entry
1406
+ _serializeConst(val) {
1407
+ if (val === null) return "null";
1408
+ if (val === undefined) return "undefined";
1409
+ if (typeof val === "object" && val._fnIdx !== undefined) {
1410
+ return `FN[${val._fnIdx}]`; // fn descriptor → reference by FN index
1411
+ }
1412
+ return JSON.stringify(val); // number / string / bool
1413
+ }
1414
+
1415
+ // One instruction → "[op, operand] // MNEMONIC description"
1416
+ _serializeInstr(instr) {
1417
+ const constants = this.constants;
1418
+
1419
+ const [op, operand] = instr;
1420
+ const name = OP_NAME[op] || `OP_${op}`;
1421
+ let comment = name;
1422
+
1423
+ // Annotate operand with its meaning
1424
+ if (operand !== undefined) {
1425
+ switch (op) {
1426
+ case OP.LOAD_CONST:
1427
+ case OP.MAKE_CLOSURE: {
1428
+ const val = constants[operand];
1429
+ if (val && typeof val === "object" && val.name) {
1430
+ comment += ` FN[${val._fnIdx}] → fn:${val.name}`;
1431
+ } else {
1432
+ comment += ` ${JSON.stringify(val)}`;
1433
+ }
1434
+ break;
1435
+ }
1436
+ case OP.LOAD_LOCAL:
1437
+ case OP.STORE_LOCAL:
1438
+ comment += ` slot[${operand}]`;
1439
+ break;
1440
+ case OP.LOAD_UPVALUE:
1441
+ case OP.STORE_UPVALUE:
1442
+ comment += ` upvalue[${operand}]`;
1443
+ break;
1444
+ case OP.LOAD_GLOBAL:
1445
+ case OP.STORE_GLOBAL:
1446
+ comment += ` "${constants[operand]}"`;
1447
+ break;
1448
+ case OP.CALL:
1449
+ case OP.CALL_METHOD:
1450
+ comment += ` (${operand} args)`;
1451
+ break;
1452
+
1453
+ case OP.BUILD_ARRAY:
1454
+ comment += ` (${operand} elements)`;
1455
+ break;
1456
+ case OP.BUILD_OBJECT:
1457
+ comment += ` (${operand} pairs)`;
1458
+ break;
1459
+
1460
+ case OP.NEW:
1461
+ comment += ` (${operand} args)`;
1462
+ break;
1463
+
1464
+ default:
1465
+ comment += ` ${operand}`;
1466
+ }
1467
+ }
1468
+
1469
+ // Pack a [op, operand?] instruction pair into a single 32-bit word.
1470
+ // Shared between the Serializer and the obfuscation path in _compileMain.
1471
+
1472
+ if (!PACK) {
1473
+ const instrText =
1474
+ operand !== undefined ? `[${op}, ${operand}]` : `[${op}]`;
1475
+
1476
+ return {
1477
+ text: ` ${instrText.padEnd(12)}, // ${comment}`,
1478
+ value: operand !== undefined ? [op, operand] : [op],
1479
+ };
1480
+ }
1481
+
1482
+ function packInstr(instr) {
1483
+ const [op, operand] = instr;
1484
+ if (operand !== undefined && !Number.isInteger(operand))
1485
+ throw new Error(`Non-integer operand: ${operand}`);
1486
+ if (operand !== undefined && operand < 0)
1487
+ throw new Error(`Negative operand: ${operand}`);
1488
+ if (operand !== undefined && operand > 0xffffff)
1489
+ throw new Error(`Operand overflow (max 0xFFFFFF): ${operand}`);
1490
+ return operand !== undefined ? (operand << 8) | op : op;
1491
+ }
1492
+
1493
+ return {
1494
+ text: "",
1495
+ value: packInstr(instr),
1496
+ };
1497
+ }
1498
+
1499
+ // Serialize one fn descriptor into its FN[n] block
1500
+ _serializeFn(desc) {
1501
+ const lines = [
1502
+ ` { // FN[${desc._fnIdx}] — ${desc.name}`,
1503
+ ` paramCount: ${desc.paramCount},`,
1504
+ ` localCount: ${desc.localCount},`,
1505
+ ` upvalueDescriptors: ${JSON5.stringify(desc.upvalueDescriptors)},`,
1506
+ ` startPc: ${desc.startPc},`,
1507
+ ` },`,
1508
+ ];
1509
+ return lines.join("\n");
1510
+ }
1511
+
1512
+ // Serialize the CONSTANTS array, showing FN[n] references
1513
+ _serializeConstants() {
1514
+ const lines = ["var CONSTANTS = ["];
1515
+ this.constants.forEach((val, idx) => {
1516
+ lines.push(` /* ${idx} */ ${this._serializeConst(val)},`);
1517
+ });
1518
+ lines.push("];");
1519
+ return lines.join("\n");
1520
+ }
1521
+
1522
+ _serializeBytecode(bytecode) {
1523
+ if (!PACK) {
1524
+ return bytecode.map((instr) => this._serializeInstr(instr).value);
1525
+ }
1526
+
1527
+ let words = [];
1528
+
1529
+ // ── BYTECODE
1530
+ for (const instr of bytecode) {
1531
+ words.push(this._serializeInstr(instr).value);
1532
+ }
1533
+
1534
+ // Convert packed words → raw 4-byte little-endian binary → base64
1535
+ const buf = new Uint8Array(words.length * 4);
1536
+ words.forEach((w, i) => {
1537
+ buf[i * 4] = w & 0xff;
1538
+ buf[i * 4 + 1] = (w >>> 8) & 0xff;
1539
+ buf[i * 4 + 2] = (w >>> 16) & 0xff;
1540
+ buf[i * 4 + 3] = (w >>> 24) & 0xff;
1541
+ });
1542
+ const b64 = Buffer.from(buf).toString("base64");
1543
+
1544
+ return b64;
1545
+ }
1546
+
1547
+ serialize(bytecode, mainStartPc) {
1548
+ const sections = [];
1549
+
1550
+ // ── FN array
1551
+ const fnLines = ["var FN = ["];
1552
+ for (const desc of this.fnDescriptors) {
1553
+ fnLines.push(this._serializeFn(desc));
1554
+ }
1555
+ fnLines.push("];");
1556
+ sections.push(fnLines.join("\n"));
1557
+
1558
+ // ── CONSTANTS
1559
+ sections.push(this._serializeConstants());
1560
+
1561
+ if (PACK) {
1562
+ sections.push(`var BYTECODE = "${this._serializeBytecode(bytecode)}";`);
1563
+ } else {
1564
+ sections.push(
1565
+ `var BYTECODE = [\n ${bytecode.map((instr) => this._serializeInstr(instr).text).join(",\n ")}\n];`,
1566
+ );
1567
+ }
1568
+
1569
+ // ── MAIN_START_PC
1570
+ sections.push(`var MAIN_START_PC = ${mainStartPc};`);
1571
+
1572
+ sections.push(`var PACK = ${PACK};`);
1573
+
1574
+ // ── VM runtime
1575
+ sections.push(VM_RUNTIME);
1576
+
1577
+ return sections.join("\n\n");
1578
+ }
1579
+ }
1580
+
1581
+ // ─────────────────────────────────────────────────────────────────
1582
+ // VM Runtime (emitted verbatim into the output file)
1583
+ // ─────────────────────────────────────────────────────────────────
1584
+ const VM_RUNTIME = `
1585
+ // ── Opcodes ──────────────────────────────────────────────────────
1586
+ var OP = ${JSON5.stringify(OP)};
1587
+ ${stripTypeScriptTypes(
1588
+ readFileSync(join(import.meta.dirname, "./runtime.ts"), "utf-8").split(
1589
+ "@START",
1590
+ )[1],
1591
+ )}
1592
+ `;
1593
+
1594
+ interface Options {
1595
+ sourceMap?: boolean;
1596
+ selfModifying?: boolean;
1597
+ }
1598
+
1599
+ export function compileAndSerialize(
1600
+ sourceCode: string,
1601
+ options: Options = {
1602
+ selfModifying: true,
1603
+ },
1604
+ ) {
1605
+ const compiler = new Compiler(options);
1606
+ const result = compiler.compile(sourceCode);
1607
+ const output = compiler.serializer.serialize(
1608
+ result.bytecode,
1609
+ result.mainStartPc,
1610
+ );
1611
+
1612
+ const finalOutput = output;
1613
+
1614
+ return {
1615
+ code: finalOutput,
1616
+ };
1617
+ }