Skip to content

v0.4.0

Choose a tag to compare

@rboudrouss rboudrouss released this 05 Sep 07:54
· 20 commits to main since this release

reactant-analyzer v0.4.0

Seventy issues closed, nine more closed as wontfix and written down as
limitations. Two of them name the release: custom rules stopped being a
demonstration vocabulary, and the corpus measure stopped being a manual
procedure.

Custom rules are a language now (ADR-027 … ADR-039)

A pack could name two things about a component and ask nine questions about
them. It can now name ten and ask twenty-seven.

v0.3.0 v0.4.0
anchors 2 10
edges (forEach) 3 8
filtering guards 9 27
certifying guards (must_*) 4 5

New anchors: hook_origins (the hooks inlining dissolved, which
kind: "custom" could never see — #6), context_providers, jsx_props,
elements, render_calls, churn_cycles, registrations,
context_consumers. New edges: writers, reads, seeds, props,
calls. New quantifiers: every (∀ over deps) and none (the negated
existential — acquires a resource and releases none, has a value prop
and no onChange
, subscribes and never reads the value).

Everything the vocabulary gained is a fact the engine already computed and
did not expose: where a slot is written and read and in which phase, whether
an updater is a proven function literal, whether an effect's registration is
paired with a teardown, whether a consumer's context has a provider on any
analyzed path, which prop of which rendered element is a fresh reference.
Nothing syntactic was added — a rule that cannot be stated semantically is
still refused rather than emulated.

Tier-A expressibility: 5/22 → 21/22. The catalogue is 22 rules that
teams asked for, and tests/catalogue.rs runs every expressible one on its
own firing fixture and its own near-miss: an entry counts only if the rule
demonstrably fires on the bug and stays silent on the conformant shape. The
one that remains is excluded by design (#101).

Severity discipline held throughout. Every new relation says which side it
over-approximates, and a may-typed verdict has no negated form — a rule
cannot suppress a finding on a row. Only must_direct_write was added
to the certifying set; every other new anchor and guard is capped at
Warning by construction.

The blind wish-list campaign

Four agents were briefed as React staff engineers, forbidden from reading
any file in this repository, and asked for fifteen scenarios each — with a
firing fixture, a deliberately hard near-miss, and the program facts a
checker would need. Sixty scenarios, triaged against the shipped vocabulary
by four more agents who had to run each rule they claimed.

16 already native, 1 expressible, 16 partial, 27 inexpressible — and four
engine defects found on the way (#122, #123, #124, #125). The 23 rules that
survived ship as five packs in packs/community/. docs/campaign/ holds
the scenarios verbatim and the per-scenario verdicts.

The other half, docs/campaign/AUDIT.md, is what the nineteen native rules
actually do on real code: 34 730 files, 14 249 components, zero parse
errors, the severity split, and the finding that 81% of the output was one
source line repeated once per consuming component (#129, fixed below).

A clean bill is only for code that was read (#9, #47)

The green tick is a claim, and a run that skipped something has not earned
it. Every run now carries a blind-spot list: imports that resolved
outside the analyzed set, aliases that never loaded, files the walk did not
reach. With a blind spot on record the summary says so instead of printing
, and the finding counts are stated as a lower bound.

--info gained the other side of the same channel: per component, the
applicable checks that ran and found nothing (verified: …), and where the
analysis was truncated, the count of assurances withheld (suspended: …).
That suspension line is deliberately exempt from the rule filters — it
reports the state of the assurance channel, not a diagnostic.

Discovery

  • A directory is build output because the repository says so (#137). A
    source tree named build/ or dist/ was silently dropped. Discovery now
    reads the tree's own .gitignore — layered, deepest file wins, seeded
    from the ancestors up to the project root — and falls back to the built-in
    names only when there is none. --exclude-dir / excludeDirs overrides
    both; node_modules/ and .git/ are skipped regardless. A glob the
    reader cannot parse matches nothing, because over-matching is the
    false-negative direction.

  • The marker is where the tsconfig search starts, not where it stops
    (#139). A vite.config.ts in a package of a monorepo stopped the lookup
    there, so the aliases one level up never loaded and every @/… import was
    unresolvable. Both the marker walk and the tsconfig walk now share one
    upward-walk helper.

  • A subdirectory is still inside its project (#9 §aggravating).
    Narrowing a run to src/components still finds the marker and the
    tsconfig in the ancestors.

  • --follow-imports (#138, opt-in). Analyzes the files the named paths
    import, transitively, so their hooks are read instead of treated as
    opaque. The report still covers only the paths you named, and says how
    many findings in the followed files it is withholding. Not a speed
    optimization
    : on a real app the closure was 402 extra files for 38
    named ones, which is most of the project. It is there so a narrowed run
    is not also a blind one.

Precision and soundness

The corpus number is 1317 distinct (file, line, col, message)
locations
over 14 pinned repositories, 35 541 files analyzed. Every entry
in docs/precision-log.md is a claim, the shape that motivated it, and the
measured delta.

Missed findings that are no longer missed (soundness-bug): a switch that
dropped every case after the first break (#1); a try body returning
unconditionally, which made the whole catch/finally vanish (#2); ten
binary operators lowering to Add (#3); a hook called in return or in a
branch condition (#4); concise-arrow bodies losing their return value
(#5); a component returning null on every path, which was not detected at
all (#122); a setter call outside statement position (#130); per-function
ExprId keys colliding across bodies in one heap (#134); always-unstable-deps
evaluating deps against an empty heap (#135); truncated deps arrays read as
declared (#104); an effect region row treated as a proven write (#121);
JSX in .js dropping the whole file (#87); class bodies dropped by
lower_stmt (#77).

False positives with a root cause, not a filter: a member of a fresh
hook-returned object losing its own stability (#88, ~2 900 findings);
identity-based dep coverage — aliases, field projections, renames and value
surrogates (#89); field-insensitive object churn (#90); a write that settles
its own guard, for the half the engine can settle (#91, still open for the
rest); the writer scan missing handler, async-continuation and
escaped writers (#92); a listener registered inside an effect treated as
executed by it (#93); ecosystem hook contracts for next/navigation,
react-hook-form, use-debounce and SWR (#94); a member read blamed on its
container (#133); an inlined callee's imported binding captured by a
same-named binding in the caller (#141); the owner of a setter read away
from the call site (#119).

frozen-initial-state reasons about mount lifetime (#95). Where the shape
is provably mount-scoped the finding is downgraded to Info rather than
dropped: soundness outranks the false-positive count.

Performance (#86): infinite-loop rebuilt the whole-program churn graph
once per component. dub and twenty never finished. The graph is built
once per program now, and block order is pinned.

Output

  • A finding's identity is its source location, not its consumer (#129).
    A bug in a shared hook was printed once per component that inlined it,
    which was 81% of the corpus output. Each distinct location is printed
    once, under the first component that reaches it, with an honest
    [in N components] count.
  • Positions in other files are printed as path:line:col instead of a bare
    line number under the wrong file (44% of custom-rule findings).
  • Deps are named, not numbered by their elems index (#118).
  • A row whose statement has no span produces no positionless finding (#131).
  • --entry rejects a name that matches nothing instead of silently
    analyzing everything (#8).

Infrastructure

  • CI (#15): rustfmt, clippy, tests, the library without default
    features, rustdoc with warnings denied, action.yml linting, and a
    wasm ↔ native parity job that builds the npm package and compares the two
    outputs byte for byte.
  • The corpus is a gate, not a habit. Fourteen repositories pinned commit
    by commit; docs/corpus-baseline.json holds the total, a digest and the
    per-rule and per-repo split; a push to main that touches the analyzer
    re-measures and fails on any drift. scripts/corpus-diff.py prints
    before / after / removed / added and errors out when the three do not
    reconcile — an endpoint is counted, never deduced.
  • The analysis is deterministic (#120, #16). Four runs of a frozen
    binary on one repository, and two on the whole corpus, produce
    bit-identical JSON. A difference between two measurements is therefore
    always a real behavior change.
  • docs/limitations.md is the user-facing page for what the analyzer misses
    and why; docs/precision-log.md is the measured history.

Claude Code plugin

The repository is a Claude Code plugin (.claude-plugin/) with two skills:
reactant-triage runs the analyzer on a React or Next.js codebase and sorts
each finding into true positive, false positive or not worth fixing, and
reactant-rules writes, builds and proves a custom pack against the
project's own invariants.


1418 tests. The wasm build stays byte-identical to the native CLI.