Skip to content

Releases: rboudrouss/reactant-analyzer

v0.6.0

Choose a tag to compare

@rboudrouss rboudrouss released this 08 Sep 11:07

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

  • engines moves 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 own require() 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 .js with module.exports in an ESM project must
    become .cjs, or use export 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

  • reactant with no command and no path analyses the current directory.
  • mutate, capture and init-once were 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 surfaces pack.d.ts.

Full changelog: v0.5.0...v0.6.0

v0.5.0

Choose a tag to compare

@rboudrouss rboudrouss released this 05 Sep 14:12

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

Choose a tag to compare

@rboudrouss rboudrouss released this 05 Sep 07:54

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 c...

Read more

reactant-analyzer v0.3.0

Choose a tag to compare

@rboudrouss rboudrouss released this 27 Aug 11:44

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 when src/app or src/pages is
    there), tsconfig paths, and TypeScript's baseUrl last resort for a
    bare specifier — the vercel/commerce shape, "baseUrl": "." with no
    paths at all. --project next forces 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
    ModuleTable carried from lowering to the analysis result. Value
    imports only: import type is 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
    (a page.tsx that 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/router and next/compat/router hooks are
    known to the analyzer instead of reported as unknown; usePathname is
    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

Choose a tag to compare

@rboudrouss rboudrouss released this 26 Aug 20:56

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
    the args edge and the returns guard.

  • New Tier-A origin guard 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

Choose a tag to compare

@rboudrouss rboudrouss released this 26 Aug 19:39
feat(npm): finish the publishable package — README, LICENSE, portable…