class

Analyzer::Ruby::Rails

Inherits Analyzer::Ruby::RubyEngine < Analyzer < FileHelper < Reference < Object

Constants

APP_CONFIG_REF = /\b(?:Rails\.configuration|Rails\.application\.config|[A-Z][A-Za-z0-9_]*::Application\.config)\.([a-z_][a-z0-9_]*)\b(?![\w(\[.=])/
BARE_VAR_ARG = /\A((?:scope|namespace|get|post|put|patch|delete|options|head|match|resources?)\s*\(?\s*)([a-z_][a-z0-9_]*)(?=\s*(?:,|=>|\)|\bdo\b|\z))/
CALLBACK_PARAM_TAG = Tag.new("rails_callback_param", "", "rails")
CONFIG_ASSIGN = /^\s*config\.([a-z_][a-z0-9_]*)\s*(=(?![=~>])|\|\|=|&&=|\+=|<<)(.*)$/
CONFIG_LITERAL = /\A\s*["']([^"'#]*)["']\s*(?:#.*)?\z/
CONTAINER_USE = /\A\s*&?\.\s*(?:(?:is_a\?|kind_of\?|instance_of\?)\s*\(?\s*(?:Hash|ActionController::Parameters|Array)\b|key\?|has_key\?|keys\b|each\b|to_unsafe_h\b|to_h\b|slice\b|except\b)/

A read that treats the value as a hash rather than a field. is_a?(String) asks whether the value is a scalar, which is a field.

DEVISE_ACTION_PARAMS = { {"sessions", "create"} => {"sign_in_params", ["email", "password"] of ::String}, {"registrations", "create"} => {"sign_up_params", ["email", "password", "password_confirmation"] of ::String}, {"registrations", "update"} => {"account_update_params", ["email", "password", "password_confirmation", "current_password"] of ::String}, {"passwords", "create"} => {"resource_params", ["email"] of ::String}, {"passwords", "update"} => {"resource_params", ["reset_password_token", "password", "password_confirmation"] of ::String}, {"confirmations", "create"} => {"resource_params", ["email"] of ::String}, {"unlocks", "create"} => {"resource_params", ["email"] of ::String}}

Parameters Devise's built-in controllers read per {group, action}, paired with the helper method that sanitizes them (user[...] nesting dropped, matching permit extraction). Attached when the action is handled by Devise itself or by a subclass that does not redefine that helper.

DEVISE_DEFAULT_MODULES = ["database_authenticatable", "registerable", "recoverable", "confirmable", "lockable"] of ::String

Modules assumed when app/models/<scope>.rb is not in the scan. :omniauthable is opt-in and only emits routes when declared on the model.

DEVISE_MODULE_ROUTES = {"database_authenticatable" => DEVISE_SESSION_ROUTES, "two_factor_authenticatable" => DEVISE_SESSION_ROUTES, "registerable" => [{"GET", "%{registration}/%{cancel}", "registrations", "cancel"}, {"GET", "%{registration}/%{sign_up}", "registrations", "new"}, {"GET", "%{registration}/%{edit}", "registrations", "edit"}, {"PATCH", "%{registration}", "registrations", "update"}, {"PUT", "%{registration}", "registrations", "update"}, {"DELETE", "%{registration}", "registrations", "destroy"}, {"POST", "%{registration}", "registrations", "create"}], "recoverable" => [{"GET", "%{password}/new", "passwords", "new"}, {"POST", "%{password}", "passwords", "create"}, {"GET", "%{password}/edit", "passwords", "edit"}, {"PATCH", "%{password}", "passwords", "update"}, {"PUT", "%{password}", "passwords", "update"}], "confirmable" => [{"GET", "%{confirmation}/new", "confirmations", "new"}, {"POST", "%{confirmation}", "confirmations", "create"}, {"GET", "%{confirmation}", "confirmations", "show"}], "lockable" => [{"GET", "%{unlock}/new", "unlocks", "new"}, {"POST", "%{unlock}", "unlocks", "create"}, {"GET", "%{unlock}", "unlocks", "show"}], "omniauthable" => [{"GET", "auth/:provider", "omniauth_callbacks", "passthru"}, {"POST", "auth/:provider", "omniauth_callbacks", "passthru"}, {"GET", "auth/:provider/callback", "omniauth_callbacks", ":provider"}, {"POST", "auth/:provider/callback", "omniauth_callbacks", ":provider"}]}
DEVISE_PATH_NAMES = {"sign_in" => "sign_in", "sign_out" => "sign_out", "sign_up" => "sign_up", "password" => "password", "confirmation" => "confirmation", "unlock" => "unlock", "registration" => "", "edit" => "edit", "cancel" => "cancel"}

Devise's Mapping#default_path_names: every key names itself except registration, which is empty (/users/sign_up, not /users/registration/sign_up).

DEVISE_SESSION_ROUTES = [{"GET", "%{sign_in}", "sessions", "new"}, {"POST", "%{sign_in}", "sessions", "create"}, {"DELETE", "%{sign_out}", "sessions", "destroy"}]

Routes devise_for :scope generates, keyed by the model module that turns them on (devise :database_authenticatable, :registerable, ... in app/models/<scope>.rb). Each tuple is {METHOD, sub_path template, controller group, action}: the template's %{key} slots are the path_names: keys Devise lets an app rename (sign_in: :login), and the group is the controllers: key that swaps the implementing controller (sessions: :sessions).

DIR_LOCAL_ASSIGN = /\A([a-z_]\w*)\s*=\s*(?:__dir__|File\.(?:expand_path|dirname)\(\s*(?:__dir__|__FILE__)\s*\))\s*\z/
DOORKEEPER_ROUTES = [{"GET", "/oauth/authorize", "authorizations", "new"}, {"POST", "/oauth/authorize", "authorizations", "create"}, {"DELETE", "/oauth/authorize", "authorizations", "destroy"}, {"POST", "/oauth/token", "tokens", "create"}, {"POST", "/oauth/revoke", "tokens", "revoke"}, {"POST", "/oauth/introspect", "tokens", "introspect"}, {"GET", "/oauth/token/info", "token_info", "show"}, {"GET", "/oauth/applications", "applications", "index"}, {"POST", "/oauth/applications", "applications", "create"}, {"GET", "/oauth/applications/new", "applications", "new"}, {"GET", "/oauth/applications/:id", "applications", "show"}, {"GET", "/oauth/applications/:id/edit", "applications", "edit"}, {"PATCH", "/oauth/applications/:id", "applications", "update"}, {"PUT", "/oauth/applications/:id", "applications", "update"}, {"DELETE", "/oauth/applications/:id", "applications", "destroy"}, {"GET", "/oauth/authorized_applications", "authorized_applications", "index"}, {"DELETE", "/oauth/authorized_applications/:id", "authorized_applications", "destroy"}]

Routes generated by Doorkeeper's use_doorkeeper (the de-facto OAuth2 provider for Rails — Mastodon, GitLab, Discourse plugins, …). Each tuple is {METHOD, sub_path, group, action}. group matches the skip_controllers symbols so a use_doorkeeper do skip_controllers :applications end block drops the right rows, and indexes the controllers foo: 'bar' remap used to resolve the implementing controller for params/callees. Paths are appended to /oauth under the scope where use_doorkeeper runs.

HELPER_TOKEN = /(?:(?<=self\.)|(?<![:\w@.]))([A-Za-z_]\w*[!?=]?)(?:\s*\[\s*:(\w+)\s*\])?/

A call to a method of this controller: a bare or self. identifier, never @ivar (@note is not the note callback) or a method on another receiver (@contact.notes). A trailing [:key] is captured: profile_params[:account_id] reads one field of what the helper permits.

INLINE_BLOCK = /\{\s*\|/
INLINE_DEVISE_SCOPE = /^devise_scope\b\s*\(?\s*(?::\w+|['"][^'"]+['"])\s*\)?\s*(?:\bdo\b\s*(.+?)\s*\bend|\{\s*(.+?)\s*\})\s*$/
INLINE_ROUTE_BLOCK = /^(member|collection|new)\s*\{\s*(.+?)\s*\}\s*$/

Rails commonly writes compact inline route scopes: collection { get "preview" } / member { post :use } / devise_scope :user do get "login", to: "devise/sessions#new" end. The parser is stack-based, so normalize these to the same do/end structure before route extraction.

LITERAL_ASSIGN = /^([a-z_][a-z0-9_]*)\s*=\s*["']([^"']+)["']\s*$/
LITERAL_CONCAT = /(["'])([^"'#]*)\1\s*\+\s*\1/
LOCAL_WRITE = /^([a-z_][a-z0-9_]*)\s*(?:=(?![=~>])|\|\|=|&&=|\+=|<<)/
LOOP_ONE_LINER = /^(#{LOOP_RECEIVER})\s*\.each(?:_with_index)?\s*\{\s*\|\s*([a-z_]\w*)[^|]*\|\s*(.+?)\s*\}\s*$/
LOOP_OPENER = /^(?:%[wi][\[\(\{]([^\]\)\}]*)[\]\)\}]|((?:::)?[A-Z]\w*(?:::[A-Z]\w*)*\.[a-z_]\w*))\s*\.each(?:_with_index)?\s+do\s*\|\s*([a-z_]\w*)[^|]*\|\s*$/
LOOP_RECEIVER = /(?:%[wi][\[\(\{][^\]\)\}]*[\]\)\}]|(?:::)?[A-Z]\w*(?:::[A-Z]\w*)*\.[a-z_]\w*)/

A %w[...].each do |var| / %i[...].each_with_index do |var, i| block over a STATIC literal list unrolls to one copy of its body per element with the loop variable's #{var} interpolations substituted. discourse alone wraps ~46 routes in %w[users u].each — without this, get "#{root_path}/..." leaks {root_path} into ~130 endpoints (a fabricated path param) AND the real /users + /u variants are lost.

Only literal %w/%i receivers are expanded: a dynamic Model.scopes.each do |s| cannot be resolved to values at parse time, so it is left for the normal parser (its block opens a neutral frame).

A Const.list receiver (Discourse.filters.each do |filter|) unrolls too when the app defines it as a literal list (see constant_list). The brace form RECV.each { |x| get "#{x}" } is the same block on one line.

MAX_LOOP_DEPTH = 3

noir scans arbitrary untrusted repos, so loop unrolling must not be a DoS vector — expansion is multiplicative (elements ^ nesting). A crafted routes.rb with deep nesting or a huge %w[...] list could otherwise blow up CPU/memory. Real apps use tiny shallow lists (discourse's biggest is the 2-element %w[users u]), so modest caps preserve all genuine recall while bounding the worst case. Over a limit, the block is passed through unexpanded (the pre-unrolling behavior — a transparent frame).

MAX_LOOP_OUTPUT = 20000
MAX_LOOP_VALUES = 100
NON_PRODUCTION_BLOCK = /\A#{NON_PRODUCTION_COND.source}/
NON_PRODUCTION_COND = /(?:if\s+(?:#{NON_PRODUCTION_ENV.source})|unless\s+Rails\.env\.production\?)\s*\z/
NON_PRODUCTION_ENV = /Rails\.env\.(?:test|development|local)\?(?:(?:\s*(?:\|\||\bor\b)\s*Rails\.env\.(?:test|development|local)\?)*|\s*(?:&&|\band\b)(?:(?!\|\||\bor\b).)*)|!\s*Rails\.env\.production\?/

A condition only a non-production Rails env satisfies: Rails.env.test? (development?, local?, an || of them, or one && anything), !Rails.env.production?, or unless Rails.env.production?.

NON_PRODUCTION_MODIFIER = /\s#{NON_PRODUCTION_COND.source}/
PARSE_OPTIONS_HASHROCKET_RE = Regex.new(":(\\w+)\\s*=>\\s*(#{PARSE_OPTIONS_VALUE_PATTERN})")
PARSE_OPTIONS_KEY_VALUE_RE = Regex.new("(\\w+):\\s*(#{PARSE_OPTIONS_VALUE_PATTERN})")
PARSE_OPTIONS_VALUE_PATTERN = "nil|true\\b|false\\b|['\"][^'\"]*['\"]|:[a-zA-Z_]\\w*|\\[[^\\]]*\\]|%i[\\[\\(][^\\]\\)]*[\\]\\)]|%w[\\[\\(][^\\]\\)]*[\\]\\)]"

parse_options runs on most DSL lines of every routes.rb file (scope, namespace, resources, member/collection actions, verb routes, match) — i.e. once per line in the main route-parsing hot loop. It used to build two Regex.new("...#{value_pattern}...") regexes from scratch on every call even though value_pattern is a fixed literal that never varies — Crystal doesn't cache/memoize a Regex.new(String) call the way it embeds a non-interpolated regex literal at compile time. Precompile both patterns once at class load instead.

PATH_OPTION_VAR = /(\bpath:\s*|:path\s*=>\s*)([a-z_][a-z0-9_]*)\b(?!\s*[(.\[+:])/
PERMIT_MAX_LINES = 200
PERMIT_OPEN = /(?<![\w.])params\.(?:expect\s*\(|(?:(?:require|fetch)\s*\([^)]*\)\s*\.)?permit\s*\()|(?<![\w.])permit\s*\(/

params.permit(, params.require(...).permit(, params.fetch(...).permit(, or Rails 8's params.expect( (anchored to bare params).

PERMIT_SPLIT = Noir::TopLevelSplit::Rules.new
PLAIN_LOCAL = /\A[a-z_]\w*\z/
PUNDIT_PERMITTED = /(?<![\w.])permitted_attributes\s*\(\s*(@\w+|[A-Z][\w:]*)\s*[,)]/

Pundit's permitted_attributes(@comment) / permitted_attributes(Comment).

RESOURCE_ACTION_VERBS = { {"index", "GET"}, {"show", "GET"}, {"create", "POST"}, {"update", "PUT"}, {"update", "PATCH"}, {"destroy", "DELETE"}, {"new", "GET"}, {"edit", "GET"} }

The RESTful actions resources routes, in the order their rows are emitted; update answers both PUT and PATCH. new and edit serve the forms at /posts/new and /posts/:id/edit.

ROUTE_FILE_LOAD = /(?:\A|[\s{;|])(?:load|require)\b\s*\(?\s*[a-z_]\w*\s*\)?\s*(?:\}|;|\z)|instance_eval\s*\(?\s*File\.read\b/
ROUTES_GLOB = /\bDir(?:\.glob\b|\s*\[).*?routes["']?\s*(?:,\s*["']|\/)(\*\*\/|["']\*\*["']\s*,\s*["'])?\*\.rb/

Dir.glob("#{__dir__}/routes/*.rb").each { |f| load f } (zammad) or Dir[Rails.root.join("config/routes/**/*.rb")]: a routes file that globs config/routes/ to load every file in it. Each file usually opens its own routes.draw; parse them in glob (sorted) order.

ROUTES_GLOB_DIR = /\#\{\s*(__dir__|File\.(?:expand_path|dirname)\(\s*(?:__dir__|__FILE__)\s*\)|[a-z_]\w*)\s*\}\/routes\//
ROUTES_GLOB_ROOTED = /config\/routes\/|config["']\s*,\s*["']routes["']|config\/routes["']/

The glob must name config/routes: a literal config/routes path or, from config/routes.rb itself, #{__dir__}/routes (or a local holding __dir__).

Class methods

tech_name
Source

Instance methods

analyze
Source
controller_to_endpoint(path : String, url : String, resource : String, path_prefix : String = "", member_prefix : String = path_prefix, only : Array(String) = [] of String, except : Array(String) = [] of String, singular : Bool = false, member_param : String | Nil = nil, to_action : String | Nil = nil)
Source
tech

Instance-side view of the same declaration. The per-file rescues live on this base class, which has no way to name the analyzer that is running inside them, so a skipped file could not be attributed to a tech. Deriving it from analyzer_for keeps the name written exactly once.

Source

Nested types