Releases: rboudrouss/reactant-analyzer
Release list
v0.6.0
The analyzer is no longer only a command. reactant-analyzer now exports a
JavaScript API that runs in Node and in the browser, from the same single
.wasm, so it fits in a script, a CI step, an editor, or a web page.
import { analyzeProject } from "reactant-analyzer";
const { report, exitCode } = await analyzeProject(["src"]);
for (const d of report.diagnostics) {
console.log(`${d.file}:${d.line} [${d.severity}] ${d.rule}: ${d.message}`);
}
process.exitCode = exitCode;report is the exact document --format json prints, witness chains and all,
so anything already reading that output keeps working and the wire schema
stays the single stability contract. Findings are a result, never an
exception. Only a usage error throws, as UsageError with exitCode: 2.
It runs in the browser
There is no filesystem inside the analyzer, so a tree handed in as a map was
always a first-class input rather than a test-only mode. That is what makes a
browser work with no engine change at all: discovery, project detection,
tsconfig chains and alias resolution all run inside the engine over the map
you give it.
import { analyze } from "reactant-analyzer/browser";
const { report } = await analyze({
files: {
"src/Dashboard.tsx": dashboardBuffer,
"src/Filters.tsx": filtersBuffer,
},
});Cross-file analysis included: a setter handed down as a prop is still followed
into the child, in the browser, with no server round-trip. No bundler config
and no fs shim, because the .wasm is fetched next to the glue, or from
wherever you point initWasm(url). Verified in Chromium, both served
directly and through a Vite production build, which emits the wasm as a
hashed asset.
Analysis holds the thread while it runs, so an interactive page should call it
from a Worker.
One artifact, not two
Shipping a second wasm for the browser would have doubled a 3.5 MB module. It
turned out to be unnecessary: wasm-bindgen emits a byte-identical .wasm
for its nodejs and web targets. Only the JS glue differs, and the web glue
runs under Node too, taking its bytes from readFileSync instead of fetch.
The package therefore ships the web glue alone. The tarball is 1.1 MB with
exactly one .wasm: browser support and the whole API cost about 27 KB of
JavaScript and type declarations, not a second copy of the module.
The CLI is now a consumer of the API
npx reactant and a script cannot disagree about what an option means,
because both build the same envelope through the same validator, and the
disk-reading half is shared between the CLI and analyzeProject. An option
cannot exist for one host and not the other.
| call | what it does |
|---|---|
analyze(input) |
check a tree, returning { report, exitCode, stderr } |
analyzeProject(paths, input) |
walk disk, discover the config and its packs, then analyze (Node) |
run(input) |
one core invocation, any format, returning { exitCode, stdout, stderr } |
rules() / explain(rule) |
the rendered rules table, or one rule's docs |
validatePack(pack) |
the same load_pack a check run uses |
initWasm(source) |
browser: instantiate from an explicit URL, Response, bytes or module |
TypeScript types ship with the package, and type-check under both nodenext
and bundler resolution.
Breaking
enginesmoves to Node >= 20.19. The glue is ESM, so a browser-native
entry cannot be CommonJS and neither can the layer both entries share.
20.19 is where Node's ownrequire()of an ESM package works, and
require()from CommonJS is still supported there and above.- The API is async-only. The core instantiates on first use, and a browser
could not be synchronous regardless. - Packs authored as
.jswithmodule.exportsin an ESM project must
become.cjs, or useexport default. Both shapes are supported, and both
are now tested to compile to the same JSON. Nothing changes for packs
already committed as JSON, or for CommonJS projects.
The CLI's output is unchanged: the 13 wasm-versus-native byte-parity cases
still pass byte for byte.
Also in this release
reactantwith no command and no path analyses the current directory.mutate,captureandinit-oncewere missing from the documented list of
witness-note kinds in the JSON schema, the same list the shipped types are
written against.- The eight em dashes this release's own new pages introduced are gone, along
with ten in the shipped declarations, where an editor surfaces them the way
it surfacespack.d.ts.
Full changelog: v0.5.0...v0.6.0
v0.5.0
reactant-analyzer v0.5.0
One soundness fix, one output fix, and the documentation rewritten to be read
by someone who has not already read it.
A JSX callee is resolved by the file that writes it (#7)
Two defects, and both of them ended in ✓ ... no issues found, the one line
limitations.md promises is printed only when the run read everything it was
pointed at.
eval_comp_app resolved a JSX callee through get_by_name, which answers with
the first (file, name) key in sort order. App.tsx importing ./b/Widget
with an unrelated a/Widget.tsx elsewhere in the tree got a's body inlined,
and the cross-setter-in-render Error that depended on the real body vanished.
Underneath it, undiscovered: discovery and ImportResolver spelled paths
differently, ./b/W.tsx against b/W.tsx, so every (file, name) lookup built
from a resolved import missed on any .-rooted run, for hooks and contexts as
much as components. reactant . lost a finding that reactant <abs> reported.
Expr::CompApp now carries the component its own file proved it names, the same
fact HookEntry::Custom::resolved_file already carried for a hook call.
ComponentRegistry::resolve_child is the one resolution: the proven origin,
else the name when only one file defines it, else Ambiguous, which makes the
child unanalysable and says so rather than inlining a body the program never
renders there. Root detection and SymbolGraph read the same fact, so the three
consumers can no longer disagree. Aliased imports (import { Widget as Panel })
resolve, which they never did.
Refusing the guess was measured before it was chosen: 1,347 ambiguous references
across the fourteen corpus repositories run separately, eight of which have
none, against 24,500 unknown-component references already reported. Analysing
every candidate instead is sound and strictly more precise, and rejected on that
measurement: <Button/> has 1,453 ambiguous sites when the corpus is one tree,
and a child analysis per candidate is the shape of #86's O(C²) hang.
Component identity is now an interned ComponentId, with the display name
minted only at render (ADR-040, superseding ADR-038 §5, which had unified on the
display name). The display name is content-dependent by construction, so keying
the results map, the shared-state store, every Versioned label and every
setter owner by it meant an unrelated file re-keyed all of them at once. That
half is behaviour-preserving and was measured as such: identical digest, bit for
bit, over 35,541 files.
Corpus: 1317 → 1348 distinct (file, line, col, message) locations, 29
removed and 60 added. The additions are the soundness half: root detection
marked a component referenced by name, so one <Demo/> anywhere demoted every
Demo out of the root set and into phase 2, where custom hooks stay opaque and
findings inside them are lost. The removals are dominated by sites analysed
against a body the program does not render there. dub's Badge, Logo and
QRCode come from an unresolvable @dub/ui, and the old code answered each by
inlining whichever same-named file sorted first.
873s against 858s over the corpus: one clean measurement each side, so no
regression worth reporting and no claim of a gain.
The CLI output says whole sentences again
Every em dash is gone from the diagnostic messages, the explain and rules
text, the clap help, the driver summary and the npm CLI. Each one was rewritten
as a full stop, a comma with a conjunction, or a colon before a list, whichever
the sentence asked for. A mechanical substitution would have produced comma
splices in half of them.
Doing it surfaced twelve messages that a previous strip had already broken by
removing a dash without replacing it, so they had been shipping as run-ons:
... to new values on every run potential infinite render loop
hook `X` not found in registry pass its source file or add a HookSummary
mount-only effect flips state `X` from `false` to `true` the SSR ...
All twenty-nine distinct message shapes the corpus produces were read back after
the change. Counts are untouched. Only the prose moved.
Documentation
The README leads with the bug instead of the method, and shows the run instead
of describing it. The capture is reactant check src/ --trace on a render loop
between a parent and a child: Dashboard owns the state, Filters normalizes
it in an effect and hands it back through a prop, and the spread allocates a
fresh object every run. Both files are individually correct, every deps array
lists what its effect reads, and ESLint has nothing to say. The rules table is
split in two, which is the actual pitch: the rules ESLint has no counterpart
for, then the three that overlap but carry through indirection.
limitations.md, usage.md, plugins.md, custom-rules.md and
precision-log.md are in English, and they read forward: what the analyzer does
and does not do now, rather than the order in which it got there.
limitations.md was re-checked issue by issue against the tracker, closing
comments included, because a wontfix is still a limitation and a completed one
is not. Nine closed issues were being presented as live limitations. The
"Recently fixed" table is gone with them: a changelog of what used to be broken
is the tracker's job. The 45 descriptions in pack.schema.json come from doc
comments and moved with them.
1436 tests. Corpus baseline 1348 findings over 14 pinned repositories and 35,541
files. The wasm build stays byte-identical to the native CLI.
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 c...
reactant-analyzer v0.3.0
reactant-analyzer v0.3.0
The Next.js release (ADR-026):
-
Next.js projects are detected and analyzed with their own conventions:
router-aware discovery (src/only whensrc/apporsrc/pagesis
there), tsconfigpaths, and TypeScript'sbaseUrllast resort for a
bare specifier — the vercel/commerce shape,"baseUrl": "."with no
pathsat all.--project nextforces it; Next is detected before
Vite, since a Next app may keep a vite.config for its test runner. -
A module's directives and import edges are now IR facts.
ModuleFacts
per file (the"use client"/"use server"prologue oxc keeps apart
from the body, plus every edge the resolver mapped to a real file) in a
ModuleTablecarried from lowering to the analysis result. Value
imports only:import typeis erased before anything runs, so counting
it would drag a server module across a client boundary. -
New rule
server-component-hook: a hook called where React renders on
the server. A module is server-compiled iff it is reachable from an App
Router entry without crossing"use client"— so both the direct case
(apage.tsxthat forgot the directive) and the transitive one (a
shared component pulled into the server graph) are caught. Silent in
projects that never use the directive; one finding per component, not
per hook; other rules' findings in the same module are never
suppressed.Server Components are analyzed, not skipped. Skipping them would rest
on an import graph whose edges the resolver may have missed, turning
every misclassification into a false negative — and a Server Component
has no state, so the interpretation over-approximates it the way it
does any component whose props are unknown. -
next/navigation,next/routerandnext/compat/routerhooks are
known to the analyzer instead of reported as unknown;usePathnameis
modelled as a string, so a pathname dep never reads as unstable. -
Aliased imports now feed the cross-file relations: an aliased
import { Ctx } from "@/lib/ctx"is proven a context, which it was not
before.
Measured on eleven corpora, four of them new Next.js repositories chosen
for their resolution layouts: zero false positives from the new rule, and
every diff against v0.2.0 is either a removed unknown-hook Info (32) or a
revealed true positive (4). 1000 tests; the wasm wrapper stays
byte-identical to the native CLI.
reactant-analyzer v0.2.0
reactant-analyzer v0.2.0
The vocabulary release (NEXTSTEPS phase 2 / ADR-023):
-
Hook identity by provenance: fail-closed HookOrigin map — the literal
"react" specifier is decided before the resolver, the raw specifier
survives self-aliasing tsconfig paths, aliased React imports classify
correctly, and every hook call carries a provenance row
(label → origin hook, source, direct|inlined) that survives custom-hook
inlining. +4 true positives on the corpora (barrel re-exports now
resolve), 0 lost. -
Expression-position entities: the returns-verdict of an inline selector
argument is computed during the fixpoint (identity question — a fresh
reference per call, the zustand v5 crash — not stability). Tier A gains
theargsedge and thereturnsguard. -
New Tier-A
originguard over provenance rows: wrapper-aware banned-hook
rules ("never call useLayoutEffect directly") stay silent on conformant
consumers of the SSR-safe wrapper. -
Tier-A expressibility measured and automated: 3/21 → 5/21 rule classes
from the corpus catalogue (tests/catalogue.rs proves each one). -
JS/TS pack authoring:
reactant packs build <pack.js>compiles an
authored module to the committed pack.json, validated by the core's own
loader (new validatePack wasm export); lib/pack.d.ts is generated from
the same schemars types as the validator.
956 tests; the wasm wrapper stays byte-identical to the native CLI.
v0.1.0
feat(npm): finish the publishable package — README, LICENSE, portable…