Files
hakorune/src/runner/pipeline.rs
nyash-codex 63012932eb feat(phase-0): 観測ライン緊急構築完了 - Silent Failure 根絶
Phase 21.7++ Phase 0: 開発者体験を劇的に向上させる3つの改善

## 🎯 実装内容

###  Phase 0.1: populate_from_toml エラー即座表示
**ファイル**: src/runner/pipeline.rs:57-65

**Before**: TOML parse エラーが silent failure
**After**: エラーを即座に警告表示 + デバッグ方法提示

```
⚠️  [using/workspace] Failed to load TOML modules:
    Error: TOML parse error at line 18...
    → All 'using' aliases will be unavailable
    → Fix TOML syntax errors in workspace modules

    💡 Debug: NYASH_DEBUG_USING=1 for detailed logs
```

**効果**: 今回の StringUtils バグなら即発見できた!

###  Phase 0.2: VM 関数ルックアップ常時提案
**ファイル**: src/backend/mir_interpreter/handlers/calls/global.rs:179-206

**Before**: "Unknown: StringUtils.starts_with" だけ
**After**: 類似関数を自動提案 + デバッグ方法提示

```
Function not found: StringUtils.starts_with

💡 Did you mean:
   - StringUtils.starts_with/2
   - StringUtils.ends_with/2

🔍 Debug: NYASH_DEBUG_FUNCTION_LOOKUP=1 for full lookup trace
```

**効果**: arity 問題を即発見!環境変数不要で親切!

###  Phase 0.3: using not found 詳細化
**ファイル**: src/runner/modes/common_util/resolve/strip.rs:354-389

**Before**: "'StringUtils' not found" だけ
**After**: 類似モジュール提案 + 利用可能数 + 修正方法提示

```
using: 'StringUtil' not found in nyash.toml [using]/[modules]

💡 Did you mean:
   - StringUtils
   - JsonUtils

Available modules: 4 aliases

📝 Suggestions:
   - Add an alias in nyash.toml: [using.aliases] YourModule = "path/to/module"
   - Use the alias: using YourModule as YourModule
   - Dev/test mode: NYASH_PREINCLUDE=1

🔍 Debug: NYASH_DEBUG_USING=1 for detailed logs
```

**効果**: タイポを即発見!TOML エラーとの因果関係も提示!

## 📊 Phase 0 成果まとめ

**工数**: 約2.5時間(予想: 2-3時間)
**効果**: Silent Failure 完全根絶 🎉

### Before Phase 0
- TOML エラー: 無言で失敗
- 関数が見つからない: "Unknown" だけ
- using が見つからない: "not found" だけ
- デバッグ方法: 環境変数を知ってる人だけ

### After Phase 0
- TOML エラー: 即座に警告 + 影響範囲説明
- 関数が見つからない: 類似関数提案 + デバッグ方法
- using が見つからない: 類似モジュール提案 + 修正方法 + デバッグ方法
- すべてのエラーが親切 🎊

##  テスト結果

- StringUtils テスト:  PASS
- 既存テスト:  337 passed(16 failed は元々の失敗)
- ビルド:  SUCCESS
- 退行:  なし

## 🎉 開発者体験の改善

今回のような StringUtils using バグが起きても:
1. **TOML エラー**: 即発見(数秒)
2. **arity 問題**: 提案から即解決(数分)
3. **タイポ**: 提案から即修正(数秒)

**Before**: 数時間のデバッグ
**After**: 数分で解決 🚀

次のステップ: Phase 1(基盤整備)に進む準備完了!
2025-11-22 02:13:10 +09:00

695 lines
26 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*!
* Runner pipeline helpers — using/modules/env pre-processing
*
* Extracts the early-phase setup from runner/mod.rs:
* - load nyash.toml [modules] and [using.paths]
* - merge with defaults and env overrides
* - expose context (using_paths, pending_modules) for downstream resolution
*/
use super::*;
use crate::using::spec::{PackageKind, UsingPackage};
use crate::using::ssot_bridge::{call_using_resolve_ssot, SsotCtx};
use std::collections::HashMap;
/// Using/module resolution context accumulated from config/env/nyash.toml
pub(super) struct UsingContext {
pub using_paths: Vec<String>,
pub pending_modules: Vec<(String, String)>,
pub aliases: std::collections::HashMap<String, String>,
pub packages: std::collections::HashMap<String, UsingPackage>,
}
impl NyashRunner {
/// Initialize using/module context from defaults, nyash.toml and env
pub(super) fn init_using_context(&self) -> UsingContext {
let mut using_paths: Vec<String> = Vec::new();
let mut pending_modules: Vec<(String, String)> = Vec::new();
let mut aliases: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
let mut packages: std::collections::HashMap<String, UsingPackage> =
std::collections::HashMap::new();
// Defaults
using_paths.extend(["apps", "lib", "."].into_iter().map(|s| s.to_string()));
// nyash.toml: delegate to using resolver (keeps existing behavior)
let toml_result = crate::using::resolver::populate_from_toml(
&mut using_paths,
&mut pending_modules,
&mut aliases,
&mut packages,
);
// 🔍 Debug: Check if aliases are loaded
if std::env::var("NYASH_DEBUG_USING").ok().as_deref() == Some("1") {
eprintln!("[DEBUG/using] populate_from_toml result: {:?}", toml_result);
eprintln!("[DEBUG/using] Loaded {} aliases", aliases.len());
for (k, v) in aliases.iter() {
eprintln!("[DEBUG/using] alias: '{}' => '{}'", k, v);
}
eprintln!("[DEBUG/using] Loaded {} packages", packages.len());
for (k, v) in packages.iter() {
eprintln!("[DEBUG/using] package: '{}' => path='{}'", k, v.path);
}
}
// ⚠️ Phase 0.1: Immediate error display for TOML parse failures
if let Err(e) = &toml_result {
eprintln!("⚠️ [using/workspace] Failed to load TOML modules:");
eprintln!(" Error: {}", e);
eprintln!(" → All 'using' aliases will be unavailable");
eprintln!(" → Fix TOML syntax errors in workspace modules");
eprintln!();
eprintln!(" 💡 Debug: NYASH_DEBUG_USING=1 for detailed logs");
}
// Env overrides: modules and using paths
if let Ok(ms) = std::env::var("NYASH_MODULES") {
for ent in ms.split(',') {
if let Some((k, v)) = ent.split_once('=') {
let k = k.trim();
let v = v.trim();
if !k.is_empty() && !v.is_empty() {
pending_modules.push((k.to_string(), v.to_string()));
}
}
}
}
if let Ok(p) = std::env::var("NYASH_USING_PATH") {
for s in p.split(':') {
let s = s.trim();
if !s.is_empty() {
using_paths.push(s.to_string());
}
}
}
// Env aliases: comma-separated k=v pairs
if let Ok(raw) = std::env::var("NYASH_ALIASES") {
for ent in raw.split(',') {
if let Some((k, v)) = ent.split_once('=') {
let k = k.trim();
let v = v.trim();
if !k.is_empty() && !v.is_empty() {
aliases.insert(k.to_string(), v.to_string());
}
}
}
}
UsingContext {
using_paths,
pending_modules,
aliases,
packages,
}
}
}
/// Suggest candidate files by leaf name within limited bases (apps/lib/.)
#[allow(dead_code)]
pub(super) fn suggest_in_base(base: &str, leaf: &str, out: &mut Vec<String>) {
use std::fs;
fn walk(dir: &std::path::Path, leaf: &str, out: &mut Vec<String>, depth: usize) {
if depth == 0 || out.len() >= 5 {
return;
}
if let Ok(entries) = fs::read_dir(dir) {
for e in entries.flatten() {
let path = e.path();
if path.is_dir() {
walk(&path, leaf, out, depth - 1);
if out.len() >= 5 {
return;
}
} else if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
if ext == "nyash" || ext == "hako" {
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
if stem == leaf {
out.push(path.to_string_lossy().to_string());
if out.len() >= 5 {
return;
}
}
}
}
}
}
}
}
let p = std::path::Path::new(base);
walk(p, leaf, out, 4);
}
/// Resolve a using target according to priority: modules > relative > using-paths
/// Returns Ok(resolved_path_or_token). On strict mode, ambiguous matches cause error.
pub(super) fn resolve_using_target(
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,
) -> Result<String, String> {
// Dev toggle: try SSOT common resolver first, then fall back to legacy path.
// This helps migrate behavior gradually without changing defaults.
if std::env::var("HAKO_USING_RESOLVER_FIRST").ok().as_deref() == Some("1") {
match crate::using::resolver::resolve_using_target_common(
tgt,
modules,
using_paths,
packages,
context_dir,
strict,
verbose,
) {
Ok(val) => return Ok(val),
Err(_) => { /* fall through to legacy path */ }
}
}
// 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 {
return Ok(tgt.to_string());
}
let trace = verbose || crate::config::env::env_bool("NYASH_RESOLVE_TRACE");
let idx = super::box_index::get_box_index();
let mut strict_effective = strict || idx.plugins_require_prefix_global;
if crate::config::env::env_bool("NYASH_PLUGIN_REQUIRE_PREFIX") {
strict_effective = true;
}
let meta_for_target = idx.plugin_meta_by_box.get(tgt).cloned();
let mut require_prefix_target = meta_for_target
.as_ref()
.map(|m| m.require_prefix)
.unwrap_or(false);
if let Some(m) = &meta_for_target {
if !m.expose_short_names {
require_prefix_target = true;
}
}
let mut is_plugin_short = meta_for_target.is_some();
if !is_plugin_short {
is_plugin_short = idx.plugin_boxes.contains(tgt)
|| super::box_index::BoxIndex::is_known_plugin_short(tgt);
}
if (strict_effective || require_prefix_target) && is_plugin_short && !tgt.contains('.') {
let mut msg = format!("plugin short name '{}' requires prefix", tgt);
if let Some(meta) = &meta_for_target {
if let Some(pref) = &meta.prefix {
msg.push_str(&format!(" (use '{}.{}')", pref, tgt));
}
}
return Err(msg);
}
let key = {
let base = context_dir.and_then(|p| p.to_str()).unwrap_or("");
format!(
"{}|{}|{}|{}",
tgt,
base,
strict as i32,
using_paths.join(":")
)
};
if let Some(hit) = crate::runner::box_index::cache_get(&key) {
if trace {
crate::runner::trace::log(format!("[using/cache] '{}' -> '{}'", tgt, hit));
}
return Ok(hit);
}
// Resolve aliases early推移的に
// - ループ/循環を検出して早期エラー
// - 10段まで防衛的
if let Some(_) = aliases.get(tgt) {
use std::collections::HashSet;
let mut seen: HashSet<String> = HashSet::new();
let mut cur = tgt.to_string();
let mut depth = 0usize;
while let Some(next) = aliases.get(&cur).cloned() {
if trace {
crate::runner::trace::log(format!("[using/resolve] alias '{}' -> '{}'", cur, next));
}
if !seen.insert(cur.clone()) {
return Err(format!("alias cycle detected at '{}'", cur));
}
cur = next;
depth += 1;
if depth > 10 {
return Err(format!("alias resolution too deep starting at '{}'", tgt));
}
// Continue while next is also an alias; break when concrete
if !aliases.contains_key(&cur) {
break;
}
}
// Recurse once into final target to materialize path/token
let rec = resolve_using_target(
&cur,
false,
modules,
using_paths,
aliases,
packages,
context_dir,
strict,
verbose,
)?;
crate::runner::box_index::cache_put(&key, rec.clone());
return Ok(rec);
}
// Named packages (nyash.toml [using.<name>])
if let Some(pkg) = packages.get(tgt) {
match pkg.kind {
PackageKind::Dylib => {
// Return a marker token to avoid inlining attempts; loader will consume later stages
let out = format!("dylib:{}", pkg.path);
if trace {
crate::runner::trace::log(format!(
"[using/resolve] dylib '{}' -> '{}'",
tgt, out
));
}
crate::runner::box_index::cache_put(&key, out.clone());
return Ok(out);
}
PackageKind::Package => {
// Compute entry: main or <dir_last>.hako
let base = std::path::Path::new(&pkg.path);
let out = if let Some(m) = &pkg.main {
if matches!(
base.extension().and_then(|s| s.to_str()),
Some("nyash") | Some("hako")
) {
// path is a file; ignore main and use as-is
pkg.path.clone()
} else {
base.join(m).to_string_lossy().to_string()
}
} else {
if matches!(
base.extension().and_then(|s| s.to_str()),
Some("nyash") | Some("hako")
) {
pkg.path.clone()
} else {
let leaf = base.file_name().and_then(|s| s.to_str()).unwrap_or(tgt);
// prefer .hako when package path points to a directory without explicit main
let hako = base.join(format!("{}.hako", leaf));
if hako.exists() {
hako.to_string_lossy().to_string()
} else {
base.join(format!("{}.hako", leaf))
.to_string_lossy()
.to_string()
}
}
};
if trace {
crate::runner::trace::log(format!(
"[using/resolve] package '{}' -> '{}'",
tgt, out
));
}
crate::runner::box_index::cache_put(&key, out.clone());
return Ok(out);
}
}
}
// Also consult env aliases
if let Ok(raw) = std::env::var("NYASH_ALIASES") {
for ent in raw.split(',') {
if let Some((k, v)) = ent.split_once('=') {
if k.trim() == tgt {
let out = v.trim().to_string();
if trace {
crate::runner::trace::log(format!(
"[using/resolve] env-alias '{}' -> '{}'",
tgt, out
));
}
crate::runner::box_index::cache_put(&key, out.clone());
return Ok(out);
}
}
}
}
// 2) Special handling for built-in namespaces
if tgt == "nyashstd" {
let out = "builtin:nyashstd".to_string();
if trace {
crate::runner::trace::log(format!("[using/resolve] builtin '{}' -> '{}'", tgt, out));
}
crate::runner::box_index::cache_put(&key, out.clone());
return Ok(out);
}
// 3) delegate resolution to using::resolver (SSOT)
match crate::using::resolver::resolve_using_target_common(
tgt,
modules,
using_paths,
packages,
context_dir,
strict,
verbose,
) {
Ok(val) => {
crate::runner::box_index::cache_put(&key, val.clone());
Ok(val)
}
Err(e) => {
// Maintain previous behavior: return original name and log when unresolved
if trace {
crate::runner::trace::log(format!("[using] unresolved '{}' ({})", tgt, e));
} else {
eprintln!("[using] not found: '{}'", tgt);
}
Ok(tgt.to_string())
}
}
}
/// 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> {
let mut brace: i32 = 0;
let mut in_box = false;
let mut box_depth: i32 = 0;
let mut seen_method = false;
let mut cur_box: String = String::new();
let mut violations: Vec<(usize, String, String)> = Vec::new(); // (line, field, box)
for (idx, line) in code.lines().enumerate() {
let lno = idx + 1;
let pre_brace = brace;
let trimmed = line.trim();
// Count braces for this line
let opens = line.matches('{').count() as i32;
let closes = line.matches('}').count() as i32;
// Enter box on same-line K&R style: `box Name {` or `static box Name {`
if !in_box && trimmed.starts_with("box ") || trimmed.starts_with("static box ") {
// capture name
let mut name = String::new();
let after = if let Some(rest) = trimmed.strip_prefix("static box ") {
rest
} else {
trimmed.strip_prefix("box ").unwrap_or("")
};
for ch in after.chars() {
if ch.is_alphanumeric() || ch == '_' {
name.push(ch);
} else {
break;
}
}
// require K&R brace on same line to start tracking
if opens > 0 {
in_box = true;
cur_box = name;
box_depth = pre_brace + 1; // assume one level for box body
seen_method = false;
}
}
if in_box {
// Top-level inside box only
if pre_brace == box_depth {
// Skip empty/comment lines
if !trimmed.is_empty() && !trimmed.starts_with("//") {
// Detect method: name(args) {
let is_method = {
// starts with identifier then '(' and later '{'
let mut it = trimmed.chars();
let mut ident = String::new();
while let Some(c) = it.next() {
if c.is_whitespace() {
continue;
}
if c.is_alphabetic() || c == '_' {
ident.push(c);
break;
} else {
break;
}
}
while let Some(c) = it.next() {
if c.is_alphanumeric() || c == '_' {
ident.push(c);
} else {
break;
}
}
trimmed.contains('(') && trimmed.ends_with('{') && !ident.is_empty()
};
if is_method {
seen_method = true;
}
// Detect field: ident ':' Type (rough heuristic)
let is_field = {
let parts: Vec<&str> = trimmed.split(':').collect();
if parts.len() == 2 {
let lhs = parts[0].trim();
let rhs = parts[1].trim();
let lhs_ok = !lhs.is_empty()
&& lhs
.chars()
.next()
.map(|c| c.is_alphabetic() || c == '_')
.unwrap_or(false);
let rhs_ok = !rhs.is_empty()
&& rhs
.chars()
.next()
.map(|c| c.is_alphabetic() || c == '_')
.unwrap_or(false);
lhs_ok && rhs_ok && !trimmed.contains('(') && !trimmed.contains(')')
} else {
false
}
};
if is_field && seen_method {
violations.push((lno, trimmed.to_string(), cur_box.clone()));
}
}
}
// Exit box when closing brace reduces depth below box_depth
let post_brace = pre_brace + opens - closes;
if post_brace < box_depth {
in_box = false;
cur_box.clear();
}
}
// Update brace after processing
brace += opens - closes;
}
if violations.is_empty() {
return Ok(());
}
if strict {
// Compose error message
let mut msg =
String::from("Field declarations must appear at the top of box. Violations:\n");
for (lno, fld, bx) in violations.iter().take(10) {
msg.push_str(&format!(
" line {} in box {}: '{}",
lno,
if bx.is_empty() { "<unknown>" } else { bx },
fld
));
msg.push_str("'\n");
}
if violations.len() > 10 {
msg.push_str(&format!(" ... and {} more\n", violations.len() - 10));
}
return Err(msg);
}
if verbose || crate::config::env::env_bool("NYASH_RESOLVE_TRACE") {
for (lno, fld, bx) in violations {
eprintln!(
"[lint] fields-top: line {} in box {} -> {}",
lno,
if bx.is_empty() { "<unknown>" } else { &bx },
fld
);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn plugin_meta_requires_prefix_even_when_relaxed() {
let dir = tempdir().expect("tempdir");
let old = std::env::current_dir().expect("cwd");
std::env::set_current_dir(dir.path()).expect("chdir");
let toml = r#"
[plugins]
require_prefix = false
[plugins."test-plugin"]
prefix = "test"
require_prefix = true
expose_short_names = false
boxes = ["ArrayBox"]
"#;
std::fs::write("nyash.toml", toml).expect("write nyash.toml");
crate::runner::box_index::refresh_box_index();
crate::runner::box_index::cache_clear();
let res = resolve_using_target(
"ArrayBox",
false,
&[],
&[],
&HashMap::new(),
&std::collections::HashMap::<String, crate::using::spec::UsingPackage>::new(),
None,
false,
false,
);
assert!(res.is_err(), "expected prefix enforcement");
let err = res.err().unwrap();
assert!(err.contains("requires prefix"));
assert!(err.contains("test."));
std::env::set_current_dir(old).expect("restore cwd");
crate::runner::box_index::refresh_box_index();
crate::runner::box_index::cache_clear();
}
}