Add scopeAt() — scope-aware stack frame resolution#3
Merged
Conversation
No behavior change. Callers already treat both as pass-through value objects; marking the class readonly enforces that at the type level.
scopeAt() resolves a generated position to the enclosing source-language
scope, modeled after the ECMA-426 Scopes proposal. Today's implementation
is a heuristic polyfill that walks sourcesContent backward looking for
function-shaped declarations; when bundlers begin emitting the `scopes`
field it will be backed natively behind the same API.
Recognised source patterns: `function NAME`, `const/let/var NAME = …`
(arrow, async arrow, function expression), class/object method shorthand
(including async/static/get/set/private), and anonymous function
boundaries (`arr.map(() => …)`) which return a Scope with a null name.
Control-flow keywords (`if`/`while`/`for`/`switch`/`catch`) are filtered
so they can't masquerade as method names.
The per-line tokenizer skips braces inside string literals, line and
block comments, and template literal text; `${ … }` interpolation is
treated as code. Multiline strings and block comments spanning lines are
a documented limitation.
isIgnored() exposes the normative `ignoreList` field and accepts either
the raw `sources[]` entry or its `sourceRoot`-resolved form.
Position and GeneratedPosition were already being used as value objects;
scopeAt's Scope is a new `readonly class` under `Scopes\`. A per-call
`$maxLinesBack` argument overrides `DEFAULT_WALKBACK_LINES` (60).
Benchmarked at ~35% overhead over lookup() on realistic fixtures with
the walk-back result cache warm; cold-path is ~6 µs/call on the 6 MB
fixture. A perf canary lives in `benchmarks/scope_at_cost.php`; it's
not wired into `composer bench`.
- Headline paragraph now mentions enclosing-scope resolution - Top-of-file example shows lookup() and scopeAt() side by side - Added a `Scope` object schema block next to the scopeAt section, mirroring the existing Position schema - Listed `ignoreList` and scope resolution in "Supported source map features"
Review-driven cleanup of the feature/scope-at work:
- `sourceLines()` and `scopeAt()` both needed `explode("\n", sourcesContent)`
and were splitting (and caching, in the scopeAt case) independently. Both
now route through the same private `splitLinesFor()`, so a caller that
uses both never splits the same file twice.
- `isIgnored()` was iterating `$sources` and rebuilding the resolved path
per call (O(N) with string concats). Replaced with a lazy name→true set
built on the first call, covering both raw and resolved forms. ~200× faster
for stack traces that filter many frames against a map with many sources.
- Pre-computed the sourceRoot prefix once in the constructor, so
`resolveFileName()` skips the `str_ends_with` + conditional concat on
every call. Hot path for every Position materialisation.
- `WalkBack::countBraces` state is now integer class constants
(`STATE_CODE` etc.) instead of raw string literals. Typo-safe in a hot
switch with no runtime cost.
- Dropped two narrative comments in `WalkBack` whose content was already
clear from the code (state-list comment and "rest of line is a comment").
No public-API change. 157 tests still pass, benchmark unchanged at ~30-39%
overhead on medium/large fixtures.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds
SourceMapLookup::scopeAt()— resolves a generated position to the enclosing source-language scope, modeled after the ECMA-426 Scopes proposal. Today it's a heuristic polyfill that walkssourcesContentbackward; when bundlers begin emitting the normativescopesfield, the same API will be backed natively.Also adds
isIgnored()for theignoreListfield and convertsPosition/GeneratedPositionto class-levelreadonly.Motivation
For Flare's stack-trace de-minification,
lookup()gets the file+line back to source, but the frame's method name stays minified (u,r,j). The sourcemap'snamestable has the original identifiers (thirdLevelExplode,RenderCrashChild, etc.) but typically only attached to declaration tokens — not to the throw site itself.scopeAt()is the "which function am I inside?" primitive thelookupside was missing.What it resolves
function NAME, generator,const/let/var NAME = (arrow | async arrow | function | x => …)async,static,get/set, and#privateScope::$parentlinks outward (e.g.onClick → DeeplyNestedTrigger)arr.map(() => { … })) — returns aScopewithname === nullControl-flow keywords (
if/while/for/switch/catch) are filtered so they can't masquerade as method names. The per-line tokenizer skips braces inside strings, line/block comments, and template literal text;${…}interpolation is treated as code.Against the Flare React playground's six user frames —
thirdLevelExplode,secondLevelCompute,DeeplyNestedTrigger.onClick,AsyncPromiseTrigger.onClick,ManualReportTrigger.onClick,RenderCrashChild— all six resolve to a namedScope, with the handlers correctly nested under their component.API
Per-call
$maxLinesBackargument overridesSourceMapLookup::DEFAULT_WALKBACK_LINES(60).Performance
Benchmarked against small (82 KB), medium (549 KB), and large (6.2 MB) fixtures. On medium/large: ~35% overhead over
lookup()with the walk-back cache warm. Cold per-call cost on the large fixture: ~6 µs. Cache key is(sourceFileIndex, sourceLine, maxLinesBack)— which is effectively free to hit because many generated columns share the same source line, so in typical stack-trace resolution workloads cache hit rate approaches 100%.Full numbers at
benchmarks/scope_at_cost.php(not wired intocomposer bench— run manually).Deferred
ScopesSourceMapLookupengine — will slot in behind the same API when bundlers emit thescopesfield.ScopeKindenum, inlined-callsite chain viaGeneratedRange.callsite,ScopeOriginsignal — add when the native engine lands.WalkBack's class docblock.Test plan
if,while,for,switch,catch).namewhen nosourcesContentis availableScope(name: null)rather thannulltests/fixtures/scopes/frontend-errors.js.map(included; provenance documented in the fixture's README)