Skip to content

Add scopeAt() — scope-aware stack frame resolution#3

Merged
AlexVanderbist merged 5 commits into
mainfrom
feature/scope-at
Apr 22, 2026
Merged

Add scopeAt() — scope-aware stack frame resolution#3
AlexVanderbist merged 5 commits into
mainfrom
feature/scope-at

Conversation

@AlexVanderbist

Copy link
Copy Markdown
Member

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 walks sourcesContent backward; when bundlers begin emitting the normative scopes field, the same API will be backed natively.

Also adds isIgnored() for the ignoreList field and converts Position / GeneratedPosition to class-level readonly.

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's names table 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 the lookup side was missing.

What it resolves

  • function NAME, generator, const/let/var NAME = (arrow | async arrow | function | x => …)
  • Class and object method shorthand including async, static, get/set, and #private
  • Nested enclosure: Scope::$parent links outward (e.g. onClick → DeeplyNestedTrigger)
  • Anonymous arrow callbacks (arr.map(() => { … })) — returns a Scope with name === null

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 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 named Scope, with the handlers correctly nested under their component.

API

$scope = $map->scopeAt(line: 42, column: 17);

$scope->name;                  // "onClick", or null for an anonymous function boundary
$scope->position->sourceLine;  // where the frame actually executed
$scope->parent?->name;         // lexically enclosing scope

Per-call $maxLinesBack argument overrides SourceMapLookup::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 into composer bench — run manually).

Deferred

  • Native ScopesSourceMapLookup engine — will slot in behind the same API when bundlers emit the scopes field.
  • ScopeKind enum, inlined-callsite chain via GeneratedRange.callsite, ScopeOrigin signal — add when the native engine lands.
  • Multiline strings / block comments that span lines in the walk-back tokenizer — documented in WalkBack's class docblock.

Test plan

  • 46 new tests (7 ignoreList feature, 25 WalkBack unit, 14 scopeAt feature) on top of the existing 111 — 157 total pass
  • Regression test for control-flow keyword filter (if, while, for, switch, catch)
  • Test that scopeAt falls back to the mapping's .name when no sourcesContent is available
  • Test that anonymous arrow callbacks produce Scope(name: null) rather than null
  • End-to-end test against the real Vite-produced fixture at tests/fixtures/scopes/frontend-errors.js.map (included; provenance documented in the fixture's README)
  • Benchmark harness runs clean against all three fixtures

AlexVanderbist and others added 5 commits April 22, 2026 16:40
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.
@AlexVanderbist AlexVanderbist merged commit 0b399fd into main Apr 22, 2026
18 checks passed
@AlexVanderbist AlexVanderbist deleted the feature/scope-at branch April 22, 2026 14:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant