v0.4.0
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 namedbuild/ordist/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/excludeDirsoverrides
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). Avite.config.tsin 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 tosrc/componentsstill 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:colinstead of a bare
line number under the wrong file (44% of custom-rule findings). - Deps are named, not numbered by their
elemsindex (#118). - A row whose statement has no span produces no positionless finding (#131).
--entryrejects 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.ymllinting, 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.jsonholds the total, a digest and the
per-rule and per-repo split; a push tomainthat touches the analyzer
re-measures and fails on any drift.scripts/corpus-diff.pyprints
before / after / removed / addedand 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.mdis the user-facing page for what the analyzer misses
and why;docs/precision-log.mdis 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.