diff --git a/ROADMAP.md b/ROADMAP.md
index bb4734ef..7c6ef692 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -70,13 +70,13 @@ The language surface is organized around **structural types**, **explicit annota
| Feature | Status | Notes |
| --- | --- | --- |
-| `ensure` statements (basic type assertions) | ✅ done | Validates assertions and optional blocks; does **not** narrow the subject’s type for statements **after** the `ensure` (see control-flow narrowing). |
+| `ensure` statements (basic type assertions) | ✅ done | Validates assertions and optional blocks. **Successor narrowing** after a successful `ensure` works for **simple identifiers** (`ensure x is …` then use `x` on the next lines); **compound paths** (e.g. `ensure req.state is …`) remain deferred (see control-flow narrowing). |
| Shape guards (struct refinement) | ✅ done | Refinement on shapes. |
| `is` operator for `ensure` conditions | ✅ done | Parser requires `ensure … is …` (see `forst/internal/parser/ensure.go`; `ensure !ident` uses implicit `Nil()`). The typechecker enforces presence (`Present`) and type-guard subject compatibility (`forst/internal/typechecker/unify_typeguard.go`, `infer_ensure.go`); it does **not** fully validate arbitrary built-in constraint semantics beyond that. Emission: `forst/internal/transformer/go/ensure.go`, `ensure_constraint.go`. Examples: `examples/in/ensure.ft` and `task example:ensure`. |
| Type guards (beyond shape guards) | 🔬 experimental | Top-level `is (subject T) Name { … }` declarations are **implemented** end-to-end: parse (`forst/internal/parser/typeguard.go`), typecheck (`unify_typeguard.go`, registration in `Defs`), Go emit (`forst/internal/transformer/go/typeguard.go`, `ensure_typeguard.go`). Examples: `examples/in/rfc/guard/` (`task example:shape-guard`, `task example:basic-guard`). **Not** at full RFC “done” yet: narrowing across all control-flow positions and other polish overlap **Control-flow type narrowing**; language design: [type guards RFC](./examples/in/rfc/guard/guard.md); Go / `.d.ts` interop: [interop](./examples/in/rfc/guard/interop.md). |
| Immutability guarantees (`ensure`-scoped; unsafe mode for Go interop) | 📋 planned | Not implemented. |
| Binary type expressions (`A \| B`, `A & B`) | 🔬 experimental | Parser, AST, and typechecker support **`&`** / **`|`** on typedef bodies: unions flatten to **`TypeUnion`**, intersections use meet (`forst/internal/typechecker/typedef_binary.go`); Go emit lowers via canonical **`TypeNode`** (`forst/internal/transformer/go/typedef_expr.go`). **Closed unions of nominal errors** emit sealed Go interfaces (`error_union_sealed.go`); examples `union_error_types.ft`, `union_error_narrowing.ft`. **Gaps vs “done”:** general non-error unions, full intersection algebra, union narrowing in all control-flow positions, and TS/Go emit for arbitrary **`A \| B`** combinations—do not rely on binary typedef semantics beyond tested nominal-error and alias paths. **Narrowing** and future optionals should share one internal type algebra (assertions / `TypeNode`), not a parallel representation—see control-flow narrowing. |
-| Control-flow type narrowing | 🔬 experimental | **If-branch narrowing** for `if x is …` (assertion or shape RHS): refined type for the subject is recorded in the branch scope; variable types are keyed by identifier **and** source span in the typechecker so hover can differ per occurrence. Join/merge across branches and full `ensure`-successor narrowing are not done yet. Implementation: `forst/internal/typechecker/infer_if.go`, `narrow_if.go`; LSP uses `InferredTypesForVariableNode` when the variable AST node is found at the cursor. Type guards vs optionals / `Result`: [type guards & optionals](./examples/in/rfc/optionals/10-type-guards-shape-guards-and-optionals.md). |
+| Control-flow type narrowing | 🔬 experimental | **If-branch narrowing** for `if x is …` (assertion or shape RHS): refined type for the subject is recorded in the branch scope; variable types are keyed by identifier **and** source span in the typechecker so hover can differ per occurrence. **Ensure-successor narrowing** works for simple identifiers; compound paths and join/merge across branches are not done yet. Implementation: `forst/internal/typechecker/infer_if.go`, `narrow_if.go`; LSP uses `InferredTypesForVariableNode` when the variable AST node is found at the cursor. Type guards vs optionals / `Result`: [type guards & optionals](./examples/in/rfc/optionals/10-type-guards-shape-guards-and-optionals.md). |
### Generics, aliases, and nominal features
diff --git a/Taskfile.yml b/Taskfile.yml
index 16956b2f..96c28ccc 100644
--- a/Taskfile.yml
+++ b/Taskfile.yml
@@ -223,6 +223,18 @@ tasks:
cmds:
- go run ./{{.BUILD_DIR}} run -loglevel trace -report-phases -- {{.EXAMPLES_DIR}}/union_error_types.ft
+ example:union-error-narrowing:
+ desc: Compile union error narrowing example (golden examples/out/union_error_narrowing.go)
+ dir: forst
+ cmds:
+ - go run ./{{.BUILD_DIR}} run -loglevel trace -report-phases -- {{.EXAMPLES_DIR}}/union_error_narrowing.ft
+
+ example:map-catalog:
+ desc: Compile map catalog example (golden examples/out/map_catalog.go)
+ dir: forst
+ cmds:
+ - go run ./{{.BUILD_DIR}} run -loglevel trace -report-phases -- {{.EXAMPLES_DIR}}/map_catalog.ft
+
example:result-ensure:
desc: Compile Result + ensure Ok() example (golden examples/out/result_ensure/)
dir: forst
diff --git a/docs/language/shapes-and-constraints.mdx b/docs/language/shapes-and-constraints.mdx
index 3251448a..d62b29ae 100644
--- a/docs/language/shapes-and-constraints.mdx
+++ b/docs/language/shapes-and-constraints.mdx
@@ -8,7 +8,7 @@ import ShapesConstraintChaining from "/snippets/shapes-constraint-chaining.mdx";
import ShapesPlaceOrderTags from "/snippets/shapes-place-order-tags.mdx";
import ShapesAddressCustomer from "/snippets/shapes-address-customer.mdx";
-In Go you declare a struct and write manual checks. In Forst, **constraints live on the type**. The compiler emits runtime validation at the boundary.
+In Go you declare a struct and write manual checks. In Forst, **constraints live on the type**. Where values are known at compile time, the typechecker proves them statically; for dynamic input, use **`ensure … is …`** in the function body today. Automatic validation prologues on parameters are planned—not emitted yet.
## Manual validation in Go
@@ -35,7 +35,7 @@ The struct says nothing about valid ranges. Every handler repeats the same `if`
-When `placeOrder` runs, the compiler has already proven the shape at compile time where values are known, and emits checks for dynamic input. Invalid SKUs and quantities never reach your catalog logic.
+When `placeOrder` runs, the compiler has already proven the shape at compile time where values are known; use `ensure` (or explicit checks) for dynamic input. Invalid SKUs and quantities never reach your catalog logic.
## Built-in constraints
@@ -95,7 +95,7 @@ Invalid nested data fails at the same boundary. You do not re-walk the tree in a
## What gets emitted
-The compiler lowers constraints to plain Go checks before your function body runs: `String.Min(1)` becomes a `len(field)` comparison, `Int.Min(1)` a numeric comparison, and `HasPrefix` / `Contains` call `strings.*` with an auto-added import. Generated Go stays readable and uses standard error returns. There is no hidden magic at runtime.
+The compiler lowers **`ensure`** checks in the function body to plain Go: `String.Min(1)` becomes a `len(field)` comparison, `Int.Min(1)` a numeric comparison, and `HasPrefix` / `Contains` call `strings.*` with an auto-added import. Parameter prologue validation (automatic checks before your function body) is not emitted yet—use `ensure` explicitly today.
## Try it
diff --git a/docs/resources/roadmap.mdx b/docs/resources/roadmap.mdx
index 2f0c0ea9..9971eb2f 100644
--- a/docs/resources/roadmap.mdx
+++ b/docs/resources/roadmap.mdx
@@ -19,12 +19,12 @@ Forst is under active development. This page is a **simplified** view. The canon
| Status | Feature | Notes |
| --- | --- | --- |
-| | [Shapes and constraints](/language/shapes-and-constraints) | Structural types, built-in constraints, runtime validation at the boundary. |
-| | [`ensure`, `is`, and shape guards](/language/ensure-and-narrowing) | Runtime checks tied to types; shape refinement on records. |
+| | [Shapes and constraints](/language/shapes-and-constraints) | Structural types and built-in constraints; compile-time checks where values are static, runtime validation via `ensure` in the body today. Parameter prologue emission is future work. |
+| | [`ensure`, `is`, and shape guards](/language/ensure-and-narrowing) | Runtime checks tied to types; shape refinement on records. Successor narrowing after `ensure` works for simple identifiers; compound paths still open. |
| | [Nil and presence checks](/language/ensure-and-narrowing) | `ensure x is Nil()`, `ensure !x`, and `*T` `.Present()` / `.Nil()` on pointers. Not full optional value types yet. |
| | [Type guards](/language/type-guards) | Domain predicates with `is (subject T) Name { … }`; narrowing polish still open. |
| | [Result and nominal errors](/language/errors-and-result) | `Result(S, F)`, `error X { … }`, and `ensure` / `if x is Ok()` narrowing. Closed nominal error unions (`ParseError \| IoError`) emit sealed Go interfaces. Failure-type and export rules still maturing. |
-| | Control-flow narrowing | If-branch refinement works; join across branches and post-`ensure` narrowing still open. |
+| | Control-flow narrowing | If-branch refinement works; ensure-successor narrowing for simple identifiers works; compound paths and join across branches still open. |
| | Type aliases | Simple `type Name = BaseType` works end-to-end; broader alias semantics still evolving. |
| | Binary types (`A \| B`, `A & B`) | Union and intersection typedefs type-check; closed nominal error unions emit to Go and TS. General unions, intersections, and narrowing still incomplete. |
| | Optional types (`T \| Nil`) | Planned: Crystal-style `T \| Nil` / `T?` unions, narrowing, and Go lowering. See [optionals RFC](https://github.com/forst-lang/forst/tree/main/examples/in/rfc/optionals). |
diff --git a/docs/snippets/ensure-if-ok.mdx b/docs/snippets/ensure-if-ok.mdx
index 01740791..691a83d7 100644
--- a/docs/snippets/ensure-if-ok.mdx
+++ b/docs/snippets/ensure-if-ok.mdx
@@ -8,9 +8,10 @@
```go
- // forst build
- if x, ok := x.(Ok); ok {
- println(x.Value)
+ // forst build — Result lowers to (T, error)
+ x, xErr := one()
+ if xErr == nil {
+ println(x)
}
```
diff --git a/docs/snippets/type-guards-mutation.mdx b/docs/snippets/type-guards-mutation.mdx
index 762af85c..482958ce 100644
--- a/docs/snippets/type-guards-mutation.mdx
+++ b/docs/snippets/type-guards-mutation.mdx
@@ -14,7 +14,7 @@
```go
- // forst build
+ // forst build — illustrative; type-level guards do not emit Go helpers yet
func G_Input(m MutationArg, input Shape) error {
// shape guard: m must include input field
return nil
diff --git a/examples/in/union_error_narrowing.ft b/examples/in/union_error_narrowing.ft
index cce0e091..1bf8d965 100644
--- a/examples/in/union_error_narrowing.ft
+++ b/examples/in/union_error_narrowing.ft
@@ -2,7 +2,7 @@ package main
// Narrowing: failure payloads from closed nominal-error unions (ErrKind = A | B) use Result(S, ErrKind)
// and `Err()` / `Err(...)` guards — not `is ParseError` on the union (nominal errors are not `is` guards).
-// Covered by compiler and typechecker tests; no golden .go (TestExamples skips when out/ is missing).
+// Golden: ../out/union_error_narrowing.go (`task example:union-error-narrowing`, cmd/forst TestExamples).
error ParseError {
code: Int,
diff --git a/forst/internal/ast/compound_assign_test.go b/forst/internal/ast/compound_assign_test.go
index 688f53d5..13941db4 100644
--- a/forst/internal/ast/compound_assign_test.go
+++ b/forst/internal/ast/compound_assign_test.go
@@ -36,6 +36,58 @@ func TestIsAssignmentOperatorToken(t *testing.T) {
}
}
+func TestCompoundAssignBinaryOp_allOperators(t *testing.T) {
+ t.Parallel()
+ cases := []struct {
+ compound TokenIdent
+ binary TokenIdent
+ }{
+ {TokenMinusEq, TokenMinus},
+ {TokenStarEq, TokenStar},
+ {TokenDivideEq, TokenDivide},
+ {TokenModuloEq, TokenModulo},
+ {TokenBitwiseAndEq, TokenBitwiseAnd},
+ {TokenBitwiseOrEq, TokenBitwiseOr},
+ }
+ for _, tc := range cases {
+ op, ok := CompoundAssignBinaryOp(tc.compound)
+ if !ok || op != tc.binary {
+ t.Fatalf("%s -> %v ok=%v", tc.compound, op, ok)
+ }
+ }
+ if _, ok := CompoundAssignBinaryOp(TokenColonEquals); ok {
+ t.Fatal(":= is not compound assign")
+ }
+}
+
+func TestCompoundAssignOperatorString_allSpellings(t *testing.T) {
+ t.Parallel()
+ cases := map[TokenIdent]string{
+ TokenPlusEq: "+=",
+ TokenMinusEq: "-=",
+ TokenStarEq: "*=",
+ TokenDivideEq: "/=",
+ TokenModuloEq: "%=",
+ TokenBitwiseAndEq: "&=",
+ TokenBitwiseOrEq: "|=",
+ }
+ for tok, want := range cases {
+ if got := CompoundAssignOperatorString(tok); got != want {
+ t.Fatalf("%s: got %q want %q", tok, got, want)
+ }
+ }
+ if got := CompoundAssignOperatorString(TokenPlus); got != string(TokenPlus) {
+ t.Fatalf("unknown token: %q", got)
+ }
+}
+
+func TestIsAssignmentOperatorToken_colonEquals(t *testing.T) {
+ t.Parallel()
+ if !IsAssignmentOperatorToken(Token{Type: TokenColonEquals, Value: ":="}) {
+ t.Fatal("expected := to be assignment operator")
+ }
+}
+
func TestAssignmentNode_String_compound(t *testing.T) {
t.Parallel()
a := AssignmentNode{
diff --git a/forst/internal/ast/expression.go b/forst/internal/ast/expression.go
index 5a192206..2ba39ae6 100644
--- a/forst/internal/ast/expression.go
+++ b/forst/internal/ast/expression.go
@@ -44,7 +44,7 @@ type IndexExpressionNode struct {
Index ExpressionNode
}
-func (IndexExpressionNode) isExpression() {}
+func (i IndexExpressionNode) isExpression() { _ = i }
// Kind returns the node kind for index expressions.
func (i IndexExpressionNode) Kind() NodeKind {
diff --git a/forst/internal/ast/for_stmt.go b/forst/internal/ast/for_stmt.go
index 51d76e97..be76b161 100644
--- a/forst/internal/ast/for_stmt.go
+++ b/forst/internal/ast/for_stmt.go
@@ -7,6 +7,8 @@ import "fmt"
// Range: IsRange true, RangeX required; RangeKey/RangeValue optional (nil means omitted, as in `for range ch`).
// Use Ident ID "_" for blank identifiers.
type ForNode struct {
+ Label *Ident
+
Init Node
Cond ExpressionNode
Post Node
diff --git a/forst/internal/ast/interface_markers_test.go b/forst/internal/ast/interface_markers_test.go
index 8a207e96..994206da 100644
--- a/forst/internal/ast/interface_markers_test.go
+++ b/forst/internal/ast/interface_markers_test.go
@@ -22,4 +22,9 @@ func TestInterfaceMarkerMethodsExecute(t *testing.T) {
VariableNode{}.isValue()
DereferenceNode{}.isValue()
ReferenceNode{}.isValue()
+
+ DereferenceNode{}.isExpression()
+ IndexExpressionNode{}.isExpression()
+ OkExprNode{}.isExpression()
+ ErrExprNode{}.isExpression()
}
diff --git a/forst/internal/ast/param.go b/forst/internal/ast/param.go
index a043aade..d8238823 100644
--- a/forst/internal/ast/param.go
+++ b/forst/internal/ast/param.go
@@ -1,6 +1,9 @@
package ast
-import "fmt"
+import (
+ "fmt"
+ "strings"
+)
// ParamNode is the interface for parameter nodes
type ParamNode interface {
@@ -55,3 +58,8 @@ func (p SimpleParamNode) GetType() TypeNode {
func (p DestructuredParamNode) GetType() TypeNode {
return p.Type
}
+
+// GetIdent returns a comma-separated list of destructured field names.
+func (p DestructuredParamNode) GetIdent() string {
+ return strings.Join(p.Fields, ", ")
+}
diff --git a/forst/internal/ast/param_test.go b/forst/internal/ast/param_test.go
index c27264f6..69221362 100644
--- a/forst/internal/ast/param_test.go
+++ b/forst/internal/ast/param_test.go
@@ -16,4 +16,7 @@ func TestDestructuredParamNode_String_Kind_GetType(t *testing.T) {
if d.GetType().Ident != TypeInt {
t.Fatal(d.GetType())
}
+ if d.GetIdent() != "a, b" {
+ t.Fatalf("GetIdent() = %q", d.GetIdent())
+ }
}
diff --git a/forst/internal/ast/providers_test.go b/forst/internal/ast/providers_test.go
index 6f203a3d..ddb6d1fd 100644
--- a/forst/internal/ast/providers_test.go
+++ b/forst/internal/ast/providers_test.go
@@ -60,6 +60,23 @@ func TestIsPublicExportIdent(t *testing.T) {
if IsPublicExportIdent("handle") {
t.Fatal("handle is not public")
}
+ if IsPublicExportIdent("") {
+ t.Fatal("empty ident is not public")
+ }
+}
+
+func TestParamTypesFromFunction_simpleParamsOnly(t *testing.T) {
+ fn := FunctionNode{
+ Params: []ParamNode{
+ SimpleParamNode{Ident: Ident{ID: "a"}, Type: TypeNode{Ident: TypeInt}},
+ DestructuredParamNode{Fields: []string{"x"}, Type: TypeNode{Ident: TypeString}},
+ SimpleParamNode{Ident: Ident{ID: "b"}, Type: TypeNode{Ident: TypeString}},
+ },
+ }
+ types := ParamTypesFromFunction(fn)
+ if len(types) != 2 || types[0].Ident != TypeInt || types[1].Ident != TypeString {
+ t.Fatalf("got %+v", types)
+ }
}
func TestIsTestingTParamType(t *testing.T) {
diff --git a/forst/internal/ast/result_expr.go b/forst/internal/ast/result_expr.go
index fa522796..e7beec63 100644
--- a/forst/internal/ast/result_expr.go
+++ b/forst/internal/ast/result_expr.go
@@ -8,7 +8,7 @@ type OkExprNode struct {
Value ExpressionNode
}
-func (OkExprNode) isExpression() {}
+func (o OkExprNode) isExpression() { _ = o }
func (OkExprNode) Kind() NodeKind { return NodeKindOkExpr }
@@ -25,7 +25,7 @@ type ErrExprNode struct {
Value ExpressionNode
}
-func (ErrExprNode) isExpression() {}
+func (e ErrExprNode) isExpression() { _ = e }
func (ErrExprNode) Kind() NodeKind { return NodeKindErrExpr }
diff --git a/forst/internal/ast/shape_format_test.go b/forst/internal/ast/shape_format_test.go
index d9877394..7205a84f 100644
--- a/forst/internal/ast/shape_format_test.go
+++ b/forst/internal/ast/shape_format_test.go
@@ -44,6 +44,55 @@ func TestShapeFieldNamesForCompositeEmit_preservesShapeFieldOrder(t *testing.T)
}
}
+func TestShapeFieldNode_MethodSignatureString(t *testing.T) {
+ field := ShapeFieldNode{
+ IsMethod: true,
+ MethodParams: []ParamNode{
+ SimpleParamNode{Ident: Ident{ID: "n"}, Type: TypeNode{Ident: TypeInt}},
+ },
+ }
+ if got := field.MethodSignatureString(); got != "(n Int)" {
+ t.Fatalf("got %q", got)
+ }
+}
+
+func TestShapeFieldNamesInOrder_usesFieldOrderWhenSet(t *testing.T) {
+ fields := map[string]ShapeFieldNode{"b": {}, "a": {}}
+ got := ShapeFieldNamesInOrder(fields, []string{"b", "a"})
+ if len(got) != 2 || got[0] != "b" || got[1] != "a" {
+ t.Fatalf("got %v", got)
+ }
+}
+
+func TestShapeFieldNamesForCompositeEmit_skipsOrderEntriesMissingFromPayload(t *testing.T) {
+ payload := map[string]ShapeFieldNode{"only": {Type: &TypeNode{Ident: TypeString}}}
+ shape := &ShapeNode{FieldOrder: []string{"ghost", "only"}}
+ got := ShapeFieldNamesForCompositeEmit(payload, shape)
+ if len(got) != 1 || got[0] != "only" {
+ t.Fatalf("got %v", got)
+ }
+}
+
+func TestShapeFieldNamesForCompositeEmit_appendsExtraPayloadFieldsSorted(t *testing.T) {
+ payload := map[string]ShapeFieldNode{
+ "id": {Type: &TypeNode{Ident: TypeString}},
+ "extra": {Type: &TypeNode{Ident: TypeInt}},
+ }
+ shape := &ShapeNode{FieldOrder: []string{"id"}}
+ got := ShapeFieldNamesForCompositeEmit(payload, shape)
+ if len(got) != 2 || got[0] != "id" || got[1] != "extra" {
+ t.Fatalf("got %v", got)
+ }
+}
+
+func TestShapeFieldNamesForCompositeEmit_nilShapeUsesSortedOrder(t *testing.T) {
+ payload := map[string]ShapeFieldNode{"z": {}, "a": {}}
+ got := ShapeFieldNamesForCompositeEmit(payload, nil)
+ if len(got) != 2 || got[0] != "a" || got[1] != "z" {
+ t.Fatalf("got %v", got)
+ }
+}
+
func TestShapeFieldNamesInOrder_sortsWhenUnspecified(t *testing.T) {
fields := map[string]ShapeFieldNode{
"z": {},
diff --git a/forst/internal/ast/shape_test.go b/forst/internal/ast/shape_test.go
index 57a4c7fc..00cf7a5a 100644
--- a/forst/internal/ast/shape_test.go
+++ b/forst/internal/ast/shape_test.go
@@ -78,6 +78,47 @@ func TestShapeNode_String_multiple_top_level_fields(t *testing.T) {
}
}
+func TestShapeFieldNode_IsMethodField_and_methodString(t *testing.T) {
+ field := ShapeFieldNode{
+ IsMethod: true,
+ MethodParams: []ParamNode{
+ SimpleParamNode{Ident: Ident{ID: "msg"}, Type: TypeNode{Ident: TypeString}},
+ },
+ MethodReturnTypes: []TypeNode{{Ident: TypeError}},
+ }
+ if !field.IsMethodField() {
+ t.Fatal("expected method field")
+ }
+ if got := field.String(); got != "(msg String): Error" {
+ t.Fatalf("got %q", got)
+ }
+}
+
+func TestShapeNode_IsMethodOnlyContract_emptyFields(t *testing.T) {
+ if (ShapeNode{Fields: map[string]ShapeFieldNode{}}).IsMethodOnlyContract() {
+ t.Fatal("empty shape is not method-only")
+ }
+}
+
+func TestShapeFieldNode_ValueExpression_fromNode(t *testing.T) {
+ lit := StringLiteralNode{Value: "wired"}
+ field := ShapeFieldNode{Node: lit}
+ expr, ok := field.ValueExpression()
+ if !ok {
+ t.Fatal("expected expression from Node")
+ }
+ if got, ok := expr.(StringLiteralNode); !ok || got.Value != "wired" {
+ t.Fatalf("got %#v", expr)
+ }
+}
+
+func TestShapeFieldNode_ValueExpression_nonExpressionNode(t *testing.T) {
+ field := ShapeFieldNode{Node: CommentNode{Text: "note"}}
+ if _, ok := field.ValueExpression(); ok {
+ t.Fatal("comment node is not an expression")
+ }
+}
+
func TestShapeNode_String_field_assertion_branch(t *testing.T) {
bt := TypeIdent("Str")
s := ShapeNode{Fields: map[string]ShapeFieldNode{
diff --git a/forst/internal/ast/type_test.go b/forst/internal/ast/type_test.go
index f0773e65..6abcebd1 100644
--- a/forst/internal/ast/type_test.go
+++ b/forst/internal/ast/type_test.go
@@ -179,6 +179,20 @@ func TestTypeNode_String_each_simple_builtin_switch_case(t *testing.T) {
}
}
+func TestNewChannelType_andString(t *testing.T) {
+ t.Parallel()
+ ch := NewChannelType(NewBuiltinType(TypeInt))
+ if ch.Ident != TypeChannel || len(ch.TypeParams) != 1 || ch.TypeParams[0].Ident != TypeInt {
+ t.Fatalf("got %+v", ch)
+ }
+ if got := ch.String(); got != "chan Int" {
+ t.Fatalf("got %q", got)
+ }
+ if got := (TypeNode{Ident: TypeChannel}).String(); got != "chan" {
+ t.Fatalf("got %q", got)
+ }
+}
+
func TestTypeNode_String_default_branch_multiple_type_params(t *testing.T) {
tn := TypeNode{
Ident: "Pair",
diff --git a/forst/internal/ast/typedef_test.go b/forst/internal/ast/typedef_test.go
index d9299c75..c2710f88 100644
--- a/forst/internal/ast/typedef_test.go
+++ b/forst/internal/ast/typedef_test.go
@@ -62,6 +62,12 @@ func TestTypeDefErrorExpr_Kind_and_PayloadShape(t *testing.T) {
}
}
+func TestPayloadShape_nonShapeOrErrorReturnsFalse(t *testing.T) {
+ if _, ok := PayloadShape(TypeDefAssertionExpr{}); ok {
+ t.Fatal("assertion expr has no payload shape")
+ }
+}
+
func TestTypeDefBinaryExpr_and_TypeDefShapeExpr_Kind(t *testing.T) {
bin := TypeDefBinaryExpr{
Op: TokenBitwiseAnd,
diff --git a/forst/internal/ast/typeguard.go b/forst/internal/ast/typeguard.go
index 6fd5af27..4242a78a 100644
--- a/forst/internal/ast/typeguard.go
+++ b/forst/internal/ast/typeguard.go
@@ -59,10 +59,15 @@ func (s ShapeGuardNode) String() string {
return fmt.Sprintf("ShapeGuardNode(%s, %s: %s)", s.Ident, s.FieldName, s.TypeArg)
}
-// ValidateShapeGuard validates that a type guard is a valid shape guard
-func ValidateShapeGuard(node TypeGuardNode) error {
+// ValidateShapeGuard validates that a type guard is a valid shape guard.
+// When isShape is non-nil it resolves typedef aliases (e.g. via typechecker.IsShapeType).
+func ValidateShapeGuard(node TypeGuardNode, isShape func(TypeNode) bool) error {
+ check := isShapeType
+ if isShape != nil {
+ check = isShape
+ }
// Check if the receiver type is a Shape type
- if !isShapeType(node.Subject.GetType()) {
+ if !check(node.Subject.GetType()) {
return fmt.Errorf("shape guard can only be used on Shape types, got %s", node.Subject.GetType())
}
diff --git a/forst/internal/ast/typeguard_test.go b/forst/internal/ast/typeguard_test.go
index beb7a591..abe712da 100644
--- a/forst/internal/ast/typeguard_test.go
+++ b/forst/internal/ast/typeguard_test.go
@@ -140,7 +140,7 @@ func TestValidateShapeGuard(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- err := ValidateShapeGuard(tt.node)
+ err := ValidateShapeGuard(tt.node, nil)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateShapeGuard() error = %v, wantErr %v", err, tt.wantErr)
}
@@ -255,7 +255,7 @@ func TestValidateShapeGuard_extra_error_paths(t *testing.T) {
validReturn,
},
}
- if err := ValidateShapeGuard(node); err == nil {
+ if err := ValidateShapeGuard(node, nil); err == nil {
t.Fatal("expected error")
}
})
@@ -265,7 +265,7 @@ func TestValidateShapeGuard_extra_error_paths(t *testing.T) {
Subject: shapeSubject,
Body: []Node{IntLiteralNode{Value: 1}},
}
- if err := ValidateShapeGuard(node); err == nil {
+ if err := ValidateShapeGuard(node, nil); err == nil {
t.Fatal("expected error")
}
})
@@ -277,12 +277,39 @@ func TestValidateShapeGuard_extra_error_paths(t *testing.T) {
ReturnNode{Values: []ExpressionNode{IntLiteralNode{Value: 1}, IntLiteralNode{Value: 2}}},
},
}
- if err := ValidateShapeGuard(node); err == nil {
+ if err := ValidateShapeGuard(node, nil); err == nil {
t.Fatal("expected error")
}
})
}
+func TestValidateShapeGuard_customIsShapeResolver(t *testing.T) {
+ node := TypeGuardNode{
+ Ident: "HasField",
+ Subject: SimpleParamNode{
+ Ident: Ident{ID: "s"},
+ Type: TypeNode{Ident: "MutationArg"},
+ },
+ Body: []Node{
+ ReturnNode{
+ Values: []ExpressionNode{
+ BinaryExpressionNode{
+ Left: VariableNode{Ident: Ident{ID: "s"}},
+ Operator: TokenIs,
+ Right: ShapeNode{Fields: map[string]ShapeFieldNode{
+ "f": {Type: &TypeNode{Ident: TypeString}},
+ }},
+ },
+ },
+ },
+ },
+ }
+ isShape := func(t TypeNode) bool { return string(t.Ident) == "MutationArg" }
+ if err := ValidateShapeGuard(node, isShape); err != nil {
+ t.Fatalf("expected success with custom resolver: %v", err)
+ }
+}
+
func TestIsShapeRefinement_left_not_variable(t *testing.T) {
ok := isShapeRefinement(BinaryExpressionNode{
Left: IntLiteralNode{Value: 1},
diff --git a/forst/internal/discovery/discovery_streaming_test.go b/forst/internal/discovery/discovery_streaming_test.go
index 665711f2..2db9fb41 100644
--- a/forst/internal/discovery/discovery_streaming_test.go
+++ b/forst/internal/discovery/discovery_streaming_test.go
@@ -91,3 +91,30 @@ func TestStreamingSupported_channelReturn(t *testing.T) {
t.Fatal("expected streaming for chan return")
}
}
+
+func TestStreamingSupported_nilFunction(t *testing.T) {
+ if StreamingSupported(nil, typechecker.New(logrus.New(), false)) {
+ t.Fatal("nil function must not support streaming")
+ }
+}
+
+func TestStreamingSupported_prefersTypecheckerReturnTypes(t *testing.T) {
+ tc := typechecker.New(logrus.New(), false)
+ fn := &ast.FunctionNode{
+ Ident: ast.Ident{ID: "Emit"},
+ ReturnTypes: []ast.TypeNode{{Ident: "string"}},
+ }
+ tc.Functions[fn.Ident.ID] = typechecker.FunctionSignature{
+ ReturnTypes: []ast.TypeNode{{Ident: "EventStream"}},
+ }
+ if !StreamingSupported(fn, tc) {
+ t.Fatal("expected streaming from typechecker return type name")
+ }
+}
+
+func TestStreamingSupported_noReturnTypesNotStreaming(t *testing.T) {
+ fn := &ast.FunctionNode{Ident: ast.Ident{ID: "Ping"}}
+ if StreamingSupported(fn, typechecker.New(logrus.New(), false)) {
+ t.Fatal("expected false without streaming hints")
+ }
+}
diff --git a/forst/internal/discovery/providers_json_test.go b/forst/internal/discovery/providers_json_test.go
index fb94a20e..b95dbda3 100644
--- a/forst/internal/discovery/providers_json_test.go
+++ b/forst/internal/discovery/providers_json_test.go
@@ -2,6 +2,7 @@ package discovery
import (
"encoding/json"
+ "fmt"
"testing"
"forst/internal/ast"
@@ -67,3 +68,11 @@ func TestDiscoverer_extractProvidersFromTypechecker(t *testing.T) {
t.Fatal("ExpireToken should not be runnable")
}
}
+
+func TestDiscoverer_DiscoverProvidersJSONV1_configError(t *testing.T) {
+ discoverer := NewDiscoverer("/test", &MockLogger{}, &MockConfig{err: fmt.Errorf("config error")})
+ _, err := discoverer.DiscoverProvidersJSONV1()
+ if err == nil {
+ t.Fatal("expected error when discovery fails")
+ }
+}
diff --git a/forst/internal/parser/array_shape_field_test.go b/forst/internal/parser/array_shape_field_test.go
new file mode 100644
index 00000000..cef561a0
--- /dev/null
+++ b/forst/internal/parser/array_shape_field_test.go
@@ -0,0 +1,32 @@
+package parser
+
+import (
+ "testing"
+
+ "forst/internal/ast"
+)
+
+func TestParseShapeType_ArrayInlineShapeField(t *testing.T) {
+ t.Parallel()
+ input := `{ items: Array({ id: String, quantity: Int }) }`
+ p := NewTestParser(input, ast.SetupTestLogger(nil))
+ shape := p.parseShapeType()
+ field := shape.Fields["items"]
+ if field.Type == nil {
+ t.Fatal("items field has no type")
+ }
+ if field.Type.Ident != ast.TypeArray {
+ t.Fatalf("expected Array type, got %s", field.Type.Ident)
+ }
+ if len(field.Type.TypeParams) != 1 {
+ t.Fatalf("expected 1 type param, got %d", len(field.Type.TypeParams))
+ }
+ elem := field.Type.TypeParams[0]
+ if elem.Assertion == nil || len(elem.Assertion.Constraints) == 0 {
+ t.Fatalf("expected assertion on array element type, got %+v", elem)
+ }
+ nested := elem.Assertion.Constraints[0].Args[0].Shape
+ if nested == nil || len(nested.Fields) != 2 {
+ t.Fatalf("expected nested shape with 2 fields, got %+v", nested)
+ }
+}
diff --git a/forst/internal/parser/shape.go b/forst/internal/parser/shape.go
index 2d5fa2af..350715c7 100644
--- a/forst/internal/parser/shape.go
+++ b/forst/internal/parser/shape.go
@@ -109,7 +109,9 @@ func (p *Parser) parseShapeFieldTypeAfterColon(name string, opts ShapeFieldTypeO
},
}
}
- if isPossibleTypeIdentifier(p.current(), TypeIdentOpts{AllowLowercaseTypes: false}) || p.current().Type == ast.TokenStar {
+ if isPossibleTypeIdentifier(p.current(), TypeIdentOpts{AllowLowercaseTypes: false}) ||
+ p.current().Type == ast.TokenStar ||
+ p.current().Type == ast.TokenArray {
typ := p.parseType(TypeIdentOpts{AllowLowercaseTypes: true})
typeIdent := typ.Ident
p.logParsedNodeWithMessage(typ, fmt.Sprintf("Parsed type for shape field %s and type ident %s (type: %+v)", name, typeIdent, typ))
diff --git a/forst/internal/parser/statement.go b/forst/internal/parser/statement.go
index 2c8eb3ab..1526199e 100644
--- a/forst/internal/parser/statement.go
+++ b/forst/internal/parser/statement.go
@@ -44,6 +44,21 @@ func (p *Parser) parseBlockStatement() []ast.Node {
body = append(body, returnStatement)
case ast.TokenIdentifier:
next := p.peek()
+ if next.Type == ast.TokenColon && p.peek(2).Type == ast.TokenFor {
+ if p.context.IsTypeGuard() {
+ p.FailWithParseError(token, "For loop not allowed in type guards")
+ }
+ label := &ast.Ident{ID: ast.Identifier(token.Value), Span: ast.SpanFromToken(token)}
+ p.advance() // ident
+ p.advance() // colon
+ forStatement := p.parseForStatement()
+ if fn, ok := forStatement.(*ast.ForNode); ok {
+ fn.Label = label
+ }
+ p.logParsedNode(forStatement)
+ body = append(body, forStatement)
+ break
+ }
if next.Type == ast.TokenComma {
assignment := p.parseMultipleAssignment()
p.logParsedNode(assignment)
diff --git a/forst/internal/parser/type.go b/forst/internal/parser/type.go
index d96e33cb..0514960f 100644
--- a/forst/internal/parser/type.go
+++ b/forst/internal/parser/type.go
@@ -15,6 +15,15 @@ func (p *Parser) parseType(opts TypeIdentOpts) ast.TypeNode {
return ast.NewPointerType(baseType)
}
+ // Array(T) — element type may be an inline shape `{ ... }`
+ if token.Type == ast.TokenArray {
+ p.advance()
+ p.expect(ast.TokenLParen)
+ elementType := p.parseType(opts)
+ p.expect(ast.TokenRParen)
+ return ast.NewArrayType(elementType)
+ }
+
// Handle shape types
if token.Type == ast.TokenLBrace {
shape := p.parseShapeType()
@@ -182,6 +191,7 @@ func isPossibleTypeIdentifier(token ast.Token, opts TypeIdentOpts) bool {
token.Type == ast.TokenFloat ||
token.Type == ast.TokenBool ||
token.Type == ast.TokenVoid ||
+ token.Type == ast.TokenArray ||
token.Type == ast.TokenLBracket ||
token.Type == ast.TokenMap ||
token.Type == ast.TokenChan
diff --git a/forst/internal/printer/printer.go b/forst/internal/printer/printer.go
index 782740a1..4a6d186b 100644
--- a/forst/internal/printer/printer.go
+++ b/forst/internal/printer/printer.go
@@ -800,6 +800,10 @@ func (p *printer) printIf(n *ast.IfNode) (string, error) {
func (p *printer) printFor(n ast.ForNode) (string, error) {
var b strings.Builder
+ if n.Label != nil {
+ b.WriteString(string(n.Label.ID))
+ b.WriteString(": ")
+ }
if n.IsRange {
b.WriteString("for ")
if n.RangeKey != nil {
@@ -948,7 +952,34 @@ func (p *printer) printExpr(e ast.ExpressionNode) (string, error) {
buf.WriteByte(']')
return buf.String(), nil
case ast.MapLiteralNode:
- return "", fmt.Errorf("printer: MapLiteral not supported yet")
+ var buf strings.Builder
+ buf.WriteString("map[")
+ if len(x.Type.TypeParams) >= 1 {
+ buf.WriteString(printType(x.Type.TypeParams[0]))
+ }
+ buf.WriteByte(']')
+ if len(x.Type.TypeParams) >= 2 {
+ buf.WriteString(printType(x.Type.TypeParams[1]))
+ }
+ buf.WriteByte('{')
+ for i, ent := range x.Entries {
+ if i > 0 {
+ buf.WriteString(", ")
+ }
+ k, err := p.printExpr(ent.Key.(ast.ExpressionNode))
+ if err != nil {
+ return "", err
+ }
+ buf.WriteString(k)
+ buf.WriteString(": ")
+ v, err := p.printExpr(ent.Value.(ast.ExpressionNode))
+ if err != nil {
+ return "", err
+ }
+ buf.WriteString(v)
+ }
+ buf.WriteByte('}')
+ return buf.String(), nil
default:
return "", fmt.Errorf("printer: unsupported expression %T", e)
}
diff --git a/forst/internal/printer/printer_test.go b/forst/internal/printer/printer_test.go
index 80e7e869..557bd422 100644
--- a/forst/internal/printer/printer_test.go
+++ b/forst/internal/printer/printer_test.go
@@ -355,3 +355,26 @@ func main() {
t.Fatalf("re-parse pretty output: %v\n--- out ---\n%s", err, out)
}
}
+
+func TestPrintExpr_mapLiteral(t *testing.T) {
+ t.Parallel()
+ var p printer
+ p.cfg = DefaultConfig()
+ got, err := p.printExpr(ast.MapLiteralNode{
+ Type: ast.TypeNode{
+ Ident: ast.TypeMap,
+ TypeParams: []ast.TypeNode{{Ident: ast.TypeString}, {Ident: ast.TypeInt}},
+ },
+ Entries: []ast.MapEntryNode{
+ {Key: ast.StringLiteralNode{Value: "a"}, Value: ast.IntLiteralNode{Value: 1}},
+ {Key: ast.StringLiteralNode{Value: "b"}, Value: ast.IntLiteralNode{Value: 2}},
+ },
+ })
+ if err != nil {
+ t.Fatalf("printExpr map literal: %v", err)
+ }
+ want := `map[String]Int{"a": 1, "b": 2}`
+ if got != want {
+ t.Fatalf("got %q want %q", got, want)
+ }
+}
diff --git a/forst/internal/transformer/go/emit_validation_coverage_test.go b/forst/internal/transformer/go/emit_validation_coverage_test.go
index 3b2928e0..543eae31 100644
--- a/forst/internal/transformer/go/emit_validation_coverage_test.go
+++ b/forst/internal/transformer/go/emit_validation_coverage_test.go
@@ -66,6 +66,26 @@ func main() {
assertGoParses(t, out)
}
+func TestEmitValidation_labeledBreak(t *testing.T) {
+ src := `package main
+
+func main() {
+outer: for i := 0; i < 10; i = i + 1 {
+ for j := 0; j < 10; j = j + 1 {
+ if j == 5 {
+ break outer
+ }
+ }
+ }
+}
+`
+ out := compileForstPipeline(t, src)
+ if !strings.Contains(out, `outer:`) || !strings.Contains(out, `break outer`) {
+ t.Fatalf("expected labeled break:\n%s", out)
+ }
+ assertGoParses(t, out)
+}
+
func TestEmitValidation_pointerAndReference(t *testing.T) {
src := `package main
diff --git a/forst/internal/transformer/go/ensure_builtins.go b/forst/internal/transformer/go/ensure_builtins.go
index 1955cd96..b9e41d24 100644
--- a/forst/internal/transformer/go/ensure_builtins.go
+++ b/forst/internal/transformer/go/ensure_builtins.go
@@ -5,13 +5,19 @@ import (
"forst/internal/ast"
goast "go/ast"
"go/token"
+ "sync"
)
// Constraint types
type ConstraintHandler func(at *AssertionTransformer, variable ast.VariableNode, constraint ast.ConstraintNode) (goast.Expr, error)
-// Map of builtin types to their constraints and handlers
-var builtinConstraints = map[ast.TypeIdent]map[BuiltinConstraint]ConstraintHandler{
+var (
+ builtinConstraintsOnce sync.Once
+ builtinConstraints map[ast.TypeIdent]map[BuiltinConstraint]ConstraintHandler
+)
+
+func initBuiltinConstraints() {
+ builtinConstraints = map[ast.TypeIdent]map[BuiltinConstraint]ConstraintHandler{
ast.TypePointer: {
NilConstraint: func(at *AssertionTransformer, variable ast.VariableNode, constraint ast.ConstraintNode) (goast.Expr, error) {
if err := at.validateConstraintArgs(constraint, 0); err != nil {
@@ -47,19 +53,11 @@ var builtinConstraints = map[ast.TypeIdent]map[BuiltinConstraint]ConstraintHandl
if err := at.validateConstraintArgs(constraint, 1); err != nil {
return nil, err
}
- arg, err := at.expectValue(&constraint.Args[0])
- if err != nil {
- return nil, err
- }
- arg, err = expectIntLiteral(arg)
- if err != nil {
- return nil, err
- }
variableExpr, err := at.transformStringBuiltinVariable(variable)
if err != nil {
return nil, err
}
- argExpr, err := at.transformer.transformExpression(arg)
+ argExpr, err := at.constraintArgAsExpr(constraint.Args[0])
if err != nil {
return nil, err
}
@@ -78,19 +76,11 @@ var builtinConstraints = map[ast.TypeIdent]map[BuiltinConstraint]ConstraintHandl
if err := at.validateConstraintArgs(constraint, 1); err != nil {
return nil, err
}
- arg, err := at.expectValue(&constraint.Args[0])
- if err != nil {
- return nil, err
- }
- arg, err = expectIntLiteral(arg)
- if err != nil {
- return nil, err
- }
variableExpr, err := at.transformStringBuiltinVariable(variable)
if err != nil {
return nil, err
}
- argExpr, err := at.transformer.transformExpression(arg)
+ argExpr, err := at.constraintArgAsExpr(constraint.Args[0])
if err != nil {
return nil, err
}
@@ -190,14 +180,6 @@ var builtinConstraints = map[ast.TypeIdent]map[BuiltinConstraint]ConstraintHandl
Y: &goast.BasicLit{Kind: token.INT, Value: "0"},
}, nil
},
- ValidConstraint: func(at *AssertionTransformer, _ ast.VariableNode, constraint ast.ConstraintNode) (goast.Expr, error) {
- if err := at.validateConstraintArgs(constraint, 0); err != nil {
- return nil, err
- }
- // For now, just return a simple condition that will always be false
- // This simulates a validation that always fails
- return goast.NewIdent("false"), nil
- },
},
// Slice/array: length constraints use len(...) like strings.
ast.TypeArray: {
@@ -521,10 +503,12 @@ var builtinConstraints = map[ast.TypeIdent]map[BuiltinConstraint]ConstraintHandl
}, nil
},
},
+ }
}
// TransformBuiltinConstraint transforms a builtin constraint
func (at *AssertionTransformer) TransformBuiltinConstraint(typeIdent ast.TypeIdent, variable ast.VariableNode, constraint ast.ConstraintNode) (goast.Expr, error) {
+ builtinConstraintsOnce.Do(initBuiltinConstraints)
handlerMap, ok := builtinConstraints[typeIdent]
if !ok {
return nil, fmt.Errorf("unknown typeIdent %s for built-in constraints: %s", typeIdent, constraint.Name)
diff --git a/forst/internal/transformer/go/ensure_constraint.go b/forst/internal/transformer/go/ensure_constraint.go
index 4559f90b..be5bb1e0 100644
--- a/forst/internal/transformer/go/ensure_constraint.go
+++ b/forst/internal/transformer/go/ensure_constraint.go
@@ -42,6 +42,11 @@ func (t *Transformer) transformEnsureConstraint(ensure ast.EnsureNode, constrain
}
}
+ // Type-level shape constraints (`ensure m is { field }`) are not lowered to runtime checks yet.
+ if constraint.Name == "is" {
+ return goast.NewIdent("true"), nil
+ }
+
// Try built-in constraints first
if transformed, err := t.assertionTransformer.TransformBuiltinConstraint(varType.Ident, ensure.Variable, constraint); err == nil {
return transformed, nil
diff --git a/forst/internal/transformer/go/ensure_types.go b/forst/internal/transformer/go/ensure_types.go
index f11eaa2e..6e1f1c6b 100644
--- a/forst/internal/transformer/go/ensure_types.go
+++ b/forst/internal/transformer/go/ensure_types.go
@@ -3,6 +3,7 @@ package transformergo
import (
"fmt"
"forst/internal/ast"
+ goast "go/ast"
)
type BuiltinConstraint string
@@ -30,8 +31,6 @@ const (
PresentConstraint BuiltinConstraint = "Present"
// NotEmptyConstraint is the built-in NotEmpty constraint in Forst
NotEmptyConstraint BuiltinConstraint = "NotEmpty"
- // ValidConstraint is the built-in Valid constraint in Forst
- ValidConstraint BuiltinConstraint = "Valid"
// ValueConstraint is the built-in Value constraint in Forst
ValueConstraint BuiltinConstraint = ast.ValueConstraint
)
@@ -67,3 +66,14 @@ func (at *AssertionTransformer) expectValue(arg *ast.ConstraintArgumentNode) (as
return *arg.Value, nil
}
+
+// constraintArgAsExpr lowers a constraint argument (literal value or param ident parsed as Type).
+func (at *AssertionTransformer) constraintArgAsExpr(arg ast.ConstraintArgumentNode) (goast.Expr, error) {
+ if arg.Value != nil {
+ return at.transformer.transformExpression((*arg.Value).(ast.ExpressionNode))
+ }
+ if arg.Type != nil && arg.Shape == nil {
+ return goast.NewIdent(string(arg.Type.Ident)), nil
+ }
+ return nil, fmt.Errorf("expected argument to be a value")
+}
diff --git a/forst/internal/transformer/go/examples_pipeline_test.go b/forst/internal/transformer/go/examples_pipeline_test.go
index 3d66a6d5..36699ec0 100644
--- a/forst/internal/transformer/go/examples_pipeline_test.go
+++ b/forst/internal/transformer/go/examples_pipeline_test.go
@@ -7,6 +7,30 @@ import (
"testing"
)
+// examplesPipelineSkip lists example paths excluded from the known-good pipeline until fixed.
+var examplesPipelineSkip = map[string]string{
+ "rfc/guard/anonymous_objects.ft": "Tier 2a: anonymous object literal parser fix",
+}
+
+// TestWriteUnionErrorNarrowingGolden regenerates examples/out/union_error_narrowing.go.
+// Run: UPDATE_UNION_ERROR_NARROWING_GOLDEN=1 go test ./internal/transformer/go -run TestWriteUnionErrorNarrowingGolden -count=1
+func TestWriteUnionErrorNarrowingGolden(t *testing.T) {
+ if os.Getenv("UPDATE_UNION_ERROR_NARROWING_GOLDEN") != "1" {
+ t.Skip("set UPDATE_UNION_ERROR_NARROWING_GOLDEN=1 to regenerate golden")
+ }
+ examplesRoot := filepath.Join("..", "..", "..", "..", "examples", "in")
+ src, err := os.ReadFile(filepath.Join(examplesRoot, "union_error_narrowing.ft"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ out := compileForstPipeline(t, string(src))
+ goldenPath := filepath.Join("..", "..", "..", "..", "examples", "out", "union_error_narrowing.go")
+ if err := os.WriteFile(goldenPath, []byte(out), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ t.Logf("wrote %s (%d bytes)", goldenPath, len(out))
+}
+
// TestPipeline_examplesInKnownGood runs the full compile pipeline on example programs that
// compile standalone (same set as internal/compiler TestProgramCompilation plus a few guards).
func TestPipeline_examplesInKnownGood(t *testing.T) {
@@ -27,6 +51,7 @@ func TestPipeline_examplesInKnownGood(t *testing.T) {
"nominal_error.ft",
"echo.ft",
"map_catalog.ft",
+ "rfc/guard/anonymous_objects.ft",
"tictactoe/engine.ft",
"rfc/guard/shape_guard.ft",
"rfc/guard/basic_guard.ft",
@@ -34,6 +59,9 @@ func TestPipeline_examplesInKnownGood(t *testing.T) {
for _, rel := range relPaths {
t.Run(rel, func(t *testing.T) {
t.Parallel()
+ if reason, skip := examplesPipelineSkip[rel]; skip {
+ t.Skip(reason)
+ }
p := filepath.Join(examplesRoot, rel)
src, err := os.ReadFile(p)
if err != nil {
diff --git a/forst/internal/transformer/go/expression_if_is.go b/forst/internal/transformer/go/expression_if_is.go
index be715967..42b7506a 100644
--- a/forst/internal/transformer/go/expression_if_is.go
+++ b/forst/internal/transformer/go/expression_if_is.go
@@ -13,8 +13,7 @@ import (
var errMissingConstraintArgValue = errors.New("missing value argument for constraint argument")
// transformIfIsCondition builds a Go bool for `left is ` when the RHS is an AssertionNode.
-// The typechecker already validated the branch; we emit an expression that evaluates the subject
-// (for side effects) and returns true. Constrained assertions need dedicated runtime checks (TODO).
+// The typechecker already validated the branch; builtin constraints reuse ensure codegen.
func (t *Transformer) transformIfIsCondition(left ast.ExpressionNode, assertion *ast.AssertionNode) (goast.Expr, error) {
if assertion == nil {
return nil, fmt.Errorf("if-is: nil assertion")
@@ -24,6 +23,24 @@ func (t *Transformer) transformIfIsCondition(left ast.ExpressionNode, assertion
if c.Name == "Ok" || c.Name == "Err" {
return t.transformResultIsDiscriminator(left, c)
}
+ if vn, ok := left.(ast.VariableNode); ok {
+ ensure := ast.EnsureNode{
+ Variable: vn,
+ Assertion: *assertion,
+ }
+ varType, err := t.TypeChecker.LookupEnsureBaseType(&ensure, t.currentScope())
+ if err != nil {
+ return nil, fmt.Errorf("if-is: %w", err)
+ }
+ if expr, err := t.assertionTransformer.TransformBuiltinConstraint(varType.Ident, vn, c); err == nil {
+ return &goast.UnaryExpr{Op: token.NOT, X: expr}, nil
+ }
+ for _, baseType := range t.TypeChecker.GetTypeAliasChain(*varType)[1:] {
+ if result, err := t.assertionTransformer.TransformBuiltinConstraint(ast.TypeIdent(baseType.Ident), vn, c); err == nil {
+ return &goast.UnaryExpr{Op: token.NOT, X: result}, nil
+ }
+ }
+ }
}
leftExpr, err := t.transformExpression(left)
if err != nil {
diff --git a/forst/internal/transformer/go/expression_if_is_test.go b/forst/internal/transformer/go/expression_if_is_test.go
new file mode 100644
index 00000000..d83e73bc
--- /dev/null
+++ b/forst/internal/transformer/go/expression_if_is_test.go
@@ -0,0 +1,81 @@
+package transformergo
+
+import (
+ "testing"
+
+ "forst/internal/ast"
+ "forst/internal/parser"
+ "forst/internal/typechecker"
+)
+
+func TestTransformIfIsCondition_minWithParamInTypeGuard(t *testing.T) {
+ t.Parallel()
+ src := `package main
+
+type Password = String
+
+is (password Password) Strong(min Int) {
+ if password is Min(min) {
+ println("ok")
+ } else {
+ println("no")
+ }
+}
+`
+ log := ast.SetupTestLogger(nil)
+ p := parser.NewTestParser(src, log)
+ nodes, err := p.ParseFile()
+ if err != nil {
+ t.Fatalf("parse: %v", err)
+ }
+ tc := typechecker.New(log, false)
+ if err := tc.CheckTypes(nodes); err != nil {
+ t.Fatalf("typecheck: %v", err)
+ }
+ tr := New(tc, log)
+
+ var guard ast.TypeGuardNode
+ found := false
+ for _, n := range nodes {
+ if g, ok := n.(ast.TypeGuardNode); ok {
+ guard = g
+ found = true
+ break
+ }
+ if gp, ok := n.(*ast.TypeGuardNode); ok && gp != nil {
+ guard = *gp
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Fatal("expected type guard")
+ }
+ ifNodePtr, ok := guard.Body[0].(*ast.IfNode)
+ if !ok {
+ ifNodeVal, ok2 := guard.Body[0].(ast.IfNode)
+ if !ok2 {
+ t.Fatalf("expected IfNode, got %T", guard.Body[0])
+ }
+ ifNodePtr = &ifNodeVal
+ }
+ bin, ok := ifNodePtr.Condition.(ast.BinaryExpressionNode)
+ if !ok {
+ t.Fatalf("expected binary is condition, got %T", ifNodePtr.Condition)
+ }
+ asn, ok := bin.Right.(ast.AssertionNode)
+ if !ok {
+ t.Fatalf("expected AssertionNode, got %T", bin.Right)
+ }
+
+ if err := tr.restoreScope(guard); err != nil {
+ t.Fatalf("restoreScope: %v", err)
+ }
+ expr, err := tr.transformIfIsCondition(bin.Left, &asn)
+ if err != nil {
+ t.Fatalf("transformIfIsCondition: %v", err)
+ }
+ if expr == nil {
+ t.Fatal("expected non-nil expr")
+ }
+}
diff --git a/forst/internal/transformer/go/for_loop.go b/forst/internal/transformer/go/for_loop.go
index 699fd395..42fe7a7a 100644
--- a/forst/internal/transformer/go/for_loop.go
+++ b/forst/internal/transformer/go/for_loop.go
@@ -115,6 +115,7 @@ func (t *Transformer) transformForNode(fn *ast.ForNode) (goast.Stmt, error) {
}
body.List = append(body.List, gst)
}
+ var loop goast.Stmt
if fn.IsRange {
rs := &goast.RangeStmt{Body: body}
if fn.RangeKey != nil {
@@ -135,28 +136,35 @@ func (t *Transformer) transformForNode(fn *ast.ForNode) (goast.Stmt, error) {
return nil, err
}
rs.X = xe
- return rs, nil
- }
-
- fs := &goast.ForStmt{Body: body}
- var err error
- if fn.Init != nil {
- fs.Init, err = t.transformInitPostStmt(fn.Init)
- if err != nil {
- return nil, err
+ loop = rs
+ } else {
+ fs := &goast.ForStmt{Body: body}
+ var err error
+ if fn.Init != nil {
+ fs.Init, err = t.transformInitPostStmt(fn.Init)
+ if err != nil {
+ return nil, err
+ }
}
- }
- if fn.Cond != nil {
- fs.Cond, err = t.transformExpression(fn.Cond)
- if err != nil {
- return nil, err
+ if fn.Cond != nil {
+ fs.Cond, err = t.transformExpression(fn.Cond)
+ if err != nil {
+ return nil, err
+ }
}
- }
- if fn.Post != nil {
- fs.Post, err = t.transformInitPostStmt(fn.Post)
- if err != nil {
- return nil, err
+ if fn.Post != nil {
+ fs.Post, err = t.transformInitPostStmt(fn.Post)
+ if err != nil {
+ return nil, err
+ }
}
+ loop = fs
+ }
+ if fn.Label != nil {
+ return &goast.LabeledStmt{
+ Label: goast.NewIdent(string(fn.Label.ID)),
+ Stmt: loop,
+ }, nil
}
- return fs, nil
+ return loop, nil
}
diff --git a/forst/internal/transformer/go/function.go b/forst/internal/transformer/go/function.go
index eb4a5d4d..76e314a7 100644
--- a/forst/internal/transformer/go/function.go
+++ b/forst/internal/transformer/go/function.go
@@ -7,6 +7,48 @@ import (
goast "go/ast"
)
+func (t *Transformer) transformFunctionParamField(paramName string, paramType ast.TypeNode) (*goast.Field, error) {
+ var inferredTypes []ast.TypeNode
+ var err error
+
+ if paramType.Assertion != nil {
+ if paramType.Assertion.BaseType != nil && len(paramType.Assertion.Constraints) == 0 {
+ baseType := *paramType.Assertion.BaseType
+ baseTypeNode := ast.TypeNode{Ident: baseType}
+ if !baseTypeNode.IsHashBased() {
+ name, err := t.TypeChecker.GetAliasedTypeName(baseTypeNode, typechecker.GetAliasedTypeNameOptions{AllowStructuralAlias: true})
+ if err != nil {
+ return nil, fmt.Errorf("failed to get aliased type name for parameter %s: %w", paramName, err)
+ }
+ return &goast.Field{
+ Names: []*goast.Ident{goast.NewIdent(paramName)},
+ Type: goast.NewIdent(name),
+ }, nil
+ }
+ }
+ inferredTypes, err = t.TypeChecker.InferAssertionType(paramType.Assertion, false, "", nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to infer assertion type for parameter %s: %w", paramName, err)
+ }
+ } else {
+ inferredTypes = []ast.TypeNode{paramType}
+ }
+
+ if len(inferredTypes) == 0 {
+ return nil, fmt.Errorf("no inferred type found for parameter %s", paramName)
+ }
+
+ typeExpr, err := t.transformType(inferredTypes[0])
+ if err != nil {
+ return nil, fmt.Errorf("failed to transform type for parameter %s: %w", paramName, err)
+ }
+
+ return &goast.Field{
+ Names: []*goast.Ident{goast.NewIdent(paramName)},
+ Type: typeExpr,
+ }, nil
+}
+
func (t *Transformer) transformFunctionParams(params []ast.ParamNode) (*goast.FieldList, error) {
t.log.Debugf("transformFunctionParams: processing %d parameters", len(params))
@@ -15,71 +57,38 @@ func (t *Transformer) transformFunctionParams(params []ast.ParamNode) (*goast.Fi
}
for i, param := range params {
- var paramName string
- var paramType ast.TypeNode
-
switch p := param.(type) {
case ast.SimpleParamNode:
- paramName = string(p.Ident.ID)
- paramType = p.Type
+ t.log.Debugf("transformFunctionParams: param %d '%s' has type %q", i, p.Ident.ID, p.Type.Ident)
+ field, err := t.transformFunctionParamField(string(p.Ident.ID), p.Type)
+ if err != nil {
+ return nil, err
+ }
+ fields.List = append(fields.List, field)
case ast.DestructuredParamNode:
- // Handle destructured params if needed
- continue
- }
-
- // Add debug output for parameter type
- t.log.Debugf("transformFunctionParams: param %d '%s' has type %q", i, param.GetIdent(), paramType.Ident)
-
- // Look up the inferred type from the type checker
- var inferredTypes []ast.TypeNode
- var err error
-
- if paramType.Assertion != nil {
- // For assertion types, check if we should preserve the original type name
- // If the assertion has a base type that's a user-defined type AND no constraints, use that
- if paramType.Assertion.BaseType != nil && len(paramType.Assertion.Constraints) == 0 {
- baseType := *paramType.Assertion.BaseType
- // Check if the base type is a user-defined type (not a hash-based type)
- baseTypeNode := ast.TypeNode{Ident: baseType}
- if !baseTypeNode.IsHashBased() {
- // Use the original type name instead of inferring a hash-based type
- name, err := t.TypeChecker.GetAliasedTypeName(baseTypeNode, typechecker.GetAliasedTypeNameOptions{AllowStructuralAlias: true})
- if err != nil {
- return nil, fmt.Errorf("failed to get aliased type name for parameter %s: %w", paramName, err)
- }
- fields.List = append(fields.List, &goast.Field{
- Names: []*goast.Ident{goast.NewIdent(paramName)},
- Type: goast.NewIdent(name),
- })
- continue
- }
+ shapeFields, ok := t.TypeChecker.ShapeFieldsFromParamType(p.Type)
+ if !ok {
+ return nil, fmt.Errorf("destructured parameter has no shape fields in type %s", p.Type.Ident)
}
-
- // For other assertion types (with constraints), use the inferred type from the type checker
- inferredTypes, err = t.TypeChecker.InferAssertionType(paramType.Assertion, false, "", nil)
- if err != nil {
- return nil, fmt.Errorf("failed to infer assertion type for parameter %s: %w", paramName, err)
+ for _, fieldName := range p.Fields {
+ sf, ok := shapeFields[fieldName]
+ if !ok {
+ return nil, fmt.Errorf("destructured field %s not found in parameter type", fieldName)
+ }
+ fieldType, ok := typechecker.ShapeFieldTypeNode(sf)
+ if !ok {
+ return nil, fmt.Errorf("destructured field %s has no type", fieldName)
+ }
+ t.log.Debugf("transformFunctionParams: destructured field %s has type %q", fieldName, fieldType.Ident)
+ field, err := t.transformFunctionParamField(fieldName, fieldType)
+ if err != nil {
+ return nil, err
+ }
+ fields.List = append(fields.List, field)
}
- } else {
- // For non-assertion types, use the original type
- inferredTypes = []ast.TypeNode{paramType}
- }
-
- // Use the first inferred type (should be only one for parameters)
- if len(inferredTypes) == 0 {
- return nil, fmt.Errorf("no inferred type found for parameter %s", paramName)
- }
-
- actualType := inferredTypes[0]
- typeExpr, err := t.transformType(actualType)
- if err != nil {
- return nil, fmt.Errorf("failed to transform type for parameter %s: %w", paramName, err)
+ default:
+ return nil, fmt.Errorf("unsupported parameter type %T", param)
}
-
- fields.List = append(fields.List, &goast.Field{
- Names: []*goast.Ident{goast.NewIdent(paramName)},
- Type: typeExpr,
- })
}
return fields, nil
diff --git a/forst/internal/transformer/go/function_destructured_test.go b/forst/internal/transformer/go/function_destructured_test.go
new file mode 100644
index 00000000..2353bba9
--- /dev/null
+++ b/forst/internal/transformer/go/function_destructured_test.go
@@ -0,0 +1,119 @@
+package transformergo
+
+import (
+ "strings"
+ "testing"
+
+ "forst/internal/ast"
+ "forst/internal/parser"
+ "forst/internal/typechecker"
+ goast "go/ast"
+)
+
+func TestTransformFunction_destructuredParams(t *testing.T) {
+ t.Parallel()
+ src := `package main
+
+func sum(a Int, {x, y}: { x: Int, y: Int }): Int {
+ return x + y
+}
+`
+ log := ast.SetupTestLogger(nil)
+ p := parser.NewTestParser(src, log)
+ nodes, err := p.ParseFile()
+ if err != nil {
+ t.Fatalf("parse: %v", err)
+ }
+
+ tc := typechecker.New(log, false)
+ if err := tc.CheckTypes(nodes); err != nil {
+ t.Fatalf("typecheck: %v", err)
+ }
+
+ tr := New(tc, log)
+ var fn ast.FunctionNode
+ for _, n := range nodes {
+ if f, ok := n.(ast.FunctionNode); ok && f.Ident.ID == "sum" {
+ fn = f
+ break
+ }
+ }
+
+ decl, err := tr.transformFunction(fn)
+ if err != nil {
+ t.Fatalf("transform: %v", err)
+ }
+
+ paramNames := []string{}
+ for _, f := range decl.Type.Params.List {
+ for _, n := range f.Names {
+ paramNames = append(paramNames, n.Name)
+ }
+ }
+ if len(paramNames) != 3 {
+ t.Fatalf("expected 3 Go params, got %v", paramNames)
+ }
+ if paramNames[0] != "a" || paramNames[1] != "x" || paramNames[2] != "y" {
+ t.Fatalf("unexpected param names: %v", paramNames)
+ }
+}
+
+func TestTransformTypeGuard_typeLevelStub(t *testing.T) {
+ t.Parallel()
+ src := `package main
+
+type MutationArg = Shape
+
+is (m MutationArg) Input(input Shape) {
+ ensure m is { input }
+}
+`
+ log := ast.SetupTestLogger(nil)
+ p := parser.NewTestParser(src, log)
+ nodes, err := p.ParseFile()
+ if err != nil {
+ t.Fatalf("parse: %v", err)
+ }
+
+ tc := typechecker.New(log, false)
+ if err := tc.CheckTypes(nodes); err != nil {
+ t.Fatalf("typecheck: %v", err)
+ }
+
+ tr := New(tc, log)
+ var guard ast.TypeGuardNode
+ for _, n := range nodes {
+ if g, ok := n.(ast.TypeGuardNode); ok && g.GetIdent() == "Input" {
+ guard = g
+ break
+ }
+ if gp, ok := n.(*ast.TypeGuardNode); ok && gp != nil && gp.GetIdent() == "Input" {
+ guard = *gp
+ break
+ }
+ }
+ if guard.GetIdent() == "" {
+ t.Fatal("expected Input type guard")
+ }
+
+ decl, err := tr.transformTypeGuard(guard)
+ if err != nil {
+ t.Fatalf("transformTypeGuard: %v", err)
+ }
+ if decl == nil {
+ t.Fatal("expected type-level guard stub decl")
+ }
+ if !strings.HasPrefix(decl.Name.Name, "G_") {
+ t.Fatalf("expected G_ prefix, got %s", decl.Name.Name)
+ }
+ if len(decl.Body.List) != 1 {
+ t.Fatalf("expected stub body with single return, got %d stmts", len(decl.Body.List))
+ }
+ ret, ok := decl.Body.List[0].(*goast.ReturnStmt)
+ if !ok || len(ret.Results) != 1 {
+ t.Fatalf("expected return stmt, got %#v", decl.Body.List[0])
+ }
+ if ident, ok := ret.Results[0].(*goast.Ident); !ok || ident.Name != "true" {
+ t.Fatalf("expected return true, got %#v", ret.Results[0])
+ }
+}
diff --git a/forst/internal/transformer/go/pipeline_integration_test.go b/forst/internal/transformer/go/pipeline_integration_test.go
index 62001e05..7c923627 100644
--- a/forst/internal/transformer/go/pipeline_integration_test.go
+++ b/forst/internal/transformer/go/pipeline_integration_test.go
@@ -561,6 +561,29 @@ func main() {
}
}
+func TestEmitValidation_ifIsMinOnString(t *testing.T) {
+ src := `package main
+
+func checkLen(name String) {
+ if name is Min(1) {
+ println("ok")
+ } else {
+ println("short")
+ }
+}
+
+func main() {
+ checkLen("hi")
+}
+`
+ out := compileForstPipeline(t, src)
+ for _, sub := range []string{`func checkLen`, `len(`, `!`, `package main`} {
+ if !strings.Contains(out, sub) {
+ t.Fatalf("generated Go missing %q\n----\n%s\n----", sub, out)
+ }
+ }
+}
+
func TestEmitValidation_builtinLessThanOnInt(t *testing.T) {
src := `package main
diff --git a/forst/internal/transformer/go/shape_context_typeguard_test.go b/forst/internal/transformer/go/shape_context_typeguard_test.go
index 7c34a7d9..4ce98423 100644
--- a/forst/internal/transformer/go/shape_context_typeguard_test.go
+++ b/forst/internal/transformer/go/shape_context_typeguard_test.go
@@ -439,8 +439,11 @@ is (password Password) Strong(min Int) {
}
decl, err := tr.transformTypeGuard(guard)
- if err == nil {
- t.Fatalf("expected known if-is codegen limitation error, got decl=%#v", decl)
+ if err != nil {
+ t.Fatalf("transformTypeGuard: %v", err)
+ }
+ if decl == nil {
+ t.Fatal("expected non-nil type guard decl")
}
}
diff --git a/forst/internal/transformer/go/transformer.go b/forst/internal/transformer/go/transformer.go
index 2f454de7..7d7678c1 100644
--- a/forst/internal/transformer/go/transformer.go
+++ b/forst/internal/transformer/go/transformer.go
@@ -85,8 +85,14 @@ func (t *Transformer) TransformForstFileToGo(nodes []ast.Node) (*goast.File, err
return nil, err
}
- // Process all definitions first
- for _, def := range t.TypeChecker.Defs {
+ // Process all definitions first (sorted for deterministic emission)
+ typeNames := make([]ast.TypeIdent, 0, len(t.TypeChecker.Defs))
+ for name := range t.TypeChecker.Defs {
+ typeNames = append(typeNames, name)
+ }
+ sort.Slice(typeNames, func(i, j int) bool { return typeNames[i] < typeNames[j] })
+ for _, name := range typeNames {
+ def := t.TypeChecker.Defs[name]
switch def := def.(type) {
case ast.TypeDefNode:
t.log.WithFields(logrus.Fields{
diff --git a/forst/internal/transformer/go/transformer_test.go b/forst/internal/transformer/go/transformer_test.go
index 974ea6ba..0eedf7eb 100644
--- a/forst/internal/transformer/go/transformer_test.go
+++ b/forst/internal/transformer/go/transformer_test.go
@@ -18,7 +18,6 @@ import (
)
func TestDeterministicShapeGuardExample(t *testing.T) {
- t.Skip("Skipping deterministic shape guard example test – has to do with ordering of generated type definitions during typecheck")
// Load the shape guard example source
inputPath := filepath.Join("..", "..", "..", "..", "examples", "in", "rfc", "guard", "shape_guard.ft")
diff --git a/forst/internal/transformer/go/typeguard.go b/forst/internal/transformer/go/typeguard.go
index 328e28f2..3537f4a8 100644
--- a/forst/internal/transformer/go/typeguard.go
+++ b/forst/internal/transformer/go/typeguard.go
@@ -70,31 +70,50 @@ func (t *Transformer) transformTypeGuardParams(params []ast.ParamNode) (*goast.F
}
for _, param := range params {
- var paramName string
- var paramType ast.TypeNode
-
switch p := param.(type) {
case ast.SimpleParamNode:
- paramName = string(p.Ident.ID)
- paramType = p.Type
+ paramName := string(p.Ident.ID)
+ paramType := p.Type
+ var ident *goast.Ident
+ if t != nil {
+ name, err := t.TypeChecker.GetAliasedTypeName(paramType, typechecker.GetAliasedTypeNameOptions{AllowStructuralAlias: true})
+ if err != nil {
+ return nil, fmt.Errorf("failed to get type alias name: %s", err)
+ }
+ ident = goast.NewIdent(name)
+ } else {
+ ident = goast.NewIdent(string(paramType.Ident))
+ }
+ fields.List = append(fields.List, &goast.Field{
+ Names: []*goast.Ident{goast.NewIdent(paramName)},
+ Type: ident,
+ })
case ast.DestructuredParamNode:
- return nil, fmt.Errorf("DestructuredParamNode not supported in transformTypeGuardParams")
- }
-
- var ident *goast.Ident
- if t != nil {
- name, err := t.TypeChecker.GetAliasedTypeName(paramType, typechecker.GetAliasedTypeNameOptions{AllowStructuralAlias: true})
- if err != nil {
- return nil, fmt.Errorf("failed to get type alias name: %s", err)
+ shapeFields, ok := t.TypeChecker.ShapeFieldsFromParamType(p.Type)
+ if !ok {
+ return nil, fmt.Errorf("destructured type guard param has no shape fields in type %s", p.Type.Ident)
+ }
+ for _, fieldName := range p.Fields {
+ sf, ok := shapeFields[fieldName]
+ if !ok {
+ return nil, fmt.Errorf("destructured field %s not found in type guard param type", fieldName)
+ }
+ fieldType, ok := typechecker.ShapeFieldTypeNode(sf)
+ if !ok {
+ return nil, fmt.Errorf("destructured field %s has no type", fieldName)
+ }
+ typeExpr, err := t.transformType(fieldType)
+ if err != nil {
+ return nil, fmt.Errorf("failed to transform destructured field %s: %w", fieldName, err)
+ }
+ fields.List = append(fields.List, &goast.Field{
+ Names: []*goast.Ident{goast.NewIdent(fieldName)},
+ Type: typeExpr,
+ })
}
- ident = goast.NewIdent(name)
- } else {
- ident = goast.NewIdent(string(paramType.Ident))
+ default:
+ return nil, fmt.Errorf("unsupported type guard parameter type %T", param)
}
- fields.List = append(fields.List, &goast.Field{
- Names: []*goast.Ident{goast.NewIdent(paramName)},
- Type: ident,
- })
}
return fields, nil
@@ -120,8 +139,37 @@ func (t *Transformer) transformTypeGuard(guard ast.TypeGuardNode) (*goast.FuncDe
t.log.WithFields(logrus.Fields{
"guard": guard.Ident,
"function": "transformTypeGuard",
- }).Debug("Skipping function generation for type-level type guard")
- return nil, nil // Return nil to indicate no function should be generated
+ }).Debug("Emitting stub for type-level type guard")
+
+ subjectParam, err := t.transformTypeGuardParams([]ast.ParamNode{guard.Subject})
+ if err != nil {
+ return nil, fmt.Errorf("failed to transform subject parameter: %s", err)
+ }
+ additionalParams, err := t.transformTypeGuardParams(guard.Params)
+ if err != nil {
+ return nil, fmt.Errorf("failed to transform type guard parameters: %s", err)
+ }
+ params := append(subjectParam.List, additionalParams.List...)
+
+ return &goast.FuncDecl{
+ Name: goast.NewIdent(string(guardIdent)),
+ Doc: &goast.CommentGroup{
+ List: []*goast.Comment{
+ {Text: "// Type-level shape guard stub; `ensure m is { field }` is not lowered to runtime checks yet."},
+ },
+ },
+ Type: &goast.FuncType{
+ Params: &goast.FieldList{List: params},
+ Results: &goast.FieldList{
+ List: []*goast.Field{{Type: goast.NewIdent("bool")}},
+ },
+ },
+ Body: &goast.BlockStmt{
+ List: []goast.Stmt{
+ &goast.ReturnStmt{Results: []goast.Expr{goast.NewIdent("true")}},
+ },
+ },
+ }, nil
}
// Transform subject parameter
diff --git a/forst/internal/transformer/go/typeguard_test.go b/forst/internal/transformer/go/typeguard_test.go
index 43b37e23..79217399 100644
--- a/forst/internal/transformer/go/typeguard_test.go
+++ b/forst/internal/transformer/go/typeguard_test.go
@@ -51,17 +51,12 @@ func TestTransformEnsureConstraintWithIsConstraint(t *testing.T) {
transformer := New(tc, nil)
// Try to transform the ensure constraint
- // This should fail because "is" is not a built-in constraint
- _, err := transformer.transformEnsureConstraint(ensure, ensure.Assertion.Constraints[0], ast.TypeNode{Ident: ast.TypeIdent("MutationArg")})
-
- // This should fail with "no valid transformation found for constraint: is"
- if err == nil {
- t.Fatal("Expected error 'no valid transformation found for constraint: is', but got nil")
+ expr, err := transformer.transformEnsureConstraint(ensure, ensure.Assertion.Constraints[0], ast.TypeNode{Ident: ast.TypeIdent("MutationArg")})
+ if err != nil {
+ t.Fatalf("transformEnsureConstraint failed: %v", err)
}
-
- expectedError := "no valid transformation found for constraint: is"
- if err.Error() != expectedError {
- t.Errorf("Expected error '%s', but got '%s'", expectedError, err.Error())
+ if ident, ok := expr.(*goast.Ident); !ok || ident.Name != "true" {
+ t.Fatalf("expected true literal for is constraint, got %#v", expr)
}
}
diff --git a/forst/internal/typechecker/collect.go b/forst/internal/typechecker/collect.go
index 1bb18f3c..309c22e0 100644
--- a/forst/internal/typechecker/collect.go
+++ b/forst/internal/typechecker/collect.go
@@ -47,9 +47,8 @@ func (tc *TypeChecker) collectExplicitTypes(node ast.Node) error {
switch p := param.(type) {
case ast.SimpleParamNode:
tc.storeSymbol(p.Ident.ID, []ast.TypeNode{p.Type}, SymbolVariable)
- case ast.DestructuredParamNode:
- // TODO: Handle destructured params
- continue
+ case ast.DestructuredParamNode:
+ tc.registerDestructuredParamSymbols(p.Fields, p.Type, SymbolVariable)
}
}
@@ -83,13 +82,12 @@ func (tc *TypeChecker) collectExplicitTypes(node ast.Node) error {
"function": "collectExplicitTypes",
}).Debug("Storing symbol for simple param of type guard")
tc.storeSymbol(p.Ident.ID, []ast.TypeNode{p.Type}, SymbolParameter)
- case ast.DestructuredParamNode:
- // Handle destructured params if needed
- tc.log.WithFields(logrus.Fields{
- "node": p.String(),
- "function": "collectExplicitTypes",
- }).Warn("Destructured params are not supported for type guards yet")
- continue
+ case ast.DestructuredParamNode:
+ tc.registerDestructuredParamSymbols(p.Fields, p.Type, SymbolParameter)
+ tc.log.WithFields(logrus.Fields{
+ "node": p.String(),
+ "function": "collectExplicitTypes",
+ }).Debug("Registered destructured type guard param fields")
}
}
diff --git a/forst/internal/typechecker/infer.go b/forst/internal/typechecker/infer.go
index 4ee3c9bc..41ad2527 100644
--- a/forst/internal/typechecker/infer.go
+++ b/forst/internal/typechecker/infer.go
@@ -48,7 +48,14 @@ func (tc *TypeChecker) inferNodeType(node ast.Node) ([]ast.TypeNode, error) {
return []ast.TypeNode{n.Type}, nil
case ast.DestructuredParamNode:
- return nil, nil
+ if n.Type.Assertion != nil {
+ inferredType, err := tc.InferAssertionType(n.Type.Assertion, false, "", nil)
+ if err != nil {
+ return nil, err
+ }
+ return inferredType, nil
+ }
+ return []ast.TypeNode{n.Type}, nil
case ast.ExpressionNode:
tc.log.WithFields(logrus.Fields{
@@ -163,7 +170,10 @@ func (tc *TypeChecker) inferNodeType(node ast.Node) ([]ast.TypeNode, error) {
case *ast.BreakNode:
if n.Label != nil {
- return nil, fmt.Errorf("labeled break is not implemented yet")
+ if !tc.hasLoopLabel(n.Label.ID) {
+ return nil, fmt.Errorf("undefined label %q for break", n.Label.ID)
+ }
+ return nil, nil
}
if tc.loopDepth == 0 {
return nil, fmt.Errorf("break is not inside a loop")
@@ -171,7 +181,10 @@ func (tc *TypeChecker) inferNodeType(node ast.Node) ([]ast.TypeNode, error) {
return nil, nil
case *ast.ContinueNode:
if n.Label != nil {
- return nil, fmt.Errorf("labeled continue is not implemented yet")
+ if !tc.hasLoopLabel(n.Label.ID) {
+ return nil, fmt.Errorf("undefined label %q for continue", n.Label.ID)
+ }
+ return nil, nil
}
if tc.loopDepth == 0 {
return nil, fmt.Errorf("continue is not inside a loop")
diff --git a/forst/internal/typechecker/infer_assertion.go b/forst/internal/typechecker/infer_assertion.go
index a603b05e..3127ffc1 100644
--- a/forst/internal/typechecker/infer_assertion.go
+++ b/forst/internal/typechecker/infer_assertion.go
@@ -16,7 +16,7 @@ const ConstraintMatch = "Match"
func isBuiltinAssertionConstraintName(name string) bool {
switch name {
case "Min", "Max", "LessThan", "GreaterThan", "HasPrefix", "Contains",
- "True", "False", "Nil", "Present", "NotEmpty", "Valid", ast.ValueConstraint:
+ "True", "False", "Nil", "Present", "NotEmpty", ast.ValueConstraint:
return true
default:
return false
diff --git a/forst/internal/typechecker/infer_ensure_test.go b/forst/internal/typechecker/infer_ensure_test.go
index 00acd8f3..7f84d4b8 100644
--- a/forst/internal/typechecker/infer_ensure_test.go
+++ b/forst/internal/typechecker/infer_ensure_test.go
@@ -2,6 +2,7 @@ package typechecker
import (
"io"
+ "strings"
"testing"
"forst/internal/ast"
@@ -76,6 +77,29 @@ func TestInferEnsureType_validatesConstraintsLikeBinaryIs(t *testing.T) {
}
})
+ t.Run("Valid_reserved_placeholder", func(t *testing.T) {
+ tc := New(log, false)
+ fn := ast.FunctionNode{Ident: ast.Ident{ID: "f"}, Body: []ast.Node{}}
+ tc.scopeStack.pushScope(fn)
+ tc.CurrentScope().RegisterSymbol(ast.Identifier("s"), []ast.TypeNode{{Ident: ast.TypeString}}, SymbolVariable)
+
+ ensure := ast.EnsureNode{
+ Variable: ast.VariableNode{Ident: ast.Ident{ID: "s"}},
+ Assertion: ast.AssertionNode{
+ Constraints: []ast.ConstraintNode{
+ {Name: "Valid", Args: []ast.ConstraintArgumentNode{}},
+ },
+ },
+ }
+ _, err := tc.inferEnsureType(ensure)
+ if err == nil {
+ t.Fatal("expected error: Valid() is reserved")
+ }
+ if !strings.Contains(err.Error(), "Valid() is a reserved placeholder") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ })
+
t.Run("Present_allows_pointer", func(t *testing.T) {
tc := New(log, false)
fn := ast.FunctionNode{Ident: ast.Ident{ID: "f"}, Body: []ast.Node{}}
diff --git a/forst/internal/typechecker/infer_for.go b/forst/internal/typechecker/infer_for.go
index d124fb3c..faa3f5f3 100644
--- a/forst/internal/typechecker/infer_for.go
+++ b/forst/internal/typechecker/infer_for.go
@@ -31,6 +31,11 @@ func (tc *TypeChecker) inferForNode(n *ast.ForNode) ([]ast.TypeNode, error) {
}
}
+ if n.Label != nil {
+ tc.loopLabelStack = append(tc.loopLabelStack, n.Label.ID)
+ defer func() { tc.loopLabelStack = tc.loopLabelStack[:len(tc.loopLabelStack)-1] }()
+ }
+
tc.loopDepth++
for _, stmt := range n.Body {
if _, err := tc.inferNodeType(stmt); err != nil {
@@ -132,3 +137,12 @@ func (tc *TypeChecker) rangeTypesForTwoVars(t ast.TypeNode) (keyT, valT ast.Type
}
return ast.TypeNode{}, ast.TypeNode{}, fmt.Errorf("unsupported range over type %s (two-variable form)", t.Ident)
}
+
+func (tc *TypeChecker) hasLoopLabel(label ast.Identifier) bool {
+ for _, l := range tc.loopLabelStack {
+ if l == label {
+ return true
+ }
+ }
+ return false
+}
diff --git a/forst/internal/typechecker/infer_function_node.go b/forst/internal/typechecker/infer_function_node.go
index 8908adca..773d31b3 100644
--- a/forst/internal/typechecker/infer_function_node.go
+++ b/forst/internal/typechecker/infer_function_node.go
@@ -38,7 +38,7 @@ func (tc *TypeChecker) inferFunctionNode(functionNode ast.FunctionNode) ([]ast.T
[]ast.TypeNode{typedParam.Type},
SymbolVariable)
case ast.DestructuredParamNode:
- continue
+ tc.registerDestructuredParamSymbols(typedParam.Fields, typedParam.Type, SymbolVariable)
}
}
tc.DebugPrintCurrentScope()
@@ -61,13 +61,32 @@ func (tc *TypeChecker) inferFunctionNode(functionNode ast.FunctionNode) ([]ast.T
"function": "inferNodeType",
}).Trace("Storing param variable type")
- tc.scopeStack.currentScope().RegisterSymbol(
- ast.Identifier(param.GetIdent()),
- inferredParamTypes,
- SymbolVariable)
-
- if simpleParam, ok := param.(ast.SimpleParamNode); ok && len(inferredParamTypes) > 0 {
- tc.VariableTypes[simpleParam.Ident.ID] = append([]ast.TypeNode(nil), inferredParamTypes...)
+ switch p := param.(type) {
+ case ast.SimpleParamNode:
+ tc.scopeStack.currentScope().RegisterSymbol(
+ p.Ident.ID,
+ inferredParamTypes,
+ SymbolVariable)
+ if len(inferredParamTypes) > 0 {
+ tc.VariableTypes[p.Ident.ID] = append([]ast.TypeNode(nil), inferredParamTypes...)
+ }
+ case ast.DestructuredParamNode:
+ if shapeFields, ok := tc.ShapeFieldsFromParamType(p.Type); ok {
+ for _, fieldName := range p.Fields {
+ sf, ok := shapeFields[fieldName]
+ if !ok {
+ continue
+ }
+ if tn, ok := ShapeFieldTypeNode(sf); ok {
+ fieldTypes := []ast.TypeNode{tn}
+ tc.scopeStack.currentScope().RegisterSymbol(
+ ast.Identifier(fieldName),
+ fieldTypes,
+ SymbolVariable)
+ tc.VariableTypes[ast.Identifier(fieldName)] = append([]ast.TypeNode(nil), fieldTypes...)
+ }
+ }
+ }
}
}
diff --git a/forst/internal/typechecker/infer_node_dispatch_test.go b/forst/internal/typechecker/infer_node_dispatch_test.go
index 7e7772dd..139550b1 100644
--- a/forst/internal/typechecker/infer_node_dispatch_test.go
+++ b/forst/internal/typechecker/infer_node_dispatch_test.go
@@ -36,18 +36,27 @@ func TestInferNodeType_breakAndContinueLoopGuards(t *testing.T) {
}
}
-func TestInferNodeType_breakAndContinueLabeledNotImplemented(t *testing.T) {
+func TestInferNodeType_breakAndContinueLabeled(t *testing.T) {
tc := New(logrus.New(), false)
- tc.loopDepth = 1
- _, err := tc.inferNodeType(&ast.BreakNode{Label: &ast.Ident{ID: "outer"}})
- if err == nil || !strings.Contains(err.Error(), "labeled break is not implemented yet") {
- t.Fatalf("expected labeled break not implemented error, got %v", err)
+ outerFor := &ast.ForNode{
+ Label: &ast.Ident{ID: "outer"},
+ Body: []ast.Node{
+ &ast.ForNode{
+ Body: []ast.Node{
+ &ast.BreakNode{Label: &ast.Ident{ID: "outer"}},
+ &ast.ContinueNode{Label: &ast.Ident{ID: "outer"}},
+ },
+ },
+ },
+ }
+ if _, err := tc.inferNodeType(outerFor); err != nil {
+ t.Fatalf("labeled break/continue in nested loop: %v", err)
}
- _, err = tc.inferNodeType(&ast.ContinueNode{Label: &ast.Ident{ID: "outer"}})
- if err == nil || !strings.Contains(err.Error(), "labeled continue is not implemented yet") {
- t.Fatalf("expected labeled continue not implemented error, got %v", err)
+ _, err := tc.inferNodeType(&ast.BreakNode{Label: &ast.Ident{ID: "missing"}})
+ if err == nil || !strings.Contains(err.Error(), `undefined label "missing"`) {
+ t.Fatalf("expected undefined label error, got %v", err)
}
}
diff --git a/forst/internal/typechecker/infer_typeguard_node.go b/forst/internal/typechecker/infer_typeguard_node.go
index aa237e92..c4e4f512 100644
--- a/forst/internal/typechecker/infer_typeguard_node.go
+++ b/forst/internal/typechecker/infer_typeguard_node.go
@@ -24,7 +24,7 @@ func (tc *TypeChecker) inferTypeGuardNode(typeGuardNode ast.Node) ([]ast.TypeNod
SymbolVariable)
tc.VariableTypes[typedParam.Ident.ID] = parameterTypes
case ast.DestructuredParamNode:
- continue
+ tc.registerDestructuredParamSymbols(typedParam.Fields, typedParam.Type, SymbolVariable)
}
}
diff --git a/forst/internal/typechecker/param_shape.go b/forst/internal/typechecker/param_shape.go
new file mode 100644
index 00000000..44d340f4
--- /dev/null
+++ b/forst/internal/typechecker/param_shape.go
@@ -0,0 +1,88 @@
+package typechecker
+
+import (
+ "forst/internal/ast"
+)
+
+// ShapeFieldsFromParamType returns shape fields for a destructured parameter type
+// (inline `{ ... }`, typedef, or assertion chain such as AppMutation.Input({ ... })).
+func (tc *TypeChecker) ShapeFieldsFromParamType(typeNode ast.TypeNode) (map[string]ast.ShapeFieldNode, bool) {
+ if typeNode.Ident == ast.TypeShape && typeNode.Assertion != nil {
+ for _, c := range typeNode.Assertion.Constraints {
+ if c.Name == ConstraintMatch && len(c.Args) > 0 && c.Args[0].Shape != nil {
+ return c.Args[0].Shape.Fields, true
+ }
+ if c.Name == "Shape" && len(c.Args) > 0 && c.Args[0].Shape != nil {
+ return c.Args[0].Shape.Fields, true
+ }
+ }
+ }
+
+ if typeNode.Assertion != nil {
+ fields := tc.resolveShapeFieldsFromAssertion(typeNode.Assertion)
+ if len(fields) > 0 {
+ return fields, true
+ }
+ }
+
+ if def, ok := tc.Defs[typeNode.Ident]; ok {
+ if shape, ok := tc.getShapeFromTypeDef(def); ok {
+ return shape.Fields, true
+ }
+ if typeDef, ok := def.(ast.TypeDefNode); ok {
+ if ae, ok := typeDef.Expr.(ast.TypeDefAssertionExpr); ok && ae.Assertion != nil {
+ fields := tc.resolveShapeFieldsFromAssertion(ae.Assertion)
+ if len(fields) > 0 {
+ return fields, true
+ }
+ }
+ }
+ }
+
+ return nil, false
+}
+
+// ShapeFieldTypeNode extracts a TypeNode from a shape field definition.
+func ShapeFieldTypeNode(field ast.ShapeFieldNode) (ast.TypeNode, bool) {
+ if field.Type != nil {
+ return *field.Type, true
+ }
+ if field.Assertion != nil {
+ return ast.TypeNode{
+ Ident: ast.TypeAssertion,
+ Assertion: field.Assertion,
+ }, true
+ }
+ if field.Shape != nil {
+ baseType := ast.TypeIdent(ast.TypeShape)
+ return ast.TypeNode{
+ Ident: ast.TypeShape,
+ Assertion: &ast.AssertionNode{
+ BaseType: &baseType,
+ Constraints: []ast.ConstraintNode{{
+ Name: ConstraintMatch,
+ Args: []ast.ConstraintArgumentNode{{
+ Shape: field.Shape,
+ }},
+ }},
+ },
+ }, true
+ }
+ return ast.TypeNode{}, false
+}
+
+func (tc *TypeChecker) registerDestructuredParamSymbols(fields []string, paramType ast.TypeNode, symbolKind SymbolKind) {
+ shapeFields, ok := tc.ShapeFieldsFromParamType(paramType)
+ if !ok {
+ return
+ }
+ for _, name := range fields {
+ sf, ok := shapeFields[name]
+ if !ok {
+ continue
+ }
+ if tn, ok := ShapeFieldTypeNode(sf); ok {
+ tc.storeSymbol(ast.Identifier(name), []ast.TypeNode{tn}, symbolKind)
+ }
+ }
+}
diff --git a/forst/internal/typechecker/param_shape_test.go b/forst/internal/typechecker/param_shape_test.go
new file mode 100644
index 00000000..95f63519
--- /dev/null
+++ b/forst/internal/typechecker/param_shape_test.go
@@ -0,0 +1,60 @@
+package typechecker
+
+import (
+ "testing"
+
+ "forst/internal/ast"
+ "forst/internal/parser"
+)
+
+func TestDestructuredParam_endToEnd(t *testing.T) {
+ t.Parallel()
+ src := `package main
+
+func sum({a, b}: { a: Int, b: Int }): Int {
+ return a + b
+}
+`
+ log := ast.SetupTestLogger(nil)
+ p := parser.NewTestParser(src, log)
+ nodes, err := p.ParseFile()
+ if err != nil {
+ t.Fatalf("parse: %v", err)
+ }
+
+ tc := New(log, false)
+ if err := tc.CheckTypes(nodes); err != nil {
+ t.Fatalf("typecheck: %v", err)
+ }
+
+ sig, ok := tc.Functions[ast.Identifier("sum")]
+ if !ok || len(sig.Parameters) != 1 {
+ t.Fatalf("expected one parameter signature for sum, got %v", sig.Parameters)
+ }
+}
+
+func TestShapeFieldsFromParamType_inlineShape(t *testing.T) {
+ t.Parallel()
+ tc := New(nil, false)
+ baseType := ast.TypeIdent(ast.TypeShape)
+ typeNode := ast.TypeNode{
+ Ident: ast.TypeShape,
+ Assertion: &ast.AssertionNode{
+ BaseType: &baseType,
+ Constraints: []ast.ConstraintNode{{
+ Name: ConstraintMatch,
+ Args: []ast.ConstraintArgumentNode{{
+ Shape: &ast.ShapeNode{
+ Fields: map[string]ast.ShapeFieldNode{
+ "x": {Type: &ast.TypeNode{Ident: ast.TypeInt}},
+ },
+ },
+ }},
+ }},
+ },
+ }
+ fields, ok := tc.ShapeFieldsFromParamType(typeNode)
+ if !ok || len(fields) != 1 {
+ t.Fatalf("ShapeFieldsFromParamType: ok=%v fields=%d", ok, len(fields))
+ }
+}
diff --git a/forst/internal/typechecker/receiver_methods.go b/forst/internal/typechecker/receiver_methods.go
index a2fe7b15..918e92d7 100644
--- a/forst/internal/typechecker/receiver_methods.go
+++ b/forst/internal/typechecker/receiver_methods.go
@@ -42,7 +42,10 @@ func (tc *TypeChecker) registerTypeMethod(recvType ast.TypeIdent, methodName str
case ast.SimpleParamNode:
params[i] = ParameterSignature{Ident: p.Ident, Type: p.Type}
case ast.DestructuredParamNode:
- continue
+ params[i] = ParameterSignature{
+ Ident: ast.Ident{ID: ast.Identifier(p.GetIdent())},
+ Type: p.Type,
+ }
}
}
diff --git a/forst/internal/typechecker/register.go b/forst/internal/typechecker/register.go
index 88f99574..fd36b17a 100644
--- a/forst/internal/typechecker/register.go
+++ b/forst/internal/typechecker/register.go
@@ -176,8 +176,10 @@ func (tc *TypeChecker) registerFunction(fn ast.FunctionNode) {
Type: p.Type,
}
case ast.DestructuredParamNode:
- // TODO: Handle destructured params
- continue
+ params[i] = ParameterSignature{
+ Ident: ast.Ident{ID: ast.Identifier(p.GetIdent())},
+ Type: p.Type,
+ }
}
}
@@ -204,8 +206,7 @@ func (tc *TypeChecker) registerFunction(fn ast.FunctionNode) {
case ast.SimpleParamNode:
tc.storeSymbol(p.Ident.ID, []ast.TypeNode{p.Type}, SymbolParameter)
case ast.DestructuredParamNode:
- // Handle destructured params if needed
- continue
+ tc.registerDestructuredParamSymbols(p.Fields, p.Type, SymbolParameter)
}
}
}
@@ -237,8 +238,7 @@ func (tc *TypeChecker) registerTypeGuard(guard *ast.TypeGuardNode) {
SymbolParameter,
)
case ast.DestructuredParamNode:
- // Handle destructured params if needed
- continue
+ tc.registerDestructuredParamSymbols(p.Fields, p.Type, SymbolParameter)
}
}
}
diff --git a/forst/internal/typechecker/shape_type.go b/forst/internal/typechecker/shape_type.go
new file mode 100644
index 00000000..a950ba9c
--- /dev/null
+++ b/forst/internal/typechecker/shape_type.go
@@ -0,0 +1,28 @@
+package typechecker
+
+import "forst/internal/ast"
+
+// IsShapeType reports whether t is Shape or resolves to a shape typedef/alias.
+func (tc *TypeChecker) IsShapeType(t ast.TypeNode) bool {
+ if t.Ident == ast.TypeShape {
+ return true
+ }
+ if def, ok := tc.Defs[t.Ident]; ok {
+ if td, ok := def.(ast.TypeDefNode); ok {
+ if _, ok := td.Expr.(ast.TypeDefShapeExpr); ok {
+ return true
+ }
+ if ae, ok := td.Expr.(ast.TypeDefAssertionExpr); ok && ae.Assertion != nil && ae.Assertion.BaseType != nil {
+ if *ae.Assertion.BaseType == ast.TypeShape {
+ return true
+ }
+ }
+ }
+ }
+ for _, link := range tc.GetTypeAliasChain(t) {
+ if link.Ident == ast.TypeShape {
+ return true
+ }
+ }
+ return false
+}
diff --git a/forst/internal/typechecker/shape_type_test.go b/forst/internal/typechecker/shape_type_test.go
new file mode 100644
index 00000000..583f2a68
--- /dev/null
+++ b/forst/internal/typechecker/shape_type_test.go
@@ -0,0 +1,44 @@
+package typechecker
+
+import (
+ "testing"
+
+ "forst/internal/ast"
+)
+
+func TestIsShapeType(t *testing.T) {
+ tc := New(nil, false)
+
+ if !tc.IsShapeType(ast.TypeNode{Ident: ast.TypeShape}) {
+ t.Fatal("Shape builtin should be a shape type")
+ }
+ if tc.IsShapeType(ast.TypeNode{Ident: ast.TypeString}) {
+ t.Fatal("String should not be a shape type")
+ }
+
+ tc.Defs["User"] = ast.TypeDefNode{
+ Ident: "User",
+ Expr: ast.TypeDefShapeExpr{
+ Shape: ast.ShapeNode{
+ Fields: map[string]ast.ShapeFieldNode{
+ "id": {Type: &ast.TypeNode{Ident: ast.TypeString}},
+ },
+ },
+ },
+ }
+ if !tc.IsShapeType(ast.TypeNode{Ident: "User"}) {
+ t.Fatal("typedef to shape literal should be a shape type")
+ }
+
+ tc.Defs["AppContext"] = ast.TypeDefNode{
+ Ident: "AppContext",
+ Expr: ast.TypeDefAssertionExpr{
+ Assertion: &ast.AssertionNode{
+ BaseType: typeIdentPtr(string(ast.TypeShape)),
+ },
+ },
+ }
+ if !tc.IsShapeType(ast.TypeNode{Ident: "AppContext"}) {
+ t.Fatal("typedef alias to Shape should be a shape type")
+ }
+}
diff --git a/forst/internal/typechecker/typechecker.go b/forst/internal/typechecker/typechecker.go
index 57e93c0c..f066961c 100644
--- a/forst/internal/typechecker/typechecker.go
+++ b/forst/internal/typechecker/typechecker.go
@@ -59,6 +59,8 @@ type TypeChecker struct {
reportPhases bool
// loopDepth counts nested for-loop bodies for break/continue validation
loopDepth int
+ // loopLabelStack records labels of nested for-loops (innermost last) for labeled break/continue
+ loopLabelStack []ast.Identifier
// ifChainNarrowingStack records per-if-chain narrowing events (`x is …`) for merge/join (narrow_if.go).
ifChainNarrowingStack [][]narrowingEvent
// currentFunction is set while inferring a function body (Ok/Err need Result(S,F) from the signature).
diff --git a/forst/internal/typechecker/unify_typeguard.go b/forst/internal/typechecker/unify_typeguard.go
index 13d6e007..c28a9283 100644
--- a/forst/internal/typechecker/unify_typeguard.go
+++ b/forst/internal/typechecker/unify_typeguard.go
@@ -162,6 +162,9 @@ func (tc *TypeChecker) validateAssertionNode(assertionNode ast.AssertionNode, va
}
}
for _, constraint := range assertionNode.Constraints {
+ if constraint.Name == "Valid" {
+ return fmt.Errorf("Valid() is a reserved placeholder; use explicit constraints or type guards")
+ }
if constraint.Name == "Present" {
// Check if left type is a pointer type
if varLeftType.Ident != ast.TypePointer {