Skip to content

Resolve only const bindings; scope templates and tags correctly - #79

Merged
nstepien merged 6 commits into
mainfrom
const-only-scoping
Oct 17, 2026
Merged

nstepien merged 6 commits into
mainfrom
const-only-scoping

Conversation

@nstepien

@nstepien nstepien commented Aug 31, 2026 •

Copy link
Copy Markdown
Owner

Summary

First slice split out of #68 so it can be reviewed on its own: the static-resolution and scoping semantics of parseFile, with none of the import/export machinery.

Only const bindings have a static value. let/var bindings, parameters, and destructured bindings are still recorded so they shadow outer bindings and imports (including var hoisting to the function scope), but a template using one is skipped with UNRESOLVED_INTERPOLATION. A css template assigned to a let/var is still extracted. Only references to it need a const. parseFile records the initializer of each const, and resolveValue evaluates it on demand, like an interpolation: a literal, signed numbers included (const offset = -5), or another binding that resolves (const accent = brand), class names and imports included. An imported binding is resolved in the module that declares it, so an exported alias resolves for its importers too. The values are cached for the transform, unknown ones included, and a binding is cached as unknown while it is resolved, so a cycle stays unknown. Resolution is static, so a template or a const may reference a const declared later in the file (a TDZ error at runtime). The plugin keeps resolving it on purpose, but the README does not document it, so users do not rely on it. Re-exported imports (export { x } of an import, and export … from) are still not followed.

Resolving let/var values would require reassignment tracking and, for var, position and control-flow analysis to avoid inlining a value the runtime never had (hoisted callees, conditional initialization, break label, Annex B catch). Static style tokens are idiomatically const anyway.

Tag shadowing. A local binding named like the tag means the template is not ecij's css and is left alone. Templates are collected during the visit and decided once every scope is populated, so bindings declared after the template count too (let/const/class are hoisted to their block, var/functions to their function). The tag is recognized through its import binding (cssTagNames), so import { css as styled } from 'ecij' works as a by-product.

Scopes.

  • Function bodies are scopes of their own: parameter defaults cannot see the body's declarations, and body vars hoist into the body rather than next to the parameters
  • Loop and catch bodies are nested in the scope of the loop head or the catch parameter instead of sharing it, so a body-level declaration cannot leak into the loop head
  • The switch scope is entered with the first case, so the discriminant is evaluated in the enclosing scope
  • The var declarator kind is tracked as a stack: a declarator following a sibling whose initializer contains nested declarations, like a function body, lost its kind, so a var was bound to the block (and a const would no longer resolve)
  • A template's interpolations are resolved in the scope where the template appears (previously a var-declared template used the function scope, so a block-scoped binding shadowing an outer one resolved to the outer value)

Docs: the README gains a "Static resolution" section, with an example of the supported interpolations, and AGENTS.md's transform description matches. AGENTS.md also records why the const cache only lives for one transform.

Behavior changes vs main

  • let/var bindings with a literal initializer no longer resolve and warn instead
  • const bindings of signed numbers now resolve
  • A const bound to another binding that resolves, local or imported, now resolves too, also for the modules importing it
  • A binding exported under several names now resolves under each of them
  • Templates whose tag is shadowed by a local binding are no longer extracted
  • Aliased tag imports are now supported

Test plan

  • variable scoping and advanced scoping snapshots updated for the const-only rule (§7 var, §11 for-head let, and §23 var in a block now warn). New advanced sections 32–36: var declarator kind stack, let not resolved, parameter defaults vs. body const/var, and var-declared templates resolving where they appear
  • The barrel test re-exports scoping.input.ts, so it now records that fixture's var warning
  • New tag-shadow-hoisting fixture and test: later const/var/function/class, self-reference, enclosing-scope declaration, nested-block negative control, and module level. Only the two unshadowed templates are extracted
  • literal-interpolation gains a const bound to a signed number
  • New const-alias fixture and test: aliases of literals and class names, an alias of a shadowing binding, a chain declared after its use, an alias of an import, and exported aliases of a literal, a class name, and an import resolve. Aliases of a let and a cycle warn
  • Vite builds of scratch projects: a const cycle across two modules and a re-export cycle terminate unresolved, a chain of aliases across three modules resolves with the stylesheets in dependency order, modules interpolating each other's aliased class names build, and a binding exported under two names resolves under both
  • node --run test (locally and with CI=true against dist/), lint, typecheck, format:check, and build pass. src/index.ts statement coverage is 99.6%, and its one uncovered line (TypeScript parameter properties) is uncovered on main too

Relationship to #68

The code here started out textually identical to the corresponding regions of #68. Merging main into it (IIFE class names, hook filters, cycle handling, and the dev server updates) and the review simplifications since then mean they no longer match, so merging main back into #68 afterwards will need conflict resolution in these regions too.

Candidate next slices:

  • Raw TypeScript handling (enum/namespace/import =, moduleType-aware parsing)
  • Prologue-safe import injection (Full import/export handling, query variant modules, and raw TypeScript #68's moduleSideEffects is no longer needed: main keeps such modules alive with IIFE class names)
  • The rest of the import/export resolution core: re-exports, and default and namespace imports and exports
  • Dev-server resolution and module identity for query variants

🤖 Generated with Claude Code

Static resolution now takes values from `const` bindings only. `let`/`var`
bindings, parameters and destructured bindings are still recorded so they
shadow outer bindings and imports (including `var` hoisting), but a
template using one is skipped with UNRESOLVED_INTERPOLATION: a `let`/`var`
may be reassigned and a `var` is `undefined` until its declaration has run,
which static analysis cannot follow faithfully. A css`` template assigned
to a `let`/`var` is still extracted; only references to it need a `const`.

Templates are collected during the visit and extracted once every scope is
populated, so a local binding shadowing the tag — even one declared later
and hoisted — leaves the template alone; the tag is recognized through its
import binding, which also supports `import { css as styled }`.

Scopes: function bodies are scopes of their own (parameter defaults cannot
see the body, body `var`s hoist into the body), loop bodies are separate
from their heads, the switch scope starts with the first case, the `var`
declarator kind is tracked as a stack, and a template's interpolations
resolve where the template appears rather than in the `var` target scope.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 31, 2026 •

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: d2272a2d-4d92-4961-a994-1425942c307c
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@nstepien nstepien self-assigned this Sep 17, 2026
nstepien and others added 3 commits October 16, 2026 21:32
Conflicts:
- src/index.ts: keep the PR's loop, switch, and tag-shadowing scopes with
  main's JSDoc and `ecij()` signature
- AGENTS.md: main's transform description with the PR's resolution rules
- test fixtures and snapshots: main's formatting and IIFE class names; the
  barrel test now records the warning from scoping.input.ts's `var` case

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Drop "e.g."/"i.e." and README semicolons, unwrap the README section, and
use JSDoc for interface fields like `Scope`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Drop `CssTagCandidate.bindingScope`: only a `const` records its class
  name, and a `const` is bound in the scope of its template.
- Give catch bodies their own nested scope, like loop bodies, which
  retires `skippedBlockStatements`.
- Share `resolveStaticExpression` between interpolations and `const`
  initializers, so `const offset = -5` resolves, as the README states.
- Require the scope argument of `recordIdentifierWithValue`: templates
  are extracted after the visit, when `currentScope` is the root again.
- README: drop the ambiguous "or a class" and the repeated list of
  bindings that are not resolved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nstepien and others added 2 commits October 17, 2026 02:32
A `const` value is now evaluated once every scope is populated, like the
tag shadowing, so an initializer may also reference another binding:
`const accent = brand` takes the value of `brand`, class names included,
following lexical scoping. The values are evaluated in rounds, as an
initializer may reference a `const` evaluated after it, until a round
makes no progress, which leaves cycles unknown. An alias of an import is
not followed.

`isBoundInScopeChain` becomes `findBindingScope`, which `resolveValue`
now uses to walk the scope chain too. Every tagged template passed to
`addCssTagCandidate` is marked as processed, whatever its tag.

The README shows the supported interpolations in an example instead of a
list, and formats css`` as code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`parseFile` now records the initializer of each `const` instead of
evaluating it, and `resolveValue` evaluates an expression on demand: a
literal, a signed number, or an identifier, whose `const` initializer is
resolved in turn, in its own module for an imported binding. So a
`const` bound to an import resolves too, and so does an exported alias
in the modules importing it. The values are cached for the transform,
keyed by initializer, unknown ones included. A binding is cached as
unknown while it is resolved, so that a cycle stays unknown.

Exports now map exported names to local names, so a binding exported
under several names resolves under each of them. Re-exported imports
are still not followed.

The README example interpolates an imported class name, and no longer
shows a `const` declared after the template that uses it, so that users
do not rely on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nstepien
nstepien merged commit f71d758 into main Oct 17, 2026
8 checks passed
@nstepien
nstepien deleted the const-only-scoping branch October 17, 2026 03:41
nstepien added a commit that referenced this pull request Oct 17, 2026
main took in the first slice of this PR (#79) and solved several of its
other problems differently since August, so the merge ports the rest of
this branch onto main's design instead of keeping both:

- Values, class names included, come from the parse of each module, and
  const initializers resolve lazily (#79). Re-exports, `export *`,
  default and namespace imports, and `export default` resolve through the
  same evaluator, with ESM precedence and ambiguity.
- Cycles are handled by main's pendingLoads and isWaitingFor (#102), so
  the wait graph, the deferred same-file passes, and the import-last
  ordering are gone, along with the guard against inlining the class
  names of skipped templates, which needed the imported modules'
  transforms.
- Class names are IIFEs (#87, #98), so `moduleSideEffects: true` is gone.
- Vite's dev server keeps main's behavior (#104) and does not transform
  imported modules through transformRequest. There, the code of modules
  that no file holds comes from their `load` hooks.
- UNREADABLE_MODULE and UNPARSEABLE_MODULE are gone: a module whose code
  cannot be read exports nothing, and modules are parsed as the language
  Rolldown assigned them.
- Kept: raw TypeScript bindings, `meta.moduleType` parsing,
  prologue-safe imports, query-variant module identity, skipping
  external modules, and resolving each import once per transform.

Tests: main's suite, plus this branch's tests of the kept features, with
new cases for the paths that no fixture reached. The tests of the removed
behavior, and the cycle test that main already has, are dropped.
basic.input.ts is restored for main's inlineConst tests, and this
branch's barrel.ts is now barrel-index.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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