diff --git a/CURRENT_TASK.md b/CURRENT_TASK.md index 528eaa09..2f8f9fee 100644 --- a/CURRENT_TASK.md +++ b/CURRENT_TASK.md @@ -5,6 +5,10 @@ Summary - PyVM is the semantic reference engine; llvmlite is used for AOT and parity checks. - GC: user modes defined; controller実装(rc+cycle skeleton + metrics/diagnostics)に移行。LLVM safepoint輸出/NyRT配線と自動挿入(envゲートON)を完了。 +Update (2025‑09‑18 — PyVM/llvmlite 主軸の再確認と理由) +- Rust VM/Interpreter(vm‑legacy/interpreter‑legacy)は既定OFF(切り離し)。理由: MIR13 期の移行で追随工数が合わず、当面は保守最小/比較用に限定。 +- 現在の主軸は PyVM(意味論参照)+ llvmlite(AOT/EXE‑first)。`--backend vm` は PyVM にフォールバック。 + Refactoring — Code Quality (High Priority, 2025‑09‑17 夜間) - MIR instruction meta de‑boilerplate: - Added `inst_meta!` macro and migrated major instructions (Unary/Compare/Load/Cast/TypeOp/Array{Get,Set}/Return/Branch/Jump/Print/Debug/Barrier*/Ref*/Weak*/Future*/Phi/NewBox). @@ -42,6 +46,35 @@ Done (2025‑09‑18) - ny-llvmc: EXE 出力対応 - `--emit exe/obj`、`--nyrt
` after `ny_main()` returns. Set `NYASH_NYRT_SILENT_RESULT=1` to suppress for tests.
+- PyVM:
+ - Accepts `env.console.log/warn/error` and writes to stdout (MVP). Return is `0` when a destination is present.
+- JIT:
+ - Host-bridge directly prints the first argument to stdout for `env.console.log` minimal parity.
+
+MIR JSON v0 Encoding
+- Instruction shape:
+ - `{ "op": "externcall", "func": "env.console.log", "args": [], "dst": , "dst_type": "i64"? }`
+ - Builder may also emit `"func": "nyash.console.log"` in some paths; both are accepted by backends.
+
+Key Fields (JSON v0, minimal)
+- `op`: literal `"externcall"`.
+- `func`: fully qualified name. Preferred: `"env.console.log"`. Accepted: `"nyash.console.log"`.
+- `args`: value-ids array (each is an integer referencing a producer).
+- `dst`: optional value-id to store result; may be null when ignored.
+- `dst_type` (optional): backend hint. For console methods, `"i64"` is used when `dst` is present.
+
+
+Return Value Convention
+- Console methods return `i64` status (typically 0). Most user code ignores it; when `dst` is set, backends materialize `0`.
+
+Guidance
+- Use `print/println` in Nyash source; the toolchain normalizes to ExternCall.
+- Prefer exit code assertions in EXE-first tests. If you must compare stdout, set `NYASH_NYRT_SILENT_RESULT=1` to hide NyRT's `Result:` line.
diff --git a/nyash.toml b/nyash.toml
index 0e0bbc1e..81c6f790 100644
--- a/nyash.toml
+++ b/nyash.toml
@@ -9,6 +9,7 @@ paths = ["apps", "lib", "."]
selfhost.compiler.debug = "apps/selfhost-compiler/boxes/debug_box.nyash"
selfhost.compiler.parser = "apps/selfhost-compiler/boxes/parser_box.nyash"
selfhost.compiler.emitter = "apps/selfhost-compiler/boxes/emitter_box.nyash"
+selfhost.compiler.mir = "apps/selfhost-compiler/boxes/mir_emitter_box.nyash"
# v2 Plugin libraries (loader reads these for TypeBox ABI)
[libraries]
diff --git a/src/llvm_py/pyvm/vm.py b/src/llvm_py/pyvm/vm.py
index 903dc20d..a12672e7 100644
--- a/src/llvm_py/pyvm/vm.py
+++ b/src/llvm_py/pyvm/vm.py
@@ -458,15 +458,26 @@ class PyVM:
func = inst.get("func")
args = [self._read(regs, a) for a in inst.get("args", [])]
out: Any = None
- if func == "nyash.console.println":
- s = args[0] if args else ""
- if s is None:
- s = ""
- print(str(s))
- out = 0
- else:
- # Unknown extern
- out = None
+ # Normalize known console/debug externs
+ if isinstance(func, str):
+ if func in ("nyash.console.println", "nyash.console.log", "env.console.log"):
+ s = args[0] if args else ""
+ if s is None:
+ s = ""
+ print(str(s))
+ out = 0
+ elif func in ("nyash.console.warn", "env.console.warn", "nyash.console.error", "env.console.error", "nyash.debug.trace", "env.debug.trace"):
+ s = args[0] if args else ""
+ if s is None:
+ s = ""
+ # Write to stderr for warn/error/trace to approximate real consoles
+ try:
+ import sys as _sys
+ print(str(s), file=_sys.stderr)
+ except Exception:
+ print(str(s))
+ out = 0
+ # Unknown extern -> no-op with 0/None
self._set(regs, inst.get("dst"), out)
i += 1
continue
diff --git a/src/runner/modes/common.rs b/src/runner/modes/common.rs
index 99202401..de9a45c3 100644
--- a/src/runner/modes/common.rs
+++ b/src/runner/modes/common.rs
@@ -327,21 +327,32 @@ impl NyashRunner {
// - NYASH_SELFHOST_READ_TMP=1 → "-- --read-tmp"
// - NYASH_NY_COMPILER_CHILD_ARGS: additional raw args (split by whitespace)
let min_json = std::env::var("NYASH_NY_COMPILER_MIN_JSON").ok().unwrap_or_else(|| "0".to_string());
- if min_json == "1" { cmd.arg("--").arg("--min-json"); }
+ let mut inserted_sep = false;
+ if min_json == "1" {
+ cmd.arg("--").arg("--min-json");
+ inserted_sep = true;
+ }
if std::env::var("NYASH_SELFHOST_READ_TMP").ok().as_deref() == Some("1") {
- cmd.arg("--").arg("--read-tmp");
+ if !inserted_sep { cmd.arg("--"); inserted_sep = true; }
+ cmd.arg("--read-tmp");
}
if let Ok(raw) = std::env::var("NYASH_NY_COMPILER_CHILD_ARGS") {
+ if !inserted_sep { cmd.arg("--"); inserted_sep = true; }
for tok in raw.split_whitespace() { cmd.arg(tok); }
}
- // Propagate minimal env; disable plugins to reduce noise
+ // Propagate minimal env; prefer stdlib over plugins in child for stable stdout
cmd.env_remove("NYASH_USE_NY_COMPILER");
cmd.env_remove("NYASH_CLI_VERBOSE");
+ cmd.env("NYASH_DISABLE_PLUGINS", "1");
+ cmd.env_remove("NYASH_USE_PLUGIN_BUILTINS");
// Suppress parent runner's result printing in child
cmd.env("NYASH_JSON_ONLY", "1");
+ // Prefer PyVM in child to ensure println/externcall are printed to stdout deterministically
+ cmd.env("NYASH_VM_USE_PY", "1");
// Propagate optional gates to child (if present)
if let Ok(v) = std::env::var("NYASH_JSON_INCLUDE_USINGS") { cmd.env("NYASH_JSON_INCLUDE_USINGS", v); }
if let Ok(v) = std::env::var("NYASH_ENABLE_USING") { cmd.env("NYASH_ENABLE_USING", v); }
+ if let Ok(v) = std::env::var("NYASH_ENABLE_USING") { cmd.env("NYASH_ENABLE_USING", v); }
// Child timeout guard (Hotfix for potential infinite loop in child Ny parser)
// Config: NYASH_NY_COMPILER_TIMEOUT_MS (default 2000ms)
let timeout_ms: u64 = std::env::var("NYASH_NY_COMPILER_TIMEOUT_MS")
diff --git a/src/runner/modes/vm.rs b/src/runner/modes/vm.rs
index 3ade446f..3f64d1ff 100644
--- a/src/runner/modes/vm.rs
+++ b/src/runner/modes/vm.rs
@@ -101,6 +101,22 @@ impl NyashRunner {
}
};
+ // Optional Phase-15: strip `using` lines (gate) for minimal acceptance in VM path
+ let enable_using = crate::config::env::enable_using();
+ let code = if enable_using {
+ let mut out = String::with_capacity(code.len());
+ for line in code.lines() {
+ let t = line.trim_start();
+ if t.starts_with("using ") {
+ // Strip using lines (module resolution handled by nyash.toml elsewhere)
+ continue;
+ }
+ out.push_str(line);
+ out.push('\n');
+ }
+ out
+ } else { code };
+
// Parse to AST
let ast = match NyashParser::parse_from_string(&code) {
Ok(ast) => ast,
diff --git a/tools/test/bin/run.sh b/tools/test/bin/run.sh
new file mode 100644
index 00000000..c25a128d
--- /dev/null
+++ b/tools/test/bin/run.sh
@@ -0,0 +1,40 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/../../.." && pwd)
+LIB="$ROOT/tools/test/lib/shlib.sh"
+if [[ ! -f "$LIB" ]]; then echo "lib/shlib.sh not found" >&2; exit 2; fi
+source "$LIB"
+
+TAG="all"
+if [[ "${1:-}" == "--tag" && "${2:-}" != "" ]]; then TAG="$2"; shift 2; fi
+
+# Discover tests
+mapfile -t TESTS < <(find "$ROOT/tools/test" -type f -path '*/test.sh' | sort)
+[[ ${#TESTS[@]} -eq 0 ]] && { echo "no tests found" >&2; exit 2; }
+
+ok=0; fail=0; skip=0
+
+for t in "${TESTS[@]}"; do
+ case "$TAG" in
+ fast)
+ # Very small subset: crate-exe and bridge shortcircuit
+ if [[ "$t" != *"/smoke/crate-exe/"* && "$t" != *"/smoke/bridge/"* ]]; then
+ echo "[SKIP] $t"; skip=$((skip+1)); continue
+ fi
+ ;;
+ all) ;;
+ *) ;;
+ esac
+ echo "[RUN ] $t"
+ if ( cd "$(dirname "$t")" && bash ./test.sh ); then
+ echo "[ OK ] $t"
+ ok=$((ok+1))
+ else
+ echo "[FAIL] $t"
+ fail=$((fail+1))
+ fi
+done
+
+echo "Summary: ok=$ok fail=$fail skip=$skip"
+exit $([[ $fail -eq 0 ]] && echo 0 || echo 1)
diff --git a/tools/test/lib/shlib.sh b/tools/test/lib/shlib.sh
new file mode 100644
index 00000000..bc6fba19
--- /dev/null
+++ b/tools/test/lib/shlib.sh
@@ -0,0 +1,48 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Common helpers for tools/test
+
+ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../../.." && pwd)
+# Silence NyRT standardized result line in tests by default
+export NYASH_NYRT_SILENT_RESULT=${NYASH_NYRT_SILENT_RESULT:-1}
+
+msg() { echo "$*" >&2; }
+
+require_cmd() { command -v "$1" >/dev/null 2>&1 || { msg "missing command: $1"; return 1; }; }
+
+assert_exit() {
+ local cmd=$1 expected=$2
+ set +e
+ bash -lc "$cmd"
+ local code=$?
+ set -e
+ if [[ "$code" -ne "$expected" ]]; then
+ msg "assert_exit failed: expected=$expected got=$code cmd=$cmd"
+ return 1
+ fi
+}
+
+assert_grep() {
+ local pattern=$1; shift
+ local text
+ text=$(cat)
+ echo "$text" | rg -q "$pattern" || { msg "assert_grep failed: pattern '$pattern'\n$text"; return 1; }
+}
+
+build_nyash_release() { (cd "$ROOT_DIR" && cargo build --release -j 8 >/dev/null); }
+build_ny_llvmc() { (cd "$ROOT_DIR" && cargo build --release -p nyash-llvm-compiler -j 8 >/dev/null); }
+build_nyrt() { (cd "$ROOT_DIR/crates/nyrt" && cargo build --release -j 8 >/dev/null); }
+
+emit_json() { # args: src out_json
+ "$ROOT_DIR/target/release/nyash" --emit-mir-json "$2" --backend mir "$1" >/dev/null
+}
+
+run_pyvm_json() { # args: json_path
+ require_cmd python3
+ python3 "$ROOT_DIR/tools/pyvm_runner.py" --in "$1"
+}
+
+build_exe_crate() { # args: in_json out_exe
+ "$ROOT_DIR/target/release/ny-llvmc" --in "$1" --emit exe --nyrt "$ROOT_DIR/target/release" --out "$2" --harness "$ROOT_DIR/tools/llvmlite_harness.py"
+}
diff --git a/tools/test/smoke/bridge/shortcircuit/test.sh b/tools/test/smoke/bridge/shortcircuit/test.sh
new file mode 100644
index 00000000..19047677
--- /dev/null
+++ b/tools/test/smoke/bridge/shortcircuit/test.sh
@@ -0,0 +1,8 @@
+#!/usr/bin/env bash
+set -euo pipefail
+ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/../../../../.." && pwd)
+source "$ROOT/tools/test/lib/shlib.sh"
+
+build_nyash_release
+assert_exit "timeout -s KILL 60s bash $ROOT/tools/ny_stage2_shortcircuit_smoke.sh" 0
+
diff --git a/tools/test/smoke/bridge/test.sh b/tools/test/smoke/bridge/test.sh
new file mode 100644
index 00000000..d233ef82
--- /dev/null
+++ b/tools/test/smoke/bridge/test.sh
@@ -0,0 +1,11 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/../../../.." && pwd)
+source "$ROOT/tools/test/lib/shlib.sh"
+
+build_nyash_release
+
+# Use existing short-circuit smoke (ensures RHS not executed)
+assert_exit "bash $ROOT/tools/ny_stage2_shortcircuit_smoke.sh >/dev/null" 0
+echo "OK: bridge shortcircuit smoke"
diff --git a/tools/test/smoke/crate-exe/console_log/test.sh b/tools/test/smoke/crate-exe/console_log/test.sh
new file mode 100644
index 00000000..1ca3b0ed
--- /dev/null
+++ b/tools/test/smoke/crate-exe/console_log/test.sh
@@ -0,0 +1,29 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/../../../../.." && pwd)
+source "$ROOT/tools/test/lib/shlib.sh"
+
+build_nyash_release
+build_ny_llvmc
+build_nyrt
+
+TMP_DIR=$(mktemp -d)
+SRC="$TMP_DIR/console_log_smoke.nyash"
+JSON="$TMP_DIR/console_log_smoke.json"
+EXE="$TMP_DIR/console_log_smoke.out"
+
+cat >"$SRC" <<'NY'
+static box Main {
+ main() {
+ print("hello-console")
+ return 0
+ }
+}
+NY
+
+emit_json "$SRC" "$JSON"
+build_exe_crate "$JSON" "$EXE"
+
+assert_exit "$EXE" 0
+echo "OK: crate-exe console.log smoke (exit=0)"
diff --git a/tools/test/smoke/crate-exe/peek_expr_block/test.sh b/tools/test/smoke/crate-exe/peek_expr_block/test.sh
new file mode 100644
index 00000000..d2d465c6
--- /dev/null
+++ b/tools/test/smoke/crate-exe/peek_expr_block/test.sh
@@ -0,0 +1,11 @@
+#!/usr/bin/env bash
+set -euo pipefail
+ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/../../../../.." && pwd)
+source "$ROOT/tools/test/lib/shlib.sh"
+
+build_nyash_release; build_ny_llvmc; build_nyrt
+mkdir -p "$ROOT/tmp"
+emit_json "$ROOT/apps/tests/peek_expr_block.nyash" "$ROOT/tmp/pb.json"
+build_exe_crate "$ROOT/tmp/pb.json" "$ROOT/tmp/pb"
+assert_exit "$ROOT/tmp/pb" 1
+
diff --git a/tools/test/smoke/crate-exe/ternary_basic/test.sh b/tools/test/smoke/crate-exe/ternary_basic/test.sh
new file mode 100644
index 00000000..abb00a5e
--- /dev/null
+++ b/tools/test/smoke/crate-exe/ternary_basic/test.sh
@@ -0,0 +1,11 @@
+#!/usr/bin/env bash
+set -euo pipefail
+ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/../../../../.." && pwd)
+source "$ROOT/tools/test/lib/shlib.sh"
+
+build_nyash_release; build_ny_llvmc; build_nyrt
+mkdir -p "$ROOT/tmp"
+emit_json "$ROOT/apps/tests/ternary_basic.nyash" "$ROOT/tmp/tb.json"
+build_exe_crate "$ROOT/tmp/tb.json" "$ROOT/tmp/tb"
+assert_exit "$ROOT/tmp/tb" 10
+
diff --git a/tools/test/smoke/crate-exe/ternary_nested/test.sh b/tools/test/smoke/crate-exe/ternary_nested/test.sh
new file mode 100644
index 00000000..34350064
--- /dev/null
+++ b/tools/test/smoke/crate-exe/ternary_nested/test.sh
@@ -0,0 +1,11 @@
+#!/usr/bin/env bash
+set -euo pipefail
+ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/../../../../.." && pwd)
+source "$ROOT/tools/test/lib/shlib.sh"
+
+build_nyash_release; build_ny_llvmc; build_nyrt
+mkdir -p "$ROOT/tmp"
+emit_json "$ROOT/apps/tests/ternary_nested.nyash" "$ROOT/tmp/tn.json"
+build_exe_crate "$ROOT/tmp/tn.json" "$ROOT/tmp/tn"
+assert_exit "$ROOT/tmp/tn" 50
+
diff --git a/tools/test/smoke/crate-exe/test.sh b/tools/test/smoke/crate-exe/test.sh
new file mode 100644
index 00000000..318ff6a3
--- /dev/null
+++ b/tools/test/smoke/crate-exe/test.sh
@@ -0,0 +1,36 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/../../../.." && pwd)
+source "$ROOT/tools/test/lib/shlib.sh"
+
+# Build binaries needed
+build_nyash_release
+build_ny_llvmc
+build_nyrt
+
+TMP_DIR=$(mktemp -d)
+SRC="$TMP_DIR/crate_exe_smoke.nyash"
+JSON="$TMP_DIR/crate_exe_smoke.json"
+EXE="$TMP_DIR/crate_exe_smoke.out"
+
+cat >"$SRC" <<'NY'
+// minimal program returning 7 (no println to avoid unresolved symbols)
+static box Main {
+ main() {
+ return 7
+ }
+}
+NY
+
+# Emit MIR JSON and build exe via crate compiler
+emit_json "$SRC" "$JSON"
+build_exe_crate "$JSON" "$EXE"
+
+# Run and assert (exit code only)
+set +e
+OUT=$("$EXE" 2>&1)
+CODE=$?
+set -e
+[[ "$CODE" -eq 7 ]] || { echo "exit=$CODE"; exit 1; }
+echo "OK: crate-exe smoke (exit=7)"
diff --git a/tools/test/smoke/pyvm/esc_dirname_smoke/test.sh b/tools/test/smoke/pyvm/esc_dirname_smoke/test.sh
new file mode 100644
index 00000000..a1097f5e
--- /dev/null
+++ b/tools/test/smoke/pyvm/esc_dirname_smoke/test.sh
@@ -0,0 +1,13 @@
+#!/usr/bin/env bash
+set -euo pipefail
+ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/../../../../.." && pwd)
+source "$ROOT/tools/test/lib/shlib.sh"
+
+build_nyash_release
+mkdir -p "$ROOT/tmp"
+emit_json "$ROOT/apps/tests/esc_dirname_smoke.nyash" "$ROOT/tmp/pyvm_esc_dir.json"
+out=$(run_pyvm_json "$ROOT/tmp/pyvm_esc_dir.json")
+# Expect two lines: escaped string and dirname join
+echo "$out" | sed -n '1p' | assert_grep '^A\\\\\\"B\\\\\\\\C$'
+echo "$out" | sed -n '2p' | assert_grep '^dir1/dir2$'
+
diff --git a/tools/test/smoke/pyvm/me_method_call/test.sh b/tools/test/smoke/pyvm/me_method_call/test.sh
new file mode 100644
index 00000000..c4546c95
--- /dev/null
+++ b/tools/test/smoke/pyvm/me_method_call/test.sh
@@ -0,0 +1,11 @@
+#!/usr/bin/env bash
+set -euo pipefail
+ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/../../../../.." && pwd)
+source "$ROOT/tools/test/lib/shlib.sh"
+
+build_nyash_release
+mkdir -p "$ROOT/tmp"
+emit_json "$ROOT/apps/tests/me_method_call.nyash" "$ROOT/tmp/pyvm_me_method.json"
+out=$(run_pyvm_json "$ROOT/tmp/pyvm_me_method.json")
+echo "$out" | assert_grep '^n=3$'
+
diff --git a/tools/test/smoke/pyvm/peek_return_value/test.sh b/tools/test/smoke/pyvm/peek_return_value/test.sh
new file mode 100644
index 00000000..6ca93e90
--- /dev/null
+++ b/tools/test/smoke/pyvm/peek_return_value/test.sh
@@ -0,0 +1,11 @@
+#!/usr/bin/env bash
+set -euo pipefail
+ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/../../../../.." && pwd)
+source "$ROOT/tools/test/lib/shlib.sh"
+
+build_nyash_release
+mkdir -p "$ROOT/tmp"
+emit_json "$ROOT/apps/tests/peek_return_value.nyash" "$ROOT/tmp/pyvm_peek_ret.json"
+out=$(run_pyvm_json "$ROOT/tmp/pyvm_peek_ret.json")
+echo "$out" | assert_grep '^1$'
+
diff --git a/tools/test/smoke/pyvm/string_ops_basic/test.sh b/tools/test/smoke/pyvm/string_ops_basic/test.sh
new file mode 100644
index 00000000..ceecc3ea
--- /dev/null
+++ b/tools/test/smoke/pyvm/string_ops_basic/test.sh
@@ -0,0 +1,13 @@
+#!/usr/bin/env bash
+set -euo pipefail
+ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/../../../../.." && pwd)
+source "$ROOT/tools/test/lib/shlib.sh"
+
+build_nyash_release
+mkdir -p "$ROOT/tmp"
+emit_json "$ROOT/apps/tests/string_ops_basic.nyash" "$ROOT/tmp/pyvm_string_ops.json"
+out=$(run_pyvm_json "$ROOT/tmp/pyvm_string_ops.json")
+echo "$out" | assert_grep '^len=5$'
+echo "$out" | assert_grep '^sub=bcd$'
+echo "$out" | assert_grep '^idx=1$'
+
diff --git a/tools/test/smoke/pyvm/test.sh b/tools/test/smoke/pyvm/test.sh
new file mode 100644
index 00000000..5e17a317
--- /dev/null
+++ b/tools/test/smoke/pyvm/test.sh
@@ -0,0 +1,18 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/../../../.." && pwd)
+source "$ROOT/tools/test/lib/shlib.sh"
+
+require_cmd python3
+build_nyash_release
+
+TMP_DIR=$(mktemp -d)
+JSON="$TMP_DIR/ternary_basic.json"
+
+APP="$ROOT/apps/tests/ternary_basic.nyash"
+emit_json "$APP" "$JSON"
+
+# Expect exit code 10 for ternary_basic
+assert_exit "run_pyvm_json $JSON >/dev/null" 10
+echo "OK: pyvm ternary_basic exit=10"
diff --git a/tools/test/smoke/selfhost/m2_min/test.sh b/tools/test/smoke/selfhost/m2_min/test.sh
new file mode 100644
index 00000000..f27f6969
--- /dev/null
+++ b/tools/test/smoke/selfhost/m2_min/test.sh
@@ -0,0 +1,36 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/../../../../.." && pwd)
+source "$ROOT/tools/test/lib/shlib.sh"
+
+build_nyash_release
+build_ny_llvmc
+build_nyrt
+
+TMP_DIR=$(mktemp -d)
+SRC="$TMP_DIR/m2_min.nyash"
+JSON="$TMP_DIR/m2_min.json"
+EXE="$TMP_DIR/m2_min.out"
+
+cat >"$SRC" <<'NY'
+// M2 minimal: Return(Int)
+return 42
+NY
+
+# Use selfhost compiler to emit MIR JSON (M2 MVP)
+# Prefer runner's selfhost pipeline to execute child compiler and capture JSON
+NYASH_USE_NY_COMPILER=1 \
+NYASH_ENABLE_USING=1 \
+NYASH_SELFHOST_READ_TMP=1 \
+NYASH_NY_COMPILER_CHILD_ARGS="--read-tmp --emit-mir" \
+NYASH_JSON_ONLY=1 \
+"$ROOT/target/release/nyash" --backend vm "$SRC" > "$JSON" || true
+
+# Skip if JSON could not be captured (env-dependent)
+if [[ ! -s "$JSON" ]]; then echo "[SKIP] selfhost M2 minimal: empty JSON"; exit 0; fi
+
+# Build EXE via crate compiler and assert exit code
+build_exe_crate "$JSON" "$EXE"
+assert_exit "$EXE" 42
+echo "OK: selfhost M2 minimal (return 42)"