From b4af881d7340fd1265c3777787a4e5e1463b03cb Mon Sep 17 00:00:00 2001 From: Cheng Liu Date: Fri, 29 Aug 2025 01:58:21 -0700 Subject: [PATCH 1/5] feat: pass first invalid test --- internal/linter/linter.go | 6 +- .../code_path_analysis/break_context.go | 139 ++ .../code_path_analysis/chain_context.go | 51 + .../code_path_analysis/choice_context.go | 198 ++ .../code_path_analysis/code_path.go | 84 + .../code_path_analysis/code_path_analyzer.go | 689 +++++++ .../code_path_analysis/code_path_segment.go | 162 ++ .../code_path_analysis/code_path_state.go | 147 ++ .../code_path_analysis/fork_context.go | 197 ++ .../code_path_analysis/id_generator.go | 28 + .../code_path_analysis/loop_context.go | 358 ++++ .../code_path_analysis/switch_context.go | 127 ++ .../code_path_analysis/try_context.go | 229 +++ internal/plugins/react_hooks/plugin.go | 3 + .../rules/rules_of_hooks/rules_of_hooks.go | 930 +++++++++ .../rules_of_hooks/rules_of_hooks_test.go | 1700 +++++++++++++++++ internal/rule/rule.go | 5 + 17 files changed, 5052 insertions(+), 1 deletion(-) create mode 100644 internal/plugins/react_hooks/code_path_analysis/break_context.go create mode 100644 internal/plugins/react_hooks/code_path_analysis/chain_context.go create mode 100644 internal/plugins/react_hooks/code_path_analysis/choice_context.go create mode 100644 internal/plugins/react_hooks/code_path_analysis/code_path.go create mode 100644 internal/plugins/react_hooks/code_path_analysis/code_path_analyzer.go create mode 100644 internal/plugins/react_hooks/code_path_analysis/code_path_segment.go create mode 100644 internal/plugins/react_hooks/code_path_analysis/code_path_state.go create mode 100644 internal/plugins/react_hooks/code_path_analysis/fork_context.go create mode 100644 internal/plugins/react_hooks/code_path_analysis/id_generator.go create mode 100644 internal/plugins/react_hooks/code_path_analysis/loop_context.go create mode 100644 internal/plugins/react_hooks/code_path_analysis/switch_context.go create mode 100644 internal/plugins/react_hooks/code_path_analysis/try_context.go create mode 100644 internal/plugins/react_hooks/plugin.go create mode 100644 internal/plugins/react_hooks/rules/rules_of_hooks/rules_of_hooks.go create mode 100644 internal/plugins/react_hooks/rules/rules_of_hooks/rules_of_hooks_test.go diff --git a/internal/linter/linter.go b/internal/linter/linter.go index f5e50722..c8b5a5c1 100644 --- a/internal/linter/linter.go +++ b/internal/linter/linter.go @@ -177,6 +177,7 @@ func RunLinterInProgram(program *compiler.Program, allowFiles []string, skipFile var childVisitor ast.Visitor var patternVisitor func(node *ast.Node) patternVisitor = func(node *ast.Node) { + runListeners(rule.WildcardTokenKind, node) runListeners(node.Kind, node) kind := rule.ListenerOnAllowPattern(node.Kind) runListeners(kind, node) @@ -200,8 +201,10 @@ func RunLinterInProgram(program *compiler.Program, allowFiles []string, skipFile runListeners(rule.ListenerOnExit(kind), node) runListeners(rule.ListenerOnExit(node.Kind), node) + runListeners(rule.WildcardExitTokenKind, node) } childVisitor = func(node *ast.Node) bool { + runListeners(rule.WildcardTokenKind, node) runListeners(node.Kind, node) switch node.Kind { @@ -222,10 +225,11 @@ func RunLinterInProgram(program *compiler.Program, allowFiles []string, skipFile } runListeners(rule.ListenerOnExit(node.Kind), node) + runListeners(rule.WildcardExitTokenKind, node) return false } - file.Node.ForEachChild(childVisitor) + patternVisitor(&file.Node) clear(registeredListeners) } diff --git a/internal/plugins/react_hooks/code_path_analysis/break_context.go b/internal/plugins/react_hooks/code_path_analysis/break_context.go new file mode 100644 index 00000000..bb7b4e1b --- /dev/null +++ b/internal/plugins/react_hooks/code_path_analysis/break_context.go @@ -0,0 +1,139 @@ +package code_path_analysis + +type BreakContext struct { + upper *BreakContext + breakable bool + label string + brokenForkContext *ForkContext +} + +func NewBreakContext(state *CodePathState, breakable bool, label string) *BreakContext { + return &BreakContext{ + upper: state.breakContext, + breakable: breakable, + label: label, + brokenForkContext: NewEmptyForkContext(state.forkContext, nil /*forkLeavingPath*/), + } +} + +// Creates new context for BreakStatement. +func (s *CodePathState) PushBreakContext(breakable bool, label string) *BreakContext { + s.breakContext = NewBreakContext(s, breakable, label) + return s.breakContext +} + +// Removes the top item of the break context stack. +func (s *CodePathState) PopBreakContext() *BreakContext { + context := s.breakContext + forkContext := s.forkContext + + s.breakContext = context.upper + + // Process this context here for other than switches and loops. + if !context.breakable { + brokenForkContext := context.brokenForkContext + + if !brokenForkContext.IsEmpty() { + brokenForkContext.Add(forkContext.Head()) + forkContext.ReplaceHead(brokenForkContext.MakeNext(0, -1)) + } + } + + return context +} + +// Makes a path for a `break` statement. +// It registers the head segment to a context of `break`. +// It makes new unreachable segment, then it set the head with the segment. +func (s *CodePathState) MakeBreak(label string) { + forkContext := s.forkContext + + if !forkContext.IsReachable() { + return + } + + context := s.getBreakContext(label) + + if context != nil { + context.brokenForkContext.Add(forkContext.Head()) + } + + forkContext.ReplaceHead(forkContext.MakeUnreachable(-1, -1)) +} + +func (s *CodePathState) getBreakContext(label string) *BreakContext { + context := s.breakContext + + for context != nil { + if label == "" && context.breakable { + return context + } else if context.label == label { + return context + } + + context = context.upper + } + + return nil +} + +// Makes a path for a `continue` statement. +// +// It makes a looping path. +// It makes new unreachable segment, then it set the head with the segment. +func (s *CodePathState) MakeContinue(label string) { + forkContext := s.forkContext + + if !forkContext.IsReachable() { + return + } + + context := s.getContinueContext(label) + + if context != nil { + if context.continueDestSegments != nil { + s.MakeLooped(forkContext.Head(), context.continueDestSegments) + + // If the context is a for-in/of loop, this effects a break also. + if context.kind == ForInStatement || context.kind == ForOfStatement { + context.brokenForkContext.Add(forkContext.Head()) + } + } else { + context.continueForkContext.Add(forkContext.Head()) + } + } + forkContext.ReplaceHead(forkContext.MakeUnreachable(-1, -1)) +} + +// Gets a loop-context for a `continue` statement. +func (s *CodePathState) getContinueContext(label string) *LoopContext { + if label == "" { + return s.loopContext + } + + context := s.loopContext + for context != nil { + if context.label == label { + return context + } + context = context.upper + } + + return nil +} + +// Makes a path for a `return` statement. +// +// It registers the head segment to a context of `return`. +// It makes new unreachable segment, then it set the head with the segment. +func (s *CodePathState) MakeReturn() { + forkContext := s.forkContext + + if forkContext.IsReachable() { + returnCtx := s.getReturnContext() + if returnCtx != nil { + returnCtx.returnedForkContext.Add(forkContext.Head()) + } + forkContext.ReplaceHead(forkContext.MakeUnreachable(-1, -1)) + } +} diff --git a/internal/plugins/react_hooks/code_path_analysis/chain_context.go b/internal/plugins/react_hooks/code_path_analysis/chain_context.go new file mode 100644 index 00000000..1a4b5963 --- /dev/null +++ b/internal/plugins/react_hooks/code_path_analysis/chain_context.go @@ -0,0 +1,51 @@ +package code_path_analysis + +type ChainContext struct { + upper *ChainContext + countChoiceContext int +} + +func NewChainContext(state *CodePathState) *ChainContext { + return &ChainContext{ + upper: state.chainContext, + countChoiceContext: 0, + } +} + +// Push a new `ChainExpression` context to the stack. +// This method is called on entering to each `ChainExpression` node. +// This context is used to count forking in the optional chain then merge them on the exiting from the `ChainExpression` node. +func (s *CodePathState) PushChainContext() { + s.chainContext = NewChainContext(s) +} + +// Pop a `ChainExpression` context from the stack. +// This method is called on exiting from each `ChainExpression` node. +// This merges all forks of the last optional chaining. +func (s *CodePathState) PopChainContext() { + context := s.chainContext + s.chainContext = context.upper + + // pop all choice contexts of this. + for i := context.countChoiceContext; i > 0; i-- { + s.PopChoiceContext() + } +} + +// Create a choice context for optional access. +// This method is called on entering to each `(Call|Member)Expression[optional=true]` node. +// This creates a choice context as similar to `LogicalExpression[operator="??"]` node. +func (s *CodePathState) MakeOptionalNode() { + if s.chainContext != nil { + s.chainContext.countChoiceContext += 1 + s.PushChoiceContext("??", false) + } +} + +// Create a fork. +// This method is called on entering to the `arguments|property` property of each `(Call|Member)Expression` node. +func (s *CodePathState) MakeOptionalRight() { + if s.chainContext != nil { + s.MakeLogicalRight() + } +} diff --git a/internal/plugins/react_hooks/code_path_analysis/choice_context.go b/internal/plugins/react_hooks/code_path_analysis/choice_context.go new file mode 100644 index 00000000..39a01e54 --- /dev/null +++ b/internal/plugins/react_hooks/code_path_analysis/choice_context.go @@ -0,0 +1,198 @@ +package code_path_analysis + +// A context for ConditionalExpression, LogicalExpression, +// AssignmentExpression (logical assignments only), IfStatement, WhileStatement, +// DoWhileStatement, or ForStatement. +// +// LogicalExpressions have cases that it goes different paths between the +// true case and the false case. +// +// For Example: +// +// if (a || b) { +// foo(); +// } else { +// bar(); +// } +// +// In this case, `b` is evaluated always in the code path of the `else` +// block, but it's not so in the code path of the `if` block. +// So there are 3 paths: +// +// a -> foo(); +// a -> b -> foo(); +// a -> b -> bar(); +type ChoiceContext struct { + upper *ChoiceContext + kind string + isForkingAsResult bool + trueForkContext *ForkContext + falseForkContext *ForkContext + qqForkContext *ForkContext + processed bool +} + +func NewChoiceContext(state *CodePathState, kind string, isForkingAsResult bool) *ChoiceContext { + return &ChoiceContext{ + upper: state.choiceContext, + kind: kind, + isForkingAsResult: isForkingAsResult, + trueForkContext: NewEmptyForkContext(state.forkContext, nil /*forkLeavingPath*/), + falseForkContext: NewEmptyForkContext(state.forkContext, nil /*forkLeavingPath*/), + qqForkContext: NewEmptyForkContext(state.forkContext, nil /*forkLeavingPath*/), + processed: false, + } +} + +func (s *CodePathState) PushChoiceContext(kind string, isForkingAsResult bool) { + s.choiceContext = NewChoiceContext(s, kind, isForkingAsResult) +} + +// Pops the last choice context and finalizes it. +func (s *CodePathState) PopChoiceContext() *ChoiceContext { + context := s.choiceContext + + s.choiceContext = context.upper + + forkContext := s.forkContext + headSegments := forkContext.Head() + + switch context.kind { + case "&&", "||", "??": + { + // If any result were not transferred from child contexts, + // this sets the head segments to both cases. + // The head segments are the path of the right-hand operand. + if !context.processed { + context.trueForkContext.Add(headSegments) + context.falseForkContext.Add(headSegments) + context.qqForkContext.Add(headSegments) + } + + // Transfers results to upper context if this context is in + // test chunk. + if context.isForkingAsResult { + parentContext := s.choiceContext + parentContext.trueForkContext.AddAll(context.trueForkContext) + parentContext.falseForkContext.AddAll(context.falseForkContext) + parentContext.qqForkContext.AddAll(context.qqForkContext) + parentContext.processed = true + + return context + } + } + case "test": + { + if !context.processed { + // The head segments are the path of the `if` block here. + // Updates the `true` path with the end of the `if` block. + context.trueForkContext.Clear() + context.trueForkContext.Add(headSegments) + } else { + // The head segments are the path of the `else` block here. + // Updates the `false` path with the end of the `else` + // block. + context.falseForkContext.Clear() + context.falseForkContext.Add(headSegments) + } + } + case "loop": + { + // Loops are addressed in popLoopContext(). + // This is called from popLoopContext(). + + return context + } + default: + { + panic("Unreachable") + } + } + + // Merges all paths. + prevForkContext := context.trueForkContext + + prevForkContext.AddAll(context.falseForkContext) + forkContext.ReplaceHead(prevForkContext.MakeNext(0, -1)) + + return context +} + +// Makes a code path segment of the right-hand operand of a logical expression. +func (s *CodePathState) MakeLogicalRight() { + context := s.choiceContext + forkContext := s.forkContext + + if context.processed { + // This got segments already from the child choice context. + // Creates the next path from own true/false fork context. + var prevForkContext *ForkContext + + switch context.kind { + case "&&": // if true then go to the right-hand side. + prevForkContext = context.trueForkContext + case "||": // if false then go to the right-hand side. + prevForkContext = context.falseForkContext + case "??": // Both true/false can short-circuit, so needs the third path to go to the right-hand side. That's qqForkContext. + prevForkContext = context.qqForkContext + default: + panic("Unreachable") + } + + forkContext.ReplaceHead(prevForkContext.MakeNext(0, -1)) + prevForkContext.Clear() + context.processed = false + } else { + // This did not get segments from the child choice context. + // So addresses the head segments. + // The head segments are the path of the left-hand operand. + switch context.kind { + case "&&": // the false path can short-circuit. + context.falseForkContext.Add(forkContext.Head()) + case "||": // the true path can short-circuit. + context.trueForkContext.Add(forkContext.Head()) + case "??": // both can short-circuit. + context.trueForkContext.Add(forkContext.Head()) + context.falseForkContext.Add(forkContext.Head()) + default: + panic("Unreachable") + } + + forkContext.ReplaceHead(forkContext.MakeNext(-1, -1)) + } +} + +// Makes a code path segment of the `if` block. +func (s *CodePathState) MakeIfConsequent() { + context := s.choiceContext + forkContext := s.forkContext + + // If any result were not transferred from child contexts, + // this sets the head segments to both cases. + // The head segments are the path of the test expression. + if !context.processed { + context.trueForkContext.Add(forkContext.Head()) + context.falseForkContext.Add(forkContext.Head()) + context.qqForkContext.Add(forkContext.Head()) + } + + context.processed = false + + // Creates new path from the `true` case. + forkContext.ReplaceHead(context.trueForkContext.MakeNext(0, -1)) +} + +// Makes a code path segment of the `else` block. +func (s *CodePathState) MakeIfAlternate() { + context := s.choiceContext + forkContext := s.forkContext + + // The head segments are the path of the `if` block. + // Updates the `true` path with the end of the `if` block. + context.trueForkContext.Clear() + context.trueForkContext.Add(forkContext.Head()) + context.processed = true + + // Creates new path from the `false` case. + forkContext.ReplaceHead(context.falseForkContext.MakeNext(0, -1)) +} diff --git a/internal/plugins/react_hooks/code_path_analysis/code_path.go b/internal/plugins/react_hooks/code_path_analysis/code_path.go new file mode 100644 index 00000000..289c43d8 --- /dev/null +++ b/internal/plugins/react_hooks/code_path_analysis/code_path.go @@ -0,0 +1,84 @@ +package code_path_analysis + +type CodePath struct { + id string // An identifier + origin string // The type of code path origin + upper *CodePath // The code path of the upper function scope + onLooped func(fromSegment *CodePathSegment, toSegment *CodePathSegment) // A callback funciton to notify looping + childCodePaths []*CodePath // The code paths of nested function scopes + state *CodePathState // The state of the code path +} + +func NewCodePath(id string, origin string, upper *CodePath, onLooped func(fromSegment *CodePathSegment, toSegment *CodePathSegment)) *CodePath { + codePath := &CodePath{ + id: id, + origin: origin, + upper: upper, + onLooped: onLooped, + childCodePaths: make([]*CodePath, 0), + state: NewCodePathState(NewIdGenerator(id+"_"), onLooped), + } + // Adds this into `childCodePaths` of `upper`. + if upper != nil { + upper.childCodePaths = append(upper.childCodePaths, codePath) + } + return codePath +} + +// Getter methods for accessing private fields + +func (cp *CodePath) ID() string { + return cp.id +} + +func (cp *CodePath) Origin() string { + return cp.origin +} + +func (cp *CodePath) Upper() *CodePath { + return cp.upper +} + +func (cp *CodePath) ChildCodePaths() []*CodePath { + return cp.childCodePaths +} + +func (cp *CodePath) State() *CodePathState { + return cp.state +} + +func (cp *CodePath) InitialSegment() *CodePathSegment { + if cp.state != nil { + return cp.state.InitialSegment() + } + return nil +} + +func (cp *CodePath) FinalSegments() []*CodePathSegment { + if cp.state != nil { + return cp.state.FinalSegments() + } + return nil +} + +func (cp *CodePath) ThrownSegments() []*CodePathSegment { + if cp.state != nil { + return cp.state.ThrownSegments() + } + return nil +} + +// Helper function to check if a segment is in thrown segments +func (cp *CodePath) HasThrownSegment(segment *CodePathSegment) bool { + thrownSegments := cp.ThrownSegments() + if thrownSegments == nil { + return false + } + + for _, thrownSegment := range thrownSegments { + if thrownSegment.ID() == segment.ID() { + return true + } + } + return false +} diff --git a/internal/plugins/react_hooks/code_path_analysis/code_path_analyzer.go b/internal/plugins/react_hooks/code_path_analysis/code_path_analyzer.go new file mode 100644 index 00000000..ca6445e7 --- /dev/null +++ b/internal/plugins/react_hooks/code_path_analysis/code_path_analyzer.go @@ -0,0 +1,689 @@ +package code_path_analysis + +import ( + "math" + + "github.com/microsoft/typescript-go/shim/ast" +) + +type CodePathAnalyzer struct { + currentNode *ast.Node + codePath *CodePath + idGenerator *IdGenerator + + // Maintain code segment path stack as we traverse. + onCodePathSegmentStart func(segment *CodePathSegment, node *ast.Node) + onCodePathSegmentEnd func(segment *CodePathSegment, node *ast.Node) + + // Maintain code path stack as we traverse. + onCodePathStart func(codePath *CodePath, node *ast.Node) + onCodePathEnd func(codePath *CodePath, node *ast.Node) + + onCodePathSegmentLoop func(fromSegment *CodePathSegment, toSegment *CodePathSegment, node *ast.Node) +} + +func NewCodePathAnalyzer( + onCodePathSegmentStart func(segment *CodePathSegment, node *ast.Node), + onCodePathSegmentEnd func(segment *CodePathSegment, node *ast.Node), + onCodePathStart func(codePath *CodePath, node *ast.Node), + onCodePathEnd func(codePath *CodePath, node *ast.Node), + onCodePathSegmentLoop func(fromSegment *CodePathSegment, toSegment *CodePathSegment, node *ast.Node), +) *CodePathAnalyzer { + return &CodePathAnalyzer{ + currentNode: nil, + codePath: nil, + idGenerator: NewIdGenerator("s"), + onCodePathSegmentStart: onCodePathSegmentStart, + onCodePathSegmentEnd: onCodePathSegmentEnd, + onCodePathStart: onCodePathStart, + onCodePathEnd: onCodePathEnd, + onCodePathSegmentLoop: onCodePathSegmentLoop, + } +} + +func (analyzer *CodePathAnalyzer) State() *CodePathState { + codePath := analyzer.codePath + var state *CodePathState + if codePath != nil { + state = codePath.State() + } + return state +} + +// Does the process to enter a given AST node. +// This updates state of analysis and calls `enterNode` of the wrapped. +func (analyzer *CodePathAnalyzer) EnterNode(node *ast.Node) { + analyzer.currentNode = node + + // Updates the code path due to node's position in its parent node. + if node.Parent != nil { + analyzer.preprocess(node) + } + + // Updates the code path. + // And emits onCodePathStart/onCodePathSegmentStart events. + analyzer.processCodePathToEnter(node) + + analyzer.currentNode = nil +} + +// Does the process to leave a given AST node. +// This updates state of analysis and calls `leaveNode` of the wrapped. +func (analyzer *CodePathAnalyzer) LeaveNode(node *ast.Node) { + analyzer.currentNode = node + + analyzer.processCodePathToExit(node) + + analyzer.postprocess(node) + + analyzer.currentNode = nil +} + +// Updates the code path due to the position of a given node in the parent node thereof. +// +// For example, if the node is `parent.consequent`, this creates a fork from the current path. +func (analyzer *CodePathAnalyzer) preprocess(node *ast.Node) { + state := analyzer.State() + parent := node.Parent + + switch parent.Kind { + // The `arguments.length == 0` case is in `postprocess` function. + case ast.KindCallExpression: + if ast.IsOptionalChain(parent) && len(parent.Arguments()) >= 1 && parent.Arguments()[0] == node { + state.MakeOptionalRight() + } + + case ast.KindPropertyAccessExpression: + // Corresponds to ESLint's MemberExpression + expr := parent.AsPropertyAccessExpression() + if ast.IsOptionalChain(parent) && len(expr.Properties()) > 0 && expr.Properties()[0] == node { + state.MakeOptionalRight() + } + + case ast.KindBinaryExpression: + // Handle LogicalExpression (&&, ||, ??) + binExpr := parent.AsBinaryExpression() + if binExpr.Right == node && isHandledLogicalOperator(binExpr.OperatorToken.Kind) { + state.MakeLogicalRight() + } + + case ast.KindConditionalExpression: + // Handle ternary operator: condition ? consequent : alternate + condExpr := parent.AsConditionalExpression() + if condExpr.WhenTrue == node { + state.MakeIfConsequent() + } else if condExpr.WhenFalse == node { + state.MakeIfAlternate() + } + + case ast.KindIfStatement: + // Handle if-else statements + ifStmt := parent.AsIfStatement() + if ifStmt.ThenStatement == node { + state.MakeIfConsequent() + } else if ifStmt.ElseStatement == node { + state.MakeIfAlternate() + } + + case ast.KindCaseClause: + // Handle switch case body + caseStmts := parent.AsCaseOrDefaultClause().Statements.Nodes + if caseStmts[0] == node { + state.MakeSwitchCaseBody(false, false) + } + + case ast.KindDefaultClause: + defaultStmts := parent.AsCaseOrDefaultClause().Statements.Nodes + if defaultStmts[0] == node { + state.MakeSwitchCaseBody(false, true) + } + + case ast.KindTryStatement: + // Handle try-catch-finally + tryStmt := parent.AsTryStatement() + if tryStmt.CatchClause == node { + state.MakeCatchBlock() + } else if tryStmt.FinallyBlock == node { + state.MakeFinallyBlock() + } + + case ast.KindWhileStatement: + // Handle while loops + whileStmt := parent.AsWhileStatement() + if whileStmt.Expression == node { + state.MakeWhileTest(getBooleanValueIfSimpleConstant(node)) + } else if whileStmt.Statement == node { + state.MakeWhileBody() + } + + case ast.KindDoStatement: + // Handle do-while loops + doStmt := parent.AsDoStatement() + if doStmt.Statement == node { + state.MakeDoWhileBody() + } else if doStmt.Expression == node { + state.MakeDoWhileTest(getBooleanValueIfSimpleConstant(node)) + } + + case ast.KindForStatement: + // Handle for loops + forStmt := parent.AsForStatement() + if forStmt.Condition == node { + state.MakeForTest(getBooleanValueIfSimpleConstant(node)) + } else if forStmt.Incrementor == node { + state.MakeForUpdate() + } else if forStmt.Statement == node { + state.MakeForBody() + } + + case ast.KindForInStatement: + // Handle for-in loops + forInStmt := parent.AsForInOrOfStatement() + if forInStmt.Initializer == node { + state.MakeForInOfLeft() + } else if forInStmt.Expression == node { + state.MakeForInOfRight() + } else if forInStmt.Statement == node { + state.MakeForInOfBody() + } + + case ast.KindForOfStatement: + // Handle for-of loops + forOfStmt := parent.AsForInOrOfStatement() + if forOfStmt.Initializer == node { + state.MakeForInOfLeft() + } else if forOfStmt.Expression == node { + state.MakeForInOfRight() + } else if forOfStmt.Statement == node { + state.MakeForInOfBody() + } + + case ast.KindBindingElement: + // Handle assignment patterns (destructuring with defaults) + bindingElem := parent.AsBindingElement() + if bindingElem.Initializer == node { + state.PushForkContext(nil) + state.ForkBypassPath() + state.ForkPath() + } + } +} + +func (analyzer *CodePathAnalyzer) processCodePathToEnter(node *ast.Node) { + // Special case: The right side of class field initializer is considered + // to be its own function, so we need to start a new code path in this case. + if isPropertyDefinitionValue(node) { + analyzer.startCodePath("class-field-initializer", node) + + /* + * Intentional fall through because `node` needs to also be + * processed by the code below. For example, if we have: + * + * class Foo { + * a = () => {} + * } + * + * In this case, we also need start a second code path. + */ + } + + state := analyzer.State() + parent := node.Parent + + switch node.Kind { + case ast.KindSourceFile: + analyzer.startCodePath("program", node) + + case ast.KindFunctionDeclaration, ast.KindFunctionExpression, ast.KindArrowFunction: + analyzer.startCodePath("function", node) + + case ast.KindClassStaticBlockDeclaration: + analyzer.startCodePath("class-static-block", node) + + case ast.KindCallExpression: + if ast.IsOptionalChain(node) { + state.MakeOptionalNode() + } + + case ast.KindPropertyAccessExpression, ast.KindElementAccessExpression: + if ast.IsOptionalChain(node) { + state.MakeOptionalNode() + } + + case ast.KindBinaryExpression: + // Handle LogicalExpression (&&, ||, ??) + binExpr := node.AsBinaryExpression() + if isHandledLogicalOperator(binExpr.OperatorToken.Kind) { + state.PushChoiceContext(tokenToText[binExpr.OperatorToken.Kind], isForkingByTrueOrFalse(node)) + } else if isLogicalAssignmentOperator(binExpr.OperatorToken.Kind) { + text := tokenToText[binExpr.OperatorToken.Kind] + // removes `=` from the end + text = text[:len(text)-1] + state.PushChoiceContext(text, isForkingByTrueOrFalse(node)) + } + + case ast.KindConditionalExpression, ast.KindIfStatement: + state.PushChoiceContext("test", false) + + case ast.KindSwitchStatement: + switchStmt := node.AsSwitchStatement() + hasDefaultCase := false + for _, clause := range switchStmt.CaseBlock.AsCaseBlock().Clauses.Nodes { + if ast.IsDefaultClause(clause) { + hasDefaultCase = true + break + } + } + label := getLabel(node) + state.PushSwitchContext(hasDefaultCase, label) + + case ast.KindTryStatement: + tryStmt := node.AsTryStatement() + hasFinalizer := tryStmt.FinallyBlock != nil + state.PushTryContext(hasFinalizer) + + case ast.KindCaseClause: + // Fork if this node is after the 1st node in `cases`. + if parent != nil && parent.Kind == ast.KindSwitchStatement { + state.ForkPath() + } + + case ast.KindWhileStatement: + label := getLabel(node) + state.PushLoopContext(WhileStatement, label) + case ast.KindDoStatement: + label := getLabel(node) + state.PushLoopContext(DoWhileStatement, label) + case ast.KindForStatement: + label := getLabel(node) + state.PushLoopContext(ForStatement, label) + case ast.KindForInStatement: + label := getLabel(node) + state.PushLoopContext(ForInStatement, label) + case ast.KindForOfStatement: + label := getLabel(node) + state.PushLoopContext(ForOfStatement, label) + case ast.KindLabeledStatement: + if !isBreakableType(node.Body().Kind) { + state.PushBreakContext(false, node.Label().Text()) + } + default: + // No special handling needed + } + + // Emits onCodePathSegmentStart events if updated. + analyzer.forwardCurrentToHead(node) +} + +// Updates the code path due to the type of a given node in leaving. +func (analyzer *CodePathAnalyzer) processCodePathToExit(node *ast.Node) { + state := analyzer.State() + if state == nil { + return + } + + dontForward := false + + switch node.Kind { + // !!! ChainExpression + case ast.KindIfStatement, ast.KindConditionalExpression: + state.PopChoiceContext() + + case ast.KindBinaryExpression: + // Handle LogicalExpression (&&, ||, ??) + binExpr := node.AsBinaryExpression() + if isHandledLogicalOperator(binExpr.OperatorToken.Kind) || + isLogicalAssignmentOperator(binExpr.OperatorToken.Kind) { + state.PopBreakContext() + } + + case ast.KindSwitchStatement: + state.PopSwitchContext() + + case ast.KindCaseClause: + // This is the same as the process at the 1st `consequent` node in preprocess function. + // Must do if this `consequent` is empty. + caseClause := node.AsCaseOrDefaultClause() + if len(caseClause.Statements.Nodes) == 0 { + isDefault := caseClause.Expression == nil + state.MakeSwitchCaseBody(true, isDefault) + } + if state.forkContext.IsReachable() { + dontForward = true + } + + case ast.KindTryStatement: + state.PopTryContext() + + case ast.KindBreakStatement: + analyzer.forwardCurrentToHead(node) + breakStmt := node.AsBreakStatement() + label := "" + if breakStmt.Label != nil { + label = breakStmt.Label.Text() + } + state.MakeBreak(label) + dontForward = true + + case ast.KindContinueStatement: + analyzer.forwardCurrentToHead(node) + continueStmt := node.AsContinueStatement() + label := "" + if continueStmt.Label != nil { + label = continueStmt.Label.Text() + } + state.MakeContinue(label) + dontForward = true + + case ast.KindReturnStatement: + analyzer.forwardCurrentToHead(node) + state.MakeReturn() + dontForward = true + + case ast.KindThrowStatement: + analyzer.forwardCurrentToHead(node) + state.MakeThrow() + dontForward = true + + case ast.KindIdentifier: + // TODO: Implement isIdentifierReference check + // if analyzer.isIdentifierReference(node) { + // state.MakeFirstThrowablePathInTryBlock() + // dontForward = true + // } + + case ast.KindCallExpression, ast.KindPropertyAccessExpression, ast.KindElementAccessExpression, ast.KindNewExpression, ast.KindYieldExpression: + state.MakeFirstThrowablePathInTryBlock() + + case ast.KindWhileStatement, ast.KindDoStatement, ast.KindForStatement, ast.KindForInStatement, ast.KindForOfStatement: + state.PopLoopContext() + + case ast.KindBindingElement: + state.PopForkContext() + + case ast.KindLabeledStatement: + labeledStmt := node.AsLabeledStatement() + if !isBreakableType(labeledStmt.Body().Kind) { + state.PopBreakContext() + } + + default: + // No special handling needed + } + + // Emits onCodePathSegmentStart events if updated. + if !dontForward { + analyzer.forwardCurrentToHead(node) + } +} + +func (analyzer *CodePathAnalyzer) postprocess(node *ast.Node) { + switch node.Kind { + case ast.KindSourceFile, + ast.KindFunctionDeclaration, + ast.KindFunctionExpression, + ast.KindArrowFunction, + ast.KindClassStaticBlockDeclaration: + analyzer.endCodePath(node) + + // The `arguments.length >= 1` case is in `preprocess` function. + case ast.KindCallExpression: + callExpr := node.AsCallExpression() + if ast.IsOptionalChain(node) && len(callExpr.Arguments.Nodes) == 0 { + if analyzer.codePath != nil { + analyzer.codePath.state.MakeOptionalRight() + } + } + + default: + // No special handling needed + } + + // Special case: The right side of class field initializer is considered + // to be its own function, so we need to end a code path in this case. + if isPropertyDefinitionValue(node) { + analyzer.endCodePath(node) + } +} + +func (analyzer *CodePathAnalyzer) startCodePath(origin string, node *ast.Node) { + codePath := analyzer.codePath + if codePath != nil { + // Emits onCodePathSegmentStart events if updated. + analyzer.forwardCurrentToHead(node) + } + + // Create the code path of this scope. + analyzer.codePath = NewCodePath( + analyzer.idGenerator.Next(), + origin, + codePath, + analyzer.onLooped, + ) + + if analyzer.onCodePathStart != nil { + analyzer.onCodePathStart(codePath, node) + } +} + +// Ends the code path for the current node. +func (analyzer *CodePathAnalyzer) endCodePath(node *ast.Node) { + codePath := analyzer.codePath + if codePath == nil { + return + } + + // Mark the current path as the final node. + codePath.state.MakeFinal() + + // Emits onCodePathSegmentEnd event of the current segments. + analyzer.leaveFromCurrentSegment(node) + + // Emits onCodePathEnd event of this code path. + if analyzer.onCodePathEnd != nil { + analyzer.onCodePathEnd(codePath, node) + } + + analyzer.codePath = codePath.upper +} + +func (analyzer *CodePathAnalyzer) onLooped(fromSegment *CodePathSegment, toSegment *CodePathSegment) { + if fromSegment.reachable && toSegment.reachable { + if analyzer.onCodePathSegmentLoop != nil { + analyzer.onCodePathSegmentLoop(fromSegment, toSegment, analyzer.currentNode) + } + } +} + +// Updates the current segment with the head segment. +// This is similar to local branches and tracking branches of git. +// +// To separate the current and the head is in order to not make useless segments. +// +// In this process, both "onCodePathSegmentStart" and "onCodePathSegmentEnd" +// events are fired. +func (analyzer *CodePathAnalyzer) forwardCurrentToHead(node *ast.Node) { + state := analyzer.State() + currentSegments := state.currentSegments + headSegments := state.HeadSegments() + end := int(math.Max(float64(len(currentSegments)), float64(len(headSegments)))) + + if analyzer.onCodePathSegmentEnd != nil { + for i := range end { + var currentSegment *CodePathSegment + var headSegment *CodePathSegment + + if i < len(currentSegments) { + currentSegment = currentSegments[i] + } + if i < len(headSegments) { + headSegment = headSegments[i] + } + + if currentSegment != headSegment && currentSegment != nil { + if currentSegment.reachable { + analyzer.onCodePathSegmentEnd(currentSegment, node) + } + } + } + } + + // Update state. + state.currentSegments = headSegments + + if analyzer.onCodePathSegmentStart != nil { + for i := range end { + var currentSegment *CodePathSegment + var headSegment *CodePathSegment + + if i < len(currentSegments) { + currentSegment = currentSegments[i] + } + if i < len(headSegments) { + headSegment = headSegments[i] + } + + if currentSegment != headSegment && headSegment != nil { + markUsed(headSegment) + if headSegment.reachable { + analyzer.onCodePathSegmentStart(headSegment, node) + } + } + } + } +} + +// Updates the current segment with empty. +// This is called at the last of functions or the program. +func (analyzer *CodePathAnalyzer) leaveFromCurrentSegment(node *ast.Node) { + state := analyzer.State() + currentSegments := state.currentSegments + + for _, currentSegment := range currentSegments { + if currentSegment.reachable { + analyzer.onCodePathSegmentEnd(currentSegment, node) + } + } + + state.currentSegments = make([]*CodePathSegment, 0) +} + +// Checks if a given node appears as the value of a PropertyDefinition node. +func isPropertyDefinitionValue(node *ast.Node) bool { + parent := node.Parent + + return parent != nil && ast.IsPropertyDeclaration(parent) && parent.AsPropertyDeclaration().Initializer == node +} + +// Checks whether the given logical operator is taken into account for the code path analysis. +func isHandledLogicalOperator(operatorKind ast.Kind) bool { + return operatorKind == ast.KindBarBarToken || operatorKind == ast.KindAmpersandAmpersandToken || operatorKind == ast.KindQuestionQuestionToken +} + +// Checks whether the given assignment operator is a logical assignment operator. +// Logical assignments are taken into account for the code path analysis +// because of their short-circuiting semantics. +func isLogicalAssignmentOperator(operatorKind ast.Kind) bool { + return operatorKind == ast.KindAmpersandAmpersandEqualsToken || operatorKind == ast.KindBarBarEqualsToken || operatorKind == ast.KindQuestionQuestionEqualsToken +} + +// Checks whether or not a given logical expression node goes different path +// between the `true` case and the `false` case. +func isForkingByTrueOrFalse(node *ast.Node) bool { + parent := node.Parent + if parent == nil { + return false + } + + switch parent.Kind { + case ast.KindConditionalExpression: + condExpr := parent.AsConditionalExpression() + return condExpr.Condition == node + + case ast.KindIfStatement: + ifStmt := parent.AsIfStatement() + return ifStmt.Expression == node + + case ast.KindWhileStatement: + whileStmt := parent.AsWhileStatement() + return whileStmt.Expression == node + + case ast.KindDoStatement: + doStmt := parent.AsDoStatement() + return doStmt.Expression == node + + case ast.KindForStatement: + forStmt := parent.AsForStatement() + return forStmt.Condition == node + + case ast.KindBinaryExpression: + binExpr := parent.AsBinaryExpression() + return isHandledLogicalOperator(binExpr.OperatorToken.Kind) || isLogicalAssignmentOperator(binExpr.OperatorToken.Kind) + + default: + return false + } +} + +// Gets the boolean value of a given literal node. +// +// This is used to detect infinity loops (e.g. `while (true) {}`). +// Statements preceded by an infinity loop are unreachable if the loop didn't +// have any `break` statement. +func getBooleanValueIfSimpleConstant(node *ast.Node) bool { + if node.Kind == ast.KindTrueKeyword { + return true + } + if node.Kind == ast.KindFalseKeyword { + return false + } + if node.Kind == ast.KindNumericLiteral { + numLiteral := node.AsNumericLiteral() + // In JavaScript, any non-zero number is truthy, zero is falsy + if numLiteral.Text == "0" { + return false + } else { + return true + } + } + if node.Kind == ast.KindStringLiteral { + strLiteral := node.AsStringLiteral() + // In JavaScript, empty string is falsy, non-empty string is truthy + if strLiteral.Text == `""` || strLiteral.Text == `''` { + return false + } else { + return true + } + } + if node.Kind == ast.KindNullKeyword { + return false + } + // Return nil for non-literal nodes or literals we can't determine + return false +} + +// Gets the label if the parent node of a given node is a LabeledStatement. +func getLabel(node *ast.Node) string { + if ast.IsLabeledStatement(node.Parent) { + return node.Parent.Label().Text() + } + return "" +} + +func isBreakableType(kind ast.Kind) bool { + switch kind { + case ast.KindWhileStatement, ast.KindDoStatement, ast.KindForStatement, ast.KindForInStatement, ast.KindForOfStatement, ast.KindSwitchStatement: + return true + default: + return false + } +} + +var tokenToText = map[ast.Kind]string{ + ast.KindAmpersandAmpersandToken: "&&", + ast.KindBarBarToken: "||", + ast.KindQuestionQuestionToken: "??", + ast.KindAmpersandAmpersandEqualsToken: "&&=", + ast.KindBarBarEqualsToken: "||=", + ast.KindQuestionQuestionEqualsToken: "??=", +} diff --git a/internal/plugins/react_hooks/code_path_analysis/code_path_segment.go b/internal/plugins/react_hooks/code_path_analysis/code_path_segment.go new file mode 100644 index 00000000..9fcfbbda --- /dev/null +++ b/internal/plugins/react_hooks/code_path_analysis/code_path_segment.go @@ -0,0 +1,162 @@ +package code_path_analysis + +import "slices" + +type internalData struct { + used bool + loopedPrevSegments []*CodePathSegment +} + +// A code path segment. +type CodePathSegment struct { + id string // The identifier of this code path. Rules use it to store additional information of each rule. + nextSegments []*CodePathSegment // An array of the next segments. + prevSegments []*CodePathSegment // An array of the previous segments. + allNextSegments []*CodePathSegment // An array of the next segments. This array includes unreachable segments. + allPrevSegments []*CodePathSegment // An array of the previous segments. This array includes unreachable segments. + reachable bool // A flag which shows this is reachable. + internal *internalData // Internal data. +} + +func NewCodePathSegment(id string, allPrevSegments []*CodePathSegment, reachable bool) *CodePathSegment { + segment := &CodePathSegment{ + id: id, + nextSegments: make([]*CodePathSegment, 0), + prevSegments: make([]*CodePathSegment, 0), + allNextSegments: make([]*CodePathSegment, 0), + allPrevSegments: allPrevSegments, + reachable: reachable, + internal: &internalData{ + used: false, + loopedPrevSegments: make([]*CodePathSegment, 0), + }, + } + + for _, prevSegment := range segment.allPrevSegments { + if prevSegment.reachable { + segment.prevSegments = append(segment.prevSegments, prevSegment) + } + } + + return segment +} + +// Creates the root segment. +func NewRootCodePathSegment(id string) *CodePathSegment { + return NewCodePathSegment(id, []*CodePathSegment{} /*allPrevSegments*/, true /*reachable*/) +} + +// Creates a segment that follows given segments. +func NewNextCodePathSegment(id string, allPrevSegments []*CodePathSegment) *CodePathSegment { + reachable := false + for _, segment := range allPrevSegments { + if segment.reachable { + reachable = true + break + } + } + return NewCodePathSegment(id, flattenUnusedSegments(allPrevSegments), reachable) +} + +// Creates an unreachable segment that follows given segments. +func NewUnreachableCodePathSegment(id string, allPrevSegments []*CodePathSegment) *CodePathSegment { + segment := NewCodePathSegment(id, flattenUnusedSegments(allPrevSegments), false /*reachable*/) + + // In `if (a) return a; foo();` case, the unreachable segment preceded by + // the return statement is not used but must not be remove. + markUsed(segment) + return segment +} + +// Creates a segment that follows given segments. This factory method does not connect with `allPrevSegments`. But this inherits `reachable` flag. +func NewDisconnectedCodePathSegment(id string, allPrevSegments []*CodePathSegment) *CodePathSegment { + isReachable := false + for _, prevSegment := range allPrevSegments { + if prevSegment.reachable { + isReachable = true + break + } + } + return NewCodePathSegment(id, []*CodePathSegment{}, isReachable) +} + +// Checks a given previous segment is coming from the end of a loop. +func (cps *CodePathSegment) IsLoopedPrevSegment(segment *CodePathSegment) bool { + return slices.Contains(cps.internal.loopedPrevSegments, segment) +} + +// Replaces unused segments with the previous segments of each unused segment. +func flattenUnusedSegments(segments []*CodePathSegment) []*CodePathSegment { + done := make(map[string]bool) + retv := make([]*CodePathSegment, 0) + for _, segment := range segments { + // Ignores duplicated. + if done[segment.id] { + continue + } + + if !segment.internal.used { + for _, prevSegment := range segment.allPrevSegments { + if !done[prevSegment.id] { + done[prevSegment.id] = true + retv = append(retv, prevSegment) + } + } + } else { + done[segment.id] = true + retv = append(retv, segment) + } + } + return retv +} + +// Makes a given segment being used. +func markUsed(segment *CodePathSegment) { + if segment.internal.used { + return + } + + segment.internal.used = true + + if segment.reachable { + for _, prevSegment := range segment.allPrevSegments { + prevSegment.allNextSegments = append(prevSegment.allNextSegments, segment) + prevSegment.nextSegments = append(prevSegment.nextSegments, segment) + } + } else { + for _, prevSegment := range segment.allPrevSegments { + prevSegment.allNextSegments = append(prevSegment.allNextSegments, segment) + } + } +} + +// Marks a previous segment as looped. +func markPrevSegmentAsLooped(segment *CodePathSegment, prevSegment *CodePathSegment) { + segment.internal.loopedPrevSegments = append(segment.internal.loopedPrevSegments, prevSegment) +} + +// Getter methods for accessing private fields + +func (cps *CodePathSegment) ID() string { + return cps.id +} + +func (cps *CodePathSegment) NextSegments() []*CodePathSegment { + return cps.nextSegments +} + +func (cps *CodePathSegment) PrevSegments() []*CodePathSegment { + return cps.prevSegments +} + +func (cps *CodePathSegment) AllNextSegments() []*CodePathSegment { + return cps.allNextSegments +} + +func (cps *CodePathSegment) AllPrevSegments() []*CodePathSegment { + return cps.allPrevSegments +} + +func (cps *CodePathSegment) Reachable() bool { + return cps.reachable +} diff --git a/internal/plugins/react_hooks/code_path_analysis/code_path_state.go b/internal/plugins/react_hooks/code_path_analysis/code_path_state.go new file mode 100644 index 00000000..bcf69351 --- /dev/null +++ b/internal/plugins/react_hooks/code_path_analysis/code_path_state.go @@ -0,0 +1,147 @@ +package code_path_analysis + +type CodePathState struct { + idGenerator *IdGenerator // idGenerator An id generator to generate id for code + forkContext *ForkContext + notifyLooped func(fromSegment *CodePathSegment, toSegment *CodePathSegment) + choiceContext *ChoiceContext + chainContext *ChainContext + breakContext *BreakContext + switchContext *SwitchContext + tryContext *TryContext + loopContext *LoopContext + + currentSegments []*CodePathSegment + initialSegment *CodePathSegment + finalSegments []*CodePathSegment + returnedSegments []*CodePathSegment + thrownSegments []*CodePathSegment +} + +func NewCodePathState(idGenerator *IdGenerator, onLooped func(fromSegment *CodePathSegment, toSegment *CodePathSegment)) *CodePathState { + forkContext := NewRootForkContext(idGenerator) + return &CodePathState{ + idGenerator: idGenerator, + notifyLooped: onLooped, + forkContext: forkContext, + currentSegments: make([]*CodePathSegment, 0), + initialSegment: forkContext.Head()[0], + finalSegments: make([]*CodePathSegment, 0), + returnedSegments: make([]*CodePathSegment, 0), + thrownSegments: make([]*CodePathSegment, 0), + } +} + +// The head segments. +func (s *CodePathState) HeadSegments() []*CodePathSegment { + return s.forkContext.Head() +} + +// The parent forking context. This is used for the root of new forks. +func (s *CodePathState) ParentForkContext() *ForkContext { + current := s.forkContext + if current == nil { + return nil + } + return current.upper +} + +// Creates and stacks new forking context. +func (s *CodePathState) PushForkContext(forkLeavingPath *ForkContext) *ForkContext { + s.forkContext = NewEmptyForkContext(s.forkContext, forkLeavingPath) + return s.forkContext +} + +// Pops and merges the last forking context. +func (s *CodePathState) PopForkContext() *ForkContext { + lastContext := s.forkContext + + s.forkContext = lastContext.upper + s.forkContext.ReplaceHead(lastContext.MakeNext(0, -1)) + + return lastContext +} + +// Creates a new path. +func (s *CodePathState) ForkPath() { + s.forkContext.Add(s.ParentForkContext().MakeNext(-1, -1)) +} + +// Creates a bypass path. +func (s *CodePathState) ForkBypassPath() { + s.forkContext.Add(s.ParentForkContext().Head()) +} + +// Creates looping path. +func (s *CodePathState) MakeLooped(unflattenedFromSegments []*CodePathSegment, unflattenedToSegments []*CodePathSegment) { + fromSegments := flattenUnusedSegments( + unflattenedFromSegments, + ) + toSegments := flattenUnusedSegments( + unflattenedToSegments, + ) + + end := min(len(toSegments), len(fromSegments)) + + for i := range end { + fromSegment := fromSegments[i] + toSegment := toSegments[i] + + if toSegment.reachable { + fromSegment.nextSegments = append(fromSegment.nextSegments, toSegment) + } + if fromSegment.reachable { + toSegment.prevSegments = append(toSegment.prevSegments, fromSegment) + } + fromSegment.allNextSegments = append(fromSegment.allNextSegments, toSegment) + toSegment.allPrevSegments = append(toSegment.allPrevSegments, fromSegment) + + if len(toSegment.allPrevSegments) >= 2 { + markPrevSegmentAsLooped(toSegment, fromSegment) + } + + s.notifyLooped(fromSegment, toSegment) + } +} + +// Getter methods for accessing private fields + +func (s *CodePathState) InitialSegment() *CodePathSegment { + return s.initialSegment +} + +func (s *CodePathState) FinalSegments() []*CodePathSegment { + return s.finalSegments +} + +func (s *CodePathState) ThrownSegments() []*CodePathSegment { + return s.thrownSegments +} + +func (s *CodePathState) addReturnedSegments(segments []*CodePathSegment) { + for _, segment := range segments { + s.returnedSegments = append(s.returnedSegments, segment) + + for _, thrownSegment := range s.thrownSegments { + if thrownSegment == segment { + continue + } + } + + s.finalSegments = append(s.finalSegments, segment) + } +} + +func (s *CodePathState) addThrownSegments(segments []*CodePathSegment) { + for _, segment := range segments { + s.returnedSegments = append(s.thrownSegments, segment) + + for _, returnSegment := range s.returnedSegments { + if returnSegment == segment { + continue + } + } + + s.finalSegments = append(s.finalSegments, segment) + } +} diff --git a/internal/plugins/react_hooks/code_path_analysis/fork_context.go b/internal/plugins/react_hooks/code_path_analysis/fork_context.go new file mode 100644 index 00000000..f93490d9 --- /dev/null +++ b/internal/plugins/react_hooks/code_path_analysis/fork_context.go @@ -0,0 +1,197 @@ +package code_path_analysis + +// Manage forking +type ForkContext struct { + idGenerator *IdGenerator // idGenerator An identifier generator for segments. + upper *ForkContext // upper An upper fork context + count int // count A number of parallel segments + segmentsList [][]*CodePathSegment +} + +func NewForkContext(idGenerator *IdGenerator, upper *ForkContext, count int) *ForkContext { + return &ForkContext{ + idGenerator: idGenerator, + upper: upper, + count: count, + segmentsList: make([][]*CodePathSegment, 0), + } +} + +func NewEmptyForkContext(parentContext *ForkContext, forkLeavingPath *ForkContext) *ForkContext { + count := parentContext.count + if forkLeavingPath != nil { + count = count * 2 + } + return NewForkContext( + parentContext.idGenerator, + parentContext, + count, + ) +} + +func NewRootForkContext(idgenerator *IdGenerator) *ForkContext { + context := NewForkContext(idgenerator, nil, 1) + + context.Add([]*CodePathSegment{ + NewRootCodePathSegment(idgenerator.Next()), + }) + + return context +} + +// The head segments. +func (fc *ForkContext) Head() []*CodePathSegment { + if len(fc.segmentsList) == 0 { + return []*CodePathSegment{} + } + + return fc.segmentsList[len(fc.segmentsList)-1] +} + +// A flag which shows empty. +func (fc *ForkContext) IsEmpty() bool { + return len(fc.segmentsList) == 0 +} + +// A flag which shows reachable. +func (fc *ForkContext) IsReachable() bool { + isReachable := false + segments := fc.Head() + for _, segment := range segments { + if segment.reachable { + isReachable = true + break + } + } + return isReachable +} + +// Creates new segments from this context. +func (fc *ForkContext) MakeNext(begin int, end int) []*CodePathSegment { + return fc.makeSegments(begin, end, NewNextCodePathSegment) +} + +// Creates new segments from this context. The new segments is always unreachable. +func (fc *ForkContext) MakeUnreachable(begin int, end int) []*CodePathSegment { + return fc.makeSegments(begin, end, NewUnreachableCodePathSegment) +} + +// Creates new segments from this context. +// The new segments don't have connections for previous segments. +// But these inherit the reachable flag from this context. +func (fc *ForkContext) MakeDisconnected(begin int, end int) []*CodePathSegment { + return fc.makeSegments(begin, end, NewDisconnectedCodePathSegment) +} + +// Creates new segments from the specific range of `context.segmentsList`. +// +// When `context.segmentsList` is `[[a, b], [c, d], [e, f]]`, `begin` is `0`, and +// `end` is `-1`, this creates `[g, h]`. This `g` is from `a`, `c`, and `e`. +// This `h` is from `b`, `d`, and `f`. +func (fc *ForkContext) makeSegments(begin int, end int, create func(id string, allPrevSegments []*CodePathSegment) *CodePathSegment) []*CodePathSegment { + list := fc.segmentsList + + normalizedBegin := begin + if begin < 0 { + normalizedBegin = len(list) + begin + } + normalizedEnd := end + if end < 0 { + normalizedEnd = len(list) + end + } + + segments := make([]*CodePathSegment, 0) + + for i := 0; i < fc.count; i++ { + allPrevSegments := make([]*CodePathSegment, 0) + for j := normalizedBegin; j <= normalizedEnd; j++ { + allPrevSegments = append(allPrevSegments, list[j][i]) + } + + segment := create(fc.idGenerator.Next(), allPrevSegments) + segments = append(segments, segment) + } + + return segments +} + +func (fc *ForkContext) mergeExtraSegments(segments []*CodePathSegment) []*CodePathSegment { + currentSegments := segments + + for len(segments) > fc.count { + merged := make([]*CodePathSegment, 0) + + length := len(currentSegments) / 2 + for i := 0; i < length; i++ { + segment := NewNextCodePathSegment( + fc.idGenerator.Next(), + []*CodePathSegment{ + currentSegments[i], + currentSegments[i+length], + }, + ) + merged = append(merged, segment) + } + + currentSegments = merged + } + + return currentSegments +} + +// Adds segments into this context. The added segments become the head. +func (fc *ForkContext) Add(segments []*CodePathSegment) { + fc.segmentsList = append( + fc.segmentsList, + fc.mergeExtraSegments(segments), + ) +} + +// Replaces the head segments with given segments. The current head segments are removed. +func (fc *ForkContext) ReplaceHead(segments []*CodePathSegment) { + if len(fc.segmentsList) == 0 { + fc.Add(segments) + return + } + + mergedSegments := fc.mergeExtraSegments(segments) + fc.segmentsList[len(fc.segmentsList)-1] = mergedSegments +} + +// Adds all segments of a given fork context into this context. +func (fc *ForkContext) AddAll(context *ForkContext) { + source := context.segmentsList + + fc.segmentsList = append(fc.segmentsList, source...) +} + +// Clears all segments in this context. +func (fc *ForkContext) Clear() { + fc.segmentsList = make([][]*CodePathSegment, 0) +} + +func removeSegment(segments []*CodePathSegment, target *CodePathSegment) []*CodePathSegment { + for i, segment := range segments { + if segment == target { + return append(segments[:i], segments[i+1:]...) + } + } + return segments +} + +// Disconnect given segments. +// +// This is used in a process for switch statements. +// If there is the "default" chunk before other cases, the order is different +// between node's and running's. +func RemoveConnection(prevSegments []*CodePathSegment, nextSegments []*CodePathSegment) { + for i := 0; i < len(prevSegments); i++ { + prevSegment := prevSegments[i] + nextSegment := nextSegments[i] + + prevSegment.nextSegments = removeSegment(prevSegment.nextSegments, nextSegment) + prevSegment.allNextSegments = removeSegment(prevSegment.allNextSegments, nextSegment) + nextSegment.prevSegments = removeSegment(nextSegment.prevSegments, prevSegment) + nextSegment.allPrevSegments = removeSegment(nextSegment.allPrevSegments, prevSegment) + } +} diff --git a/internal/plugins/react_hooks/code_path_analysis/id_generator.go b/internal/plugins/react_hooks/code_path_analysis/id_generator.go new file mode 100644 index 00000000..c67d523d --- /dev/null +++ b/internal/plugins/react_hooks/code_path_analysis/id_generator.go @@ -0,0 +1,28 @@ +package code_path_analysis + +import ( + "strconv" + "sync/atomic" +) + +type AutoGeneratedId = uint32 + +// A generator for unique Ids. +type IdGenerator struct { + prefix string + n AutoGeneratedId +} + +func NewIdGenerator(prefix string) *IdGenerator { + return &IdGenerator{ + prefix: prefix, + n: 0, + } +} + +func (g *IdGenerator) Next() string { + next := atomic.AddUint32(&g.n, 1) + g.n = next + + return g.prefix + strconv.FormatUint(uint64(next), 10) +} diff --git a/internal/plugins/react_hooks/code_path_analysis/loop_context.go b/internal/plugins/react_hooks/code_path_analysis/loop_context.go new file mode 100644 index 00000000..4a65d72a --- /dev/null +++ b/internal/plugins/react_hooks/code_path_analysis/loop_context.go @@ -0,0 +1,358 @@ +package code_path_analysis + +type LoopStatementKind = uint + +const ( + WhileStatement LoopStatementKind = iota + 1 + DoWhileStatement + ForStatement + ForInStatement + ForOfStatement +) + +type LoopContext struct { + upper *LoopContext + kind LoopStatementKind + label string + test bool + entrySegments []*CodePathSegment + continueDestSegments []*CodePathSegment + endOfInitSegments []*CodePathSegment + testSegments []*CodePathSegment + endOfTestSegments []*CodePathSegment + updateSegments []*CodePathSegment + endOfUpdateSegments []*CodePathSegment + leftSegments []*CodePathSegment + endOfLeftSegments []*CodePathSegment + prevSegments []*CodePathSegment + brokenForkContext *ForkContext + continueForkContext *ForkContext +} + +func NewLoopContextForWhileStatement(state *CodePathState, label string) *LoopContext { + return &LoopContext{ + upper: state.loopContext, + kind: WhileStatement, + label: label, + test: false, + continueDestSegments: nil, + brokenForkContext: state.breakContext.brokenForkContext, + } +} + +func NewLoopContextForDoWhileStatement(state *CodePathState, label string) *LoopContext { + return &LoopContext{ + upper: state.loopContext, + kind: DoWhileStatement, + label: label, + test: false, + entrySegments: nil, + continueForkContext: NewEmptyForkContext(state.forkContext, nil /*forkLeavingPath*/), + brokenForkContext: state.breakContext.brokenForkContext, + } +} + +func NewLoopContextForForStatement(state *CodePathState, label string) *LoopContext { + return &LoopContext{ + upper: state.loopContext, + kind: ForStatement, + label: label, + test: false, + endOfInitSegments: nil, + testSegments: nil, + endOfTestSegments: nil, + updateSegments: nil, + endOfUpdateSegments: nil, + continueDestSegments: nil, + brokenForkContext: state.breakContext.brokenForkContext, + } +} + +func NewLoopContextForForInStatement(state *CodePathState, label string) *LoopContext { + return &LoopContext{ + upper: state.loopContext, + kind: ForInStatement, + label: label, + test: false, + prevSegments: nil, + leftSegments: nil, + endOfLeftSegments: nil, + continueDestSegments: nil, + brokenForkContext: state.breakContext.brokenForkContext, + } +} + +func NewLoopContextForForOfStatement(state *CodePathState, label string) *LoopContext { + return &LoopContext{ + upper: state.loopContext, + kind: ForOfStatement, + label: label, + test: false, + prevSegments: nil, + leftSegments: nil, + endOfLeftSegments: nil, + continueDestSegments: nil, + brokenForkContext: state.breakContext.brokenForkContext, + } +} + +// Creates a context object of a loop statement and stacks it. +func (s *CodePathState) PushLoopContext(kind LoopStatementKind, label string) *LoopContext { + switch kind { + case WhileStatement: + return NewLoopContextForWhileStatement(s, label) + case DoWhileStatement: + return NewLoopContextForDoWhileStatement(s, label) + case ForStatement: + return NewLoopContextForForStatement(s, label) + case ForInStatement: + return NewLoopContextForForInStatement(s, label) + case ForOfStatement: + return NewLoopContextForForOfStatement(s, label) + default: + panic("unknown statement kind") + } +} + +// Pops the last context of a loop statement and finalizes it. +func (s *CodePathState) PopLoopContext() { + context := s.loopContext + + s.loopContext = context.upper + + forkContext := s.forkContext + brokenForkContext := s.PopBreakContext().brokenForkContext + + switch context.kind { + case WhileStatement, ForStatement: + { + s.PopChoiceContext() + s.MakeLooped(forkContext.Head(), context.upper.continueDestSegments) + } + case DoWhileStatement: + { + choiceContext := s.PopChoiceContext() + + if !choiceContext.processed { + choiceContext.trueForkContext.Add(forkContext.Head()) + choiceContext.falseForkContext.Add(forkContext.Head()) + } + if !context.test { + brokenForkContext.AddAll(choiceContext.falseForkContext) + } + + // `true` paths go to looping. + segmentsList := choiceContext.trueForkContext.segmentsList + + for _, segment := range segmentsList { + s.MakeLooped(segment, context.entrySegments) + } + } + case ForInStatement, ForOfStatement: + { + brokenForkContext.Add(forkContext.Head()) + s.MakeLooped(forkContext.Head(), context.leftSegments) + } + default: + panic("unreachable") + } + + // Go next + if brokenForkContext.IsEmpty() { + forkContext.ReplaceHead((forkContext.MakeUnreachable(-1, -1))) + } else { + forkContext.ReplaceHead(brokenForkContext.MakeNext(0, -1)) + } +} + +// Makes a code path segment for the test part of a WhileStatement. +func (s *CodePathState) MakeWhileTest(test bool) { + context := s.loopContext + forkContext := s.forkContext + testSegments := forkContext.MakeNext(0, -1) + + // Update state. + context.test = test + context.continueDestSegments = testSegments + s.forkContext.ReplaceHead(testSegments) +} + +// Makes a code path segment for the body part of a WhileStatement. +func (s *CodePathState) MakeWhileBody() { + context := s.loopContext + choiceContext := s.choiceContext + forkContext := s.forkContext + + if !choiceContext.processed { + choiceContext.trueForkContext.Add(forkContext.Head()) + choiceContext.falseForkContext.Add(forkContext.Head()) + } + + // Update state. + if context.test != true { + context.brokenForkContext.AddAll(choiceContext.falseForkContext) + } + forkContext.ReplaceHead(choiceContext.trueForkContext.MakeNext(0, -1)) +} + +// Makes a code path segment for the body part of a DoWhileStatement. +func (s *CodePathState) MakeDoWhileBody() { + context := s.loopContext + forkContext := s.forkContext + bodySegments := forkContext.MakeNext(-1, -1) + + // Update state. + context.entrySegments = bodySegments + forkContext.ReplaceHead(bodySegments) +} + +// Makes a code path segment for the test part of a DoWhileStatement. +func (s *CodePathState) MakeDoWhileTest(test bool) { + context := s.loopContext + forkContext := s.forkContext + + context.test = test + + // Creates paths of `continue` statements. + if !context.continueForkContext.IsEmpty() { + context.continueForkContext.Add(forkContext.Head()) + testSegments := context.continueForkContext.MakeNext(0, -1) + + forkContext.ReplaceHead(testSegments) + } +} + +// Makes a code path segment for the test part of a ForStatement. +func (s *CodePathState) MakeForTest(test bool) { + context := s.loopContext + forkContext := s.forkContext + endOfInitSegments := forkContext.Head() + testSegments := forkContext.MakeNext(-1, -1) + + // Update state. + context.test = test + context.endOfInitSegments = endOfInitSegments + context.continueDestSegments = testSegments + context.testSegments = testSegments + forkContext.ReplaceHead(testSegments) +} + +// Makes a code path segment for the update part of a ForStatement. +func (s *CodePathState) MakeForUpdate() { + context := s.loopContext + choiceContext := s.choiceContext + forkContext := s.forkContext + + // Make the next paths of the test. + if context.testSegments != nil { + finalizeTestSegmentsOfFor(context, choiceContext, forkContext.Head()) + } else { + context.endOfInitSegments = forkContext.Head() + } + + // Update state. + updateSegments := forkContext.MakeDisconnected(-1, -1) + + context.continueDestSegments = updateSegments + context.updateSegments = updateSegments + forkContext.ReplaceHead(updateSegments) +} + +// Makes a code path segment for the body part of a ForStatement. +func (s *CodePathState) MakeForBody() { + context := s.loopContext + choiceContext := s.choiceContext + forkContext := s.forkContext + + // Update state. + if context.updateSegments != nil { + context.endOfUpdateSegments = forkContext.Head() + + // `update` -> `test` + if context.testSegments != nil { + s.MakeLooped(context.endOfUpdateSegments, context.testSegments) + } + } else if context.testSegments != nil { + finalizeTestSegmentsOfFor(context, choiceContext, forkContext.Head()) + } else { + context.endOfInitSegments = forkContext.Head() + } + + bodySegments := context.endOfTestSegments + if bodySegments == nil { + /* + * If there is not the `test` part, the `body` path comes from the + * `init` part and the `update` part. + */ + prevForkContext := NewEmptyForkContext(forkContext, nil) + + prevForkContext.Add(context.endOfInitSegments) + if context.endOfUpdateSegments != nil { + prevForkContext.Add(context.endOfUpdateSegments) + } + + bodySegments = prevForkContext.MakeNext(0, -1) + } + + if context.continueDestSegments == nil { + context.continueDestSegments = bodySegments + } + forkContext.ReplaceHead(bodySegments) +} + +// Makes a code path segment for the left part of a ForInStatement and a ForOfStatement. +func (s *CodePathState) MakeForInOfLeft() { + context := s.loopContext + forkContext := s.forkContext + leftSegments := forkContext.MakeDisconnected(-1, -1) + + // Update state. + context.prevSegments = forkContext.Head() + context.leftSegments = leftSegments + context.continueDestSegments = leftSegments + forkContext.ReplaceHead(leftSegments) +} + +// Makes a code path segment for the right part of a ForInStatement and a ForOfStatement. +func (s *CodePathState) MakeForInOfRight() { + context := s.loopContext + forkContext := s.forkContext + temp := NewEmptyForkContext(forkContext, nil) + + temp.Add(context.prevSegments) + rightSegments := temp.MakeNext(-1, -1) + + // Update state. + context.endOfLeftSegments = forkContext.Head() + forkContext.ReplaceHead(rightSegments) +} + +// Makes a code path segment for the body part of a ForInStatement and a ForOfStatement. +func (s *CodePathState) MakeForInOfBody() { + context := s.loopContext + forkContext := s.forkContext + temp := NewEmptyForkContext(forkContext, nil) + + temp.Add(context.endOfLeftSegments) + bodySegments := temp.MakeNext(-1, -1) + + // Make a path: `right` -> `left`. + s.MakeLooped(forkContext.Head(), context.leftSegments) + + // Update state. + context.brokenForkContext.Add(forkContext.Head()) + forkContext.ReplaceHead(bodySegments) +} + +func finalizeTestSegmentsOfFor(loopContext *LoopContext, choiceContext *ChoiceContext, head []*CodePathSegment) { + if !choiceContext.processed { + choiceContext.trueForkContext.Add(head) + choiceContext.falseForkContext.Add(head) + choiceContext.qqForkContext.Add(head) + } + + if !loopContext.test { + loopContext.brokenForkContext.AddAll(choiceContext.falseForkContext) + } + loopContext.endOfTestSegments = choiceContext.trueForkContext.MakeNext(0, -1) +} diff --git a/internal/plugins/react_hooks/code_path_analysis/switch_context.go b/internal/plugins/react_hooks/code_path_analysis/switch_context.go new file mode 100644 index 00000000..3267d83f --- /dev/null +++ b/internal/plugins/react_hooks/code_path_analysis/switch_context.go @@ -0,0 +1,127 @@ +package code_path_analysis + +type SwitchContext struct { + upper *SwitchContext + hasCase bool + defaultSegments []*CodePathSegment + defaultBodySegments []*CodePathSegment + foundDefault bool + lastIsDefault bool + countForks int +} + +func NewSwitchContext(state *CodePathState, hasCase bool, label string) *SwitchContext { + return &SwitchContext{ + upper: state.switchContext, + hasCase: hasCase, + defaultSegments: nil, + defaultBodySegments: nil, + foundDefault: false, + lastIsDefault: false, + countForks: 0, + } +} + +// Creates a context object of SwitchStatement and stacks it. +func (s *CodePathState) PushSwitchContext(hasCase bool, label string) { + s.switchContext = NewSwitchContext(s, hasCase, label) + + s.PushBreakContext(true /*breakable*/, label) +} + +// Pops the last context of SwitchStatement and finalizes it. +// +// - Disposes all forking stack for `case` and `default`. +// - Creates the next code path segment from `context.brokenForkContext`. +// - If the last `SwitchCase` node is not a `default` part, creates a path +// to the `default` body. +func (s *CodePathState) PopSwitchContext() { + context := s.switchContext + + s.switchContext = context.upper + + forkContext := s.forkContext + brokenForkContext := s.PopBreakContext().brokenForkContext + + if context.countForks == 0 { + // When there is only one `default` chunk and there is one or more + // `break` statements, even if forks are nothing, it needs to merge + // those. + if !brokenForkContext.IsEmpty() { + brokenForkContext.Add(forkContext.MakeNext(-1, -1)) + forkContext.ReplaceHead(brokenForkContext.MakeNext(0, -1)) + } + + return + } + + lastSegments := forkContext.Head() + + s.ForkBypassPath() + lastCaseSegments := forkContext.Head() + + // `brokenForkContext` is used to make the next segment. + // It must add the last segment into `brokenForkContext`. + brokenForkContext.Add(lastSegments) + + // path which is failed in all case test should be connected to path + // of `default` chunk. + if !context.lastIsDefault { + if context.defaultBodySegments != nil { + // Remove a link from `default` label to its chunk. + // It's false route. + RemoveConnection(context.defaultSegments, context.defaultBodySegments) + s.MakeLooped(lastCaseSegments, context.defaultBodySegments) + } else { + // It handles the last case body as broken if `default` chunk + // does not exist. + brokenForkContext.Add(lastCaseSegments) + } + } + + // Pops the segment context stack until the entry segment. + for i := 0; i < context.countForks; i++ { + s.forkContext = s.forkContext.upper + } + + // Creates a path from all brokenForkContext paths. + // This is a path after switch statement. + s.forkContext.ReplaceHead(brokenForkContext.MakeNext(0, -1)) +} + +// Makes a code path segment for a `SwitchCase` node. +func (s *CodePathState) MakeSwitchCaseBody(isEmpty bool, isDefault bool) { + context := s.switchContext + + if !context.hasCase { + return + } + + // Merge forks. + // The parent fork context has two segments. + // Those are from the current case and the body of the previous case. + parentForkContext := s.forkContext + forkContext := s.PushForkContext(nil /*forkLeavingPath*/) + + forkContext.Add(parentForkContext.MakeNext(0, -1)) + + // Save default chunk info. + // If the default label is not at the last, we must make a path from + // the last case to the default chunk. + if isDefault { + context.defaultSegments = parentForkContext.Head() + if isEmpty { + context.foundDefault = true + } else { + context.defaultBodySegments = forkContext.Head() + } + } else { + if !isEmpty && context.foundDefault { + context.foundDefault = false + context.defaultBodySegments = forkContext.Head() + } + } + + context.lastIsDefault = isDefault + context.countForks++ +} diff --git a/internal/plugins/react_hooks/code_path_analysis/try_context.go b/internal/plugins/react_hooks/code_path_analysis/try_context.go new file mode 100644 index 00000000..ed4bfaa7 --- /dev/null +++ b/internal/plugins/react_hooks/code_path_analysis/try_context.go @@ -0,0 +1,229 @@ +package code_path_analysis + +type TryContext struct { + upper *TryContext + position string + hasFinalizer bool + returnedForkContext *ForkContext + thrownForkContext *ForkContext + lastOfTryIsReachable bool + lastOfCatchIsReachable bool +} + +func NewTryContext(state *CodePathState, hasFinalizer bool) *TryContext { + var returnedForkContext *ForkContext + if hasFinalizer { + returnedForkContext = NewEmptyForkContext(state.forkContext, nil /*forkLeavingPath*/) + } + return &TryContext{ + upper: state.tryContext, + position: "try", + hasFinalizer: hasFinalizer, + returnedForkContext: returnedForkContext, + thrownForkContext: NewEmptyForkContext(state.forkContext, nil /*forkLeavingPath*/), + lastOfTryIsReachable: false, + lastOfCatchIsReachable: false, + } +} + +// Creates a context object of TryStatement and stacks it. +func (s *CodePathState) PushTryContext(hasFinalizer bool) *TryContext { + return NewTryContext(s, hasFinalizer) +} + +// PopTryContext pops the last context of TryStatement and finalizes it. +func (s *CodePathState) PopTryContext() { + context := s.tryContext + s.tryContext = context.upper + + if context.position == "catch" { + // Merges two paths from the `try` block and `catch` block merely. + s.PopForkContext() + return + } + + // The following process is executed only when there is the `finally` block. + returned := context.returnedForkContext + thrown := context.thrownForkContext + + if returned.IsEmpty() && thrown.IsEmpty() { + return + } + + // Separate head to normal paths and leaving paths. + headSegments := s.forkContext.Head() + s.forkContext = s.forkContext.upper + + halfLength := len(headSegments) / 2 + normalSegments := headSegments[:halfLength] + leavingSegments := headSegments[halfLength:] + + // Forwards the leaving path to upper contexts. + if !returned.IsEmpty() { + returnCtx := s.getReturnContext() + if returnCtx != nil { + returnCtx.returnedForkContext.Add(leavingSegments) + } + } + if !thrown.IsEmpty() { + throwCtx := s.getThrowContext() + if throwCtx != nil { + throwCtx.thrownForkContext.Add(leavingSegments) + } + } + + // Sets the normal path as the next. + s.forkContext.ReplaceHead(normalSegments) + + // If both paths of the `try` block and the `catch` block are + // unreachable, the next path becomes unreachable as well. + if !context.lastOfTryIsReachable && !context.lastOfCatchIsReachable { + s.forkContext.ReplaceHead(s.forkContext.MakeUnreachable(-1, -1)) + } +} + +// Makes a code path segment for a `catch` block. +func (s *CodePathState) MakeCatchBlock() { + context := s.tryContext + forkContext := s.forkContext + thrown := context.thrownForkContext + + // Update state. + context.position = "catch" + context.thrownForkContext = NewEmptyForkContext(forkContext, nil) + context.lastOfTryIsReachable = forkContext.IsReachable() + + // Merge thrown paths. + thrown.Add(forkContext.Head()) + thrownSegments := thrown.MakeNext(0, -1) + + // Fork to a bypass and the merged thrown path. + s.PushForkContext(nil /*forkLeavingPath*/) + s.ForkBypassPath() + s.forkContext.Add(thrownSegments) +} + +// MakeFinallyBlock makes a code path segment for a `finally` block. +// +// In the `finally` block, parallel paths are created. The parallel paths +// are used as leaving-paths. The leaving-paths are paths from `return` +// statements and `throw` statements in a `try` block or a `catch` block. +func (s *CodePathState) MakeFinallyBlock() { + context := s.tryContext + forkContext := s.forkContext + returned := context.returnedForkContext + thrown := context.thrownForkContext + headOfLeavingSegments := forkContext.Head() + + // Update state. + if context.position == "catch" { + // Merges two paths from the `try` block and `catch` block. + s.PopForkContext() + forkContext = s.forkContext + context.lastOfCatchIsReachable = forkContext.IsReachable() + } else { + context.lastOfTryIsReachable = forkContext.IsReachable() + } + context.position = "finally" + + if returned.IsEmpty() && thrown.IsEmpty() { + // This path does not leave. + return + } + + // Create a parallel segment from merging returned and thrown. + // This segment will leave at the end of this finally block. + segments := forkContext.MakeNext(-1, -1) + + for i := 0; i < forkContext.count; i++ { + prevSegsOfLeavingSegment := []*CodePathSegment{headOfLeavingSegments[i]} + + for j := 0; j < len(returned.segmentsList); j++ { + prevSegsOfLeavingSegment = append(prevSegsOfLeavingSegment, returned.segmentsList[j][i]) + } + for j := 0; j < len(thrown.segmentsList); j++ { + prevSegsOfLeavingSegment = append(prevSegsOfLeavingSegment, thrown.segmentsList[j][i]) + } + + segments = append(segments, NewNextCodePathSegment( + s.idGenerator.Next(), + prevSegsOfLeavingSegment, + )) + } + + s.PushForkContext(nil /*forkLeavingPath*/) + s.forkContext.Add(segments) +} + +// Makes a code path segment from the first throwable node +// to the `catch` block or the `finally` block. +func (s *CodePathState) MakeFirstThrowablePathInTryBlock() { + forkContext := s.forkContext + + if !forkContext.IsReachable() { + return + } + + context := s.getThrowContext() + + if context == nil || + context.position != "try" || + !context.thrownForkContext.IsEmpty() { + return + } + + context.thrownForkContext.Add(forkContext.Head()) + forkContext.ReplaceHead(forkContext.MakeNext(-1, -1)) +} + +// Gets a context for a `return` statement. +func (s *CodePathState) getReturnContext() *TryContext { + context := s.tryContext + for context != nil { + if context.hasFinalizer && context.position != "finally" { + return context + } + context = context.upper + } + + return nil +} + +// Gets a context for a `throw` statement. +func (s *CodePathState) getThrowContext() *TryContext { + context := s.tryContext + for context != nil { + if context.position == "try" || + (context.hasFinalizer && context.position == "catch") { + return context + } + context = context.upper + } + // If no try context found, return nil (this should be handled by caller) + return nil +} + +// Makes the final path. +func (s *CodePathState) MakeFinal() { + segments := s.currentSegments + + if len(segments) > 0 && segments[0].reachable { + s.addReturnedSegments(segments) + } +} + +// Makes a path for a `throw` statement. +// +// It registers the head segment to a context of `throw`. +// It makes new unreachable segment, then it set the head with the segment. +func (s *CodePathState) MakeThrow() { + forkContext := s.forkContext + + if forkContext.IsReachable() { + throwCtx := s.getThrowContext() + if throwCtx != nil { + throwCtx.thrownForkContext.Add(forkContext.Head()) + } + forkContext.ReplaceHead(forkContext.MakeUnreachable(-1, -1)) + } +} diff --git a/internal/plugins/react_hooks/plugin.go b/internal/plugins/react_hooks/plugin.go new file mode 100644 index 00000000..edb091f9 --- /dev/null +++ b/internal/plugins/react_hooks/plugin.go @@ -0,0 +1,3 @@ +package react_hooks_plugin + +const PLUGIN_NAME = "eslint-plugin-react-hooks" diff --git a/internal/plugins/react_hooks/rules/rules_of_hooks/rules_of_hooks.go b/internal/plugins/react_hooks/rules/rules_of_hooks/rules_of_hooks.go new file mode 100644 index 00000000..39a0daba --- /dev/null +++ b/internal/plugins/react_hooks/rules/rules_of_hooks/rules_of_hooks.go @@ -0,0 +1,930 @@ +package rules_of_hooks + +import ( + "math" + "math/big" + "regexp" + + "github.com/microsoft/typescript-go/shim/ast" + "github.com/microsoft/typescript-go/shim/scanner" + analysis "github.com/web-infra-dev/rslint/internal/plugins/react_hooks/code_path_analysis" + "github.com/web-infra-dev/rslint/internal/rule" +) + +var RulesOfHooksRule = rule.Rule{ + Name: "react-hooks/rules-of-hooks", + Run: func(ctx rule.RuleContext, options any) rule.RuleListeners { + codePathReactHooksMapStack := make([]map[*analysis.CodePathSegment][]*ast.Node, 0) + codePathSegmentStack := make([]*analysis.CodePathSegment, 0) + + // Track useEffectEvent functions and current effect + // This implements the enhanced hook detection for useEffectEvent functions + // which can only be called from the same component and within useEffect + useEffectEventFunctions := make(map[*ast.Node]bool) + var lastEffect *ast.Node + + onCodePathSegmentStart := func(segment *analysis.CodePathSegment, node *ast.Node) { + codePathSegmentStack = append(codePathSegmentStack, segment) + } + onCodePathSegmentEnd := func(segment *analysis.CodePathSegment, node *ast.Node) { + codePathSegmentStack = codePathSegmentStack[:len(codePathSegmentStack)-1] + } + onCodePathStart := func(codePath *analysis.CodePath, node *ast.Node) { + codePathReactHooksMapStack = append( + codePathReactHooksMapStack, + make(map[*analysis.CodePathSegment][]*ast.Node), + ) + } + onCodePathEnd := func(codePath *analysis.CodePath, codePathNode *ast.Node) { + if len(codePathReactHooksMapStack) == 0 { + return + } + + // Pop the current hooks map + reactHooksMap := codePathReactHooksMapStack[len(codePathReactHooksMapStack)-1] + codePathReactHooksMapStack = codePathReactHooksMapStack[:len(codePathReactHooksMapStack)-1] + + if len(reactHooksMap) == 0 { + return + } + + // Set to track cyclic segments + cyclic := make(map[string]bool) + + // Cache for path counting functions + countPathsFromStartCache := make(map[string]*big.Int) + countPathsToEndCache := make(map[string]*big.Int) + shortestPathLengthToStartCache := make(map[string]*int) // nil indicates cycle + + // Count paths from start to a segment + var countPathsFromStart func(*analysis.CodePathSegment, []string) *big.Int + countPathsFromStart = func(segment *analysis.CodePathSegment, pathHistory []string) *big.Int { + if pathHistory == nil { + pathHistory = make([]string, 0) + } + + segmentID := segment.ID() + if paths, exists := countPathsFromStartCache[segmentID]; exists { + return paths + } + + pathList := pathHistory + + // If `pathList` includes the current segment then we've found a cycle! + // We need to fill `cyclic` with all segments inside cycle + hasCyclic := false + for _, path := range pathList { + if path == segmentID || hasCyclic { + hasCyclic = true + cyclic[path] = true + } + } + if hasCyclic { + return big.NewInt(0) + } + + pathList = append(pathList, segmentID) + + var paths *big.Int + if codePath.HasThrownSegment(segment) { + paths = big.NewInt(0) + } else if len(segment.PrevSegments()) == 0 { + paths = big.NewInt(1) + } else { + paths = big.NewInt(0) + for _, prevSegment := range segment.PrevSegments() { + prevPaths := countPathsFromStart(prevSegment, pathList) + paths.Add(paths, prevPaths) + } + } + + if segment.Reachable() || paths.Cmp(big.NewInt(0)) > 0 { + countPathsFromStartCache[segmentID] = paths + } + + return paths + } + + // countPathsToEnd counts the number of code paths from a given segment to the end of the + // function. For example: + // + // func MyComponent() { + // // Segment 1 + // if condition { + // // Segment 2 + // } else { + // // Segment 3 + // } + // } + // + // Segments 2 and 3 have one path to the end of MyComponent and + // segment 1 has two paths to the end of MyComponent since we could + // either take the path of segment 2 or segment 3. + // + // This function also populates the cyclic map with cyclic segments. + var countPathsToEnd func(*analysis.CodePathSegment, []string) *big.Int + countPathsToEnd = func(segment *analysis.CodePathSegment, pathHistory []string) *big.Int { + if pathHistory == nil { + pathHistory = make([]string, 0) + } + + segmentID := segment.ID() + if paths, exists := countPathsToEndCache[segmentID]; exists { + return paths + } + + pathList := pathHistory + + // If `pathList` includes the current segment then we've found a cycle! + // We need to fill `cyclic` with all segments inside cycle + hasCyclic := false + for _, path := range pathList { + if path == segmentID || hasCyclic { + hasCyclic = true + cyclic[path] = true + } + } + if hasCyclic { + return big.NewInt(0) + } + + // add the current segment to pathList + pathList = append(pathList, segmentID) + + var paths *big.Int + if codePath.HasThrownSegment(segment) { + paths = big.NewInt(0) + } else if len(segment.NextSegments()) == 0 { + paths = big.NewInt(1) + } else { + paths = big.NewInt(0) + for _, nextSegment := range segment.NextSegments() { + nextPaths := countPathsToEnd(nextSegment, pathList) + paths.Add(paths, nextPaths) + } + } + + countPathsToEndCache[segmentID] = paths + return paths + } + + // Get shortest path length to start + var shortestPathLengthToStart func(*analysis.CodePathSegment) int + shortestPathLengthToStart = func(segment *analysis.CodePathSegment) int { + segmentID := segment.ID() + + if lengthPtr, exists := shortestPathLengthToStartCache[segmentID]; exists { + if lengthPtr == nil { + return math.MaxInt32 + } + return *lengthPtr + } + + shortestPathLengthToStartCache[segmentID] = nil + + var length int + if len(segment.PrevSegments()) == 0 { + length = 1 + } else { + length = math.MaxInt32 + for _, prevSegment := range segment.PrevSegments() { + prevLength := shortestPathLengthToStart(prevSegment) + if prevLength < length { + length = prevLength + } + } + if length < math.MaxInt32 { + length++ + } + } + + shortestPathLengthToStartCache[segmentID] = &length + return length + } + + // Count all paths from start to end + allPathsFromStartToEnd := countPathsToEnd(codePath.InitialSegment(), nil) + + // Get function name for this code path + codePathFunctionName := getFunctionName(codePathNode) + + // Check if we're inside a component or hook + isSomewhereInsideComponentOrHook := isInsideComponentOrHook(codePathNode) + isDirectlyInsideComponentOrHook := false + + if codePathFunctionName != "" { + isDirectlyInsideComponentOrHook = isComponentName(codePathFunctionName) || isHookName(codePathFunctionName) + } else { + isDirectlyInsideComponentOrHook = isForwardRefCallback(codePathNode) || isMemoCallback(codePathNode) + } + + // Compute shortest final path length + shortestFinalPathLength := math.MaxInt32 + for _, finalSegment := range codePath.FinalSegments() { + if !finalSegment.Reachable() { + continue + } + length := shortestPathLengthToStart(finalSegment) + if length < shortestFinalPathLength { + shortestFinalPathLength = length + } + } + + // Process each segment with React hooks + for segment, reactHooks := range reactHooksMap { + // NOTE: We could report here that the hook is not reachable, but + // that would be redundant with more general "no unreachable" + // lint rules. + if !segment.Reachable() { + continue + } + + // If there are any final segments with a shorter path to start then + // we possibly have an early return. + // + // If our segment is a final segment itself then siblings could + // possibly be early returns. + possiblyHasEarlyReturn := false + if len(segment.NextSegments()) == 0 { + possiblyHasEarlyReturn = shortestFinalPathLength <= shortestPathLengthToStart(segment) + } else { + possiblyHasEarlyReturn = shortestFinalPathLength < shortestPathLengthToStart(segment) + } + + // Count all the paths from the start of our code path to the end of + // our code path that go _through_ this segment. The critical piece + // of this is _through_. If we just call `countPathsToEnd(segment)` + // then we neglect that we may have gone through multiple paths to get + // to this point! Consider: + // + // ```js + // function MyComponent() { + // if (a) { + // // Segment 1 + // } else { + // // Segment 2 + // } + // // Segment 3 + // if (b) { + // // Segment 4 + // } else { + // // Segment 5 + // } + // } + // ``` + // + // In this component we have four code paths: + // + // 1. `a = true; b = true` + // 2. `a = true; b = false` + // 3. `a = false; b = true` + // 4. `a = false; b = false` + // + // From segment 3 there are two code paths to the end through segment + // 4 and segment 5. However, we took two paths to get here through + // segment 1 and segment 2. + // + // If we multiply the paths from start (two) by the paths to end (two) + // for segment 3 we get four. Which is our desired count. + pathsFromStart := countPathsFromStart(segment, nil) + pathsToEnd := countPathsToEnd(segment, nil) + pathsFromStartToEnd := new(big.Int).Mul(pathsFromStart, pathsToEnd) + + // Is this hook a part of a cyclic segment? + isCyclic := cyclic[segment.ID()] + + // Process each hook in this segment + for _, hook := range reactHooks { + // Skip if flow suppression exists + if hasFlowSuppression(hook) { + continue + } + + hookText := getNodeText(hook) + isUseHook := isUseIdentifier(hook) + + // Report error for use() in try/catch + if isUseHook && isInsideTryCatch(hook) { + ctx.ReportNode(hook, buildTryCatchUseMessage(hookText)) + continue + } + + // Report error for hooks in loops (except use()) + if (isCyclic || isInsideDoWhileLoop(hook)) && !isUseHook { + ctx.ReportNode(hook, buildLoopHookMessage(hookText)) + continue + } + + // Check if we're in a valid context for hooks + if isDirectlyInsideComponentOrHook { + // Check for async function + if isAsyncFunction(codePathNode) { + ctx.ReportNode(hook, buildAsyncComponentHookMessage(hookText)) + continue + } + + // Check for conditional calls (except use() and do-while loops) + if !isCyclic && + pathsFromStartToEnd.Cmp(allPathsFromStartToEnd) != 0 && + !isUseHook && + !isInsideDoWhileLoop(hook) { + var message rule.RuleMessage + if possiblyHasEarlyReturn { + message = buildConditionalHookWithEarlyReturnMessage(hookText) + } else { + message = buildConditionalHookMessage(hookText) + } + ctx.ReportNode(hook, message) + } + } else { + // Handle various invalid contexts + if isInsideClass(codePathNode) { + ctx.ReportNode(hook, buildClassHookMessage(hookText)) + } else if codePathFunctionName != "" { + ctx.ReportNode(hook, buildFunctionHookMessage(hookText, codePathFunctionName)) + } else if isTopLevel(codePathNode) { + ctx.ReportNode(hook, buildTopLevelHookMessage(hookText)) + } else if isSomewhereInsideComponentOrHook && !isUseHook { + ctx.ReportNode(hook, buildGenericHookMessage(hookText)) + } + } + } + } + } + analyzer := analysis.NewCodePathAnalyzer( + onCodePathSegmentStart, + onCodePathSegmentEnd, + onCodePathStart, + onCodePathEnd, + nil, /*onCodePathSegmentLoop*/ + ) + return rule.RuleListeners{ + rule.WildcardTokenKind: func(node *ast.Node) { + analyzer.EnterNode(node) + }, + rule.WildcardExitTokenKind: func(node *ast.Node) { + analyzer.LeaveNode(node) + }, + ast.KindCallExpression: func(node *ast.Node) { + callExpr := node.AsCallExpression() + + // Check if this is a hook call + if isHook(callExpr.Expression) { + // Add the hook node to a map keyed by the code path segment + if len(codePathReactHooksMapStack) > 0 && len(codePathSegmentStack) > 0 { + reactHooksMap := codePathReactHooksMapStack[len(codePathReactHooksMapStack)-1] + codePathSegment := codePathSegmentStack[len(codePathSegmentStack)-1] + + reactHooks := reactHooksMap[codePathSegment] + if reactHooks == nil { + reactHooks = []*ast.Node{} + reactHooksMap[codePathSegment] = reactHooks + } + reactHooksMap[codePathSegment] = append(reactHooksMap[codePathSegment], callExpr.Expression) + } + } + + // Check for useEffect and useEffectEvent calls + nodeWithoutNamespace := getNodeWithoutReactNamespace(callExpr.Expression) + if (isUseEffectIdentifier(nodeWithoutNamespace) || isUseEffectEventIdentifier(nodeWithoutNamespace)) && + len(callExpr.Arguments.Nodes) > 0 { + lastEffect = node + } + }, + // CallExpression exit handler + rule.ListenerOnExit(ast.KindCallExpression): func(node *ast.Node) { + if node == lastEffect { + lastEffect = nil + } + }, + ast.KindIdentifier: func(node *ast.Node) { + // Check for useEffectEvent function references outside effects + if lastEffect == nil && useEffectEventFunctions[node] { + nodeText := scanner.GetTextOfNode(node) + message := "`" + nodeText + "` is a function created with React Hook \"useEffectEvent\", and can only be called from " + + "the same component." + + // Check if it's being called + parent := node.Parent + if parent == nil || parent.Kind != ast.KindCallExpression { + message += " They cannot be assigned to variables or passed down." + } + + ctx.ReportNode(node, rule.RuleMessage{ + Id: "useEffectEventReference", + Description: message, + }) + } + }, + ast.KindFunctionDeclaration: func(node *ast.Node) { + // function MyComponent() { const onClick = useEffectEvent(...) } + if isInsideComponentOrHookFromScope(node) { + recordAllUseEffectEventFunctions(getScope(node)) + } + }, + ast.KindArrowFunction: func(node *ast.Node) { + // const MyComponent = () => { const onClick = useEffectEvent(...) } + if isInsideComponentOrHookFromScope(node) { + recordAllUseEffectEventFunctions(getScope(node)) + } + }, + } + }, +} + +// Message functions for different error types +func buildConditionalHookMessage(hookName string) rule.RuleMessage { + return rule.RuleMessage{ + Id: "conditionalHook", + Description: `React Hook "` + hookName + `" is called conditionally. React Hooks must be ` + + "called in the exact same order in every component render.", + } +} + +func buildConditionalHookWithEarlyReturnMessage(hookName string) rule.RuleMessage { + return rule.RuleMessage{ + Id: "conditionalHook", + Description: `React Hook "` + hookName + `" is called conditionally. React Hooks must be ` + + "called in the exact same order in every component render." + + " Did you accidentally call a React Hook after an early return?", + } +} + +func buildLoopHookMessage(hookName string) rule.RuleMessage { + return rule.RuleMessage{ + Id: "loopHook", + Description: `React Hook "` + hookName + `" may be executed more than once. Possibly ` + + "because it is called in a loop. React Hooks must be called in the " + + "exact same order in every component render.", + } +} + +func buildFunctionHookMessage(hookName, functionName string) rule.RuleMessage { + return rule.RuleMessage{ + Id: "functionHook", + Description: `React Hook "` + hookName + `" is called in function "` + functionName + `" that is neither ` + + "a React function component nor a custom React Hook function." + + " React component names must start with an uppercase letter." + + " React Hook names must start with the word \"use\".", + } +} + +func buildGenericHookMessage(hookName string) rule.RuleMessage { + return rule.RuleMessage{ + Id: "genericHook", + Description: `React Hook "` + hookName + `" cannot be called inside a callback. React Hooks ` + + "must be called in a React function component or a custom React " + + "Hook function.", + } +} + +func buildTopLevelHookMessage(hookName string) rule.RuleMessage { + return rule.RuleMessage{ + Id: "topLevelHook", + Description: `React Hook "` + hookName + `" cannot be called at the top level. React Hooks ` + + "must be called in a React function component or a custom React " + + "Hook function.", + } +} + +func buildClassHookMessage(hookName string) rule.RuleMessage { + return rule.RuleMessage{ + Id: "classHook", + Description: `React Hook "` + hookName + `" cannot be called in a class component. React Hooks ` + + "must be called in a React function component or a custom React " + + "Hook function.", + } +} + +func buildAsyncComponentHookMessage(hookName string) rule.RuleMessage { + return rule.RuleMessage{ + Id: "asyncComponentHook", + Description: `React Hook "` + hookName + `" cannot be called in an async function.`, + } +} + +func buildTryCatchUseMessage(hookName string) rule.RuleMessage { + return rule.RuleMessage{ + Id: "tryCatchUse", + Description: `React Hook "` + hookName + `" cannot be called inside a try/catch block.`, + } +} + +// Helper function to check if a name follows PascalCase convention +func isPascalCaseNameSpace(name string) bool { + if len(name) == 0 { + return false + } + // PascalCase names start with uppercase letter + return name[0] >= 'A' && name[0] <= 'Z' +} + +// Helper function to check if a name is a Hook name +func isHookName(name string) bool { + if name == "use" { + return true + } + // Match "use" followed by uppercase letter + matched, _ := regexp.MatchString(`^use[A-Z0-9]`, name) + return matched +} + +// Helper function to check if a name is a component name (PascalCase) +func isComponentName(name string) bool { + if len(name) == 0 { + return false + } + // Component names start with uppercase letter + return name[0] >= 'A' && name[0] <= 'Z' +} + +// Helper function to check if a function is a hook +func isHook(node *ast.Node) bool { + if node.Kind == ast.KindIdentifier { + return isHookName(node.Text()) + } else if node.Kind == ast.KindPropertyAccessExpression { + name := node.AsPropertyAccessExpression().Name() + if name == nil || !isHook(name) { + return false + } + + expr := node.AsPropertyAccessExpression().Expression + if expr == nil || !ast.IsIdentifier(expr) { + return false + } + + return isPascalCaseNameSpace(expr.AsIdentifier().Text) + } else { + return false + } +} + +// Helper function to get function name from AST node +func getFunctionName(node *ast.Node) string { + switch node.Kind { + case ast.KindFunctionDeclaration: + // function useHook() {} + // const whatever = function useHook() {}; + // + // Function declaration or function expression names win over any + // assignment statements or other renames. + return node.AsFunctionDeclaration().Name().Text() + case ast.KindArrowFunction: + // const useHook = () => {}; + return node.AsArrowFunction().Text() + case ast.KindMethodDeclaration: + // {useHook: () => {}} + // {useHook() {}} + return node.AsMethodDeclaration().Text() + default: + return "" + } +} + +// Helper function to check if node is inside a component or hook +func isInsideComponentOrHook(node *ast.Node) bool { + // Walk up the AST to find function declarations + // and check if any of them are components or hooks + current := node + for current != nil { + functionName := getFunctionName(current) + if isComponentName(functionName) || isHookName(functionName) { + return true + } + if isForwardRefCallback(node) || isMemoCallback(node) { + return true + } + current = current.Parent + } + return false +} + +// Helper function to check if node is a function-like construct +func isFunctionLike(node *ast.Node) bool { + kind := node.Kind + return kind == ast.KindFunctionDeclaration || + kind == ast.KindFunctionExpression || + kind == ast.KindArrowFunction || + kind == ast.KindMethodDeclaration +} + +// Helper function to check if node is inside a loop +func isInsideLoop(node *ast.Node) bool { + current := node.Parent + for current != nil { + kind := current.Kind + if kind == ast.KindForStatement || + kind == ast.KindForInStatement || + kind == ast.KindForOfStatement || + kind == ast.KindWhileStatement || + kind == ast.KindDoStatement { + return true + } + current = current.Parent + } + return false +} + +// Helper function to check if node is inside a conditional +func isInsideConditional(node *ast.Node) bool { + current := node.Parent + for current != nil { + kind := current.Kind + if kind == ast.KindIfStatement || + kind == ast.KindConditionalExpression { + return true + } + // TODO: Check for logical operators (&& || ??) + if kind == ast.KindBinaryExpression { + binExpr := current.AsBinaryExpression() + if binExpr != nil { + op := binExpr.OperatorToken.Kind + if op == ast.KindAmpersandAmpersandToken || + op == ast.KindBarBarToken || + op == ast.KindQuestionQuestionToken { + return true + } + } + } + current = current.Parent + } + return false +} + +// Helper function to check if node is inside a class +func isInsideClass(node *ast.Node) bool { + current := node.Parent + for current != nil { + if current.Kind == ast.KindClassDeclaration || + current.Kind == ast.KindClassExpression { + return true + } + current = current.Parent + } + return false +} + +// Helper function to check if node is inside an async function +func isInsideAsyncFunction(node *ast.Node) bool { + current := node.Parent + for current != nil { + if isFunctionLike(current) { + // TODO: Check if function has async modifier + // This requires checking the modifiers array + // For now, check specific function types + if current.Kind == ast.KindFunctionDeclaration { + funcDecl := current.AsFunctionDeclaration() + if funcDecl != nil { + // TODO: Check for async modifier in modifiers + return false // placeholder + } + } else if current.Kind == ast.KindArrowFunction { + // TODO: Check for async modifier + return false // placeholder + } + } + current = current.Parent + } + return false +} + +// Helper function to check if node is inside try/catch +func isInsideTryCatch(node *ast.Node) bool { + current := node.Parent + for current != nil { + if current.Kind == ast.KindTryStatement || + current.Kind == ast.KindCatchClause { + return true + } + current = current.Parent + } + return false +} + +// Helper function to check if identifier is "use" +func isUseIdentifier(node *ast.Node) bool { + return isReactFunction(node, "use") +} + +// Helper function to check if call expression is a hook call +func isHookCall(node *ast.Node) (bool, string) { + if node.Kind != ast.KindCallExpression { + return false, "" + } + + callExpr := node.AsCallExpression() + if callExpr == nil { + return false, "" + } + + // Get the callee and extract the hook name + // Handle different call patterns: + // - useHook() + // - React.useHook() + // - obj.useHook() + callee := callExpr.Expression + if callee == nil { + return false, "" + } + + switch callee.Kind { + case ast.KindIdentifier: + // Direct call: useHook() + identifier := callee.AsIdentifier() + if identifier != nil { + name := scanner.GetTextOfNode(&identifier.Node) + if isHookName(name) { + return true, name + } + } + case ast.KindPropertyAccessExpression: + // Property access: React.useHook(), obj.useHook() + propAccess := callee.AsPropertyAccessExpression() + if propAccess != nil { + nameNode := propAccess.Name() + if nameNode != nil { + name := scanner.GetTextOfNode(nameNode) + if isHookName(name) { + return true, name + } + } + } + } + + return false, "" +} + +// Helper function to check if node is at top level +func isTopLevel(node *ast.Node) bool { + current := node.Parent + for current != nil { + if isFunctionLike(current) { + return false + } + current = current.Parent + } + return true +} + +// Helper function to check if a call expression is a React function +func isReactFunction(node *ast.Node, functionName string) bool { + if node == nil { + return false + } + + switch node.Kind { + case ast.KindIdentifier: + // Direct call: forwardRef() + identifier := node.AsIdentifier() + if identifier != nil { + name := scanner.GetTextOfNode(&identifier.Node) + return name == functionName + } + case ast.KindPropertyAccessExpression: + // Property access: React.forwardRef() + propAccess := node.AsPropertyAccessExpression() + if propAccess != nil { + nameNode := propAccess.Name() + if nameNode != nil { + name := scanner.GetTextOfNode(nameNode) + if name == functionName { + // Check if the object is React + expr := propAccess.Expression + if expr != nil && expr.Kind == ast.KindIdentifier { + objName := scanner.GetTextOfNode(expr) + return objName == "React" + } + } + } + } + } + return false +} + +// Helper function to check if the node is a callback argument of forwardRef +// This render function should follow the rules of hooks +func isForwardRefCallback(node *ast.Node) bool { + if node == nil || node.Parent == nil { + return false + } + + parent := node.Parent + if parent.Kind == ast.KindCallExpression { + callExpr := parent.AsCallExpression() + if callExpr != nil && callExpr.Expression != nil { + return isReactFunction(callExpr.Expression, "forwardRef") + } + } + return false +} + +// Helper function to check if the node is a callback argument of memo +func isMemoCallback(node *ast.Node) bool { + if node == nil || node.Parent == nil { + return false + } + + parent := node.Parent + if parent.Kind == ast.KindCallExpression { + callExpr := parent.AsCallExpression() + if callExpr != nil && callExpr.Expression != nil { + return isReactFunction(callExpr.Expression, "memo") + } + } + return false +} + +// Helper function to check for flow suppression comments +func hasFlowSuppression(node *ast.Node) bool { + // No need implementation + return false +} + +// Helper function to get node text +func getNodeText(node *ast.Node) string { + // This is a simplified implementation + // You would extract the text from the source code + if node != nil && node.Kind == ast.KindIdentifier { + return scanner.GetTextOfNode(node) + } + return "" +} + +// Helper function to check if node is inside do-while loop +func isInsideDoWhileLoop(node *ast.Node) bool { + current := node.Parent + for current != nil { + if current.Kind == ast.KindDoStatement { + return true + } + current = current.Parent + } + return false +} + +// Helper function to check if function is async +func isAsyncFunction(node *ast.Node) bool { + // This is a simplified implementation + // You would check the modifiers for async keyword + return false +} + +// Helper function to check if node is useEffect identifier +func isUseEffectIdentifier(node *ast.Node) bool { + if node == nil || node.Kind != ast.KindIdentifier { + return false + } + text := scanner.GetTextOfNode(node) + return text == "useEffect" +} + +// Helper function to check if node is useEffectEvent identifier +func isUseEffectEventIdentifier(node *ast.Node) bool { + if node == nil || node.Kind != ast.KindIdentifier { + return false + } + text := scanner.GetTextOfNode(node) + return text == "useEffectEvent" +} + +// Helper function to get node without React namespace +func getNodeWithoutReactNamespace(node *ast.Node) *ast.Node { + if node == nil { + return nil + } + + // If it's React.someHook, return someHook + if node.Kind == ast.KindPropertyAccessExpression { + propAccess := node.AsPropertyAccessExpression() + if propAccess != nil { + expr := propAccess.Expression + if expr != nil && expr.Kind == ast.KindIdentifier { + identifier := expr.AsIdentifier() + if identifier != nil && scanner.GetTextOfNode(&identifier.Node) == "React" { + return propAccess.Name() + } + } + } + } + + return node +} + +// Helper function to get scope (simplified implementation) +func getScope(node *ast.Node) *ast.Node { + // This is a simplified implementation + // In a real implementation, you would traverse the scope chain + return node +} + +// Helper function to record all useEffectEvent functions (simplified) +func recordAllUseEffectEventFunctions(scope *ast.Node) { + // This is a simplified implementation + // In a real implementation, you would traverse the scope and find all useEffectEvent declarations +} + +// Helper function to check if we're inside a component or hook (from scope context) +func isInsideComponentOrHookFromScope(node *ast.Node) bool { + // This is a simplified implementation based on the existing isInsideComponentOrHook + return isInsideComponentOrHook(node) +} diff --git a/internal/plugins/react_hooks/rules/rules_of_hooks/rules_of_hooks_test.go b/internal/plugins/react_hooks/rules/rules_of_hooks/rules_of_hooks_test.go new file mode 100644 index 00000000..fa5ed403 --- /dev/null +++ b/internal/plugins/react_hooks/rules/rules_of_hooks/rules_of_hooks_test.go @@ -0,0 +1,1700 @@ +package rules_of_hooks_test + +import ( + "testing" + + "github.com/web-infra-dev/rslint/internal/plugins/import/fixtures" + "github.com/web-infra-dev/rslint/internal/plugins/react_hooks/rules/rules_of_hooks" + "github.com/web-infra-dev/rslint/internal/rule_tester" +) + +func TestRulesOfHooks(t *testing.T) { + rule_tester.RunRuleTester( + fixtures.GetRootDir(), + "tsconfig.json", + t, + &rules_of_hooks.RulesOfHooksRule, + []rule_tester.ValidTestCase{ + // { + // Code: ` + // // Valid because components can use hooks. + // function ComponentWithHook() { + // useHook(); + // } + // `, + // }, + // { + // Code: ` + // // Valid because components can use hooks. + // function createComponentWithHook() { + // return function ComponentWithHook() { + // useHook(); + // }; + // } + // `, + // }, + // { + // Code: ` + // // Valid because hooks can use hooks. + // function useHookWithHook() { + // useHook(); + // } + // `, + // }, + // { + // Code: ` + // // Valid because hooks can use hooks. + // function createHook() { + // return function useHookWithHook() { + // useHook(); + // } + // } + // `, + // }, + // { + // Code: ` + // // Valid because components can call functions. + // function ComponentWithNormalFunction() { + // doSomething(); + // } + // `, + // }, + // { + // Code: ` + // // Valid because functions can call functions. + // function normalFunctionWithNormalFunction() { + // doSomething(); + // } + // `, + // }, + // { + // Code: ` + // // Valid because functions can call functions. + // function normalFunctionWithConditionalFunction() { + // if (cond) { + // doSomething(); + // } + // } + // `, + // }, + // { + // Code: ` + // // Valid because functions can call functions. + // function functionThatStartsWithUseButIsntAHook() { + // if (cond) { + // userFetch(); + // } + // } + // `, + // }, + // { + // Code: ` + // // Valid although unconditional return doesn't make sense and would fail other rules. + // // We could make it invalid but it doesn't matter. + // function useUnreachable() { + // return; + // useHook(); + // } + // `, + // }, + // { + // Code: ` + // // Valid because hooks can call hooks. + // function useHook() { useState(); } + // const whatever = function useHook() { useState(); }; + // const useHook1 = () => { useState(); }; + // let useHook2 = () => useState(); + // useHook2 = () => { useState(); }; + // ({useHook: () => { useState(); }}); + // ({useHook() { useState(); }}); + // const {useHook3 = () => { useState(); }} = {}; + // ({useHook = () => { useState(); }} = {}); + // Namespace.useHook = () => { useState(); }; + // `, + // }, + // { + // Code: ` + // // Valid because hooks can call hooks. + // function useHook() { + // useHook1(); + // useHook2(); + // } + // `, + // }, + // { + // Code: ` + // // Valid because hooks can call hooks. + // function createHook() { + // return function useHook() { + // useHook1(); + // useHook2(); + // }; + // } + // `, + // }, + // { + // Code: ` + // // Valid because hooks can call hooks. + // function useHook() { + // useState() && a; + // } + // `, + // }, + // { + // Code: ` + // // Valid because hooks can call hooks. + // function useHook() { + // return useHook1() + useHook2(); + // } + // `, + // }, + // { + // Code: ` + // // Valid because hooks can call hooks. + // function useHook() { + // return useHook1(useHook2()); + // } + // `, + // }, + // { + // Code: ` + // // Valid because hooks can be used in anonymous arrow-function arguments + // // to forwardRef. + // const FancyButton = React.forwardRef((props, ref) => { + // useHook(); + // return ; + // }); + // `, + + // Errors: []rule_tester.InvalidTestCaseError{ + // { + // MessageId: "conditionalHook", + // Line: 6, + // }, + // }, + // }, + // { + // Code: ` + // // Invalid because it's dangerous and might not warn otherwise. + // // This *must* be invalid. + // const FancyButton = forwardRef(function(props, ref) { + // if (props.fancy) { + // useCustomHook(); + // } + // return ; + // }); + // `, + + // Errors: []rule_tester.InvalidTestCaseError{ + // { + // MessageId: "conditionalHook", + // Line: 6, + // }, + // }, + // }, + // { + // Code: ` + // // Invalid because it's dangerous and might not warn otherwise. + // // This *must* be invalid. + // const MemoizedButton = memo(function(props) { + // if (props.fancy) { + // useCustomHook(); + // } + // return ; + // }); + // `, + + // Errors: []rule_tester.InvalidTestCaseError{ + // { + // MessageId: "conditionalHook", + // Line: 6, + // }, + // }, + // }, + // { + // Code: ` + // // This is invalid because "use"-prefixed functions used in named + // // functions are assumed to be hooks. + // React.unknownFunction(function notAComponent(foo, bar) { + // useProbablyAHook(bar) + // }); + // `, + + // Errors: []rule_tester.InvalidTestCaseError{ + // { + // MessageId: "functionHook", + // Line: 5, + // }, + // }, + // }, + // { + // Code: ` + // // Invalid because it's dangerous. + // // Normally, this would crash, but not if you use inline requires. + // // This *must* be invalid. + // // It's expected to have some false positives, but arguably + // // they are confusing anyway due to the use*() convention + // // already being associated with Hooks. + // useState(); + // if (foo) { + // const foo = React.useCallback(() => {}); + // } + // useCustomHook(); + // `, + + // Errors: []rule_tester.InvalidTestCaseError{ + // { + // MessageId: "topLevelHook", + // Line: 8, + // }, + // { + // MessageId: "topLevelHook", + // Line: 10, + // }, + // { + // MessageId: "topLevelHook", + // Line: 12, + // }, + // }, + // }, + // { + // Code: ` + // // Technically this is a false positive. + // // We *could* make it valid (and it used to be). + // // + // // However, top-level Hook-like calls can be very dangerous + // // in environments with inline requires because they can mask + // // the runtime error by accident. + // // So we prefer to disallow it despite the false positive. + + // const {createHistory, useBasename} = require('history-2.1.2'); + // const browserHistory = useBasename(createHistory)({ + // basename: '/', + // }); + // `, + + // Errors: []rule_tester.InvalidTestCaseError{ + // { + // MessageId: "topLevelHook", + // Line: 11, + // }, + // }, + // }, + // { + // Code: ` + // class ClassComponentWithFeatureFlag extends React.Component { + // render() { + // if (foo) { + // useFeatureFlag(); + // } + // } + // } + // `, + + // Errors: []rule_tester.InvalidTestCaseError{ + // { + // MessageId: "classHook", + // Line: 5, + // }, + // }, + // }, + // { + // Code: ` + // class ClassComponentWithHook extends React.Component { + // render() { + // React.useState(); + // } + // } + // `, + + // Errors: []rule_tester.InvalidTestCaseError{ + // { + // MessageId: "classHook", + // Line: 4, + // }, + // }, + // }, + // { + // Code: ` + // (class {useHook = () => { useState(); }}); + // `, + + // Errors: []rule_tester.InvalidTestCaseError{ + // { + // MessageId: "classHook", + // Line: 2, + // }, + // }, + // }, + // { + // Code: ` + // (class {useHook() { useState(); }}); + // `, + + // Errors: []rule_tester.InvalidTestCaseError{ + // { + // MessageId: "classHook", + // Line: 2, + // }, + // }, + // }, + // { + // Code: ` + // (class {h = () => { useState(); }}); + // `, + + // Errors: []rule_tester.InvalidTestCaseError{ + // { + // MessageId: "classHook", + // Line: 2, + // }, + // }, + // }, + // { + // Code: ` + // (class {i() { useState(); }}); + // `, + + // Errors: []rule_tester.InvalidTestCaseError{ + // { + // MessageId: "classHook", + // Line: 2, + // }, + // }, + // }, + // { + // Code: ` + // async function AsyncComponent() { + // useState(); + // } + // `, + + // Errors: []rule_tester.InvalidTestCaseError{ + // { + // MessageId: "asyncComponentHook", + // Line: 3, + // }, + // }, + // }, + // { + // Code: ` + // async function useAsyncHook() { + // useState(); + // } + // `, + + // Errors: []rule_tester.InvalidTestCaseError{ + // { + // MessageId: "asyncComponentHook", + // Line: 3, + // }, + // }, + // }, + // { + // Code: ` + // async function Page() { + // useId(); + // React.useId(); + // } + // `, + + // Errors: []rule_tester.InvalidTestCaseError{ + // { + // MessageId: "asyncComponentHook", + // Line: 3, + // }, + // { + // MessageId: "asyncComponentHook", + // Line: 4, + // }, + // }, + // }, + // { + // Code: ` + // async function useAsyncHook() { + // useId(); + // } + // `, + + // Errors: []rule_tester.InvalidTestCaseError{ + // { + // MessageId: "asyncComponentHook", + // Line: 3, + // }, + // }, + // }, + // { + // Code: ` + // async function notAHook() { + // useId(); + // } + // `, + + // Errors: []rule_tester.InvalidTestCaseError{ + // { + // MessageId: "functionHook", + // Line: 3, + // }, + // }, + // }, + // { + // Code: ` + // Hook.use(); + // Hook._use(); + // Hook.useState(); + // Hook._useState(); + // Hook.use42(); + // Hook.useHook(); + // Hook.use_hook(); + // `, + + // Errors: []rule_tester.InvalidTestCaseError{ + // { + // MessageId: "topLevelHook", + // Line: 2, + // }, + // { + // MessageId: "topLevelHook", + // Line: 4, + // }, + // { + // MessageId: "topLevelHook", + // Line: 6, + // }, + // { + // MessageId: "topLevelHook", + // Line: 7, + // }, + // }, + // }, + // { + // Code: ` + // function notAComponent() { + // use(promise); + // } + // `, + + // Errors: []rule_tester.InvalidTestCaseError{ + // { + // MessageId: "functionHook", + // Line: 3, + // }, + // }, + // }, + // { + // Code: ` + // const text = use(promise); + // function App() { + // return + // } + // `, + + // Errors: []rule_tester.InvalidTestCaseError{ + // { + // MessageId: "topLevelHook", + // Line: 2, + // }, + // }, + // }, + // { + // Code: ` + // class C { + // m() { + // use(promise); + // } + // } + // `, + + // Errors: []rule_tester.InvalidTestCaseError{ + // { + // MessageId: "classHook", + // Line: 4, + // }, + // }, + // }, + // { + // Code: ` + // async function AsyncComponent() { + // use(); + // } + // `, + + // Errors: []rule_tester.InvalidTestCaseError{ + // { + // MessageId: "asyncComponentHook", + // Line: 3, + // }, + // }, + // }, + // { + // Code: ` + // function App({p1, p2}) { + // try { + // use(p1); + // } catch (error) { + // console.error(error); + // } + // use(p2); + // return
App
; + // } + // `, + + // Errors: []rule_tester.InvalidTestCaseError{ + // { + // MessageId: "tryCatchUse", + // Line: 4, + // }, + // }, + // }, + // { + // Code: ` + // function App({p1, p2}) { + // try { + // doSomething(); + // } catch { + // use(p1); + // } + // use(p2); + // return
App
; + // } + // `, + + // Errors: []rule_tester.InvalidTestCaseError{ + // { + // MessageId: "tryCatchUse", + // Line: 6, + // }, + // }, + // }, + }, + ) +} diff --git a/internal/rule/rule.go b/internal/rule/rule.go index d533a25d..fd07bdcc 100644 --- a/internal/rule/rule.go +++ b/internal/rule/rule.go @@ -68,6 +68,11 @@ const ( lastOnNotAllowPatternOnExitTokenKind ast.Kind = 6000 ) +const ( + WildcardTokenKind ast.Kind = iota + 10000 // start well beyond any AST kind + WildcardExitTokenKind +) + func ListenerOnExit(kind ast.Kind) ast.Kind { return kind + 1000 } From b9b071f9913e9c7959dcd3e664c729e626ef7d44 Mon Sep 17 00:00:00 2001 From: Cheng Liu Date: Fri, 29 Aug 2025 23:28:49 -0700 Subject: [PATCH 2/5] feat: pass all test cases --- .../code_path_analysis/break_context.go | 2 + .../code_path_analysis/code_path_analyzer.go | 27 +- .../code_path_analysis/code_path_state.go | 2 +- .../code_path_analysis/loop_context.go | 18 +- .../code_path_analysis/try_context.go | 10 +- .../rules/rules_of_hooks/rules_of_hooks.go | 113 +- .../rules_of_hooks/rules_of_hooks_test.go | 3305 +++++++++-------- 7 files changed, 1771 insertions(+), 1706 deletions(-) diff --git a/internal/plugins/react_hooks/code_path_analysis/break_context.go b/internal/plugins/react_hooks/code_path_analysis/break_context.go index bb7b4e1b..446239f9 100644 --- a/internal/plugins/react_hooks/code_path_analysis/break_context.go +++ b/internal/plugins/react_hooks/code_path_analysis/break_context.go @@ -133,6 +133,8 @@ func (s *CodePathState) MakeReturn() { returnCtx := s.getReturnContext() if returnCtx != nil { returnCtx.returnedForkContext.Add(forkContext.Head()) + } else { + s.addReturnedSegments(forkContext.Head()) } forkContext.ReplaceHead(forkContext.MakeUnreachable(-1, -1)) } diff --git a/internal/plugins/react_hooks/code_path_analysis/code_path_analyzer.go b/internal/plugins/react_hooks/code_path_analysis/code_path_analyzer.go index ca6445e7..2c4835c4 100644 --- a/internal/plugins/react_hooks/code_path_analysis/code_path_analyzer.go +++ b/internal/plugins/react_hooks/code_path_analysis/code_path_analyzer.go @@ -198,10 +198,10 @@ func (analyzer *CodePathAnalyzer) preprocess(node *ast.Node) { state.MakeForInOfBody() } - case ast.KindBindingElement: + case ast.KindParameter: // Handle assignment patterns (destructuring with defaults) - bindingElem := parent.AsBindingElement() - if bindingElem.Initializer == node { + parameterDecl := parent.AsParameterDeclaration() + if parameterDecl.Initializer == node { state.PushForkContext(nil) state.ForkBypassPath() state.ForkPath() @@ -234,7 +234,10 @@ func (analyzer *CodePathAnalyzer) processCodePathToEnter(node *ast.Node) { case ast.KindSourceFile: analyzer.startCodePath("program", node) - case ast.KindFunctionDeclaration, ast.KindFunctionExpression, ast.KindArrowFunction: + case ast.KindFunctionDeclaration, + ast.KindFunctionExpression, + ast.KindArrowFunction, + ast.KindMethodDeclaration: analyzer.startCodePath("function", node) case ast.KindClassStaticBlockDeclaration: @@ -304,7 +307,7 @@ func (analyzer *CodePathAnalyzer) processCodePathToEnter(node *ast.Node) { label := getLabel(node) state.PushLoopContext(ForOfStatement, label) case ast.KindLabeledStatement: - if !isBreakableType(node.Body().Kind) { + if !isBreakableType(node.AsLabeledStatement().Statement.Kind) { state.PushBreakContext(false, node.Label().Text()) } default: @@ -334,7 +337,7 @@ func (analyzer *CodePathAnalyzer) processCodePathToExit(node *ast.Node) { binExpr := node.AsBinaryExpression() if isHandledLogicalOperator(binExpr.OperatorToken.Kind) || isLogicalAssignmentOperator(binExpr.OperatorToken.Kind) { - state.PopBreakContext() + state.PopChoiceContext() } case ast.KindSwitchStatement: @@ -398,12 +401,13 @@ func (analyzer *CodePathAnalyzer) processCodePathToExit(node *ast.Node) { case ast.KindWhileStatement, ast.KindDoStatement, ast.KindForStatement, ast.KindForInStatement, ast.KindForOfStatement: state.PopLoopContext() - case ast.KindBindingElement: - state.PopForkContext() + case ast.KindParameter: + if node.Initializer() != nil { + state.PopForkContext() + } case ast.KindLabeledStatement: - labeledStmt := node.AsLabeledStatement() - if !isBreakableType(labeledStmt.Body().Kind) { + if !isBreakableType(node.AsLabeledStatement().Statement.Kind) { state.PopBreakContext() } @@ -423,7 +427,8 @@ func (analyzer *CodePathAnalyzer) postprocess(node *ast.Node) { ast.KindFunctionDeclaration, ast.KindFunctionExpression, ast.KindArrowFunction, - ast.KindClassStaticBlockDeclaration: + ast.KindClassStaticBlockDeclaration, + ast.KindMethodDeclaration: analyzer.endCodePath(node) // The `arguments.length >= 1` case is in `preprocess` function. diff --git a/internal/plugins/react_hooks/code_path_analysis/code_path_state.go b/internal/plugins/react_hooks/code_path_analysis/code_path_state.go index bcf69351..5ca6def5 100644 --- a/internal/plugins/react_hooks/code_path_analysis/code_path_state.go +++ b/internal/plugins/react_hooks/code_path_analysis/code_path_state.go @@ -134,7 +134,7 @@ func (s *CodePathState) addReturnedSegments(segments []*CodePathSegment) { func (s *CodePathState) addThrownSegments(segments []*CodePathSegment) { for _, segment := range segments { - s.returnedSegments = append(s.thrownSegments, segment) + s.thrownSegments = append(s.thrownSegments, segment) for _, returnSegment := range s.returnedSegments { if returnSegment == segment { diff --git a/internal/plugins/react_hooks/code_path_analysis/loop_context.go b/internal/plugins/react_hooks/code_path_analysis/loop_context.go index 4a65d72a..cc6d8ee6 100644 --- a/internal/plugins/react_hooks/code_path_analysis/loop_context.go +++ b/internal/plugins/react_hooks/code_path_analysis/loop_context.go @@ -97,18 +97,22 @@ func NewLoopContextForForOfStatement(state *CodePathState, label string) *LoopCo } // Creates a context object of a loop statement and stacks it. -func (s *CodePathState) PushLoopContext(kind LoopStatementKind, label string) *LoopContext { +func (s *CodePathState) PushLoopContext(kind LoopStatementKind, label string) { + s.PushBreakContext(true, label) switch kind { case WhileStatement: - return NewLoopContextForWhileStatement(s, label) + s.PushChoiceContext("loop", false) + s.loopContext = NewLoopContextForWhileStatement(s, label) case DoWhileStatement: - return NewLoopContextForDoWhileStatement(s, label) + s.PushChoiceContext("loop", false) + s.loopContext = NewLoopContextForDoWhileStatement(s, label) case ForStatement: - return NewLoopContextForForStatement(s, label) + s.PushChoiceContext("loop", false) + s.loopContext = NewLoopContextForForStatement(s, label) case ForInStatement: - return NewLoopContextForForInStatement(s, label) + s.loopContext = NewLoopContextForForInStatement(s, label) case ForOfStatement: - return NewLoopContextForForOfStatement(s, label) + s.loopContext = NewLoopContextForForOfStatement(s, label) default: panic("unknown statement kind") } @@ -127,7 +131,7 @@ func (s *CodePathState) PopLoopContext() { case WhileStatement, ForStatement: { s.PopChoiceContext() - s.MakeLooped(forkContext.Head(), context.upper.continueDestSegments) + s.MakeLooped(forkContext.Head(), context.continueDestSegments) } case DoWhileStatement: { diff --git a/internal/plugins/react_hooks/code_path_analysis/try_context.go b/internal/plugins/react_hooks/code_path_analysis/try_context.go index ed4bfaa7..a69d0c9c 100644 --- a/internal/plugins/react_hooks/code_path_analysis/try_context.go +++ b/internal/plugins/react_hooks/code_path_analysis/try_context.go @@ -27,8 +27,8 @@ func NewTryContext(state *CodePathState, hasFinalizer bool) *TryContext { } // Creates a context object of TryStatement and stacks it. -func (s *CodePathState) PushTryContext(hasFinalizer bool) *TryContext { - return NewTryContext(s, hasFinalizer) +func (s *CodePathState) PushTryContext(hasFinalizer bool) { + s.tryContext = NewTryContext(s, hasFinalizer) } // PopTryContext pops the last context of TryStatement and finalizes it. @@ -63,12 +63,16 @@ func (s *CodePathState) PopTryContext() { returnCtx := s.getReturnContext() if returnCtx != nil { returnCtx.returnedForkContext.Add(leavingSegments) + } else { + s.addReturnedSegments(leavingSegments) } } if !thrown.IsEmpty() { throwCtx := s.getThrowContext() if throwCtx != nil { throwCtx.thrownForkContext.Add(leavingSegments) + } else { + s.addThrownSegments(leavingSegments) } } @@ -223,6 +227,8 @@ func (s *CodePathState) MakeThrow() { throwCtx := s.getThrowContext() if throwCtx != nil { throwCtx.thrownForkContext.Add(forkContext.Head()) + } else { + s.addThrownSegments(forkContext.Head()) } forkContext.ReplaceHead(forkContext.MakeUnreachable(-1, -1)) } diff --git a/internal/plugins/react_hooks/rules/rules_of_hooks/rules_of_hooks.go b/internal/plugins/react_hooks/rules/rules_of_hooks/rules_of_hooks.go index 39a0daba..c5f068c0 100644 --- a/internal/plugins/react_hooks/rules/rules_of_hooks/rules_of_hooks.go +++ b/internal/plugins/react_hooks/rules/rules_of_hooks/rules_of_hooks.go @@ -64,7 +64,7 @@ var RulesOfHooksRule = rule.Rule{ } segmentID := segment.ID() - if paths, exists := countPathsFromStartCache[segmentID]; exists { + if paths, exists := countPathsFromStartCache[segmentID]; exists && paths != nil { return paths } @@ -98,7 +98,9 @@ var RulesOfHooksRule = rule.Rule{ } } - if segment.Reachable() || paths.Cmp(big.NewInt(0)) > 0 { + if segment.Reachable() && paths.Cmp(big.NewInt(0)) == 0 { + countPathsFromStartCache[segmentID] = nil + } else { countPathsFromStartCache[segmentID] = paths } @@ -318,14 +320,16 @@ var RulesOfHooksRule = rule.Rule{ // Check if we're in a valid context for hooks if isDirectlyInsideComponentOrHook { // Check for async function - if isAsyncFunction(codePathNode) { + if isInsideAsyncFunction(codePathNode) { ctx.ReportNode(hook, buildAsyncComponentHookMessage(hookText)) continue } + pathsCmp := pathsFromStartToEnd.Cmp(allPathsFromStartToEnd) + // Check for conditional calls (except use() and do-while loops) if !isCyclic && - pathsFromStartToEnd.Cmp(allPathsFromStartToEnd) != 0 && + pathsCmp != 0 && !isUseHook && !isInsideDoWhileLoop(hook) { var message rule.RuleMessage @@ -341,10 +345,18 @@ var RulesOfHooksRule = rule.Rule{ if isInsideClass(codePathNode) { ctx.ReportNode(hook, buildClassHookMessage(hookText)) } else if codePathFunctionName != "" { + // Custom message if we found an invalid function name.kj ctx.ReportNode(hook, buildFunctionHookMessage(hookText, codePathFunctionName)) } else if isTopLevel(codePathNode) { + // These are dangerous if you have inline requires enabled. ctx.ReportNode(hook, buildTopLevelHookMessage(hookText)) } else if isSomewhereInsideComponentOrHook && !isUseHook { + // Assume in all other cases the user called a hook in some + // random function callback. This should usually be true for + // anonymous function expressions. Hopefully this is clarifying + // enough in the common case that the incorrect message in + // uncommon cases doesn't matter. + // `use(...)` can be called in callbacks. ctx.ReportNode(hook, buildGenericHookMessage(hookText)) } } @@ -569,16 +581,52 @@ func getFunctionName(node *ast.Node) string { // Function declaration or function expression names win over any // assignment statements or other renames. return node.AsFunctionDeclaration().Name().Text() + case ast.KindFunctionExpression: + name := node.AsFunctionExpression().Name() + if name != nil { + return node.AsFunctionExpression().Name().Text() + } case ast.KindArrowFunction: - // const useHook = () => {}; - return node.AsArrowFunction().Text() + if node.Parent != nil { + switch node.Parent.Kind { + case ast.KindVariableDeclaration, // const useHook = () => {}; + ast.KindShorthandPropertyAssignment, // ({k = () => { useState(); }} = {}); + ast.KindBindingElement, // const {j = () => { useState(); }} = {}; + ast.KindPropertyAssignment: // ({f: () => { useState(); }}); + if ast.IsInExpressionContext(node) { + return node.Parent.Name().Text() + } + case ast.KindBinaryExpression: + if node.Parent.AsBinaryExpression().Right == node { + left := node.Parent.AsBinaryExpression().Left + switch left.Kind { + case ast.KindIdentifier: + // e = () => { useState(); }; + return left.AsIdentifier().Text + case ast.KindPropertyAccessExpression: + // Namespace.useHook = () => { useState(); }; + return left.AsPropertyAccessExpression().Name().Text() + } + } + } + } + return "" case ast.KindMethodDeclaration: + // NOTE: We could also support `ClassProperty` and `MethodDefinition` + // here to be pedantic. However, hooks in a class are an anti-pattern. So + // we don't allow it to error early. + // + // class {useHook = () => {}} + // class {useHook() {}} + if ast.GetContainingClass(node) != nil { + return "" + } + // {useHook: () => {}} // {useHook() {}} - return node.AsMethodDeclaration().Text() - default: - return "" + return node.AsMethodDeclaration().Name().Text() } + return "" } // Helper function to check if node is inside a component or hook @@ -588,10 +636,10 @@ func isInsideComponentOrHook(node *ast.Node) bool { current := node for current != nil { functionName := getFunctionName(current) - if isComponentName(functionName) || isHookName(functionName) { + if functionName != "" && (isComponentName(functionName) || isHookName(functionName)) { return true } - if isForwardRefCallback(node) || isMemoCallback(node) { + if isForwardRefCallback(current) || isMemoCallback(current) { return true } current = current.Parent @@ -666,22 +714,10 @@ func isInsideClass(node *ast.Node) bool { // Helper function to check if node is inside an async function func isInsideAsyncFunction(node *ast.Node) bool { - current := node.Parent + current := node for current != nil { - if isFunctionLike(current) { - // TODO: Check if function has async modifier - // This requires checking the modifiers array - // For now, check specific function types - if current.Kind == ast.KindFunctionDeclaration { - funcDecl := current.AsFunctionDeclaration() - if funcDecl != nil { - // TODO: Check for async modifier in modifiers - return false // placeholder - } - } else if current.Kind == ast.KindArrowFunction { - // TODO: Check for async modifier - return false // placeholder - } + if isAsyncFunction(current) { + return true } current = current.Parent } @@ -756,14 +792,15 @@ func isHookCall(node *ast.Node) (bool, string) { // Helper function to check if node is at top level func isTopLevel(node *ast.Node) bool { - current := node.Parent - for current != nil { - if isFunctionLike(current) { - return false - } - current = current.Parent - } - return true + // current := node.Parent + // for current != nil { + // if isFunctionLike(current) { + // return false + // } + // current = current.Parent + // } + // return true + return node.Kind == ast.KindSourceFile } // Helper function to check if a call expression is a React function @@ -864,8 +901,9 @@ func isInsideDoWhileLoop(node *ast.Node) bool { // Helper function to check if function is async func isAsyncFunction(node *ast.Node) bool { - // This is a simplified implementation - // You would check the modifiers for async keyword + if isFunctionLike(node) { + return ast.HasSyntacticModifier(node, ast.ModifierFlagsAsync) + } return false } @@ -919,8 +957,7 @@ func getScope(node *ast.Node) *ast.Node { // Helper function to record all useEffectEvent functions (simplified) func recordAllUseEffectEventFunctions(scope *ast.Node) { - // This is a simplified implementation - // In a real implementation, you would traverse the scope and find all useEffectEvent declarations + // !!! useEffectEvent } // Helper function to check if we're inside a component or hook (from scope context) diff --git a/internal/plugins/react_hooks/rules/rules_of_hooks/rules_of_hooks_test.go b/internal/plugins/react_hooks/rules/rules_of_hooks/rules_of_hooks_test.go index fa5ed403..d50e664b 100644 --- a/internal/plugins/react_hooks/rules/rules_of_hooks/rules_of_hooks_test.go +++ b/internal/plugins/react_hooks/rules/rules_of_hooks/rules_of_hooks_test.go @@ -15,538 +15,1297 @@ func TestRulesOfHooks(t *testing.T) { t, &rules_of_hooks.RulesOfHooksRule, []rule_tester.ValidTestCase{ - // { - // Code: ` - // // Valid because components can use hooks. - // function ComponentWithHook() { - // useHook(); - // } - // `, - // }, - // { - // Code: ` - // // Valid because components can use hooks. - // function createComponentWithHook() { - // return function ComponentWithHook() { - // useHook(); - // }; - // } - // `, - // }, - // { - // Code: ` - // // Valid because hooks can use hooks. - // function useHookWithHook() { - // useHook(); - // } - // `, - // }, - // { - // Code: ` - // // Valid because hooks can use hooks. - // function createHook() { - // return function useHookWithHook() { - // useHook(); - // } - // } - // `, - // }, - // { - // Code: ` - // // Valid because components can call functions. - // function ComponentWithNormalFunction() { - // doSomething(); - // } - // `, - // }, - // { - // Code: ` - // // Valid because functions can call functions. - // function normalFunctionWithNormalFunction() { - // doSomething(); - // } - // `, - // }, - // { - // Code: ` - // // Valid because functions can call functions. - // function normalFunctionWithConditionalFunction() { - // if (cond) { - // doSomething(); - // } - // } - // `, - // }, - // { - // Code: ` - // // Valid because functions can call functions. - // function functionThatStartsWithUseButIsntAHook() { - // if (cond) { - // userFetch(); - // } - // } - // `, - // }, - // { - // Code: ` - // // Valid although unconditional return doesn't make sense and would fail other rules. - // // We could make it invalid but it doesn't matter. - // function useUnreachable() { - // return; - // useHook(); - // } - // `, - // }, - // { - // Code: ` - // // Valid because hooks can call hooks. - // function useHook() { useState(); } - // const whatever = function useHook() { useState(); }; - // const useHook1 = () => { useState(); }; - // let useHook2 = () => useState(); - // useHook2 = () => { useState(); }; - // ({useHook: () => { useState(); }}); - // ({useHook() { useState(); }}); - // const {useHook3 = () => { useState(); }} = {}; - // ({useHook = () => { useState(); }} = {}); - // Namespace.useHook = () => { useState(); }; - // `, - // }, - // { - // Code: ` - // // Valid because hooks can call hooks. - // function useHook() { - // useHook1(); - // useHook2(); - // } - // `, - // }, - // { - // Code: ` - // // Valid because hooks can call hooks. - // function createHook() { - // return function useHook() { - // useHook1(); - // useHook2(); - // }; - // } - // `, - // }, - // { - // Code: ` - // // Valid because hooks can call hooks. - // function useHook() { - // useState() && a; - // } - // `, - // }, - // { - // Code: ` - // // Valid because hooks can call hooks. - // function useHook() { - // return useHook1() + useHook2(); - // } - // `, - // }, - // { - // Code: ` - // // Valid because hooks can call hooks. - // function useHook() { - // return useHook1(useHook2()); - // } - // `, - // }, - // { - // Code: ` - // // Valid because hooks can be used in anonymous arrow-function arguments - // // to forwardRef. - // const FancyButton = React.forwardRef((props, ref) => { - // useHook(); - // return ; + }); + `, + Tsx: true, Errors: []rule_tester.InvalidTestCaseError{ { MessageId: "conditionalHook", @@ -554,1147 +1313,399 @@ function ComponentWithConditionalHook() { }, }, }, - // { - // Code: ` - // Hook.useState(); - // Hook._useState(); - // Hook.use42(); - // Hook.useHook(); - // Hook.use_hook(); - // `, - - // Errors: []rule_tester.InvalidTestCaseError{ - // { - // MessageId: "topLevelHook", - // Line: 2, - // }, - // { - // MessageId: "topLevelHook", - // Line: 4, - // }, - // { - // MessageId: "topLevelHook", - // Line: 5, - // }, - // }, - // }, - // { - // Code: ` - // class C { - // m() { - // This.useHook(); - // Super.useHook(); - // } - // } - // `, - - // Errors: []rule_tester.InvalidTestCaseError{ - // { - // MessageId: "classHook", - // Line: 4, - // }, - // { - // MessageId: "classHook", - // Line: 5, - // }, - // }, - // }, - // { - // Code: ` - // // This is a false positive (it's valid) that unfortunately - // // we cannot avoid. Prefer to rename it to not start with "use" - // class Foo extends Component { - // render() { - // if (cond) { - // FooStore.useFeatureFlag(); - // } - // } - // } - // `, - - // Errors: []rule_tester.InvalidTestCaseError{ - // { - // MessageId: "classHook", - // Line: 7, - // }, - // }, - // }, - // { - // Code: ` - // // Invalid because it's dangerous and might not warn otherwise. - // // This *must* be invalid. - // function ComponentWithConditionalHook() { - // if (cond) { - // Namespace.useConditionalHook(); - // } - // } - // `, - - // Errors: []rule_tester.InvalidTestCaseError{ - // { - // MessageId: "conditionalHook", - // Line: 6, - // }, - // }, - // }, - // { - // Code: ` - // // Invalid because it's dangerous and might not warn otherwise. - // // This *must* be invalid. - // function createComponent() { - // return function ComponentWithConditionalHook() { - // if (cond) { - // useConditionalHook(); - // } - // } - // } - // `, - - // Errors: []rule_tester.InvalidTestCaseError{ - // { - // MessageId: "conditionalHook", - // Line: 7, - // }, - // }, - // }, - // { - // Code: ` - // // Invalid because it's dangerous and might not warn otherwise. - // // This *must* be invalid. - // function useHookWithConditionalHook() { - // if (cond) { - // useConditionalHook(); - // } - // } - // `, - - // Errors: []rule_tester.InvalidTestCaseError{ - // { - // MessageId: "conditionalHook", - // Line: 6, - // }, - // }, - // }, - // { - // Code: ` - // // Invalid because it's dangerous and might not warn otherwise. - // // This *must* be invalid. - // function createHook() { - // return function useHookWithConditionalHook() { - // if (cond) { - // useConditionalHook(); - // } - // } - // } - // `, - - // Errors: []rule_tester.InvalidTestCaseError{ - // { - // MessageId: "conditionalHook", - // Line: 7, - // }, - // }, - // }, - // { - // Code: ` - // // Invalid because it's dangerous and might not warn otherwise. - // // This *must* be invalid. - // function ComponentWithTernaryHook() { - // cond ? useTernaryHook() : null; - // } - // `, - - // Errors: []rule_tester.InvalidTestCaseError{ - // { - // MessageId: "conditionalHook", - // Line: 5, - // }, - // }, - // }, - // { - // Code: ` - // // Invalid because it's a common misunderstanding. - // // We *could* make it valid but the runtime error could be confusing. - // function ComponentWithHookInsideCallback() { - // useEffect(() => { - // useHookInsideCallback(); - // }); - // } - // `, - - // Errors: []rule_tester.InvalidTestCaseError{ - // { - // MessageId: "genericHook", - // Line: 6, - // }, - // }, - // }, - // { - // Code: ` - // // Invalid because it's a common misunderstanding. - // // We *could* make it valid but the runtime error could be confusing. - // function createComponent() { - // return function ComponentWithHookInsideCallback() { - // useEffect(() => { - // useHookInsideCallback(); - // }); - // } - // } - // `, - - // Errors: []rule_tester.InvalidTestCaseError{ - // { - // MessageId: "genericHook", - // Line: 7, - // }, - // }, - // }, - // { - // Code: ` - // // Invalid because it's a common misunderstanding. - // // We *could* make it valid but the runtime error could be confusing. - // const ComponentWithHookInsideCallback = React.forwardRef((props, ref) => { - // useEffect(() => { - // useHookInsideCallback(); - // }); - // return ; - // }); - // `, - - // Errors: []rule_tester.InvalidTestCaseError{ - // { - // MessageId: "conditionalHook", - // Line: 6, - // }, - // }, - // }, - // { - // Code: ` - // // Invalid because it's dangerous and might not warn otherwise. - // // This *must* be invalid. - // const FancyButton = forwardRef(function(props, ref) { - // if (props.fancy) { - // useCustomHook(); - // } - // return ; - // }); - // `, - - // Errors: []rule_tester.InvalidTestCaseError{ - // { - // MessageId: "conditionalHook", - // Line: 6, - // }, - // }, - // }, - // { - // Code: ` - // // Invalid because it's dangerous and might not warn otherwise. - // // This *must* be invalid. - // const MemoizedButton = memo(function(props) { - // if (props.fancy) { - // useCustomHook(); - // } - // return ; - // }); - // `, - - // Errors: []rule_tester.InvalidTestCaseError{ - // { - // MessageId: "conditionalHook", - // Line: 6, - // }, - // }, - // }, - // { - // Code: ` - // // This is invalid because "use"-prefixed functions used in named - // // functions are assumed to be hooks. - // React.unknownFunction(function notAComponent(foo, bar) { - // useProbablyAHook(bar) - // }); - // `, - - // Errors: []rule_tester.InvalidTestCaseError{ - // { - // MessageId: "functionHook", - // Line: 5, - // }, - // }, - // }, - // { - // Code: ` - // // Invalid because it's dangerous. - // // Normally, this would crash, but not if you use inline requires. - // // This *must* be invalid. - // // It's expected to have some false positives, but arguably - // // they are confusing anyway due to the use*() convention - // // already being associated with Hooks. - // useState(); - // if (foo) { - // const foo = React.useCallback(() => {}); - // } - // useCustomHook(); - // `, - - // Errors: []rule_tester.InvalidTestCaseError{ - // { - // MessageId: "topLevelHook", - // Line: 8, - // }, - // { - // MessageId: "topLevelHook", - // Line: 10, - // }, - // { - // MessageId: "topLevelHook", - // Line: 12, - // }, - // }, - // }, - // { - // Code: ` - // // Technically this is a false positive. - // // We *could* make it valid (and it used to be). - // // - // // However, top-level Hook-like calls can be very dangerous - // // in environments with inline requires because they can mask - // // the runtime error by accident. - // // So we prefer to disallow it despite the false positive. - - // const {createHistory, useBasename} = require('history-2.1.2'); - // const browserHistory = useBasename(createHistory)({ - // basename: '/', - // }); - // `, - - // Errors: []rule_tester.InvalidTestCaseError{ - // { - // MessageId: "topLevelHook", - // Line: 11, - // }, - // }, - // }, - // { - // Code: ` - // class ClassComponentWithFeatureFlag extends React.Component { - // render() { - // if (foo) { - // useFeatureFlag(); - // } - // } - // } - // `, + { + Code: ` + // Invalid because it's dangerous and might not warn otherwise. + // This *must* be invalid. + const FancyButton = forwardRef(function(props, ref) { + if (props.fancy) { + useCustomHook(); + } + return ; + }); + `, + Tsx: true, + Errors: []rule_tester.InvalidTestCaseError{ + { + MessageId: "conditionalHook", + Line: 6, + }, + }, + }, + { + Code: ` + // Invalid because it's dangerous and might not warn otherwise. + // This *must* be invalid. + const MemoizedButton = memo(function(props) { + if (props.fancy) { + useCustomHook(); + } + return ; + }); + `, + Tsx: true, + Errors: []rule_tester.InvalidTestCaseError{ + { + MessageId: "conditionalHook", + Line: 6, + }, + }, + }, + { + Code: ` + // This is invalid because "use"-prefixed functions used in named + // functions are assumed to be hooks. + React.unknownFunction(function notAComponent(foo, bar) { + useProbablyAHook(bar) + }); + `, - // Errors: []rule_tester.InvalidTestCaseError{ - // { - // MessageId: "classHook", - // Line: 5, - // }, - // }, - // }, - // { - // Code: ` - // class ClassComponentWithHook extends React.Component { - // render() { - // React.useState(); - // } - // } - // `, + Errors: []rule_tester.InvalidTestCaseError{ + { + MessageId: "functionHook", + Line: 5, + }, + }, + }, + { + Code: ` + // Invalid because it's dangerous. + // Normally, this would crash, but not if you use inline requires. + // This *must* be invalid. + // It's expected to have some false positives, but arguably + // they are confusing anyway due to the use*() convention + // already being associated with Hooks. + useState(); + if (foo) { + const foo = React.useCallback(() => {}); + } + useCustomHook(); + `, - // Errors: []rule_tester.InvalidTestCaseError{ - // { - // MessageId: "classHook", - // Line: 4, - // }, - // }, - // }, - // { - // Code: ` - // (class {useHook = () => { useState(); }}); - // `, + Errors: []rule_tester.InvalidTestCaseError{ + { + MessageId: "topLevelHook", + // Line: 8, + }, + { + MessageId: "topLevelHook", + // Line: 10, + }, + { + MessageId: "topLevelHook", + // Line: 12, + }, + }, + }, + { + Code: ` + // Technically this is a false positive. + // We *could* make it valid (and it used to be). + // + // However, top-level Hook-like calls can be very dangerous + // in environments with inline requires because they can mask + // the runtime error by accident. + // So we prefer to disallow it despite the false positive. + + const {createHistory, useBasename} = require('history-2.1.2'); + const browserHistory = useBasename(createHistory)({ + basename: '/', + }); + `, - // Errors: []rule_tester.InvalidTestCaseError{ - // { - // MessageId: "classHook", - // Line: 2, - // }, - // }, - // }, - // { - // Code: ` - // (class {useHook() { useState(); }}); - // `, + Errors: []rule_tester.InvalidTestCaseError{ + { + MessageId: "topLevelHook", + Line: 11, + }, + }, + }, + { + Code: ` + class ClassComponentWithFeatureFlag extends React.Component { + render() { + if (foo) { + useFeatureFlag(); + } + } + } + `, - // Errors: []rule_tester.InvalidTestCaseError{ - // { - // MessageId: "classHook", - // Line: 2, - // }, - // }, - // }, - // { - // Code: ` - // (class {h = () => { useState(); }}); - // `, + Errors: []rule_tester.InvalidTestCaseError{ + { + MessageId: "classHook", + Line: 5, + }, + }, + }, + { + Code: ` + class ClassComponentWithHook extends React.Component { + render() { + React.useState(); + } + } + `, - // Errors: []rule_tester.InvalidTestCaseError{ - // { - // MessageId: "classHook", - // Line: 2, - // }, - // }, - // }, - // { - // Code: ` - // (class {i() { useState(); }}); - // `, + Errors: []rule_tester.InvalidTestCaseError{ + { + MessageId: "classHook", + Line: 4, + }, + }, + }, + { + Code: ` + (class {useHook = () => { useState(); }}); + `, - // Errors: []rule_tester.InvalidTestCaseError{ - // { - // MessageId: "classHook", - // Line: 2, - // }, - // }, - // }, - // { - // Code: ` - // async function AsyncComponent() { - // useState(); - // } - // `, + Errors: []rule_tester.InvalidTestCaseError{ + { + MessageId: "classHook", + Line: 2, + }, + }, + }, + { + Code: ` + (class {useHook() { useState(); }}); + `, - // Errors: []rule_tester.InvalidTestCaseError{ - // { - // MessageId: "asyncComponentHook", - // Line: 3, - // }, - // }, - // }, - // { - // Code: ` - // async function useAsyncHook() { - // useState(); - // } - // `, + Errors: []rule_tester.InvalidTestCaseError{ + { + MessageId: "classHook", + Line: 2, + }, + }, + }, + { + Code: ` + (class {h = () => { useState(); }}); + `, - // Errors: []rule_tester.InvalidTestCaseError{ - // { - // MessageId: "asyncComponentHook", - // Line: 3, - // }, - // }, - // }, - // { - // Code: ` - // async function Page() { - // useId(); - // React.useId(); - // } - // `, + Errors: []rule_tester.InvalidTestCaseError{ + { + MessageId: "classHook", + Line: 2, + }, + }, + }, + { + Code: ` + (class {i() { useState(); }}); + `, - // Errors: []rule_tester.InvalidTestCaseError{ - // { - // MessageId: "asyncComponentHook", - // Line: 3, - // }, - // { - // MessageId: "asyncComponentHook", - // Line: 4, - // }, - // }, - // }, - // { - // Code: ` - // async function useAsyncHook() { - // useId(); - // } - // `, + Errors: []rule_tester.InvalidTestCaseError{ + { + MessageId: "classHook", + Line: 2, + }, + }, + }, + { + Code: ` + async function AsyncComponent() { + useState(); + } + `, - // Errors: []rule_tester.InvalidTestCaseError{ - // { - // MessageId: "asyncComponentHook", - // Line: 3, - // }, - // }, - // }, - // { - // Code: ` - // async function notAHook() { - // useId(); - // } - // `, + Errors: []rule_tester.InvalidTestCaseError{ + { + MessageId: "asyncComponentHook", + Line: 3, + }, + }, + }, + { + Code: ` + async function useAsyncHook() { + useState(); + } + `, - // Errors: []rule_tester.InvalidTestCaseError{ - // { - // MessageId: "functionHook", - // Line: 3, - // }, - // }, - // }, - // { - // Code: ` - // Hook.use(); - // Hook._use(); - // Hook.useState(); - // Hook._useState(); - // Hook.use42(); - // Hook.useHook(); - // Hook.use_hook(); - // `, + Errors: []rule_tester.InvalidTestCaseError{ + { + MessageId: "asyncComponentHook", + Line: 3, + }, + }, + }, + { + Code: ` + async function Page() { + useId(); + React.useId(); + } + `, - // Errors: []rule_tester.InvalidTestCaseError{ - // { - // MessageId: "topLevelHook", - // Line: 2, - // }, - // { - // MessageId: "topLevelHook", - // Line: 4, - // }, - // { - // MessageId: "topLevelHook", - // Line: 6, - // }, - // { - // MessageId: "topLevelHook", - // Line: 7, - // }, - // }, - // }, - // { - // Code: ` - // function notAComponent() { - // use(promise); - // } - // `, + Errors: []rule_tester.InvalidTestCaseError{ + { + MessageId: "asyncComponentHook", + // Line: 3, + }, + { + MessageId: "asyncComponentHook", + // Line: 4, + }, + }, + }, + { + Code: ` + async function useAsyncHook() { + useId(); + } + `, - // Errors: []rule_tester.InvalidTestCaseError{ - // { - // MessageId: "functionHook", - // Line: 3, - // }, - // }, - // }, - // { - // Code: ` - // const text = use(promise); - // function App() { - // return - // } - // `, + Errors: []rule_tester.InvalidTestCaseError{ + { + MessageId: "asyncComponentHook", + Line: 3, + }, + }, + }, + { + Code: ` + async function notAHook() { + useId(); + } + `, - // Errors: []rule_tester.InvalidTestCaseError{ - // { - // MessageId: "topLevelHook", - // Line: 2, - // }, - // }, - // }, - // { - // Code: ` - // class C { - // m() { - // use(promise); - // } - // } - // `, + Errors: []rule_tester.InvalidTestCaseError{ + { + MessageId: "functionHook", + Line: 3, + }, + }, + }, + { + Code: ` + Hook.use(); + Hook._use(); + Hook.useState(); + Hook._useState(); + Hook.use42(); + Hook.useHook(); + Hook.use_hook(); + `, - // Errors: []rule_tester.InvalidTestCaseError{ - // { - // MessageId: "classHook", - // Line: 4, - // }, - // }, - // }, - // { - // Code: ` - // async function AsyncComponent() { - // use(); - // } - // `, + Errors: []rule_tester.InvalidTestCaseError{ + { + MessageId: "topLevelHook", + // Line: 2, + }, + { + MessageId: "topLevelHook", + // Line: 4, + }, + { + MessageId: "topLevelHook", + // Line: 6, + }, + { + MessageId: "topLevelHook", + // Line: 7, + }, + }, + }, + { + Code: ` + function notAComponent() { + use(promise); + } + `, - // Errors: []rule_tester.InvalidTestCaseError{ - // { - // MessageId: "asyncComponentHook", - // Line: 3, - // }, - // }, - // }, - // { - // Code: ` - // function App({p1, p2}) { - // try { - // use(p1); - // } catch (error) { - // console.error(error); - // } - // use(p2); - // return
App
; - // } - // `, + Errors: []rule_tester.InvalidTestCaseError{ + { + MessageId: "functionHook", + Line: 3, + }, + }, + }, + { + Code: ` + const text = use(promise); + function App() { + return + } + `, + Tsx: true, + Errors: []rule_tester.InvalidTestCaseError{ + { + MessageId: "topLevelHook", + Line: 2, + }, + }, + }, + { + Code: ` + class C { + m() { + use(promise); + } + } + `, - // Errors: []rule_tester.InvalidTestCaseError{ - // { - // MessageId: "tryCatchUse", - // Line: 4, - // }, - // }, - // }, - // { - // Code: ` - // function App({p1, p2}) { - // try { - // doSomething(); - // } catch { - // use(p1); - // } - // use(p2); - // return
App
; - // } - // `, + Errors: []rule_tester.InvalidTestCaseError{ + { + MessageId: "classHook", + Line: 4, + }, + }, + }, + { + Code: ` + async function AsyncComponent() { + use(); + } + `, - // Errors: []rule_tester.InvalidTestCaseError{ - // { - // MessageId: "tryCatchUse", - // Line: 6, - // }, - // }, - // }, + Errors: []rule_tester.InvalidTestCaseError{ + { + MessageId: "asyncComponentHook", + Line: 3, + }, + }, + }, + { + Code: ` + function App({p1, p2}) { + try { + use(p1); + } catch (error) { + console.error(error); + } + use(p2); + return
App
; + } + `, + Tsx: true, + Errors: []rule_tester.InvalidTestCaseError{ + { + MessageId: "tryCatchUse", + Line: 4, + }, + }, + }, + { + Code: ` + function App({p1, p2}) { + try { + doSomething(); + } catch { + use(p1); + } + use(p2); + return
App
; + } + `, + Tsx: true, + Errors: []rule_tester.InvalidTestCaseError{ + { + MessageId: "tryCatchUse", + Line: 6, + }, + }, + }, }, ) } From ac97206d0dc62f4950021d70e8c89cd12ef4ab08 Mon Sep 17 00:00:00 2001 From: Cheng Liu Date: Fri, 29 Aug 2025 23:34:30 -0700 Subject: [PATCH 3/5] feat: add all --- internal/plugins/react_hooks/all.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 internal/plugins/react_hooks/all.go diff --git a/internal/plugins/react_hooks/all.go b/internal/plugins/react_hooks/all.go new file mode 100644 index 00000000..aaef3d57 --- /dev/null +++ b/internal/plugins/react_hooks/all.go @@ -0,0 +1,12 @@ +package react_hooks_plugin + +import ( + "github.com/web-infra-dev/rslint/internal/plugins/react_hooks/rules/rules_of_hooks" + "github.com/web-infra-dev/rslint/internal/rule" +) + +func GetAllRules() []rule.Rule { + return []rule.Rule{ + rules_of_hooks.RulesOfHooksRule, + } +} From a59959aa3152e24a8529d2a9fd18868c704608f7 Mon Sep 17 00:00:00 2001 From: Cheng Liu Date: Fri, 29 Aug 2025 23:49:13 -0700 Subject: [PATCH 4/5] chore: lint --- internal/plugins/import/plugin.go | 3 +- .../code_path_analysis/code_path.go | 2 +- .../code_path_analysis/fork_context.go | 6 +- .../code_path_analysis/loop_context.go | 2 +- .../code_path_analysis/switch_context.go | 2 +- .../code_path_analysis/try_context.go | 6 +- .../rules/rules_of_hooks/rules_of_hooks.go | 178 +++++++++--------- 7 files changed, 100 insertions(+), 99 deletions(-) diff --git a/internal/plugins/import/plugin.go b/internal/plugins/import/plugin.go index 187adec4..1baaec4f 100644 --- a/internal/plugins/import/plugin.go +++ b/internal/plugins/import/plugin.go @@ -1,2 +1,3 @@ package import_plugin -const PLUGIN_NAME = "eslint-plugin-import" \ No newline at end of file + +const PLUGIN_NAME = "eslint-plugin-import" diff --git a/internal/plugins/react_hooks/code_path_analysis/code_path.go b/internal/plugins/react_hooks/code_path_analysis/code_path.go index 289c43d8..b90e1fad 100644 --- a/internal/plugins/react_hooks/code_path_analysis/code_path.go +++ b/internal/plugins/react_hooks/code_path_analysis/code_path.go @@ -4,7 +4,7 @@ type CodePath struct { id string // An identifier origin string // The type of code path origin upper *CodePath // The code path of the upper function scope - onLooped func(fromSegment *CodePathSegment, toSegment *CodePathSegment) // A callback funciton to notify looping + onLooped func(fromSegment *CodePathSegment, toSegment *CodePathSegment) // A callback function to notify looping childCodePaths []*CodePath // The code paths of nested function scopes state *CodePathState // The state of the code path } diff --git a/internal/plugins/react_hooks/code_path_analysis/fork_context.go b/internal/plugins/react_hooks/code_path_analysis/fork_context.go index f93490d9..8b846304 100644 --- a/internal/plugins/react_hooks/code_path_analysis/fork_context.go +++ b/internal/plugins/react_hooks/code_path_analysis/fork_context.go @@ -102,7 +102,7 @@ func (fc *ForkContext) makeSegments(begin int, end int, create func(id string, a segments := make([]*CodePathSegment, 0) - for i := 0; i < fc.count; i++ { + for i := range fc.count { allPrevSegments := make([]*CodePathSegment, 0) for j := normalizedBegin; j <= normalizedEnd; j++ { allPrevSegments = append(allPrevSegments, list[j][i]) @@ -122,7 +122,7 @@ func (fc *ForkContext) mergeExtraSegments(segments []*CodePathSegment) []*CodePa merged := make([]*CodePathSegment, 0) length := len(currentSegments) / 2 - for i := 0; i < length; i++ { + for i := range length { segment := NewNextCodePathSegment( fc.idGenerator.Next(), []*CodePathSegment{ @@ -185,7 +185,7 @@ func removeSegment(segments []*CodePathSegment, target *CodePathSegment) []*Code // If there is the "default" chunk before other cases, the order is different // between node's and running's. func RemoveConnection(prevSegments []*CodePathSegment, nextSegments []*CodePathSegment) { - for i := 0; i < len(prevSegments); i++ { + for i := range prevSegments { prevSegment := prevSegments[i] nextSegment := nextSegments[i] diff --git a/internal/plugins/react_hooks/code_path_analysis/loop_context.go b/internal/plugins/react_hooks/code_path_analysis/loop_context.go index cc6d8ee6..3244e7c6 100644 --- a/internal/plugins/react_hooks/code_path_analysis/loop_context.go +++ b/internal/plugins/react_hooks/code_path_analysis/loop_context.go @@ -193,7 +193,7 @@ func (s *CodePathState) MakeWhileBody() { } // Update state. - if context.test != true { + if !context.test { context.brokenForkContext.AddAll(choiceContext.falseForkContext) } forkContext.ReplaceHead(choiceContext.trueForkContext.MakeNext(0, -1)) diff --git a/internal/plugins/react_hooks/code_path_analysis/switch_context.go b/internal/plugins/react_hooks/code_path_analysis/switch_context.go index 3267d83f..b02acfe6 100644 --- a/internal/plugins/react_hooks/code_path_analysis/switch_context.go +++ b/internal/plugins/react_hooks/code_path_analysis/switch_context.go @@ -80,7 +80,7 @@ func (s *CodePathState) PopSwitchContext() { } // Pops the segment context stack until the entry segment. - for i := 0; i < context.countForks; i++ { + for range context.countForks { s.forkContext = s.forkContext.upper } diff --git a/internal/plugins/react_hooks/code_path_analysis/try_context.go b/internal/plugins/react_hooks/code_path_analysis/try_context.go index a69d0c9c..73c300b6 100644 --- a/internal/plugins/react_hooks/code_path_analysis/try_context.go +++ b/internal/plugins/react_hooks/code_path_analysis/try_context.go @@ -139,13 +139,13 @@ func (s *CodePathState) MakeFinallyBlock() { // This segment will leave at the end of this finally block. segments := forkContext.MakeNext(-1, -1) - for i := 0; i < forkContext.count; i++ { + for i := range forkContext.count { prevSegsOfLeavingSegment := []*CodePathSegment{headOfLeavingSegments[i]} - for j := 0; j < len(returned.segmentsList); j++ { + for j := range len(returned.segmentsList) { prevSegsOfLeavingSegment = append(prevSegsOfLeavingSegment, returned.segmentsList[j][i]) } - for j := 0; j < len(thrown.segmentsList); j++ { + for j := range len(thrown.segmentsList) { prevSegsOfLeavingSegment = append(prevSegsOfLeavingSegment, thrown.segmentsList[j][i]) } diff --git a/internal/plugins/react_hooks/rules/rules_of_hooks/rules_of_hooks.go b/internal/plugins/react_hooks/rules/rules_of_hooks/rules_of_hooks.go index c5f068c0..1eb78b57 100644 --- a/internal/plugins/react_hooks/rules/rules_of_hooks/rules_of_hooks.go +++ b/internal/plugins/react_hooks/rules/rules_of_hooks/rules_of_hooks.go @@ -552,9 +552,10 @@ func isComponentName(name string) bool { // Helper function to check if a function is a hook func isHook(node *ast.Node) bool { - if node.Kind == ast.KindIdentifier { + switch node.Kind { + case ast.KindIdentifier: return isHookName(node.Text()) - } else if node.Kind == ast.KindPropertyAccessExpression { + case ast.KindPropertyAccessExpression: name := node.AsPropertyAccessExpression().Name() if name == nil || !isHook(name) { return false @@ -566,9 +567,8 @@ func isHook(node *ast.Node) bool { } return isPascalCaseNameSpace(expr.AsIdentifier().Text) - } else { - return false } + return false } // Helper function to get function name from AST node @@ -657,47 +657,47 @@ func isFunctionLike(node *ast.Node) bool { } // Helper function to check if node is inside a loop -func isInsideLoop(node *ast.Node) bool { - current := node.Parent - for current != nil { - kind := current.Kind - if kind == ast.KindForStatement || - kind == ast.KindForInStatement || - kind == ast.KindForOfStatement || - kind == ast.KindWhileStatement || - kind == ast.KindDoStatement { - return true - } - current = current.Parent - } - return false -} +// func isInsideLoop(node *ast.Node) bool { +// current := node.Parent +// for current != nil { +// kind := current.Kind +// if kind == ast.KindForStatement || +// kind == ast.KindForInStatement || +// kind == ast.KindForOfStatement || +// kind == ast.KindWhileStatement || +// kind == ast.KindDoStatement { +// return true +// } +// current = current.Parent +// } +// return false +// } // Helper function to check if node is inside a conditional -func isInsideConditional(node *ast.Node) bool { - current := node.Parent - for current != nil { - kind := current.Kind - if kind == ast.KindIfStatement || - kind == ast.KindConditionalExpression { - return true - } - // TODO: Check for logical operators (&& || ??) - if kind == ast.KindBinaryExpression { - binExpr := current.AsBinaryExpression() - if binExpr != nil { - op := binExpr.OperatorToken.Kind - if op == ast.KindAmpersandAmpersandToken || - op == ast.KindBarBarToken || - op == ast.KindQuestionQuestionToken { - return true - } - } - } - current = current.Parent - } - return false -} +// func isInsideConditional(node *ast.Node) bool { +// current := node.Parent +// for current != nil { +// kind := current.Kind +// if kind == ast.KindIfStatement || +// kind == ast.KindConditionalExpression { +// return true +// } +// // TODO: Check for logical operators (&& || ??) +// if kind == ast.KindBinaryExpression { +// binExpr := current.AsBinaryExpression() +// if binExpr != nil { +// op := binExpr.OperatorToken.Kind +// if op == ast.KindAmpersandAmpersandToken || +// op == ast.KindBarBarToken || +// op == ast.KindQuestionQuestionToken { +// return true +// } +// } +// } +// current = current.Parent +// } +// return false +// } // Helper function to check if node is inside a class func isInsideClass(node *ast.Node) bool { @@ -743,52 +743,52 @@ func isUseIdentifier(node *ast.Node) bool { } // Helper function to check if call expression is a hook call -func isHookCall(node *ast.Node) (bool, string) { - if node.Kind != ast.KindCallExpression { - return false, "" - } - - callExpr := node.AsCallExpression() - if callExpr == nil { - return false, "" - } - - // Get the callee and extract the hook name - // Handle different call patterns: - // - useHook() - // - React.useHook() - // - obj.useHook() - callee := callExpr.Expression - if callee == nil { - return false, "" - } - - switch callee.Kind { - case ast.KindIdentifier: - // Direct call: useHook() - identifier := callee.AsIdentifier() - if identifier != nil { - name := scanner.GetTextOfNode(&identifier.Node) - if isHookName(name) { - return true, name - } - } - case ast.KindPropertyAccessExpression: - // Property access: React.useHook(), obj.useHook() - propAccess := callee.AsPropertyAccessExpression() - if propAccess != nil { - nameNode := propAccess.Name() - if nameNode != nil { - name := scanner.GetTextOfNode(nameNode) - if isHookName(name) { - return true, name - } - } - } - } - - return false, "" -} +// func isHookCall(node *ast.Node) (bool, string) { +// if node.Kind != ast.KindCallExpression { +// return false, "" +// } + +// callExpr := node.AsCallExpression() +// if callExpr == nil { +// return false, "" +// } + +// // Get the callee and extract the hook name +// // Handle different call patterns: +// // - useHook() +// // - React.useHook() +// // - obj.useHook() +// callee := callExpr.Expression +// if callee == nil { +// return false, "" +// } + +// switch callee.Kind { +// case ast.KindIdentifier: +// // Direct call: useHook() +// identifier := callee.AsIdentifier() +// if identifier != nil { +// name := scanner.GetTextOfNode(&identifier.Node) +// if isHookName(name) { +// return true, name +// } +// } +// case ast.KindPropertyAccessExpression: +// // Property access: React.useHook(), obj.useHook() +// propAccess := callee.AsPropertyAccessExpression() +// if propAccess != nil { +// nameNode := propAccess.Name() +// if nameNode != nil { +// name := scanner.GetTextOfNode(nameNode) +// if isHookName(name) { +// return true, name +// } +// } +// } +// } + +// return false, "" +// } // Helper function to check if node is at top level func isTopLevel(node *ast.Node) bool { From b053be85dc3fb641cbc60a39a469d7d1e701f7e0 Mon Sep 17 00:00:00 2001 From: Cheng Liu Date: Fri, 29 Aug 2025 23:54:28 -0700 Subject: [PATCH 5/5] chore: code changes according to cr --- .../rules/rules_of_hooks/rules_of_hooks.go | 101 +----------------- 1 file changed, 1 insertion(+), 100 deletions(-) diff --git a/internal/plugins/react_hooks/rules/rules_of_hooks/rules_of_hooks.go b/internal/plugins/react_hooks/rules/rules_of_hooks/rules_of_hooks.go index 1eb78b57..88c890ee 100644 --- a/internal/plugins/react_hooks/rules/rules_of_hooks/rules_of_hooks.go +++ b/internal/plugins/react_hooks/rules/rules_of_hooks/rules_of_hooks.go @@ -345,7 +345,7 @@ var RulesOfHooksRule = rule.Rule{ if isInsideClass(codePathNode) { ctx.ReportNode(hook, buildClassHookMessage(hookText)) } else if codePathFunctionName != "" { - // Custom message if we found an invalid function name.kj + // Custom message if we found an invalid function name. ctx.ReportNode(hook, buildFunctionHookMessage(hookText, codePathFunctionName)) } else if isTopLevel(codePathNode) { // These are dangerous if you have inline requires enabled. @@ -656,49 +656,6 @@ func isFunctionLike(node *ast.Node) bool { kind == ast.KindMethodDeclaration } -// Helper function to check if node is inside a loop -// func isInsideLoop(node *ast.Node) bool { -// current := node.Parent -// for current != nil { -// kind := current.Kind -// if kind == ast.KindForStatement || -// kind == ast.KindForInStatement || -// kind == ast.KindForOfStatement || -// kind == ast.KindWhileStatement || -// kind == ast.KindDoStatement { -// return true -// } -// current = current.Parent -// } -// return false -// } - -// Helper function to check if node is inside a conditional -// func isInsideConditional(node *ast.Node) bool { -// current := node.Parent -// for current != nil { -// kind := current.Kind -// if kind == ast.KindIfStatement || -// kind == ast.KindConditionalExpression { -// return true -// } -// // TODO: Check for logical operators (&& || ??) -// if kind == ast.KindBinaryExpression { -// binExpr := current.AsBinaryExpression() -// if binExpr != nil { -// op := binExpr.OperatorToken.Kind -// if op == ast.KindAmpersandAmpersandToken || -// op == ast.KindBarBarToken || -// op == ast.KindQuestionQuestionToken { -// return true -// } -// } -// } -// current = current.Parent -// } -// return false -// } - // Helper function to check if node is inside a class func isInsideClass(node *ast.Node) bool { current := node.Parent @@ -742,64 +699,8 @@ func isUseIdentifier(node *ast.Node) bool { return isReactFunction(node, "use") } -// Helper function to check if call expression is a hook call -// func isHookCall(node *ast.Node) (bool, string) { -// if node.Kind != ast.KindCallExpression { -// return false, "" -// } - -// callExpr := node.AsCallExpression() -// if callExpr == nil { -// return false, "" -// } - -// // Get the callee and extract the hook name -// // Handle different call patterns: -// // - useHook() -// // - React.useHook() -// // - obj.useHook() -// callee := callExpr.Expression -// if callee == nil { -// return false, "" -// } - -// switch callee.Kind { -// case ast.KindIdentifier: -// // Direct call: useHook() -// identifier := callee.AsIdentifier() -// if identifier != nil { -// name := scanner.GetTextOfNode(&identifier.Node) -// if isHookName(name) { -// return true, name -// } -// } -// case ast.KindPropertyAccessExpression: -// // Property access: React.useHook(), obj.useHook() -// propAccess := callee.AsPropertyAccessExpression() -// if propAccess != nil { -// nameNode := propAccess.Name() -// if nameNode != nil { -// name := scanner.GetTextOfNode(nameNode) -// if isHookName(name) { -// return true, name -// } -// } -// } -// } - -// return false, "" -// } - // Helper function to check if node is at top level func isTopLevel(node *ast.Node) bool { - // current := node.Parent - // for current != nil { - // if isFunctionLike(current) { - // return false - // } - // current = current.Parent - // } - // return true return node.Kind == ast.KindSourceFile }