class

Analyzer::Javascript::RouteHelperScanner

Inherits Analyzer::Javascript::ExpressConstants < Reference < Object

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:

  • The receiver, the path — and, for the computed form, the verb — must all be parameters of the same function. request.get(url) inside function fetch(url) fails on the receiver.
  • The forwarded call must pass at least one argument after the path. A one-argument client.get(url) is not a registration.
  • The receiver parameter must be named like a router (router, app, server, *Router, *App, *Server). That is what separates a route helper from a generic client wrapper such as function request(client, method, url) { client[method](url, o) }, which satisfies every other condition.
  • At the call site both the verb and the path must be literals. A computed path yields nothing rather than a guess.
  • Definitions and call sites in test-stub / minified files are skipped, exactly as JSRouteExtractor.extract_routes skips them.

Constants

CALL_SITE = /(?<![\w$.])([A-Za-z_$][\w$]*)\s*(?:\.\s*([A-Za-z_$][\w$]*)\s*|\[\s*['"]([^'"]+)['"]\s*\]\s*)?\(/

name(, obj.key(, obj['key'](: what a file calls, so only the bindings it calls are resolved on disk.

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

FORWARD_CALL_RE minus its leading receiver: a necessary condition for it, checked first. The full pattern opens with an identifier class, so PCRE2 has no first byte to skip to and tries it at every position of the file; this one opens at a [ or ., which it can search for. Over 15k JS/TS files (78 MB) the gate alone takes 23ms against 330ms for the full pattern, and it rejects all but a few hundred files.

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
IDENTIFIER = /\A[A-Za-z_$][\w$]*\z/
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_EXPORT_FN = /^[ \t]*module\s*\.\s*exports\s*=\s*(?:async\s+)?(?:function\b[^(\n]*)?\(/m
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*['"]([^'"]+)['"]/]
MODULE_OBJ_MEMBER = /(?:^|[{,])\s*(?:([A-Za-z_$][\w$]*)|['"]([^'"]+)['"])\s*:\s*require\s*\(\s*['"]([^'"]+)['"]\s*\)/m
MODULE_REQUIRE_BIND = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*require\s*\(\s*['"]([^'"]+)['"]\s*\)\s*[;\n]/
MODULE_REQUIRE_CALL = /\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)\s*\(/
MODULE_REQUIRE_OBJ = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*\{/
NAMED_IMPORT_RES = [/(?:const|let|var)\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

new(all_files : Array(String), base_paths : Array(String), base_path : String, logger : NoirLogger)
Source

Instance methods

any_helpers?
Source
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.

Source
helpers

Defining file → helper name → spec.

Source
index

PASS 1: index every forwarding helper in the tree.

Source
routes_for(file : String, content : String) : Array(HelperRoute)

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

Source

Nested types