diff --git a/hosts/ocaml/bin/mcp_tree.ml b/hosts/ocaml/bin/mcp_tree.ml index ebcb1f50..8591d00a 100644 --- a/hosts/ocaml/bin/mcp_tree.ml +++ b/hosts/ocaml/bin/mcp_tree.ml @@ -1892,8 +1892,34 @@ let handle_sx_harness_eval args = let file = args |> member "file" |> to_string_option in let setup_str = args |> member "setup" |> to_string_option in let files_json = try args |> member "files" with _ -> `Null in + let host_stubs = match args |> member "host_stubs" with `Bool b -> b | _ -> false in let e = !env in let warnings = ref [] in + (* Inject stub host primitives so files using host-get/host-new/etc. can load *) + if host_stubs then begin + let stubs = {| + (define host-global (fn (&rest _) nil)) + (define host-get (fn (&rest _) nil)) + (define host-set! (fn (obj k v) v)) + (define host-call (fn (&rest _) nil)) + (define host-new (fn (&rest _) (dict))) + (define host-callback (fn (f) f)) + (define host-typeof (fn (&rest _) "string")) + (define hs-ref-eq (fn (a b) (identical? a b))) + (define host-call-fn (fn (&rest _) nil)) + (define host-iter? (fn (&rest _) false)) + (define host-to-list (fn (&rest _) (list))) + (define host-await (fn (&rest _) nil)) + (define host-new-function (fn (&rest _) nil)) + (define load-library! (fn (&rest _) false)) + |} in + let stub_exprs = Sx_parser.parse_all stubs in + List.iter (fun expr -> + try ignore (Sx_ref.eval_expr expr (Env e)) + with exn -> + warnings := Printf.sprintf "Stub warning: %s" (Printexc.to_string exn) :: !warnings + ) stub_exprs + end; (* Collect all files to load *) let all_files = match files_json with | `List items -> @@ -3018,7 +3044,8 @@ let tool_definitions = `List [ ("mock", `Assoc [("type", `String "string"); ("description", `String "Optional mock platform overrides as SX dict, e.g. {:fetch (fn (url) {:status 200})}")]); ("file", `Assoc [("type", `String "string"); ("description", `String "Optional .sx file to load for definitions")]); ("files", `Assoc [("type", `String "array"); ("items", `Assoc [("type", `String "string")]); ("description", `String "Multiple .sx files to load in order")]); - ("setup", `Assoc [("type", `String "string"); ("description", `String "SX setup expression to run before main evaluation")])] + ("setup", `Assoc [("type", `String "string"); ("description", `String "SX setup expression to run before main evaluation")]); + ("host_stubs", `Assoc [("type", `String "boolean"); ("description", `String "If true, inject nil-returning stubs for host-get/host-set!/host-call/host-new/etc. so files that use host primitives can load in the harness")])] ["expr"]; tool "sx_nav" "Manage sx-docs navigation and articles. Modes: list (all nav items with status), check (validate consistency), add (create article + nav entry), delete (remove nav entry + page fn), move (move entry between sections, rewriting hrefs)." [("mode", `Assoc [("type", `String "string"); ("description", `String "Mode: list, check, add, delete, or move")]); diff --git a/tests/hs-kernel-eval.js b/tests/hs-kernel-eval.js new file mode 100644 index 00000000..710d7099 --- /dev/null +++ b/tests/hs-kernel-eval.js @@ -0,0 +1,263 @@ +#!/usr/bin/env node +/** + * Evaluate SX (or inspect HS compiler/parser output) in the full WASM kernel. + * + * Environment variables (preferred — avoids shell escaping): + * HS_EVAL_EXPR SX expression to evaluate (required unless --expr arg given) + * HS_EVAL_SETUP SX setup expression run before main eval + * HS_EVAL_FILES Comma-separated list of .sx files to load first + * HS_EVAL_MODE 'eval' (default) | 'compile' | 'parse' + * compile: wraps expr as hs-compile arg, returns SX AST string + * parse: wraps expr as hs-parse arg, returns parse tree string + * + * CLI fallback: first positional arg used as expression if HS_EVAL_EXPR not set. + * + * Output: JSON to stdout { ok: true, result: "..." } + * or { ok: false, error: "..." } + * Progress / load errors go to stderr. + */ + +'use strict'; +const fs = require('fs'); +const path = require('path'); + +const PROJECT = path.resolve(__dirname, '..'); +const WASM_DIR = path.join(PROJECT, 'shared/static/wasm'); +const SX_DIR = path.join(WASM_DIR, 'sx'); + +// ── Load WASM kernel ──────────────────────────────────────────── +eval(fs.readFileSync(path.join(WASM_DIR, 'sx_browser.bc.js'), 'utf8')); +const K = globalThis.SxKernel; + +// ── Minimal DOM mock ──────────────────────────────────────────── +class CL { + constructor() { this._s = new Set(); } + add(c) { if (c) this._s.add(c); } + remove(c) { this._s.delete(c); } + contains(c) { return this._s.has(c); } + toggle(c) { this._s.has(c) ? this.remove(c) : this.add(c); return this._s.has(c); } + _sync(v) { this._s = new Set((v||'').split(' ').filter(Boolean)); } +} +class El { + constructor(t) { + this.tagName = t.toUpperCase(); this.nodeName = this.tagName; this.nodeType = 1; + this.id = ''; this.className = ''; this.textContent = ''; this.innerHTML = ''; + this.value = ''; this.checked = false; this.disabled = false; this.type = ''; + this.style = { setProperty(p,v){this[p]=v;}, getPropertyValue(p){return this[p]||'';} }; + this.attributes = {}; this.children = []; this.childNodes = []; + this.childNodes.item = i => this.childNodes[i] || null; + this.parentNode = null; this.parentElement = null; this._listeners = {}; + this.classList = new CL(); + this.dataset = {}; + this.open = false; this.multiple = false; this.selected = false; + } + setAttribute(n,v) { + this.attributes[n] = String(v); + if (n==='id') this.id = v; + if (n==='class') { this.className = v; this.classList._sync(v); } + if (n==='value') this.value = v; + } + getAttribute(n) { return this.attributes[n] !== undefined ? this.attributes[n] : null; } + removeAttribute(n){ delete this.attributes[n]; } + hasAttribute(n) { return n in this.attributes; } + appendChild(c) { if(c){ c.parentNode=this; c.parentElement=this; this.children.push(c); this.childNodes.push(c); } return c; } + removeChild(c) { this.children=this.children.filter(x=>x!==c); this.childNodes=this.childNodes.filter(x=>x!==c); if(c){c.parentNode=null;c.parentElement=null;} return c; } + remove() { if(this.parentNode) this.parentNode.removeChild(this); } + prepend(c) { if(c){ c.parentNode=this; this.children.unshift(c); this.childNodes.unshift(c); } } + insertBefore(c,r) { if(!r) return this.appendChild(c); const i=this.childNodes.indexOf(r); if(i<0) return this.appendChild(c); this.childNodes.splice(i,0,c); this.children.splice(i,0,c); c.parentNode=this; return c; } + replaceChild(n,o) { const i=this.childNodes.indexOf(o); if(i>=0){ this.childNodes[i]=n; this.children[i]=n; n.parentNode=this; o.parentNode=null; } return o; } + cloneNode(deep) { const c=new El(this.tagName); if(deep) for(const ch of this.childNodes) c.appendChild(ch.cloneNode&&ch.cloneNode(true)||{...ch}); return c; } + addEventListener(t,h) { if(!this._listeners[t]) this._listeners[t]=[]; this._listeners[t].push(h); } + removeEventListener(t,h) { if(this._listeners[t]) this._listeners[t]=this._listeners[t].filter(x=>x!==h); } + dispatchEvent(ev) { (this._listeners[ev&&ev.type]||[]).forEach(h=>{ try{h(ev);}catch(e){} }); return true; } + querySelector(sel) { + if (!sel) return null; + if (sel.startsWith('#')) { const id=sel.slice(1); if(this.id===id) return this; for(const c of this.childNodes){const r=c.querySelector&&c.querySelector(sel); if(r) return r;} return null; } + return null; + } + querySelectorAll() { return []; } + closest(sel) { return sel && this.matches(sel) ? this : (this.parentNode && this.parentNode.closest ? this.parentNode.closest(sel) : null); } + matches(sel) { + if (!sel) return false; + if (sel.startsWith('#')) return this.id === sel.slice(1); + if (sel.startsWith('.')) return this.classList.contains(sel.slice(1)); + return this.tagName.toLowerCase() === sel.toLowerCase(); + } + focus() {} + blur() {} + click() { this.dispatchEvent(new Ev('click',{bubbles:true})); } + getBoundingClientRect() { return {width:0,height:0,top:0,left:0,right:0,bottom:0}; } +} +class Ev { + constructor(t,o) { this.type=t; const opts=o||{}; this.bubbles=opts.bubbles!==false; this.detail=opts.detail||null; this.target=null; this.currentTarget=null; } + preventDefault() {} + stopPropagation() {} +} + +const _body = new El('body'); +const _head = new El('head'); +const _docListeners = {}; +const _domRegistry = new Map(); // id -> El + +function _findById(id) { + function find(el) { + if (!(el instanceof El)) return null; + if (el.id === id) return el; + for (const c of (el.childNodes||[])) { const r = find(c); if (r) return r; } + return null; + } + return find(_body); +} + +globalThis.document = { + body: _body, head: _head, title: '', + createElement: t => new El(t), + createElementNS: (ns,t) => new El(t), + createTextNode: s => ({ nodeType:3, textContent:String(s||''), nodeName:'#text', parentNode:null }), + createDocumentFragment: () => { const f=new El('fragment'); f.nodeType=11; return f; }, + createComment: s => ({ nodeType:8, textContent:s, nodeName:'#comment' }), + getElementById: id => _findById(id), + querySelector: sel => sel && sel.startsWith('#') ? _findById(sel.slice(1)) : null, + querySelectorAll: () => [], + addEventListener: (t,h) => { if(!_docListeners[t]) _docListeners[t]=[]; _docListeners[t].push(h); }, + removeEventListener: (t,h) => { if(_docListeners[t]) _docListeners[t]=_docListeners[t].filter(x=>x!==h); }, + dispatchEvent: ev => { (_docListeners[ev&&ev.type]||[]).forEach(h=>{ try{h(ev);}catch(e){} }); }, + activeElement: null, +}; +globalThis.CustomEvent = Ev; +globalThis.Event = Ev; +globalThis.window = globalThis; +globalThis.navigator = { userAgent: 'node' }; +globalThis.location = { href:'http://localhost/', pathname:'/', search:'', hash:'' }; +globalThis.history = { pushState(){}, replaceState(){} }; +globalThis.getSelection = () => ({ toString: () => '' }); +globalThis.console = { log:()=>{}, error:()=>{}, warn:()=>{}, info:()=>{}, debug:()=>{} }; +globalThis.ResizeObserver = class { observe(){} unobserve(){} disconnect(){} }; +globalThis.IntersectionObserver = class { constructor(cb){} observe(){} unobserve(){} disconnect(){} takeRecords(){return[];} }; + +// ── FFI registrations ─────────────────────────────────────────── +K.registerNative('hs-ref-eq', a => a[0]===a[1]); +K.registerNative('host-global', a => { const n=a[0]; return (n in globalThis)?globalThis[n]:null; }); +K.registerNative('host-get', a => { + if (a[0]==null) return null; + if (a[0] && a[0]._type==='list' && (a[1]==='length'||a[1]==='size')) return a[0].items.length; + if (a[0] instanceof El && a[1]==='innerText') return String(a[0].textContent||''); + const v = a[0][a[1]]; return v===undefined ? null : v; +}); +K.registerNative('host-set!', a => { if(a[0]!=null){ a[0][a[1]]=a[2]; if(a[0] instanceof El && a[1]==='id' && a[2]) a[0].id=a[2]; } return a[2]; }); +K.registerNative('host-call', a => { + const [o,m,...r]=a; + if(o==null){ const f=globalThis[m]; return typeof f==='function'?f.apply(null,r):null; } + if(o && typeof o[m]==='function'){ try{ const v=o[m].apply(o,r); return v===undefined?null:v; }catch(e){ return null; } } + return null; +}); +K.registerNative('host-call-fn', a => { + const [fn,argList]=a; + if(!fn) return null; + const args=(argList&&argList._type==='list'&&argList.items)?Array.from(argList.items):(Array.isArray(argList)?argList:[]); + if(fn&&fn.__sx_handle!==undefined) return K.callFn(fn,args); + try{ return fn.apply(null,args); }catch(e){ return null; } +}); +K.registerNative('host-new', a => { const C=typeof a[0]==='string'?globalThis[a[0]]:a[0]; return typeof C==='function'?new C(...a.slice(1)):null; }); +K.registerNative('host-callback',a => { + const fn=a[0]; + if(fn&&fn.__sx_handle!==undefined) return function(){ const r=K.callFn(fn,Array.from(arguments)); if(globalThis._driveAsync) globalThis._driveAsync(r); return r; }; + return typeof fn==='function'?fn:function(){}; +}); +K.registerNative('host-typeof', a => { const o=a[0]; if(o==null) return 'nil'; if(o instanceof El) return 'element'; if(o instanceof Ev) return 'event'; return typeof o; }); +K.registerNative('host-iter?', ([obj]) => obj!=null && typeof obj[Symbol.iterator]==='function'); +K.registerNative('host-to-list', ([obj]) => { try{ return [...obj]; }catch(e){ return []; } }); +K.registerNative('host-await', () => {}); +K.registerNative('host-new-function', a => { const p=(a[0]&&a[0]._type==='list')?Array.from(a[0].items):[]; try{ return new Function(...p,a[1]); }catch(e){ return null; } }); +K.registerNative('host-promise-state', a => { const p=a[0]; if(!p||typeof p.then!=='function') return null; const s=globalThis._promiseStates&&globalThis._promiseStates.get(p); return s?{ok:s.ok,value:s.value}:null; }); +K.registerNative('load-library!', () => false); + +// Async IO driver +let _evalDeadline = 0; +globalThis._driveAsync = function driveAsync(r, depth) { + depth = depth||0; + if (_evalDeadline && Date.now() > _evalDeadline) throw new Error('TIMEOUT: wall clock exceeded'); + if (!r || !r.suspended || depth > 200) return; + const req = r.request; + const items = req && (req.items || req); + const op = items && items[0]; + const opName = typeof op==='string' ? op : (op&&op.name)||String(op); + function doResume(v) { try{ const x=r.resume(v); driveAsync(x,depth+1); }catch(e){} } + if (opName==='io-sleep'||opName==='wait') doResume(null); + else if (opName==='io-wait-event') { + const target=items&&items[1]; + const evName=typeof items[2]==='string'?items[2]:''; + const timeout=items&&items.length>3?items[3]:undefined; + if (typeof timeout==='number') { doResume(null); } + else if (target && target instanceof El && evName) { + const handler=function(ev){ target.removeEventListener(evName,handler); doResume(ev); }; + target.addEventListener(evName,handler); + } else { doResume(null); } + } + else if (opName==='io-transition') doResume(null); + else doResume(null); +}; + +// ── SX aliases ────────────────────────────────────────────────── +K.eval('(define SX_VERSION "hs-eval-1.0")'); +K.eval('(define SX_ENGINE "ocaml-vm-sandbox")'); +K.eval('(define parse sx-parse)'); +K.eval('(define serialize sx-serialize)'); + +// ── Load HS modules ───────────────────────────────────────────── +const WEB = ['render','core-signals','signals','deps','router','page-helpers','freeze','dom','browser', + 'adapter-html','adapter-sx','adapter-dom','boot-helpers','hypersx','engine','orchestration','boot']; +const HS = ['hs-tokenizer','hs-parser','hs-compiler','hs-runtime','hs-integration']; +K.beginModuleLoad(); +for (const mod of [...WEB, ...HS]) { + const sp = path.join(SX_DIR, mod+'.sx'); + const lp = path.join(PROJECT, 'lib/hyperscript', mod.replace(/^hs-/,'')+'.sx'); + let s; + try { + const lpExists = mod.startsWith('hs-') && fs.existsSync(lp); + s = lpExists ? fs.readFileSync(lp,'utf8') + : fs.existsSync(sp) ? fs.readFileSync(sp,'utf8') + : fs.readFileSync(lp,'utf8'); + } catch(e) { continue; } + try { K.load(s); } catch(e) { process.stderr.write(`LOAD ERROR: ${mod}: ${e.message}\n`); } +} +K.endModuleLoad(); + +// ── Extra files ───────────────────────────────────────────────── +const extraFiles = (process.env.HS_EVAL_FILES || '').split(',').filter(Boolean); +for (const f of extraFiles) { + try { K.load(fs.readFileSync(f.trim(),'utf8')); } + catch(e) { process.stderr.write(`FILE ERROR: ${f}: ${e.message}\n`); } +} + +// ── Setup expression ──────────────────────────────────────────── +const setup = process.env.HS_EVAL_SETUP || ''; +if (setup) { + try { K.eval(setup); } + catch(e) { + process.stdout.write(JSON.stringify({ok:false,error:`Setup error: ${e.message||String(e)}`})+'\n'); + process.exit(1); + } +} + +// ── Main evaluation ───────────────────────────────────────────── +const mode = process.env.HS_EVAL_MODE || 'eval'; +const rawExpr = process.env.HS_EVAL_EXPR || process.argv[2] || ''; +if (!rawExpr) { + process.stdout.write(JSON.stringify({ok:false,error:'No expression provided. Set HS_EVAL_EXPR or pass as first argument.'})+'\n'); + process.exit(1); +} + +const expr = mode==='compile' ? `(str (hs-compile ${JSON.stringify(rawExpr)}))` + : mode==='parse' ? `(str (hs-parse ${JSON.stringify(rawExpr)}))` + : rawExpr; + +_evalDeadline = Date.now() + parseInt(process.env.HS_EVAL_TIMEOUT_MS||'30000'); +try { + const result = K.eval(expr); + let resultStr; + try { resultStr = JSON.stringify(result); } catch(e) { resultStr = String(result); } + process.stdout.write(JSON.stringify({ok:true,result:resultStr})+'\n'); +} catch(e) { + process.stdout.write(JSON.stringify({ok:false,error:e.message||String(e)})+'\n'); +} diff --git a/tests/hs-run-filtered.js b/tests/hs-run-filtered.js index 9978dbdc..6db62ad0 100755 --- a/tests/hs-run-filtered.js +++ b/tests/hs-run-filtered.js @@ -791,10 +791,13 @@ for(let i=startTest;i