Developer Tools · Static Analysis
Scope Chains and Name Resolution: Why Spelling Is Not Identity
How a linter builds a scope tree, resolves each reference to a declaration, and avoids reports that confuse two unrelated variables with the same name.
Two functions in the same file can each declare a local named log, one a logger and one a numeric logarithm helper. A rule that forbids calling the logger outside a request context will flag the second function if it matches by name. The false positive does not come from a bad pattern; it comes from treating a string as an identity. Resolving a name means finding the declaration it denotes, and that requires a model of scopes.
Declarations are collected before references are resolved
A scope is a region in which a set of names is visible. The tree described in Why Linters Parse records the shape of the code, but it does not say which x a given x refers to; that is a separate analysis over the tree. The standard construction is two passes.
The first pass walks the tree and records every declaration into the scope that owns it. The second walks again and resolves each reference by searching the innermost enclosing scope, then its parent, and so on to the module scope and the predeclared globals. The result is a mapping from each reference node to a declaration node, usually stored as an edge in a scope graph.
The two-pass split exists because declarations are not ordered the way execution is. In JavaScript, a function declaration is visible throughout its enclosing function body, including before its textual position; var declarations are hoisted to the function scope but their initialisers are not. In Python, a name assigned anywhere in a function body is local for the whole body, so a read before the assignment is an error rather than a lookup in the enclosing scope. A single-pass resolver that processed the file top to bottom would resolve these cases incorrectly. Languages with block-scoped let and const add a temporal dead zone: the binding exists in the block but reading it before initialisation throws. The scope graph records the binding; the definite-assignment analysis, not the resolver, decides whether a particular read is legal.
Class bodies and function parameters introduce further scope kinds. Default parameter expressions in JavaScript are evaluated in a scope that sits between the parameter list and the enclosing function, which is why a default referring to another parameter works but a default referring to a body-local var does not. Getting these boundaries wrong produces resolution errors on ordinary code.
Shadowing is a feature, not an accident
When an inner scope declares a name that an outer scope also declares, the inner declaration shadows the outer one for references inside the region, and the outer one remains visible outside it. Resolution walks outward and stops at the first match, so the same spelling in two branches of the tree maps to two different declarations.
A linter should use that mapping everywhere. A rule about an unused variable is really a rule about a declaration with no reference edges. A rule about calling a deprecated function is about calls whose callee resolves to a particular imported binding. A rule about a dangerous global is about references that resolve to no local declaration and then to a known global. All three become precise only after resolution.
Shadowing also produces a class of true positives that naive tools miss entirely: an inner declaration that unintentionally captures a name the author meant to use from outside. Detecting it requires comparing the resolved declaration with the one that would have been found if the inner declaration were removed, which is a question the scope graph can answer by re-running the lookup from the parent scope.
def resolve(scope, name):
while scope is not None:
if name in scope.decls:
return scope.decls[name]
scope = scope.parent
return None # falls through to globals or an unresolved marker
class Scope:
def __init__(self, parent=None):
self.parent = parent
self.decls = {}
self.children = []
This is the entire core of resolution, and it is short because the difficulty is in constructing the scopes, not in walking them. The walk is bounded by nesting depth, which is small in real code; a lookup is effectively constant time with a hash map per scope.
Modules move the boundary outward
A file is rarely a closed world. Imports create bindings in the module scope, and those bindings denote declarations in other files. Resolving them requires a module resolver that maps a specifier to a file, and then an export table that maps an exported name to a declaration. Re-exports and namespace imports make this a small graph traversal of its own, and the traversal can cycle.
The important design decision is what to do when resolution fails. Module resolution depends on configuration: path aliases, package exports maps, conditional exports for different runtimes, and type-only imports that vanish at runtime. A linter that cannot resolve a specifier has three options: report an unresolved import, treat the name as any and suppress dependent rules, or fall back to matching by name. The third is the dangerous one, because it silently reintroduces the false positives that resolution was meant to remove. Mature analyzers mark unresolved references explicitly and let rules opt in to guessing.
TypeScript’s import type is a useful illustration of why syntax and binding must be kept separate. The import is a declaration in the module scope, but it has no runtime existence. A rule about unused imports should consider it used by type positions; a rule about runtime side effects should not treat it as a value. Only the resolver, with type information attached, can distinguish the two.
Dynamic constructs break static resolution
Every language has constructs that defeat a static scope walk. JavaScript’s with inserts an object’s properties into the scope chain at runtime, so a name inside the block may resolve to a property that no declaration mentions. Direct eval can introduce declarations into the calling scope. Python’s exec and module-level globals() mutation do the same. C macros can substitute tokens that were never in the source.
Tools handle these by restricting the claim. A linter can mark a scope as dynamically extended and stop reporting unresolved-name findings inside it, or report only for names that are declared locally. The alternative, pretending the dynamic construct does not exist, produces findings that are wrong in a way the user cannot debug.
A subtler case is implicit binding. Python’s self is an ordinary parameter, but an attribute assignment creates or updates an attribute rather than a local; a resolver that treats self.x = 1 as declaring x in the function scope will misresolve later reads. JavaScript’s this is not a lexical name at all, and arrow functions capture it from the enclosing scope while function expressions rebind it. Rules about this need the function-kind distinction, not just the scope tree.
What resolution buys, and what it costs
A resolved scope graph turns a large family of text-level heuristics into set operations: unused declarations are nodes with in-degree zero, shadowing is a parent lookup that finds a different declaration, and cross-module impact is a reverse reachability query. That is the foundation for interprocedural work such as Interprocedural Summaries and Context Sensitivity, where the callee of a call must be known before anything can be said about its arguments.
The cost is a full extra pass over every file and a memory-resident graph that must be invalidated when a file changes. On a large repository, that graph is the main reason incremental analysis is hard, since a change to one declaration can alter resolution in files that never mention it by name.
Resolution also determines where a finding points. If a rule reports on a declaration, the diagnostic spans the declaration’s name; if it reports on a use, the span is the reference. A resolver that conflates the two produces a report on the wrong line even when the underlying finding is correct, which is the failure mode described in Diagnostic Spans and the Encoding Problem. The cheap alternative to resolution, matching identifiers as strings, is exactly the approach that produces the false positive in the opening example, and no amount of rule tuning repairs it.
References
- ECMAScript Language Specification (ECMA-262) — Ecma International
- The Python Language Reference, Execution Model — Python Software Foundation
- Static Program Analysis — Moller and Schwartzbach, Aarhus University