module

Noir::CLI::Inspect

Shared machinery of the query verbs: noir trace, noir reach, noir query. Each is a view over the endpoint list a scan produces — the verbs filter and project, they do not analyze.

The endpoints come from a live scan (every scan flag accepted, with --ai-context all --include callee implied) or, with --from, from a noir scan -f json / -f jsonl report, so an agent pays for one scan and asks many questions.

Constants

CASE_INSENSITIVE_ROUTER_TECHS = Set {"js_express", "js_koa", "vb_aspnet_mvc"}

Routers that match literal path text without regard to case by default: ASP.NET (MVC, Web API, Razor Pages, minimal APIs, ...) and Express/Koa (caseSensitive / sensitive default false). Rails, Django, Flask, Spring, Laravel, Go routers and Fastify are case-sensitive. ponytail: tech-name list; a router configured the other way round is not modelled.

CODE_PREFIX_TAGS = Set {"go-http-path-type"}
COPY_DIRS = Set {"codefixes", "fixtures", "fixture", "snippets", "samples", "examples", "example", "vendor", "node_modules", "test", "tests", "__tests__", "spec", "testdata"}

Directories that hold copies of an app's code rather than the app: juice-shop keeps a snippet of every route under data/static/codefixes/ and the optimizer merges it with the real server.ts declaration.

EXIT_MATCH = 0

grep's convention, so a shell or an agent can branch without parsing.

EXIT_NO_MATCH = 1
EXIT_USAGE = 2
FORMATS = ["jsonl", "json", "text"] of ::String
GLOB_CHARS = /[*?\[]/

A glob when it looks like one, a substring otherwise. The glob is the agent's, not the shell's: * and ** match anything including / (every Django URL ends in /, *users* has to find it), ? one character, [...] a class, and {, } are literal so a route with {id} in it can be matched as written.

NON_AUTH_GUARD_KINDS = {"rate_limit_guard", "csrf_guard"}

Guards that gate who may call the route. Rate limiting and CSRF checks are guards the augmentor records, but neither stops an anonymous caller, and "no auth" is what --no-guard asks.

ONE_OR_MORE_STAR_TECHS = Set {"js_nextjs", "rust_axum", "grpc"}

Where {*name} needs at least one segment: Next.js [...slug] (its optional [[...slug]] is written as a second, bare route), axum and a gRPC-transcoding {name=**}. Spring, ASP.NET, Armeria, salvo's {*name} / {**name} and Rocket's <name..> take zero.

ONE_PLACEHOLDER = /\A(?:\$\{[^}]*\}|\{(?:[^{}]|\{[^{}]*\})*\}|<[^>]*>|\[[^\]]*\]|:[A-Za-z_]\w*(?:\((?:[^()]|\([^()]*\))*\)|\{(?:[^{}]|\{[^{}]*\})*\})?\??|\$[A-Za-z_]\w*(?:<[^>]*>)?)/

:id(\d+) (Express 4 / Koa / Fastify) and :id{[0-9]+} (Hono) carry a regex constraint; a trailing ? makes the segment optional. Play's $id<\w{8}> is one placeholder with its regex.

OPTIONAL_GROUP = /\A\{(\/[^{}]*)\}/

path-to-regexp v8 (Express 5) optional group: /posts{/:id}.

PLACEHOLDER_CHAR = /[{:<\[$*]/
RAILS_FORMAT_SUFFIX = /\.[A-Za-z0-9]+\z/
REST_PLACEHOLDER = /\A(?:<path:[^>]*>|\*\*|\{\*(?:[^{}]|\{[^{}]*\})*\}|\{[^}]*\*\}|\{\.\.\.[^}]*\}|\{[^}]*\.\.\.\}|\[\[\.\.\.[^\]]*\]\]|\[\.\.\.[^\]]*\]|\*[A-Za-z_]\w*|:[A-Za-z_]\w*[*+]|\*)/

{id} :id <int:pk> [id] $id ${id} match one segment; * ** {path*} {...rest} [...slug] [[...slug]] *path :rest* <path:p> {*path} (Spring, ASP.NET, Next.js and Starlette's {p:path} as the optimizer writes them) and {path...} (Ktor, Go) match the rest (Express's bare * is a catch-all, not one segment).

RESTATING_SIGNAL_SOURCES = {"route", "detector", "http_method"}
SEGMENT_PREFIX_TAGS = {"ingress-path-type" => {"prefix"}, "gateway-path-type" => {"pathprefix"}}
STRING_PREFIX_TAGS = {"nginx-path-type" => {"prefix", "prefix-stop"}, "haproxy-path-type" => {"prefix"}, "virtualservice-path-type" => {"prefix"}, "go-http-path-type" => {"subtree"}}

Proxy rules that serve every path under their own, by the path-type tag the analyzer sets: nginx location /api/, haproxy path_beg and Istio prefix compare strings (/api serves /apis); Ingress and Gateway API Prefix compare whole segments. A Go ServeMux pattern ending in / serves its subtree (gin's or Echo's /x/ is one route, and /x/{$} only /x/).

A proxy rule merged into a code route (an Ingress Prefix folded into POST /api/users) keeps its tag, but the code serves only its own route, so the proxy tags count only on a row no code analyzer reported.

ZERO_OR_MORE = /\A(?:\*\*|\{\*(?:[^{}]|\{[^{}]*\})*\}|\{[^}]*(?:\*|\.\.\.)\}|:\w+\*|\[\[)/

The rest placeholders that also take zero segments, so /files/:path* serves /files too.

Class methods

auth_guards(context : AIContext | Nil) : Array(AIContextEntry)
Source
bucket_matches?(wanted : Array(String), entries : Array(AIContextEntry) | Nil) : Bool

any (or an empty bucket filter) is satisfied by a non-empty bucket.

Source
candidates(endpoints : Array(Endpoint), path : String, limit = 5) : Array(Row)

Nearest declared routes for a miss: most shared leading segments (case-insensitive, placeholders count), then the closest spelling of the first segment that differs (/rest/baskets/3 → /rest/basket/:id), then the closest length. Any method: the row names it, and "that path exists, but as PATCH" is the answer a miss most often needs.

Source
case_insensitive_router?(endpoint : Endpoint) : Bool
Source
common_help

The --from / -f / --full / --limit / --count block every verb's help page shares.

Source
derived_signals(context : AIContext | Nil) : Array(AIContextEntry)

Signals worth filtering on: the route/detector/method ones restate method, url and tech, and would make --signal any match everything.

Source
emit(rows : Array(Row), common : Common, io : IO = STDOUT) : Int32

Prints rows in common.format and returns the exit code. -f text always prints the compact line; json/jsonl honour --full.

Source
filter_warnings(endpoints : Array(Endpoint), filters : Filters) : Array(String)

Filter values that no endpoint can satisfy, so a typo (--sink file for file_io) or a report scanned without --ai-context is said out loud instead of returning an empty answer that looks final.

Source
glob_or_substring?(pattern : String, value : String) : Bool
Source
load(common : Common, scan_argv : Array(String)) : Array(Endpoint)

The endpoint list to query: a saved report or a live scan.

Source
load_report(path : String) : Array(Endpoint)

Reads the endpoints of a -f json document or a -f jsonl stream. Passive findings and failure lines (jsonl) are told apart by shape.

Source
method_listed?(methods : Array(String), declared : String) : Bool

Whether query --method lists a route declared for declared. An ANY row (nginx, Apache, router.all) serves every HTTP verb, so --method POST lists it, as trace does; --method ANY lists only the ANY rows.

Source
method_serves?(declared : String, wanted : String | Nil) : Bool

Whether a route declared for declared serves a wanted request method. Frameworks answer HEAD from the GET route, so a HEAD request traces to it.

Source
parse_common(argv : Array(String)) : Common
Source
path_glob?(pattern : String, value : String) : Bool

Reported paths carry the scan root (corpus/app/routes/x.ts), so a glob written from inside the app (routes/*) also matches from any /.

Source
path_matches?(recorded : String | Nil, wanted : String) : Bool

Component-wise suffix match: users.go and api/users.go both name src/api/users.go; sers.go does not.

Source
primary_declaration(endpoint : Endpoint) : PathInfo | Nil

The declaration the compact row names when the optimizer merged several files into one endpoint: the first one that is not under a copy directory, else the first. Scan order is kept otherwise, since analyzers put the declaring file first and its include chain after it (Django's apps.py before urls.py). ponytail: a directory-name list; declarations says when it mattered.

Source
project_matches?(wanted : String, project : String) : Bool

A project is named by its directory under the scan base, so services/orders is that project and not services/orders-v2. A glob (same rules as above) matches the whole name. An endpoint with no project matches nothing.

Source
query(endpoints : Array(Endpoint), filters : Filters, full : Bool) : Array(Row)
Source
reach(endpoints : Array(Endpoint), file : String, line : Int32 | Nil, full : Bool) : Array(Row)

Every endpoint with evidence in file, each with its via list; with line, evidence is ordered by distance and nearest_line is set. ponytail: declaration lines only — no function spans, so :line orders rather than filters. Tree-sitter spans would make it a filter.

Source
report_scan_args(scan_argv : Array(String)) : Array(String)

Global output flags are already applied by the router, and remain valid when a verb reads a report instead of scanning a path.

Source
request_path(url : String) : String

The path part of an endpoint URL: -u prefixes a scheme+host. Any other scheme (ws://, cli://shopctl/users, a deep link) names the surface, so it stays: /users is not the CLI's users command.

Source
request_target(path : String) : String

The path a client sent, as a router sees it: no query or fragment, a leading /, // collapsed, each segment percent-decoded (%2F stays one segment, since a router splits before it decodes). A declared route is written decoded, so /%C3%A9t%C3%A9 has to be read as /été.

Source
route_regex(endpoint : Endpoint, ignore_case : Bool = false) : Regex | Nil

The regex a declared route matches request paths against, or nil for a pattern Noir cannot turn into one.

Source
specificity(url : String) : Tuple(Int32, Int32, Int32)

How specific a route is: fewer catch-all segments, then fewer placeholders, then more literal text. /u/:id outranks /*proxyPath for /u/5. Among routes with a catch-all the literal text comes first: /media/:id/*any is more specific than /*unmatched_route, though it has one more param.

Source
split_positionals(args : Array(String)) : Tuple(Array(String), Array(String))

Splits rest into positionals and the scan flags (with their values). Which flags consume a value is read off ScanFlags, the same table the completions use; an unknown flag is passed through for scan's parser to reject with its own message.

Source
trace(endpoints : Array(Endpoint), method : String | Nil, path : String) : Array(Match)

Routes matching a concrete request, exact matches first. A PHP front controller also answers its routes under another spelling: /index.php/x is /x (PATH_INFO routing, Nextcloud without pretty URLs), and Nextcloud serves /ocs/v2.php/... as /ocs/v1.php/... too. Those are tried against the PHP routes when the request itself matches nothing.

A Rails route also answers with an optional .format suffix (/ai-llms.json is /ai-llms), so when only a catch-all matched, the suffix-less path is tried against the Rails routes and its matches lead.

Source
unknown_flags(scan_argv : Array(String)) : Array(String)

Flags scan's parser would reject. Caught here so the verb keeps its exit code 2 instead of scan's usage page and exit 1.

Source
wildcard_only_candidates(endpoints : Array(Endpoint), matches : Array(Match), path : String) : Array(Row)

When only a catch-all answered (/*unmatched_route, an SPA fallback), the request may be a typo of a real route: the nearest declared routes, wildcard routes left out. Empty when any match is exact or a pattern.

Source

Nested types