refactor(joinir): move P5b escape policy under patterns/policies
This commit is contained in:
@ -36,7 +36,7 @@
|
||||
---
|
||||
|
||||
### p5b_escape_derived_policy.rs (Phase 94)
|
||||
**現在の場所**: `patterns/p5b_escape_derived_policy.rs`
|
||||
**現在の場所**: `patterns/policies/p5b_escape_derived_policy.rs`
|
||||
|
||||
**責務**: P5b escapeパターン認識とBodyLocalDerivedルーティング
|
||||
|
||||
@ -51,8 +51,7 @@
|
||||
- `P5bEscapeDerivedDecision::None` - 該当なし
|
||||
|
||||
**将来的な整理**:
|
||||
- policies/へ移動してpolicy箱として統一
|
||||
- 現在はpatterns/直下に配置
|
||||
- policies/配下でpolicy箱として統一済み(Phase 96)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -14,16 +14,14 @@
|
||||
//! - **Fail-Fast**: パターンマッチング失敗は即座にReject/Noneを返す
|
||||
//!
|
||||
//! ## 将来の拡張
|
||||
//! 現在はpolicies/ディレクトリの準備段階です。
|
||||
//! 既存のpolicy関連ファイル(p5b_escape_derived_policy.rs, body_local_policy.rs等)は
|
||||
//! patterns/直下に配置されていますが、将来的にこのディレクトリへ移動する予定です。
|
||||
//! policies/ は「認識とルーティング決定(policy)」を分離する受け皿です。
|
||||
//! Phase 94(P5b derived)から段階的に移設を開始しました。
|
||||
//!
|
||||
//! ### 段階的な移行計画
|
||||
//! - Phase 1: ディレクトリ準備(今回) ✅
|
||||
//! - Phase 2: 既存policy箱の移動(将来)
|
||||
//! - Phase 1: ディレクトリ準備 ✅
|
||||
//! - Phase 2: 既存policy箱の移動(進行中)
|
||||
//! - Phase 3: インターフェース統一(将来)
|
||||
//!
|
||||
//! 詳細は [README.md](README.md) を参照してください。
|
||||
|
||||
// 現在は空モジュール(将来の拡張用)
|
||||
// 既存のpolicy関連ファイルは親モジュール(patterns/)に配置されています
|
||||
pub(in crate::mir::builder) mod p5b_escape_derived_policy;
|
||||
|
||||
@ -0,0 +1,323 @@
|
||||
//! Phase 94: P5b escape + body-local derived policy (Box)
|
||||
//!
|
||||
//! Purpose: detect the minimal "escape handling" shape that requires:
|
||||
//! - loop counter conditional step (skip escape char)
|
||||
//! - body-local reassignment (e.g. `ch = substring(...)`) represented as a derived Select
|
||||
//!
|
||||
//! This is a *route* decision (not a fallback). In strict mode, if we detect
|
||||
//! a body-local reassignment that matches the P5b entry shape but cannot be
|
||||
//! converted to a derived recipe, we fail-fast with a reason tag.
|
||||
|
||||
use crate::ast::ASTNode;
|
||||
use crate::config::env::joinir_dev;
|
||||
use crate::mir::builder::control_flow::joinir::patterns::escape_pattern_recognizer::EscapeSkipPatternInfo;
|
||||
use crate::mir::join_ir::lowering::common::body_local_derived_emitter::BodyLocalDerivedRecipe;
|
||||
use crate::mir::join_ir::lowering::error_tags;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum P5bEscapeDerivedDecision {
|
||||
None,
|
||||
UseDerived(BodyLocalDerivedRecipe),
|
||||
Reject(String),
|
||||
}
|
||||
|
||||
/// Detect a P5b derived body-local (`ch`) recipe from a Pattern2 loop body.
|
||||
///
|
||||
/// Minimal supported shape (SSOT):
|
||||
/// - `local ch = <expr>` exists at top level
|
||||
/// - escape if exists (detected by EscapeSkipPatternInfo)
|
||||
/// - inside the escape if's then-body, after pre-increment:
|
||||
/// - optional bounds `if i < n { ch = <override_expr> }`
|
||||
/// - or direct `ch = <override_expr>`
|
||||
pub fn classify_p5b_escape_derived(
|
||||
body: &[ASTNode],
|
||||
loop_var_name: &str,
|
||||
) -> P5bEscapeDerivedDecision {
|
||||
let Some(info) = super::super::ast_feature_extractor::detect_escape_skip_pattern(body) else {
|
||||
return P5bEscapeDerivedDecision::None;
|
||||
};
|
||||
|
||||
if info.counter_name != loop_var_name {
|
||||
// Not the loop counter we lower as the JoinIR loop var; ignore to avoid misrouting.
|
||||
return P5bEscapeDerivedDecision::None;
|
||||
}
|
||||
|
||||
match build_recipe_from_info(body, &info) {
|
||||
Ok(Some(recipe)) => P5bEscapeDerivedDecision::UseDerived(recipe),
|
||||
Ok(None) => {
|
||||
// Escape pattern exists but there is no body-local reassignment to cover.
|
||||
P5bEscapeDerivedDecision::None
|
||||
}
|
||||
Err(e) => {
|
||||
if joinir_dev::strict_enabled() {
|
||||
P5bEscapeDerivedDecision::Reject(error_tags::freeze(&e))
|
||||
} else {
|
||||
// Non-strict mode: keep legacy behavior (no derived slot); still loggable via dev.
|
||||
P5bEscapeDerivedDecision::None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_recipe_from_info(
|
||||
body: &[ASTNode],
|
||||
info: &EscapeSkipPatternInfo,
|
||||
) -> Result<Option<BodyLocalDerivedRecipe>, String> {
|
||||
// 1) Find base init: `local ch = <expr>`
|
||||
let Some(base_init_expr) = find_local_init_expr(body, "ch") else {
|
||||
return Err("[phase94/body_local_derived/contract/missing_local_init] Missing `local ch = <expr>`".to_string());
|
||||
};
|
||||
|
||||
// 2) Locate escape if and find override assignment to ch
|
||||
let escape_if = body.get(info.escape_idx).ok_or_else(|| {
|
||||
format!(
|
||||
"[phase94/body_local_derived/contract/escape_idx_oob] escape_idx={} out of bounds (body.len={})",
|
||||
info.escape_idx,
|
||||
body.len()
|
||||
)
|
||||
})?;
|
||||
let (escape_cond, then_body) = match escape_if {
|
||||
ASTNode::If { condition, then_body, else_body: _, .. } => (condition.as_ref().clone(), then_body.as_slice()),
|
||||
other => {
|
||||
return Err(format!(
|
||||
"[phase94/body_local_derived/contract/escape_node_kind] escape_idx points to non-If: {:?}",
|
||||
other
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let override_assignment = find_ch_override_in_escape_then(then_body)?;
|
||||
let Some((bounds_check, override_expr)) = override_assignment else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
// EscapeSkipPatternInfo uses "escape_delta" for the then-body increment, and "normal_delta" for the unconditional tail.
|
||||
// For the common P5b shape:
|
||||
// - escape iteration total delta = escape_delta + normal_delta
|
||||
// - normal iteration total delta = normal_delta
|
||||
let recipe = BodyLocalDerivedRecipe {
|
||||
name: "ch".to_string(),
|
||||
base_init_expr,
|
||||
escape_cond,
|
||||
loop_counter_name: info.counter_name.clone(),
|
||||
pre_delta: info.escape_delta,
|
||||
post_delta: info.normal_delta,
|
||||
bounds_check,
|
||||
override_expr,
|
||||
};
|
||||
Ok(Some(recipe))
|
||||
}
|
||||
|
||||
fn find_local_init_expr(body: &[ASTNode], name: &str) -> Option<ASTNode> {
|
||||
for node in body {
|
||||
if let ASTNode::Local { variables, initial_values, .. } = node {
|
||||
for (var_name, maybe_expr) in variables.iter().zip(initial_values.iter()) {
|
||||
if var_name == name {
|
||||
if let Some(expr) = maybe_expr.as_ref() {
|
||||
return Some((**expr).clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Find `ch = <expr>` either directly or under an inner bounds `if`.
|
||||
///
|
||||
/// Returns:
|
||||
/// - Ok(Some((bounds_opt, override_expr))) when an override assignment exists
|
||||
/// - Ok(None) when no override assignment exists (no derived slot needed)
|
||||
/// - Err when an override exists but violates minimal contract
|
||||
fn find_ch_override_in_escape_then(
|
||||
then_body: &[ASTNode],
|
||||
) -> Result<Option<(Option<ASTNode>, ASTNode)>, String> {
|
||||
// Direct assignment form: `ch = <expr>`
|
||||
for stmt in then_body {
|
||||
if let ASTNode::Assignment { target, value, .. } = stmt {
|
||||
if is_var_named(target.as_ref(), "ch") {
|
||||
return Ok(Some((None, value.as_ref().clone())));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Nested bounds form: `if <cond> { ch = <expr> }`
|
||||
for stmt in then_body {
|
||||
if let ASTNode::If { condition, then_body, else_body: None, .. } = stmt {
|
||||
if then_body.len() != 1 {
|
||||
continue;
|
||||
}
|
||||
if let ASTNode::Assignment { target, value, .. } = &then_body[0] {
|
||||
if is_var_named(target.as_ref(), "ch") {
|
||||
return Ok(Some((Some(condition.as_ref().clone()), value.as_ref().clone())));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn is_var_named(node: &ASTNode, name: &str) -> bool {
|
||||
matches!(node, ASTNode::Variable { name: n, .. } if n == name)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ast::{BinaryOperator, Span};
|
||||
|
||||
fn var(name: &str) -> ASTNode {
|
||||
ASTNode::Variable {
|
||||
name: name.to_string(),
|
||||
span: Span::unknown(),
|
||||
}
|
||||
}
|
||||
|
||||
fn str_lit(s: &str) -> ASTNode {
|
||||
ASTNode::Literal {
|
||||
value: crate::ast::LiteralValue::String(s.to_string()),
|
||||
span: Span::unknown(),
|
||||
}
|
||||
}
|
||||
|
||||
fn int_lit(v: i64) -> ASTNode {
|
||||
ASTNode::Literal {
|
||||
value: crate::ast::LiteralValue::Integer(v),
|
||||
span: Span::unknown(),
|
||||
}
|
||||
}
|
||||
|
||||
fn binop(op: BinaryOperator, lhs: ASTNode, rhs: ASTNode) -> ASTNode {
|
||||
ASTNode::BinaryOp {
|
||||
operator: op,
|
||||
left: Box::new(lhs),
|
||||
right: Box::new(rhs),
|
||||
span: Span::unknown(),
|
||||
}
|
||||
}
|
||||
|
||||
fn assignment(target: ASTNode, value: ASTNode) -> ASTNode {
|
||||
ASTNode::Assignment {
|
||||
target: Box::new(target),
|
||||
value: Box::new(value),
|
||||
span: Span::unknown(),
|
||||
}
|
||||
}
|
||||
|
||||
fn method_call(obj: &str, method: &str, args: Vec<ASTNode>) -> ASTNode {
|
||||
ASTNode::MethodCall {
|
||||
object: Box::new(var(obj)),
|
||||
method: method.to_string(),
|
||||
arguments: args,
|
||||
span: Span::unknown(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_p5b_shape_and_builds_recipe() {
|
||||
// Body layout:
|
||||
// 0: local ch = s.substring(i, i+1)
|
||||
// 1: if ch == "\"" { break }
|
||||
// 2: if ch == "\\" { i = i + 1; ch = s.substring(i, i+1) }
|
||||
// 3: i = i + 1
|
||||
let body = vec![
|
||||
ASTNode::Local {
|
||||
variables: vec!["ch".to_string()],
|
||||
initial_values: vec![Some(Box::new(method_call(
|
||||
"s",
|
||||
"substring",
|
||||
vec![var("i"), binop(BinaryOperator::Add, var("i"), int_lit(1))],
|
||||
)))],
|
||||
span: Span::unknown(),
|
||||
},
|
||||
ASTNode::If {
|
||||
condition: Box::new(binop(BinaryOperator::Equal, var("ch"), str_lit("\""))),
|
||||
then_body: vec![ASTNode::Break { span: Span::unknown() }],
|
||||
else_body: None,
|
||||
span: Span::unknown(),
|
||||
},
|
||||
ASTNode::If {
|
||||
condition: Box::new(binop(BinaryOperator::Equal, var("ch"), str_lit("\\"))),
|
||||
then_body: vec![
|
||||
assignment(var("i"), binop(BinaryOperator::Add, var("i"), int_lit(1))),
|
||||
assignment(
|
||||
var("ch"),
|
||||
method_call(
|
||||
"s",
|
||||
"substring",
|
||||
vec![var("i"), binop(BinaryOperator::Add, var("i"), int_lit(1))],
|
||||
),
|
||||
),
|
||||
],
|
||||
else_body: None,
|
||||
span: Span::unknown(),
|
||||
},
|
||||
assignment(var("i"), binop(BinaryOperator::Add, var("i"), int_lit(1))),
|
||||
];
|
||||
|
||||
match classify_p5b_escape_derived(&body, "i") {
|
||||
P5bEscapeDerivedDecision::UseDerived(recipe) => {
|
||||
assert_eq!(recipe.name, "ch");
|
||||
assert_eq!(recipe.loop_counter_name, "i");
|
||||
assert_eq!(recipe.pre_delta, 1);
|
||||
assert_eq!(recipe.post_delta, 1);
|
||||
match recipe.override_expr {
|
||||
ASTNode::MethodCall { ref method, .. } => assert_eq!(method, "substring"),
|
||||
other => panic!("expected override MethodCall, got {:?}", other),
|
||||
}
|
||||
}
|
||||
other => panic!("expected UseDerived recipe, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_rejects_when_local_init_missing() {
|
||||
// escape pattern exists, but `local ch = ...` is absent -> strict should reject
|
||||
let body = vec![
|
||||
ASTNode::If {
|
||||
condition: Box::new(binop(BinaryOperator::Equal, var("ch"), str_lit("\""))),
|
||||
then_body: vec![ASTNode::Break { span: Span::unknown() }],
|
||||
else_body: None,
|
||||
span: Span::unknown(),
|
||||
},
|
||||
ASTNode::If {
|
||||
condition: Box::new(binop(BinaryOperator::Equal, var("ch"), str_lit("\\"))),
|
||||
then_body: vec![
|
||||
assignment(var("i"), binop(BinaryOperator::Add, var("i"), int_lit(1))),
|
||||
assignment(
|
||||
var("ch"),
|
||||
method_call(
|
||||
"s",
|
||||
"substring",
|
||||
vec![var("i"), binop(BinaryOperator::Add, var("i"), int_lit(1))],
|
||||
),
|
||||
),
|
||||
],
|
||||
else_body: None,
|
||||
span: Span::unknown(),
|
||||
},
|
||||
assignment(var("i"), binop(BinaryOperator::Add, var("i"), int_lit(1))),
|
||||
];
|
||||
|
||||
let prev = std::env::var("HAKO_JOINIR_STRICT").ok();
|
||||
std::env::set_var("HAKO_JOINIR_STRICT", "1");
|
||||
let decision = classify_p5b_escape_derived(&body, "i");
|
||||
if let Some(v) = prev {
|
||||
std::env::set_var("HAKO_JOINIR_STRICT", v);
|
||||
} else {
|
||||
std::env::remove_var("HAKO_JOINIR_STRICT");
|
||||
}
|
||||
|
||||
match decision {
|
||||
P5bEscapeDerivedDecision::Reject(reason) => {
|
||||
assert!(
|
||||
reason.contains("missing_local_init"),
|
||||
"unexpected reason: {}",
|
||||
reason
|
||||
);
|
||||
}
|
||||
other => panic!("expected Reject, got {:?}", other),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user