Skip to content

perf(engine): fuse the particle sweep, bound the spatial grid, and fill the pub-API test gaps - #297

Open
eastspire wants to merge 19 commits into
masterfrom
perf/engine-hot-path-allocs-2026-10-01
Open

eastspire wants to merge 19 commits into
masterfrom
perf/engine-hot-path-allocs-2026-10-01

Conversation

@eastspire

@eastspire eastspire commented Oct 1, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Engine per-frame fixes, a pub-interface completion, the test coverage that was missing around both, and a cleanup of 561 constants that only existed to satisfy the hardcoded-strings rule inside the DSL macros. Bumps 0.28.9 -> 0.28.10.

Scope note: the per-frame work is deliberately at the algorithm/architecture level. The per-accessor clone costs in the WebGL uniform cache and the WebGPU descriptor paths were reviewed and left alone — they are Lombok accessor semantics, and changing them belongs in a separate pass.

1. ParticleEmitter::update walked the particle array twice per step

The integration pass applied gravity, velocity and age to every live particle, then a second Vec::retain pass walked the whole array again to drop the expired ones. The two passes are now one: a read cursor integrates each particle and a write cursor compacts the survivors downward.

  • One sweep per step instead of two.
  • retain moved the whole tail whenever anything died; the swap-based compaction moves only survivors, and nothing at all when nothing expired — the common case for a short-lived emitter.
  • The per-particle gravity.scaled(delta_time) is hoisted out of the loop.
  • The emission cap reads the live count through the mutable borrow it is about to take, instead of materialising an owned copy of the whole vector to call .len().

Survivor order is preserved, and surviving_particles_keep_their_relative_order_after_compaction pins it as a subsequence check.

2. SpatialHashGrid2D/3D::clear() never released a cell

clear() emptied each cell's Vec but kept every key it had ever seen, so a roaming scene accumulated one permanent map entry per cell ever visited and both the map and the per-tick clear grew for the life of the process. Cells that end the pass empty are now dropped; occupied cells keep their key and buffer, so a stationary body still pays no allocation.

3. A degenerate cell size turned one insert into a billions-long loop

Found by a test, not by inspection. create clamped a non-positive cell size to EPSILON (1e-6), leaving an inverse cell size of 1e6 — a body of ordinary size mapped to a column range in the millions, and a single insert iterated billions of empty cells. The suite hung on it. A cell size that is not finite and strictly positive now falls back to the documented default, shared by the 2D and 3D constructors.

4. ParticleConfig was not configurable from outside the crate

Every tunable carried #[set(pub(crate))], so from outside the crate the emitter could only be built from ParticleConfig::default(); gravity, colours, sizes, angle and spread had no setter at all. The narrowing attributes are removed so the derive emits the public setters — additive, no existing signature changes.

5. 561 constants that only existed to satisfy §1.3c

The hardcoded-strings rule pushes literals into const.rs. Inside the four DSL macros — html!, class!, var!, vars! — that produced indirection with no payoff: a literal in a template is the payload, and the macro already emits the bytes verbatim. The shape of the result is the evidence: 561 constants had exactly one kind of use, a reference from inside a macro body, and 38 const.rs files existed only to hold them.

All 714 references are inlined back to literals, the constants are deleted, and 16 const.rs files that end up with no items are removed along with their mod r#const; declaration and re-export in the sibling mod.rs.

Constants that are genuinely reused are untouched. Four sit on both sides of a macro boundary and keep their name, because the non-macro caller is the real reason they exist:

  • FILE_UPLOAD_ID — id: in the template and get_element_by_id in the hook
  • ROUTE_HASH_PREFIX — used by the sidebar and navbar link builders
  • THEME_DARK — read by the theme hook's match_media comparison
  • HOOKS_ASYNC_RESOLVED_VALUE — passed to the resolver in the hook

The rule itself is unchanged for everything else: check 38 went from 552 to 273 hits, and the remainder are literals in ordinary code (item.link.starts_with("http") and similar), not template payloads.

No rendered output changes — every inlined value is the exact string the constant held.

6. Standards violations in the touched files

check 35 (§5.1) reported 5 real hits, all pre-existing. Fixed here: four handlers in example/src/page/event/view/fn.rs that were bare let x = move |_: Event| { bindings while every neighbouring handler in the same file was already Box<dyn FnMut(Event)>, plus pin_pos in docs/build.rs sitting beside an already-boxed order_of. A sixth surfaced in ui/src/component/nav/view/fn.rs once the surrounding const block was deleted; it predates this branch too.

Tests

Nine modules had no coverage at all. The engine suite goes from 136 to 261 cases.

module covers
cell EngineCell / MaybeEngineCell, incl. try_set returning Err(value) rather than overwriting
collider 2D/3D AABB, circle, sphere, cross-shape, and AABB3D::broad_phase
easing all 31 variants: endpoints, symmetry, overshoot legality, closed forms
entity component lifecycle, tags, object-pool recycling, EventBus routing
particle lifetime retirement, emission rate, RNG reproducibility, render output, compaction order
scene register/unregister, switch, deferred transition, update isolation
spatial hash grid 2D/3D, dedup, query_into buffer reset, cell reclamation
timer one-shot vs repeating, pause, reset, clamping
tween delay, pause/resume, loop, ping-pong, completion callback

Two are correctness anchors rather than shape checks: grid_query_never_misses_a_body_a_linear_scan_would_overlap cross-checks the grid against a linear scan, and the compaction test checks survivor order as a subsequence.

Verification

  • cargo test -p euv-engine — 261 passed, 0 failed
  • cargo clippy -p euv -p euv-ui -p euv-example --all-targets — 0 warnings
  • cargo fmt --check — clean, idempotent
  • cargo check -p euv -p euv-core -p euv-ui -p euv-example -p euv-docs --target wasm32-unknown-unknown — clean
  • staged_file_gate.py (the pre-commit hook) — 0 new violations on all three commits

Standards audit: two false positives fixed in the skill, not papered over

run_check in audit_rust_standards.py decided PASS/FAIL purely on "did the wrapper print anything", never consulting the subprocess exit code. Every wrapper propagates its verifier's verdict through exit "$exit_code", so a passing verifier that printed its own success trailer scored as a violation. Both offenders were filter drift — the wrapper's grep -v guard had fallen out of sync with the verifier's summary string:

  • check 27 filtered ^OK: 0 lib, but the script prints OK: 8 lib.rs file(s) checked, no group-order violation
  • check 28 filtered ^=== no-comments-in-tests:, but the script prints === no-comments-in-test-files: 0 violation(s) in 0 file(s)

Running either verifier by hand exits 0. Patching the two hardcoded filters would have left the same landmine in the other 25 wrappers, so run_check now trusts the exit code, and the "exited non-zero with no stdout" path reports a real diagnostic instead of silently passing a check that never ran.

Confirmed the fix does not hide real findings: the five genuine §5.1 violations were still reported as 5 hits after the change and were fixed on their own merits.

That last guard immediately exposed a third problem, in the other direction. Check 15 (pure &Foo helper in fn.rs, §1.3.1) had never executed. Its inline awk template carried three compounding defects: {{/}} brace escapes meant for str.format() while substitute() resolves placeholders with a manual .replace(); a split(types, arr, "\n") inside a Python triple-quoted string, so Python ate the escape and awk received a real newline inside a string literal; and a 3-argument match(), a GNU awk extension BSD awk lacks. awk died on every run, and because the verdict came from stdout, the check reported PASS the entire time. It is now a companion script, verify_pure_ref_helper.py, which is portable and self-testable; it scans 10 fn.rs files in this tree and finds 0 violations.

Still open, and deliberately not in this PR

  • 389 real pre-existing violations outside the engine and untouched here: check 37 (§2.2 doc comments) has 116 in example/ and ui/; check 38 retains 273 literals that are in ordinary code rather than template payloads. Genuine cleanup work, not patch-sized.
  • Checks 16, 17, 18 and 20 cannot run in the sandbox this was developed in. They are inline bash snippets using <<'PY' heredocs, and the sandbox refuses to create the temp file a heredoc needs. They now fail loudly with the shell's own error rather than silently reporting PASS, which is the correct behaviour but means the full-tree audit cannot be demonstrated green here. On an ordinary shell they run normally, and nothing in this branch depends on them.
  • TaskRegistry::unregister has a real stale-handle bug. Register A, B, C, unregister A, then unregister B's handle — Vec::remove(1) deletes C. The doc comment claims it "re-resolves the index against the current list rather than trusting a stale one", which the code does not do. The fix needs a generation tag on TaskHandle, changing its shape and new() arity, so it is a breaking change for a minor bump rather than this patch.

Three engine-level fixes, all in the per-frame path, plus the pub-interface
and test coverage that was missing around them.

ParticleEmitter::update walked the live particles twice: once to integrate
motion and age, then again through Vec::retain to drop the expired ones.
The two passes are now one. A read cursor integrates each particle and a
write cursor compacts the survivors downward, so a step costs one sweep
instead of two, and the compaction moves no memory at all when nothing
expired, which is the common case for a short-lived emitter. Survivors keep
their relative order because a dead slot is only refilled by swapping a live
particle into it, and the displaced element lands past the truncation point.
The per-particle gravity step is also hoisted out of the loop, and the
emission cap now reads the live count through the mutable borrow it is about
to take rather than materialising an owned copy of the whole vector.

SpatialHashGrid2D/3D::clear() kept every cell key it had ever seen and only
emptied the backing vectors. A scene whose bodies roam across a large world
therefore accumulated one permanent map entry per cell ever visited, and both
the map and the per-tick clear grew for the lifetime of the process. Cells
that end the pass empty are now dropped; occupied cells keep their key and
their Vec buffer, so a body that stays put still pays no allocation.

The grid constructors clamped a degenerate cell size up to EPSILON, which
still leaves an inverse cell size of 1e6: a body of ordinary size then mapped
to a column range in the millions, so a single insert or query degenerated
into a loop over billions of empty cells. A cell size that is not finite and
strictly positive is now rejected in favour of the documented default.

ParticleConfig carried #[set(pub(crate))] on every tunable, so from outside
the crate the emitter could only ever be built from the defaults. Emission
rate, lifetimes, speeds, gravity, colours and the particle cap were all
unreachable. Those setters are now public, as is ParticleEmitter's active
flag.

Tests: nine modules that had no coverage at all, cell, collider, easing,
entity, particle, scene, spatial, timer and tween, take the engine suite from
136 to 261 cases. That includes a brute-force cross-check that the grid never
culls an overlapping body and a subsequence check that compaction preserves
survivor order.

Bump 0.28.9 -> 0.28.10.
… bindings

Clears the only audit findings for the files this branch touches. check 35
(§5.1) went from 5 hits to 0, and check 33 (§5.2) is clean.

Four of them are stragglers in example/src/page/event/view/fn.rs: that file
already declares every neighbouring handler as `Box<dyn FnMut(Event)>`, and
these four had been left as bare `let x = move |_: Event| { ... }` bindings.
They now follow the pattern the rest of the file uses, which is also the
pattern the handlers are consumed under, so nothing about the call sites
changes.

The fifth is docs/build.rs, where `pin_pos` sat directly beside `order_of`,
which was already `Box<dyn Fn(&SideItem) -> i64>`. It gets the same shape so
the two sort-key helpers read alike. Unlike the four above, this one was a
plain closure, so it now goes through a Box: that is one allocation when the
build script starts, not per call, and the call sites already deref through
`Box<dyn Fn>` for `order_of`.
…§1.3c

The hardcoded-strings rule pushes string literals into `const.rs`. Inside
the four DSL macros — `html!`, `class!`, `var!`, `vars!` — that produced
indirection with no payoff: a literal written in a template is the
payload, not incidental program data, and the macro already emits the
bytes verbatim. The evidence is in the shape of the result: 561 constants
in this workspace had exactly one kind of use, a reference from inside a
macro body, and 38 `const.rs` files existed only to hold them.

This inlines all 714 such references back to their literals and deletes
the constants that are then unused. 16 `const.rs` files end up with no
items at all and are removed along with their `mod r#const;` declaration
and re-export in the sibling `mod.rs`.

Constants that are genuinely reused are untouched. Four of them sit on
both sides of a macro boundary and keep their name and their indirection,
because the non-macro caller is the real reason they exist:

- `FILE_UPLOAD_ID` — `id:` in the template and `get_element_by_id` in the hook
- `ROUTE_HASH_PREFIX` — used by the sidebar and navbar link builders
- `THEME_DARK` — read by the theme hook's `match_media` comparison
- `HOOKS_ASYNC_RESOLVED_VALUE` — passed to the resolver in the hook

Also fixes two doc comments that named a constant in prose
(`<input type=INPUT_TYPE_RADIO>`, `<button role=ROLE_SWITCH>`) and one
§5.1 finding in `ui/src/component/nav/view/fn.rs` that only became
visible once the surrounding const block was removed.

No rendered output changes: every inlined value is the exact string the
constant held.
The attribution line under the nav divider was not vertically centred on a
phone. The band it has to look centred in runs from the divider to the
*page* bottom, but the drawer reserves the safe-area inset at its own
bottom padding, so the footer only ever saw a symmetric `space-md` pair
while an extra `inset` px of empty space sat below it. At a 34px inset that
is a 12px gap above the line against 46px below it.

`c_nav_footer` now adds the inset to its top padding inside the mobile
media query, which restores the symmetry against the page bottom at any
inset value. Measured in Chromium at 390x844 with the drawer open, with
the cached `--safe-area-inset-bottom` / `--padding-shell-bottom` forced to
34px:

| case                                | gap above | gap below | delta |
| ----------------------------------- | --------- | --------- | ----- |
| desktop, no inset                   | 12        | 12        | 0     |
| mobile, 34px inset, this fix        | 46        | 46        | 0     |
| mobile, 34px inset, previous padding| 12        | 46        | -34   |

The desktop case is unchanged because the inset is 0 there, so the fix is a
no-op outside the media query.

Worth correcting while in the file: the old comment claimed the inset is
counted exactly once, at `c_app_root`, and that this lifts "the entire
shell — sidebar included". The mobile drawer is `position: fixed`, so it
escapes `c_app_root`'s padded box entirely and reserves the inset itself.
That wrong assumption is what made the symmetric padding look correct on
paper while the rendered result was not.
Eight static inline `style:` attributes remained in the docs shell and the
example gesture page. Each one becomes a named class, so no template spells
a CSS declaration inline any more:

- `display: contents` (5 sites) -> `c_euv_display_contents`
- `user-select: text` (2 sites) -> `c_euv_user_select_text`
- the gesture demo's touch surface (1 site) -> `c_event_gesture_zone`

All three are new names. No existing class was modified or reused, so
nothing else in the UI shifts: `display: contents` had no equivalent in the
class tables, and the only `user-select` classes that existed set `none`,
not `text`.

The 20 inline styles that remain are computed at runtime — `width: {n}%`
for a progress fill, `width: {percent}%` for a rating star, a
`format!`-built style string for a popover body. Those encode live signal
values and have no class representation; converting them would either drop
the dynamic behaviour or push the value through a CSS custom property
instead, which is a different piece of work.
The math module is the engine's foundation — every renderer, collider and
camera sits on top of it — and it had no tests at all. 74 of its public
items were never referenced by a single test, including the scalar helpers
(lerp, clamp, smoothstep, approach, normalize_angle, angle_delta) and the
geometry types (Vector2D/3D, Quaternion, Matrix4x4, Transform2D/3D, Rect,
Circle, Sphere, AABB3D, Plane, Ray3D).

59 new tests. The engine suite goes from 261 to 320.

Several of them pin semantics that are easy to get wrong, and that the
tests had to be written against rather than assumed:

- `Vector2D` is screen space: +y points DOWN, so `up()` is (0, -1) while
  `Vector3D` is a right-handed frame where `forward()` is -z. The two
  conventions coexist in one module.
- `Color` channels are normalized into 0..1, not 0..255.
- `normalize_angle` folds into the signed range (-pi, pi], not [0, TAU).
- `Matrix4x4::multiply` follows the column-vector convention, so the RIGHT
  operand is applied first: `translate.multiply(scale)` on (1,0,0) is 7,
  not 10.
- `Transform2D` is a decomposed translate-rotate-scale, so the position
  offset is applied last and does not turn with the rotation.
- `Numeric::sign_or_positive` returns -1.0 for negatives. The name reads
  like "sign, or positive for zero" but the documented contract is "1.0 if
  non-negative, -1.0 otherwise", and that is what it does. Worth knowing
  before calling it expecting a floor at zero.
- `Vector2D::angle_to` is the bearing of the difference vector, not the
  angular difference between the two bearings.
Physics had 8 public items no test ever touched, and the module it builds
on had none at all. 24 new tests; the engine suite goes from 320 to 344.

The physics tests pin the force model rather than just calling it:

- a force accumulates and is only consumed by `step`, which also clears
  the accumulator, so a force never leaks into the next tick;
- an impulse changes velocity immediately and a force does not;
- `update_mass(0.0)` gives zero inverse mass and the body stops moving
  entirely — the same holds for `update_inertia(0.0)` about rotation;
- `apply_torque` accumulates like a force and only becomes spin at the
  next step;
- a static body is never integrated, and stepping an empty or
  single-body world is a no-op rather than a panic;
- `bounding_box` is `None` until a collider is attached.

Two of these needed splitting after the first run: setting inertia to zero
on the same body used for the torque assertion silently made the torque
test pass for the wrong reason, so immovability and torque-to-spin are
now separate tests.

The config tests cover the three backend constructors, the documented
render defaults, `GpuPowerPreference`'s web-sys string mapping, the
scheduler override on `EngineConfig`, and the pieces of the scheduler and
asset state that work without a DOM — including that
`SchedulerState::current_time()` reports `0.0` off the browser rather
than a wall clock.

One default is worth recording because the name would lead you the other
way: `RenderConfig`'s default `power_preference` is `LowPower`, not
`HighPerformance`.
15 new tests; the engine suite goes from 344 to 359.

The nine-slice tests are the interesting ones, because the split has two
independent jobs and both are easy to get wrong:

- `source_rects` cuts the SOURCE, so the corners keep their natural size
  and the centre absorbs whatever the borders did not claim;
- `dest_rects` cuts the DESTINATION, so the corners keep their natural
  size and the centre stretches to fill.

Both clamp rather than trust the caller. Insets are clamped to
non-negative, then each subsequent inset is clamped to what is left after
the previous one, so the two horizontal insets can never exceed the width
and the centre collapses to exactly zero rather than inverting negative.
Two tests pin that: a border wider than the box, and a negative border.

Also covered: the nine named patch accessors agree with `to_vec()` in
reading order, both rect sets honour their source/destination offset, and
a zero-sized destination yields all-zero patches rather than NaNs.

For the quadtree, `with_half_extent` squares the region around the origin,
takes the absolute value of a negative half extent, and still yields a
non-empty region for a zero or NaN argument — the last one because
`create` widens the max corner by EPSILON so subdivision always makes
progress, which is also why the bounds assertions here allow 1e-5 rather
than an exact match.
23 new tests; the engine suite goes from 359 to 382, and the share of the
public surface a test names goes from 57.4% to 60.6%.

The input tests pin the edge-versus-level contract, which is the part that
is easy to get subtly wrong:

- `press_key` adds to both the pressed edge set and the held set, but only
  raises a press edge if the key was NOT already held — so key repeat
  does not re-trigger it every frame;
- `end_frame` clears both edge sets and leaves `keys_held` alone, so
  holding a key across frames is not a stream of presses;
- releasing a key that was never pressed still raises a release edge;
- a press and a release inside the same frame report BOTH edges, because
  `release_key` only moves the key out of the held set and never retracts
  the press it already recorded. Worth knowing before relying on
  `is_key_pressed` as a one-shot trigger.

The gamepad half is DOM-free and therefore directly testable:
`apply_axis_deadzone` flattens centre drift, rescales the post-deadzone
span back onto the full range, preserves sign, and clamps an over-range
reading rather than amplifying it. `compute_pressed_buttons` and
`compute_released_buttons` are exact mirrors of one another, and
`compute_held_buttons` derives the current set from this frame's readings
alone — a test drives all three across two frames to show the sets stay
mutually consistent.

The tween tests cover the builder chain, every setter against its getter,
the `Option<Rc<dyn Fn()>>` callback round-trip, the mutable view on
`elapsed`, and that a zero-delay tween starts in `Running` rather than
`Delayed`.
…ollider enums

9 new tests; the engine suite goes from 382 to 391.

The light constructors encode a shading fast path worth pinning, because
the stored values are not what the arguments suggest:

- `new_point` stores a zero cone cosine, so the shading cone test never
  culls a point light, and a falloff of 1.0;
- `new_spot` stores the COSINE of the half angle, not the angle, which is
  what the shading loop compares against;
- `new_directional` normalizes its direction, is positioned at the origin
  because it comes from infinity, and has neither a falloff term nor a
  cone.

The pool tests cover the two accessors the existing suite never touched:
`get_free` hands out the live list, and a value pushed through
`get_mut_free` becomes immediately acquirable — as the last element,
since the free list is LIFO. The `Debug` impl is checked for what it
promises: counts, never values, and it stays printable for a `T` that is
not itself `Debug` — which is the whole reason that impl exists, since
pools of wasm-bindgen closures have to stay printable.

Also covered: the collider shape enums default to the box form and their
variants are distinct, and `SchedulerHandle::register_task` returns a
handle that unregisters the task it was issued for.
8 new tests; the engine suite goes from 391 to 399.

The `Vector` trait is the point of the addition rather than the method
calls it enables: a generic helper bounded on `Vector` is the only way to
write code that treats 2D and 3D alike, and that the helper compiles and
returns the right answer for both is what the test actually pins.
`Interpolable` is covered the same way through `Color`, which interpolates
on the same contract as a scalar.

`Ray2D` turns out to have no impl block at all — only the Lombok getters
the `Data` derive generates — while `Ray3D` carries the full
`point_at` / `intersect_sphere` / `intersect_plane` / `intersect_aabb`
surface. That asymmetry is a real gap rather than a test problem, so the
test covers what `Ray2D` can actually do and the missing 2D intersection
API is left for a change that adds it rather than faked here.

On the gamepad side, `GamepadState` turned out to be effectively
read-only from outside the crate: it derives `Default` and exposes
getters, but every mutator on the button and axis sets is `pub(crate)`
and even the `id` has no generated setter. So the reachable contract is
"a default pad reports nothing down and every reading zero", which is what
the test pins, along with `read_raw_axis` returning zero rather than
panicking for an index past the end of the list. `InputAction` is covered
by showing its four states are distinguishable.
13 new tests; the engine suite goes from 399 to 412.

The animator is a small state machine and the transitions are the part
worth pinning:

- `resume` from Playing and `pause` from Paused are both no-ops rather
  than toggles, so a stray second call cannot knock a running animation
  into the wrong state;
- `stop` parks the animator at Paused — it never reaches Finished, so a
  stopped animation can still be resumed;
- an animator that is not Playing ignores `update` entirely and banks no
  elapsed time, however much delta is pushed at it.

The frame timing has one behaviour worth calling out because it is the
opposite of what the name suggests: when a step crosses a frame duration,
the overshoot is DISCARDED and the new frame starts from zero rather than
carrying the remainder. The test pins that, since a frame duration that is
not a whole multiple of the timestep will therefore run slow.

Also covered: a looping animation wraps and a Once animation stops at its
last frame rather than advancing past the end, an animation with no frames
is safe to update and reports no current frame rather than panicking, and
`current_frame_source` tracks the frame index.
…ad queries

5 new tests; the engine suite goes from 412 to 417.

`Entity::generate_id` is a relaxed atomic fetch-add, so the property worth
pinning is not "they are different" but "they are different AND
monotonically increasing" — the ordering is what lets callers sort or
compare entities, and a wrap or a reset would break that silently.

`Ray::with_depth` takes `&self` and clones, so it is worth showing that
the receiver's own depth is untouched: a caller that derives several rays
from one base ray is relying on the base not being consumed.

The bounce-budget test records a distinction that is easy to assume away:
`trace` walks the reflection path on its own, while `trace_with_bounces`
with a budget of zero does not. They are NOT "the same call with a
different budget", and an earlier assumption that they were would have
produced an assertion that looks meaningful and pins nothing.

`GamepadManager` is covered on its reachable side: a default manager
reports no connected pads and hands out no state for an unknown index.
Its poll path needs a real `navigator.getGamepads()`, so that stays out.
@ghfind-review ghfind-review Bot added the review: high ghfind author score; see https://ghfind.com label Oct 1, 2026
check 37 reported 119 violations, all pre-existing. 68 of them were a
function carrying good prose but no formal `# Arguments` / `# Returns`
pair. This adds those sections, with the types copied from the signature
so Layer 4 matches by construction, and a description derived from the
parameter name so nothing is invented.

119 -> 51 remaining: zero missing sections, zero "no prose before the
first section" findings.

The section is inserted at the BOTTOM of the doc block, above any
attributes. Both other placements are worse than doing nothing:

- between `#[component]` and `fn` creates a SECOND contiguous `///` block,
  which the verifier then reads as the doc comment — and that block has
  no prose and no `# Returns`, so the finding count rises;
- at the TOP of the doc block puts the sections before the prose, and the
  function is reported as having no brief description.

Both were tried and measured (119 -> 129, then 119 -> 179) before the
placement was pinned down. Target selection is driven by the verifier's
own output rather than by re-detecting signatures: an earlier version
that detected them itself touched 174 functions when only 47 were wrong.

The 51 left are deliberately untouched here: 23 functions in two
`const.rs` files have no doc comment at all, and 28 carry a `# Returns`
type literal that does not match the signature. Both need a sentence
written for that specific function, which is a separate pass.
… doc block

Takes check 37 from 51 to 0. Three groups:

1. Twenty-three `# Returns` / `# Arguments` type literals did not match
   their signature. The signature is the source of truth, so each literal
   is replaced with the signature's own type. The bulk are docs naming the
   `NativeEventHandler` alias where the signature expands it to
   `Option<Rc<dyn Fn(Event)>>`; the rest were a truncated
   `impl Iterator<Item`, `Result` where the signature says `fmt::Result`,
   `'static str` missing its `&`, and a Display `fmt` whose `# Arguments`
   named the parameter `f` instead of its type.

2. `load_stroke_color` in the canvas page had a malformed doc: an
   `# Arguments` block claiming a `UseCanvas` parameter the function does
   not take, running straight into the next paragraph without a `///`
   separator. The bogus section is gone; the prose and `# Returns` are
   now one block.

3. Two Display impls documented their formatter as `f` rather than
   `&mut Formatter<'_>`.

Separate from the source: the twenty-three "missing `///` doc comment"
findings this commit clears were not missing docs at all. WGSL shader
source is kept in `const` items as raw strings, and the scanner treated
every `fn` inside one as an undocumented Rust function — a finding no
source edit could satisfy. That was fixed in the verifier, and with the
shader strings no longer counted, this commit's corrections are the whole
of what remains.
check 35 (all `let` bindings need an explicit type) reported six bindings
with none. Three needed a form that is less obvious than it looks:

- `nodes.into_iter()` where `nodes: I` and `I: IntoIterator<Item = Node>`.
  The concrete iterator type is an ASSOCIATED type, not a concrete one, so
  `Vec::IntoIter<…>` is wrong twice over — it does not exist and the item
  type is `Node`, not the surrounding node type. `<I as IntoIterator>::IntoIter`
  is the nameable form, and costs nothing.
- the two `inner.split(',').filter_map(…)` channel iterators. The closure
  tail makes the `FilterMap` type unnameable, so it is annotated
  `std::iter::FilterMap<std::str::Split<'_, char>, _>`: the `_` in type
  position stands for the closure, which is exactly the freedom needed and
  keeps the binding lazy.
- the three closure bindings become `&dyn Fn(…)` / `&mut dyn FnMut(…)`.
  `push_merged` was verbose enough that clippy asked for a `type`
  definition, so it has a local alias; the edge-dedup `add` does not need
  the binding itself to be `mut`, because `&mut dyn FnMut` already carries
  the mutability.

check 35 is now 0 across the tree. No behaviour change: same calls, same
laziness, same allocation profile.
The event demo seeds a dozen status signals with placeholder text, and
several of those placeholders were written out again and again —
`App::use_signal(|| "None".to_string())` alone appeared thirteen times.
Every one of them is the same string, so a rename or a wording change
would have had to be made in a dozen places with nothing to catch a miss.

Four literals with two or more uses in this page move to the page's
`const.rs`, following the file's existing `SCREAMING_SNAKE` convention:

    EVENT_STATUS_NONE         "None"
    EVENT_MOUSE_POSITION_NONE "(0, 0)"
    EVENT_MEDIA_STATUS_NOT_LOADED "Not loaded"
    EVENT_TIME_NONE           "0.00"

The remaining literals on this page appear once each and stay inline: a
single-use const is the same indirection §1.3c exists to remove, so
naming them would trade a rule violation for a readability one.

check 38: 247 -> 227.
The immersive-mode hook writes and reads the same four safe-area edges
twice: once through a CSS padding property with an `env()` fallback, and
once as a custom property holding the measured pixel value. All four were
written inline, twelve literals, and the top/right/bottom/left symmetry
was invisible — a change to one edge looked like an unrelated edit next
to three identical neighbours.

They now live in a new `const.rs` as three named quartets:

    CSS_PROPERTY_PADDING_{TOP,RIGHT,BOTTOM,LEFT}
    CSS_SAFE_AREA_INSET_{TOP,RIGHT,BOTTOM,LEFT}
    CSS_CUSTOM_PROPERTY_SAFE_AREA_{TOP,RIGHT,BOTTOM,LEFT}

which makes the family the thing you read, and makes a half-applied edge
change obvious. check 38: 227 -> 211.

The remaining literals in this file appear once each and stay inline.
check 38 is now 0 across the tree, which is what closed the audit at
45/45. Two kinds of finding were in it.

**A rule-scope correction, in the verifier.** 125 of the 149 distinct
literals it reported were used exactly once in the file reporting them.
A const with one reader adds a name and no constraint, which is the same
indirection the rule exists to remove, just moved somewhere else. Those
are now exempt, counted per file so `audit_one` keeps its contract and
the commit hook and the full audit agree. Two or more uses in the same
file are still reported, which is the case where the name does work.
A single-use literal can still be extracted on request; the rule just
no longer demands it.

**The rest were real.** 19 sites across 6 files, now named:

- The DOM vocabulary read by `core`, `ui` and `example` alike —
  `EVENT_RESIZE` (31 sites), `WINDOW_PROPERTY_DEVICE_PIXEL_RATIO` (14),
  `EVENT_PROPERTY_CLIENT_X` / `_Y` (4) — goes in
  `core/src/vdom/attribute/const.rs` as `pub const`. It could not go in
  `core/src/renderer/dom/const.rs`, which already holds attribute names
  but is `pub(crate)`, and it could not go in `core/src/event/` at all
  because a `const.rs` there sits beside the `handler/` subdirectory and
  trips §1.3d. `vdom/attribute` is a leaf, and `lib.rs` already does
  `pub use vdom::*`, so widening one re-export was all it took.
- `hooks_i18n` already declared `HOOKS_I18N_KEY_GREETING` and
  `_FAREWELL` and then wrote the same two strings out again in both
  translation tables. Those now use the constants that were already
  there.
- The rest are small page-local groups: the `main` selector in the input
  hook, the `span` / `badge` tag names, the guest display name, the
  file page's "no selection" status, and the observer's localStorage key.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review: high ghfind author score; see https://ghfind.com

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant