[wip]: dasSMT: solver-backed lint (SMT001-SMT008) + SFloat/SDouble; STYLE039 interval merge - #3631
Open
aleksisch wants to merge 4 commits into
Open
[wip]: dasSMT: solver-backed lint (SMT001-SMT008) + SFloat/SDouble; STYLE039 interval merge#3631aleksisch wants to merge 4 commits into
aleksisch wants to merge 4 commits into
Conversation
SReal is an exact rational, so it cannot express NaN, the infinities or rounding. Model das float/double on the FPA theory instead: `x != x` is now satisfiable, `+0 == -0` holds, and `0.1 + 0.2 == 0.3` is false, all matching runtime exactly. `==` uses Z3_mk_fpa_eq, NOT Z3_mk_eq. The latter is structural equality, which would make NaN == NaN true and +0 == -0 false -- the exact inverse of IEEE. Model readback goes through Z3_mk_fpa_to_ieee_bv rather than the rational value, so NaN, the infinities and negative zero all round-trip. Int -> FP conversions are provided but documented as impractical: unbounded Int -> Real -> FP times out even on `i < 3 && to_float(i) > 5.0f`. Every other conversion answers instantly. s_lift deliberately gains no float overloads: `double` already lifts to SReal for the constrain macro, and adding a second mapping would silently mix sorts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Symbolically executes each function body, accumulating a path condition on the
solver stack, and asks Z3 whether each branch condition can hold on the paths
that reach it. Nothing else in the tree tracks values across branches: LINT001
is syntactic, STYLE010 needs a literal, LINT010 abandons any variable touched
under a branch.
SMT001 reports an unreachable branch; SMT002 an always-true condition with no
else, seeded default-off since it is the noisier half. Both report under the
existing 31209 style-warning code, so nolint / .lint_config / LintIssue work
with no C++ change.
Only UNSAT is ever reported. Everything else is silence:
- int is unbounded SMT Int, which does not wrap, so every integer add/sub/mul
feeding a decision must first be proven inside the 32-bit range under the
path condition. Found by walking the Z3 term -- an earlier das-side version
missed arithmetic that arrived through a tracked local and false-positived
on `let b = a + 1; if (b < a)`.
- Generic instantiations are skipped entirely: a specialized body's `at` points
at the shared template, so a verdict cannot be attributed there. A tree sweep
caught this via `if (a != a)` in `def f(a : auto(TT))` -- dead for TT=int,
live for TT=float.
- Literal conditions are left to STYLE010; a $v(...) qmacro splice collapsing
to a constant is the macro author's intent.
- Aliased locals, loop-carried values, calls, fields and indices are all opaque.
- Every query carries a 250ms Z3 timeout, 200 queries per function.
The pass runs after optimization, which rewrites `if (c) { return x }` +
`return y` into a ternary, leaving no if-node. Guard-style findings therefore
need optimizations off, the way utils/lint/main.das compiles its inputs -- so
the fixtures go through their own driver rather than the lint_macro path.
utils/lint/main.das is untouched: it cannot require an opt-in module.
Fixtures mark expectations inline with `// smt_expect <CODE>`; the driver fails
on a missed detection and on an unexpected one, and runs under run_examples.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ep found
Sweeping all 2748 .das files showed the pass was analyzing almost nothing and,
where it did, trusting stale values. Both are fixed, and six defect classes are
added on top of the two reachability rules.
Coverage: a class method's body is `with (self) { ... }`, and ExprWith was not a
statement the walker descended into, so every method body in the tree was
skipped. daslib/perf_lint.das went from 0 if-nodes analyzed to 344,
daslib/aot_cpp.das from 79 to 552. ExprTryCatch and ExprLabel/ExprGoto are now
handled too (conservatively).
Soundness, all three found only by sweeping real code:
- Method calls are ExprInvoke, not ExprCall, so out-params of methods were never
havocked. `parse_range_leg(..., var bound_out : int&)` left bound_out at its
initial value and one stale fact then poisoned every later guard in the
function -- 23 false positives in three files.
- Arrays, tables and structs pass by reference WITHOUT flags.ref set, so testing
that flag missed `arr |> push(x)`. Constness is the reliable signal.
- `length(v)` is memoized per container so two reads agree, but a mutated
container's length changes under us; volatile containers now get a fresh
symbol each read.
A report is also refused when the path condition is itself unsatisfiable: that
means dead code or a broken model, and it stops one bad fact cascading.
New rules. SMT003 divisor provably zero, SMT004 assert that cannot hold, SMT005
loop that never runs, SMT006 shift count outside 0..31, SMT007 always-negative
subscript -- each asks "is this ALWAYS broken here", never "could it be", since a
merely-possible zero divisor would fire on every unconstrained value.
SMT008 judges a &&/|| chain on its own, via a second solver carrying no path
condition: `x != 1 || x != 2` always holds (the author meant &&), `x > 5 && x < 3`
never does. Always-true *given the enclosing guards* is a redundancy (SMT002,
default-off); always-true *on its own* is a defect in the expression, so SMT008 is
default-on. `a > b && b > a` moves from SMT001 to SMT008 accordingly.
A bounds off-by-one rule was written and dropped: "the path condition mentions
this container's length" is too weak a proxy for "the author bounds-checked this
index", giving 137 false positives against one true case. Relating an index to a
length needs real range analysis.
`options _smt_lint_stats = true` prints why each if was or was not queried; that
is what exposed the coverage gap.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`x != 1 && x > 0` is `x >= 2`, and nothing in the tree detected that. This is the solver-free replacement for an SMT-backed attempt that did not survive review. Evaluates the &&/||/! tree on a value lattice over one int variable, the domain LLVM's ConstantRange and Clang's RangeConstraintManager use: `x > 0` is [1,MAX], `x != 1` is [MIN,0]+[2,MAX], && intersects, || unites, ! complements. If the result is one one-sided interval, a single point, or the complement of a point, that comparison is reported. Not subsumption: in `x != 1 && x > 0` neither operand implies the other, so dropping an implied literal cannot find this -- Z3's own ctx-solver-simplify leaves the pair untouched. The merge is arithmetic, not boolean. Solver-free, so unlike the SMT rules it needs no opt-in module and runs in every build, in utils/lint/main.das, and under the CI zero-warning gate. Endpoints are int64 so `c - 1` and `c + 1` cannot wrap at an int boundary. Silent when no single comparison is equivalent (two-sided range, disjoint union), when more than one variable appears, when the condition is already a single comparison, or on non-int operands. Always-true stays with STYLE010. Measured over daslib, utils, modules, tests, tutorials, examples and dastest: zero findings, so it is quiet rather than noisy -- which is why it is default-on. Lives in STYLE, not LINT: LINT codes report through macro_error, and a simplification suggestion must not fail a build. Numbered 039: master took 036 (inert cast contract), 037 and 038 while this branch was open. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Four commits: three grow the opt-in
dasSMTmodule into a solver-backed lint, one adds a solver-free style rule that came out of the same work.SFloat/SDoubleover the Z3 FPA theorysmt_exprgains symbolic IEEE floats — the exact model of dasfloat/double, unlikeSReal(exact rational, no NaN). Rounded ops use round-nearest-ties-to-even,==isZ3_mk_fpa_eqrather than structural equality (soNaN == NaNis false and+0 == -0is true, matching runtime), and model readback goes through the IEEE bit pattern so NaN, the infinities and negative zero survive it.Int↔float conversions are provided but documented as a trap: unbounded
Int → Real → FPis not decidable in practice — it times out on trivial goals.SMT001/SMT002— reachability lintmodules/dasSMT/daslib/smt_lint.dasis a[lint_macro]that symbolically executes each function body, accumulating a path condition, and asks Z3 whether each branch condition can hold on the paths that reach it. Nothing else in the tree tracks values across branches.SMT001— a branch unsatisfiable on every path that reaches it.SMT002— a condition always true with noelse: a redundant guard. Default-off (noisy by nature), seeded viaseed_default_disabled.Only an
UNSATverdict is ever reported. Timeouts, unmodeled types, unproven integer overflow and anything the translation cannot represent all yield silence — the pass under-reports by construction rather than guessing.SMT003–SMT008, plus three soundness fixesSweeping all 2748
.dasfiles showed the pass was analyzing almost nothing, and trusting stale values where it did.Coverage: a class method's body is
with (self) { ... }, andExprWithwas not a statement the walker descended into — so every method body in the tree was skipped.daslib/perf_lint.daswent from 0 if-nodes analyzed to 344;daslib/aot_cpp.dasfrom 79 to 552.Soundness (all three found only by sweeping real code):
ExprInvoke, notExprCall, so out-params of methods were never havocked — one stale fact then poisoned every later guard in the function (23 false positives across three files).flags.refset, so testing that flag missedarr |> push(x). Constness is the reliable signal.length(v)is memoized per container so two reads agree — but a mutated container's length changes under us, so volatile containers now get a fresh symbol per read.New codes: division/modulo by a definitely-zero divisor (
SMT003), anassert/verifythat cannot hold when reached (SMT004), awhilewhose body never runs (SMT005), a shift count outside0..31(SMT006), a definitely-negative subscript (SMT007), and a&&/||condition that is constant whatever its inputs are (SMT008, judged on a second solver carrying no path condition — always-true-on-its-own is a defect, so it is default-on, unlike the context-relativeSMT002).Each asks "is this ALWAYS broken here", never "could it be" — a merely-possible zero divisor would fire on every unconstrained value.
STYLE039— condition collapses to a single comparisonx != 1 && x > 0isx >= 2, and nothing detected that. Solver-free, so unlike the SMT rules it needs no opt-in module and runs in every build and under the CI zero-warning gate.The
&&/||/!tree is evaluated on an interval lattice — the domain LLVM'sConstantRangeand Clang'sRangeConstraintManageruse — and reported only when the result is one one-sided interval, a single point, or the complement of a point. Not subsumption: inx != 1 && x > 0neither operand implies the other, so dropping an implied literal cannot find it; Z3's ownctx-solver-simplifyleaves the pair untouched. Endpoints areint64soc ± 1cannot wrap at an int boundary.Measured over
daslib,utils,modules,tests,tutorials,examplesanddastest: zero findings — quiet rather than noisy, which is why it is default-on.Testing
modules/dasSMT/tests/smt_lint_check.das— fixture driver; each fixture marks expected findings with a trailing// smt_expect <CODE>and the driver fails on both a missed detection and an unexpected one. 7/7, 3/3, 10/10 across the three fixtures. Wired into CMake as an interpreted example run.utils/lint/tests/style039_condition_interval_merge.das—expect 31209:5; the full lint fixture suite is 63/63..das: 0 issues. Full build,tests/suite (12144 passed / 0 failed),das2rstregeneration and a Sphinx-Wbuild all clean locally.Notes for review
if (c) { return x }/return yinto a ternary — leaving no if-node. Guard-style findings therefore needno_optimizations, the wayutils/lint/main.dasand the fixture driver compile their inputs.utils/lint/main.dasdoes not run this pass at all: it cannotrequirean opt-in module.modules/dasSMT/README.md.🤖 Generated with Claude Code