feat(joinir): Phase 112 strict guard for StepTree required_caps

- Add control_tree_capability_guard.rs with check(tree, func_name, strict, dev)
- Allowlist: If, NestedIf, Loop, Return, Break, Continue
- Deny (strict): NestedLoop, TryCatch, Throw, Lambda, While, ForRange, Match, Arrow
- Wire into lower_function_body() (strict-only check)
- Error format: [joinir/control_tree/cap_missing/<Cap>] with 1-line Hint
- Unit tests: nested_loop_rejects, if_only_passes, strict_false_passes
- Default behavior unchanged (strict=false always Ok)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
nyash-codex
2025-12-18 01:38:25 +09:00
parent 14730c227f
commit 09ce24e132
4 changed files with 225 additions and 3 deletions

View File

@ -176,11 +176,30 @@ impl MirBuilder {
fn lower_function_body(&mut self, body: Vec<ASTNode>) -> Result<(), String> {
let trace = crate::mir::builder::control_flow::joinir::trace::trace();
if crate::config::env::joinir_dev_enabled() {
// Phase 112: StepTree capability guard (strict-only)
let strict = crate::config::env::joinir_dev::strict_enabled();
let dev = crate::config::env::joinir_dev_enabled();
if strict || dev {
let tree = crate::mir::control_tree::StepTreeBuilderBox::build_from_block(&body);
if dev {
trace.dev("control_tree/step_tree", &tree.to_compact_string());
}
// Phase 112: Guard check (strict mode only)
let func_name = self
.scope_ctx
.current_function
.as_ref()
.map(|f| f.signature.name.clone())
.unwrap_or_else(|| "<unknown>".to_string());
crate::mir::builder::control_flow::joinir::control_tree_capability_guard::check(
&tree, &func_name, strict, dev,
)?;
}
trace.emit_if(
"debug",
"lower_function_body",

View File

@ -0,0 +1,200 @@
//! Phase 112: StepTree Capability Guard (strict-only)
//!
//! ## Purpose
//! Detects unsupported capabilities in StepTree required_caps during strict mode.
//! Provides actionable 1-line hints for developers to fix their code.
//!
//! ## Design
//! - **Allowlist**: If, NestedIf, Loop, Return, Break, Continue
//! - **Deny (strict)**: NestedLoop, TryCatch, Throw, Lambda, While, ForRange, Match, Arrow
//! - **Default behavior unchanged**: strict=false always returns Ok(())
//!
//! ## Integration
//! - Called from `lower_function_body()` in `calls/lowering.rs`
//! - Uses existing `HAKO_JOINIR_STRICT` / `NYASH_JOINIR_STRICT` env vars
//! - Error format: `[joinir/control_tree/cap_missing/<Cap>] <msg> Hint: <hint>`
use crate::mir::control_tree::{StepCapability, StepTree};
use crate::mir::join_ir::lowering::error_tags;
use std::collections::BTreeSet;
/// Check StepTree capabilities against allowlist (Phase 112)
///
/// # Arguments
/// - `tree`: StepTree to check
/// - `func_name`: Function name for error messages
/// - `strict`: If false, always returns Ok (default behavior unchanged)
/// - `dev`: If true, trace missing caps (debug info)
///
/// # Returns
/// - Ok(()) if all required_caps are allowed (or strict=false)
/// - Err with freeze_with_hint format if unsupported cap found in strict mode
pub fn check(tree: &StepTree, func_name: &str, strict: bool, dev: bool) -> Result<(), String> {
if !strict {
return Ok(()); // Default behavior: always pass
}
// Allowlist (supported capabilities)
let allowed: BTreeSet<StepCapability> = [
StepCapability::If,
StepCapability::NestedIf,
StepCapability::Loop,
StepCapability::Return,
StepCapability::Break,
StepCapability::Continue,
]
.into_iter()
.collect();
// Check for unsupported caps
for cap in &tree.contract.required_caps {
if !allowed.contains(cap) {
let cap_name = format!("{:?}", cap);
let tag = format!("control_tree/cap_missing/{}", cap_name);
let msg = format!(
"{} detected in '{}' (step_tree_sig={})",
cap_name,
func_name,
tree.signature.to_hex()
);
let hint = get_hint_for_cap(cap);
if dev {
// Trace for debugging
eprintln!(
"[joinir/control_tree] missing cap: {} in {}",
cap_name, func_name
);
}
return Err(error_tags::freeze_with_hint(&tag, &msg, &hint));
}
}
Ok(())
}
fn get_hint_for_cap(cap: &StepCapability) -> String {
match cap {
StepCapability::NestedLoop => {
"refactor to avoid nested loops (not supported yet) or run without HAKO_JOINIR_STRICT=1".to_string()
}
StepCapability::TryCatch => {
"try/catch not supported in JoinIR yet, use HAKO_JOINIR_STRICT=0".to_string()
}
StepCapability::Throw => {
"throw not supported in JoinIR yet, use HAKO_JOINIR_STRICT=0".to_string()
}
StepCapability::Lambda => {
"lambda not supported in JoinIR yet, extract to named function".to_string()
}
StepCapability::While => {
"use 'loop(cond)' instead of 'while(cond)' syntax".to_string()
}
StepCapability::ForRange => {
"use 'loop(i < n)' instead of 'for i in range' syntax".to_string()
}
StepCapability::Match => {
"match expressions not supported in JoinIR yet, use if-else chain".to_string()
}
StepCapability::Arrow => {
"arrow functions not supported in JoinIR yet, use regular functions".to_string()
}
_ => format!("{:?} not supported in JoinIR yet", cap),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ast::{ASTNode, BinaryOperator, LiteralValue, Span};
use crate::mir::control_tree::StepTreeBuilderBox;
fn var(name: &str) -> ASTNode {
ASTNode::Variable {
name: name.to_string(),
span: Span::unknown(),
}
}
fn int_lit(v: i64) -> ASTNode {
ASTNode::Literal {
value: LiteralValue::Integer(v),
span: Span::unknown(),
}
}
fn bin_lt(lhs: ASTNode, rhs: ASTNode) -> ASTNode {
ASTNode::BinaryOp {
operator: BinaryOperator::Less,
left: Box::new(lhs),
right: Box::new(rhs),
span: Span::unknown(),
}
}
#[test]
fn test_nested_loop_strict_rejects() {
// AST: loop(i < 3) { loop(j < 2) { ... } }
let nested_loop_ast = vec![ASTNode::Loop {
condition: Box::new(bin_lt(var("i"), int_lit(3))),
body: vec![ASTNode::Loop {
condition: Box::new(bin_lt(var("j"), int_lit(2))),
body: vec![],
span: Span::unknown(),
}],
span: Span::unknown(),
}];
let tree = StepTreeBuilderBox::build_from_block(&nested_loop_ast);
// strict=true should reject NestedLoop
let result = check(&tree, "test_func", true, false);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.contains("[joinir/control_tree/cap_missing/NestedLoop]"));
assert!(err.contains("Hint:"));
}
#[test]
fn test_if_only_strict_passes() {
// AST: if x == 1 { ... } else { ... }
let if_only_ast = vec![ASTNode::If {
condition: Box::new(ASTNode::BinaryOp {
operator: BinaryOperator::Equal,
left: Box::new(var("x")),
right: Box::new(int_lit(1)),
span: Span::unknown(),
}),
then_body: vec![],
else_body: Some(vec![]),
span: Span::unknown(),
}];
let tree = StepTreeBuilderBox::build_from_block(&if_only_ast);
// strict=true should pass (If is allowed)
let result = check(&tree, "test_func", true, false);
assert!(result.is_ok());
}
#[test]
fn test_strict_false_always_passes() {
// Even with NestedLoop, strict=false should pass
let nested_loop_ast = vec![ASTNode::Loop {
condition: Box::new(bin_lt(var("i"), int_lit(3))),
body: vec![ASTNode::Loop {
condition: Box::new(bin_lt(var("j"), int_lit(2))),
body: vec![],
span: Span::unknown(),
}],
span: Span::unknown(),
}];
let tree = StepTreeBuilderBox::build_from_block(&nested_loop_ast);
// strict=false should always pass
let result = check(&tree, "test_func", false, false);
assert!(result.is_ok());
}
}

View File

@ -7,7 +7,9 @@
//! - Loop processing context (loop_context.rs) ✅ Phase 140-P5
//! - MIR block merging (merge/) ✅ Phase 4
//! - Unified tracing (trace.rs) ✅ Phase 195
//! - Control tree capability guard (control_tree_capability_guard.rs) ✅ Phase 112
pub(in crate::mir::builder) mod control_tree_capability_guard;
pub(in crate::mir::builder) mod loop_context;
pub(in crate::mir::builder) mod merge;
pub(in crate::mir::builder) mod parity_checker;

View File

@ -7,6 +7,7 @@
mod step_tree;
pub use step_tree::{
AstSummary, StepNode, StepStmtKind, StepTree, StepTreeBuilderBox, StepTreeFeatures,
AstSummary, ExitKind, StepCapability, StepNode, StepStmtKind, StepTree, StepTreeBuilderBox,
StepTreeContract, StepTreeFeatures, StepTreeSignature,
};