Phase 22.1 WIP: SSOT resolver + TLV infrastructure + Hako MIR builder setup

Setup infrastructure for Phase 22.1 (TLV C shim & Resolver SSOT):

Core changes:
- Add nyash_tlv, nyash_c_core, nyash_kernel_min_c crates (opt-in)
- Implement SSOT resolver bridge (src/using/ssot_bridge.rs)
- Add HAKO_USING_SSOT=1 / HAKO_USING_SSOT_HAKO=1 env support
- Add HAKO_TLV_SHIM=1 infrastructure (requires --features tlv-shim)

MIR builder improvements:
- Fix using/alias consistency in Hako MIR builder
- Add hako.mir.builder.internal.{prog_scan,pattern_util} to nyash.toml
- Normalize LLVM extern calls: nyash.console.* → nyash_console_*

Smoke tests:
- Add phase2211 tests (using_ssot_hako_parity_canary_vm.sh)
- Add phase2220, phase2230, phase2231 test structure
- Add phase2100 S3 backend selector tests
- Improve test_runner.sh with quiet/timeout controls

Documentation:
- Add docs/ENV_VARS.md (Phase 22.1 env vars reference)
- Add docs/development/runtime/C_CORE_ABI.md
- Update de-rust-roadmap.md with Phase 22.x details

Tools:
- Add tools/hakorune_emit_mir.sh (Hako-first MIR emission wrapper)
- Add tools/tlv_roundtrip_smoke.sh placeholder
- Improve ny_mir_builder.sh with better backend selection

Known issues (to be fixed):
- Parser infinite loop in static method parameter parsing
- Stage-B output contamination with "RC: 0" (needs NYASH_JSON_ONLY=1)
- phase2211/using_ssot_hako_parity_canary_vm.sh fork bomb (needs recursion guard)

Next steps: Fix parser infinite loop + Stage-B quiet mode for green tests
This commit is contained in:
nyash-codex
2025-11-09 15:11:18 +09:00
parent 5d2cd5bad0
commit 981ddd890c
62 changed files with 1981 additions and 103 deletions

View File

@ -3,6 +3,19 @@ use super::super::utils::*;
use serde_json::Value as JsonValue;
impl MirInterpreter {
#[inline]
fn should_trace_call_extern(target: &str, method: &str) -> bool {
if let Ok(flt) = std::env::var("HAKO_CALL_TRACE_FILTER") {
let key = format!("{}.{}", target, method);
for pat in flt.split(',') {
let p = pat.trim();
if p.is_empty() { continue; }
if p == method || p == key { return true; }
}
return false;
}
true
}
fn patch_mir_json_version(s: &str) -> String {
match serde_json::from_str::<JsonValue>(s) {
Ok(mut v) => {
@ -26,6 +39,20 @@ impl MirInterpreter {
extern_name: &str,
args: &[ValueId],
) -> Option<Result<VMValue, VMError>> {
// Unified call trace (optional)
if std::env::var("HAKO_CALL_TRACE").ok().as_deref() == Some("1") {
// Split iface.method for filtering
if let Some((iface, method)) = extern_name.rsplit_once('.') {
if Self::should_trace_call_extern(iface, method) {
eprintln!("[call:{}.{}]", iface, method);
}
} else {
// Fallback: no dot in extern name (e.g., 'print')
if Self::should_trace_call_extern("", extern_name) {
eprintln!("[call:{}]", extern_name);
}
}
}
match extern_name {
// Console family (minimal)
"nyash.console.log" | "env.console.log" | "print" | "nyash.builtin.print" => {

View File

@ -0,0 +1,39 @@
"""
extern_normalize.py — Single point of truth for extern name normalization.
Policy (MVP):
- Map bare "print"/"println""nyash.console.log"
- Map "env.console.*" (println/log/print/warn/error) → "nyash.console.<method>"
* println is normalized to log (pointer API).
- Keep already-qualified "nyash.console.*" as-is, but normalize ...println → ...log
This module is imported by both instructions.externcall and instructions.mir_call
to avoid duplication and drift.
"""
from typing import Optional
def normalize_extern_name(name: Optional[str]) -> str:
if not name:
return ""
try:
n = str(name)
except Exception:
return ""
try:
if n.startswith("env.console."):
method = n.split(".")[-1]
if method == "println":
method = "log"
return f"nyash.console.{method}"
if n in ("println", "print"):
return "nyash.console.log"
if n.startswith("nyash.console.") and n.endswith("println"):
return "nyash.console.log"
except Exception:
# Fallthrough to original if anything odd happens
pass
return n

View File

@ -6,6 +6,7 @@ Minimal mapping for NyRT-exported symbols (console/log family等)
import llvmlite.ir as ir
from typing import Dict, List, Optional, Any
from instructions.safepoint import insert_automatic_safepoint
from instructions.extern_normalize import normalize_extern_name
def lower_externcall(
builder: ir.IRBuilder,
@ -45,26 +46,17 @@ def lower_externcall(
bb_map = ctx.bb_map
except Exception:
pass
# Normalize extern target names
# Accept full symbol names (e.g., "nyash.console.log", "nyash.string.len_h").
# Also accept legacy/environment names and map them to kernel exports.
llvm_name = func_name
# Normalize extern target names through shared policy
llvm_name = normalize_extern_name(func_name)
# For C linkage, map dot-qualified console names to underscore symbols.
# This keeps the logical name (nyash.console.log) stable at the MIR level
# while emitting a C-friendly symbol (nyash_console_log) for linkage.
c_symbol_name = llvm_name
try:
if func_name.startswith("env.console."):
# Map env.console.* → nyash.console.* (kernel exports)
method = func_name.split(".")[-1]
# println maps to log for now
if method == "println":
method = "log"
llvm_name = f"nyash.console.{method}"
elif func_name == "println" or func_name == "print":
# Bare println/print fallback
llvm_name = "nyash.console.log"
elif func_name.startswith("nyash.console.") and func_name.endswith("println"):
# Normalize nyash.console.println → nyash.console.log
llvm_name = "nyash.console.log"
if llvm_name.startswith("nyash.console."):
c_symbol_name = llvm_name.replace(".", "_")
except Exception:
pass
c_symbol_name = llvm_name
i8 = ir.IntType(8)
i64 = ir.IntType(64)
@ -95,22 +87,22 @@ def lower_externcall(
# Find or declare function with appropriate prototype
func = None
for f in module.functions:
if f.name == llvm_name:
if f.name == c_symbol_name:
func = f
break
if not func:
if llvm_name in sig_map:
ret_ty, arg_tys = sig_map[llvm_name]
fnty = ir.FunctionType(ret_ty, arg_tys)
func = ir.Function(module, fnty, name=llvm_name)
func = ir.Function(module, fnty, name=c_symbol_name)
elif llvm_name.startswith("nyash.console."):
# console.*: (i8*) -> i64
fnty = ir.FunctionType(i64, [i8p])
func = ir.Function(module, fnty, name=llvm_name)
func = ir.Function(module, fnty, name=c_symbol_name)
else:
# Unknown extern: declare as void(...no args...) and call without args
fnty = ir.FunctionType(void, [])
func = ir.Function(module, fnty, name=llvm_name)
func = ir.Function(module, fnty, name=c_symbol_name)
# Prepare/coerce arguments
call_args: List[ir.Value] = []

View File

@ -588,6 +588,10 @@ def lower_extern_call(builder, module, extern_name, args, dst_vid, vmap, resolve
pass
return vmap.get(vid)
# Normalize extern target names via shared normalizer
from instructions.extern_normalize import normalize_extern_name
extern_name = normalize_extern_name(extern_name)
# Look up extern function in module
func = None
for f in module.functions:

View File

@ -10,6 +10,7 @@
use super::*;
use std::collections::HashMap;
use crate::using::spec::{UsingPackage, PackageKind};
use crate::using::ssot_bridge::{call_using_resolve_ssot, SsotCtx};
/// Using/module resolution context accumulated from config/env/nyash.toml
pub(super) struct UsingContext {
@ -129,6 +130,19 @@ pub(super) fn resolve_using_target(
strict: bool,
verbose: bool,
) -> Result<String, String> {
// Phase 22.1: Thin SSOT hook (future wiring). No behavior change for now.
if std::env::var("HAKO_USING_SSOT").ok().as_deref() == Some("1")
&& std::env::var("HAKO_USING_SSOT_INVOKING")
.ok()
.as_deref()
!= Some("1")
{
if let Some(ssot_res) = try_resolve_using_target_ssot(
tgt, is_path, modules, using_paths, aliases, packages, context_dir, strict, verbose,
) {
return Ok(ssot_res);
}
}
// Invalidate and rebuild index/cache if env or nyash.toml changed
super::box_index::rebuild_if_env_changed();
if is_path {
@ -330,6 +344,96 @@ pub(super) fn resolve_using_target(
Ok(out)
}
/// Thin SSOT wrapper — returns Some(resolved) when an alternative SSOT path is available.
/// MVP: return None to keep current behavior. Future: call into Hako `UsingResolveSSOTBox`.
#[allow(clippy::too_many_arguments)]
fn try_resolve_using_target_ssot(
tgt: &str,
is_path: bool,
modules: &[(String, String)],
using_paths: &[String],
aliases: &HashMap<String, String>,
packages: &HashMap<String, UsingPackage>,
context_dir: Option<&std::path::Path>,
strict: bool,
verbose: bool,
) -> Option<String> {
// Phase 22.1 MVP: Build context and consult SSOT bridge (modules-only).
let trace = verbose || crate::config::env::env_bool("NYASH_RESOLVE_TRACE");
let mut map: HashMap<String, String> = HashMap::new();
for (k, v) in modules {
map.insert(k.clone(), v.clone());
}
let cwd_str = context_dir.and_then(|p| p.to_str()).map(|s| s.to_string());
let ctx = SsotCtx { modules: map, using_paths: using_paths.to_vec(), cwd: cwd_str };
if let Some(hit) = call_using_resolve_ssot(tgt, &ctx) {
if trace {
crate::runner::trace::log(format!("[using/ssot] '{}' -> '{}'", tgt, hit));
}
return Some(hit);
}
// Optional relative inference (Runner-side, guarded): prefer cwd > using_paths
if std::env::var("HAKO_USING_SSOT_RELATIVE").ok().as_deref() == Some("1") {
let rel_hako = tgt.replace('.', "/") + ".hako";
let rel_ny = tgt.replace('.', "/") + ".nyash";
let mut try_paths: Vec<std::path::PathBuf> = Vec::new();
if let Some(dir) = context_dir {
try_paths.push(dir.join(&rel_hako));
try_paths.push(dir.join(&rel_ny));
}
for base in using_paths {
let p = std::path::Path::new(base);
try_paths.push(p.join(&rel_hako));
try_paths.push(p.join(&rel_ny));
}
let mut found: Vec<String> = Vec::new();
for p in try_paths {
if p.exists() {
found.push(p.to_string_lossy().to_string());
}
}
if !found.is_empty() {
if found.len() > 1 && strict {
if trace {
let total = found.len();
// Allow customizing the number of shown candidates via env (bounded 1..=10)
let n_show: usize = std::env::var("HAKO_USING_SSOT_RELATIVE_AMBIG_FIRST_N")
.ok()
.and_then(|s| s.parse::<usize>().ok())
.map(|n| n.clamp(1, 10))
.unwrap_or(3);
let shown: Vec<String> = found.iter().take(n_show).cloned().collect();
// Standardized message: count + first N + explicit delegation policy
crate::runner::trace::log(format!(
"[using/ssot:relative ambiguous] name='{}' count={} first=[{}] -> delegate=legacy(strict)",
tgt,
total,
shown.join(", ")
));
}
// Strict ambiguity: delegate to legacy resolver (behavior unchanged)
} else {
let out = found.remove(0);
if trace {
crate::runner::trace::log(format!(
"[using/ssot:relative] '{}' -> '{}' (priority=cwd>using_paths)",
tgt, out
));
}
return Some(out);
}
}
}
// Fallback: keep parity by delegating to existing resolver within the same gate
let prev = std::env::var("HAKO_USING_SSOT_INVOKING").ok();
std::env::set_var("HAKO_USING_SSOT_INVOKING", "1");
let res = resolve_using_target(
tgt, is_path, modules, using_paths, aliases, packages, context_dir, strict, verbose,
);
if let Some(val) = prev { std::env::set_var("HAKO_USING_SSOT_INVOKING", val); } else { let _ = std::env::remove_var("HAKO_USING_SSOT_INVOKING"); }
res.ok()
}
/// Lint: enforce "fields must be at the top of box" rule.
/// - Warns by default (when verbose); when `strict` is true, returns Err on any violation.
pub(super) fn lint_fields_top(code: &str, strict: bool, verbose: bool) -> Result<(), String> {

View File

@ -29,7 +29,53 @@ pub fn encode_args(args: &[Box<dyn NyashBox>]) -> Vec<u8> {
encode::string(&mut buf, &a.to_string_box().value);
}
}
buf
maybe_tlv_roundtrip(buf)
}
/// Optional TLV shim roundtrip (feature/env gated).
///
/// Behavior:
/// - When compiled with feature `tlv-shim` AND env `HAKO_TLV_SHIM=1`,
/// the encoded TLV buffer is passed through `nyash-tlv` identity roundtrip.
/// - Otherwise, returns the original buffer unchanged.
pub fn maybe_tlv_roundtrip(buf: Vec<u8>) -> Vec<u8> {
if std::env::var("HAKO_TLV_SHIM").ok().as_deref() != Some("1") {
return buf;
}
#[cfg(feature = "tlv-shim")]
{
return nyash_tlv::tlv_roundtrip_identity(&buf);
}
#[cfg(not(feature = "tlv-shim"))]
{
// Feature disabled: keep behavior identical
buf
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tlv_roundtrip_off_by_default() {
std::env::remove_var("HAKO_TLV_SHIM");
let src = vec![1u8, 2, 3, 4, 5];
let out = maybe_tlv_roundtrip(src.clone());
assert_eq!(out, src);
}
#[cfg(feature = "tlv-shim")]
#[test]
fn tlv_roundtrip_env_feature_on() {
std::env::set_var("HAKO_TLV_SHIM", "1");
let src = vec![9u8, 8, 7, 6, 5, 4, 3];
let out = maybe_tlv_roundtrip(src.clone());
// Identity roundtrip returns the same bytes
assert_eq!(out, src);
// Cleanup
std::env::remove_var("HAKO_TLV_SHIM");
}
}
/// Simple helpers for common primitive returns

View File

@ -17,6 +17,11 @@ pub fn extern_call(
method_name: &str,
args: &[Box<dyn NyashBox>],
) -> BidResult<Option<Box<dyn NyashBox>>> {
if std::env::var("HAKO_CALL_TRACE").ok().as_deref() == Some("1") {
if should_trace_call_extern(iface_name, method_name) {
eprintln!("[call:{}.{}]", iface_name, method_name);
}
}
match iface_name {
"env.console" => handle_console(method_name, args),
"env.result" => handle_result(method_name, args),
@ -31,6 +36,19 @@ pub fn extern_call(
}
}
fn should_trace_call_extern(target: &str, method: &str) -> bool {
if let Ok(flt) = std::env::var("HAKO_CALL_TRACE_FILTER") {
let key = format!("{}.{}", target, method);
for pat in flt.split(',') {
let p = pat.trim();
if p.is_empty() { continue; }
if p == method || p == key { return true; }
}
return false;
}
true
}
/// Handle env.console.* methods
fn handle_console(method_name: &str, args: &[Box<dyn NyashBox>]) -> BidResult<Option<Box<dyn NyashBox>>> {
match method_name {

View File

@ -4,6 +4,7 @@ use crate::bid::{BidError, BidResult};
use crate::box_trait::NyashBox;
use crate::runtime::plugin_loader_v2::enabled::PluginLoaderV2;
use std::sync::Arc;
use std::env;
fn dbg_on() -> bool {
std::env::var("PLUGIN_DEBUG").is_ok()
@ -39,9 +40,60 @@ impl PluginLoaderV2 {
let plugins = self.plugins.read().map_err(|_| BidError::PluginError)?;
let _plugin = plugins.get(&lib_name).ok_or(BidError::PluginError)?;
// Optional C wrapper (Phase 22.2: design insertion point; default OFF)
if env::var("HAKO_PLUGIN_LOADER_C_WRAP").ok().as_deref() == Some("1") {
if should_trace_cwrap(box_type, method_name) {
eprintln!("[cwrap:invoke:{}.{}]", box_type, method_name);
}
// Future: route into a thin C shim here. For now, fall through to normal path.
}
// Optional C-core probe (design): emit tag and optionally call into c-core when enabled
if env::var("HAKO_C_CORE_ENABLE").ok().as_deref() == Some("1") && should_route_ccore(box_type, method_name) {
eprintln!("[c-core:invoke:{}.{}]", box_type, method_name);
#[cfg(feature = "c-core")]
{
// MapBox.set: call C-core stub (no-op) with available info
if box_type == "MapBox" && method_name == "set" {
let key = args.get(0).map(|b| b.to_string_box().value).unwrap_or_default();
let val = args.get(1).map(|b| b.to_string_box().value).unwrap_or_default();
let _ = nyash_c_core::core_map_set(type_id as i32, instance_id, &key, &val);
} else if box_type == "ArrayBox" && method_name == "push" {
// For design stage, pass 0 (we don't rely on c-core result)
let _ = nyash_c_core::core_array_push(type_id as i32, instance_id, 0);
} else if box_type == "ArrayBox" && method_name == "get" {
let _ = nyash_c_core::core_array_get(type_id as i32, instance_id, 0);
} else if box_type == "ArrayBox" && (method_name == "size" || method_name == "len" || method_name == "length") {
let _ = nyash_c_core::core_array_len(type_id as i32, instance_id);
} else {
// Generic probe
let _ = nyash_c_core::core_probe_invoke(box_type, method_name, args.len() as i32);
}
}
}
// Encode TLV args via shared helper (numeric→string→toString)
let tlv = crate::runtime::plugin_ffi_common::encode_args(args);
// Unified call trace (optional): plugin calls
if env::var("HAKO_CALL_TRACE").ok().as_deref() == Some("1") {
if should_trace_call(box_type, method_name) {
eprintln!("[call:{}.{}]", box_type, method_name);
}
}
// Optional trace for TLV shim path (debug only; default OFF)
if env::var("HAKO_TLV_SHIM_TRACE").ok().as_deref() == Some("1")
&& env::var("HAKO_TLV_SHIM").ok().as_deref() == Some("1")
{
if should_trace_tlv_shim(box_type, method_name) {
eprintln!("[tlv/shim:{}.{}]", box_type, method_name);
if env::var("HAKO_TLV_SHIM_TRACE_DETAIL").ok().as_deref() == Some("1") {
eprintln!("[tlv/shim:detail argc={}]", args.len());
}
}
}
if dbg_on() {
eprintln!(
"[PluginLoaderV2] call {}.{}: type_id={} method_id={} instance_id={}",
@ -62,6 +114,63 @@ impl PluginLoaderV2 {
}
}
fn should_trace_tlv_shim(box_type: &str, method: &str) -> bool {
// Filter provided → honor it
if let Ok(flt) = env::var("HAKO_TLV_SHIM_FILTER") {
let key = format!("{}.{}", box_type, method);
for pat in flt.split(',') {
let p = pat.trim();
if p.is_empty() { continue; }
if p == method || p == key { return true; }
}
return false;
}
// Default (minimal noise): only trace MapBox.set to begin with
box_type == "MapBox" && method == "set"
}
fn should_trace_cwrap(box_type: &str, method: &str) -> bool {
// Filter provided → honor it
if let Ok(flt) = env::var("HAKO_PLUGIN_LOADER_C_WRAP_FILTER") {
let key = format!("{}.{}", box_type, method);
for pat in flt.split(',') {
let p = pat.trim();
if p.is_empty() { continue; }
if p == method || p == key { return true; }
}
return false;
}
// Default (minimal noise): only trace MapBox.set to begin with
box_type == "MapBox" && method == "set"
}
fn should_trace_call(target: &str, method: &str) -> bool {
if let Ok(flt) = env::var("HAKO_CALL_TRACE_FILTER") {
let key = format!("{}.{}", target, method);
for pat in flt.split(',') {
let p = pat.trim();
if p.is_empty() { continue; }
if p == method || p == key { return true; }
}
return false;
}
true
}
fn should_route_ccore(box_type: &str, method: &str) -> bool {
if let Ok(flt) = env::var("HAKO_C_CORE_TARGETS") {
let key = format!("{}.{}", box_type, method);
for pat in flt.split(',') {
let p = pat.trim();
if p.is_empty() { continue; }
if p == method || p == key { return true; }
}
return false;
}
// Default minimal scope: MapBox.set only
box_type == "MapBox" && method == "set"
}
/// Resolve type information for a box
fn resolve_type_info(loader: &PluginLoaderV2, box_type: &str) -> BidResult<(String, u32)> {
if let Some(cfg) = loader.config.as_ref() {
@ -156,4 +265,4 @@ fn decode_tlv_result(box_type: &str, data: &[u8]) -> BidResult<Option<Box<dyn Ny
return Ok(Some(bx));
}
Ok(Some(Box::new(crate::box_trait::VoidBox::new())))
}
}

View File

@ -17,3 +17,4 @@ pub mod spec;
pub mod policy;
pub mod errors;
pub mod simple_registry;
pub mod ssot_bridge;

107
src/using/ssot_bridge.rs Normal file
View File

@ -0,0 +1,107 @@
//! SSOT bridge — thin callable shim from Rust to Hako resolver (Phase 22.1)
//!
//! MVP: does not invoke Hako VM yet. It mirrors the Hako box logic for modules-only
//! resolution, returning the mapped path when present. Callers must keep behavior
//! identical to existing resolver and use this only under an explicit env toggle.
use std::collections::HashMap;
use std::io::Write;
use std::process::Command;
#[derive(Default, Debug, Clone)]
pub struct SsotCtx {
pub modules: HashMap<String, String>,
pub using_paths: Vec<String>,
pub cwd: Option<String>,
}
/// Attempt to resolve via SSOT bridge. Returns Some(path) if found; otherwise None.
///
/// Behavior (MVP):
/// - Only consults `modules` map (exact match).
/// - Does not access filesystem nor invoke Hako VM.
pub fn call_using_resolve_ssot(name: &str, ctx: &SsotCtx) -> Option<String> {
if name.is_empty() { return None; }
// Optional: delegate to Hako resolver when explicitly requested.
if std::env::var("HAKO_USING_SSOT_HAKO").ok().as_deref() == Some("1") {
if let Some(hit) = call_hako_box(name, ctx) { return Some(hit); }
}
// MVP: modules-only
ctx.modules.get(name).cloned()
}
/// Try resolving via Hako `UsingResolveSSOTBox.resolve(name, ctx)` by spawning the nyash VM.
/// Guarded by `HAKO_USING_SSOT_HAKO=1`. Returns Some(path) on success; otherwise None.
fn call_hako_box(name: &str, ctx: &SsotCtx) -> Option<String> {
// Build inline Hako code that constructs a minimal ctx with modules map.
let mut code = String::new();
code.push_str("using hako.using.resolve.ssot as UsingResolveSSOTBox\n");
code.push_str("static box Main {\n main() {\n local modules = new MapBox()\n");
for (k, v) in ctx.modules.iter() {
// Escape quotes conservatively
let kk = k.replace('\"', "\\\"");
let vv = v.replace('\"', "\\\"");
code.push_str(&format!(" modules.set(\"{}\", \"{}\")\n", kk, vv));
}
code.push_str(" local ctx = new MapBox()\n ctx.set(\"modules\", modules)\n");
// relative_hint: opt-in via parent env HAKO_USING_SSOT_RELATIVE=1
if std::env::var("HAKO_USING_SSOT_RELATIVE").ok().as_deref() == Some("1") {
code.push_str(" ctx.set(\\\"relative_hint\\\", \\\"1\\\")\\n");
}
// using_paths
if !ctx.using_paths.is_empty() {
code.push_str(" local ups = new ArrayBox()\n");
for up in ctx.using_paths.iter() {
let upq = up.replace('\"', "\\\"");
code.push_str(&format!(" ups.push(\"{}\")\n", upq));
}
code.push_str(" ctx.set(\\\"using_paths\\\", ups)\n");
}
// cwd
if let Some(cwd) = &ctx.cwd {
let cwq = cwd.replace('\"', "\\\"");
code.push_str(&format!(" ctx.set(\\\"cwd\\\", \"{}\")\n", cwq));
}
let nn = name.replace('\"', "\\\"");
code.push_str(&format!(
" local r = UsingResolveSSOTBox.resolve(\"{}\", ctx)\n if r == null {{ return 0 }}\n print(r)\n return 0\n }}\n",
nn
));
// Write to a temp file
// Write ephemeral file; any failure → None (delegate to legacy)
let mut tf = tempfile::Builder::new()
.prefix("ny_ssot_")
.suffix(".hako")
.tempfile()
.ok()?;
let _ = write!(tf, "{}", code);
let path = tf.path().to_path_buf();
// Resolve nyash binary; fallback to current exe or default path on failure
let bin = std::env::var("NYASH_BIN").ok().unwrap_or_else(|| {
if let Ok(p) = std::env::current_exe() { p.to_string_lossy().to_string() }
else { "target/release/nyash".to_string() }
});
// Stage3 + tolerance (matches smokes wrappers)
let mut cmd = Command::new(bin);
cmd.arg("--backend").arg("vm").arg(&path)
// Parser/entry tolerances (same as smokes "safe" mode)
.env("NYASH_PARSER_STAGE3", "1")
.env("HAKO_PARSER_STAGE3", "1")
.env("NYASH_PARSER_ALLOW_SEMICOLON", "1")
.env("NYASH_ENTRY_ALLOW_TOPLEVEL_MAIN", "1")
// Disable inline compiler for stability
.env("NYASH_DISABLE_NY_COMPILER", "1")
.env("HAKO_DISABLE_NY_COMPILER", "1")
// Hard-disable SSOT in the child to avoid recursion; mark invoking guard
.env("HAKO_USING_SSOT", "0")
.env("HAKO_USING_SSOT_HAKO", "0")
.env("HAKO_USING_SSOT_RELATIVE", "0")
.env("HAKO_USING_SSOT_INVOKING", "1");
// Any spawn/IO error → None (fail-safe to legacy)
let out = cmd.output().ok()?;
if !out.status.success() { return None; }
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
if s.is_empty() { None } else { Some(s) }
}