Skip to content

Fix integer literal type inference for subexpressions of binary expressions - #1014

Draft
jcp19 wants to merge 40 commits into
masterfrom
claude/improve-int-type-inference-UFkBe
Draft

jcp19 wants to merge 40 commits into
masterfrom
claude/improve-int-type-inference-UFkBe

Conversation

@jcp19

@jcp19 jcp19 commented Apr 11, 2026 •

Copy link
Copy Markdown
Contributor

Summary

This PR fixes the type inference of integer literals that occur as subexpressions of binary expressions and, building on that, ports the parsing and type-checking half of the bounded-integer work (see #1083) ahead of its encoding. Along the way, it replaces the ambiguous fractional-permission syntax a/b with an explicit constructor perm(a, b).

Integer literal type inference

  • Root cause: getTypeFromCtxt in ExprTyping.scala had no case for PBinaryExp or PBitNegation as parents, so integer literal subexpressions always kept the internal UNTYPED_INT_CONST type. In var x int = 1 + 2, the expression 1 + 2 got type int, but 1 and 2 individually stayed untyped.
  • Fix: the cycle-free, structural predicate isUntypedIntConst decides whether an expression is a pure untyped integer constant (literals, iota, references to untyped named constants, and shifts, bit-negation and arithmetic on such). getTypeFromCtxt uses it to propagate the context type to the operands of arithmetic, shift and bit-negation expressions, and the type of a typed sibling to the untyped operand of a comparison (so that x < 1000 for x uint8 is rejected, as by gc).
  • Untyped named constants are checked for representability in the type their context imposes, exactly like literals: with const c = 300, var x uint8 = c, return c, g(c) and x == c are rejected as by gc.
  • Go-conformant constant expressions: constant expressions are evaluated exactly. Representability is checked only at the outermost node of an untyped constant expression, so var x uint16 = (1 << 16) - 1 and var x uint16 = (1 * 70000) - 69999 are accepted; the shift count is no longer bounded by the width of the left operand's type (var c uint8 = (1 << 10) >> 3 is valid Go), only the value of a constant shift has to be representable; and constant division by zero is a type error instead of an uncaught ArithmeticException.

Integer types

  • TypeBounds.UntypedConstInteger distinguishes an untyped literal from the explicit ghost type integer.
  • Every integer kind is assignable to integer; the converse requires an explicit conversion. integer is the common type of itself and any bounded kind, so mixed arithmetic and comparisons are well-typed.
  • len/cap of a ghost collection, multiplicities, option projections and range sequences have type integer instead of an untyped constant.
  • int(x)/uint(x) conversions use the configured integer kind, i.e. they are 64-bit in 64-bit mode (previously they were given 32-bit bounds).
  • Bitwise NOT of an untyped constant is computed with arbitrary precision (-(x+1)), unsigned constant right shifts no longer overflow near MaxUint64, and constant shifts are folded by the desugarer so that Silicon can reason about them.

Fractional permission syntax

1/2 is now always integer division, as in Go (m := 1/2 + 1/2 yields 0). Fractional permission amounts are written perm(num, den); perm(p, n) divides a permission amount p by an integer n. Both arguments accept integers of any kind (a bounded operand is promoted to integer). Integer literals are no longer implicitly converted to perm, so p > 0 for a perm variable is written p > noPerm. All regression tests, the stubs and docs/tutorial.md are migrated. (Alternative designs, such as 1.0/2, were discussed in this PR.)

Other fixes

  • PackageResolver closed the DirectoryStreams it opened only implicitly, leaking file handles.
  • TypeEncoding.offendingNodeIn matches a ContractNotWellformed error to a contract when Viper reports a sub-expression of the contract as offending node (e.g. perm(1, 0) inside an accessibility predicate), so that such errors are reported as contract_not_well_formed instead of being dropped.
  • int(1, 2) (a two-argument "conversion" other than perm) is a type error instead of an internal violation.

Test plan

  • New unit tests in ExprTypingUnitTests.scala assert the types of literal subexpressions (typed context, typed siblings, function-call, method-call and field-access siblings, comparisons, shifts).
  • New regression tests: integers/int-lit-subexpr-types.gobra, integers/const-folding-overflow.gobra, integers/const-expr-exact-eval{,-fail1}.gobra, integers/comparison-type-infer-fail1.gobra, integers/int-conversion-64bit.gobra, integers/integer-bounded-mix.gobra, integers/named-const-representability{,-fail1}.gobra, fractional_permissions/perm-constructor-{simple1,fail1}.gobra.
  • sbt test with Z3 4.8.7 (Silicon); the two Carbon tests need Boogie and were not run locally.

https://claude.ai/code/session_01SJi7UpEB7rFRw39Q1Uo1R8

…ssions

When an integer literal appears as a subexpression of a binary expression
(e.g. `1` and `2` in `var x int = 1 + 2`), it previously kept the internal
`UNTYPED_INT_CONST` type even though the outer context clearly implies `int`.
Only the top-level binary expression `1 + 2` had its type resolved to `int`
via `getTypeFromCtxt`; the individual operands were never updated.

Fix: extend `getTypeFromCtxt` with new cases that walk from a subexpression up
through its binary-expression parent when the sibling operand is itself a pure
untyped integer constant:
- For non-shift numeric binary expressions: if `isUntypedIntConst(sibling)`,
  propagate the context type of the parent binary expression.  When the sibling
  already has a concrete type the case returns `None`, preserving the existing
  `typeMerge` behaviour (e.g. `1 + y` where `y: int8` still yields `int8`).
- For shift expressions: the left operand follows the shift's context type;
  the right operand (shift count) is left as-is.
- For unary bit-negation: the operand follows the negation's context type.

A cycle-free helper `isUntypedIntConst` is added to check structurally (without
calling `exprType`) whether an expression is composed solely of integer literals,
iota, and arithmetic/bit operations on such expressions.

https://claude.ai/code/session_01SJi7UpEB7rFRw39Q1Uo1R8
@jcp19
jcp19 marked this pull request as draft April 11, 2026 22:48
claude added 28 commits April 11, 2026 22:55
…'s type

In a binary expression like `1 + x` where `x: uint8`, the literal `1`
now also receives type `uint8` instead of staying as UNTYPED_INT_CONST.
This matches Go's specification for untyped constants in mixed expressions.

The fix updates the PBinaryExp case in getTypeFromCtxt: when the sibling
operand is not a pure untyped integer constant, we call exprOrTypeType on
it and, if the result is a concrete IntT, return that as the context type
for the literal. No cycles are introduced because exprOrTypeType on a
named/typed operand never recurses back through the literal's own type.

https://claude.ai/code/session_01SJi7UpEB7rFRw39Q1Uo1R8
In ExprTyping.scala the wildcard import brings the inner sealed trait
Type into scope as the identifier 'Type', not the outer companion object.
Using Type.IntT(_) in a pattern match therefore caused a compilation error.
Replacing with the directly-imported IntT(_) fixes this.

https://claude.ai/code/session_01SJi7UpEB7rFRw39Q1Uo1R8
…face context

When an untyped integer constant appears in a binary expression whose
outer context type is an interface (e.g. `var y interface{} = bigLit + 2`),
the new getTypeFromCtxt cases for PBinaryExp/PShiftLeft/PShiftRight/PBitNegation
previously propagated `Some(InterfaceT)` down to the literal subexpressions.
This caused `numExprWithinTypeBounds` to fire an extra bounds-check error on
each literal subexpression in addition to the one already reported on the
binary expression as a whole, resulting in more errors than the
`//:: ExpectedOutput(type_error)` annotations expected.

Fix: in all four new cases, filter out `Some(InterfaceT)` results before
returning, falling back to `None`. The bounds check then happens only once
at the top-level binary expression node, preserving the pre-existing
error count for regression tests like issues/000157-3.gobra.

https://claude.ai/code/session_01SJi7UpEB7rFRw39Q1Uo1R8
The previous implementation of the 'typed sibling' case in
getTypeFromCtxt called exprOrTypeType(sibling) = exprType(sibling),
a Kiama cached attribute, from within another attribute computation.
This caused an evaluation cycle:

  wellDefExpr(A op B) → getTypeFromCtxt(A) → exprType(B)
    → wellDefExpr.safe(B) → wellDefExpr(B)
    → getTypeFromCtxt(B) → exprType(A)
    → wellDefExpr.safe(A) → wellDefExpr(A op B)  ← CYCLE

The cycle manifested as "Cycle detected in attribute evaluation
'store' at x * x" when checking subexpression_overflow.gobra.

Fix: replace exprType(sibling) with direct symbol-table lookups that
are cycle-safe. We only handle PNamedOperand with explicit type
annotations (the common case for `1 + x` where x: uint8). For other
sibling types, typeMerge at the binary expression level handles the
typing correctly without any context propagation needed.

https://claude.ai/code/session_01SJi7UpEB7rFRw39Q1Uo1R8
Replaces the ambiguous x/y perm literal syntax with an explicit
perm(x, y) constructor. Key changes:

- Add ap.FractionalPermConstructor pattern to AstPattern.scala
- Recognize perm(x, y) in AmbiguityResolution (2-arg PInvoke on perm type)
- Add ExprTyping well-def/type rules for FractionalPermConstructor
- Add in.PermConstructorFromInt / in.PermConstructorFromPerm to internal AST
- Encode to $newPerm(x:Int,y:Int) and $newPermFromPerm(x:Perm,y:Int) Viper
  functions generated lazily via FunctionGeneratorWithoutContext
- Remove int->perm assignability, convertibility, and type merging
- Fix getTypeFromCtxt: block PermissionT propagation to int subexpressions
  in division (prevents Kiama cycle introduced by earlier commit)
- Split perm guard in wellDefActualExpr: PDiv only fires on explicit perm
  operands, not from context
- Update Desugar: handle FractionalPermConstructor, remove (IntT,IntT) PDiv
  path and int->perm fallback
- Update ConstantEvaluation: handle FractionalPermConstructor, remove PDiv

Test files still need updating (Step 10).

https://claude.ai/code/session_01SJi7UpEB7rFRw39Q1Uo1R8
Replace all old fractional permission literal syntax with the new
perm(x, y) constructor throughout ~90 test and stub files:

- acc(e, 1/2) → acc(e, perm(1, 2))
- acc(e, 1/4) → acc(e, perm(1, 4)), etc.
- perm(1/2) → perm(1, 2) (old single-arg with fraction → 2-arg)
- dividend/divisor in acc() → perm(dividend, divisor)
- perm comparisons: p > 0 → p > noPerm, p == 1 → p == writePerm
- Ghost var assignments: ghost var m5 perm = 1/2 → perm(1, 2)
- perm-simple1.gobra: test9 updated to demonstrate PermDiv; m4 uses int
- perm-fail1.gobra: test6/9 use int comparisons; test15 now provable
- stubs: net/waitgroup/strconv updated for new perm syntax

https://claude.ai/code/session_01SJi7UpEB7rFRw39Q1Uo1R8
Add cases for the new ap.FractionalPermConstructor pattern in all PInvoke
dispatch sites that were missing it, preventing violations:

- GhostWellDef.scala: return noMessages (perm(x,y) is ghost, no issues)
- GhostTyping.scala (ghostExprTyping): classify as isGhost (produces perm)
- GhostTyping.scala (ghostExprResultTyping): classify result as isGhost
- GhostTyping.scala (expectedArgGhostTyping): args are notGhost (integers)
- GhostExprTyping.scala (isPureExpr): pure if both args are pure
- Addressability.scala: AddrMod.rValue (perm value, not addressable)
- Enclosing.scala (nilType): return None (no nil args in perm constructor)

https://claude.ai/code/session_01SJi7UpEB7rFRw39Q1Uo1R8
After removing int→perm assignability, these files still compared perm
variables directly to integer literals (0 or 1). Update to use noPerm/
writePerm constants or perm(x, y) constructor:

- stubs/strconv/atoi.gobra: p > 0 → p > noPerm
- stubs/time/time.gobra: p > 0 → p > noPerm (5 occurrences)
- tutorial-examples/predicate.gobra: p > 0 → p > noPerm
- globals/scion/monotonicset/bounded.gobra: 0 < p → p > noPerm (2 occurrences)
- features/structs/structs-simple5.gobra: t.B(1/2) → t.B(perm(1, 2))

https://claude.ai/code/session_01SJi7UpEB7rFRw39Q1Uo1R8
After removing implicit int→perm assignability/convertibility:
- `perm / int` (PermDiv) was incorrectly rejected because the well-def
  check for PDiv required both operands to be perm-assignable.
- The result type of `perm / int` was computed as UnknownType because
  typeMerge(PermissionT, IntT) was removed.

Fix: In wellDefActualExpr, for PDiv where left is perm, only check
that the right (divisor) is an integer, not perm. In numExprType,
return PermissionT directly when dividing a perm by anything.

This restores correct behavior for `perm(1,2) / 4`, `p / 2` etc.

https://claude.ai/code/session_01SJi7UpEB7rFRw39Q1Uo1R8
After removing implicit int→perm coercion, several files still used
integer expressions as perm arguments in acc() calls:

- waitgroup-simple1.gobra: (i + 1)/1 → perm(i + 1, 1)
- visitor_pattern.gobra (3 copies): 1/u → perm(1, u),
  1/(u+1) → perm(1, u+1) (where u is an int variable)
- binary_search_tree.gobra (3 copies): 1/dividend → perm(1, dividend)
  (where dividend is an int variable)
- stubs/sync/waitgroup.gobra: -n/1 → perm(-n, 1), n/1 → perm(n, 1)

All replacement expressions preserve the intended semantics and are
valid for the perm(x, y) constructor since the denominators are
guaranteed non-zero by existing preconditions.

https://claude.ai/code/session_01SJi7UpEB7rFRw39Q1Uo1R8
PermConstructorFromInt and PermConstructorFromPerm were added to the
sealed Permission hierarchy but not handled in the showExpr match in
PrettyPrinter.scala. This would cause a MatchError at runtime when
these nodes appeared in error messages or debug output.

https://claude.ai/code/session_01SJi7UpEB7rFRw39Q1Uo1R8
Replace the $newPerm/$newPermFromPerm Viper function generator approach
with direct use of vpr.FractionalPerm and vpr.PermDiv. This preserves
the exact offendingNode structure that Viper's ContractNotWellformed
errors attach to, ensuring the causedBy check in Gobra's error
transformer fires correctly for contract_not_well_formed errors.

https://claude.ai/code/session_01SJi7UpEB7rFRw39Q1Uo1R8
…on call siblings

- fields/fail3.gobra: remove conjunction from preconditions so the
  contract IS the FieldAccessPredicate, matching what causedBy() checks.
  A conjunction encodes as And(A,B) in Viper; when A is ill-formed,
  Viper's offendingNode is A not And(A,B), so causedBy(And(A,B)) fails
  and produces imprecise_contract_not_well_formed instead of
  contract_not_well_formed.

- ExprTyping.scala getTypeFromCtxt: extend sibling-type propagation to
  PInvoke (function calls). For `f() + (1 - 2)` where f(): uint8, the
  literals 1 and 2 now correctly receive type uint8. The lookup reads
  the callee's declared result type from the symbol table (cycle-safe),
  mirroring the existing PNamedOperand handling.

https://claude.ai/code/session_01SJi7UpEB7rFRw39Q1Uo1R8
For zero-divisor permission errors like perm(1, 0), Viper reports
offendingNode = FractionalPerm(1, 0) — a subexpression of the contract —
not the enclosing FieldAccessPredicate/PredicateAccessPredicate. The
previous `e causedBy inv` check required exact equality between
offendingNode and the entire contract expression, so it failed for this
case, producing imprecise_contract_not_well_formed instead.

Fix: replace `causedBy` in contractErr and invErr with a recursive
subtree check `offendingNodeIn(target, root)` that succeeds whenever
the offending node appears anywhere in the contract's Viper AST subtree.
This preserves existing behaviour for access-permission errors (where
offendingNode IS the whole contract) while also handling the sub-
expression case needed by the perm(x, 0) fail tests.

https://claude.ai/code/session_01SJi7UpEB7rFRw39Q1Uo1R8
TypeEncoding.scala: replace `target.pos == root.pos` with `target eq root`
in offendingNodeIn. vpr.Node does not extend vpr.Positioned, so calling
.pos on a plain vpr.Node reference was a compile error that broke the
Docker build. Reference equality (eq) is also semantically more correct:
the error's offendingNode IS the actual object from the encoded AST, so
reference identity correctly identifies it anywhere in the subtree without
risk of false matches from structurally equal nodes at different positions.

ExprTyping.scala: replace the manual symbol-table lookup block in the
else-branch of getTypeFromCtxt (binary-expression sibling case) with a
single exprType(sibling) call. The call is cycle-safe in the else-branch
because the cycle exprType(A) → getTypeFromCtxt(A) → exprType(B) →
getTypeFromCtxt(B) → exprType(A) only forms when isUntypedIntConst holds
for BOTH operands; the else-branch is only reached when isUntypedIntConst
is false for the sibling, breaking the cycle. This generalises type
propagation to cover all context-independent expressions: method calls
(m.f()), field accesses (s.field), type conversions, and complex
sub-expressions — not just plain variables and free-function calls.

int-lit-subexpr-types.gobra: add regression tests for function-call,
method-call, and field-access siblings.

https://claude.ai/code/session_01SJi7UpEB7rFRw39Q1Uo1R8
testFunctionCallSibling: add postcondition `ensures ret == 42` to
getUint8() so the verifier can prove assert(x == 43). Without a
postcondition, the function's return value is opaque to callers.

testMethodCallSibling: switch from pointer receiver (*S) to value
receiver (S) to avoid heap-permission requirements, and use a named
return value instead of an assertion (avoids needing to know the
specific field value).

testFieldAccessSibling: similarly use a named return value.

https://claude.ai/code/session_01SJi7UpEB7rFRw39Q1Uo1R8
The previous implementation used `eq` (reference equality), which would
fail when Silver creates structurally-equal but distinct node copies
during error reporting (e.g. after AbstractVerificationError.transformedError()).

Use the same matching logic as `causedBy` in BackTranslator: structural
equality (`==`) for the node, plus position equality for Positioned nodes.
Apply this recursively to subnodes so that sub-expression offending nodes
(e.g. FractionalPerm(1, 0) inside a FieldAccessPredicate) are found even
when Silver reports the inner node rather than the outer contract expression.

https://claude.ai/code/session_01SJi7UpEB7rFRw39Q1Uo1R8
…ings

When the sibling in a binary expression is a PNumExpression (e.g. x*x
where x is an untyped named constant), calling exprType(sibling) causes
a cycle:
  exprType(A) → getTypeFromCtxt(A) → isUntypedIntConst(B)=false
  → exprType(B) → getTypeFromCtxt(B) → exprType(A) → CYCLE

The fix: for PNumExpression siblings, use numExprType(sibling) instead
of exprType(sibling). numExprType only calls exprType on sub-nodes of
the sibling expression (not on the sibling itself), and those sub-nodes
go through exprAndTypeType (for named constants) which does NOT call
getTypeFromCtxt, so no cycle can form.

For non-PNumExpression siblings (variables, method calls, field accesses),
exprType remains safe because those branches in actualExprType do not
call getTypeFromCtxt on the expression itself.

https://claude.ai/code/session_01SJi7UpEB7rFRw39Q1Uo1R8
Both FileResource and JarResource listContent() methods were not closing
the DirectoryStream obtained from Files.newDirectoryStream(). Wrap the
stream in a try/finally to ensure it is always closed.

https://claude.ai/code/session_01SJi7UpEB7rFRw39Q1Uo1R8
… folding

Three targeted fixes:

1. bounded.gobra: Replace old `p/2` permission notation with `perm(p, 2)`
   in loop invariants of ContainsImpliesAbstractContains and
   DoesNotContainsImpliesAbstractDoesNotContain, which caused type errors.

2. ConstantEvaluation.scala (PBitNegation): Add isLiteralCluster helper to
   detect untyped constant operands. For untyped clusters, compute the
   arbitrary-precision NOT as -(x+1) instead of using exprType, which
   could be contaminated by the surrounding typed context. This restores
   the expected overflow error for `AND3 = uint32(1) & ^1`.

3. Desugar.scala (PShiftLeft/PShiftRight): Constant-fold shift expressions
   when intConstantEvaluation returns a value. Shift operations are encoded
   as uninterpreted Viper functions opaque to Z3, so `1 << 2` would not be
   provably equal to 4. With constant folding, the expression is replaced by
   IntLit(4) in the internal AST, making `assert(d == 4)` trivially provable.

https://claude.ai/code/session_01SJi7UpEB7rFRw39Q1Uo1R8
Replace integer-division perm expressions (1/2, 1/4, 1/512) with the
current perm constructor notation (perm(1,2), perm(1,4), perm(1,512)).
The old notation is no longer valid and caused type errors.

https://claude.ai/code/session_01SJi7UpEB7rFRw39Q1Uo1R8
Ignore *.bak backup files, JVM crash logs (hs_err_pid*.log), and local
sbt/ directory that can appear during development.

https://claude.ai/code/session_01SJi7UpEB7rFRw39Q1Uo1R8
Add five unit tests (and two TestFrontend helpers) that directly assert the
types inferred for subexpressions, covering each scenario from the earlier
discussion:

- int8-typed declaration context: `var b int8 = 1 + 2` → both literals int8
- shift left operand: `n := 1 << 2` → left literal gets context type (int)
- uint8 sibling variable: `n := 1 + x` (x: uint8) → literal gets uint8
- function-call sibling: `n := getUint8() + 1` → literal gets return type uint8
- field-access sibling: `n := s.field + 1` (field: uint8) → literal gets uint8

New helpers: singleExprTypedTypeInfo (typed-var-decl context) and
singleExprWithFuncTypeInfo (program with an auxiliary function declaration).

https://claude.ai/code/session_01SJi7UpEB7rFRw39Q1Uo1R8
Go evaluates constant expressions with arbitrary precision; only the final
value assigned to a typed constant needs to be representable in that type.
Intermediate values (e.g. `1 << 16` in `(1 << 16) - 1`) are allowed to
exceed the declared type's bounds.

Fix: in the exprWithinBounds block for arithmetic/bitwise binary expressions,
skip the per-operand bounds checks when the whole expression is a pure untyped
integer constant (no explicit type conversions in the tree, as determined by
isUntypedIntConst). Only the final-result check on `n` is kept. Per-operand
checks are preserved for expressions involving explicit types (e.g.
`uint8(1) * (-1)`) or variable operands (e.g. `300 + y` where y : uint8).

Add regression test const-folding-overflow.gobra with:
  const MaxISD    uint16 = (1 << 16) - 1
  const MaxUint8  uint8  = (1 << 8)  - 1
  const MaxUint32 uint32 = (1 << 32) - 1
  const MaxInt8   int8   = (1 << 7)  - 1

https://claude.ai/code/session_01SJi7UpEB7rFRw39Q1Uo1R8
In test environments where `dependentTypeInfo` is initialized with
`Map.empty` (e.g. minimal TypeInfoImpl stubs in unit tests), resolving
a qualified expression like `PDot(s, "field")` could trigger
`tryUnqualifiedBuiltInPackageLookup` which unconditionally called
`tryPackageLookup(BuiltInImport, id)` — crashing with a LogicException
because BuiltInImport is not present in the map.

Guard the lookup with a presence check: return None when BuiltInImport
is absent. In production, Info.scala always adds BuiltInImport for
every non-builtin package, so the guard is never triggered there.

https://claude.ai/code/session_01SJi7UpEB7rFRw39Q1Uo1R8
Go requires that untyped constant operands of comparisons be representable
in the type of the other operand (e.g. `x < 1000` where x: uint8 must be
rejected because 1000 overflows uint8). Previously, getTypeFromCtxt only
handled PNumExpression binary ops, so comparison operands like PEquals,
PLess, etc. returned None and literals stayed as UNTYPED_INT_CONST,
bypassing bounds checks.

Add a case in getTypeFromCtxt for comparison operators (== != < <= > >=)
that propagates the sibling's concrete integer type to the literal operand.
Unlike arithmetic expressions, comparisons return bool so there is no outer
context type to propagate — only the sibling type matters.

https://claude.ai/code/session_01SJi7UpEB7rFRw39Q1Uo1R8
Using `bExpr @ (_: PEquals | ...)` infers bExpr as PActualExpression
(the LUB of the alternatives), not PBinaryExp, so .left/.right did not
resolve. Switch to the same guard style as the PNumExpression case:
`bExpr: PBinaryExp[_, _] if bExpr.isInstanceOf[...]`.

https://claude.ai/code/session_01SJi7UpEB7rFRw39Q1Uo1R8
claude and others added 3 commits April 15, 2026 17:18
numExprType can throw LogicException for ill-typed PNumExpression nodes
(e.g. len applied to a non-collection argument). When such an expression
appears as the sibling of a comparison or arithmetic operand, the call to
numExprType inside getTypeFromCtxt would crash instead of gracefully
returning None.

Introduce tryNumExprType, which wraps numExprType in a try-catch for
LogicException and returns None on failure. Use it at both call sites in
getTypeFromCtxt (comparison case and arithmetic case). When a sibling is
ill-typed, getTypeFromCtxt now returns None (no context type inferred),
which is the correct fallback — the error on the ill-typed sibling is
reported separately by its own well-definedness check.

https://claude.ai/code/session_01SJi7UpEB7rFRw39Q1Uo1R8
PSub(PIntLit(0), expr) is Gobra's AST representation of unary minus.
When a comparison such as `assert N3 == -128` (N3: int8) propagated int8
as context to PSub(0, 128), the arithmetic-sibling case would further
propagate that int8 context to the inner PIntLit(128). Since 128 > 127
(max int8), this produced a spurious "constant expression 128 overflows
int8" error even though -128 is the minimum int8 value.

Fix: detect the PSub(PIntLit(0), expr) unary-negation pattern and skip
propagating the parent context to expr. The correct bounds check already
happens at the PSub level (value = -128, which fits in int8).

https://claude.ai/code/session_01SJi7UpEB7rFRw39Q1Uo1R8
@jcp19

jcp19 commented Jul 6, 2026

Copy link
Copy Markdown
Contributor Author

@ArquintL do you oppose to the new syntax for permissions introduced in this PR?

@ArquintL

ArquintL commented Jul 7, 2026 •

Copy link
Copy Markdown
Member

@ArquintL do you oppose to the new syntax for permissions introduced in this PR?

@jcp19 I do understand that it helps in resolving parsing ambiguities but to be honest it's (1) changing the syntax for one of the most prevalent building blocks of Gobra programs, triggering many changes, and (2) the new syntax is quite verbose and imho makes programs less readable (but that's maybe just a getting used to).

Regarding (2), I'm wondering whether there's an alternative to perm while avoiding the ambiguity with integer division: My first thought was that we could treat 1/2 consistently as integer division and require that permissions must be (unbounded) floats, such that one would have to write 1.0/2 or 1/2.0 instead (effectively making use of Go's special treatment of constants wrt. precision). Alternatively, I was thinking of adding syntactic sugar to denote rational division (with infinite precision) such that one could write 1 // 2 for half a permission.

@jcp19

jcp19 commented Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

Regarding (1), I get that it is annoying to perform this change, but we are fixing an annoying ambiguity that hampers the type checker, and the change can be easily automated.

As for (2), I agree with the verbosity but I don't immediately see a good solution: the proposal with the float notation is pushing the ambiguity down to floats/perms; 1 // 2 introduces a comment (//). We could write 1 \ 2, but this is a bit ugly (kinda suggests that \ is the flip of /), or 1 # 2

jcp19 and others added 2 commits August 8, 2026 17:45
…type-inference-UFkBe

# Conflicts:
#	src/main/resources/stubs/sync/waitgroup.gobra
#	src/test/resources/regressions/examples/evaluation/impl_errors/parallel_search_replace.gobra
#	src/test/resources/regressions/examples/evaluation/impl_errors/parallel_sum.gobra
#	src/test/resources/regressions/examples/evaluation/parallel_search_replace.gobra
#	src/test/resources/regressions/examples/evaluation/parallel_sum.gobra
#	src/test/resources/regressions/examples/evaluation/spec_errors/parallel_search_replace.gobra
#	src/test/resources/regressions/examples/evaluation/spec_errors/parallel_sum.gobra
#	src/test/resources/regressions/examples/parallel_search_replace_shared.gobra
#	src/test/resources/regressions/examples/tutorial-examples/channels.gobra
#	src/test/resources/regressions/examples/tutorial-examples/multi-channel.gobra
#	src/test/resources/regressions/features/channels/channel-simple-buffered1.gobra
#	src/test/resources/regressions/features/channels/channel-simple-buffered2.gobra
#	src/test/resources/regressions/features/channels/channel-simple5.gobra
#	src/test/resources/regressions/features/channels/channel-simple6.gobra
#	src/test/resources/regressions/features/channels/channel-simple7.gobra
#	src/test/resources/regressions/features/channels/foo/foo.gobra
#	src/test/resources/regressions/features/channels/multi-channel-simple1.gobra
#	src/test/resources/regressions/features/defunc/waitgroup-fail1.gobra
#	src/test/resources/regressions/features/defunc/waitgroup-simple1.gobra
#	src/test/resources/regressions/issues/000695.gobra
…1083)

* Port integer parsing/type-checking from integer-type-semantics

Moves the frontend half of the bounded-integer work from
claude/integer-type-semantics-RUVTQ down to this branch, so that it can
ship ahead of the encoding. Only parsing and type-checking is ported; the
domain-based encoding, the desugarer's int-kind alignment, the
--unboundedIntegers flag and IntKindAlignment stay behind.

Type-checking changes:
  * TypeBounds: add the UntypedConstInteger kind and make UntypedConst
    default to it, so untyped literals are distinguishable from the
    explicit `integer` ghost type. merge() becomes strict (`integer` no
    longer silently merges with a bounded kind); mergeLenient() keeps the
    old permissive behaviour for the internal AST.
  * Assignability: every integer kind is assignable to `integer`; the
    converse still requires an explicit conversion.
  * TypeMerging: `integer` is the common type of itself and any bounded
    kind, so mixed comparisons are well-typed.
  * ExprTyping: perm(num, den) requires `integer`/perm rather than any
    integer; len/cap of a ghost collection yields `integer`; bitwise and
    shift diagnostics account for the new kind.
  * GhostExprTyping: multiplicities, option projections and range
    sequences yield `integer` instead of an untyped constant.
  * IdTyping: resolve the int/uint built-in tags through the configured
    type bounds, so int(x) is 64-bit in 64-bit mode.
  * ConstantEvaluation: handle the new kind, and evaluate unsigned right
    shifts without the Long round-trip that overflowed near MaxUint64.

Consequences outside the frontend, required by the above:
  * internal Program.scala uses mergeLenient for BinaryIntExpr and accepts
    an untyped constant next to a defined type. The desugarer synthesizes
    arithmetic mixing user expressions with `integer` nodes, which the
    strict frontend rule must not reject here.
  * the sync.WaitGroup stub converts explicitly in perm(integer(n), 1),
    since perm no longer accepts a bounded int.

The encoding is untouched: this branch still encodes every integer kind as
a mathematical Viper Int with overflow checks added by
OverflowChecksTransform, so the new kind needs no encoding support.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Adapt regression tests to the ported integer type-checking rules

Fallout from the type-checking port, applied minimally: only the changes
that the new rules *require*, not the overflow hardening that accompanies
them on claude/integer-type-semantics-RUVTQ.

Two rules drive every change:
  * perm(num, den) now demands `integer`, so a bounded operand is wrapped:
    perm(1, dividend) -> perm(1, integer(dividend)).
  * len/cap/multiplicity on a ghost collection now yields `integer`, so
    results are either declared `integer` or converted back with int(...).

Deliberately NOT taken from RUVTQ, since they serve the bounded-integer
encoding this branch does not have:
  * `forall i int ::` -> `forall i integer ::` quantifier hardening,
  * math.MaxInt* bounds and the `math` stub import,
  * `// ##(--unboundedIntegers)` directives for a flag not ported,
  * the seqSum -> seqSumFrom restructuring in closures-calldesc4-map.

Also ports features/integers/int-conversion-64bit.gobra, which covers the
IdTyping change (int(x) must use the configured kind, not the statically
32-bit built-in tag). It verifies on this branch.

ghost-pure-function.gobra and seq-convert-fail3.gobra still report type
errors, but only the ones their ExpectedOutput annotations assert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Correct the mergeLenient rationale and pin it with a test

The comment added with the port claimed BinaryIntExpr.typ needs
mergeLenient because the desugarer synthesizes arithmetic mixing user
expressions with internally-created `integer` nodes, naming range-loop
index increments as the example. That is wrong: the range-loop index i0 is
declared as in.IntT(exclusiveVariable), which defaults to UnboundedInteger,
and the synthesized in.IntLit(1) defaults to the same kind, so the two
merge trivially. A range loop over a []int does not exercise it.

The actual reason is the type-checker, not the desugarer: TypeMerging makes
`integer` the common type of itself and any bounded kind, so user code may
legitimately write `len(s) - i` for a ghost `s seq[int]` and a bounded
`i int`. Both operand kinds survive desugaring, so BinaryIntExpr.typ has to
merge `integer` with `int`. Verified by rebuilding with the strict merge:
that expression fails with "kinds IntegerKind(integer) and IntegerKind(int)
cannot be merged".

Adds features/integers/integer-bounded-mix.gobra covering it, so the
requirement cannot regress silently.

Note that the strict/lenient split is otherwise vestigial on this branch:
TypeBounds.merge has no caller besides mergeLenient's own fallback. It is
kept as-is to stay identical to claude/integer-type-semantics-RUVTQ, so
that branch merges without conflicting here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Comment on lines +13 to +19
ghost
requires 0 <= i && i <= len(s)
ensures res == len(s) - i
decreases
pure func remaining(s seq[int], i int) (res integer) {
return len(s) - i
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

this is a bad motivation for mergeLenient, we could have made i an integer variable too

jcp19 pushed a commit that referenced this pull request Aug 15, 2026
Four places where Gobra's treatment of integer constant expressions differed
from the Go language specification, found while reviewing #1014.

1. Untyped constant comparison crashed the type checker.

   `assert 1 + 2 == 3 + 4` is a valid untyped boolean constant expression in
   Go, but aborted Gobra with "Cycle detected in attribute evaluation 'store'".
   The comparison case of getTypeFromCtxt peeked at the sibling operand's type,
   and for two compound untyped operands each side asked the other for its type
   until exprType re-entered a node still under evaluation.

   Per the spec a comparison of two untyped constants is itself an untyped
   constant and imposes no representability requirement, so no type needs to be
   propagated in that case. Deciding this structurally, before computing any
   type, both matches the spec and removes the cycle. `uint8(4) < 1000` is
   still rejected, since there one operand is typed.

2. Representability was checked at every node of a constant expression.

   The spec requires it only where the constant is given a type: "constant
   expressions are always evaluated exactly; intermediate values and the
   constants themselves may require precision significantly larger than
   supported by any predeclared type in the language". Gobra rejected
   `var x uint16 = (1 * 70000) - 69999`, which Go accepts. The check now
   happens once, at the outermost node of an untyped constant expression.

3. Shift counts were bounded by the width of the left operand's type.

   Go states "there is no upper limit on the shift count"; what makes
   `int8(1) << 10` invalid is that the value 1024 is not representable in int8.
   Gobra rejected `var c uint8 = (1 << 10) >> 3`, whose result 128 does fit.
   The type-width bound is replaced by a check that the shift's value is
   representable in its type; MAX_SHIFT is kept as an implementation limit.

4. References to untyped constants were treated as typed, and constant
   division by zero crashed.

   `const c = 300; var x uint8 = c - 100` is valid Go but was rejected.
   isUntypedIntConst now follows a name to its constant declaration, treating
   it as untyped only when the declaration gives no type and its defining
   expression is itself untyped (so `const c = int8(5)` stays typed), with a
   visited set so a cyclic declaration cannot recurse forever.

   `const d = 1/0` aborted with java.lang.ArithmeticException. The spec says
   "the divisor of a constant division or remainder operation must not be
   zero"; it is now reported as a type error, matching gc's wording.

perm(x, 0) is deliberately left to the Viper well-formedness check that
reports it as contract_not_well_formed, since a non-constant denominator
cannot be caught statically and `perm` is not a Go construct.

Full regression suite: 2371 of 2376 pass, the same set as before this change.
The 5 failures are pre-existing on this branch: three files still using the
old `1/2` permission syntax that the perm(x, y) migration missed
(atomicsAndInvariants/spinlock.gobra, issues/001029.gobra,
issues/001057-2.gobra) and two Carbon tests needing Boogie.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XV3UW3HZD9Gcf2s462BLaZ

jcp19 commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

Review: conformance with the Go spec

I checked this branch's integer/constant typing against the Go language specification, and verified every case twice — against gc 1.24.7 and against a build of this branch. Four divergences, one of which is a crash. Fixes are on claude/gobra-pr-1014-review-6au6g1 (branched from bdb3b0f).

Program Go 1.24.7 this branch
assert 1 + 2 == 3 + 4 accepted crash — IllegalStateException: Cycle detected in attribute evaluation 'store' at 1
var c uint8 = (1 << 10) >> 3 accepted rejected: shift count 10 too large for type uint8
var x uint16 = (1 * 70000) - 69999 accepted rejected twice, once per intermediate
const c = 300; var x uint8 = c - 100 accepted rejected: constant expression c overflows uint8
const d = 1/0 invalid operation: division by zero crash — java.lang.ArithmeticException

1. The comparison case in getTypeFromCtxt cycles

The new comparison case peeks at the sibling operand's type. When both operands are compound untyped constants, each side asks the other for its type until exprType re-enters a node still under evaluation:

exprType(1) → getTypeFromCtxt(1) → sibling 2 untyped → getTypeFromCtxt(1+2)
  → parent is PEquals → tryNumExprType(3+4) → exprType(3) → … → tryNumExprType(1+2)
  → exprOrTypeType(1) → exprType(1)   ← already in progress

tryNumExprType only catches Violation.LogicException, so Kiama's cycle error escapes. Commits 8c90008 and 008da.. already fixed cycles here; this case was added afterwards and reintroduces the shape.

Per the spec, a comparison of two untyped constants is itself an untyped constant with no representability requirement, so no type needs propagating in that case. Deciding that structurally, before computing any type, is both the spec rule and the cycle fix. uint8(4) < 1000 is still rejected, since there one operand is typed.

2. Representability is checked at every node, not just where the type is imposed

Constant expressions are always evaluated exactly; intermediate values and the constants themselves may require precision significantly larger than supported by any predeclared type in the language.

The check belongs at the outermost node of an untyped constant expression only. isUntypedIntConst(n) handles the (1 << 16) - 1 shape, but the per-literal check in wellDefActualExpr and the per-node check still fire on intermediates, so (1 * 70000) - 69999 is rejected.

3. Shift counts are bounded by the left operand's type width

Go says "there is no upper limit on the shift count" — what makes int8(1) << 10 invalid is that 1024 is not representable in int8, not the count. v << 10 for a uint8 variable is valid Go (yields 0) and was rejected too.

Worth noting: const-folding-overflow.gobra passes only because the bound is v > nBits and the tests land on v == nBits exactly. (1 << 20) >> 8 breaks it. Replacing the width bound with a value-representability check removes that fragility; MAX_SHIFT stays as an implementation limit, so 1 << 1024 is still rejected.

4. Named untyped constants, and 1/0

isUntypedIntConst treats any PNamedOperand as typed, so const c = 300; var x uint8 = c - 100 is rejected. Following the name to its declaration fixes it — untyped only when the declaration gives no type and its defining expression is itself untyped, so const c = int8(5) correctly stays typed. Constant division by zero threw an uncaught ArithmeticException; it is now a type error with gc's wording.

I deliberately did not add a static check for perm(x, 0): it broke fractional_permissions/{fields,predicates}/fail3.gobra, which expect contract_not_well_formed — which is exactly what the offendingNodeIn change here exists to support. A non-constant denominator can't be caught statically anyway, and perm isn't a Go construct.

Test results: 2371/2376, the identical failure set as before the change. The 5 are pre-existing on this branch: atomicsAndInvariants/spinlock.gobra, issues/001029.gobra and issues/001057-2.gobra still use the old 1/2 permission syntax and were never touched by this PR (the perm(x, y) migration missed them — this is what CI is red on), plus two Carbon tests needing Boogie.


On the integer(n) question in waitgroup.gobra

The conversion isn't necessary. Assignability.scala now makes every integer kind implicitly assignable to integer, but the perm(num, den) well-definedness uses an ad-hoc isIntegerOrUntypedConst that rejects bounded kinds outright. Using assignableTo.errors(numT, IntT(UnboundedInteger), mayInit) instead would let perm(n, 1) work directly and leave one rule rather than two. (The -integer(n) on the n < 0 line is separately justified by its MinInt64 comment — that one should stay.)

On perm(a, b) vs 1/2

Go hit this exact ambiguity and resolved it the way @ArquintL proposed — from Constant expressions:

const Θ float64 = 3/2     // Θ == 1.0 (3/2 is integer division)
const Π float64 = 3/2.    // Π == 1.5 (3/2. is float division)

Note what that says: the declared type does not make 3/2 float division; the operand kinds do. So 1.0/2 vs 1/2 is a rule Go users already know, and it avoids both the verbosity and the AmbiguityResolution special case — which currently violations (internal error, not a type error) on any two-argument T(a, b) where T isn't the built-in perm, so a typo like int(1, 2) crashes.

Smaller things

  • docs/tutorial.md is not updated. It teaches acc(x, 1/2) in ~10 places (287, 415–421, 490, 509, 515–517, 535, 677–678), all of which this PR turns into type errors.
  • isLiteralCluster in ConstantEvaluation.scala is a byte-for-byte duplicate of isUntypedIntConst; both traits mix into TypeInfoImpl, so one can be shared.
  • perm-fail1.gobra: test11 and test12 are now identical, and test15's comment ("the subexpressions … are treated as integer expressions and not as perms") is stale — the test now asserts the opposite.
  • Convertibility drops (IntT(_), PermissionT), so one-arg perm(x) on an int no longer converts; the ap.Conversion-to-perm paths in Desugar/permConstantEval are now reachable only for perm→perm.
  • The PR description still describes only the original literal-typing fix. The DirectoryStream leak fix, the 64-bit int()/uint() conversion fix and offendingNodeIn are independent of the perm syntax change and could land separately rather than waiting on that discussion.

Generated by Claude Code

Four places where Gobra's treatment of integer constant expressions differed
from the Go language specification, found while reviewing #1014.

1. Untyped constant comparison crashed the type checker.

   `assert 1 + 2 == 3 + 4` is a valid untyped boolean constant expression in
   Go, but aborted Gobra with "Cycle detected in attribute evaluation 'store'".
   The comparison case of getTypeFromCtxt peeked at the sibling operand's type,
   and for two compound untyped operands each side asked the other for its type
   until exprType re-entered a node still under evaluation.

   Per the spec a comparison of two untyped constants is itself an untyped
   constant and imposes no representability requirement, so no type needs to be
   propagated in that case. Deciding this structurally, before computing any
   type, both matches the spec and removes the cycle. `uint8(4) < 1000` is
   still rejected, since there one operand is typed.

2. Representability was checked at every node of a constant expression.

   The spec requires it only where the constant is given a type: "constant
   expressions are always evaluated exactly; intermediate values and the
   constants themselves may require precision significantly larger than
   supported by any predeclared type in the language". Gobra rejected
   `var x uint16 = (1 * 70000) - 69999`, which Go accepts. The check now
   happens once, at the outermost node of an untyped constant expression.

3. Shift counts were bounded by the width of the left operand's type.

   Go states "there is no upper limit on the shift count"; what makes
   `int8(1) << 10` invalid is that the value 1024 is not representable in int8.
   Gobra rejected `var c uint8 = (1 << 10) >> 3`, whose result 128 does fit.
   The type-width bound is replaced by a check that the shift's value is
   representable in its type; MAX_SHIFT is kept as an implementation limit.

4. References to untyped constants were treated as typed, and constant
   division by zero crashed.

   `const c = 300; var x uint8 = c - 100` is valid Go but was rejected.
   isUntypedIntConst now follows a name to its constant declaration, treating
   it as untyped only when the declaration gives no type and its defining
   expression is itself untyped (so `const c = int8(5)` stays typed), with a
   visited set so a cyclic declaration cannot recurse forever.

   `const d = 1/0` aborted with java.lang.ArithmeticException. The spec says
   "the divisor of a constant division or remainder operation must not be
   zero"; it is now reported as a type error, matching gc's wording.

perm(x, 0) is deliberately left to the Viper well-formedness check that
reports it as contract_not_well_formed, since a non-constant denominator
cannot be caught statically and `perm` is not a Go construct.

Full regression suite: 2371 of 2376 pass, the same set as before this change.
The 5 failures are pre-existing on this branch: three files still using the
old `1/2` permission syntax that the perm(x, y) migration missed
(atomicsAndInvariants/spinlock.gobra, issues/001029.gobra,
issues/001057-2.gobra) and two Carbon tests needing Boogie.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XV3UW3HZD9Gcf2s462BLaZ
… master

Merges master (which introduced -Werror) and adapts the branch accordingly:
unused imports and pattern variables are removed, and numExprType's match
is restructured so that the compiler no longer reports the PDiv case as
unreachable.

Review feedback:
  * perm(num, den) accepts integers of any kind: both arguments are checked
    with assignableTo(IntT(UnboundedInteger)), so a bounded operand is
    promoted like everywhere else, and the ad-hoc isIntegerOrUntypedConst
    rule is gone. The sync.WaitGroup stub drops the integer(n) conversion
    that this rule had forced (the -integer(n) negation stays, as it avoids
    the MinInt64 overflow).
  * TypeBounds.mergeLenient is folded back into TypeBounds.merge, i.e. the
    internal AST keeps master's rule that the bounded kind wins over
    UnboundedInteger, now documented with its actual reason: in the
    internal AST that kind is also the kind of every literal and every
    synthesized node, which the desugarer creates without the inferred
    kind, so `x + 1` must stay an `int` expression for overflow checking.
    The strict/lenient split had no other caller. integer-bounded-mix.gobra
    now documents the language rule it tests rather than an implementation
    detail, and additionally checks that narrowing back to a bounded kind
    needs an explicit conversion.
  * isLiteralCluster in ConstantEvaluation is replaced by the shared
    isUntypedIntConst, which also follows untyped named constants.
  * A two-argument "conversion" other than perm, e.g. int(1, 2), is a type
    error instead of an internal violation.
  * perm-fail1.gobra: test12 no longer duplicates test11, and test15's
    stale comment is corrected.

Go spec conformance (ported from the review branch): representability of
an untyped constant expression is checked only at its outermost node, the
shift count is not bounded by the width of the left operand's type, named
untyped constants are untyped constant expressions, comparisons of two
untyped constants propagate no type (which also removes an attribute
cycle on `1 + 2 == 3 + 4`), and constant division by zero is a type error.

Tests and docs: the regression tests added on master since the last merge
and docs/tutorial.md are migrated to the perm(a, b) syntax; new tests cover
perm with bounded operands and the two-argument conversion error.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X5Tny5JHavNi2Nz4bY9Ao5
Literals were checked against the type imposed by their context, but a
bare untyped named constant was not: with `const c = 300`, the programs
`var x uint8 = c`, `return c`, `g(c)`, `x == c` and `c > x` (for `x uint8`)
type-checked, whereas gc rejects all of them (Go spec, Representability).
Only arithmetic operands (`x + c`) were covered, by the per-operand checks
of binary expressions.

The well-definedness of a PNamedOperand resolving to a constant now
performs the same check as the PIntLit case: if the operand is an untyped
integer constant (structurally, via isUntypedIntConst) and not an inner
node of a larger untyped constant expression, its value must be
representable in the context type. The operand's own type is not computed
there, since typing an expression presupposes its well-definedness.

Verified against gc 1.24.7 on the cases above and on the accepted
counterparts (`var x uint8 = c - 100`, `c == 300`, `x == d` for `d = 200`).
Imported constants (`k.K`) are unaffected: they are still treated as typed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X5Tny5JHavNi2Nz4bY9Ao5
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants