UNPKG

coffeelint-multiple-callback

Version:

Coffeelint rule that checks for callbacks being called multiple times

522 lines (398 loc) 17 kB
_ = require 'lodash' Branch = require './Branch' prefix = 'prefix' getNodeType = (node)-> return node.constructor.name # Given a var_name, dumps the var name we will consider as being incremented # or null if we don't think its a callback variable getCallbackVarName = (var_name)-> express_regex = /// ^res\. # var name matches res.[blah] ( _end | end | json | redirect | render | send )$ | ^next$ # var name is "next" ///i promise_regex = /// ^(resolve|reject)$ /// # if it matches an express callback, return "express variable" if express_regex.test var_name return 'express variable' # if it matches an promise callback, return "promise variable" if promise_regex.test var_name return 'promise variable' regex = /// (^(cb|callback|fn|cbb)\d*$) # var name exact matches | (^(cb|callback|fn)_) # var name that starts with "cb_" | (_(cb|callback|fn)\d*$) # var name that ends with "_cb" | (^(express|promise)\svariable$) # one of our special variables ///i # if it looks like a cb variable, return the var name if regex.test var_name return var_name return null variableObjToStr = (variable_obj)-> var_name = variable_obj.base.value accessors = _.filter variable_obj.properties, (prop)-> out = getNodeType(prop) is 'Access' return out if not _.isEmpty accessors var_name_pieces = [] var_name_pieces.push var_name _.each accessors, (prop)-> var_name_pieces.push prop.name.value return var_name = var_name_pieces.join '.' return var_name isExempt = (func_name)-> exempt_regex = /// ^(module\.)?exports(\.|$) # module = # module.exports = # module.exports.blah.... = | ^constructor$ # class constructors /// if exempt_regex.test func_name return true return false class ForkLinter constructor: ()-> @current_branch = null @root_branch = null @stack = [] return lint: (root, @options)=> @errors = [] @current_branch = new Branch(null, true) @root_branch = @current_branch @visit root @root_branch.is_end_of_branch = true @root_branch.is_dead_branch = true @root_branch.is_new_scope = false @checkBranchForBadCalls @root_branch return checkBranchForBadCalls: (branch)=> calls = branch.getCalls() _.each calls, (call_obj)=> @checkForBadCall call_obj, branch return return checkForBadCall: (call_obj, branch)=> if isExempt call_obj.func_name return if call_obj.might_not_be_func # see if the name of the variable is callback-esque. if not, we'll skip over it if not getCallbackVarName call_obj.func_name return # check if this func might never be called :0!! if call_obj.min_hits is 0 # only trigger if this is the end of a branch and the var was defined in this branch if branch.isEndOfFuncExistence(call_obj) # branch.isDeadBranch() if not call_obj.triggered_errors.no_hits call_obj.triggered_errors.no_hits = true err_msg = "Callback '#{call_obj.func_name}' has the chance of never being called." @throwError call_obj.def_node, err_msg, call_obj.func_name, call_obj.min_hits, call_obj # check if this func has been called multiple times :0!! if call_obj.max_hits > 1 if not call_obj.triggered_errors.multiple_hits if call_obj.is_defined_in_this_file call_obj.triggered_errors.multiple_hits = true # guarenteed to be length - max_hits_this_branch first_bad_node = call_obj.called_at_nodes[1] err_msg = "Callback '#{call_obj.func_name}' has the chance of being called multiple times (#{call_obj.max_hits})." @throwError first_bad_node, err_msg, call_obj.func_name, call_obj.max_hits, call_obj return addBranch: (is_new_scope, cb)=> parent_branch = @current_branch child_branch = new Branch parent_branch, is_new_scope @current_branch = child_branch cb() # @current_branch.is_dead_branch = not is_new_scope @current_branch.is_end_of_branch = true @checkBranchForBadCalls @current_branch @current_branch = parent_branch return child_branch mergeForkBranches: (fork_branches...)=> out_calls = {} first_branch = _.first fork_branches parent_branch = first_branch.getParentBranch() _.each fork_branches, (fork_branch)=> _.each fork_branch.getCalls(), (call_obj, prefixed_func_name)=> # skip if it was defined in the fork if call_obj.is_defined_in_this_branch return # if not already in out, just add it and return to the loop if not out_calls[prefixed_func_name] out_calls[prefixed_func_name] = call_obj if fork_branch.isDeadBranch() out_calls[prefixed_func_name].max_hits_this_branch = 0 out_calls[prefixed_func_name].called_at_nodes = [] out_calls[prefixed_func_name].called_at_nodes_this_branch = [] # then resume _.each loop return out_calls[prefixed_func_name].triggered_errors.no_hits |= call_obj.no_hits out_calls[prefixed_func_name].triggered_errors.multiple_hits |= call_obj.multiple_hits if not fork_branch.isDeadBranch() out_calls[prefixed_func_name].def_node ?= call_obj.def_node out_calls[prefixed_func_name].called_at_nodes_this_branch = out_calls[prefixed_func_name].called_at_nodes_this_branch.concat call_obj.called_at_nodes_this_branch out_calls[prefixed_func_name].max_hits_this_branch = _.max [out_calls[prefixed_func_name].max_hits_this_branch, call_obj.max_hits_this_branch] out_calls[prefixed_func_name].min_hits_this_branch = _.min [out_calls[prefixed_func_name].min_hits_this_branch, call_obj.min_hits_this_branch] # out_calls[prefixed_func_name].is_defined_in_this_scope |= call_obj.is_defined_in_this_scope out_calls[prefixed_func_name].might_not_be_func &= call_obj.might_not_be_func return return parent_calls = parent_branch.getCalls() _.each out_calls, (call_obj, prefixed_func_name)-> if not parent_calls[prefixed_func_name] parent_branch.initBlankFunc call_obj.func_name call_obj.min_hits = parent_calls[prefixed_func_name].min_hits + out_calls[prefixed_func_name].min_hits_this_branch call_obj.max_hits = parent_calls[prefixed_func_name].max_hits + out_calls[prefixed_func_name].max_hits_this_branch call_obj.called_at_nodes_this_branch = parent_calls[prefixed_func_name].called_at_nodes_this_branch.concat out_calls[prefixed_func_name].called_at_nodes_this_branch call_obj.called_at_nodes = parent_calls[prefixed_func_name].called_at_nodes.concat out_calls[prefixed_func_name].called_at_nodes_this_branch return # # merge in current branch's calls, overwriting values that it has priority over # _.each @current_branch.getCalls(), (call_obj, prefixed_func_name)=> # if not out_calls[prefixed_func_name] # out_calls[prefixed_func_name] = call_obj # return # ## out_calls[prefixed_func_name].def_node ?= call_obj.def_node ## out_calls[prefixed_func_name].called_at_nodes = out_calls[prefixed_func_name].called_at_nodes.concat call_obj.called_at_nodes # out_calls[prefixed_func_name].min_hits = _.min [out_calls[prefixed_func_name].min_hits, call_obj.min_hits] # out_calls[prefixed_func_name].max_hits = _.max [out_calls[prefixed_func_name].max_hits, call_obj.max_hits] # # out_calls[prefixed_func_name].is_defined_in_this_scope = call_obj.is_defined_in_this_scope # out_calls[prefixed_func_name].might_not_be_func &= call_obj.might_not_be_func # # # return combined_branch = new Branch parent_branch, false # combined_branch = new Branch parent_branch, first_branch.is_new_scope combined_branch.calls = out_calls return combined_branch visit: (node)=> node_type = getNodeType(node) handler = @["visit#{node_type}"] # console.log "Visiting #{node_type}" if handler? handler node else # console.log "No handler for #{node_type}" node.eachChild @visit return visitReturn: (node)=> # console.log node @current_branch.is_dead_branch = true @current_branch.is_end_of_branch = true # @checkForBadCall @current_branch.calls # @current_branch.calls = {} return false visitDef: (node, func_name, might_not_be_func = true)=> converted_func_name = getCallbackVarName(func_name) or func_name if not converted_func_name throw new Error "no converted_func_name passed to visitDef()" # converted_func_name = variableObjToStr node.variable @current_branch.addFuncDef converted_func_name, node, might_not_be_func return visitCall: (node)=> # FIXME: handle do variable = node.variable # trigger a call on each param # This happens before the main Call handling because coffeescript doesn't have a node for a super() call if node.args _.each node.args, (child_node)=> child_node_type = getNodeType child_node switch child_node_type when 'Value' # we only care about variables. don't care about strings, numbers, etc if child_node.isAssignable() called_func_name = variableObjToStr child_node converted_called_func_name = getCallbackVarName(called_func_name) or called_func_name @current_branch.addFuncCall converted_called_func_name, node, false # end when Value return # Now handle the Call node # super() has a null variable or something if not variable node.eachChild @visit return switch getNodeType variable # if a call calls a call, it needs to be handled special when 'Call' @visitCall variable return func_name = variableObjToStr variable converted_func_name = getCallbackVarName(func_name) or func_name @current_branch.addFuncCall converted_func_name, node node.eachChild @visit return # when 'CodeFragment', 'Base', 'Block', 'Literal', 'Undefined', 'Null', 'Bool', 'Return', 'Value', 'Comment', 'Call', 'Extends', 'Access' ,'Index', 'Range', 'Slice', 'Obj', 'Arr', 'Class', 'Assign', 'Code', 'Param', 'Splat', 'Expansion', 'While', 'Op', 'In', 'Try', 'Throw' ,'Existence', 'Parens', 'For', 'Switch', 'If' visitAssign: (node)=> # TODO: bring this back and check for whether or not a func is module.exported to flag it as used # node_type = getNodeType node.value # if node_type is 'Code' # func_name = variableObjToStr node.variable # if func_name # @visitDef node, func_name, false node.eachChild @visit return visitIf: (node)=> block1 = node.body block2 = node.elseBody condition = node.condition # check for these: # if callback # if callback? # if not callback # if not callback? invert_checking_existence_varname = false while condition.first and condition.operator is '!' invert_checking_existence_varname ^= true condition = condition.first if condition.expression condition = condition.expression checking_existence_varname = condition.base?.value if checking_existence_varname existence_func_name = variableObjToStr condition existence_converted_func_name = getCallbackVarName(existence_func_name) or existence_func_name block1_branch = @addBranch false, ()=> # if the if's condition is checking for the existence of a variable and it has a "not" operator, # trigger this condition as a call, because missing a call in this branch is considered okay if existence_converted_func_name and invert_checking_existence_varname @current_branch.addFuncCall existence_converted_func_name, condition, false block1.eachChild @visit return block2_branch = @addBranch false, ()=> # if the if's condition is checking for the existence of a variable and it doesn't have a "not" operator, # trigger this condition as a call, because missing a call in this branch is considered okay if existence_converted_func_name and not invert_checking_existence_varname @current_branch.addFuncCall existence_converted_func_name, condition, false if block2 block2.eachChild @visit return # @checkBranchForBadCalls block1_branch # @checkBranchForBadCalls block2_branch # AFTER processing all the branches, merge the calls back in # if child hit a Return, then all its calls have already been emptied out fork_branch = @mergeForkBranches block1_branch, block2_branch @current_branch.mergeChildCalls fork_branch # @current_branch.calls = fork_calls @checkBranchForBadCalls @current_branch return visitSwitch: (node)=> branches = [] block_else = node.otherwise _.each node.cases, (block_obj)=> # the second arg to the block obj is always the actual Block block = block_obj[1] block_branch = @addBranch false, ()=> block.eachChild @visit return branches.push block_branch return else_branch = @addBranch false, ()=> if block_else block_else.eachChild @visit return # AFTER processing all the branches, merge the calls back in fork_branch = @mergeForkBranches branches..., else_branch @current_branch.mergeChildCalls fork_branch @checkBranchForBadCalls @current_branch return visitParam: (node)=> func_name = node.name.value # func_name = getCallbackVarName node.variable # # if func_name # @current_branch.addFuncDef func_name, node if func_name @visitDef node, func_name, true return return visitCode: (node)=> child_branch = @addBranch true, ()=> node.eachChild @visit return # IDK!: # @checkBranchForBadCalls child_branch @current_branch.mergeChildCalls child_branch @checkBranchForBadCalls @current_branch return # visitdBlock: (node)=> # @visitCode node ## child_branch = @addBranch true, ()=> ## node.eachChild @visit ## return ## ## # IDK!: ## @checkBranchForBadCalls child_branch ## @current_branch.mergeChildCalls child_branch ## @checkBranchForBadCalls @current_branch # return # visitLiteral: ()=> # return visitClass: (node)=> node.body.eachChild (child_node)=> node_type = getNodeType child_node switch node_type when 'Value' # if a body node of a Class is a Value, that means it's a method or variable applied directly to the class child_node.base.eachChild (method_node)=> method_body_node = method_node.value @visit method_body_node return else # otherwise, it behaves as normal code child_node.eachChild @visit return return visitTry: (node)=> try_block = node.attempt catch_block = node.recovery finally_block = node.ensure # possible routes # (try -> catch) -> finally # (try) -> finally # we have two branches we're forking into: either both try and catch are hit, or only try # we have to group in the try block on both because there's a chance it returns and we have to handle that properly catch_branch_hit = @addBranch false, ()=> try_block.eachChild @visit if catch_block catch_block.eachChild @visit return catch_branch_skipped = @addBranch false, ()=> try_block.eachChild @visit return # FIXME: we might also need a branch to account for Try being hit, but not hitting an inner Return? # AFTER processing all the branches, merge the calls back in catch_fork_branch = @mergeForkBranches catch_branch_hit, catch_branch_skipped @current_branch.mergeChildCalls catch_fork_branch # then hit finally if finally_block finally_block.eachChild @visit @checkBranchForBadCalls @current_branch return throwError: (node, err_msg, func_name, hits, call_obj)=> if not node or not node.locationData throw new Error "Missing node.locationData for throwError()" return err_obj = lineNumber: node.locationData.first_line + 1 func_name: func_name hits: hits call_obj: call_obj if err_msg err_obj.message = err_msg if not _.isEmpty call_obj.called_at_nodes called_at_lines = _.map call_obj.called_at_nodes, (called_at_node)-> out = called_at_node.locationData.first_line + 1 return out err_obj.message += " - Called at #{called_at_lines.join(', ')}" @errors.push err_obj return module.exports = ForkLinter