Skip to content

feat: add bool_ops and widget_depth readability metrics - #10

Merged
ElbertePlinio merged 4 commits into
mainfrom
feat/readability-metrics
Sep 1, 2026
Merged

feat: add bool_ops and widget_depth readability metrics#10
ElbertePlinio merged 4 commits into
mainfrom
feat/readability-metrics

Conversation

@ElbertePlinio

Copy link
Copy Markdown
Member

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_ops measures 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 (Dart build methods, default 7)

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 value constructors like EdgeInsets and BoxDecoration do 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 build methods (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_field descended into @override and returned it as the method name, so every annotated Dart method was reported as X.override. This hid build methods from widget_depth entirely 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):

Command Result
cargo test --workspace --locked --all-targets 34 passed, 0 failed
cargo clippy --workspace --all-targets -- -D warnings clean
cargo run -- check crates exit 0 (the binary gates its own code)
cargo llvm-cov --workspace --locked --fail-under-lines 89 91.51% lines

No regression in existing metrics. Differential run of main's binary against this branch's over prova-clara, ConstruApp, 3d_portfolio, pickforge, pickscribe:

functions checked old/new: 10027 10027
old metric records: 25902   new: 25902
IDENTICAL

complexity, depth, lines, and params produce byte-identical (file, line, metric, value) records. Only function names changed (429, described above).

E2E — hook contract

### PostToolUse blocks on bool_ops (default 3)
{"decision":"block","reason":"FAIL bad.js:1 opaque  bool_ops 4 > 3"}

### PostToolUse blocks on widget_depth (default 7)
{"decision":"block","reason":"FAIL w.dart:1 S.build  widget_depth 9 > 7"}

### Stop hook aggregates across the diff
{"decision":"block","reason":"FAIL bad.js:1 opaque  bool_ops 4 > 3\nFAIL w.dart:1 S.build  widget_depth 9 > 7\nRefactor the listed functions (see the complexity-gate skill), then finish."}

E2E — slot restriction works

### 9 nested value constructors under padding: NOT counted
$ complexity-gate check v.dart        # Padding(padding: EdgeInsets.only(...9 deep...), child: Text("x"))
exit=0

### the same nesting under child: IS counted
FAIL w.dart:1 S.build  widget_depth 9 > 7

E2E — config

### per-language override tightens the limit
$ complexity-gate check --config tight.json good.dart   # {"languages":{"dart":{"limits":{"widget_depth":1}}}}
hookrepo/good.dart:1 S.build  widget_depth 2 > 1
exit=1

### strict validation rejects unknown keys
$ complexity-gate check --config badcfg.json good.dart  # {"limits":{"bool_opz":3}}
error: unknown config key `limits.bool_opz` in hookrepo/badcfg.json
exit=2

Exit-code contract preserved: 0 clean, 1 violations, 2 usage/runtime.

Corpus impact with shipped defaults

repo functions bool_ops widget_depth
prova-clara 3664 9 0
ConstruApp 1650 2 3
3d_portfolio 145 0 3
pickforge 3425 15 0
picklab 3044 9 0
pickgauge 2158 7 0
pickscribe 1143 4 0

52 new violations across ~15k functions — signal, not noise.

The metric caught this repo's own code. bool_ops flagged coverage_ignored in crates/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, per AGENTS.md.

Local review

Two reviewers ran against frozen HEAD beb9500 (correctness/regressions, and issue-conformance + KISS), then a fix-verification pass against 355bbfa. Three reviewed HEADs total.

Accepted and fixed:

  • P1 — the widget-slot filter leaked inside => 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 collapsed widget_depth_in_constructor into widget_depth_in_node — the correctness fix and the KISS simplify_now verdict turned out to be the same change.
  • P2 — private widgets inverted the same way. _Card(...) failed the uppercase test, so its padding: value was traversed generically. Leading underscores are now trimmed; _Card counts like any other widget.
  • P2 — the golden harness did not enforce the new metrics. Expected carried only the four original fields, so serde silently dropped every committed bool_ops/widget_depth expectation. Both are now deserialized and compared, with deny_unknown_fields to stop the same silent drop recurring. Verified by mutation: bumping one expectation by 7 fails the test; restoring it passes.
  • P2 — no golden covered an annotated build. Two fixture classes now use @override, plus cases for the arrow-builder and private-widget shapes.
  • Spec drift. The written rule promised behaviour the code did not have. Corrected: conditional expressions stop a boolean chain, constructor_invocation is constructor-like, and the named-argument filter is stated to apply during descent with the arrow-builder rationale.
  • Recalibration. The original default was measured with the leak present. Re-measured after the fix on 258 build methods.

Dismissed with evidence:

  • P1 — claimed Dart ?? chains undercount (a ?? b ?? c ?? d ?? e scoring 1). Does not reproduce: it scores 4, identical to the && equivalent. The pinned grammar nests if_null_expression rather than flattening it. A regression fixture (nullChain) now pins this.

Found by fix-verification on 355bbfa, fixed in d014015:

  • P2 — the P1 fix introduced chain inflation. Filtering during descent also meant walking the callee field, and every method chained on a widget expression is itself constructor-like, so each link added a layer: Text('x').animate().fadeIn() scored 3 instead of 1, penalising flutter_animate and 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.json was reformatted beyond the two added keys. The new form is exactly serde_json::to_string_pretty output, matching what init emits, so it is now round-trip consistent — P3, not blocking.

Open decisions for the reviewer

  • The widget-slot allowlist is a heuristic I authored (29 names). Miss a slot name and real nesting goes uncounted. It is now part of the spec, so changing it requires a fixture update.
  • The uppercase-identifier proxy cannot distinguish a widget from any other uppercase call; the slot restriction is what keeps it honest. Without type resolution there is no better syntactic option.
  • Default 7 is tuned on three Flutter repos, two of them small. 6 is the plausible tighter alternative.
  • Two existing test assertions were edited (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_depth was 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.
  • The spec now records that lint and policy rules — banned APIs, typed errors, no-any, no-unwrap — stay out of scope. They belong to clippy, ESLint, oxlint, and dart analyze.

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.
@ElbertePlinio

Copy link
Copy Markdown
Member Author

E2E transcript — raw output, final HEAD (d014015)

$ complexity-gate --version
complexity-gate 0.1.1

# 1. hook blocks on bool_ops (default 3)
{"decision":"block","reason":"FAIL bad.js:1 opaque  bool_ops 4 > 3"}

# 2. hook blocks on widget_depth (default 7)
{"decision":"block","reason":"FAIL w.dart:1 S.build  widget_depth 9 > 7"}

# 3. Stop hook aggregates across the diff
{"decision":"block","reason":"FAIL bad.js:1 opaque  bool_ops 4 > 3\nFAIL w.dart:1 S.build  widget_depth 9 > 7\nRefactor the listed functions (see the complexity-gate skill), then finish."}

# 4. widget-slot restriction: 9 nested value constructors under padding:
exit=0

# 5. clean Dart build passes
exit=0

# 6. per-language override tightens the limit
hookrepo/good.dart:1 S.build  widget_depth 2 > 1
exit=1

# 7. strict validation rejects unknown keys
error: unknown config key `limits.bool_opz` in hookrepo/badcfg.json
exit=2

Regression probes (Dart)

class A1 { Widget build(ctx) => A(padding: P(child: Q(child: R()))); }
class A2 { Widget build(ctx) => Builder(builder: (c) => A(padding: P(child: Q(child: R())))); }
class A3 { Widget build(ctx) => _Card(padding: P(child: Q())); }
class A4 { @override Widget build(ctx) => Column(child: Text('x')); }
class C1 { @override Widget build(ctx) => Text('x').animate().fadeIn(); }
class C2 { @override Widget build(ctx) => Container(child: Text('x')).animate(); }
class C3 { @override Widget build(ctx) => Theme.of(ctx).primary; }

A1.build         widget_depth=1  expected 1  value ctor under padding:
A2.build         widget_depth=1  expected 1  same, inside => builder
A3.build         widget_depth=1  expected 1  private widget _Card
A4.build         widget_depth=2  expected 2  @override build
C1.build         widget_depth=1  expected 1  Text().animate().fadeIn()
C2.build         widget_depth=2  expected 2  Container(child:Text).animate()
C3.build         widget_depth=1  expected 1  Theme.of(ctx)

CI

rust pass · gitleaks pass · osv-scanner pass · plan pass

@ElbertePlinio
ElbertePlinio merged commit 79ef2c2 into main Sep 1, 2026
8 checks passed
@ElbertePlinio
ElbertePlinio deleted the feat/readability-metrics branch September 1, 2026 14:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Readability metrics: bool_ops + widget_depth

1 participant