git » fjorth.git » commit 90a3d96

separate out html, code, tests

author Alan Dipert
2025-10-04 20:48:01 UTC
committer Alan Dipert
2025-10-04 20:48:01 UTC
parent 36f3c7b38813a052d5d7a36b6cf97d51c616bf4e

separate out html, code, tests

fjorth.html +5 -521
fjorth.js +270 -0
test.js +244 -0

diff --git a/fjorth.html b/fjorth.html
index 8632daf..3671c12 100644
--- a/fjorth.html
+++ b/fjorth.html
@@ -2,7 +2,7 @@
 <html>
 <head>
   <meta charset="utf-8" />
-  <title>Hyper‑Minimal Forth — QUnit Tests</title>
+  <title>Fjorth — QUnit Tests</title>
   <!-- Prefer local QUnit assets to avoid network prompts; if unavailable, swap to CDN manually -->
   <link rel="stylesheet" href="qunit.css">
 </head>
@@ -11,529 +11,13 @@
   <div id="qunit-fixture"></div>
 
   <!-- Interpreter (callback errors) -->
-  <script>
-  (function(){
-  'use strict';
-
-    class Forth {
-      constructor(opts={}){
-        this.print = opts.print || (s=>{});
-        this.println = opts.println || (s=>{});
-        this.onError = opts.onError || null;         // structured error callback
-        this.sliceMs = opts.sliceMs ?? 0;            // delay between timeslices
-        this.stepBudget = opts.stepBudget ?? 2000;   // instructions per slice
-        this.onIdleCbs = [];
-
-        this.OP = { LIT:1, CALL:2, RET:3, JUMP:4, JZ:5,
-                    DUP:6, DROP:7, SWAP:8, OVER:9,
-                    FETCH:10, STORE:11, ADD:12, SUB:13,
-                    DOT:14, EMIT:15 };
-
-        this.reset();
-      }
-
-      // ===== VM & Core State =====
-      reset(){
-        this.D = []; this.R = []; this.H = []; this.here = 0;
-        this.Dict = []; this.jsHandlers = new Map();
-        this.STATE = 0; this.current = null;
-        this.toks = []; this.ti = 0; this.lastTok = null; this.src='';
-        this.currentIp = null; this.pending = [];
-        this.running = false; this.paused = false;
-        this.ctrl = [];
-        this.lastError = null;
-        this.installPrims();
-      }
-
-      addPrim(name, opcode){ const xt = this.here; this.emitCell(opcode); this.emitCell(this.OP.RET); this.Dict.push({name, immediate:false, xt}); return xt; }
-      emitCell(x){ this.H[this.here++] = x|0; }
-      push(x){ this.D.push(x|0); }
-      pop(){ if(!this.D.length){ this.fail({code:'stack_underflow', message:'stack underflow', phase:'exec', stackDepth:this.D.length, rstackDepth:this.R.length, token:this.lastTok}); return 0; } return this.D.pop()|0; }
-      peek(){ if(!this.D.length){ this.fail({code:'stack_underflow', message:'stack underflow', phase:'exec', stackDepth:this.D.length, rstackDepth:this.R.length, token:this.lastTok}); return 0; } return this.D[this.D.length-1]|0; }
-      find(name){ for(let i=this.Dict.length-1;i>=0;--i){ if(this.Dict[i].name===name) return this.Dict[i]; } return null; }
-
-      // Structured error dispatcher (no throws)
-      fail(info){
-        const err = new Error(info?.message || info?.code || 'error');
-        if(info && typeof info==='object'){
-          for(const k of Object.keys(info)) err[k]=info[k];
-        }
-        this.lastError = err;
-        if(this.onError){ try{ this.onError(err); }catch(_){} }
-        // stop run loop and notify waiting callbacks with the same error
-        this.running = false;
-        const cbs = this.onIdleCbs.splice(0);
-        cbs.forEach(cb=>{ try{ cb(err); }catch(_){} });
-      }
-
-      // Public extension API: fn called with interpreter as `this`
-      defineJS(name, fn, immediate=false){
-        const xt = this.here;
-        const self = this;
-        this.jsHandlers.set(xt, function(){ return fn.call(self); });
-        this.emitCell(this.OP.CALL); this.emitCell(xt);
-        this.emitCell(this.OP.RET);
-        this.Dict.push({name, immediate, xt});
-      }
-
-      installPrims(){
-        const OP=this.OP;
-        // Core opcodes as words
-        this.addPrim('LIT',OP.LIT); this.addPrim('CALL',OP.CALL); this.addPrim('RET',OP.RET);
-        this.addPrim('JUMP',OP.JUMP); this.addPrim('JZ',OP.JZ);
-        this.addPrim('DUP',OP.DUP); this.addPrim('DROP',OP.DROP); this.addPrim('SWAP',OP.SWAP); this.addPrim('OVER',OP.OVER);
-        this.addPrim('@',OP.FETCH); this.addPrim('!',OP.STORE);
-        this.addPrim('+',OP.ADD); this.addPrim('-',OP.SUB);
-        this.addPrim('.',OP.DOT); this.addPrim('EMIT',OP.EMIT);
-
-        // Compiler words
-        this.defineJS(':', this.wCOLON);
-        this.defineJS(';', this.wSEMICOLON, true);
-        this.defineJS(',', this.wCOMMA);
-        this.defineJS('HERE', this.wWHERE);
-        this.defineJS('IMMEDIATE', this.wIMMEDIATE);
-        this.defineJS("'", this.wTICK);
-
-        // QoL
-        this.defineJS('CR', function(){ this.print('\n'); });
-        this.defineJS('.S', function(){ this.print('<'+this.D.length+'> ' + this.D.join(' ') + '\n'); });
-
-        // Arithmetic helpers
-        this.defineJS('*', function(){ const a=this.pop(), b=this.pop(); if(this.lastError) return; this.push((a*b)|0); });
-        this.defineJS('1+', function(){ const a=this.pop(); if(this.lastError) return; this.push((a+1)|0); });
-        this.defineJS('1-', function(){ const a=this.pop(); if(this.lastError) return; this.push((a-1)|0); });
-
-        // Return stack accessors
-        this.defineJS('>R', function(){ this.R.push(this.pop()); });
-        this.defineJS('R>', function(){ if(!this.R.length){ this.fail({code:'rstack_underflow', message:'rstack underflow', phase:'exec'}); return; } this.push(this.R.pop()|0); });
-        this.defineJS('R@', function(){ if(!this.R.length){ this.fail({code:'rstack_underflow', message:'rstack underflow', phase:'exec'}); return; } this.push(this.R[this.R.length-1]|0); });
-
-        // Comparisons / flags (tiny)
-        this.defineJS('0=', function(){ const a=this.pop(); if(this.lastError) return; this.push(a===0?1:0); });
-
-        // Control structures (immediate)
-        this.defineJS('IF', this.wIF, true);
-        this.defineJS('ELSE', this.wELSE, true);
-        this.defineJS('THEN', this.wTHEN, true);
-        this.defineJS('BEGIN', this.wBEGIN, true);
-        this.defineJS('UNTIL', this.wUNTIL, true);
-        this.defineJS('AGAIN', this.wAGAIN, true);
-      }
-
-      // ===== Outer interpreter (tokenizer) =====
-      isNumber(tok){
-        if(!tok || tok.length===0) return false;
-        let i=0; if(tok[0]==='-'){ if(tok.length===1) return false; i=1; }
-        for(; i<tok.length; i++){ const c=tok.charCodeAt(i); if(c<48||c>57) return false; }
-        return true;
-      }
-      load(src){
-        this.src = String(src);
-        this.toks = [];
-        let s = this.src; let i=0; let inParen=false; let t='';
-        while(i < s.length){
-          const ch = s[i];
-          if(inParen){ if(ch===')'){ inParen=false; } i++; continue; }
-          if(ch==='('){ inParen=true; i++; continue; }
-          if(ch==='\\'){
-            // backslash comment to end of line
-            while(i < s.length && s[i] !== '\n') i++;
-            continue;
-          }
-          // whitespace is a separator
-          if(ch <= ' '){ if(t){ this.toks.push(t); t=''; } i++; continue; }
-          // accumulate
-          t += ch; i++;
-        }
-        if(t) this.toks.push(t);
-        this.ti = 0; this.lastTok = null;
-      }
-      nextToken(){ const t = (this.ti < this.toks.length ? this.toks[this.ti++] : null); this.lastTok = t; return t; }
-
-      interpretNextToken(){
-        const tok = this.nextToken();
-        if(tok==null) return false;
-        if(this.isNumber(tok)){
-          const n = (tok|0);
-          if(this.STATE){ this.emitCell(this.OP.LIT); this.emitCell(n); }
-          else this.push(n);
-          return true;
-        }
-        const w = this.find(tok);
-        if(!w){ this.fail({code:'undefined_word', message:'undefined word: '+tok, phase:'interpret', token:tok, state:this.STATE, stackDepth:this.D.length, rstackDepth:this.R.length}); return false; }
-        if(this.STATE){
-          if(w.immediate){ this.queueWord(w.xt); }
-          else { this.emitCell(this.OP.CALL); this.emitCell(w.xt); }
-        } else {
-          this.queueWord(w.xt);
-        }
-        return true;
-      }
-
-      queueWord(xt){
-        if(typeof xt !== 'number' || xt < 0 || xt >= this.here || this.H[xt] === undefined){
-          this.fail({code:'bad_xt', message:'invalid execution token '+xt, phase:'schedule', token:this.lastTok});
-          return;
-        }
-        if(this.currentIp==null){ this.currentIp = xt; }
-        else { this.pending.push(xt); }
-      }
-
-      // ===== Single VM instruction =====
-      stepVM(){
-        const OP=this.OP; let ip=this.currentIp;
-        let op = this.H[ip++];
-        // Guard: off-the-end treated like implicit RET
-        if(op===undefined){
-          if(this.R.length){ ip = this.R.pop(); op = this.H[ip++]; if(op===undefined){ this.currentIp = null; return; } }
-          else { this.currentIp = null; return; }
-        }
-        const errBase = { phase:'exec', ip:ip-1 };
-        switch(op){
-          case OP.LIT: this.push(this.H[ip++]); break;
-          case OP.CALL: {
-            const target = this.H[ip++];
-            const js = this.jsHandlers.get(target);
-            if(js){ js(); }
-            else {
-              if(target==null || target<0 || target>=this.here || this.H[target]===undefined){ this.fail({...errBase, code:'bad_call', message:'invalid call target '+target}); return; }
-              this.R.push(ip); ip = target;
-            }
-          } break;
-          case OP.RET: if(this.R.length){ ip = this.R.pop(); } else { this.currentIp=null; } break;
-          case OP.JUMP: { const dest = this.H[ip++]; if(dest==null || dest<0 || dest>=this.here || this.H[dest]===undefined){ this.fail({...errBase, code:'bad_jump', message:'invalid jump '+dest}); return; } ip = dest; } break;
-          case OP.JZ: { const dest=this.H[ip++]; const flag=this.pop(); if(this.lastError) return; if(flag===0){ if(dest==null || dest<0 || dest>=this.here || this.H[dest]===undefined){ this.fail({...errBase, code:'bad_jump', message:'invalid jump '+dest}); return; } ip=dest; } } break;
-          case OP.DUP: { const v=this.peek(); if(this.lastError) return; this.push(v); } break;
-          case OP.DROP: this.pop(); break;
-          case OP.SWAP: { const a=this.pop(), b=this.pop(); if(this.lastError) return; this.push(a); this.push(b); } break;
-          case OP.OVER: { const n=this.D.length; if(n<2){ this.fail({...errBase, code:'stack_underflow', message:'stack underflow'}); return; } this.push(this.D[n-2]); } break;
-          case OP.FETCH: { const a=this.pop(); if(this.lastError) return; this.push(this.H[a]|0); } break;
-          case OP.STORE: { const a=this.pop(), v=this.pop(); if(this.lastError) return; this.H[a]=v|0; } break;
-          case OP.ADD: { const a=this.pop(), b=this.pop(); if(this.lastError) return; this.push((a+b)|0); } break;
-          case OP.SUB: { const a=this.pop(), b=this.pop(); if(this.lastError) return; this.push((b-a)|0); } break;
-          case OP.DOT: { const v=this.pop(); if(this.lastError) return; this.print(String(v)+' '); } break;
-          case OP.EMIT: { const v=this.pop(); if(this.lastError) return; this.print(String.fromCharCode(v&0xFF)); } break;
-          default: this.fail({...errBase, code:'bad_opcode', message:'bad opcode '+op+' at '+(ip-1)}); return;
-        }
-        if(this.currentIp!=null) this.currentIp = ip;
-      }
-
-      // ===== Scheduler =====
-      run(onIdle){
-        if(onIdle) this.onIdleCbs.push(onIdle);
-        if(this.running){ return; }
-        this.running = true; this.paused = false; this.lastError = null;
-        const tick = ()=>{
-          if(this.paused){ this.running = false; return; }
-          let budget = this.stepBudget;
-          try{
-            while(budget-- > 0){
-              if(this.lastError){ this.running = false; return; }
-              if(this.currentIp!=null){
-                this.stepVM();
-              } else if(this.ti < this.toks.length){
-                if(!this.interpretNextToken()){ if(this.lastError){ this.running = false; return; } }
-              } else if(this.pending.length){
-                this.currentIp = this.pending.shift();
-              } else {
-                // idle
-                this.running = false;
-                const cbs = this.onIdleCbs.splice(0);
-                cbs.forEach(cb=>{ try{ cb(); }catch(_){} });
-                return;
-              }
-            }
-          } catch(e){
-            // Safety net — convert unexpected exceptions to structured error
-            this.fail({code:'exception', message:e?.message||String(e)});
-            return;
-          }
-          setTimeout(tick, this.sliceMs);
-        };
-        setTimeout(tick, this.sliceMs);
-      }
-
-      pause(){ this.paused = true; }
-      resume(){ if(!this.running){ this.run(); } else { this.paused = false; } }
-
-      // ===== Compiler word impls (bound via defineJS) =====
-      wCOLON(){
-        const name = this.nextToken();
-        if(!name){ this.fail({code:'colon_no_name', message:'expected name after :', phase:'compile'}); return; }
-        const xt = this.here;
-        this.current = { name, xt };
-        this.Dict.push({ name, immediate:false, xt });
-        this.STATE = 1;
-      }
-      wSEMICOLON(){ if(!this.current){ this.fail({code:'no_current_def', message:'no current definition', phase:'compile'}); return; } if(this.ctrl.length){ this.fail({code:'unbalanced_control', message:'unbalanced control flow', phase:'compile'}); return; } this.emitCell(this.OP.RET); this.current=null; this.STATE=0; }
-      wCOMMA(){ this.emitCell(this.pop()); }
-      wWHERE(){ this.push(this.here); }
-      wIMMEDIATE(){ if(!this.Dict.length){ this.fail({code:'no_word', message:'no word', phase:'compile'}); return; } this.Dict[this.Dict.length-1].immediate=true; }
-      wTICK(){ const name=this.nextToken(); const w=this.find(name); if(!w){ this.fail({code:'undefined_word', message:"undefined '"+name+"'", phase:'interpret', token:name}); return; } this.push(w.xt); }
-
-      // ===== Control structure implementations =====
-      wIF(){ if(!this.STATE){ this.fail({code:'if_outside_compile', message:'IF outside compile', phase:'compile'}); return; } this.emitCell(this.OP.JZ); const hole=this.here; this.emitCell(0); this.ctrl.push({k:'IF', hole}); }
-      wELSE(){ if(!this.STATE){ this.fail({code:'else_outside_compile', message:'ELSE outside compile', phase:'compile'}); return; } const top=this.ctrl[this.ctrl.length-1]; if(!top || top.k!=='IF'){ this.fail({code:'else_without_if', message:'ELSE without IF', phase:'compile'}); return; } this.emitCell(this.OP.JUMP); const hole2=this.here; this.emitCell(0); this.H[top.hole]=this.here; this.ctrl[this.ctrl.length-1]={k:'ELSE', hole:hole2}; }
-      wTHEN(){ if(!this.STATE){ this.fail({code:'then_outside_compile', message:'THEN outside compile', phase:'compile'}); return; } const top=this.ctrl.pop(); if(!top || (top.k!=='IF' && top.k!=='ELSE')){ this.fail({code:'then_without_if', message:'THEN without IF/ELSE', phase:'compile'}); return; } this.H[top.hole]=this.here; }
-      wBEGIN(){ if(!this.STATE){ this.fail({code:'begin_outside_compile', message:'BEGIN outside compile', phase:'compile'}); return; } this.ctrl.push({k:'BEGIN', addr:this.here}); }
-      wUNTIL(){ if(!this.STATE){ this.fail({code:'until_outside_compile', message:'UNTIL outside compile', phase:'compile'}); return; } const top=this.ctrl.pop(); if(!top || top.k!=='BEGIN'){ this.fail({code:'until_without_begin', message:'UNTIL without BEGIN', phase:'compile'}); return; } this.emitCell(this.OP.JZ); this.emitCell(top.addr); }
-      wAGAIN(){ if(!this.STATE){ this.fail({code:'again_outside_compile', message:'AGAIN outside compile', phase:'compile'}); return; } const top=this.ctrl.pop(); if(!top || top.k!=='BEGIN'){ this.fail({code:'again_without_begin', message:'AGAIN without BEGIN', phase:'compile'}); return; } this.emitCell(this.OP.JUMP); this.emitCell(top.addr); }
-    }
-
-    window.Forth = Forth;
-  })();
-  </script>
+  <script src="fjorth.js"></script>
 
   <!-- QUnit (local preferred) -->
   <script id="qunit-js" src="qunit.js"></script>
 
   <!-- Tests (ported to QUnit) -->
-  <script>
-  (function(){
-    function startTests(){
-      // Helper to feed exact Forth source while avoiding JS escape confusion.
-      // Usage: runCode(R(String.raw`2 3 \ line\n +`))
-      // R() converts the two characters "\\n" into a real newline so the interpreter sees backslash-to-EOL then the next line.
-      const R = (raw) => raw.replace(/\\n/g, '\n');
-
-      function runCode(code) {
-        return new Promise((resolve, reject) => {
-          const out = [];
-          const vm = new Forth({
-            print: s => { out.push(s); },
-            println: s => { out.push(s + '\n'); },
-            stepBudget: 2000,
-            sliceMs: 0
-          });
-          try {
-            vm.reset();
-            vm.load(code);
-            vm.run((err) => {
-              if (err) reject(err);
-              else resolve({ vm, out: out.join('') });
-            });
-          } catch (e) { reject(e); }
-        });
-      }
-
-      QUnit.module('Hyper‑Minimal Forth');
-
-      QUnit.test('add', async assert => {
-        const { vm } = await runCode('2 3 +');
-        assert.strictEqual(vm.D.length, 1, 'stack has one item');
-        assert.strictEqual(vm.D[0], 5, '2 3 + => 5');
-      });
-
-      QUnit.test('square via colon + call + *', async assert => {
-        const { vm } = await runCode(': SQUARE DUP * ; 7 SQUARE');
-        assert.strictEqual(vm.D.length, 1, 'stack has one item');
-        assert.strictEqual(vm.D[0], 49, '7 SQUARE => 49');
-      });
-
-      QUnit.test('paren comment', async assert => {
-        const { vm } = await runCode('( hi ) 2 3 +');
-        assert.strictEqual(vm.D.length, 1);
-        assert.strictEqual(vm.D[0], 5);
-      });
-
-      QUnit.test('backslash to EOL comment', async assert => {
-        const { vm } = await runCode(R(String.raw`2 3 \\ line\n +`));
-        assert.strictEqual(vm.D.length, 1);
-        assert.strictEqual(vm.D[0], 5);
-      });
-
-      QUnit.test('backslash at end of line only', async assert => {
-        const { vm } = await runCode(R(String.raw`2 \n 3 +`));
-        assert.strictEqual(vm.D.length, 1);
-        assert.strictEqual(vm.D[0], 5);
-      });
-
-      QUnit.test('1- then 1+', async assert => {
-        const { vm } = await runCode('10 1- 1+');
-        assert.strictEqual(vm.D.length, 1);
-        assert.strictEqual(vm.D[0], 10);
-      });
-
-      QUnit.test('return stack accessors', async assert => {
-        const { vm } = await runCode(': SAVE >R R@ R> ; 123 SAVE');
-        assert.strictEqual(vm.D.length, 2, 'two values on stack');
-        assert.strictEqual(vm.D[0], 123);
-        assert.strictEqual(vm.D[1], 123);
-      });
-
-      QUnit.test('return stack roundtrip', async assert => {
-        const { vm } = await runCode(': PASS >R R> ; 7 PASS');
-        assert.strictEqual(vm.D.length, 1);
-        assert.strictEqual(vm.D[0], 7);
-      });
-
-      QUnit.test('nested calls', async assert => {
-        const { vm } = await runCode(': B 1+ ; : A 2 + B ; 39 A');
-        assert.strictEqual(vm.D.length, 1);
-        assert.strictEqual(vm.D[0], 42);
-      });
-
-      QUnit.test('IF/ELSE/THEN true branch', async assert => {
-        const { vm } = await runCode(': T 1 IF 42 ELSE 99 THEN ; T');
-        assert.strictEqual(vm.D.length, 1);
-        assert.strictEqual(vm.D[0], 42);
-      });
-
-      QUnit.test('IF/ELSE/THEN false branch', async assert => {
-        const { vm } = await runCode(': F 0 IF 42 ELSE 99 THEN ; F');
-        assert.strictEqual(vm.D.length, 1);
-        assert.strictEqual(vm.D[0], 99);
-      });
-
-      QUnit.test('BEGIN/UNTIL countdown with 0=', async assert => {
-        const { vm } = await runCode(': CD ( n -- ) BEGIN 1- DUP 0= UNTIL DROP ; 3 CD');
-        assert.strictEqual(vm.D.length, 0, 'stack empty after loop');
-      });
-
-      // Additional negative tests to lock semantics
-      QUnit.test('ELSE outside compile errors', async assert => {
-        await assert.rejects(runCode('ELSE'), /ELSE outside compile/);
-      });
-
-      QUnit.test('BEGIN outside compile errors', async assert => {
-        await assert.rejects(runCode('BEGIN'), /BEGIN outside compile/);
-      });
-
-      QUnit.test('unbalanced control flow at ;', async assert => {
-        await assert.rejects(runCode(': X IF 1 ;'), /unbalanced control flow/);
-      });
-
-      // ===== Additional Coverage =====
-      QUnit.module('Stack & Arithmetic');
-
-      QUnit.test('DUP, DROP, SWAP, OVER', async assert => {
-        const { vm } = await runCode('1 2 DUP SWAP OVER DROP');
-        assert.deepEqual(vm.D, [1,2,2]);
-      });
-
-      QUnit.test('add/sub/mul with negatives', async assert => {
-        const { vm: vm1 } = await runCode('10 3 -');
-        assert.strictEqual(vm1.D[0], 7);
-        const { vm: vm2 } = await runCode('3 10 -');
-        assert.strictEqual(vm2.D[0], -7);
-        const { vm: vm3 } = await runCode('6 7 *');
-        assert.strictEqual(vm3.D[0], 42);
-        const { vm: vm4 } = await runCode('-7 3 +');
-        assert.strictEqual(vm4.D[0], -4);
-      });
-
-      QUnit.module('Memory & Dictionary');
-
-      QUnit.test('HERE , @ roundtrip', async assert => {
-        const { vm } = await runCode('HERE 0 , DUP 99 SWAP ! @');
-        assert.strictEqual(vm.D.length, 1);
-        assert.strictEqual(vm.D[0], 99);
-      });
-
-      QUnit.test("' tick returns execution token", async assert => {
-        const { vm } = await runCode(': SQUARE DUP * ; \' SQUARE');
-        const xt = vm.D[0];
-        assert.strictEqual(xt, vm.find('SQUARE').xt, 'xt on stack matches dictionary entry');
-      });
-
-      QUnit.test('IMMEDIATE marks last word immediate', async assert => {
-        const { vm } = await runCode(': I 123 ; IMMEDIATE');
-        const w = vm.find('I');
-        assert.ok(w && w.immediate === true, 'I is immediate');
-      });
-
-      QUnit.module('Tokenizer & Comments');
-
-      QUnit.test('mixed comments (paren + backslash)', async assert => {
-        const { vm } = await runCode(R(String.raw`(foo) 1 \\ bar\n 2 +`));
-        assert.strictEqual(vm.D[0], 3);
-      });
-
-      QUnit.module('Output');
-
-      QUnit.test('EMIT + CR output', async assert => {
-        const { out } = await runCode('65 EMIT 66 EMIT CR');
-        assert.strictEqual(out, 'AB\n');
-      });
-
-      QUnit.test('. prints with a trailing space', async assert => {
-        const { out } = await runCode('42 .');
-        assert.strictEqual(out, '42 ');
-      });
-
-      QUnit.test('.S prints stack contents', async assert => {
-        const { out } = await runCode('1 2 3 .S');
-        assert.strictEqual(out, '<3> 1 2 3\n');
-      });
-
-      QUnit.module('Errors');
-
-      QUnit.test('undefined word throws', async assert => {
-        await assert.rejects(runCode('NOPE'), /undefined word: NOPE/);
-      });
-
-      QUnit.test('data stack underflow', async assert => {
-        await assert.rejects(runCode('DUP'), /stack underflow/);
-      });
-
-      QUnit.test('return stack underflow', async assert => {
-        await assert.rejects(runCode('R>'), /rstack underflow/);
-      });
-
-      QUnit.test('colon with no name', async assert => {
-        await assert.rejects(runCode(':'), /expected name after/);
-      });
-
-      QUnit.module('API');
-
-      function runWithVM(setup, code){
-        return new Promise((resolve, reject)=>{
-          const out=[];
-          const vm=new Forth({ print:s=>out.push(s), println:s=>out.push(s+'\n'), stepBudget:200, sliceMs:0 });
-          try{ vm.reset(); if(setup) setup(vm); vm.load(code); vm.run(err=>{ if(err) reject(err); else resolve({vm, out:out.join('')}); }); }
-          catch(e){ reject(e); }
-        });
-      }
-
-      QUnit.test('defineJS adds external word', async assert => {
-        const { vm } = await runWithVM(vm=>{
-          vm.defineJS('TWICE', function(){ const a=this.pop(); this.push(a*2); });
-        }, '21 TWICE');
-        assert.strictEqual(vm.D[0], 42);
-      });
-
-      QUnit.test('defineJS immediate flag is recorded', async assert => {
-        const { vm } = await runWithVM(vm=>{
-          vm.defineJS('IWORD', function(){ this.push(9); }, true);
-        }, ': T IWORD ;');
-        const w = vm.find('IWORD');
-        assert.ok(w && w.immediate===true);
-      });
-
-      QUnit.test('pause/resume on long program still completes', async assert => {
-        // Build a long program: 0 then N times (1 +)
-        const N = 1000;
-        const code = '0' + ' 1 +'.repeat(N);
-        const { vm } = await new Promise((resolve, reject)=>{
-          const out=[]; const vm=new Forth({ print:s=>out.push(s), println:s=>out.push(s+'\n'), stepBudget:10, sliceMs:0 });
-          try{ vm.reset(); vm.load(code); vm.run(err=>{ if(err) reject(err); else resolve({vm, out:out.join('')}); }); setTimeout(()=>{ vm.pause(); setTimeout(()=> vm.resume(), 0); }, 0); }
-          catch(e){ reject(e); }
-        });
-        assert.strictEqual(vm.D[0], 1000, 'result equals N after resume');
-      });
-
-    }
-
-    // Start after QUnit loads (local or CDN)
-    if (window.QUnit) {
-      startTests();
-    } else {
-      const s = document.getElementById('qunit-js');
-      if (s) s.addEventListener('load', () => { if (window.QUnit) startTests(); });
-      window.addEventListener('load', () => { if (window.QUnit) startTests(); });
-    }
-  })();
-  </script>
+  <script src="test.js"> </script>
 
   <!-- Prefer local QUnit script; provide CDN fallback comment if needed -->
   <!-- If local qunit.js isn't available in your environment, replace the line above with:
@@ -545,7 +29,7 @@
     function runCodeExpectErr(code, vmOpts={}) {
       return new Promise((resolve) => {
         const seen=[];
-        const vm = new Forth(Object.assign({ onError: e => seen.push(e), stepBudget: 2000, sliceMs: 0 }, vmOpts));
+        const vm = new Fjorth(Object.assign({ onError: e => seen.push(e), stepBudget: 2000, sliceMs: 0 }, vmOpts));
         try {
           vm.reset();
           vm.load(code);
@@ -603,7 +87,7 @@
 
       QUnit.test('errors do not throw synchronously from run()', assert => {
         assert.expect(1);
-        const vm = new Forth({});
+        const vm = new Fjorth({});
         vm.reset();
         vm.load('NOPE');
         let threw = false;
diff --git a/fjorth.js b/fjorth.js
new file mode 100644
index 0000000..4291098
--- /dev/null
+++ b/fjorth.js
@@ -0,0 +1,270 @@
+(function(){
+  'use strict';
+
+  class Fjorth {
+    constructor(opts={}){
+      this.print = opts.print || (s=>{});
+      this.println = opts.println || (s=>{});
+      this.onError = opts.onError || null;         // structured error callback
+      this.sliceMs = opts.sliceMs ?? 0;            // delay between timeslices
+      this.stepBudget = opts.stepBudget ?? 2000;   // instructions per slice
+      this.onIdleCbs = [];
+
+      this.OP = { LIT:1, CALL:2, RET:3, JUMP:4, JZ:5,
+                  DUP:6, DROP:7, SWAP:8, OVER:9,
+                  FETCH:10, STORE:11, ADD:12, SUB:13,
+                  DOT:14, EMIT:15 };
+
+      this.reset();
+    }
+
+    // ===== VM & Core State =====
+    reset(){
+      this.D = []; this.R = []; this.H = []; this.here = 0;
+      this.Dict = []; this.jsHandlers = new Map();
+      this.STATE = 0; this.current = null;
+      this.toks = []; this.ti = 0; this.lastTok = null; this.src='';
+      this.currentIp = null; this.pending = [];
+      this.running = false; this.paused = false;
+      this.ctrl = [];
+      this.lastError = null;
+      this.installPrims();
+    }
+
+    addPrim(name, opcode){ const xt = this.here; this.emitCell(opcode); this.emitCell(this.OP.RET); this.Dict.push({name, immediate:false, xt}); return xt; }
+    emitCell(x){ this.H[this.here++] = x|0; }
+    push(x){ this.D.push(x|0); }
+    pop(){ if(!this.D.length){ this.fail({code:'stack_underflow', message:'stack underflow', phase:'exec', stackDepth:this.D.length, rstackDepth:this.R.length, token:this.lastTok}); return 0; } return this.D.pop()|0; }
+    peek(){ if(!this.D.length){ this.fail({code:'stack_underflow', message:'stack underflow', phase:'exec', stackDepth:this.D.length, rstackDepth:this.R.length, token:this.lastTok}); return 0; } return this.D[this.D.length-1]|0; }
+    find(name){ for(let i=this.Dict.length-1;i>=0;--i){ if(this.Dict[i].name===name) return this.Dict[i]; } return null; }
+
+    // Structured error dispatcher (no throws)
+    fail(info){
+      const err = new Error(info?.message || info?.code || 'error');
+      if(info && typeof info==='object'){
+        for(const k of Object.keys(info)) err[k]=info[k];
+      }
+      this.lastError = err;
+      if(this.onError){ try{ this.onError(err); }catch(_){} }
+      // stop run loop and notify waiting callbacks with the same error
+      this.running = false;
+      const cbs = this.onIdleCbs.splice(0);
+      cbs.forEach(cb=>{ try{ cb(err); }catch(_){} });
+    }
+
+    // Public extension API: fn called with interpreter as `this`
+    defineJS(name, fn, immediate=false){
+      const xt = this.here;
+      const self = this;
+      this.jsHandlers.set(xt, function(){ return fn.call(self); });
+      this.emitCell(this.OP.CALL); this.emitCell(xt);
+      this.emitCell(this.OP.RET);
+      this.Dict.push({name, immediate, xt});
+    }
+
+    installPrims(){
+      const OP=this.OP;
+      // Core opcodes as words
+      this.addPrim('LIT',OP.LIT); this.addPrim('CALL',OP.CALL); this.addPrim('RET',OP.RET);
+      this.addPrim('JUMP',OP.JUMP); this.addPrim('JZ',OP.JZ);
+      this.addPrim('DUP',OP.DUP); this.addPrim('DROP',OP.DROP); this.addPrim('SWAP',OP.SWAP); this.addPrim('OVER',OP.OVER);
+      this.addPrim('@',OP.FETCH); this.addPrim('!',OP.STORE);
+      this.addPrim('+',OP.ADD); this.addPrim('-',OP.SUB);
+      this.addPrim('.',OP.DOT); this.addPrim('EMIT',OP.EMIT);
+
+      // Compiler words
+      this.defineJS(':', this.wCOLON);
+      this.defineJS(';', this.wSEMICOLON, true);
+      this.defineJS(',', this.wCOMMA);
+      this.defineJS('HERE', this.wWHERE);
+      this.defineJS('IMMEDIATE', this.wIMMEDIATE);
+      this.defineJS("'", this.wTICK);
+
+      // QoL
+      this.defineJS('CR', function(){ this.print('\n'); });
+      this.defineJS('.S', function(){ this.print('<'+this.D.length+'> ' + this.D.join(' ') + '\n'); });
+
+      // Arithmetic helpers
+      this.defineJS('*', function(){ const a=this.pop(), b=this.pop(); if(this.lastError) return; this.push((a*b)|0); });
+      this.defineJS('1+', function(){ const a=this.pop(); if(this.lastError) return; this.push((a+1)|0); });
+      this.defineJS('1-', function(){ const a=this.pop(); if(this.lastError) return; this.push((a-1)|0); });
+
+      // Return stack accessors
+      this.defineJS('>R', function(){ this.R.push(this.pop()); });
+      this.defineJS('R>', function(){ if(!this.R.length){ this.fail({code:'rstack_underflow', message:'rstack underflow', phase:'exec'}); return; } this.push(this.R.pop()|0); });
+      this.defineJS('R@', function(){ if(!this.R.length){ this.fail({code:'rstack_underflow', message:'rstack underflow', phase:'exec'}); return; } this.push(this.R[this.R.length-1]|0); });
+
+      // Comparisons / flags (tiny)
+      this.defineJS('0=', function(){ const a=this.pop(); if(this.lastError) return; this.push(a===0?1:0); });
+
+      // Control structures (immediate)
+      this.defineJS('IF', this.wIF, true);
+      this.defineJS('ELSE', this.wELSE, true);
+      this.defineJS('THEN', this.wTHEN, true);
+      this.defineJS('BEGIN', this.wBEGIN, true);
+      this.defineJS('UNTIL', this.wUNTIL, true);
+      this.defineJS('AGAIN', this.wAGAIN, true);
+    }
+
+    // ===== Outer interpreter (tokenizer) =====
+    isNumber(tok){
+      if(!tok || tok.length===0) return false;
+      let i=0; if(tok[0]==='-'){ if(tok.length===1) return false; i=1; }
+      for(; i<tok.length; i++){ const c=tok.charCodeAt(i); if(c<48||c>57) return false; }
+      return true;
+    }
+    load(src){
+      this.src = String(src);
+      this.toks = [];
+      let s = this.src; let i=0; let inParen=false; let t='';
+      while(i < s.length){
+        const ch = s[i];
+        if(inParen){ if(ch===')'){ inParen=false; } i++; continue; }
+        if(ch==='('){ inParen=true; i++; continue; }
+        if(ch==='\\'){
+          // backslash comment to end of line
+          while(i < s.length && s[i] !== '\n') i++;
+          continue;
+        }
+        // whitespace is a separator
+        if(ch <= ' '){ if(t){ this.toks.push(t); t=''; } i++; continue; }
+        // accumulate
+        t += ch; i++;
+      }
+      if(t) this.toks.push(t);
+      this.ti = 0; this.lastTok = null;
+    }
+    nextToken(){ const t = (this.ti < this.toks.length ? this.toks[this.ti++] : null); this.lastTok = t; return t; }
+
+    interpretNextToken(){
+      const tok = this.nextToken();
+      if(tok==null) return false;
+      if(this.isNumber(tok)){
+        const n = (tok|0);
+        if(this.STATE){ this.emitCell(this.OP.LIT); this.emitCell(n); }
+        else this.push(n);
+        return true;
+      }
+      const w = this.find(tok);
+      if(!w){ this.fail({code:'undefined_word', message:'undefined word: '+tok, phase:'interpret', token:tok, state:this.STATE, stackDepth:this.D.length, rstackDepth:this.R.length}); return false; }
+      if(this.STATE){
+        if(w.immediate){ this.queueWord(w.xt); }
+        else { this.emitCell(this.OP.CALL); this.emitCell(w.xt); }
+      } else {
+        this.queueWord(w.xt);
+      }
+      return true;
+    }
+
+    queueWord(xt){
+      if(typeof xt !== 'number' || xt < 0 || xt >= this.here || this.H[xt] === undefined){
+        this.fail({code:'bad_xt', message:'invalid execution token '+xt, phase:'schedule', token:this.lastTok});
+        return;
+      }
+      if(this.currentIp==null){ this.currentIp = xt; }
+      else { this.pending.push(xt); }
+    }
+
+    // ===== Single VM instruction =====
+    stepVM(){
+      const OP=this.OP; let ip=this.currentIp;
+      let op = this.H[ip++];
+      // Guard: off-the-end treated like implicit RET
+      if(op===undefined){
+        if(this.R.length){ ip = this.R.pop(); op = this.H[ip++]; if(op===undefined){ this.currentIp = null; return; } }
+        else { this.currentIp = null; return; }
+      }
+      const errBase = { phase:'exec', ip:ip-1 };
+      switch(op){
+      case OP.LIT: this.push(this.H[ip++]); break;
+      case OP.CALL: {
+        const target = this.H[ip++];
+        const js = this.jsHandlers.get(target);
+        if(js){ js(); }
+        else {
+          if(target==null || target<0 || target>=this.here || this.H[target]===undefined){ this.fail({...errBase, code:'bad_call', message:'invalid call target '+target}); return; }
+          this.R.push(ip); ip = target;
+        }
+      } break;
+      case OP.RET: if(this.R.length){ ip = this.R.pop(); } else { this.currentIp=null; } break;
+      case OP.JUMP: { const dest = this.H[ip++]; if(dest==null || dest<0 || dest>=this.here || this.H[dest]===undefined){ this.fail({...errBase, code:'bad_jump', message:'invalid jump '+dest}); return; } ip = dest; } break;
+      case OP.JZ: { const dest=this.H[ip++]; const flag=this.pop(); if(this.lastError) return; if(flag===0){ if(dest==null || dest<0 || dest>=this.here || this.H[dest]===undefined){ this.fail({...errBase, code:'bad_jump', message:'invalid jump '+dest}); return; } ip=dest; } } break;
+      case OP.DUP: { const v=this.peek(); if(this.lastError) return; this.push(v); } break;
+      case OP.DROP: this.pop(); break;
+      case OP.SWAP: { const a=this.pop(), b=this.pop(); if(this.lastError) return; this.push(a); this.push(b); } break;
+      case OP.OVER: { const n=this.D.length; if(n<2){ this.fail({...errBase, code:'stack_underflow', message:'stack underflow'}); return; } this.push(this.D[n-2]); } break;
+      case OP.FETCH: { const a=this.pop(); if(this.lastError) return; this.push(this.H[a]|0); } break;
+      case OP.STORE: { const a=this.pop(), v=this.pop(); if(this.lastError) return; this.H[a]=v|0; } break;
+      case OP.ADD: { const a=this.pop(), b=this.pop(); if(this.lastError) return; this.push((a+b)|0); } break;
+      case OP.SUB: { const a=this.pop(), b=this.pop(); if(this.lastError) return; this.push((b-a)|0); } break;
+      case OP.DOT: { const v=this.pop(); if(this.lastError) return; this.print(String(v)+' '); } break;
+      case OP.EMIT: { const v=this.pop(); if(this.lastError) return; this.print(String.fromCharCode(v&0xFF)); } break;
+      default: this.fail({...errBase, code:'bad_opcode', message:'bad opcode '+op+' at '+(ip-1)}); return;
+      }
+      if(this.currentIp!=null) this.currentIp = ip;
+    }
+
+    // ===== Scheduler =====
+    run(onIdle){
+      if(onIdle) this.onIdleCbs.push(onIdle);
+      if(this.running){ return; }
+      this.running = true; this.paused = false; this.lastError = null;
+      const tick = ()=>{
+        if(this.paused){ this.running = false; return; }
+        let budget = this.stepBudget;
+        try{
+          while(budget-- > 0){
+            if(this.lastError){ this.running = false; return; }
+            if(this.currentIp!=null){
+              this.stepVM();
+            } else if(this.ti < this.toks.length){
+              if(!this.interpretNextToken()){ if(this.lastError){ this.running = false; return; } }
+            } else if(this.pending.length){
+              this.currentIp = this.pending.shift();
+            } else {
+              // idle
+              this.running = false;
+              const cbs = this.onIdleCbs.splice(0);
+              cbs.forEach(cb=>{ try{ cb(); }catch(_){} });
+              return;
+            }
+          }
+        } catch(e){
+          // Safety net — convert unexpected exceptions to structured error
+          this.fail({code:'exception', message:e?.message||String(e)});
+          return;
+        }
+        setTimeout(tick, this.sliceMs);
+      };
+      setTimeout(tick, this.sliceMs);
+    }
+
+    pause(){ this.paused = true; }
+    resume(){ if(!this.running){ this.run(); } else { this.paused = false; } }
+
+    // ===== Compiler word impls (bound via defineJS) =====
+    wCOLON(){
+      const name = this.nextToken();
+      if(!name){ this.fail({code:'colon_no_name', message:'expected name after :', phase:'compile'}); return; }
+      const xt = this.here;
+      this.current = { name, xt };
+      this.Dict.push({ name, immediate:false, xt });
+      this.STATE = 1;
+    }
+    wSEMICOLON(){ if(!this.current){ this.fail({code:'no_current_def', message:'no current definition', phase:'compile'}); return; } if(this.ctrl.length){ this.fail({code:'unbalanced_control', message:'unbalanced control flow', phase:'compile'}); return; } this.emitCell(this.OP.RET); this.current=null; this.STATE=0; }
+    wCOMMA(){ this.emitCell(this.pop()); }
+    wWHERE(){ this.push(this.here); }
+    wIMMEDIATE(){ if(!this.Dict.length){ this.fail({code:'no_word', message:'no word', phase:'compile'}); return; } this.Dict[this.Dict.length-1].immediate=true; }
+    wTICK(){ const name=this.nextToken(); const w=this.find(name); if(!w){ this.fail({code:'undefined_word', message:"undefined '"+name+"'", phase:'interpret', token:name}); return; } this.push(w.xt); }
+
+    // ===== Control structure implementations =====
+    wIF(){ if(!this.STATE){ this.fail({code:'if_outside_compile', message:'IF outside compile', phase:'compile'}); return; } this.emitCell(this.OP.JZ); const hole=this.here; this.emitCell(0); this.ctrl.push({k:'IF', hole}); }
+    wELSE(){ if(!this.STATE){ this.fail({code:'else_outside_compile', message:'ELSE outside compile', phase:'compile'}); return; } const top=this.ctrl[this.ctrl.length-1]; if(!top || top.k!=='IF'){ this.fail({code:'else_without_if', message:'ELSE without IF', phase:'compile'}); return; } this.emitCell(this.OP.JUMP); const hole2=this.here; this.emitCell(0); this.H[top.hole]=this.here; this.ctrl[this.ctrl.length-1]={k:'ELSE', hole:hole2}; }
+    wTHEN(){ if(!this.STATE){ this.fail({code:'then_outside_compile', message:'THEN outside compile', phase:'compile'}); return; } const top=this.ctrl.pop(); if(!top || (top.k!=='IF' && top.k!=='ELSE')){ this.fail({code:'then_without_if', message:'THEN without IF/ELSE', phase:'compile'}); return; } this.H[top.hole]=this.here; }
+    wBEGIN(){ if(!this.STATE){ this.fail({code:'begin_outside_compile', message:'BEGIN outside compile', phase:'compile'}); return; } this.ctrl.push({k:'BEGIN', addr:this.here}); }
+    wUNTIL(){ if(!this.STATE){ this.fail({code:'until_outside_compile', message:'UNTIL outside compile', phase:'compile'}); return; } const top=this.ctrl.pop(); if(!top || top.k!=='BEGIN'){ this.fail({code:'until_without_begin', message:'UNTIL without BEGIN', phase:'compile'}); return; } this.emitCell(this.OP.JZ); this.emitCell(top.addr); }
+    wAGAIN(){ if(!this.STATE){ this.fail({code:'again_outside_compile', message:'AGAIN outside compile', phase:'compile'}); return; } const top=this.ctrl.pop(); if(!top || top.k!=='BEGIN'){ this.fail({code:'again_without_begin', message:'AGAIN without BEGIN', phase:'compile'}); return; } this.emitCell(this.OP.JUMP); this.emitCell(top.addr); }
+  }
+
+  window.Fjorth = Fjorth;
+})();
diff --git a/test.js b/test.js
new file mode 100644
index 0000000..e39a620
--- /dev/null
+++ b/test.js
@@ -0,0 +1,244 @@
+(function(){
+  function startTests(){
+    // Helper to feed exact Forth source while avoiding JS escape confusion.
+    // Usage: runCode(R(String.raw`2 3 \ line\n +`))
+    // R() converts the two characters "\\n" into a real newline so the interpreter sees backslash-to-EOL then the next line.
+    const R = (raw) => raw.replace(/\\n/g, '\n');
+
+    function runCode(code) {
+      return new Promise((resolve, reject) => {
+        const out = [];
+        const vm = new Fjorth({
+          print: s => { out.push(s); },
+          println: s => { out.push(s + '\n'); },
+          stepBudget: 2000,
+          sliceMs: 0
+        });
+        try {
+          vm.reset();
+          vm.load(code);
+          vm.run((err) => {
+            if (err) reject(err);
+            else resolve({ vm, out: out.join('') });
+          });
+        } catch (e) { reject(e); }
+      });
+    }
+
+    QUnit.module('Fjorth');
+
+    QUnit.test('add', async assert => {
+      const { vm } = await runCode('2 3 +');
+      assert.strictEqual(vm.D.length, 1, 'stack has one item');
+      assert.strictEqual(vm.D[0], 5, '2 3 + => 5');
+    });
+
+    QUnit.test('square via colon + call + *', async assert => {
+      const { vm } = await runCode(': SQUARE DUP * ; 7 SQUARE');
+      assert.strictEqual(vm.D.length, 1, 'stack has one item');
+      assert.strictEqual(vm.D[0], 49, '7 SQUARE => 49');
+    });
+
+    QUnit.test('paren comment', async assert => {
+      const { vm } = await runCode('( hi ) 2 3 +');
+      assert.strictEqual(vm.D.length, 1);
+      assert.strictEqual(vm.D[0], 5);
+    });
+
+    QUnit.test('backslash to EOL comment', async assert => {
+      const { vm } = await runCode(R(String.raw`2 3 \\ line\n +`));
+      assert.strictEqual(vm.D.length, 1);
+      assert.strictEqual(vm.D[0], 5);
+    });
+
+    QUnit.test('backslash at end of line only', async assert => {
+      const { vm } = await runCode(R(String.raw`2 \n 3 +`));
+      assert.strictEqual(vm.D.length, 1);
+      assert.strictEqual(vm.D[0], 5);
+    });
+
+    QUnit.test('1- then 1+', async assert => {
+      const { vm } = await runCode('10 1- 1+');
+      assert.strictEqual(vm.D.length, 1);
+      assert.strictEqual(vm.D[0], 10);
+    });
+
+    QUnit.test('return stack accessors', async assert => {
+      const { vm } = await runCode(': SAVE >R R@ R> ; 123 SAVE');
+      assert.strictEqual(vm.D.length, 2, 'two values on stack');
+      assert.strictEqual(vm.D[0], 123);
+      assert.strictEqual(vm.D[1], 123);
+    });
+
+    QUnit.test('return stack roundtrip', async assert => {
+      const { vm } = await runCode(': PASS >R R> ; 7 PASS');
+      assert.strictEqual(vm.D.length, 1);
+      assert.strictEqual(vm.D[0], 7);
+    });
+
+    QUnit.test('nested calls', async assert => {
+      const { vm } = await runCode(': B 1+ ; : A 2 + B ; 39 A');
+      assert.strictEqual(vm.D.length, 1);
+      assert.strictEqual(vm.D[0], 42);
+    });
+
+    QUnit.test('IF/ELSE/THEN true branch', async assert => {
+      const { vm } = await runCode(': T 1 IF 42 ELSE 99 THEN ; T');
+      assert.strictEqual(vm.D.length, 1);
+      assert.strictEqual(vm.D[0], 42);
+    });
+
+    QUnit.test('IF/ELSE/THEN false branch', async assert => {
+      const { vm } = await runCode(': F 0 IF 42 ELSE 99 THEN ; F');
+      assert.strictEqual(vm.D.length, 1);
+      assert.strictEqual(vm.D[0], 99);
+    });
+
+    QUnit.test('BEGIN/UNTIL countdown with 0=', async assert => {
+      const { vm } = await runCode(': CD ( n -- ) BEGIN 1- DUP 0= UNTIL DROP ; 3 CD');
+      assert.strictEqual(vm.D.length, 0, 'stack empty after loop');
+    });
+
+    // Additional negative tests to lock semantics
+    QUnit.test('ELSE outside compile errors', async assert => {
+      await assert.rejects(runCode('ELSE'), /ELSE outside compile/);
+    });
+
+    QUnit.test('BEGIN outside compile errors', async assert => {
+      await assert.rejects(runCode('BEGIN'), /BEGIN outside compile/);
+    });
+
+    QUnit.test('unbalanced control flow at ;', async assert => {
+      await assert.rejects(runCode(': X IF 1 ;'), /unbalanced control flow/);
+    });
+
+    // ===== Additional Coverage =====
+    QUnit.module('Stack & Arithmetic');
+
+    QUnit.test('DUP, DROP, SWAP, OVER', async assert => {
+      const { vm } = await runCode('1 2 DUP SWAP OVER DROP');
+      assert.deepEqual(vm.D, [1,2,2]);
+    });
+
+    QUnit.test('add/sub/mul with negatives', async assert => {
+      const { vm: vm1 } = await runCode('10 3 -');
+      assert.strictEqual(vm1.D[0], 7);
+      const { vm: vm2 } = await runCode('3 10 -');
+      assert.strictEqual(vm2.D[0], -7);
+      const { vm: vm3 } = await runCode('6 7 *');
+      assert.strictEqual(vm3.D[0], 42);
+      const { vm: vm4 } = await runCode('-7 3 +');
+      assert.strictEqual(vm4.D[0], -4);
+    });
+
+    QUnit.module('Memory & Dictionary');
+
+    QUnit.test('HERE , @ roundtrip', async assert => {
+      const { vm } = await runCode('HERE 0 , DUP 99 SWAP ! @');
+      assert.strictEqual(vm.D.length, 1);
+      assert.strictEqual(vm.D[0], 99);
+    });
+
+    QUnit.test("' tick returns execution token", async assert => {
+      const { vm } = await runCode(': SQUARE DUP * ; \' SQUARE');
+      const xt = vm.D[0];
+      assert.strictEqual(xt, vm.find('SQUARE').xt, 'xt on stack matches dictionary entry');
+    });
+
+    QUnit.test('IMMEDIATE marks last word immediate', async assert => {
+      const { vm } = await runCode(': I 123 ; IMMEDIATE');
+      const w = vm.find('I');
+      assert.ok(w && w.immediate === true, 'I is immediate');
+    });
+
+    QUnit.module('Tokenizer & Comments');
+
+    QUnit.test('mixed comments (paren + backslash)', async assert => {
+      const { vm } = await runCode(R(String.raw`(foo) 1 \\ bar\n 2 +`));
+      assert.strictEqual(vm.D[0], 3);
+    });
+
+    QUnit.module('Output');
+
+    QUnit.test('EMIT + CR output', async assert => {
+      const { out } = await runCode('65 EMIT 66 EMIT CR');
+      assert.strictEqual(out, 'AB\n');
+    });
+
+    QUnit.test('. prints with a trailing space', async assert => {
+      const { out } = await runCode('42 .');
+      assert.strictEqual(out, '42 ');
+    });
+
+    QUnit.test('.S prints stack contents', async assert => {
+      const { out } = await runCode('1 2 3 .S');
+      assert.strictEqual(out, '<3> 1 2 3\n');
+    });
+
+    QUnit.module('Errors');
+
+    QUnit.test('undefined word throws', async assert => {
+      await assert.rejects(runCode('NOPE'), /undefined word: NOPE/);
+    });
+
+    QUnit.test('data stack underflow', async assert => {
+      await assert.rejects(runCode('DUP'), /stack underflow/);
+    });
+
+    QUnit.test('return stack underflow', async assert => {
+      await assert.rejects(runCode('R>'), /rstack underflow/);
+    });
+
+    QUnit.test('colon with no name', async assert => {
+      await assert.rejects(runCode(':'), /expected name after/);
+    });
+
+    QUnit.module('API');
+
+    function runWithVM(setup, code){
+      return new Promise((resolve, reject)=>{
+        const out=[];
+        const vm=new Fjorth({ print:s=>out.push(s), println:s=>out.push(s+'\n'), stepBudget:200, sliceMs:0 });
+        try{ vm.reset(); if(setup) setup(vm); vm.load(code); vm.run(err=>{ if(err) reject(err); else resolve({vm, out:out.join('')}); }); }
+        catch(e){ reject(e); }
+      });
+    }
+
+    QUnit.test('defineJS adds external word', async assert => {
+      const { vm } = await runWithVM(vm=>{
+        vm.defineJS('TWICE', function(){ const a=this.pop(); this.push(a*2); });
+      }, '21 TWICE');
+      assert.strictEqual(vm.D[0], 42);
+    });
+
+    QUnit.test('defineJS immediate flag is recorded', async assert => {
+      const { vm } = await runWithVM(vm=>{
+        vm.defineJS('IWORD', function(){ this.push(9); }, true);
+      }, ': T IWORD ;');
+      const w = vm.find('IWORD');
+      assert.ok(w && w.immediate===true);
+    });
+
+    QUnit.test('pause/resume on long program still completes', async assert => {
+      // Build a long program: 0 then N times (1 +)
+      const N = 1000;
+      const code = '0' + ' 1 +'.repeat(N);
+      const { vm } = await new Promise((resolve, reject)=>{
+        const out=[]; const vm=new Fjorth({ print:s=>out.push(s), println:s=>out.push(s+'\n'), stepBudget:10, sliceMs:0 });
+        try{ vm.reset(); vm.load(code); vm.run(err=>{ if(err) reject(err); else resolve({vm, out:out.join('')}); }); setTimeout(()=>{ vm.pause(); setTimeout(()=> vm.resume(), 0); }, 0); }
+        catch(e){ reject(e); }
+      });
+      assert.strictEqual(vm.D[0], 1000, 'result equals N after resume');
+    });
+
+  }
+
+  // Start after QUnit loads (local or CDN)
+  if (window.QUnit) {
+    startTests();
+  } else {
+    const s = document.getElementById('qunit-js');
+    if (s) s.addEventListener('load', () => { if (window.QUnit) startTests(); });
+    window.addEventListener('load', () => { if (window.QUnit) startTests(); });
+  }
+})();