Skip to content

Commit e6c2397

Browse files
chore(hir): strip dead code, redundant comments, debug logging
Cleanup pass following round-5 review. No behavior changes — all 490 tests pass, lint/typecheck/format clean. Removed: - types.ts: unused predicates (isStateValueType, isUseStateHookType, isUseRefType, isRefValueType, isEffectEventType, isPropCallbackType), unused 'prop' Identifier.origin variant, unused EffectKind union members (Mutate / Capture / Store / Freeze). - print.ts: entire 80-line file (printHIR was only referenced by hir-unit.test.ts's debug logging, which is also removed). - validate-no-derived-computations-in-effects.ts: dead 'reason' field on findings — only one value was ever consumed by the runner; collapsed validator to return null on the other paths. - hir-unit.test.ts: console.log + JSON.stringify debug output that spammed CI on every run; replaced with proper expect()s. - 200+ lines of doc-style comments across types.ts / lower.ts / infer-types.ts / runner.ts / validators / hir-port.test.ts that re-explained what the code says or repeated information from the PR description. Kept (with concise // HACK: prefix): - StateTuple discriminator (real semantic distinction) - SpreadElement unwrap (real ESTree wart) - Catch-param binding (real correctness fix) - Multi-statement-with-locals defer (real overlap mitigation) - Inner-call-site Place tracking (real diagnostic-anchor concern) Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
1 parent f0d5241 commit e6c2397

11 files changed

Lines changed: 59 additions & 370 deletions

File tree

packages/react-doctor/src/oxlint-config.ts

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -248,16 +248,11 @@ export const GLOBAL_REACT_DOCTOR_RULES: Record<string, RuleSeverity> = {
248248
"react-doctor/rerender-defer-reads-hook": "warn",
249249
"react-doctor/advanced-event-handler-refs": "warn",
250250

251-
// HACK: HIR-backed rules — v1 of the IR-based analysis pass. Known
252-
// overlap with the AST-walker `no-derived-state-effect`: both fire
253-
// on the canonical "useEffect with single setter call deriving
254-
// from deps" shape, producing two diagnostics on the same line.
255-
// The HIR rule additionally catches multi-statement-with-locals
256-
// shapes the AST walker misses. Future work: scope the HIR rule
257-
// so it only reports on shapes the AST walker wouldn't, then
258-
// retire the walker. Until then, users who don't want the
259-
// duplicate can disable the AST walker rule (or this one) via
260-
// `react-doctor.config.json`.
251+
// HACK: HIR-backed rules. `hir-no-derived-computations-in-effects`
252+
// is scoped at the validator level to defer to the AST-walker
253+
// `no-derived-state-effect` on the simple shape, but
254+
// `hir-no-set-state-in-effect` still overlaps with the walker on
255+
// single-setter effects — disable either via config to dedupe.
261256
"react-doctor/hir-no-set-state-in-effect": "warn",
262257
"react-doctor/hir-no-derived-computations-in-effects": "warn",
263258

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
export * from "./types.js";
22
export { lowerFunction } from "./lower.js";
33
export { inferTypes } from "./infer-types.js";
4-
export { printHIR } from "./print.js";
54
export { hirNoSetStateInEffect, hirNoDerivedComputationsInEffects } from "./runner.js";

packages/react-doctor/src/plugin/hir/infer-types.ts

Lines changed: 4 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,9 @@
11
import type { HIRFunction, Identifier, ReactType } from "./types.js";
22

3-
// HACK: pared-down version of the compiler's `inferTypes()` pass. The
4-
// compiler runs full unification across hook return shapes; we need
5-
// just enough to power our validators — recognize the React hook
6-
// callees and tag the values they produce.
7-
//
8-
// Strategy: walk every instruction once, tagging identifiers by:
9-
// 1. LoadGlobal of a known React hook name → tag the binding's type
10-
// 2. CallExpression / MethodCall whose callee identifier is tagged
11-
// as a hook → propagate the return type to the lvalue (and to
12-
// array-destructure children for `useState` / `useReducer`)
13-
// 3. PropertyLoad of `.current` on a `RefValue` → tag the lvalue
14-
// as `RefCurrent`
15-
// 4. StoreLocal / LoadLocal → propagate the source identifier's type
16-
// so `const fn = setState; fn(x)` is still seen as a setState
17-
// call, the way the compiler tracks setState through `LoadLocal`
18-
// and `StoreLocal` in `validateNoSetStateInEffects`.
3+
// HACK: pared-down `inferTypes` pass — recognizes React hook callees
4+
// (LoadGlobal name match), propagates return types to call results,
5+
// and threads types through LoadLocal / StoreLocal so aliased setters
6+
// stay typed as `StateSetter`.
197

208
const REACT_HOOK_NAME_TO_TYPE: Record<string, ReactType> = {
219
useState: "UseStateHook",
@@ -84,22 +72,13 @@ export const inferTypes = (fn: HIRFunction): void => {
8472
}
8573
case "PropertyLoad": {
8674
const objectType = instr.value.object.identifier.type;
87-
// `const [value, setValue] = useState(...)` lowers to two
88-
// PropertyLoad instructions on the useState return. Index 0
89-
// is the state value, index 1 is the setter. Gated on
90-
// `StateTuple` (not generic `Object`) so a `useMemo`
91-
// returning an array doesn't have its destructure
92-
// misclassified as state — the lvalue of that PropertyLoad
93-
// would otherwise get StateSetter and trigger
94-
// `validateNoSetStateInEffects` on a non-setter call.
9575
if (objectType === "StateTuple" && instr.value.computed) {
9676
if (instr.value.property === "0" && lvalue) {
9777
setIdentifierType(lvalue.identifier, "StateValue");
9878
} else if (instr.value.property === "1" && lvalue) {
9979
setIdentifierType(lvalue.identifier, "StateSetter");
10080
}
10181
}
102-
// `<refIdent>.current` access
10382
if (
10483
objectType === "RefValue" &&
10584
!instr.value.computed &&
@@ -126,13 +105,4 @@ export const inferTypes = (fn: HIRFunction): void => {
126105
}
127106
}
128107
}
129-
130-
// HACK: useState/useReducer destructuring — heuristic above only
131-
// works when the lowering went useState → ArrayPattern → indexed
132-
// PropertyLoad. To make the StateValue/StateSetter tagging robust
133-
// against the call's lvalue staying `Object` (no real array shape),
134-
// we run a second sweep that finds `<x> = useState(...)` followed
135-
// by a `[a, b] = <x>`-style pattern. This is already covered by the
136-
// PropertyLoad branch above as long as `lower.ts` emits the indexed
137-
// loads, which it does.
138108
};

packages/react-doctor/src/plugin/hir/lower.ts

Lines changed: 15 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -16,30 +16,10 @@ import type {
1616
Terminal,
1717
} from "./types.js";
1818

19-
// HACK: lower ESTree → HIR. Mirrors the structure of React Compiler's
20-
// `BuildHIR.ts::lower` but with several deliberate simplifications:
21-
//
22-
// - single block per function (no control flow modeled in v1)
23-
// - no SSA: a mutable name→Place table tracks current bindings
24-
// - no aliasing / mutation effect inference (every Place gets
25-
// `effect: 'Read'` for v1; validators don't depend on this yet)
26-
// - JSX folded into a single `JSXExpression` placeholder per JSX
27-
// node — we don't need to model individual elements/attrs to
28-
// detect setState calls in a return position
29-
//
30-
// What carries over from the compiler:
31-
// - lvalue / value-place / operand-place shape so validators can
32-
// `switch (instr.value.kind)` exactly like the upstream code
33-
// - identifier IDs are stable per-binding, so propagation analyses
34-
// (e.g. setState flowing through a const) work the same way
35-
// - source locations threaded through every Place
36-
3719
interface LoweringEnvironment {
38-
// HACK: id allocators are SHARED across all nested envs of the same
39-
// lowering. A child env references the same allocator object so a
40-
// captured outer binding keeps its IdentifierId when seen by the
41-
// inner function — this is how the compiler's `loweredFunc.func.context`
42-
// ends up referencing identifiers shared with the outer function.
20+
// HACK: id allocators are shared across all nested envs of one
21+
// lowering so a captured outer binding keeps its IdentifierId when
22+
// seen by an inner function.
4323
ids: { nextIdentifierId: number; nextInstructionId: number; nextSyntheticName: number };
4424
bindings: Map<string, Place>;
4525
parent: LoweringEnvironment | null;
@@ -143,11 +123,6 @@ const setBinding = (env: LoweringEnvironment, name: string, place: Place): void
143123
env.bindings.set(name, place);
144124
};
145125

146-
// HACK: mirrors the destructured-prop scaffolding in noPropCallbackInEffect.
147-
// For `function Foo({ value, onChange }) {}`, we walk the ObjectPattern
148-
// and create one Identifier per shorthand entry, tagging callbacks
149-
// (`/^on[A-Z]/`) so prop-callback rules don't have to re-derive that
150-
// every time.
151126
const PROP_CALLBACK_NAME_PATTERN = /^on[A-Z]/;
152127

153128
const isPropCallbackName = (name: string): boolean => PROP_CALLBACK_NAME_PATTERN.test(name);
@@ -194,13 +169,8 @@ const collectFunctionParams = (
194169
return places;
195170
};
196171

197-
// HACK: `f(...args)` in ESTree wraps the spread in `SpreadElement`,
198-
// which `lowerExpression` doesn't recognize and would otherwise drop
199-
// silently. Lower the spread's `argument` directly so the spread
200-
// source still appears as a Place in the call's args list — the
201-
// "spread"-ness is lost (we have no SpreadPlace shape in v1) but
202-
// the operand identity is preserved, which is what validators that
203-
// trace setState propagation through arguments care about.
172+
// HACK: SpreadElement (`f(...args)`) isn't a real expression node in
173+
// ESTree, so unwrap to its `argument` to keep operand identity.
204174
const lowerCallArguments = (
205175
env: LoweringEnvironment,
206176
argumentNodes: Array<EsTreeNode> | undefined,
@@ -333,10 +303,8 @@ const lowerExpression = (env: LoweringEnvironment, node: EsTreeNode | null | und
333303
loc,
334304
node,
335305
);
336-
// HACK: a nested FunctionDeclaration introduces a binding in its
337-
// enclosing scope (`function helper() {}` ≈ `var helper = ...`).
338-
// Tie the name to the FunctionExpression's lvalue so subsequent
339-
// `helper()` calls resolve to it, not LoadGlobal.
306+
// HACK: nested `function helper() {}` declares `helper` in the
307+
// enclosing scope; bind the name so call sites resolve to it.
340308
if (node.type === "FunctionDeclaration" && node.id?.type === "Identifier") {
341309
setBinding(env, node.id.name, place);
342310
}
@@ -435,14 +403,10 @@ const lowerExpression = (env: LoweringEnvironment, node: EsTreeNode | null | und
435403
return emitTemporary(env, { kind: "Unsupported", reason: node.type }, loc, node);
436404
};
437405

438-
// HACK: collects which Places the lowered inner function reads from
439-
// any enclosing scope. Because env IDs are shared (root env's
440-
// allocator is reused by every child), we just look at every
441-
// LoadLocal whose source identifier was bound OUTSIDE the inner
442-
// function's own params / destructured props / lvalues — that's a
443-
// capture. The outer env isn't needed here because the captured
444-
// Place is the SAME Place object (identifier id and originNode)
445-
// that exists in the outer scope.
406+
// HACK: a "capture" is any LoadLocal whose source Identifier wasn't
407+
// declared inside the inner function (params, destructured props,
408+
// instruction lvalues). Shared id allocator means the captured Place
409+
// is already === the outer Place.
446410
const collectCapturedPlaces = (innerFn: HIRFunction): Array<Place> => {
447411
const captured: Array<Place> = [];
448412
const seenIds = new Set<IdentifierId>();
@@ -546,10 +510,8 @@ const lowerStatement = (env: LoweringEnvironment, node: EsTreeNode | null | unde
546510
}
547511

548512
// HACK: control-flow statements collapse into the surrounding block
549-
// for v1 (no real CFG terminals yet). We still recurse into their
550-
// bodies so a `useEffect` inside `try { ... }` or `for (...) { ... }`
551-
// gets lowered — silently dropping these statements would have
552-
// missed real validator findings.
513+
// for v1 (no CFG terminals modeled). We recurse into their bodies
514+
// so hooks/effects inside them still get lowered.
553515
if (node.type === "ForStatement" || node.type === "WhileStatement") {
554516
if (node.test) lowerExpression(env, node.test);
555517
if (node.update) lowerExpression(env, node.update);
@@ -593,13 +555,8 @@ const lowerStatement = (env: LoweringEnvironment, node: EsTreeNode | null | unde
593555
if (node.type === "TryStatement") {
594556
lowerStatement(env, node.block);
595557
if (node.handler) {
596-
// HACK: bind the catch param (`} catch (error) {`) so references
597-
// to it inside the handler body resolve as a LoadLocal instead
598-
// of falling through to LoadGlobal "error". The param's scope
599-
// technically ends with the handler block; we keep it visible
600-
// in the surrounding env (no real block scoping in v1) — that's
601-
// a minor over-scope which doesn't affect the validators we
602-
// currently run.
558+
// HACK: bind catch param so references inside the handler body
559+
// resolve as LoadLocal, not LoadGlobal.
603560
if (node.handler.param?.type === "Identifier") {
604561
const paramName = node.handler.param.name;
605562
const paramIdentifier = createIdentifier(env, paramName, "local");
@@ -632,7 +589,6 @@ const lowerStatement = (env: LoweringEnvironment, node: EsTreeNode | null | unde
632589
node.type === "ArrowFunctionExpression" ||
633590
node.type === "FunctionExpression"
634591
) {
635-
// Treat as an expression so it gets a FunctionExpression instruction.
636592
lowerExpression(env, node);
637593
return;
638594
}

packages/react-doctor/src/plugin/hir/print.ts

Lines changed: 0 additions & 80 deletions
This file was deleted.

packages/react-doctor/src/plugin/hir/runner.ts

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,8 @@ import { validateNoSetStateInEffects } from "./validators/validate-no-set-state-
66
import { validateNoDerivedComputationsInEffects } from "./validators/validate-no-derived-computations-in-effects.js";
77
import type { HIRFunction, Place } from "./types.js";
88

9-
// HACK: bridges HIR validators to the existing oxlint Rule contract.
10-
//
11-
// Each rule's `create()` returns visitors that detect a component-shaped
12-
// function and forward to a shared `getOrLowerHir(node)`. The lowered
13-
// HIR is cached in a WeakMap keyed by the original component AST node
14-
// so multiple HIR rules running on the same source file lower it once.
15-
//
16-
// Diagnostics carry a `Place` — its `originNode` points back at the
17-
// ESTree node the place was lowered from. We use that as the report
18-
// node, falling back to the component declaration when the place was
19-
// synthetic (no underlying source node).
20-
9+
// HACK: per-component HIR cache so multiple HIR rules visiting the
10+
// same file lower it once.
2111
const lowerCache = new WeakMap<EsTreeNode, HIRFunction>();
2212

2313
const getOrLowerHir = (componentNode: EsTreeNode): HIRFunction => {
@@ -67,7 +57,6 @@ export const hirNoDerivedComputationsInEffects: Rule = {
6757
const fn = getOrLowerHir(functionNode);
6858
const findings = validateNoDerivedComputationsInEffects(fn);
6959
for (const finding of findings) {
70-
if (finding.reason !== "all-deps-captured") continue;
7160
const reportNode = resolveReportNode(finding.effectCallPlace, functionNode);
7261
context.report({
7362
node: reportNode,

0 commit comments

Comments
 (0)