Skip to content
15 changes: 9 additions & 6 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -756,9 +756,11 @@ tco_reassign, algebra_method_template.

### LS follow-ups (tracked for future PRs)

- **Cast validation → infer phase:** CastRule uses string type names in emit.
Fix: model cast semantics structurally, validate in infer, emit renders
unconditionally. (Known violation, documented.)
- **Cast validation → infer phase:** Partial. Numeric casts (Int/Float/Bool)
validated in infer via dag_can_cast; emit retains can_cast safety net.
Remaining: structural cast model (replace string-keyed tables), fail-closed
for non-numeric domain, cast witness on Node (make invalid casts
unrepresentable at emit boundary).
- **needs_sharing flag → structural:** `SharingStrategy.needs_sharing: Bool` is
a global codegen switch. Fix: derive sharing need per-type from structural
facts (recursive types, container algebra). Phase 3d.
Expand All @@ -779,12 +781,13 @@ tco_reassign, algebra_method_template.
- **resolved_binop_algebra in emit → infer:** Re-deriving algebra field in
emit is a decision. Fix: infer resolves which algebra field Div maps to,
stores on the Node (extend ExprBinOp or annotate), emit reads it.
- **Python/Go cast_syntax pair whitelist:** Models generic `type(expr)` as
hand-curated (from,to) pairs. Fix: make emit render `type(expr)`
unconditionally, move validation to infer. Same root cause as cast→infer.
- **Python/Go cast_syntax pair whitelist:** Open. Per-target cast_rules and
can_cast still active (emit safety net). Dissolves when structural cast model
replaces string-keyed tables. Same root cause as cast→infer.
- **Emit file deletion phases:** Phase 2 (StringInterpSyntax, SumTypeStrategy,
imports, service rendering), Phase 3 (dispatch strategies, ownership),
Phase 4 (delete Py/Go), Phase 5 (delete Rust). See plan file.
Progress: for_each extracted to emit_typed_for_each_shared (Py/Go unified).

### Acceptance

Expand Down
20 changes: 20 additions & 0 deletions dsl/std/coercion.dag
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,23 @@ type CastSyntax {
template: String
cast_rules: List<CastRule>
}

// .dag-level cast validity — language-independent numeric rules.
// Validated at infer time so emit can render unconditionally.
// Alias casts (Secret→String, etc.) bypass this: they're identity at emit level.
data dag_cast_rules: List<CastRule> = [

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Invariant violation: dag_cast_rules makes .dag cast semantics depend on string type names in std/, which is the same identity-proxy pattern M4/M8 are trying to delete rather than a structural cast model.

{ from_type: "Int", to_type: "Int" },
{ from_type: "Int", to_type: "Float" },
{ from_type: "Float", to_type: "Int" },
{ from_type: "Float", to_type: "Float" },
{ from_type: "Bool", to_type: "Int" },
]

fn dag_can_cast(source_type: String, target_type: String) -> Bool {
dag_cast_rules |> any(r => r.from_type == source_type && r.to_type == target_type)
}

// Types in the numeric cast domain — only these are validated by dag_can_cast.
fn is_numeric_cast_type(name: String) -> Bool {

This comment was marked as resolved.

name == "Int" || name == "Float" || name == "Bool"
}
19 changes: 15 additions & 4 deletions dsl/std/languages.dag
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,17 @@ type ExpressionSyntax {
closure: String // e.g. "|\{params\}| \{body\}" or "lambda \{params\}: \{body\}"
}

// ForEachSyntax: structural authority for iteration rendering.
// The emitter reads these fields directly — no branching on target identity.
// Rust: "for {item} in {iter}" (prefix="for ", sep=" in ", discard="")
// Go: "for _, {item} := range {iter}" (prefix="for _, ", sep=" := range ", discard="_")
// Python: "for {item} in {iter}" (prefix="for ", sep=" in ", discard="")
// Block delimiters come from BlockSyntax (block_open/block_close).
type ForEachSyntax {
prefix: String // "for " or "for _, "
separator: String // " in " or " := range "
}

// "How does a language write control flow?"
// Ref: Rust Reference 8.2.16, Go Spec "Statements",
// Python Reference 8 "Compound statements"
Expand All @@ -138,7 +149,7 @@ type ControlFlowSyntax {
if_else: String // e.g. "if \{cond\} { \{then\} } else { \{else\} }"
match_header: String // e.g. "match \{expr\} {" or "switch \{expr\} {"
match_arm: String // e.g. "\{pattern\} => \{body\},"
for_each: String // e.g. "for \{item\} in \{iter\} { \{body\} }"
for_each_syntax: ForEachSyntax // structural authority for iteration rendering

This comment was marked as resolved.

while_loop: String // e.g. "while \{cond\} { \{body\} }"
loop_infinite: String? // e.g. "loop { \{body\} }" or null
break_keyword: String // e.g. "break"
Expand Down Expand Up @@ -637,7 +648,7 @@ data rust_control_flow: ControlFlowSyntax = {
if_else: "if \{cond\} { \{then\} } else { \{else\} }",
match_header: "match \{expr\} {",
match_arm: "\{pattern\} => \{body\},",
for_each: "for \{item\} in \{iter\} { \{body\} }",
for_each_syntax: { prefix: "for ", separator: " in " },
while_loop: "while \{cond\} { \{body\} }",
loop_infinite: "loop { \{body\} }",
break_keyword: "break",
Expand Down Expand Up @@ -756,7 +767,7 @@ data go_control_flow: ControlFlowSyntax = {
if_else: "if \{cond\} { \{then\} } else { \{else\} }",
match_header: "switch \{expr\} {",
match_arm: "case \{pattern\}: \{body\}",
for_each: "for \{item\} := range \{iter\} { \{body\} }",
for_each_syntax: { prefix: "for _, ", separator: " := range " },
while_loop: "for \{cond\} { \{body\} }",
loop_infinite: "for { \{body\} }",
break_keyword: "break",
Expand Down Expand Up @@ -876,7 +887,7 @@ data python_control_flow: ControlFlowSyntax = {
if_else: "if \{cond\}:\n \{then\}\nelse:\n \{else\}",
match_header: "match \{expr\}:",
match_arm: "case \{pattern\}: \{body\}",
for_each: "for \{item\} in \{iter\}:\n \{body\}",
for_each_syntax: { prefix: "for ", separator: " in " },
while_loop: "while \{cond\}:\n \{body\}",
loop_infinite: "while True:\n \{body\}",
break_keyword: "break",
Expand Down
32 changes: 31 additions & 1 deletion src/v2/04_infer.dag
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
module v2.compiler.infer

import std.types { SourceSpan, container_param_name }
import std.coercion { dag_can_cast, is_numeric_cast_type }
import v2.std.core {
Node,
authored_name_at, NewlineIndex, has_child_named,
Expand Down Expand Up @@ -593,6 +594,25 @@ fn categorized_error(message: String, span: SourceSpan, module_name: String, cat
make_error_node(diagnostic: InternalError { message: message, span: span }, module_name: module_name)
}

// Validate a cast at the .dag level. Returns a diagnostic if the cast
// is invalid between numeric kernel types, or none if the cast is allowed.
fn validate_cast(source_inferred: InferredNode?, target_name: String, span: SourceSpan, source_index: NewlineIndex?, module_name: String) -> ErrorNode? {
match source_inferred {
Some { value: Resolved { node: src_node } } =>
let source_name = authored_name_at(source_index: source_index, node: src_node)
if source_name == target_name { none }
else if !is_numeric_cast_type(name: source_name) { none }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Invariant violation: validate_cast fail-opens every cast whose source or target is not exactly Int/Float/Bool, so unsupported casts now survive infer and reach emit unvalidated, violating fail-closed compilation and the “emission is translation, not decision-making” invariant.

else if !is_numeric_cast_type(name: target_name) { none }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restrict non-numeric cast bypass to identity/aliases

The new cast validator returns none as soon as either side is outside the numeric set, which means casts like String as Int or List<Int> as Int are never diagnosed here. Because emit now renders every non-identity cast unconditionally, those cases can produce unsupported target code (for example Rust as casts that do not type-check) without an infer-phase error. Please only bypass validation for true identity/alias-equivalent casts (or add a structural check) so unsupported cross-domain casts are rejected before emission.

Useful? React with 👍 / 👎.

else if dag_can_cast(source_type: source_name, target_type: target_name) { none }
else {
let msg = concat("invalid cast: ", source_name, " as ", target_name)
let diag = InternalError { message: msg, span: span }
Some { value: make_error_node(diagnostic: diag, module_name: module_name) }
}
_ => none
}
}

fn type_variable_node(id: String) -> Node {
Node { name: "", span: make_span(start: 0, end: 0), ident_span: none, children: [], connective: NoConnective, params: [], inferred: Some { value: TypeVariable { id: id } }, return_cardinality: Required, uses: [], body: none, transport: none, properties: [], type_annotation: none, is_self_recursive: false, has_non_tail_self_call: false, match_pattern: none, expr_data: NoExprData }
}
Expand Down Expand Up @@ -1873,10 +1893,20 @@ fn infer_expr(texpr: Node, scope: InferScope, expected: Node?) -> InferResult {
let inner_result = infer_expr(texpr: cast_inner, scope: scope, expected: none)
let inner_typed = inner_result.typed
let inner_diags = inner_result.diagnostics
// Validate cast at .dag level: only check when both types are in the
// numeric cast domain (Int, Float, Bool). Alias casts and user types
// are allowed unconditionally — they resolve to identity at emit time.
let si = scope.type_env.source_index
let target_name = authored_name_at(source_index: si, node: target_type)
let cast_diag = validate_cast(source_inferred: inner_typed.inferred, target_name: target_name, span: span, source_index: si, module_name: scope.module_name)
let cast_diags = match cast_diag {
Some { value: d } => [d]
None => []
}
let cast_texpr = make_expr_node(expr_data: ExprCast, children: [inner_typed, target_type], inferred: Some { value: Resolved { node: target_type } }, span: span)
InferResult {
typed: cast_texpr,
diagnostics: inner_diags
diagnostics: concat(inner_diags, cast_diags)
}

ExprForEach =>
Expand Down
41 changes: 28 additions & 13 deletions src/v2/05_emit.dag
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ import v2.std.core {
import v2.compiler.infer_env { TypeEnv, TypeBinding }
import std.induction { InductiveField }
// NamingCase, PascalCase, SnakeCase imported via v2.compiler.languages (bootstrap-local mirror)
import v2.compiler.infer_types { resolved_type, child_type_node, emit_map_has, node_is_collection, node_is_keyed_collection, node_is_element_collection, normalize_access_type_node }
import v2.compiler.infer_types { resolved_type, child_type_node, emit_map_has, node_is_collection, node_is_keyed_collection, node_is_element_collection, normalize_access_type_node, for_each_element_type_node }
import std.types { is_container_type, container_to_algebra_name }
import v2.compiler.coercion { coerce_primitive_type, coerce_container_template, target_optional_template, target_callable, can_cast, render_cast, literal_suffix }
import v2.compiler.infer_sigs { ResolvedFuncSig, ResolvedFuncEnv }
Expand Down Expand Up @@ -1878,17 +1878,13 @@ fn extract_string_interp_parts(expr: Node) -> List<StringPart> {
}

// Shared ExprCast — data-driven, target-parameterized.
// Cast syntax and validity derive from CastSyntax in extdeps/languages/*/types.dag.
// Rust: `expr as type` (valid for numeric targets only, per Rust Reference §8.2.4).
// Python/Go: `type(expr)` (constructor call, always valid).
// Shared ExprCast — data-driven, target-parameterized.
// Cast syntax and validity derive from CastRule in extdeps/languages/*/types.dag.
// Cast syntax derives from CastSyntax in extdeps/languages/*/types.dag.
// Rust: `expr as type`, Python/Go: `type(expr)`.
//
// KNOWN VIOLATION (tracked): cast legality is checked here in emit, but the
// "emission is translation" invariant says emit should only render pre-validated
// constructs. The fix: .dag defines its own cast rules, infer validates against
// them, and emit renders unconditionally. Blocked on: .dag-level cast rule
// definitions (not yet modeled) and infer-phase cast validation.
// Numeric casts are validated early in infer (dag_can_cast). Emit retains
// the per-target can_cast guard as a fail-closed safety net for casts
// outside the numeric domain (String, user types, etc.) until structural
// cast modeling replaces the string-keyed tables.
fn emit_typed_cast_shared(expr: Node, cast_target_node: Node, target: RenderTarget, recurse: fn(Node) -> String, source_index: NewlineIndex?) -> String {
let expr_str = recurse(expr)
let ty_str = emit_node_type(n: cast_target_node, target: target, source_index: source_index)
Expand All @@ -1902,12 +1898,31 @@ fn emit_typed_cast_shared(expr: Node, cast_target_node: Node, target: RenderTarg
} else if can_cast(target: target, source_type: src_ty, target_type: ty_str) {
render_cast(expr_str: expr_str, type_str: ty_str, target: target)
} else {
// Unreachable in normal operation: all .dag casts are numeric and every
// target declares numeric cast rules. If reached, a CastRule is missing.
emit_error_expr(message: concat("unsupported cast from ", src_ty, " to ", ty_str), target: target)
}
}

// Shared ExprForEach — reads ForEachSyntax + BlockSyntax, no target branching.
// ForEachSyntax.prefix/separator declare the loop header; BlockSyntax declares
// block delimiters. Emit translates, does not decide.
fn emit_typed_for_each_shared(variable: String, collection: Node, body: Node, target: RenderTarget, depth: Int, source_index: NewlineIndex?, recurse: fn(Node, InferScope, Int) -> String, scope: InferScope) -> String {
let coll_str = recurse(collection, scope, depth)
let elem_type = for_each_element_type_node(n: resolved_type(n: collection))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Invariant violation: emit_typed_for_each_shared recomputes the loop binder type with resolved_type and for_each_element_type_node, so emit is still making a semantic binding decision instead of translating an infer-produced fact, violating Emission is translation, not decision-making.

let body_scope = extend_scope(scope: scope, name: variable, resolved: elem_type)
let body_str = recurse(body, body_scope, depth + 1)

This comment was marked as resolved.

let var_str = emit_ident(name: variable, target: target)
let spec = language_spec(target: target)
let fes = spec.for_each_syntax
let bs = spec.block_syntax
let body_indent = make_indent(level: depth + 1)
if bs.significant_whitespace {
concat(fes.prefix, var_str, fes.separator, coll_str, bs.block_open, body_indent, body_str)
} else {
let close_indent = make_indent(level: depth)
concat(fes.prefix, var_str, fes.separator, coll_str, bs.block_open, body_indent, body_str, "\n", close_indent, bs.block_close)
}
}

// Shared ExprIndex — dispatches on string/map/list via LanguageSpec.indexing.
fn emit_typed_index_shared(base: Node, index: Node, target: RenderTarget, recurse: fn(Node) -> String, source_index: NewlineIndex?) -> String {
let spec = language_spec(target: target)
Expand Down
20 changes: 9 additions & 11 deletions src/v2/05_emit_go.dag
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ import v2.compiler.languages {
is_string_like, CaseVisibility
}
import v2.compiler.infer_env { TypeEnv, TypeBinding, authored_name }
import v2.compiler.infer_types { resolved_type, for_each_element_type_node, normalize_access_type_node, node_is_keyed_collection, is_unit_like }
import v2.compiler.infer_types { resolved_type, normalize_access_type_node, node_is_keyed_collection, is_unit_like }
import v2.compiler.infer_method {
}
import v2.compiler.infer_sigs { ResolvedFuncSig, ResolvedFuncEnv }
Expand Down Expand Up @@ -120,7 +120,7 @@ import v2.compiler.emit {
ServiceFieldSet, compute_service_fields, service_field_decls,
seed_bindings,
emit_expr_var_shared, emit_expr_field_access_shared, extract_string_interp_parts,
emit_typed_cast_shared, emit_typed_index_shared, emit_typed_slice_shared,
emit_typed_cast_shared, emit_typed_for_each_shared, emit_typed_index_shared, emit_typed_slice_shared,
emit_block_stmts_shared, emit_init_block_stmts_shared,
emit_typed_if_shared, emit_typed_let_shared, emit_param_shared, emit_params_shared, apply_naming_case,
emit_inferred_shared, emit_typed_block_join,
Expand Down Expand Up @@ -623,7 +623,13 @@ fn emit_go_expr_cast(expr: Node, registry: Map<String, ItemInfo>, scope: InferSc
}

fn emit_go_expr_for_each(expr: Node, registry: Map<String, ItemInfo>, scope: InferScope, depth: Int) -> String {
emit_go_typed_for_each(variable: foreach_variable_at(texpr: expr, source_index: scope.type_env.source_index), collection: foreach_collection(texpr: expr), body: foreach_body(texpr: expr), registry: registry, scope: scope, depth: depth)
emit_typed_for_each_shared(
variable: foreach_variable_at(texpr: expr, source_index: scope.type_env.source_index),
collection: foreach_collection(texpr: expr),
body: foreach_body(texpr: expr),
target: Go, depth: depth, source_index: scope.type_env.source_index,
recurse: (child, s, d) => emit_go_typed_expr(texpr: child, registry: registry, scope: s, depth: d, fuel: 1024),
scope: scope)
}

fn emit_go_expr_index(expr: Node, registry: Map<String, ItemInfo>, scope: InferScope, depth: Int) -> String {
Expand Down Expand Up @@ -677,14 +683,6 @@ fn emit_go_typed_call(func: String, args: List<Node>, registry: Map<String, Item
concat(go_export_ident(name: func), "(", args_str, ")")
}

fn emit_go_typed_for_each(variable: String, collection: Node, body: Node, registry: Map<String, ItemInfo>, scope: InferScope, depth: Int) -> String {
let coll_str = emit_go_typed_expr(texpr: collection, registry: registry, scope: scope, depth: depth, fuel: 1024)
let elem_type = for_each_element_type_node(n: resolved_type(n: collection))
let body_scope = extend_scope(scope: scope, name: variable, resolved: elem_type)
let body_str = emit_go_typed_expr(texpr: body, registry: registry, scope: body_scope, depth: depth + 1, fuel: 1024)
concat("for _, ", emit_ident(name: variable, target: Go), " := range ", coll_str, " {\n",
make_indent(level: depth + 1), body_str, "\n", make_indent(level: depth), "}")
}

fn go_bridge_method_name(method_name: String) -> String {
// Derive PascalCase from the canonical snake_case method name.
Expand Down
20 changes: 9 additions & 11 deletions src/v2/05_emit_python.dag
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ import v2.compiler.languages {
is_string_like
}
import v2.compiler.infer_env { TypeEnv, TypeBinding, authored_name }
import v2.compiler.infer_types { resolved_type, for_each_element_type_node, normalize_access_type_node, node_is_keyed_collection }
import v2.compiler.infer_types { resolved_type, normalize_access_type_node, node_is_keyed_collection }
import v2.compiler.infer_method {
}
import v2.compiler.infer_sigs { ResolvedFuncSig, ResolvedFuncEnv }
Expand Down Expand Up @@ -110,7 +110,7 @@ import v2.compiler.emit {
ServiceFieldSet, compute_service_fields, service_field_decls, service_field_ctors,
seed_bindings,
emit_expr_var_shared, emit_expr_field_access_shared, extract_string_interp_parts,
emit_typed_cast_shared, emit_typed_index_shared, emit_typed_slice_shared,
emit_typed_cast_shared, emit_typed_for_each_shared, emit_typed_index_shared, emit_typed_slice_shared,
emit_block_stmts_shared, emit_init_block_stmts_shared,
emit_typed_if_shared, emit_typed_let_shared, emit_param_shared, emit_params_shared,
emit_inferred_shared, emit_typed_block_join,
Expand Down Expand Up @@ -613,7 +613,13 @@ fn emit_py_expr_cast(expr: Node, registry: Map<String, ItemInfo>, scope: InferSc
}

fn emit_py_expr_for_each(expr: Node, registry: Map<String, ItemInfo>, scope: InferScope, depth: Int) -> String {
emit_py_typed_for_each(variable: foreach_variable_at(texpr: expr, source_index: scope.type_env.source_index), collection: foreach_collection(texpr: expr), body: foreach_body(texpr: expr), registry: registry, scope: scope, depth: depth)
emit_typed_for_each_shared(
variable: foreach_variable_at(texpr: expr, source_index: scope.type_env.source_index),
collection: foreach_collection(texpr: expr),
body: foreach_body(texpr: expr),
target: Python, depth: depth, source_index: scope.type_env.source_index,
recurse: (child, s, d) => emit_py_typed_expr(texpr: child, registry: registry, scope: s, depth: d, fuel: 1024),
scope: scope)
}

fn emit_py_expr_index(expr: Node, registry: Map<String, ItemInfo>, scope: InferScope, depth: Int) -> String {
Expand Down Expand Up @@ -673,14 +679,6 @@ fn emit_py_typed_call(func: String, args: List<Node>, registry: Map<String, Item
}
}

fn emit_py_typed_for_each(variable: String, collection: Node, body: Node, registry: Map<String, ItemInfo>, scope: InferScope, depth: Int) -> String {
let coll_str = emit_py_typed_expr(texpr: collection, registry: registry, scope: scope, depth: depth, fuel: 1024)
let elem_type = for_each_element_type_node(n: resolved_type(n: collection))
let body_scope = extend_scope(scope: scope, name: variable, resolved: elem_type)
let body_str = emit_py_typed_expr(texpr: body, registry: registry, scope: body_scope, depth: depth + 1, fuel: 1024)
concat("for ", emit_ident(name: variable, target: Python), " in ", coll_str, ":\n",
make_indent(level: depth + 1), body_str)
}

fn py_bridge_method_name(method_name: String) -> String {
// Python reserved word `with` requires override; all others use method name directly.
Expand Down
Loading
Loading