From 28a3ea09d282511c7d444e46b4deaea1047527a2 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Tue, 6 Oct 2026 15:49:25 +0600 Subject: [PATCH 1/4] Preserve checked constants and exact array indexes Fold numeric constants at checked source widths before destination conversion, and materialize statics using declared runtime types. Read qualified constants from defining-module publication. Keep exact fixed-array bounds and located diagnostics, reject negative runtime indexes, and normalize validated constant GEP offsets through existing casts. Remove obsolete reverse ABI state and redundant locals. Reuse test setup and evidence assertions; add numeric, imported-index, wide-bounds and discard source fixtures. Validate both target widths and the rebuilt executable-fixture suite. Refs #154, #155 --- docs/architecture/ir.md | 15 +- docs/architecture/semantics-bindings.md | 19 +- internal/backend/llvm/emitter_test.go | 144 +++++ internal/backend/llvm/instruction_emit.go | 14 +- internal/backend/llvm/string_slice_emit.go | 6 +- internal/ir/mir/module_lower.go | 58 +- internal/ir/mir/module_lower_test.go | 51 +- internal/ir/types.go | 24 +- internal/ir/types_test.go | 8 +- internal/pipeline/pipeline_test.go | 557 ++++++++++++++++-- internal/semantics/typechecker/check_expr.go | 12 +- .../semantics/typechecker/constant_eval.go | 86 ++- .../typechecker/constant_eval_test.go | 206 +++++-- .../semantics/typechecker/typechecker_test.go | 46 ++ .../peeper.toml | 8 + .../src/main.peep | 5 + .../peeper.toml | 7 + .../src/main.peep | 23 + .../src/values.peep | 2 + .../peeper.toml | 7 + .../src/main.peep | 5 + .../peeper.toml | 7 + .../src/main.peep | 10 + .../positive_large_array_bounds/peeper.toml | 7 + .../positive_large_array_bounds/src/main.peep | 8 + .../peeper.toml | 6 + .../src/indexes.peep | 4 + .../src/main.peep | 38 ++ .../runtime_constant_conversions/peeper.toml | 6 + .../src/main.peep | 38 ++ .../src/numbers.peep | 14 + .../peeper.toml | 6 + .../src/main.peep | 6 + 33 files changed, 1286 insertions(+), 167 deletions(-) create mode 100644 x_test/negative_host_sized_array_bounds/peeper.toml create mode 100644 x_test/negative_host_sized_array_bounds/src/main.peep create mode 100644 x_test/negative_imported_constant_queries/peeper.toml create mode 100644 x_test/negative_imported_constant_queries/src/main.peep create mode 100644 x_test/negative_imported_constant_queries/src/values.peep create mode 100644 x_test/negative_module_constant_materialization/peeper.toml create mode 100644 x_test/negative_module_constant_materialization/src/main.peep create mode 100644 x_test/negative_wide_constant_indexes/peeper.toml create mode 100644 x_test/negative_wide_constant_indexes/src/main.peep create mode 100644 x_test/positive_large_array_bounds/peeper.toml create mode 100644 x_test/positive_large_array_bounds/src/main.peep create mode 100644 x_test/runtime_checked_constant_indexes/peeper.toml create mode 100644 x_test/runtime_checked_constant_indexes/src/indexes.peep create mode 100644 x_test/runtime_checked_constant_indexes/src/main.peep create mode 100644 x_test/runtime_constant_conversions/peeper.toml create mode 100644 x_test/runtime_constant_conversions/src/main.peep create mode 100644 x_test/runtime_constant_conversions/src/numbers.peep create mode 100644 x_test/runtime_module_discard_constants/peeper.toml create mode 100644 x_test/runtime_module_discard_constants/src/main.peep diff --git a/docs/architecture/ir.md b/docs/architecture/ir.md index 55872136..a16e8248 100644 --- a/docs/architecture/ir.md +++ b/docs/architecture/ir.md @@ -130,7 +130,20 @@ rebuilt: - expression trees lower through `exprlower`, then existing shared-IR-to-MIR logic. External functions and body-backed functions share THIR signature lowering. -Published module constants become MIR static data before function lowering. +Published module constants become MIR static data before function lowering, using +the declared semantic type's runtime representation. Lowering checks published +value ABI identity against that type, including nominal enum identity. Missing +values produce a compile-time evaluation diagnostic; mismatched published types +produce an invalid-evidence diagnostic. Neither failure emits partial MIR. +Discard (`_`) module bindings remain source-checked but emit no addressable static. + +LLVM fixed-array index lowering retains unsigned 64-bit lengths and compares +constant indexes exactly; backend bounds diagnostics retain index source location. +After validation, constant offsets use existing signed/unsigned cast rules to i64 +for GEP; narrow unsigned values must not become negative through LLVM's implicit +sign extension. Checked constant-index types/values remain intact in THIR/MIR. +Runtime indexes continue through the shared bounds guard before element access; +signed indexes also reject negative values independently of unsigned length. ## Validation boundaries diff --git a/docs/architecture/semantics-bindings.md b/docs/architecture/semantics-bindings.md index bc13e4f3..dbe71679 100644 --- a/docs/architecture/semantics-bindings.md +++ b/docs/architecture/semantics-bindings.md @@ -448,11 +448,24 @@ are `types.go`, `syntax.go`, `relations.go`, `compatibility.go`, `lookup.go`, - Evaluator cache and in-progress state are keyed by `symbols.SymbolID` and discarded after checking. - A module constant read from another module uses `ctx.PublishedConstant`. - Local module queries use the operation-local cache; authoritative values become visible through `SymbolIndex.ConstantValue` only during finalization. -- Expected numeric types influence literal construction and identifier adaptation. +- Expected numeric types influence literal construction and bound-symbol adaptation. +- Fixed-array bounds queries use the checked index type after literal inference; + constant-index evidence preserves that value and type for THIR/MIR lowering. +- Constant bounds compare exact integers, without host-sized length/index parsing. +- Checked numeric conversions run after folding at the source expression's width; + binary operands use the checked operation type, or the unconverted operand's + type for comparisons. Untyped lazy queries retain common-type fallback. +- Explicit numeric casts preserve source-width intermediates before converting. + Float narrowing rounds before any later widening; nonfinite or out-of-range + float-to-integer values are not published as wrapping integers. +- Integer-to-float casts round the exact integer directly to the destination + width, preserving IEEE infinity when the conversion overflows. - Numeric literals use default or explicit numeric types and target-aware parsing. - Boolean and string literals produce typed constant values. -- Constant identifiers consume their resolver-bound `SymbolIndex` entry and must - denote `SymbolConst`; evaluation never repeats lexical name lookup. +- Identifiers and qualified constant paths consume their resolver-bound `SymbolIndex` + entry and must denote `SymbolConst`; evaluation never repeats name/import lookup. + Foreign values come only from the defining module's finalized publication, not + consumer caches or a fresh evaluation of the foreign declaration. - Unary and binary operations delegate folding to `constvalue`. - Variant constructions use private typechecker evidence before THIR publication. - Non-copyable variant types are not evaluated as constants. diff --git a/internal/backend/llvm/emitter_test.go b/internal/backend/llvm/emitter_test.go index c33b1e91..14334c14 100644 --- a/internal/backend/llvm/emitter_test.go +++ b/internal/backend/llvm/emitter_test.go @@ -7,6 +7,7 @@ import ( "os/exec" "path" "path/filepath" + "runtime" "strings" "testing" "time" @@ -2914,6 +2915,57 @@ func TestGenerateLLVMIRLowersIndexPlaceForArrayRead(t *testing.T) { } } +func TestGenerateLLVMIRNormalizesConstantArrayIndexes(t *testing.T) { + u1 := llvmTypes.table.Intern(ir.Type{Kind: ir.TypeInteger, Bits: 1}) + for _, compilerTarget := range []target.Info{testLinux386, testLinuxAMD64} { + for _, test := range []struct { + name string + indexType ir.TypeID + value, length, conversion string + }{ + {"u1_zero", u1, "0", "2", "zext i1 0 to i64"}, + {"u1_one", u1, "1", "2", "zext i1 1 to i64"}, + {"u8_top_bit", llvmTypes.u8, "128", "129", "zext i8 128 to i64"}, + {"u24_top_bit", llvmTypes.table.Intern(ir.Type{Kind: ir.TypeInteger, Bits: 24}), "8388608", "8388609", "zext i24 8388608 to i64"}, + {"byte_top_bit", llvmTypes.table.Intern(ir.Type{Kind: ir.TypeByte}), "128", "129", "zext i8 128 to i64"}, + {"signed_i8", llvmTypes.i8, "1", "2", "sext i8 1 to i64"}, + {"u64", llvmTypes.usize, "1", "2", ""}, + {"i128", llvmTypes.table.Intern(ir.Type{Kind: ir.TypeInteger, IsSigned: true, Bits: 128}), "1", "2", "trunc i128 1 to i64"}, + {"u128", llvmTypes.u128, "1", "2", "trunc i128 1 to i64"}, + } { + for _, operation := range []struct { + name string + build func(ir.TypeID, mir.ValueRef) *mir.Module + }{{"read", indexReadMIRModule}, {"store", indexStoreMIRModule}} { + t.Run(compilerTarget.Arch+"/"+test.name+"/"+operation.name, func(t *testing.T) { + array := llvmTypes.table.Intern(ir.Type{Kind: ir.TypeArray, Elem: llvmTypes.i32, Length: test.length}) + base := llvmTypes.table.Intern(ir.Type{Kind: ir.TypeReference, Elem: array, IsMutable: true}) + diag := diagnostics.NewDiagnosticBag() + text := GenerateLLVMIR(operation.build(base, &mir.RefConst{Value: test.value, Type: test.indexType}), diag, compilerTarget, false) + if diag.HasErrors() || text == "" { + t.Fatalf("valid constant index failed:\n%s", diag.EmitAllToString()) + } + if test.conversion != "" && !strings.Contains(text, test.conversion) { + t.Fatalf("missing value-preserving conversion %q:\n%s", test.conversion, text) + } + projections := 0 + for _, line := range strings.Split(text, "\n") { + if strings.Contains(line, "getelementptr inbounds ["+test.length+" x i32]") { + projections++ + if !strings.Contains(line, ", i32 0, i64 ") { + t.Fatalf("constant element GEP did not use normalized index:\n%s", text) + } + } + } + if projections != 1 || strings.Contains(text, "call void @llvm.trap()") { + t.Fatalf("constant index must have one GEP and no runtime bounds trap:\n%s", text) + } + }) + } + } + } +} + func TestGenerateLLVMIRBoundsChecksRuntimeFixedArrayIndex(t *testing.T) { const targetTriple = "x86_64-unknown-linux-gnu" mod := indexReadMIRModule(llvmTypes.fixed4I32, &mir.RefName{Name: "index", Type: llvmTypes.i32}) @@ -3017,6 +3069,98 @@ func TestGenerateLLVMIRRejectsInvalidConstantArrayIndexes(t *testing.T) { } } +func TestGenerateLLVMIRChecksUnsignedArrayBounds(t *testing.T) { + large := llvmTypes.table.Intern(ir.Type{Kind: ir.TypeArray, Elem: llvmTypes.i32, Length: "9223372036854775808"}) + for _, index := range []struct { + value string + rejected bool + }{ + {"0", false}, {"9223372036854775807", false}, + {"-1", true}, {"9223372036854775808", true}, {"18446744073709551616", true}, {"bad", true}, + } { + t.Run(index.value, func(t *testing.T) { + location := source.NewLocation(unixTestPath, source.Position{Line: 2, Column: 4}, source.Position{Line: 2, Column: 8}) + diag := diagnostics.NewDiagnosticBag() + text := GenerateLLVMIR(indexReadMIRModule(large, &mir.RefConst{ + Value: index.value, Type: llvmTypes.u128, Location: location, + }), diag, testLinuxAMD64, false) + if !index.rejected { + if diag.HasErrors() || text == "" { + t.Fatalf("valid unsigned bounds rejected:\n%s", diag.EmitAllToString()) + } + return + } + items := diag.Diagnostics() + if text != "" || len(items) != 1 || items[0].Code != diagnostics.ErrArrayOutOfBounds || + len(items[0].Labels) != 1 || items[0].Labels[0].Location != location { + t.Fatalf("expected located backend bounds rejection:\n%s", diag.EmitAllToString()) + } + }) + } +} + +func TestBoundsCheckedIndexHandlesUnsignedLengths(t *testing.T) { + if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" { + t.Skip("native bounds execution requires Linux/amd64") + } + clang, err := exec.LookPath("clang") + if err != nil { + t.Skip("clang unavailable for native bounds validation") + } + types := newLLVMTypeFixture(target.Bits64) + i64 := types.table.Intern(ir.Type{Kind: ir.TypeInteger, IsSigned: true, Bits: 64}) + i128 := types.table.Intern(ir.Type{Kind: ir.TypeInteger, IsSigned: true, Bits: 128}) + for _, test := range []struct { + name string + indexType ir.TypeID + value string + trap bool + }{ + {"negative_i8", types.i8, "-2", true}, + {"negative_i64", i64, "-9223372036854775808", true}, + {"negative_i128", i128, "-2", true}, + {"positive_i64", i64, "9223372036854775807", false}, + {"unsigned_high", types.usize, "9223372036854775808", false}, + {"last_unsigned", types.usize, "18446744073709551614", false}, + {"equal_unsigned", types.usize, "18446744073709551615", true}, + {"wide_unsigned", types.u128, "18446744073709551616", true}, + } { + t.Run(test.name, func(t *testing.T) { + var body strings.Builder + emitter := &llvmEmitter{mod: &mir.Module{Types: types.table}, target: testLinuxAMD64} + builder := newLLVMBuilder(&body, emitter, -1) + indexLayout := emitter.layout(test.indexType) + builder.locals["index"] = builder.value("%index", indexLayout) + builder.namedLabel("entry") + if _, ok := emitBoundsCheckedIndex(builder, &mir.RefName{Name: "index", Type: test.indexType}, + builder.value("18446744073709551615", llvmScalarLayout("i64"))); !ok || emitter.isInvalid { + t.Fatal("bounds lowering failed") + } + builder.line("ret i32 0") + text := fmt.Sprintf("declare void @llvm.trap()\ndefine i32 @check(%s %%index) {\n%s}\n"+ + "define i32 @main() {\n %%result = call i32 @check(%s %s)\n ret i32 %%result\n}\n", + indexLayout.Text, body.String(), indexLayout.Text, test.value) + dir := t.TempDir() + binary := filepath.Join(dir, "bounds") + cmd := exec.Command(clang, "-target", testLinuxAMD64.LLVMTriple, "-x", "ir", "-o", binary, "-") + cmd.Stdin = strings.NewReader(text) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("bounds LLVM is invalid: %v\n%s\n%s", err, output, text) + } + run := exec.Command(binary) + run.Dir = dir + output, err := run.CombinedOutput() + if test.trap { + if run.ProcessState == nil || run.ProcessState.ExitCode() != -1 { + t.Fatalf("expected bounds trap, got %v\n%s\n%s", err, output, text) + } + } else if err != nil { + t.Fatalf("valid index trapped: %v\n%s\n%s", err, output, text) + } + }) + } +} + func TestGenerateLLVMIRLowersDynamicArrayIndexRead(t *testing.T) { const targetTriple = "x86_64-unknown-linux-gnu" irText := GenerateLLVMIR(indexReadMIRModule(llvmTypes.dynamicI32, &mir.RefName{Name: "i", Type: llvmTypes.i32}), diagnostics.NewDiagnosticBag(), testLinuxAMD64, false) diff --git a/internal/backend/llvm/instruction_emit.go b/internal/backend/llvm/instruction_emit.go index fb893e86..4e5de72d 100644 --- a/internal/backend/llvm/instruction_emit.go +++ b/internal/backend/llvm/instruction_emit.go @@ -107,8 +107,6 @@ func emitPrint(b *llvmBuilder, printInstr *mir.Print) { } } -// emitTargetIndexAsI64 widens a target-sized length before it reaches lowering -// paths whose arithmetic and comparisons are intentionally i64. func emitIndexPtr(b *llvmBuilder, base llvmValue, baseType ir.TypeID, addressed bool, indexRef mir.ValueRef) (llvmPlace, bool) { if b == nil || base.Layout == nil || baseType == ir.InvalidType || indexRef == nil { return llvmPlace{}, false @@ -143,18 +141,20 @@ func emitIndexPtr(b *llvmBuilder, base llvmValue, baseType ir.TypeID, addressed } return b.gep(b.pointerPlace(data), index, false), true } - length, lengthErr := strconv.Atoi(target.Length) + length, lengthErr := strconv.ParseUint(target.Length, 10, 64) var index llvmValue if indexConst, isConstant := indexRef.(*mir.RefConst); isConstant { - parsedIndex, indexErr := strconv.Atoi(indexConst.Value) - if lengthErr != nil || indexErr != nil || parsedIndex < 0 || parsedIndex >= length { + parsedIndex, indexOK := new(big.Int).SetString(indexConst.Value, 10) + if lengthErr != nil || !indexOK || parsedIndex.Sign() < 0 || parsedIndex.Cmp(new(big.Int).SetUint64(length)) >= 0 { b.emitter.isInvalid = true if b.emitter.diag != nil { - b.emitter.diag.Add(problems.ArrayIndexOutOfBounds(indexConst.Value, target.Length, nil)) + b.emitter.diag.Add(problems.ArrayIndexOutOfBounds(indexConst.Value, target.Length, indexConst.SourceLocation())) } return llvmPlace{}, false } - index = emitRef(b, indexRef) + // GEP (getelementptr) sign-extends narrow indexes even when the source integer is unsigned. + u64 := b.emitter.mod.Types.Intern(ir.Type{Kind: ir.TypeInteger, Bits: 64}) + index = emitCast(b, &mir.Cast{Arg: indexRef, Type: u64}) } else { if lengthErr != nil { return llvmPlace{}, false diff --git a/internal/backend/llvm/string_slice_emit.go b/internal/backend/llvm/string_slice_emit.go index 4b2e0a57..44a895e5 100644 --- a/internal/backend/llvm/string_slice_emit.go +++ b/internal/backend/llvm/string_slice_emit.go @@ -48,8 +48,12 @@ func emitBoundsCheckedIndex(b *llvmBuilder, indexRef mir.ValueRef, length llvmVa if !ok { return llvmValue{}, false } - // Unsigned comparison also rejects negative signed indexes after sign extension. outOfBounds := b.compare("icmp", "uge", compareIndex, compareLength) + // Unsigned lengths can exceed the signed index range. + if !isUnsignedTypeID(b.emitter.mod.Types, indexRef.TypeID()) { + negative := b.compare("icmp", "slt", compareIndex, b.value("0", compareIndex.Layout)) + outOfBounds = b.arithmetic("or", outOfBounds, negative) + } boundsID := b.nextID b.nextID++ failLabel := fmt.Sprintf("bounds_fail_%d", boundsID) diff --git a/internal/ir/mir/module_lower.go b/internal/ir/mir/module_lower.go index fe6086a5..78d4bc0a 100644 --- a/internal/ir/mir/module_lower.go +++ b/internal/ir/mir/module_lower.go @@ -64,28 +64,46 @@ func GenerateMIR(input LoweringInput) *Module { Funcs: make([]*Function, 0, len(input.Source.Functions)), } - // All semantic constant types must be interned before ABI-key lookup below. if input.Scope != nil { - for _, symbol := range input.Scope.Symbols() { - if symbol != nil && symbol.Kind == symbols.SymbolConst { - semanticType, _ := symbols.GetSymbolType(symbol) - if typelower.Type(input.Types, input.Diagnostics, semanticType) == ir.InvalidType { - return nil - } - } - } for _, symbol := range input.Scope.Symbols() { if symbol == nil || symbol.Kind != symbols.SymbolConst { continue } + // Discard bindings are checked, but have no addressable static value. + if symbol.Name == "_" { + continue + } + semanticType, _ := symbols.GetSymbolType(symbol) + typ := typelower.Type(input.Types, input.Diagnostics, semanticType) + if typ == ir.InvalidType { + return nil + } var value constvalue.Value if input.SymbolIndex != nil { value = input.SymbolIndex.ConstantValue(symbol.ID) } - internConstantStrings(out, value) - if entry, ok := staticEntryForConst(input.Types, symbol, value); ok { - out.StaticData = append(out.StaticData, entry) + if value == nil { + if input.Diagnostics != nil { + input.Diagnostics.AddError(diagnostics.ErrInvalidOperation, + "module constant `"+symbol.Name+"` cannot be evaluated at compile time", symbol.Location, "") + } + return nil + } + abiKey := value.TypeText() + if variant, ok := value.(*constvalue.VariantConst); ok && variant != nil && variant.NominalIdentity() != "" { + abiKey = "variant:" + variant.NominalIdentity() } + if abiKey != input.Types.ABIKey(typ) { + if input.Diagnostics != nil { + input.Diagnostics.AddError(diagnostics.ErrInvalidEvidence, + fmt.Sprintf("module constant `%s` has value type %s, want %s", symbol.Name, value.TypeText(), typeinfo.TypeText(semanticType)), symbol.Location, "") + } + return nil + } + internConstantStrings(out, value) + out.StaticData = append(out.StaticData, &StaticEntry{ + Name: fmt.Sprintf("@%s$%s", symbol.Name, symbol.ID.String()), Type: typ, Constant: value, + }) } } @@ -428,22 +446,6 @@ func variantPayloadPlace(subject ValueRef, caseIndex int, payloadType ir.TypeID, } } -func staticEntryForConst(types *ir.TypeTable, symbol *symbols.Symbol, value constvalue.Value) (*StaticEntry, bool) { - if types == nil || symbol == nil || value == nil { - return nil, false - } - typeText := value.TypeText() - abiKey := typeText - if variant, ok := value.(*constvalue.VariantConst); ok && variant != nil && variant.NominalIdentity() != "" { - abiKey = "variant:" + variant.NominalIdentity() - } - typ, ok := types.LookupABIKey(abiKey) - if !ok { - return nil, false - } - return &StaticEntry{Name: fmt.Sprintf("@%s$%s", symbol.Name, symbol.ID.String()), Type: typ, Constant: value}, true -} - func internConstantStrings(module *Module, value constvalue.Value) { if module == nil || value == nil { return diff --git a/internal/ir/mir/module_lower_test.go b/internal/ir/mir/module_lower_test.go index 59ad0688..8bbc5254 100644 --- a/internal/ir/mir/module_lower_test.go +++ b/internal/ir/mir/module_lower_test.go @@ -1,17 +1,19 @@ package mir import ( - "compiler/internal/constvalue" - "compiler/internal/ir/cfg" "testing" + "compiler/internal/constvalue" + "compiler/internal/diagnostics" "compiler/internal/frontend/ast" "compiler/internal/ir" + "compiler/internal/ir/cfg" "compiler/internal/ir/exprlower" "compiler/internal/ir/thir" "compiler/internal/moduleid" "compiler/internal/semantics/symbols" "compiler/internal/semantics/typeinfo" + "compiler/internal/source" ) func TestExternalFunctionSignatureUsesPublishedLinkName(t *testing.T) { @@ -88,3 +90,48 @@ func TestGenerateMIREmitsStaticConstantFromSymbolIndex(t *testing.T) { t.Fatalf("static constant = %#v, want %#v", out.StaticData[0].Constant, value) } } + +func TestGenerateMIRRejectsUnmaterializableModuleConstants(t *testing.T) { + for _, test := range []struct { + name, valueType, code string + internValueType bool + }{ + {name: "missing value", code: diagnostics.ErrInvalidOperation}, + {name: "mismatched value", valueType: "i8", code: diagnostics.ErrInvalidEvidence}, + {name: "mismatched interned value", valueType: "i8", code: diagnostics.ErrInvalidEvidence, internValueType: true}, + } { + t.Run(test.name, func(t *testing.T) { + symbol := symbols.New(symbols.ProjectedSymbolID(symbols.SymbolConst, "Value"), "Value", symbols.SymbolConst, nil, nil) + symbol.BindType(&typeinfo.IntegerType{IsSigned: true, Bits: 64}) + symbol.Location = source.NewLocation("test.peep", source.Position{Line: 1, Column: 1}, source.Position{Line: 1, Column: 6}) + scope := symbols.NewScope(nil) + if err := scope.Declare(symbol); err != nil { + t.Fatal(err) + } + index := symbols.NewIndex() + if test.valueType != "" { + value, ok := constvalue.NewIntText("2", test.valueType) + if !ok { + t.Fatal("cannot construct test constant") + } + index.PublishConstant(symbol.ID, value) + } + types := ir.NewTypeTable() + if test.internValueType { + types.Intern(ir.Type{Kind: ir.TypeInteger, IsSigned: true, Bits: 8}) + } + diag := diagnostics.NewDiagnosticBag() + out := GenerateMIR(LoweringInput{ + Types: types, Diagnostics: diag, Source: thir.NewModule("test", "test.peep", nil), + CFG: &cfg.Module{}, Scope: scope, SymbolIndex: index, + }) + if out != nil || !diag.HasErrors() { + t.Fatalf("MIR = %#v, diagnostics = %s; want materialization rejection", out, diag.EmitAllToString()) + } + items := diag.Diagnostics() + if len(items) != 1 || items[0].Code != test.code || len(items[0].Labels) != 1 || items[0].Labels[0].Location != symbol.Location { + t.Fatalf("diagnostics = %#v, want %s at constant declaration", items, test.code) + } + }) + } +} diff --git a/internal/ir/types.go b/internal/ir/types.go index 8b9c4239..60a1f6a9 100644 --- a/internal/ir/types.go +++ b/internal/ir/types.go @@ -128,7 +128,6 @@ type TypeTable struct { mu sync.RWMutex types []Type ids map[string]TypeID - abiKeys map[string]TypeID isComplete []bool indexType TypeID } @@ -137,7 +136,6 @@ func NewTypeTable() *TypeTable { return &TypeTable{ types: []Type{{Name: ""}}, ids: make(map[string]TypeID), - abiKeys: make(map[string]TypeID), isComplete: []bool{false}, } } @@ -156,7 +154,6 @@ func (t *TypeTable) Intern(typ Type) TypeID { t.types = append(t.types, cloneType(typ)) t.isComplete = append(t.isComplete, true) t.ids[key] = id - t.abiKeys[t.abiKeyLocked(id)] = id return id } @@ -210,23 +207,9 @@ func (t *TypeTable) CompleteNamed(id TypeID, typ Type) error { } t.types[id] = cloneType(typ) t.isComplete[id] = true - t.abiKeys[t.abiKeyLocked(id)] = id return nil } -// LookupABIKey bridges finalized semantic identity into an already-interned IR -// type. It never parses text or creates a type; typelower remains the only -// semantic-to-runtime-IR type construction boundary. -func (t *TypeTable) LookupABIKey(key string) (TypeID, bool) { - if t == nil { - return InvalidType, false - } - t.mu.RLock() - defer t.mu.RUnlock() - id, ok := t.abiKeys[key] - return id, ok -} - func (t *TypeTable) SetIndexType(id TypeID) { if t == nil { panic("setting invalid IR index type") @@ -428,12 +411,7 @@ func (t *TypeTable) ABIKey(id TypeID) string { if id == InvalidType || int(id) >= len(t.types) { return "" } - return t.abiKeyLocked(id) -} - -func (t *TypeTable) abiKeyLocked(id TypeID) string { - typ := t.types[id] - if key, ok := identifiedTypeKey(typ); ok { + if key, ok := identifiedTypeKey(t.types[id]); ok { return key } return t.textLocked(id) diff --git a/internal/ir/types_test.go b/internal/ir/types_test.go index ddd01885..cce52ac8 100644 --- a/internal/ir/types_test.go +++ b/internal/ir/types_test.go @@ -32,9 +32,8 @@ func TestTypeTableConcurrentInterningAndReads(t *testing.T) { results <- result{err: fmt.Sprintf("Text(%d) = %q, want %q", unique, got, uniqueName)} return } - abiKey := types.ABIKey(unique) - if got, ok := types.LookupABIKey(abiKey); !ok || got != unique { - results <- result{err: fmt.Sprintf("LookupABIKey(%q) = (%d, %t), want (%d, true)", abiKey, got, ok, unique)} + if got := types.ABIKey(unique); got != "struct:test::"+uniqueName { + results <- result{err: fmt.Sprintf("ABIKey(%d) = %q, want %q", unique, got, "struct:test::"+uniqueName)} return } if typ, ok := types.Type(shared); !ok || typ.Kind != TypeStruct { @@ -147,6 +146,9 @@ func TestTypeTableReservesAndCompletesRecursiveNamedComposite(t *testing.T) { if _, complete := types.Type(nodeID); complete { t.Fatal("reserved named type was visible before completion") } + if got := types.ABIKey(nodeID); got != "struct:test::Node" { + t.Fatalf("reserved recursive ABI key = %q, want struct:test::Node", got) + } if repeated, err := types.ReserveNamed(nodeShell); err != nil || repeated != nodeID { t.Fatalf("repeated reservation = (%d, %v), want (%d, nil)", repeated, err, nodeID) } diff --git a/internal/pipeline/pipeline_test.go b/internal/pipeline/pipeline_test.go index 32e397b4..14fbc823 100644 --- a/internal/pipeline/pipeline_test.go +++ b/internal/pipeline/pipeline_test.go @@ -16,6 +16,7 @@ import ( "compiler/internal/graph" "compiler/internal/ir/cfg" "compiler/internal/ir/mir" + "compiler/internal/ir/thir" "compiler/internal/module" "compiler/internal/moduleid" "compiler/internal/phase" @@ -23,6 +24,7 @@ import ( "compiler/internal/project" "compiler/internal/semantics/intrinsics" "compiler/internal/semantics/symbols" + "compiler/internal/semantics/typeinfo" "compiler/internal/target" "compiler/pkg/manifest" "compiler/pkg/peeper" @@ -100,36 +102,108 @@ func buildPipelineTestWithConfig(t *testing.T, cfg project.Config, preludeSrc, e return diag } +func assertLLVMCompiles(t *testing.T, compilerTarget target.Info, text string) { + t.Helper() + clang, err := exec.LookPath("clang") + if err != nil { + t.Skip("clang unavailable for LLVM IR validation") + } + cmd := exec.Command(clang, "-target", compilerTarget.LLVMTriple, "-x", "ir", "-c", "-o", filepath.Join(t.TempDir(), "module.o"), "-") + cmd.Stdin = strings.NewReader(text) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("%s LLVM is invalid: %v\n%s\n%s", compilerTarget.LLVMTriple, err, output, text) + } +} + func runImportedRuntimeSymbolPipeline(t *testing.T, entrySrc, runtimeSrc string) *diagnostics.DiagnosticBag { + t.Helper() + ctx, entry := importedPipelineContext(t, "", map[string]string{ + peeper.MainFileName: entrySrc, "runtime" + peeper.SourceExt: runtimeSrc, + }) + if err := Run(ctx, entry); err != nil { + t.Fatalf("pipeline.Run returned error: %v", err) + } + return ctx.Diagnostics +} + +func importedPipelineContext(t *testing.T, arch string, sources map[string]string) (*project.CompilerContext, *module.Module) { t.Helper() root := t.TempDir() srcDir := filepath.Join(root, peeper.SourceDirName) - entryPath := filepath.Join(srcDir, peeper.MainFileName) - runtimePath := filepath.Join(srcDir, "runtime"+peeper.SourceExt) if err := os.MkdirAll(srcDir, 0o755); err != nil { t.Fatalf("mkdir source dir: %v", err) } - if err := os.WriteFile(entryPath, []byte(entrySrc), 0o644); err != nil { - t.Fatalf("write entry: %v", err) - } - if err := os.WriteFile(runtimePath, []byte(runtimeSrc), 0o644); err != nil { - t.Fatalf("write runtime module: %v", err) + for name, src := range sources { + if err := os.WriteFile(filepath.Join(srcDir, name), []byte(src), 0o644); err != nil { + t.Fatalf("write %s: %v", name, err) + } } diag := diagnostics.NewDiagnosticBag() - ctx := project.NewWithConfig(project.Config{ + cfg := project.Config{ RootDir: root, ProjectName: "app", Extension: peeper.SourceExt, - }, diag) + TargetArch: arch, + } + if arch != "" { + cfg.TargetOS = "linux" + } + ctx := project.NewWithConfig(cfg, diag) entry := &module.Module{ ID: moduleid.ID{Origin: string(project.ModuleOriginLocal), ImportPath: "app/main"}, - FilePath: entryPath, + FilePath: filepath.Join(srcDir, peeper.MainFileName), } - if err := Run(ctx, entry); err != nil { - t.Fatalf("pipeline.Run returned error: %v", err) + return ctx, entry +} + +func assertCheckedConstantIndex(t *testing.T, entry *module.Module, wantValue, wantType string) { + t.Helper() + if entry.THIR == nil || entry.MIR == nil || entry.LLVMIR == "" { + t.Fatal("constant index did not reach THIR, MIR and backend") + } + accesses := 0 + for _, stmt := range entry.AST.Stmts { + ast.Inspect(stmt, func(node ast.Node) bool { + if index, ok := node.(*ast.IndexExpr); ok { + accesses++ + checked := entry.THIR.Node(index.ID()).(*thir.Index) + if checked.Constant == nil || checked.Constant.Text != wantValue || + !typeinfo.IsSameType(checked.Constant.Type, checked.Index.ExprType()) || typeinfo.TypeText(typeinfo.Underlying(checked.Constant.Type)) != wantType { + t.Fatalf("THIR index = %#v, want %s %s", checked.Constant, wantType, wantValue) + } + } + return true + }) + } + projections := 0 + for _, function := range entry.MIR.Funcs { + for _, block := range function.Blocks { + for _, instruction := range block.Instrs { + assign, ok := instruction.(*mir.Assign) + if !ok { + continue + } + load, ok := assign.Value.(*mir.Load) + if !ok || load.Place == nil { + continue + } + for _, projection := range load.Place.Projections { + if projection.Kind != mir.PlaceProjectionIndex { + continue + } + projections++ + index, ok := projection.Index.(*mir.RefConst) + if !ok || index.Value != wantValue || entry.MIR.Types.Text(index.TypeID()) != wantType { + t.Fatalf("MIR index = %#v, want %s %s", projection.Index, wantType, wantValue) + } + } + } + } + } + if accesses != 1 || projections != 1 { + t.Fatalf("index counts: AST %d, MIR %d, want one each", accesses, projections) } - return diag } // TestPipelinePreludeSymbolsVisibleInEntry verifies that prelude-defined symbols @@ -247,15 +321,7 @@ fn main() { if !strings.Contains(entry.LLVMIR, "icmp ult "+test.llvmType) || strings.Contains(entry.LLVMIR, "trunc i64") { t.Fatalf("LLVM index width invalid for %s:\n%s", test.arch, entry.LLVMIR) } - clang, err := exec.LookPath("clang") - if err != nil { - t.Skip("clang unavailable for LLVM IR validation") - } - cmd := exec.Command(clang, "-target", targetInfo.LLVMTriple, "-x", "ir", "-c", "-o", filepath.Join(t.TempDir(), "for-loop.o"), "-") - cmd.Stdin = strings.NewReader(entry.LLVMIR) - if output, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("%s for-loop LLVM is invalid: %v\n%s\n%s", test.arch, err, output, entry.LLVMIR) - } + assertLLVMCompiles(t, targetInfo, entry.LLVMIR) }) if diag.HasErrors() { t.Fatalf("unexpected diagnostics:\n%s", diag.EmitAllToString()) @@ -1038,6 +1104,79 @@ fn main() -> i32 { return Value; } } } +func TestPipelineMaterializesNumericConstantsAcrossTargets(t *testing.T) { + for _, arch := range []string{"386", "amd64"} { + for _, test := range []struct{ name, source, typ, value string }{ + {"mixed widths", "const Value: i64 = 1i32 + 2i64;", "i64", "3"}, + {"narrow initializer", "const Value: i64 = 2i8;", "i64", "2"}, + {"narrow type interned", "const Narrow: i8 = 1i8; const Value: i64 = 2i8;", "i64", "2"}, + {"float narrowing", "const Value: f32 = 1.25f64 as f32;", "f32", "1.25"}, + {"source overflow", "const Value: i64 = 127i8 + 1i8;", "i64", "-128"}, + {"integer float rounding", "const Value: f32 = 9223372586610589697u64 as f32;", "f32", "9.223373e+18"}, + {"integer float infinity", "const Value: f64 = (1u2048 << 1024u2048) as f64;", "f64", "+Inf"}, + } { + t.Run(arch+"/"+test.name, func(t *testing.T) { + source := test.source + "\nfn Read(value: " + test.typ + ") -> " + test.typ + " { return value; }\nfn main() { let value = Read(Value); }" + diag := buildPipelineTestWithConfig(t, project.Config{ + RootDir: ".", Extension: peeper.SourceExt, TargetOS: "linux", TargetArch: arch, + }, "", source, func(entry *module.Module) { + if entry.MIR == nil || entry.LLVMIR == "" { + t.Fatal("constant did not reach backend") + } + symbol, found := entry.ModuleScope.LookupLocal("Value") + if !found || symbol == nil { + t.Fatal("missing constant Value") + } + name := "@Value$" + symbol.ID.String() + var static *mir.StaticEntry + for _, candidate := range entry.MIR.StaticData { + if candidate.Name == name { + static = candidate + } + } + if static == nil || entry.MIR.Types.Text(static.Type) != test.typ || static.Constant != entry.SymbolIndex.ConstantValue(symbol.ID) { + t.Fatalf("static = %#v, want authoritative %s constant", static, test.typ) + } + text, ok := static.Constant.(interface{ Text() string }) + if !ok || text.Text() != test.value || static.Constant.TypeText() != test.typ { + t.Fatalf("static value = %#v, want %s %s", static.Constant, test.value, test.typ) + } + if !strings.Contains(entry.LLVMIR, name+" = constant ") { + t.Fatal("LLVM lacks required constant definition") + } + targetInfo, err := target.New("linux", arch) + if err != nil { + t.Fatal(err) + } + assertLLVMCompiles(t, targetInfo, entry.LLVMIR) + }) + if diag.HasErrors() { + t.Fatalf("unexpected diagnostics:\n%s", diag.EmitAllToString()) + } + }) + } + } +} + +func TestPipelineOmitsDiscardConstantStaticsAcrossTargets(t *testing.T) { + for _, arch := range []string{"386", "amd64"} { + t.Run(arch, func(t *testing.T) { + diag := buildPipelineTestWithConfig(t, project.Config{ + RootDir: ".", Extension: peeper.SourceExt, TargetOS: "linux", TargetArch: arch, + }, "", `const _: i32 = 1i32; +const _: f64 = 1.25f32; +fn main() -> i32 { return 0; }`, func(entry *module.Module) { + if entry.MIR == nil || entry.LLVMIR == "" || len(entry.MIR.StaticData) != 0 { + t.Fatalf("discard bindings require no static data: MIR=%#v LLVM=%v", entry.MIR, entry.LLVMIR != "") + } + }) + if diag.HasErrors() { + t.Fatalf("unexpected diagnostics:\n%s", diag.EmitAllToString()) + } + }) + } +} + func TestTypecheckedPhaseFinalizesNamedVariantConstants(t *testing.T) { diag := diagnostics.NewDiagnosticBag() const entryPath = "entry" + peeper.SourceExt @@ -1891,15 +2030,7 @@ fn main() -> i32 { t.Fatalf("reference-recursive enum stopped before backend: phase=%v\n%s", entry.Phase, diag.EmitAllToString()) } - clang, err := exec.LookPath("clang") - if err != nil { - t.Skip("clang unavailable for LLVM IR validation") - } - cmd := exec.Command(clang, "-target", ctx.Target.LLVMTriple, "-x", "ir", "-c", "-o", filepath.Join(t.TempDir(), "recursive-enum.o"), "-") - cmd.Stdin = strings.NewReader(entry.LLVMIR) - if output, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("%s reference-recursive enum LLVM is invalid: %v\n%s\n%s", arch, err, output, entry.LLVMIR) - } + assertLLVMCompiles(t, ctx.Target, entry.LLVMIR) }) } } @@ -2419,6 +2550,360 @@ fn first() -> i32 { } } +func TestPipelineRejectsWideConstantIndexesAcrossTargets(t *testing.T) { + for _, arch := range []string{"386", "amd64"} { + for _, test := range []struct{ name, declaration, index, value string }{ + {"float cast", "", "4294967296f64 as u64", "4294967296"}, + {"negative float cast", "", "(-4294967296f64) as i64", "-4294967296"}, + {"local constant", "const Offset: i64 = 4294967296i64;", "Offset", "4294967296"}, + {"negative local constant", "const Offset: i64 = -4294967296i64;", "Offset", "-4294967296"}, + } { + t.Run(arch+"/"+test.name, func(t *testing.T) { + var entry *module.Module + diag := buildPipelineTestWithConfig(t, project.Config{ + RootDir: ".", Extension: peeper.SourceExt, TargetOS: "linux", TargetArch: arch, + }, "", "fn main() -> i32 { "+test.declaration+" let values = [1]i32{7}; return values["+test.index+"]; }", func(mod *module.Module) { + entry = mod + }) + items := diag.Diagnostics() + if len(items) != 1 || items[0].Code != diagnostics.ErrArrayOutOfBounds || + items[0].Message != "array index out of bounds: index "+test.value+" for length 1" || + len(items[0].Labels) != 1 || items[0].Labels[0].Location == nil { + t.Fatalf("expected located bounds diagnostic, got:\n%s", diag.EmitAllToString()) + } + if entry.MIR != nil || entry.LLVMIR != "" { + t.Fatal("invalid constant index reached backend") + } + }) + } + } +} + +func TestPipelineRejectsHostSizedConstantBounds(t *testing.T) { + for _, index := range []struct{ source, value string }{ + {"-1i64", "-1"}, + {"9223372036854775808u64", "9223372036854775808"}, + {"18446744073709551616u128", "18446744073709551616"}, + } { + t.Run(index.source, func(t *testing.T) { + var entry *module.Module + diag := buildPipelineTestWithConfig(t, project.Config{ + RootDir: ".", Extension: peeper.SourceExt, TargetOS: "linux", TargetArch: "amd64", + }, "", "fn read(xs: [9223372036854775808u64]u8) -> u8 { return xs["+index.source+"]; }", func(mod *module.Module) { + entry = mod + }) + fn := entry.AST.Stmts[0].(*ast.FnDecl) + access := fn.Body.Stmts[0].(*ast.ReturnStmt).Value.(*ast.IndexExpr) + items := diag.Diagnostics() + if len(items) != 1 || items[0].Code != diagnostics.ErrArrayOutOfBounds || + items[0].Message != "array index out of bounds: index "+index.value+" for length 9223372036854775808" || + len(items[0].Labels) != 1 || items[0].Labels[0].Location != ast.LocOf(access.Index) { + t.Fatalf("expected source-located exact bounds diagnostic:\n%s", diag.EmitAllToString()) + } + if entry.MIR != nil || entry.LLVMIR != "" { + t.Fatal("invalid large-array index reached backend") + } + }) + } +} + +func TestPipelineLowersLargeArrayBoundsAcrossTargets(t *testing.T) { + for _, compilerTarget := range []struct{ arch, length string }{ + {"386", "2147483648"}, {"amd64", "9223372036854775808"}, {"amd64", "9223372036854775809"}, + } { + for _, index := range []string{"0u64", "index"} { + t.Run(compilerTarget.arch+"/"+compilerTarget.length+"/"+index, func(t *testing.T) { + params := "xs: &Large" + if index == "index" { + params += ", index: i64" + } + src := "struct Large { values: [" + compilerTarget.length + "u64]u8, flag: bool }\n" + + "fn read(" + params + ") -> u8 { return xs.values[" + index + "]; }" + var entry *module.Module + diag := buildPipelineTestWithConfig(t, project.Config{ + RootDir: ".", Extension: peeper.SourceExt, TargetOS: "linux", TargetArch: compilerTarget.arch, + }, "", src, func(mod *module.Module) { entry = mod }) + if diag.HasErrors() || entry.MIR == nil || entry.LLVMIR == "" { + t.Fatalf("large array access failed:\n%s", diag.EmitAllToString()) + } + if index == "index" && (!strings.Contains(entry.LLVMIR, "icmp uge i64") || !strings.Contains(entry.LLVMIR, "icmp slt i64") || + !strings.Contains(entry.LLVMIR, compilerTarget.length) || !strings.Contains(entry.LLVMIR, "call void @llvm.trap()")) { + t.Fatalf("large array runtime guard missing:\n%s", entry.LLVMIR) + } + targetInfo, err := target.New("linux", compilerTarget.arch) + if err != nil { + t.Fatal(err) + } + assertLLVMCompiles(t, targetInfo, entry.LLVMIR) + }) + } + } +} + +func TestPipelineLowersCheckedConstantIndexesAcrossTargets(t *testing.T) { + for _, arch := range []string{"386", "amd64"} { + for _, test := range []struct{ name, declaration, index, typ, value, conversion, length string }{ + {"float cast", "", "1.75f64 as u64", "u64", "1", "", ""}, + {"source width negation", "const Offset: u8 = 255u8;", "-Offset", "u8", "1", "zext i8 1 to i64", ""}, + {"explicit narrowing", "", "257u16 as u8", "u8", "1", "zext i8 1 to i64", ""}, + {"single bit literal", "", "1u1", "u1", "1", "zext i1 1 to i64", ""}, + {"single bit zero", "", "0u1", "u1", "0", "zext i1 0 to i64", ""}, + {"single bit float cast", "", "1f64 as u1", "u1", "1", "zext i1 1 to i64", ""}, + {"single bit binding", "const Offset: u1 = 1u1;", "Offset", "u1", "1", "zext i1 1 to i64", ""}, + {"signed narrow", "", "1i8", "i8", "1", "sext i8 1 to i64", ""}, + {"wide unsigned", "", "1u128", "u128", "1", "trunc i128 1 to i64", ""}, + {"byte", "", "1u8 as byte", "byte", "1", "zext i8 1 to i64", ""}, + {"u8 top bit", "", "128u8", "u8", "128", "zext i8 128 to i64", "129"}, + {"u24 top bit", "", "8388608u24", "u24", "8388608", "zext i24 8388608 to i64", "8388609"}, + } { + t.Run(arch+"/"+test.name, func(t *testing.T) { + src := "fn main() -> i32 { " + test.declaration + " let values = [2]i32{7, 9}; return values[" + test.index + "]; }" + if test.length != "" { + // High-bit indexes use borrowed storage; no large object is allocated. + src = "struct Values { items: [" + test.length + "]i32 }\n" + + "fn read(values: &Values) -> i32 { " + test.declaration + " return values.items[" + test.index + "]; }" + } + diag := buildPipelineTestWithConfig(t, project.Config{ + RootDir: ".", Extension: peeper.SourceExt, TargetOS: "linux", TargetArch: arch, + }, "", src, func(entry *module.Module) { + assertCheckedConstantIndex(t, entry, test.value, test.typ) + if test.conversion != "" && !strings.Contains(entry.LLVMIR, test.conversion) { + t.Fatalf("checked index value not preserved for GEP; missing %q:\n%s", test.conversion, entry.LLVMIR) + } + targetInfo, err := target.New("linux", arch) + if err != nil { + t.Fatal(err) + } + assertLLVMCompiles(t, targetInfo, entry.LLVMIR) + }) + if diag.HasErrors() { + t.Fatalf("unexpected diagnostics:\n%s", diag.EmitAllToString()) + } + }) + } + } +} + +func TestPipelineEvaluatesQualifiedConstantIndexesAcrossTargets(t *testing.T) { + for _, arch := range []string{"386", "amd64"} { + for _, test := range []struct { + name, prefix, declaration, index, value, typ string + rejected bool + }{ + {"direct wide", "", "", "values::Offset", "18446744073709551616", "", true}, + {"local wide", "", "const Saved: u128 = values::Offset;", "Saved", "18446744073709551616", "", true}, + {"module wide", "const Saved: u128 = values::Offset;", "", "Saved", "18446744073709551616", "", true}, + {"arithmetic wide", "", "", "values::Offset + 0u128", "18446744073709551616", "", true}, + {"cast wide", "", "", "values::Offset as i128", "18446744073709551616", "", true}, + {"transitive wide", "import \"app/bridge\";", "", "bridge::Forward", "18446744073709551616", "", true}, + {"negative", "", "", "values::Negative", "-4294967296", "", true}, + {"equal length", "", "", "values::End", "2", "", true}, + {"zero", "", "", "values::Zero", "0", "u128", false}, + {"unsigned single bit", "", "", "values::Bit", "1", "u1", false}, + {"type alias", "", "", "values::Selected", "1", "u64", false}, + {"source width negation", "", "", "-values::Wrapped", "1", "u8", false}, + {"explicit narrowing", "", "", "values::Wide as u8", "1", "u8", false}, + {"module alias", "const Saved: u128 = values::Zero;", "", "Saved", "0", "u128", false}, + {"transitive valid", "import \"app/bridge\";", "", "bridge::Selected", "1", "u1", false}, + } { + t.Run(arch+"/"+test.name, func(t *testing.T) { + ctx, entry := importedPipelineContext(t, arch, map[string]string{ + peeper.MainFileName: "import \"app/values\";\n" + test.prefix + "\nfn main() -> i32 { " + test.declaration + " let items = [2]i32{7, 9}; return items[" + test.index + "]; }", + "values" + peeper.SourceExt: `type Index = u64; +const Offset: u128 = 1u128 << 64u128; +const Negative: i64 = -4294967296i64; +const End: u8 = 2u8; +const Zero: u128 = 0u128; +const Bit: u1 = 1u1; +const Selected: Index = 1u64; +const Wrapped: u8 = 255u8; +const Wide: u16 = 257u16;`, + "bridge" + peeper.SourceExt: `import "app/values"; +const Forward: u128 = values::Offset; +const Selected: u1 = values::Bit;`, + }) + if err := Run(ctx, entry); err != nil { + t.Fatal(err) + } + diag := ctx.Diagnostics + if test.rejected { + fn := entry.AST.Stmts[len(entry.AST.Stmts)-1].(*ast.FnDecl) + access := fn.Body.Stmts[len(fn.Body.Stmts)-1].(*ast.ReturnStmt).Value.(*ast.IndexExpr) + if diag.ErrorCount() != 1 { + t.Fatalf("want one source bounds error, got:\n%s", diag.EmitAllToString()) + } + for _, item := range diag.Diagnostics() { + if item.Severity != diagnostics.Error { + continue + } + if item.Code != diagnostics.ErrArrayOutOfBounds || item.Message != "array index out of bounds: index "+test.value+" for length 2" || + len(item.Labels) != 1 || item.Labels[0].Location != ast.LocOf(access.Index) { + t.Fatalf("want exact index-located bounds error, got:\n%s", diag.EmitAllToString()) + } + } + if entry.MIR != nil || entry.LLVMIR != "" { + t.Fatal("invalid qualified constant reached backend") + } + return + } + if diag.HasErrors() { + t.Fatalf("valid qualified constant failed:\n%s", diag.EmitAllToString()) + } + assertCheckedConstantIndex(t, entry, test.value, test.typ) + assertLLVMCompiles(t, ctx.Target, entry.LLVMIR) + }) + } + } +} + +func TestPipelineChecksQualifiedConstantShiftCounts(t *testing.T) { + for _, arch := range []string{"386", "amd64"} { + for _, test := range []struct { + name, left, count string + rejected bool + }{ + {"out of width", "1u8", "values::Invalid", true}, + {"negative", "1i8", "values::Negative", true}, + {"direct valid", "1u8", "values::Valid", false}, + {"explicit narrowing", "1u8", "values::Wide as u8", false}, + } { + t.Run(arch+"/"+test.name, func(t *testing.T) { + ctx, entry := importedPipelineContext(t, arch, map[string]string{ + peeper.MainFileName: "import \"app/values\"; fn Read() -> i32 { return (" + test.left + " << (" + test.count + ")) as i32; }", + "values" + peeper.SourceExt: `const Invalid: u8 = 8u8; +const Negative: i8 = -1i8; +const Valid: u8 = 2u8; +const Wide: u16 = 258u16;`, + }) + if err := Run(ctx, entry); err != nil { + t.Fatal(err) + } + diag := ctx.Diagnostics + if test.rejected { + cast := entry.AST.Stmts[0].(*ast.FnDecl).Body.Stmts[0].(*ast.ReturnStmt).Value.(*ast.AsExpr) + shift := cast.Expr.(*ast.BinaryExpr) + items := diag.Diagnostics() + if len(items) != 1 || items[0].Code != diagnostics.ErrInvalidOperation || items[0].Message != "shift count must be between 0 and 7" || + len(items[0].Labels) != 1 || items[0].Labels[0].Location != ast.LocOf(shift.Right) || entry.MIR != nil || entry.LLVMIR != "" { + t.Fatalf("want located constant-shift error before MIR, got:\n%s", diag.EmitAllToString()) + } + return + } + if diag.HasErrors() || entry.MIR == nil || entry.LLVMIR == "" { + t.Fatalf("valid qualified shift failed:\n%s", diag.EmitAllToString()) + } + assertLLVMCompiles(t, ctx.Target, entry.LLVMIR) + }) + } + } +} + +func TestPipelineRecordsQualifiedRangeEntryAcrossTargets(t *testing.T) { + for _, arch := range []string{"386", "amd64"} { + for _, test := range []struct { + name, params, start, end string + guaranteed bool + }{ + {"ascending", "", "values::Zero", "values::One", true}, + {"equal", "", "values::One", "values::One", false}, + {"reversed", "", "values::One", "values::Zero", false}, + {"runtime", "start: i64", "start", "values::One", false}, + } { + t.Run(arch+"/"+test.name, func(t *testing.T) { + ctx, entry := importedPipelineContext(t, arch, map[string]string{ + peeper.MainFileName: "import \"app/values\"; fn Read(" + test.params + ") -> i64 { for value in " + test.start + "..(" + test.end + " as i64) { return value; } }", + "values" + peeper.SourceExt: `const Zero: i64 = 0i64; const One: i64 = 1i64;`, + }) + if err := Run(ctx, entry); err != nil { + t.Fatal(err) + } + loop := entry.AST.Stmts[0].(*ast.FnDecl).Body.Stmts[0].(*ast.ForStmt) + plan, ok := entry.THIR.Node(loop.ID()).(*thir.For).Iteration.(*thir.RangeIteration) + if !ok { + t.Fatalf("missing range plan for %T:\n%s", loop.Iterable, ctx.Diagnostics.EmitAllToString()) + } + if plan.HasGuaranteedEntry != test.guaranteed { + t.Fatalf("range entry = %v, want %v", plan.HasGuaranteedEntry, test.guaranteed) + } + for name, typ := range map[string]typeinfo.Type{"element": plan.ElementType, "cursor": plan.Cursor.Type, "limit": plan.Limit.Type} { + if typeinfo.TypeText(typ) != "i64" { + t.Fatalf("%s type = %s, want i64", name, typeinfo.TypeText(typ)) + } + } + diag := ctx.Diagnostics + if !test.guaranteed { + if diag.ErrorCount() != 1 || !hasDiagnosticCode(diag, diagnostics.ErrMissingReturn) || entry.MIR != nil || entry.LLVMIR != "" { + t.Fatalf("non-guaranteed range must retain missing-return error:\n%s", diag.EmitAllToString()) + } + return + } + if diag.HasErrors() || entry.MIR == nil || entry.LLVMIR == "" { + t.Fatalf("guaranteed imported range failed:\n%s", diag.EmitAllToString()) + } + assertLLVMCompiles(t, ctx.Target, entry.LLVMIR) + }) + } + } +} + +func TestPipelineReportsQualifiedConstantConditionsInCFGPhase(t *testing.T) { + for _, test := range []struct { + condition, warning string + value bool + }{ + {"values::Flag", diagnostics.WarnConstantConditionTrue, true}, + {"values::One == 1i64", diagnostics.WarnConstantConditionTrue, true}, + {"values::One == 0i64", diagnostics.WarnConstantConditionFalse, false}, + } { + t.Run(test.condition, func(t *testing.T) { + ctx, entry := importedPipelineContext(t, "amd64", map[string]string{ + peeper.MainFileName: "import \"app/values\"; fn main() -> i32 { if " + test.condition + " { return 1; } return 0; }", + "values" + peeper.SourceExt: `const Flag: bool = true; const One: i64 = 1i64;`, + }) + loader := &moduleLoader{ctx: ctx, scheduled: make(map[moduleid.ID]string)} + if err := loader.Load(entry); err != nil { + t.Fatal(err) + } + owner, found := ctx.ModuleByID(entry.Imports["values"].ID) + if !found { + t.Fatal("qualified owner was not loaded") + } + diag := ctx.Diagnostics + for _, mod := range []*module.Module{owner, entry} { + for mod.Phase < phase.Typechecked { + if !advanceModulePhase(ctx, mod, diag) { + t.Fatalf("typing stopped at %v:\n%s", mod.Phase, diag.EmitAllToString()) + } + } + } + condition := entry.AST.Stmts[0].(*ast.FnDecl).Body.Stmts[0].(*ast.IfStmt) + checked := entry.THIR.Node(condition.ID()).(*thir.If) + if checked.ConstantCondition == nil || *checked.ConstantCondition != test.value { + t.Fatalf("qualified condition = %v, want %v", checked.ConstantCondition, test.value) + } + if diag.HasErrors() || hasDiagnosticCode(diag, diagnostics.WarnConstantConditionTrue) || hasDiagnosticCode(diag, diagnostics.WarnConstantConditionFalse) { + t.Fatalf("qualified warning/error emitted before CFG:\n%s", diag.EmitAllToString()) + } + entry.AST = nil + if !advanceModulePhase(ctx, entry, diag) || entry.Phase != phase.CFG || entry.MIR != nil || diag.HasErrors() { + t.Fatalf("CFG did not consume qualified evidence without AST:\n%s", diag.EmitAllToString()) + } + warnings := 0 + for _, item := range diag.Diagnostics() { + if item.Code == diagnostics.WarnConstantConditionTrue || item.Code == diagnostics.WarnConstantConditionFalse { + if item.Code != test.warning { + t.Fatalf("wrong qualified warning %s", item.Code) + } + warnings++ + } + } + if warnings != 1 { + t.Fatalf("qualified warnings = %d, want one:\n%s", warnings, diag.EmitAllToString()) + } + }) + } +} + func TestPipelineLowersTopLevelConstArrayIndex(t *testing.T) { preludeSrc := `` entrySrc := `const I: i32 = 1; @@ -2792,15 +3277,7 @@ fn main() -> i32 { } } - clang, err := exec.LookPath("clang") - if err != nil { - t.Skip("clang unavailable for LLVM IR validation") - } - cmd := exec.Command(clang, "-target", ctx.Target.LLVMTriple, "-x", "ir", "-c", "-o", filepath.Join(t.TempDir(), "intrinsic.o"), "-") - cmd.Stdin = strings.NewReader(entry.LLVMIR) - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("%s representative LLVM IR is invalid: %v\n%s\n%s", compilerTarget.name, err, out, entry.LLVMIR) - } + assertLLVMCompiles(t, ctx.Target, entry.LLVMIR) }) } } diff --git a/internal/semantics/typechecker/check_expr.go b/internal/semantics/typechecker/check_expr.go index 8cb943ca..9d801cd8 100644 --- a/internal/semantics/typechecker/check_expr.go +++ b/internal/semantics/typechecker/check_expr.go @@ -653,7 +653,8 @@ func (c *checker) typeIndexExpr(scope *symbols.Scope, node *ast.IndexExpr) typei return elem } array := typeinfo.Underlying(baseType).(*typeinfo.ArrayType) - value, ok := c.evaluateConstant(c.ctx, node.Index, typeinfo.DefaultIntegerType()) + // Literal inference is finished; bounds checking must keep the checked width. + value, ok := c.evaluateConstant(c.ctx, node.Index, indexType) if !ok { return elem } @@ -665,11 +666,10 @@ func (c *checker) typeIndexExpr(scope *symbols.Scope, node *ast.IndexExpr) typei Text: indexConst.Text(), Type: indexType, }) - length, lengthErr := strconv.Atoi(array.Len) - indexText := indexConst.Text() - indexValue, indexErr := strconv.Atoi(indexText) - if lengthErr == nil && (indexErr != nil || indexValue < 0 || indexValue >= length) { - c.ctx.Diagnostics.Add(problems.ArrayIndexOutOfBounds(indexText, array.Len, ast.LocOf(node.Index))) + length, lengthErr := numeric.StringToBigInt(array.Len) + indexValue := indexConst.Int() + if lengthErr == nil && (indexValue == nil || indexValue.Sign() < 0 || indexValue.Cmp(length) >= 0) { + c.ctx.Diagnostics.Add(problems.ArrayIndexOutOfBounds(indexConst.Text(), array.Len, ast.LocOf(node.Index))) } return elem } diff --git a/internal/semantics/typechecker/constant_eval.go b/internal/semantics/typechecker/constant_eval.go index 1b40f8c5..114002fd 100644 --- a/internal/semantics/typechecker/constant_eval.go +++ b/internal/semantics/typechecker/constant_eval.go @@ -1,6 +1,9 @@ package typechecker import ( + "math" + "math/big" + "compiler/internal/constvalue" "compiler/internal/diagnostics" "compiler/internal/frontend/ast" @@ -112,8 +115,24 @@ func (e *constantEvaluator) evalConstSymbol(sym *symbols.Symbol) (constvalue.Val return value, true } -func (e *constantEvaluator) evalExpr(expr ast.Expr, expected typeinfo.Type) (constvalue.Value, bool) { +func (e *constantEvaluator) evalExpr(expr ast.Expr, expected typeinfo.Type) (value constvalue.Value, evaluated bool) { + if expr == nil { + return nil, false + } + var checkedType typeinfo.Type if e.evidence != nil { + checkedType = e.evidence.ExprType(expr.ID()) + if conversion, found := e.evidence.ImplicitConversion(expr.ID()); found && checkedType != nil && + conversion.Kind == typeinfo.ConversionNumeric && conversion.Compatibility == typeinfo.Compatible { + // Fold at the checked source width before converting to the context's + // destination. Widening earlier would change intermediate overflow. + defer func(destination typeinfo.Type) { + if evaluated { + value, evaluated = expectedNumericConstValue(value, destination) + } + }(expected) + expected = checkedType + } if construction, ok := e.evidence.VariantConstruction(expr.ID()); ok { if typeinfo.OwnershipCapabilityOf(construction.EnumType).Copy != typeinfo.CopyImplicit { return nil, false @@ -212,9 +231,9 @@ func (e *constantEvaluator) evalExpr(expr ast.Expr, expected typeinfo.Type) (con return constvalue.NewIntText(value, typText) case *ast.BoolLit: return constvalue.NewBool(node.Value), true - case *ast.Ident: + case *ast.Ident, *ast.ScopeResolution: // Later declarations may shadow the name, but not its resolved binding. - sym := e.module.SymbolIndex.Symbol(node) + sym := e.module.SymbolIndex.Symbol(expr) if sym == nil || sym.Kind != symbols.SymbolConst { return nil, false } @@ -224,28 +243,30 @@ func (e *constantEvaluator) evalExpr(expr ast.Expr, expected typeinfo.Type) (con } return expectedNumericConstValue(value, expected) case *ast.AsExpr: - var targetType, sourceType typeinfo.Type + targetType := checkedType + var sourceType typeinfo.Type if e.evidence != nil { - targetType = e.evidence.ExprType(node.ID()) sourceType = e.evidence.ExprType(node.Expr.ID()) } if targetType == nil { targetType = e.ctx.TypeResolver.Query(e.module, node.TypeExpr, typeresolution.Context{}).Type } - if !typeinfo.IsIntegral(targetType) { + if _, _, numericTarget := typeinfo.NumericInfo(targetType); !numericTarget { return nil, false } // Cast input keeps its own width; destination context must not change // overflow before the explicit finite-width conversion. value, ok := e.evalExpr(node.Expr, sourceType) - integer, isInteger := value.(*constvalue.IntConst) - if !ok || !isInteger || integer == nil { + if !ok { return nil, false } - converted, ok := constvalue.NewInt(integer.Int(), typeinfo.TypeText(typeinfo.Underlying(targetType))) + converted, ok := expectedNumericConstValue(value, targetType) if !ok { return nil, false } + if typeinfo.IsSameType(targetType, expected) { + return converted, true + } return expectedNumericConstValue(converted, expected) case *ast.UnaryExpr: value, ok := e.evalExpr(node.Expr, expected) @@ -254,15 +275,28 @@ func (e *constantEvaluator) evalExpr(expr ast.Expr, expected typeinfo.Type) (con } return constvalue.FoldUnary(node.Op, value) case *ast.BinaryExpr: - left, lok := e.evalExpr(node.Left, expected) - right, rok := e.evalExpr(node.Right, expected) + operandType := expected + if checkedType != nil { + operandType = checkedType + if binaryResultIsBool(node.Op) { + // The result is bool; the unconverted operand owns the checked + // numeric destination for comparisons. + operandType = e.evidence.ExprType(node.Left.ID()) + if conversion, found := e.evidence.ImplicitConversion(node.Left.ID()); found && + conversion.Kind == typeinfo.ConversionNumeric && conversion.Compatibility == typeinfo.Compatible { + operandType = e.evidence.ExprType(node.Right.ID()) + } + } + } + left, lok := e.evalExpr(node.Left, operandType) + right, rok := e.evalExpr(node.Right, operandType) if !lok || !rok { return nil, false } if folded, ok := constvalue.FoldBinary(node.Op, left, right); ok { return folded, true } - if expected != nil { + if checkedType != nil || expected != nil { return nil, false } commonType := typeinfo.CommonNumericType(&typeinfo.NamedType{Name: left.TypeText()}, &typeinfo.NamedType{Name: right.TypeText()}) @@ -284,20 +318,29 @@ func expectedNumericConstValue(value constvalue.Value, expected typeinfo.Type) ( if expected == nil { return value, true } - family, _, ok := typeinfo.NumericInfo(expected) + family, bits, ok := typeinfo.NumericInfo(expected) if !ok { return value, true } typeText := typeinfo.TypeText(typeinfo.Underlying(expected)) switch v := value.(type) { case *constvalue.IntConst: - if v == nil { + integer := v.Int() + if integer == nil { return nil, false } if family == typeinfo.NumericFloat { - return constvalue.NewFloatText(v.Text(), typeText) + // Round the exact integer directly to its destination. Routing f32 + // through f64 can double-round, and text parsing rejects IEEE overflow. + number := new(big.Float).SetInt(integer) + if bits == 32 { + rounded, _ := number.Float32() + return constvalue.NewFloat(float64(rounded), typeText) + } + rounded, _ := number.Float64() + return constvalue.NewFloat(rounded, typeText) } - return constvalue.NewInt(v.Int(), typeText) + return constvalue.NewInt(integer, typeText) case *constvalue.FloatConst: if v == nil { return nil, false @@ -305,7 +348,16 @@ func expectedNumericConstValue(value constvalue.Value, expected typeinfo.Type) ( if family == typeinfo.NumericFloat { return constvalue.NewFloat(v.Float(), typeText) } - return nil, false + if math.IsNaN(v.Float()) || math.IsInf(v.Float(), 0) { + return nil, false + } + integer, _ := big.NewFloat(v.Float()).Int(nil) + // LLVM float-to-integer conversion is undefined outside the destination + // range. Do not turn an unmaterializable cast into a wrapping integer. + if !numeric.FitsIntegerLiteral(integer.String(), bits, family == typeinfo.NumericSigned) { + return nil, false + } + return constvalue.NewInt(integer, typeText) default: return value, true } diff --git a/internal/semantics/typechecker/constant_eval_test.go b/internal/semantics/typechecker/constant_eval_test.go index 5bfa4260..e6b112e6 100644 --- a/internal/semantics/typechecker/constant_eval_test.go +++ b/internal/semantics/typechecker/constant_eval_test.go @@ -2,6 +2,7 @@ package typechecker import ( "fmt" + "math" "testing" "compiler/internal/constvalue" @@ -16,7 +17,6 @@ import ( "compiler/internal/semantics/binder" "compiler/internal/semantics/collector" "compiler/internal/semantics/resolver" - "compiler/internal/semantics/symbols" "compiler/internal/semantics/typeinfo" "compiler/pkg/peeper" ) @@ -126,12 +126,110 @@ func TestEvaluateIntegerCastWithoutTypingEvidence(t *testing.T) { module, diag := constevalModule(t, `type Count = u8; const Converted: Count = 258u16 as Count; const Shifted: u8 = 128u8 >> Converted; +const Inferred = 258u16 as Count; +const Widened: u16 = 258u16 as Count; +const InferredByte = 258u16 as byte; `) if diag.HasErrors() { t.Fatalf("unexpected diagnostics:\n%s", diag.EmitAllToString()) } assertIntConst(t, module, "Shifted", "32", "u8") assertIntConst(t, module, "Converted", "2", "u8") + assertIntConst(t, module, "Inferred", "2", "u8") + assertIntConst(t, module, "Widened", "2", "u16") + assertIntConst(t, module, "InferredByte", "2", "byte") +} + +func TestEvaluateCheckedNumericConversions(t *testing.T) { + for _, test := range []struct{ name, source, value, typ string }{ + {"mixed operands", "const Value: i64 = 1i32 + 2i64;", "3", "i64"}, + {"explicit initializer", "const Value: i64 = 2i8;", "2", "i64"}, + {"signed widening", "const Value: u16 = -1i8;", "65535", "u16"}, + {"source overflow", "const Value: i64 = 127i8 + 1i8;", "-128", "i64"}, + {"nested overflow", "const Value: i64 = (127i8 + 1i8) + 1i64;", "-127", "i64"}, + {"unary overflow", "const Value: i64 = -(-128i8);", "-128", "i64"}, + {"implicit float widening", "const Value: f64 = 1.25f32;", "1.25", "f64"}, + {"mixed floats", "const Value: f64 = 1.25f32 + 2.5f64;", "3.75", "f64"}, + {"explicit float narrowing", "const Value: f32 = 1.25f64 as f32;", "1.25", "f32"}, + {"round then widen", "const Value: f64 = 16777217f64 as f32;", "1.6777216e+07", "f64"}, + {"aliased round then widen", "type Narrow = f32; type Wide = f64; const Value: Wide = 16777217f64 as Narrow;", "1.6777216e+07", "f64"}, + {"negative zero then widen", "const Value: f64 = -0f64 as f32;", "-0", "f64"}, + {"float intermediate width", "const Value: f64 = 16777216f32 + 1f32;", "1.6777216e+07", "f64"}, + {"integer to float", "const Value: f32 = -3i8 as f32;", "-3", "f32"}, + {"float to integer", "const Value: i8 = -3.75f64 as i8;", "-3", "i8"}, + } { + t.Run(test.name, func(t *testing.T) { + module, diag := checkTypeModule(t, test.source) + if diag.HasErrors() { + t.Fatalf("unexpected diagnostics:\n%s", diag.EmitAllToString()) + } + value := evaluatedConst(t, module, "Value") + text, ok := value.(interface{ Text() string }) + if !ok || text.Text() != test.value || value.TypeText() != test.typ { + t.Fatalf("published Value = %#v, want %s %s", value, test.value, test.typ) + } + }) + } + module, diag := checkTypeModule(t, `const Less: bool = 1i8 < 2i64; +const Greater: bool = 2i64 > 1i8;`) + if diag.HasErrors() { + t.Fatalf("unexpected comparison diagnostics:\n%s", diag.EmitAllToString()) + } + assertBoolConst(t, module, "Less", true) + assertBoolConst(t, module, "Greater", true) +} + +func TestEvaluateRejectsUnrepresentableFloatToIntegerConstants(t *testing.T) { + for _, expression := range []string{ + "128f64 as i8", "-129f64 as i8", "-1f64 as u8", "256f64 as u8", + "(1f64 / 0f64) as i32", "(0f64 / 0f64) as i32", + } { + t.Run(expression, func(t *testing.T) { + module, diag := checkTypeModule(t, "const Value = "+expression+";") + if diag.HasErrors() { + t.Fatalf("unexpected typing diagnostics:\n%s", diag.EmitAllToString()) + } + if value := evaluatedConst(t, module, "Value"); value != nil { + t.Fatalf("unrepresentable float cast published %#v", value) + } + }) + } +} + +func TestEvaluateIntegerToFloatRoundsAtDestinationWidth(t *testing.T) { + for _, test := range []struct { + name, expression, typ string + want float64 + }{ + {"midpoint", "9223372586610589696u64 as f32", "f32", 9223372036854775808}, + {"above midpoint", "9223372586610589697u64 as f32", "f32", 9223373136366403584}, + {"negative above midpoint", "-9007199791611905i64 as f32", "f32", -9007200328482816}, + {"f32 infinity", "(1u256 << 128u256) as f32", "f32", math.Inf(1)}, + {"f64 infinity", "(1u2048 << 1024u2048) as f64", "f64", math.Inf(1)}, + {"negative infinity", "-(1i2048 << 1024i2048) as f64", "f64", math.Inf(-1)}, + } { + t.Run(test.name, func(t *testing.T) { + module, diag := checkTypeModule(t, "const Value = "+test.expression+";") + if diag.HasErrors() { + t.Fatalf("unexpected diagnostics:\n%s", diag.EmitAllToString()) + } + published := evaluatedConst(t, module, "Value") + value, ok := published.(*constvalue.FloatConst) + if !ok || value == nil || value.Float() != test.want || value.TypeText() != test.typ { + t.Fatalf("published Value = %#v, want %g %s", published, test.want, test.typ) + } + }) + } +} + +func TestExpectedNumericConstValueRejectsInvalidIntegers(t *testing.T) { + for _, value := range []*constvalue.IntConst{nil, {}} { + for _, typ := range []typeinfo.Type{&typeinfo.FloatType{Bits: 32}, &typeinfo.IntegerType{IsSigned: true, Bits: 64}} { + if converted, ok := expectedNumericConstValue(value, typ); ok || converted != nil { + t.Fatalf("invalid integer converted to %#v", converted) + } + } + } } func TestEvaluateBitwiseConstExpressionsThroughIntegralAlias(t *testing.T) { @@ -319,8 +417,13 @@ func TestEvaluateRejectsUnboundConstantReferences(t *testing.T) { ctx := project.New(".", peeper.SourceExt, diag) ctx.AddModule(module) evaluator := newConstantEvaluator(ctx, module, newEvidence()) - if value, ok := evaluator.evalExpr(&ast.Ident{Name: "Value"}, nil); ok { - t.Fatalf("unbound identifier evaluated to %#v through name lookup", value) + for _, expr := range []ast.Expr{ + &ast.Ident{Name: "Value"}, + &ast.ScopeResolution{Segments: []ast.PathSegment{{Name: &ast.Ident{Name: "owner"}}, {Name: &ast.Ident{Name: "Value"}}}}, + } { + if value, ok := evaluator.evalExpr(expr, nil); ok { + t.Fatalf("unbound %T evaluated to %#v through name lookup", expr, value) + } } } @@ -344,7 +447,9 @@ func TestEvaluateReadsForeignPublishedConstantWithoutConsumerCache(t *testing.T) resolver.Resolve(ctx, module) } - owner := parse("owner"+peeper.SourceExt, "owner", "const Shared: i32 = 7;") + owner := parse("owner"+peeper.SourceExt, "owner", `const Shared: i32 = 7; +type Number = i32; +fn Read() -> i32 { return 7; }`) resolve(owner) finalizeConstantValues(ctx, owner, newEvidence()) shared, found := owner.ModuleScope.LookupLocal("Shared") @@ -355,26 +460,62 @@ func TestEvaluateReadsForeignPublishedConstantWithoutConsumerCache(t *testing.T) t.Fatalf("publish shared constant: %v", err) } - consumer := parse("consumer"+peeper.SourceExt, "consumer", "const Local = Shared;") + consumer := parse("consumer"+peeper.SourceExt, "consumer", `const Local = Shared; +const Qualified = owner::Shared; +fn Probe() { + let Mutable: i32 = 7; + const Variable = Mutable; + const Function = owner::Read; + const Type = owner::Number; +}`) + consumer.Imports["owner"] = module.ResolvedImport{ID: owner.ID, FilePath: owner.FilePath} resolve(consumer) - local, found := consumer.ModuleScope.LookupLocal("Local") - if !found || local == nil { - t.Fatal("missing consumer constant") + if diag.HasErrors() { + t.Fatalf("foreign binding setup failed:\n%s", diag.EmitAllToString()) } + // Foreign reads must use the published value, not re-evaluate changed owner AST. + shared.ASTNode.(*ast.ConstDecl).Value = &ast.NumberLit{Value: "99"} evaluator := newConstantEvaluator(ctx, consumer, newEvidence()) - if _, ok := evaluator.evalConstSymbol(local); !ok { - t.Fatal("failed to lazily evaluate imported constant") - } - cached := evaluator.cache[local.ID] - value, ok := cached.(*constvalue.IntConst) - if !ok || value == nil || value.Text() != "7" { - t.Fatalf("consumer value = %#v, want 7", cached) + for _, name := range []string{"Local", "Qualified"} { + local, found := consumer.ModuleScope.LookupLocal(name) + if !found || local == nil { + t.Fatalf("missing consumer constant %s", name) + } + if _, ok := evaluator.evalConstSymbol(local); !ok { + t.Fatalf("failed to lazily evaluate %s", name) + } + cached := evaluator.cache[local.ID] + value, ok := cached.(*constvalue.IntConst) + if !ok || value == nil || value.Text() != "7" { + t.Fatalf("%s consumer value = %#v, want published 7", name, cached) + } } if _, found := evaluator.cache[shared.ID]; found { t.Fatal("foreign constant duplicated in consumer query cache") } - if owner.SymbolIndex.ConstantValue(shared.ID) == nil { - t.Fatal("owner lost published constant") + if value := owner.SymbolIndex.ConstantValue(shared.ID).(*constvalue.IntConst); value.Text() != "7" { + t.Fatalf("owner published value changed to %s", value.Text()) + } + probe := consumer.AST.Stmts[2].(*ast.FnDecl) + for _, stmt := range probe.Body.Stmts[1:] { + decl := stmt.(*ast.ConstDecl) + expr := decl.Value + bound := consumer.SymbolIndex.Symbol(expr) + if bound == nil { + t.Fatalf("nonconstant %s has no canonical binding", decl.Name.Name) + } + evaluator.cache[bound.ID], _ = constvalue.NewIntText("99", "i32") + if value, ok := evaluator.evalExpr(expr, nil); ok { + t.Fatalf("nonconstant %s evaluated to %#v", decl.Name.Name, value) + } + } + owner.SymbolIndex.ClearConstants() + evaluator.cache[shared.ID], _ = constvalue.NewIntText("99", "i32") + for _, stmt := range consumer.AST.Stmts[:2] { + expr := stmt.(*ast.ConstDecl).Value + if value, ok := evaluator.evalExpr(expr, nil); ok { + t.Fatalf("unpublished foreign %T evaluated to %#v through consumer cache or owner AST", expr, value) + } } if diag.HasErrors() { t.Fatalf("unexpected diagnostics:\n%s", diag.EmitAllToString()) @@ -397,11 +538,7 @@ func TestEvaluateStringConst(t *testing.T) { if diag.HasErrors() { t.Fatalf("unexpected diagnostics:\n%s", diag.EmitAllToString()) } - sym, ok := module.ModuleScope.LookupLocal("Name") - if !ok || sym == nil { - t.Fatalf("missing symbol Name") - } - value := evaluatedConst(module, sym.ID) + value := evaluatedConst(t, module, "Name") got, ok := value.(*constvalue.StringConst) if !ok || got == nil || got.Text() != "puts" || got.TypeText() != "cstr" { t.Fatalf("Name = %#v, want str puts cstr", value) @@ -410,31 +547,28 @@ func TestEvaluateStringConst(t *testing.T) { func assertIntConst(t *testing.T, module *module.Module, name, want, wantType string) { t.Helper() - sym, ok := module.ModuleScope.LookupLocal(name) - if !ok || sym == nil { - t.Fatalf("missing symbol %s", name) - } - value := evaluatedConst(module, sym.ID) + value := evaluatedConst(t, module, name) got, ok := value.(*constvalue.IntConst) if !ok || got == nil || got.Text() != want || (wantType != "" && got.TypeText() != wantType) { t.Fatalf("%s = %#v, want int %s %s", name, value, want, wantType) } } -func evaluatedConst(module *module.Module, id symbols.SymbolID) constvalue.Value { - if module == nil || module.SymbolIndex == nil { - return nil +func evaluatedConst(t *testing.T, module *module.Module, name string) constvalue.Value { + t.Helper() + if module == nil || module.ModuleScope == nil || module.SymbolIndex == nil { + t.Fatal("constant publication setup missing") + } + sym, ok := module.ModuleScope.LookupLocal(name) + if !ok || sym == nil { + t.Fatalf("missing constant %s", name) } - return module.SymbolIndex.ConstantValue(id) + return module.SymbolIndex.ConstantValue(sym.ID) } func assertBoolConst(t *testing.T, module *module.Module, name string, want bool) { t.Helper() - sym, ok := module.ModuleScope.LookupLocal(name) - if !ok || sym == nil { - t.Fatalf("missing symbol %s", name) - } - value := evaluatedConst(module, sym.ID) + value := evaluatedConst(t, module, name) got, ok := value.(*constvalue.BoolConst) if !ok || got == nil || got.Bool() != want { t.Fatalf("%s = %#v, want bool %v", name, value, want) diff --git a/internal/semantics/typechecker/typechecker_test.go b/internal/semantics/typechecker/typechecker_test.go index 56b989b1..8642b267 100644 --- a/internal/semantics/typechecker/typechecker_test.go +++ b/internal/semantics/typechecker/typechecker_test.go @@ -1739,6 +1739,52 @@ fn first(xs: [4]i32) -> i32 { } } +func TestArrayConstantIndexPreservesCheckedTypeAndValue(t *testing.T) { + for _, test := range []struct { + name, declarations, index, value, typ string + rejected bool + }{ + {"wide float cast", "", "4294967296f64 as u64", "4294967296", "u64", true}, + {"negative float cast", "", "(-4294967296f64) as i64", "-4294967296", "i64", true}, + {"wide signed constant", "const Offset: i64 = 4294967296i64;", "Offset", "4294967296", "i64", true}, + {"negative signed constant", "const Offset: i64 = -4294967296i64;", "Offset", "-4294967296", "i64", true}, + {"unsigned boundary", "const Offset: u64 = 18446744073709551615u64;", "Offset", "18446744073709551615", "u64", true}, + {"beyond host integer", "const Offset: u128 = 1u128 << 64u128;", "Offset", "18446744073709551616", "u128", true}, + {"wide integer cast", "", "4294967296u64 as i64", "4294967296", "i64", true}, + {"wide literal control", "", "4294967296u64", "4294967296", "u64", true}, + {"default literal", "", "1", "1", "i32", false}, + {"fractional float cast", "", "1.75f64 as u64", "1", "u64", false}, + {"negative fraction", "", "(-0.75f64) as i64", "0", "i64", false}, + {"explicit narrowing", "", "257u16 as u8", "1", "u8", false}, + {"source width negation", "const Offset: u8 = 255u8;", "-Offset", "1", "u8", false}, + {"alias constant", "type Index = u64; const Offset: Index = 1u64;", "Offset", "1", "u64", false}, + } { + t.Run(test.name, func(t *testing.T) { + mod, diag := checkTypeModule(t, test.declarations+"\nfn first(values: [2]i32) -> i32 { return values["+test.index+"]; }") + fn := mod.AST.Stmts[len(mod.AST.Stmts)-1].(*ast.FnDecl) + index := fn.Body.Stmts[0].(*ast.ReturnStmt).Value.(*ast.IndexExpr) + typing := testEvidence(mod) + constant, found := typing.ConstantIndex(index.ID()) + if !found || constant.Text != test.value || typeinfo.TypeText(typeinfo.Underlying(constant.Type)) != test.typ || + !typeinfo.IsSameType(constant.Type, typing.ExprType(index.Index.ID())) { + t.Fatalf("constant index = %#v, want checked %s %s", constant, test.typ, test.value) + } + if !test.rejected { + if diag.HasErrors() { + t.Fatalf("unexpected diagnostics:\n%s", diag.EmitAllToString()) + } + return + } + items := diag.Diagnostics() + if len(items) != 1 || items[0].Code != diagnostics.ErrArrayOutOfBounds || + items[0].Message != "array index out of bounds: index "+test.value+" for length 2" || + len(items[0].Labels) != 1 || items[0].Labels[0].Location != ast.LocOf(index.Index) { + t.Fatalf("expected exact located bounds diagnostic, got:\n%s", diag.EmitAllToString()) + } + }) + } +} + func TestArrayIndexExprAcceptsRuntimeIndex(t *testing.T) { src := `fn first(xs: [4]i32, i: i32) -> i32 { return xs[i]; diff --git a/x_test/negative_host_sized_array_bounds/peeper.toml b/x_test/negative_host_sized_array_bounds/peeper.toml new file mode 100644 index 00000000..b44de95a --- /dev/null +++ b/x_test/negative_host_sized_array_bounds/peeper.toml @@ -0,0 +1,8 @@ +name = "negative_host_sized_array_bounds" +build = "program" + +[test] +mode = "check" +outcome = "failure" +compiler_args = ["-target-os", "linux", "-target-arch", "amd64"] +stderr_contains = ["array index out of bounds: index -1 for length 9223372036854775808", "main.peep:2:"] diff --git a/x_test/negative_host_sized_array_bounds/src/main.peep b/x_test/negative_host_sized_array_bounds/src/main.peep new file mode 100644 index 00000000..de14b653 --- /dev/null +++ b/x_test/negative_host_sized_array_bounds/src/main.peep @@ -0,0 +1,5 @@ +fn read(xs: [9223372036854775808u64]u8) -> u8 { + return xs[-1i64]; +} + +fn main() -> i32 { return 0; } diff --git a/x_test/negative_imported_constant_queries/peeper.toml b/x_test/negative_imported_constant_queries/peeper.toml new file mode 100644 index 00000000..f86ebcb6 --- /dev/null +++ b/x_test/negative_imported_constant_queries/peeper.toml @@ -0,0 +1,7 @@ +name = "negative_imported_constant_queries" +build = "program" + +[test] +mode = "check" +outcome = "failure" +stderr_contains = ["array index out of bounds: index 18446744073709551616 for length 1", "shift count must be between 0 and 7"] diff --git a/x_test/negative_imported_constant_queries/src/main.peep b/x_test/negative_imported_constant_queries/src/main.peep new file mode 100644 index 00000000..84f74a09 --- /dev/null +++ b/x_test/negative_imported_constant_queries/src/main.peep @@ -0,0 +1,23 @@ +import "negative_imported_constant_queries/values"; + +const Saved: u128 = values::Offset; + +fn Direct() -> i32 { + let items = [1]i32{7}; + return items[values::Offset]; +} + +fn Local() -> i32 { + const LocalOffset: u128 = values::Offset; + let items = [1]i32{7}; + return items[LocalOffset]; +} + +fn ModuleAlias() -> i32 { + let items = [1]i32{7}; + return items[Saved]; +} + +fn InvalidShift() -> u8 { return 1u8 << values::Shift; } + +fn main() -> i32 { return Direct() + Local() + ModuleAlias(); } diff --git a/x_test/negative_imported_constant_queries/src/values.peep b/x_test/negative_imported_constant_queries/src/values.peep new file mode 100644 index 00000000..5a5d71a7 --- /dev/null +++ b/x_test/negative_imported_constant_queries/src/values.peep @@ -0,0 +1,2 @@ +const Offset: u128 = 1u128 << 64u128; +const Shift: u8 = 8u8; diff --git a/x_test/negative_module_constant_materialization/peeper.toml b/x_test/negative_module_constant_materialization/peeper.toml new file mode 100644 index 00000000..8d17dc25 --- /dev/null +++ b/x_test/negative_module_constant_materialization/peeper.toml @@ -0,0 +1,7 @@ +name = "negative_module_constant_materialization" +build = "program" + +[test] +mode = "build" +outcome = "failure" +stderr_contains = ["module constant `Value` cannot be evaluated at compile time"] diff --git a/x_test/negative_module_constant_materialization/src/main.peep b/x_test/negative_module_constant_materialization/src/main.peep new file mode 100644 index 00000000..247ef218 --- /dev/null +++ b/x_test/negative_module_constant_materialization/src/main.peep @@ -0,0 +1,5 @@ +const Value: i32 = 1i32 / 0i32; + +fn main() -> i32 { + return Value; +} diff --git a/x_test/negative_wide_constant_indexes/peeper.toml b/x_test/negative_wide_constant_indexes/peeper.toml new file mode 100644 index 00000000..1b0a7ef1 --- /dev/null +++ b/x_test/negative_wide_constant_indexes/peeper.toml @@ -0,0 +1,7 @@ +name = "negative_wide_constant_indexes" +build = "program" + +[test] +mode = "check" +outcome = "failure" +stderr_contains = ["array index out of bounds: index 4294967296 for length 1", "array index out of bounds: index -4294967296 for length 1", "array index out of bounds: index 18446744073709551616 for length 1"] diff --git a/x_test/negative_wide_constant_indexes/src/main.peep b/x_test/negative_wide_constant_indexes/src/main.peep new file mode 100644 index 00000000..d41a260e --- /dev/null +++ b/x_test/negative_wide_constant_indexes/src/main.peep @@ -0,0 +1,10 @@ +const Signed: i64 = -4294967296i64; +const Unsigned: u128 = 1u128 << 64u128; + +fn main() -> i32 { + let values = [1]i32{7}; + let _ = values[4294967296f64 as u64]; + let _ = values[(-4294967296f64) as i64]; + let _ = values[Signed]; + return values[Unsigned]; +} diff --git a/x_test/positive_large_array_bounds/peeper.toml b/x_test/positive_large_array_bounds/peeper.toml new file mode 100644 index 00000000..a3afd607 --- /dev/null +++ b/x_test/positive_large_array_bounds/peeper.toml @@ -0,0 +1,7 @@ +name = "positive_large_array_bounds" +build = "program" + +[test] +mode = "check" +outcome = "success" +compiler_args = ["-target-os", "linux", "-target-arch", "amd64"] diff --git a/x_test/positive_large_array_bounds/src/main.peep b/x_test/positive_large_array_bounds/src/main.peep new file mode 100644 index 00000000..2fc1b0da --- /dev/null +++ b/x_test/positive_large_array_bounds/src/main.peep @@ -0,0 +1,8 @@ +struct Large { + values: [9223372036854775809u64]u8, + flag: bool +} + +fn first(value: &Large) -> u8 { return value.values[0u64]; } +fn read(value: &Large, index: i64) -> u8 { return value.values[index]; } +fn main() -> i32 { return 0; } diff --git a/x_test/runtime_checked_constant_indexes/peeper.toml b/x_test/runtime_checked_constant_indexes/peeper.toml new file mode 100644 index 00000000..4643c89e --- /dev/null +++ b/x_test/runtime_checked_constant_indexes/peeper.toml @@ -0,0 +1,6 @@ +name = "runtime_checked_constant_indexes" +build = "program" + +[test] +mode = "run" +outcome = "success" diff --git a/x_test/runtime_checked_constant_indexes/src/indexes.peep b/x_test/runtime_checked_constant_indexes/src/indexes.peep new file mode 100644 index 00000000..75fbedf9 --- /dev/null +++ b/x_test/runtime_checked_constant_indexes/src/indexes.peep @@ -0,0 +1,4 @@ +type Index = u64; +const Selected: Index = 1u64; +const Wrapped: u8 = 255u8; +const Bit: u1 = 1u1; diff --git a/x_test/runtime_checked_constant_indexes/src/main.peep b/x_test/runtime_checked_constant_indexes/src/main.peep new file mode 100644 index 00000000..e71d7fa6 --- /dev/null +++ b/x_test/runtime_checked_constant_indexes/src/main.peep @@ -0,0 +1,38 @@ +import "runtime_checked_constant_indexes/indexes"; + +type Bit = u1; +struct Cell { value: i32 } +struct Box { guard: i32, values: [2]Cell } + +fn read(cell: &Cell) -> i32 { return cell.value; } +fn write(cell: &mut Cell, value: i32) { cell.value = value; } + +fn main() -> i32 { + const Wrapped: u8 = 255u8; + let values = [2]i32{7, 9}; + if values[0f64 as u64] != 7 { return 1; } + if values[1.75f64 as i64] != 9 { return 2; } + if values[(-0.75f64) as i64] != 7 { return 3; } + if values[indexes::Selected] != 9 { return 4; } + if values[-indexes::Wrapped] != 9 { return 5; } + if values[257u16 as u8] != 9 { return 6; } + if values[-Wrapped] != 9 { return 7; } + + const Selected: Bit = 1u1; + let mut box = Box.{guard = 31, values = [2]Cell{.{value = 7}, .{value = 9}}}; + if box.values[1f64 as u1].value != 9 { return 8; } + if box.values[1u1].value != 9 || box.values[0u1].value != 7 { return 9; } + if read(&box.values[Selected]) != 9 { return 10; } + if @box.values[1f64 as u1].value != @box.values[1u64].value { return 11; } + + box.values[1f64 as u1].value = 13; + if box.guard != 31 || box.values[0u64].value != 7 || box.values[1u64].value != 13 { return 12; } + write(&mut box.values[Selected], 17); + if box.guard != 31 || box.values[0u64].value != 7 || box.values[1u64].value != 17 { return 13; } + let index = 1f64 as u1; + if box.values[index].value != 17 { return 14; } + if read(&box.values[indexes::Bit]) != 17 { return 15; } + write(&mut box.values[indexes::Bit], 19); + if box.guard != 31 || box.values[0u64].value != 7 || box.values[1u64].value != 19 { return 16; } + return 0; +} diff --git a/x_test/runtime_constant_conversions/peeper.toml b/x_test/runtime_constant_conversions/peeper.toml new file mode 100644 index 00000000..25066013 --- /dev/null +++ b/x_test/runtime_constant_conversions/peeper.toml @@ -0,0 +1,6 @@ +name = "runtime_constant_conversions" +build = "program" + +[test] +mode = "run" +outcome = "success" diff --git a/x_test/runtime_constant_conversions/src/main.peep b/x_test/runtime_constant_conversions/src/main.peep new file mode 100644 index 00000000..4833cca2 --- /dev/null +++ b/x_test/runtime_constant_conversions/src/main.peep @@ -0,0 +1,38 @@ +import "runtime_constant_conversions/numbers"; + +const ImportedInteger: i64 = numbers::ConvertedInteger; +const ImportedRounded: f64 = numbers::RoundedInteger; + +fn DefaultWide(value: i64 = numbers::Total) -> i64 { return value; } + +fn ReadWide(value: i64) -> i64 { + return value; +} + +fn ConvertInteger(value: u64) -> f32 { + return value as f32; +} + +fn main() -> i32 { + if ReadWide(numbers::Total) != 3i64 { return 1; } + if numbers::Ratio != 1.25f32 { return 2; } + if ReadWide(numbers::Wide) != 2i64 { return 3; } + if numbers::SourceOverflow != -128i64 { return 4; } + if numbers::NestedOverflow != -127i64 { return 5; } + if numbers::UnaryOverflow != -128i64 { return 6; } + if numbers::RoundedWide != 16777216f64 { return 7; } + if numbers::FloatOverflow != 16777216f64 { return 8; } + if numbers::ConvertedFloat != -3f32 { return 9; } + if numbers::ConvertedInteger != -3i8 { return 10; } + let rounded = ConvertInteger(9223372586610589697u64); + if (rounded as u64) != 9223373136366403584u64 { return 11; } + if numbers::RoundedInteger != rounded { return 12; } + let infinity = (1u2048 << 1024u2048) as f64; + if numbers::IntegerInfinity != infinity { return 13; } + if numbers::IntegerInfinity != (1f64 / 0f64) { return 14; } + if ImportedInteger != -3i64 { return 15; } + if ImportedRounded != 9223373136366403584f64 { return 16; } + const Total: i64 = 99i64; + if DefaultWide() != 3i64 || Total != 99i64 { return 17; } + return 0; +} diff --git a/x_test/runtime_constant_conversions/src/numbers.peep b/x_test/runtime_constant_conversions/src/numbers.peep new file mode 100644 index 00000000..bcdc2d00 --- /dev/null +++ b/x_test/runtime_constant_conversions/src/numbers.peep @@ -0,0 +1,14 @@ +type WideInteger = i64; + +const Total: WideInteger = 1i32 + 2i64; +const Ratio: f32 = 1.25f64 as f32; +const Wide: i64 = 2i8; +const SourceOverflow: i64 = 127i8 + 1i8; +const NestedOverflow: i64 = (127i8 + 1i8) + 1i64; +const UnaryOverflow: i64 = -(-128i8); +const RoundedWide: f64 = 16777217f64 as f32; +const FloatOverflow: f64 = 16777216f32 + 1f32; +const ConvertedFloat: f32 = -3i8 as f32; +const ConvertedInteger: i8 = -3.75f64 as i8; +const RoundedInteger: f32 = 9223372586610589697u64 as f32; +const IntegerInfinity: f64 = (1u2048 << 1024u2048) as f64; diff --git a/x_test/runtime_module_discard_constants/peeper.toml b/x_test/runtime_module_discard_constants/peeper.toml new file mode 100644 index 00000000..2644d3a6 --- /dev/null +++ b/x_test/runtime_module_discard_constants/peeper.toml @@ -0,0 +1,6 @@ +name = "runtime_module_discard_constants" +build = "program" + +[test] +mode = "run" +outcome = "success" diff --git a/x_test/runtime_module_discard_constants/src/main.peep b/x_test/runtime_module_discard_constants/src/main.peep new file mode 100644 index 00000000..cf1fc2ac --- /dev/null +++ b/x_test/runtime_module_discard_constants/src/main.peep @@ -0,0 +1,6 @@ +const _: i32 = 1i32; +const _: f64 = 1.25f32; + +fn main() -> i32 { + return 0; +} From b0653756d441aaffe1d096ffb8739303592ba688 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Tue, 6 Oct 2026 15:55:59 +0600 Subject: [PATCH 2/4] Add repo-specific Peeper code review skill Capture deletion-first, reuse-first review preferences with Standards and Spec evidence, explicit worktree scope and smallest equivalent remedies. Link the skill from AGENTS.md and expose only its intended path through narrow ignore exceptions. Reuse existing engineering rules and validation tools. --- .agents/skills/peeper-code-review/SKILL.md | 83 ++++++++++++++++++++++ .gitignore | 7 ++ AGENTS.md | 4 ++ 3 files changed, 94 insertions(+) create mode 100644 .agents/skills/peeper-code-review/SKILL.md diff --git a/.agents/skills/peeper-code-review/SKILL.md b/.agents/skills/peeper-code-review/SKILL.md new file mode 100644 index 00000000..fff45cb0 --- /dev/null +++ b/.agents/skills/peeper-code-review/SKILL.md @@ -0,0 +1,83 @@ +--- +name: peeper-code-review +description: Review Peeper compiler changes for correctness and simple, maintainable code. Use for this repository's uncommitted changes, branches or PRs, especially requests to avoid overengineering, simplify code, reduce redundant variables/helpers/state, or find clearer equivalent solutions. Report concrete findings and smallest safe remedies; review does not imply permission to apply fixes. +--- + +# Peeper code review + +Optimize for fewer concepts to maintain and code a new contributor can read cold. +Deletion and direct reuse beat new layers. Clear, equivalent code beats clever brevity. + +## Pin scope + +- Read repo `RULES.md`, `AGENTS.md`, `go-style.md` and relevant active local plan. + Consult specs/architecture for changed behavior, then verify claims against source. + Reference existing rules instead of creating another policy or precedence hierarchy. +- Inspect status and resolve requested baseline. For **current uncommitted changes**, + default to `git diff HEAD -- ` for final worktree state; inspect staged diff + separately when index differs from worktree. Read untracked source/tests/fixtures. + An empty committed range is not an empty worktree. For branch/PR review, use requested + base/merge-base and included commits. +- Identify task work and pre-existing user edits. Cover requested source/documentation; + do not treat unrelated edits as scope creep or overwrite them. Exclude generated + artifacts and operational journals/local plans from implementation findings. +- Review source read-only unless user requested fixes. Follow existing agent edit gates + and delivery authorization when implementation is requested. + +## Standards and simplicity + +Trace changed symbols to real callers and existing canonical owners before suggesting +abstraction, extraction or replacement. Review tests and docs as well as production. + +For each changed concept, ask: + +1. Does it need to exist? Recommend deletion of speculative state and stale layers. +2. Can stdlib, existing dependency or current owner do it directly? Prefer reuse. +3. Can deletion, inlining or a plain guard/loop/switch make the same behavior clearer? +4. Does a variable name a useful domain fact, prevent repeated/effectful evaluation or + clarify an invariant? Keep it. If it only renames a clear expression, suggest removal. +5. Is a helper/type/cache/boundary justified under `RULES.md`? Keep real reuse and + ownership/invariant boundaries; eliminate pass-through or single-use simple layers. +6. Is identical logic repeated? Use its canonical owner. Similar-looking operations + with different semantic or validation responsibilities should remain distinct. + +Recommend the smallest equivalent change. State exactly what disappears and why the +replacement is easier to understand. Fewer lines alone do not justify nested expressions, +bit tricks, generic frameworks, hidden mutation or erasing validation. Do not manufacture +cleanup findings merely to hit a quota, rename good domain variables or chase unrelated code. + +## Spec and correctness + +- Trace changed producers and consumers through actual compiler phases. Check source + acceptance, lowering/materialization, backend output and runtime meaning together. +- Before deleting behavior, inventory validation/diagnostics and locations, mutation, + caching, normalization/fallbacks, locking, ownership/lifetimes and phase invariants. +- Numeric simplification must retain intermediate truncation, signed extension and + float rounding. Bounds must preserve exact values and trap-before-access ordering. +- Verify semantic/nominal identity, defining-owner publication and incomplete-type + construction where affected. Do not repeat decisions already owned by checked evidence. +- Check new symbols/fields have current production consumers. Tests should prove + behavior or invariants, not require production APIs/state only for observation. + +## Evidence and validation + +Use the smallest focused source/test probe needed to confirm a suspected problem. +Inspect baseline behavior when distinguishing a regression from an existing gap. +Keep temporary probes outside tracked source; add no production instrumentation. +Respect active plan's validation environment. Do not rerun unchanged expensive suites +without new edits, failures or unresolved evidence. Clearly attribute inherited results. +For language behavior, require appropriate `x_test/` coverage and bundled compiler; +for ABI/backend changes, distinguish target object validation from native execution. +Verify docs/skill edits with links, discovery/ignore rules and diff checks as relevant. + +## Report + +Findings first, under **Standards** and **Spec**. For each finding give: + +- severity and whether correctness defect, documented breach or optional cleanup; +- file and precise lines, actual consequence and source/rule/test evidence; +- smallest safe remedy, what it removes, and behavior that must be preserved. + +Distinguish introduced problems from existing gaps. Report zero findings when no +worthwhile improvement is justified. End with validation actually run, remaining +limitations and short Rules check. Keep review concise; no generic refactor wishlist. diff --git a/.gitignore b/.gitignore index b17a56f0..a6a5f84f 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,13 @@ graphify-out *.patch clean.md .*/ +!.agents/ +.agents/* +!.agents/skills/ +.agents/skills/* +!.agents/skills/peeper-code-review/ +.agents/skills/peeper-code-review/* +!.agents/skills/peeper-code-review/SKILL.md !.gitignore !.github/ !.github/workflows/ diff --git a/AGENTS.md b/AGENTS.md index 9ab51927..ab6eb94a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,10 @@ This file defines how agents must work in this repository. When documents conflict, do not let a file's self-declared precedence settle technical correctness. Identify the conflict, inspect evidence, and ask for a decision when required. Human-facing engineering rules belong in `RULES.md`; agent-only gates, local plans, GitHub automation, and response style belong here. +For code-review requests, read [Peeper code review](.agents/skills/peeper-code-review/SKILL.md). +It captures the review workflow and user's simplicity preferences; use `RULES.md` +for durable engineering requirements. + --- ## 1) Required pre-change check From 1e45c2199dc4035bfd92d5a041e6cff1605c7053 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Tue, 6 Oct 2026 20:57:08 +0600 Subject: [PATCH 3/4] Fix target-width array GEP indexes --- docs/architecture/ir.md | 7 +- internal/backend/llvm/instruction_emit.go | 9 ++- internal/pipeline/pipeline_test.go | 78 ++++++++++++++++++- .../peeper.toml | 7 ++ .../src/main.peep | 16 ++++ 5 files changed, 112 insertions(+), 5 deletions(-) create mode 100644 x_test/runtime_high_bit_empty_array_index/peeper.toml create mode 100644 x_test/runtime_high_bit_empty_array_index/src/main.peep diff --git a/docs/architecture/ir.md b/docs/architecture/ir.md index a16e8248..4c5ddd90 100644 --- a/docs/architecture/ir.md +++ b/docs/architecture/ir.md @@ -139,9 +139,10 @@ Discard (`_`) module bindings remain source-checked but emit no addressable stat LLVM fixed-array index lowering retains unsigned 64-bit lengths and compares constant indexes exactly; backend bounds diagnostics retain index source location. -After validation, constant offsets use existing signed/unsigned cast rules to i64 -for GEP; narrow unsigned values must not become negative through LLVM's implicit -sign extension. Checked constant-index types/values remain intact in THIR/MIR. +After exact logical bounds validation, physical offsets use the target pointer-index +width for GEP; narrow unsigned values must not become negative through LLVM's +implicit sign extension or wider-index truncation. Checked constant-index +types/values remain intact in THIR/MIR. Runtime indexes continue through the shared bounds guard before element access; signed indexes also reject negative values independently of unsigned length. diff --git a/internal/backend/llvm/instruction_emit.go b/internal/backend/llvm/instruction_emit.go index 4e5de72d..1d0978d7 100644 --- a/internal/backend/llvm/instruction_emit.go +++ b/internal/backend/llvm/instruction_emit.go @@ -169,7 +169,14 @@ func emitIndexPtr(b *llvmBuilder, base llvmValue, baseType ir.TypeID, addressed return llvmPlace{}, false } arrayPlace := b.pointerPlace(base) - return b.arrayElement(arrayPlace, index, true), true + physicalIndex := index + physicalLayout := b.emitter.layout(b.emitter.mod.Types.IndexType()) + if physicalIndex.Layout.Text != physicalLayout.Text { + // Bounds use i64 so wide values stay exact; GEP must receive the target + // pointer-index width so inbounds does not implicitly truncate it. + physicalIndex = b.cast("trunc", physicalIndex, physicalLayout) + } + return b.arrayElement(arrayPlace, physicalIndex, true), true } // Directly addressed roots need entry-block storage so one pointer dominates every place use. diff --git a/internal/pipeline/pipeline_test.go b/internal/pipeline/pipeline_test.go index 14fbc823..3d811e56 100644 --- a/internal/pipeline/pipeline_test.go +++ b/internal/pipeline/pipeline_test.go @@ -318,7 +318,8 @@ fn main() { if !foundCompare || !foundIndexMove || !foundProjection { t.Fatalf("MIR target-width evidence missing: compare=%v index=%v projection=%v\n%s", foundCompare, foundIndexMove, foundProjection, entry.MIR.Text()) } - if !strings.Contains(entry.LLVMIR, "icmp ult "+test.llvmType) || strings.Contains(entry.LLVMIR, "trunc i64") { + physicalTrunc := strings.Contains(entry.LLVMIR, "trunc i64") + if !strings.Contains(entry.LLVMIR, "icmp ult "+test.llvmType) || physicalTrunc != (test.arch == "386") { t.Fatalf("LLVM index width invalid for %s:\n%s", test.arch, entry.LLVMIR) } assertLLVMCompiles(t, targetInfo, entry.LLVMIR) @@ -2640,6 +2641,81 @@ func TestPipelineLowersLargeArrayBoundsAcrossTargets(t *testing.T) { } } +func TestPipelineLowersHighBitZeroSizedArrayIndexesAtPhysicalWidth(t *testing.T) { + for _, test := range []struct { + arch, length, lowIndex, highIndex, physicalIndex string + }{ + {"386", "2147483649", "2147483647", "2147483648", "i32"}, + {"amd64", "9223372036854775809", "9223372036854775807", "9223372036854775808", "i64"}, + } { + t.Run(test.arch, func(t *testing.T) { + var entry *module.Module + src := "struct Empty {}\n" + + "struct Large { values: [" + test.length + "u64]Empty }\n" + + "fn constant_low(value: &Large) -> bool { return @value.values[" + test.lowIndex + "u64] == @value.values[0u64]; }\n" + + "fn constant_high(value: &Large) -> bool { return @value.values[" + test.highIndex + "u64] == @value.values[0u64]; }\n" + + "fn runtime_high(value: &Large, index: usize) -> bool { return @value.values[index] == @value.values[0u64]; }" + diag := buildPipelineTestWithConfig(t, project.Config{ + RootDir: ".", Extension: peeper.SourceExt, TargetOS: "linux", TargetArch: test.arch, + }, "", src, func(mod *module.Module) { entry = mod }) + if diag.HasErrors() || entry.MIR == nil || entry.LLVMIR == "" { + t.Fatalf("high-bit zero-sized array access failed:\n%s", diag.EmitAllToString()) + } + lines := strings.Split(entry.LLVMIR, "\n") + arrayGEPs := 0 + for _, line := range lines { + if !strings.Contains(line, "getelementptr inbounds ["+test.length+" x") { + continue + } + arrayGEPs++ + if !strings.Contains(line, ", i32 0, "+test.physicalIndex+" ") { + t.Fatalf("array GEP used wrong physical index width:\n%s", entry.LLVMIR) + } + } + for _, index := range []string{test.lowIndex, test.highIndex} { + found := false + for lineIndex, line := range lines { + if test.physicalIndex == "i32" { + if strings.Contains(line, "trunc i64 "+index+" to i32") && + lineIndex+1 < len(lines) && + strings.Contains(lines[lineIndex+1], "getelementptr inbounds ["+test.length+" x") { + found = true + break + } + continue + } + if strings.Contains(line, "getelementptr inbounds ["+test.length+" x") && + strings.Contains(line, ", i32 0, i64 "+index) { + found = true + break + } + } + if !found { + t.Fatalf("boundary index %s did not reach final array GEP:\n%s", index, entry.LLVMIR) + } + } + runtimeGuard := false + for _, line := range lines { + if strings.Contains(line, "icmp uge i64") && strings.HasSuffix(strings.TrimSpace(line), ", "+test.length) { + runtimeGuard = true + break + } + } + if !runtimeGuard { + t.Fatalf("runtime index did not reach exact bounds guard:\n%s", entry.LLVMIR) + } + if arrayGEPs < 6 { + t.Fatalf("expected low/high constant and runtime array GEPs, got %d:\n%s", arrayGEPs, entry.LLVMIR) + } + targetInfo, err := target.New("linux", test.arch) + if err != nil { + t.Fatal(err) + } + assertLLVMCompiles(t, targetInfo, entry.LLVMIR) + }) + } +} + func TestPipelineLowersCheckedConstantIndexesAcrossTargets(t *testing.T) { for _, arch := range []string{"386", "amd64"} { for _, test := range []struct{ name, declaration, index, typ, value, conversion, length string }{ diff --git a/x_test/runtime_high_bit_empty_array_index/peeper.toml b/x_test/runtime_high_bit_empty_array_index/peeper.toml new file mode 100644 index 00000000..e269bb3f --- /dev/null +++ b/x_test/runtime_high_bit_empty_array_index/peeper.toml @@ -0,0 +1,7 @@ +name = "runtime_high_bit_empty_array_index" +build = "program" + +[test] +mode = "check" +outcome = "success" +compiler_args = ["-target-os", "linux", "-target-arch", "386"] diff --git a/x_test/runtime_high_bit_empty_array_index/src/main.peep b/x_test/runtime_high_bit_empty_array_index/src/main.peep new file mode 100644 index 00000000..5e0f4417 --- /dev/null +++ b/x_test/runtime_high_bit_empty_array_index/src/main.peep @@ -0,0 +1,16 @@ +struct Empty {} +struct Large { values: [2147483649u64]Empty } + +fn constant_high(value: &Large) -> bool { + return @value.values[2147483648u32] == @value.values[0u32]; +} + +fn constant_low(value: &Large) -> bool { + return @value.values[2147483647u32] == @value.values[0u32]; +} + +fn runtime_high(value: &Large, index: u32) -> bool { + return @value.values[index] == @value.values[0u32]; +} + +fn main() -> i32 { return 0; } From 1d658981fafef8e9f2d2008cc7f50d1a71421c38 Mon Sep 17 00:00:00 2001 From: itsfuad Date: Tue, 6 Oct 2026 20:57:25 +0600 Subject: [PATCH 4/4] Update Code-tour.md --- Code-tour.md | 133 ++++++++++++++++++++++++--------------------------- 1 file changed, 62 insertions(+), 71 deletions(-) diff --git a/Code-tour.md b/Code-tour.md index a7e808dc..6c01dec5 100644 --- a/Code-tour.md +++ b/Code-tour.md @@ -42,10 +42,11 @@ invoked as an external tool. Peeper does not link anything by hand. | `cmd/` | CLI commands: `build`, `run`, `check`, `dump`, `doctor` | | `internal/driver` | Thin wrapper that builds a `CompilerContext` and compiles one file | | `internal/pipeline` | Module loading and the phase ladder that drives everything | -| `internal/project` | `Module`, `CompilerContext` — where every phase artifact is stored | +| `internal/module` | `Module` and its phase artifacts | +| `internal/project` | `CompilerContext`, module registry, imports, and fingerprints | | `internal/frontend` | `token`, `lexer`, `parser`, `ast` | | `internal/semantics` | Collector, binder, resolver, const eval, typechecker, flow facts, effects, definite init, ownership, usage, plus the artifact packages | -| `internal/ir` | `cfg`, `hir`, `mir`, and the shared `ir` node/type model | +| `internal/ir` | `thir`, `cfg`, `exprlower`, `mir`, and the shared `ir` node/type model | | `internal/backend/llvm` | MIR → LLVM IR text | | `internal/toolchain` | Finds `clang` and the sysroot; builds its command lines | | `internal/diagnostics` | Errors, warnings, source rendering, phase attribution | @@ -86,7 +87,7 @@ Before any phase runs, the loader walks imports and builds a dependency graph. ```go // internal/pipeline/loader.go (simplified) -func (l *moduleLoader) Load(entry *project.Module) error { +func (l *moduleLoader) Load(entry *module.Module) error { l.enqueue(entry) for len(l.queue) > 0 { module := l.pop() @@ -129,14 +130,14 @@ This is the spine of the compiler. Each module carries a `Phase`, and ```mermaid flowchart TD Setup --> Load --> Parsed --> Collected --> Bound --> Resolved - Resolved --> Typechecked --> CFG --> FlowTyped - FlowTyped --> Effects --> DefiniteInit --> Ownership --> Usage + Resolved --> Typechecked --> CFG --> Analyzed + Analyzed --> Usage Usage --> MIR --> Backend --> Finalize ``` ```go // internal/pipeline/pipeline.go (heavily simplified) -func advanceModulePhase(ctx *project.CompilerContext, module *project.Module) bool { +func advanceModulePhase(ctx *project.CompilerContext, module *module.Module) bool { if module.Phase < phase.Collected { collector.Collect(ctx, module); module.Phase = phase.Collected; return true } if module.Phase < phase.Bound { binder.Bind(ctx, module); module.Phase = phase.Bound; return true } if module.Phase < phase.Resolved { resolver.Resolve(ctx, module); module.Phase = phase.Resolved; return true } @@ -156,32 +157,34 @@ scheduler enforces that by advancing everyone one rung at a time. | `Collected` | top-level symbols, method sets | `SymbolIndex`, `ModuleScope` | | `Bound` | operator/interface bindings | `SymbolIndex` | | `Resolved` | every identifier → symbol | `SymbolIndex` occurrence lookup | -| `Typechecked` | types, typing decisions, finalized constants, typed source IR | `Typechecking`, `Constants`, `THIR` | +| `Typechecked` | types, typing decisions, finalized constants, typed source IR | `SymbolIndex`, `THIR`, `SemanticExportFingerprint` | | `CFG` | blocks, sites, edges | `CFG` | -| `FlowTyped` | path-sensitive narrowing | `Analysis` | -| `Effects` | ordered evaluation/storage events | transient during `Analysis.Run` | -| `DefiniteInit` | *diagnostics only* | — | -| `Ownership` | drop plan | `Ownership` | -| `Usage` | *warnings only* | — | +| `Analyzed` | flow, effect, definite-init, and ownership evidence | `Analysis` | +| `Usage` | usage diagnostics at the project barrier | diagnostics; no durable module artifact | | `MIR` | flat, block-structured IR | `MIR` | | `Backend` | LLVM IR text | `LLVMIR` | +| `Finalize` | cross-module runtime-symbol validation | project phase | -**Phase artifacts are the central design idea.** Each fact is produced by exactly one -phase, stored in exactly one place, and read by later phases. No phase reaches backwards -to recompute something an earlier one already decided. +**Phase artifacts are the central design idea.** Each durable artifact is produced by one +owning phase and read by later phases. Flow, effects, definite initialization, and +ownership evidence are published together in `Analysis`; `Usage` is a project barrier +for diagnostics rather than another durable module artifact. -`Module.resetToPhase` clears artifacts in phase order, so incremental rebuilds cannot -leave stale evidence behind: +`Module.ResetToPhase` clears downstream artifacts in phase order, so incremental rebuilds +cannot leave stale evidence behind: ```go -// internal/project/modules.go (simplified) -func (m *Module) resetToPhase(retained phase.Phase) { - if retained < phase.Typechecked { m.Typechecking = nil; m.TypedASTNodes = nil } - if retained < phase.CFG { m.CFG = nil } - if retained < phase.Effects { m.Effects = nil } - if retained < phase.Ownership { m.Ownership = nil } - // ... +// internal/module/module.go (simplified) +func (m *Module) ResetToPhase(retained phase.Phase) { + m.Phase = retained + if retained <= phase.Parsed { m.ModuleScope = nil; m.SymbolIndex = nil } + if retained < phase.Collected { m.typeDeclarations = nil } + if retained < phase.Typechecked { m.THIR = nil; m.SemanticExportFingerprint = "" } + if retained < phase.CFG { m.CFG = nil } + if retained < phase.Analyzed { m.Analysis = nil } + if retained < phase.MIR { m.MIR = nil } + if retained < phase.Backend { m.LLVMIR = "" } } ``` @@ -253,15 +256,13 @@ module.SymbolIndex.SetScope(block, scope) depend on, so nothing has to re-derive them: ```go -// internal/semantics/typecheckresult/result.go (excerpt) -result.RecordExprType(expr.ID(), typ) -result.RecordImplicitConversion(expr.ID(), conversion) -result.RecordForIteration(loop.ID(), iteration) - -// Later phases ask semantic questions; the backing indexes stay private. -typ := result.ExprType(expr.ID()) -conversion, ok := result.ImplicitConversion(expr.ID()) -iteration, ok := result.ForIteration(loop.ID()) +// internal/semantics/typechecker/evidence.go (private during Check) +c.evidence.RecordExprType(expr.ID(), typ) +c.evidence.RecordImplicitConversion(expr.ID(), conversion) +c.evidence.RecordForIteration(loop.ID(), iteration) + +// internal/semantics/typechecker/thir_build.go materializes those facts on THIR; +// later phases query THIR instead of re-deriving source meaning. ``` --- @@ -406,15 +407,14 @@ They currently share **evidence, not solver machinery**. Each has distinct latti initialized on *every* path, so the join is intersection. ```go -// internal/semantics/definiteinit (simplified) -func apply(current state, op effect.Op) { - switch op := op.(type) { - case effect.Define: if op.Initialized { current[op.Symbol.ID] = struct{}{} } - case effect.Write: current[op.Place.Root.ID] = struct{}{} - case effect.Use: // a read changes nothing - case effect.Borrow: // nor does taking a reference - default: panic("unhandled effect") // a new op fails loudly here +// internal/semantics/analysis/definite_init.go (simplified) +func transfer(ops []effectOp, in initState) initState { + out := copyInitState(in) + visitor := &initializationVisitor{current: out, shouldApplyState: true} + for _, op := range ops { + visitEffect(op, visitor) } + return out } ``` @@ -423,22 +423,12 @@ It contains **no AST switch at all**; source identity comes from `source.NodeID` **Ownership** tracks moves, loans and liveness, then writes the drop plan: ```go -// internal/semantics/analysis/effects.go (simplified) -for _, op := range a.effects[site.ID] { - switch op := op.(type) { - case effect.CallBegin: - calls = append(calls, callFrame{ // remember where this call's loans start - call: callFor(op), temporary: len(loans.temporary), reserved: len(loans.reserved), - }) - case effect.CallEnd: - frame := pop(&calls) - a.activateCallReservations(frame.call, frame.reserved, loans) // fire at call start - loans.temporary = loans.temporary[:frame.temporary] // argument temporaries die - loans.reserved = loans.reserved[:frame.reserved] - case effect.Use: - a.applyUse(op, st, loans) // move state + storage access - case effect.Borrow: - a.applyBorrow(op, st, loans, calls) // access + loan +// internal/semantics/analysis/ownership_effects.go (simplified) +func (a *analyzer) applyEffects(node *site, st ownershipState, loans *loanContext) { + ops := a.effects[node.cfgSite.ID] + visitor := &ownershipEffectVisitor{a: a, node: node, st: st, loans: loans} + for _, op := range ops { + visitEffect(op, visitor) } } ``` @@ -447,12 +437,12 @@ The result is the `CleanupPlan` — the **single source of drop obligations** ov values: ```go -// internal/semantics/ownershipresult/result.go -type CleanupPlan struct { - AfterScope map[cfg.SiteID][]symbols.SymbolID // scope exit - BeforeReturn map[source.NodeID][]symbols.SymbolID // after the value is computed - BeforeAssign map[source.NodeID]struct{} // replacing a value drops the old - DiscardedValue map[source.NodeID]struct{} // a temporary nobody owns +// internal/semantics/analysis/cleanup.go +type cleanupPlan struct { + AfterScope map[cfg.SiteID][]symbols.SymbolID + BeforeReturn map[source.NodeID][]symbols.SymbolID + BeforeAssign map[source.NodeID]struct{} + DiscardedValue map[source.NodeID]struct{} // … } ``` @@ -543,18 +533,18 @@ The compiler is built so that *forgetting* something fails loudly. | Guard | Where | Catches | | --- | --- | --- | -| AST traversal tests | `frontend/ast` tests + source fixtures | broken child traversal behavior | +| AST traversal tests | `internal/frontend/ast` tests + source fixtures | broken child traversal behavior | | Sealed semantic type contract | Go type system | missing child/ownership behavior on a new semantic type | | THIR validation + required operation methods | `internal/ir/thir` | malformed evidence or missing consumer support | -| MIR/backend rejecting dispatch | `internal/ir/mir`, `backend/llvm` | unsupported lowered nodes fail loudly | +| MIR/backend rejecting dispatch | `internal/ir/mir`, `internal/backend/llvm` | unsupported lowered nodes fail loudly | | `cfg.Validate` | `internal/ir/cfg` | malformed topology | -| `effect.Validate` | `internal/semantics/effect` | operations with no symbol, unbalanced calls | -| `ownershipresult.Validate` | `internal/semantics/ownershipresult` | evidence that contradicts published types | +| `Analysis` validation | `internal/semantics/analysis` | malformed effects or cleanup evidence | A contract failure reads like this: ``` -publishStmt makes no decision about ast.YieldStmt; add a case or declare why the kind is inert +THIR operation contract has no implementation for a new node kind; add its case or +declare why the kind is inert ``` At true closed extension points, every omission must be handled or deliberately @@ -573,7 +563,8 @@ For a new syntax construct, in order: 4. **resolver** — scopes and bindings. 5. **typechecker** — the type rule, and *publish* whatever later phases will need. 6. **CFG** — only if the control-flow shape is genuinely new. -7. **effects** — one case in `publishStmt`/`value` saying what it does to bindings. +7. **analysis** — implement required `AnalyzeFlow` and `BuildEffects` methods when + the construct changes flow or storage behavior. 8. **THIR/MIR** — only if no existing lowering shape can represent it. Steps 1–6 are unavoidable: where a name lives and what types are legal *is* the feature. @@ -607,7 +598,7 @@ at each true extension point. | --- | --- | | Why is my program rejected? | `internal/diagnostics/codes.go`, then the phase that owns the code | | How does phase ordering work? | `internal/pipeline/pipeline.go`, `advanceModulePhase` | -| What does the typechecker publish? | `internal/semantics/typecheckresult/result.go` | -| Why is a value moved/dropped here? | `internal/semantics/effect`, then `internal/semantics/ownership` | +| What does the typechecker publish? | `internal/semantics/typechecker/evidence.go`, then `internal/ir/thir` | +| Why is a value moved/dropped here? | `internal/semantics/analysis`, then `internal/ir/mir` | | What does the backend emit for X? | `internal/backend/llvm/emitter.go` | | How do I add a node kind safely? | `docs/compiler-architecture.md`, then the owning syntax boundary |