class Analyzer::Javascript::RouteHelperScanner

Overview

Express routes registered through a project-local forwarding helper rather than by calling router.get(...) at the call site.

// src/routes/helpers.js helpers.setupApiRoute = function (...args) { const [router, verb, name] = args; ... router[verb](name, middlewares, tryRoute(controller)); };

// src/routes/write/categories.js const { setupApiRoute } = require('../helpers'); setupApiRoute(router, 'get', '/:cid', [], controllers.write.categories.get);

The verb and the path are arguments, so no verb-DSL pattern matches the call site, and the helper's own body carries no literal path — the route is invisible to every other pass. NodeBB registers its entire write API (208 calls), its page routes (69) and its admin page routes (61) this way.

What counts as a forwarding helper

A function whose body calls <recv>[<verb>](<path>, …) or <recv>.<literal verb>(<path>, …) where <recv> and <path> are both bound to that function's own parameters. The binding may be direct (function (router, verb, name)) or through a rest parameter that is destructured positionally (function (...args) + const [router, verb, name] = args), which is the shape NodeBB uses.

Why this does not mint routes for arbitrary calls

An earlier audit found ~250 NodeBB "endpoints" that were really HTTP client calls in test/*.js. Anything that lets a plain function call produce a route risks reopening that, so acceptance is deliberately narrow:

Included Modules

Defined in:

analyzer/analyzers/javascript/express/route_helper_scanner.cr

Constant Summary

FORWARD_CALL_RE = /([A-Za-z_$][\w$]*)\s*(?:\[\s*([A-Za-z_$][\w$]*)\s*\]|\.\s*(get|post|put|delete|patch|head|options|all))\s*\(\s*(`[^`\n]*`|[A-Za-z_$][\w$]*)\s*,/

The forwarded-registration shape, used both as the per-file gate and as the extractor. Group 1 is the receiver, group 2 the verb variable of the computed form, group 3 a literal verb, group 4 the path expression. The trailing comma is what enforces "at least one argument after the path".

FUNCTION_DEF_RES = [/\bfunction\s+([A-Za-z_$][\w$]*)\s*\(/, /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s+)?function\s*\*?\s*\(/, /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s+)?\(/, /(?:^|[;{}\n])\s*(?:[A-Za-z_$][\w$]*\s*\.\s*)+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s+)?function\s*\*?\s*\(/, /(?:^|[;{}\n])\s*(?:[A-Za-z_$][\w$]*\s*\.\s*)+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s+)?\(/]

Function definitions whose parameter list can be read positionally. The captured name is the local one; for helpers.setupApiRoute = … it is the last dotted segment, which is also the name importers see through const { setupApiRoute } = require('./helpers').

Every pattern ends at the opening parenthesis of the parameter list, so end(0) - 1 is that parenthesis.

FUNCTION_GLUE_MAX = 120
FUNCTION_GLUE_RE = /\A[\s=>:|,.<>\[\]A-Za-z_$0-9]*\z/

Between the parameter list and the body there may only be syntactic glue: =>, a TypeScript return type, whitespace. Anything else means the { we found opens a later construct, not this function's body.

HTTP_VERBS = ["get", "post", "put", "delete", "patch", "head", "options", "all"] of ::String
MAX_ATTRIBUTION_WORK = 1000000

Ceiling on functions x forward matches in one file, past which the innermost-enclosing-function attribution is abandoned rather than run.

MODULE_IMPORT_RES = [/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*require\s*\(\s*['"]([^'"]+)['"]\s*\)/, /import\s+([A-Za-z_$][\w$]*)\s+from\s*['"]([^'"]+)['"]/]
NAMED_IMPORT_RES = [/(?:const|let|var)\s*\{\s*([\s\S]*?)\s*\}\s*=\s*require\s*\(\s*['"]([^'"]+)['"]\s*\)/, /import\s*\{\s*([\s\S]*?)\s*\}\s*from\s*['"]([^'"]+)['"]/]
REST_DESTRUCTURE_RE = /(?:const|let|var)\s*\[([^\]\n]*)\]\s*=\s*([A-Za-z_$][\w$]*)/

const [router, verb, name] = args — the positional read of a rest parameter. Holes (const [, verb] = args) keep their position.

ROUTER_PARAM_RE = /\A_?(?:router|app|server|[A-Za-z_$][\w$]*(?:Router|App|Server|router|app|server))\z/

A router-shaped parameter name. Required of the receiver, whichever form the forward takes.

This is the load-bearing false-positive guard. Without it a generic client wrapper — exports.request = function (client, method, url) { return client[method](url, opts) }, called as request(axios, 'get', '/api/remote/users') — satisfies every other condition, and every such call becomes a route. The computed recv[verb](path, …) shape looked distinctive enough to exempt until a hostile fixture showed it is exactly how a verb-parameterised HTTP client is called.

api is deliberately absent: api[method](url, body) on an axios instance is a common client idiom. The cost of the rule is that a helper whose router parameter is named something else (r, mux) is not recognised, which is the narrow-but-defensible side of the trade.

TEST_TREE_RE = /(?:\A|\/)(?:tests?|specs?|e2e|e2e-tests|__tests__|__mocks__|cypress|playwright)\/|[.\-](?:test|spec)\.[jt]sx?\z/

A test tree, matched on the scan-base-relative path. Broader than JSRouteExtractor.test_stub_only?, which only fires on dedicated mock/e2e directories and so lets a plain test/api.js through — NodeBB's does exactly that. A route helper invoked from a test is never a production registration, and this pass only ever adds endpoints, so excluding the whole test tree costs nothing real.

Constructors

Instance Method Summary

Class methods inherited from module Analyzer::Javascript::ExpressConstants

file_key(file_path : String) : Noir::LocatorKey(Array(String)) file_key, function_key(file_path : String, function_name : String) : Noir::LocatorKey(Array(String)) function_key

Constructor Detail

def self.new(all_files : Array(String), base_paths : Array(String), base_path : String, logger : NoirLogger) #

[View source]

Instance Method Detail

def any_helpers? : Bool #

[View source]
def forward_lines(file : String) : Set(Int32) #

Lines of the forwarded calls inside the helpers defined in file. Express uses them to drop the phantom endpoint the generic parser mints from a helper's own router.get(/api${name}, …): that line declares a rewrite, it does not register a path.


[View source]
def helpers : Hash(String, Hash(String, HelperSpec)) #

Defining file → helper name → spec.


[View source]
def index : Nil #

PASS 1: index every forwarding helper in the tree.


[View source]
def routes_for(file : String, content : String) : Array(HelperRoute) #

PASS 2: routes registered by calling an indexed helper from file.


[View source]