feat: add bool_ops and widget_depth readability metrics - #10
Merged
Conversation
Add two readability metrics that the existing four cannot see. bool_ops measures the widest single boolean expression in a function rather than the function's total, so one opaque multi-clause condition fails even when cyclomatic complexity stays low. Default limit 3. widget_depth measures Flutter widget-constructor nesting in Dart build methods. The depth metric counts control flow, so a branch-free build could nest ten visual layers and stay green. Depth is carried only through widget slots (positional arguments, a named-slot allowlist, collection elements and closures passed into them) so that value constructors such as EdgeInsets and BoxDecoration do not inflate the score. Default limit 7, calibrated against 249 build methods. Dart method names now skip annotations. A name lookup previously descended into @OverRide and reported it as the method name, which hid build methods from widget_depth and mislabelled 429 functions across the Flutter corpus. Record in the spec that lint and policy rules stay out of scope.
The golden Expected struct carried only the four original metrics, so serde silently dropped the bool_ops and widget_depth expectations the fixtures already committed. Both fields are now deserialized and compared, and deny_unknown_fields stops a future fixture field from becoming inert the same way. Add a Dart fixture for a repeated null-coalescing chain, which the grammar nests rather than flattens, so the four operators count as one chain of four.
The slot filter lived only in the constructor walk, so any argument list reached generically kept its non-slot subtrees. The Dart grammar leaves the argument list dangling for arrow-bodied builders, which is the dominant Flutter style, so a value constructor under padding: inside a builder still raised widget_depth: Builder(builder: (c) => A(padding: P(child: Q(child: R())))) scored 4 instead of 1. Filtering every named argument during the walk fixes that and collapses widget_depth_in_constructor into widget_depth_in_node. Trim leading underscores before the uppercase test so private widgets count. _Card(...) is idiomatic for sub-widgets and nests like any other widget. Recalibrate against 258 build methods now that private widgets and builder bodies are visible: median 4, p90 7, p95 7, max 10. The default of 7 still sits at the p95 boundary and fails 12 methods. Correct the spec where it described behaviour the code did not have: conditional expressions stop a boolean chain, constructor_invocation is constructor-like, and the named-argument filter applies during descent.
Moving the slot filter into the descent also started walking the callee
field, and every method chained on a widget expression is itself
constructor-like, so each chain link added a layer:
Text('x').animate().fadeIn() scored 3 instead of 1. A call whose callee
subtree contains another call is now a chain rather than a new layer, so
only the innermost constructor counts.
Recalibrate once more without the inflation: 258 build methods, median
4, p90 6, p95 7, max 10. The default of 7 fails 6 methods (2.3%).
Document the chain rule, the uppercase requirement on
constructor_invocation, and the one known undercount: for arrow-bodied
builders the grammar strands the returned widget inside the closure body,
so that widget is not counted. The error is lenient, which is the safe
direction for a gate.
This was referenced Aug 31, 2026
Member
Author
E2E transcript — raw output, final HEAD (
|
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.
Closes #9. Refs #6.
Adds the two readability metrics that survived corpus calibration. Both measure things the existing four cannot see.
bool_ops— max boolean operators in one expression (all languages, default 3)Cyclomatic complexity spreads boolean operators across a whole function, so a function far below the limit can still hold one opaque five-clause condition.
bool_opsmeasures the widest single boolean chain instead.Chain roots, parenthesis handling, and the ternary/closure cutoffs are specified in
docs/spec.md.widget_depth— Flutter widget nesting (Dartbuildmethods, default 7)The
depthmetric counts control flow, so a branch-freebuildcould nest ten visual layers and stay green. Depth is carried only through widget slots — positional arguments, a named-slot allowlist, collection elements, and closures passed into them — so value constructors likeEdgeInsetsandBoxDecorationdo not inflate the score.A call whose callee subtree contains another call is treated as a method chain, not a new layer, so
Text('x').animate().fadeIn()counts once. One known undercount is documented in the spec: for arrow-bodied builders the grammar strands the returned widget inside the closure body, so that widget is not counted — the error is lenient, which is the safe direction for a gate.Calibrated against 258 Flutter
buildmethods (ConstruApp, 3d_portfolio, pickarena): median 4, p90 6, p95 7, max 10. The default of 7 fails 6 methods (2.3%); a limit of 6 would fail 23 (8.9%) and 5 would fail 48 (18.6%).Dart method names no longer resolve to annotations
descendant_fielddescended into@overrideand returned it as the method name, so every annotated Dart method was reported asX.override. This hidbuildmethods fromwidget_depthentirely and mislabelled 429 functions across the Flutter corpus. Names now skip annotation nodes:_LoginScreenState.override→_LoginScreenState.build.This is a user-visible output change riding along in a feature PR. It is a prerequisite for
widget_depth, but flagging it explicitly for review.Evidence
Gates (all commands run locally on this HEAD):
cargo test --workspace --locked --all-targetscargo clippy --workspace --all-targets -- -D warningscargo run -- check cratescargo llvm-cov --workspace --locked --fail-under-lines 89No regression in existing metrics. Differential run of
main's binary against this branch's over prova-clara, ConstruApp, 3d_portfolio, pickforge, pickscribe:complexity,depth,lines, andparamsproduce byte-identical(file, line, metric, value)records. Only function names changed (429, described above).E2E — hook contract
E2E — slot restriction works
E2E — config
Exit-code contract preserved: 0 clean, 1 violations, 2 usage/runtime.
Corpus impact with shipped defaults
bool_opswidget_depth52 new violations across ~15k functions — signal, not noise.
The metric caught this repo's own code.
bool_opsflaggedcoverage_ignoredincrates/core/src/language.rs, a six-operator||chain, when the binary gated itself. It was refactored to an array.any()rather than raising the limit, perAGENTS.md.Local review
Two reviewers ran against frozen HEAD
beb9500(correctness/regressions, and issue-conformance + KISS), then a fix-verification pass against355bbfa. Three reviewed HEADs total.Accepted and fixed:
=>builders. The filter lived only in the constructor walk, but the Dart grammar leaves the argument list dangling for arrow-bodied builders — the dominant Flutter style.Builder(builder: (c) => A(padding: P(child: Q(child: R()))))scored 4 instead of 1, breaking the issue's central negative criterion in its most common real position. Fixed by filtering every named argument during descent, which also collapsedwidget_depth_in_constructorintowidget_depth_in_node— the correctness fix and the KISSsimplify_nowverdict turned out to be the same change._Card(...)failed the uppercase test, so itspadding:value was traversed generically. Leading underscores are now trimmed;_Cardcounts like any other widget.Expectedcarried only the four original fields, so serde silently dropped every committedbool_ops/widget_depthexpectation. Both are now deserialized and compared, withdeny_unknown_fieldsto stop the same silent drop recurring. Verified by mutation: bumping one expectation by 7 fails the test; restoring it passes.build. Two fixture classes now use@override, plus cases for the arrow-builder and private-widget shapes.constructor_invocationis constructor-like, and the named-argument filter is stated to apply during descent with the arrow-builder rationale.Dismissed with evidence:
??chains undercount (a ?? b ?? c ?? d ?? escoring 1). Does not reproduce: it scores 4, identical to the&&equivalent. The pinned grammar nestsif_null_expressionrather than flattening it. A regression fixture (nullChain) now pins this.Found by fix-verification on
355bbfa, fixed ind014015:Text('x').animate().fadeIn()scored 3 instead of 1, penalisingflutter_animateand extension-method styles for nesting they do not create. Fixed, re-probed, and recalibrated a second time — which moved the failure count at limit 7 from 12 back to 6.Not fixed:
config.default.jsonwas reformatted beyond the two added keys. The new form is exactlyserde_json::to_string_prettyoutput, matching whatinitemits, so it is now round-trip consistent — P3, not blocking.Open decisions for the reviewer
crates/cli/tests/cli.rs,crates/core/src/config.rs) to track the default moving 8 → 7. Worth a look — editing tests to match a changed constant is the pattern that hides regressions.ternary_depthwas measured and cut (12 functions over the limit corpus-wide, only 4 not already caught). Rationale recorded in Readability metrics: bool_ops + widget_depth #9.no-any,no-unwrap— stay out of scope. They belong to clippy, ESLint, oxlint, anddart analyze.