Noir::PathScope
Path-boundary helpers shared by the analyzers and engines. Monorepo
scans (multiple -b base paths) have to answer two questions
repeatedly: does a file live under a given root, and which configured
base does it belong to? Both must respect path boundaries — a plain
String#starts_with? leaks siblings (/app would swallow /app2) —
so the comparison runs on File.expand_path-normalized paths.
This logic was copy-pasted across half a dozen call sites (FileHelper, Analyzer, PythonEngine, the Dart helper, the Express router scanner, FastAPI). This module is the single source of truth; a future tweak (Windows separators, symlink handling, …) now lands in one place.
Hot loops should normalize the loop-invariant root once via
normalize_root and compare with under_normalized_root? rather than
paying for File.expand_path of the root per file.
Constants
Canonical form of a user-supplied base path, preserving whether it
is relative. -b rem2, -b rem2/// and -b ./rem2 all name the
same tree, but the raw string is echoed straight into every reported
code_path, so the same codebase scanned three ways produced three
different reports — which breaks baseline diffing and any SARIF
consumer that keys on the URI.
Repeated separators collapse, trailing separators are dropped (the
filesystem root itself stays /), and . segments are resolved
away (./rem2 -> rem2, a bare . stays .). .. segments are
left alone: resolving them textually is wrong across symlinks.
Deliberately NOT normalize_root/File.expand_path — expanding a
relative base would bake the machine's checkout location into every
reported path, and an absolute code_path was itself a bug fixed in
an earlier sweep. A relative base stays relative.
Separators a base path may legitimately use. Windows accepts both
forms; on POSIX a backslash is an ordinary filename character and
must not be treated as a separator.
Instance methods
The form every convention filter must match on: path's location
relative to the scan base that owns it, /-separated and rooted
with a leading /.
"Is this file under tests/?", "is this vendored?", "is this build
output?" are all statements about a location inside the project. Ask
them of the absolute path and the answer depends on where the
checkout happens to sit: a clone into ~/work/tests/myapp, or a CI
job that checks out under a test/ step directory, matches every
file in the project and silently reports nothing. Rooting the result
means one includes?("/tests/") also covers a tests/ directory at
the top of the project, and keeps every match on a whole path
segment (/test/ never matches latest/).
A path outside base_path keeps its own (absolute) form, exactly as
get_relative_path returns it — the filters then behave as they did
before, which is the conservative choice for a file the scan base
does not own.
Multi-base overload: resolves the owning base first. Callers with a
per-path base cache (Analyzer#configured_base_for) should use that
and call the single-base form instead.
The most specific (longest normalized) base in bases that contains
path, or nil if none does. Returns the ORIGINAL base string (not its
normalized form) so callers can use it as a stable map key.
Canonical comparison form of a root: expanded, with any trailing separator stripped (except the filesystem root itself).
Remainder of path beneath base_path, or the bare basename when
path is outside base_path (or no base given). Backs the
Python/Dart test-path conventions, which key off the project-relative
path so nested fixture trees don't trip the tests//test/ filters.
Boundary check against an already-expanded path and an
already-normalized root. Use this in per-file loops where the root is
loop-invariant (normalize it once with normalize_root).